Skip to content

Commit a94e4f2

Browse files
committed
fix(cli): @ menu with apostrophes, preserve input on dialogs, keep leading whitespace
1 parent 06b8b77 commit a94e4f2

File tree

13 files changed

+809
-257
lines changed

13 files changed

+809
-257
lines changed

cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
"prebuild:agents": "bun run scripts/prebuild-agents.ts",
2020
"build:binary": "bun ./scripts/build-binary.ts codebuff $npm_package_version",
2121
"release": "bun run scripts/release.ts",
22-
"test": "bun test",
22+
"test": "NODE_ENV=production bun test",
2323
"test:tmux-poc": "bun run src/__tests__/tmux-poc.ts",
2424
"typecheck": "tsc --noEmit -p ."
2525
},
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import { describe, expect, test } from 'bun:test'
2+
3+
import { parseTerminalOutput, RunTerminalCommandComponent } from '../run-terminal-command'
4+
5+
import type { ToolBlock } from '../types'
6+
7+
// Helper to create a mock tool block
8+
const createToolBlock = (
9+
command: string,
10+
output?: string,
11+
): ToolBlock & { toolName: 'run_terminal_command' } => ({
12+
type: 'tool',
13+
toolName: 'run_terminal_command',
14+
toolCallId: 'test-tool-call-id',
15+
input: { command },
16+
output,
17+
})
18+
19+
// Helper to create JSON output in the format the component expects
20+
const createJsonOutput = (stdout: string, stderr = ''): string => {
21+
return JSON.stringify([
22+
{
23+
type: 'json',
24+
value: {
25+
command: 'test',
26+
stdout,
27+
stderr,
28+
exitCode: 0,
29+
},
30+
},
31+
])
32+
}
33+
34+
describe('RunTerminalCommandComponent', () => {
35+
describe('render', () => {
36+
test('returns content and collapsedPreview', () => {
37+
const toolBlock = createToolBlock('ls -la', createJsonOutput('file1\nfile2'))
38+
const mockTheme = {} as any
39+
const mockOptions = {
40+
availableWidth: 80,
41+
indentationOffset: 0,
42+
labelWidth: 10,
43+
}
44+
45+
const result = RunTerminalCommandComponent.render(toolBlock, mockTheme, mockOptions)
46+
47+
expect(result).toBeDefined()
48+
expect(result.content).toBeDefined()
49+
expect(result.collapsedPreview).toBe('$ ls -la')
50+
})
51+
52+
test('preserves leading whitespace in stdout (tree output)', () => {
53+
// Simulate tree command output with leading spaces for indentation
54+
const treeOutput = `├── src
55+
│ ├── index.ts
56+
│ └── utils
57+
│ └── helper.ts
58+
└── package.json`
59+
60+
const { output } = parseTerminalOutput(createJsonOutput(treeOutput))
61+
62+
expect(output).toBe(treeOutput)
63+
// Verify leading characters are preserved (├ has no leading space, but indented lines do)
64+
expect(output?.startsWith('├')).toBe(true)
65+
expect(output).toContain('│ ├')
66+
expect(output).toContain('│ └')
67+
})
68+
69+
test('preserves leading spaces in table-like output', () => {
70+
// Simulate output with leading spaces for alignment
71+
const tableOutput = ` Name Size Modified
72+
file1.txt 1.2KB 2024-01-15
73+
file2.txt 3.4MB 2024-01-16`
74+
75+
const { output } = parseTerminalOutput(createJsonOutput(tableOutput))
76+
77+
expect(output).toBe(tableOutput)
78+
// Verify leading spaces are preserved
79+
expect(output?.startsWith(' ')).toBe(true)
80+
})
81+
82+
test('preserves leading spaces in indented code output', () => {
83+
// Simulate indented output like grep with context
84+
const indentedOutput = ` function hello() {
85+
console.log("world")
86+
}`
87+
88+
const { output } = parseTerminalOutput(createJsonOutput(indentedOutput))
89+
90+
expect(output).toBe(indentedOutput)
91+
expect(output?.startsWith(' ')).toBe(true)
92+
})
93+
94+
test('removes trailing whitespace while preserving leading whitespace', () => {
95+
const outputWithTrailing = ' leading preserved\ntrailing removed \n\n'
96+
const expectedOutput = ' leading preserved\ntrailing removed'
97+
98+
const { output } = parseTerminalOutput(createJsonOutput(outputWithTrailing))
99+
100+
expect(output).toBe(expectedOutput)
101+
// Leading spaces preserved
102+
expect(output?.startsWith(' ')).toBe(true)
103+
// Trailing whitespace removed
104+
expect(output?.endsWith('removed')).toBe(true)
105+
})
106+
107+
test('handles raw string output (non-JSON) and preserves leading whitespace', () => {
108+
const rawOutput = ' indented raw output'
109+
const { output } = parseTerminalOutput(rawOutput)
110+
111+
expect(output).toBe(rawOutput)
112+
expect(output?.startsWith(' ')).toBe(true)
113+
})
114+
115+
test('handles combined stdout and stderr with leading whitespace', () => {
116+
const stdout = ' stdout with leading space\n'
117+
const stderr = ' stderr with leading space'
118+
119+
const { output } = parseTerminalOutput(
120+
JSON.stringify([
121+
{
122+
type: 'json',
123+
value: { stdout, stderr, exitCode: 0 },
124+
},
125+
]),
126+
)
127+
128+
expect(output).toContain(' stdout with leading space')
129+
expect(output).toContain(' stderr with leading space')
130+
})
131+
132+
test('handles output that is only whitespace', () => {
133+
const whitespaceOnly = ' '
134+
const { output } = parseTerminalOutput(createJsonOutput(whitespaceOnly))
135+
136+
// trimEnd() on whitespace-only string returns empty string, which becomes null
137+
expect(output).toBe(null)
138+
})
139+
140+
test('handles empty output', () => {
141+
const { output } = parseTerminalOutput(createJsonOutput(''))
142+
143+
expect(output).toBe(null)
144+
})
145+
})
146+
147+
describe('parseTerminalOutput', () => {
148+
test('handles error messages', () => {
149+
const errorPayload = JSON.stringify([
150+
{
151+
type: 'json',
152+
value: {
153+
command: 'test',
154+
errorMessage: 'Something went wrong',
155+
stdout: '',
156+
stderr: '',
157+
exitCode: 1,
158+
},
159+
},
160+
])
161+
162+
const { output, startingCwd } = parseTerminalOutput(errorPayload)
163+
164+
expect(output).toBe('Error: Something went wrong')
165+
expect(startingCwd).toBeUndefined()
166+
})
167+
168+
test('extracts startingCwd when present', () => {
169+
const payloadWithCwd = JSON.stringify([
170+
{
171+
type: 'json',
172+
value: {
173+
command: 'pwd',
174+
stdout: '/project\n',
175+
stderr: '',
176+
exitCode: 0,
177+
startingCwd: '/project',
178+
},
179+
},
180+
])
181+
182+
const { output, startingCwd } = parseTerminalOutput(payloadWithCwd)
183+
184+
expect(output).toBe('/project')
185+
expect(startingCwd).toBe('/project')
186+
})
187+
})
188+
})

cli/src/components/tools/run-terminal-command.tsx

Lines changed: 39 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,44 @@ import { TerminalCommandDisplay } from '../terminal-command-display'
33

44
import type { ToolRenderConfig } from './types'
55

6+
export interface ParsedTerminalOutput {
7+
output: string | null
8+
startingCwd?: string
9+
}
10+
11+
/**
12+
* Parse terminal command output from JSON or raw string format.
13+
* Exported for testing.
14+
*/
15+
export const parseTerminalOutput = (rawOutput: string | undefined): ParsedTerminalOutput => {
16+
if (!rawOutput) {
17+
return { output: null }
18+
}
19+
20+
try {
21+
const parsed = JSON.parse(rawOutput)
22+
// Handle array format [{ type: 'json', value: {...} }]
23+
const value = Array.isArray(parsed) ? parsed[0]?.value : parsed
24+
if (value) {
25+
const startingCwd = value.startingCwd
26+
// Handle error case
27+
if (value.errorMessage) {
28+
return { output: `Error: ${value.errorMessage}`, startingCwd }
29+
}
30+
// Combine stdout and stderr for display
31+
// Use trimEnd() to preserve leading spaces (used for UI elements like trees/tables)
32+
const stdout = value.stdout || ''
33+
const stderr = value.stderr || ''
34+
const output = (stdout + stderr).trimEnd() || null
35+
return { output, startingCwd }
36+
}
37+
return { output: null }
38+
} catch {
39+
// If not JSON, use raw output (preserve leading spaces)
40+
return { output: rawOutput.trimEnd() || null }
41+
}
42+
}
43+
644
/**
745
* UI component for run_terminal_command tool.
846
* Displays the command in bold next to the bullet point,
@@ -19,31 +57,7 @@ export const RunTerminalCommandComponent = defineToolComponent({
1957
: ''
2058

2159
// Extract output and startingCwd from tool result
22-
let output: string | null = null
23-
let startingCwd: string | undefined
24-
25-
if (toolBlock.output) {
26-
try {
27-
const parsed = JSON.parse(toolBlock.output)
28-
// Handle array format [{ type: 'json', value: {...} }]
29-
const value = Array.isArray(parsed) ? parsed[0]?.value : parsed
30-
if (value) {
31-
startingCwd = value.startingCwd
32-
// Handle error case
33-
if (value.errorMessage) {
34-
output = `Error: ${value.errorMessage}`
35-
} else {
36-
// Combine stdout and stderr for display
37-
const stdout = value.stdout || ''
38-
const stderr = value.stderr || ''
39-
output = (stdout + stderr).trim() || null
40-
}
41-
}
42-
} catch {
43-
// If not JSON, use raw output
44-
output = toolBlock.output.trim() || null
45-
}
46-
}
60+
const { output, startingCwd } = parseTerminalOutput(toolBlock.output)
4761

4862
// Custom content component using shared TerminalCommandDisplay
4963
const content = (

cli/src/components/top-banner.tsx

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,8 @@ const TOP_BANNER_REGISTRY: Record<NonNullable<TopBannerType>, BannerConfig> = {
4242
borderColorKey: 'warning',
4343
textColorKey: 'foreground',
4444
relatedInputMode: 'homeDir',
45-
content: (
46-
<>
47-
You are currently in your home directory.
48-
<br />
49-
Select a project folder to get started, or choose "Start here".
50-
</>
51-
),
45+
content:
46+
'You are currently in your home directory.\nSelect a project folder to get started, or choose "Start here".',
5247
},
5348
gitRoot: {
5449
borderColorKey: 'warning',

0 commit comments

Comments
 (0)