Skip to content

Commit eed92fb

Browse files
committed
feat(cli): add timeout display to terminal commands
- Show timeout value next to command (e.g., "60s timeout", "2m timeout", "1h timeout") - Hide default 30s timeout to reduce visual noise - Handle edge cases: negative values, NaN, Infinity, floating points - Extract formatTimeout to shared utility for reusability - Add comprehensive unit tests for formatting logic
1 parent 030d436 commit eed92fb

File tree

5 files changed

+171
-7
lines changed

5 files changed

+171
-7
lines changed

cli/src/components/terminal-command-display.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useState } from 'react'
44
import { Button } from './button'
55
import { useTerminalDimensions } from '../hooks/use-terminal-dimensions'
66
import { useTheme } from '../hooks/use-theme'
7+
import { formatTimeout } from '../utils/format-timeout'
78
import { getLastNVisualLines } from '../utils/text-layout'
89

910
interface TerminalCommandDisplayProps {
@@ -17,19 +18,21 @@ interface TerminalCommandDisplayProps {
1718
isRunning?: boolean
1819
/** Working directory where the command was run */
1920
cwd?: string
21+
/** Timeout in seconds for the command */
22+
timeoutSeconds?: number
2023
}
2124

2225
/**
2326
* Shared component for displaying terminal command with output.
2427
* Used in both the ghost message (pending bash) and message history.
2528
*/
26-
2729
export const TerminalCommandDisplay = ({
2830
command,
2931
output,
3032
expandable = true,
3133
maxVisibleLines,
3234
isRunning = false,
35+
timeoutSeconds,
3336
}: TerminalCommandDisplayProps) => {
3437
const theme = useTheme()
3538
const { contentMaxWidth } = useTerminalDimensions()
@@ -40,13 +43,25 @@ export const TerminalCommandDisplay = ({
4043
const defaultMaxLines = expandable ? 5 : 10
4144
const maxLines = maxVisibleLines ?? defaultMaxLines
4245

46+
// Format timeout display - show when provided and not the default (30s)
47+
const DEFAULT_TIMEOUT_SECONDS = 30
48+
const timeoutLabel =
49+
timeoutSeconds !== undefined && timeoutSeconds !== DEFAULT_TIMEOUT_SECONDS
50+
? formatTimeout(timeoutSeconds)
51+
: null
52+
4353
// Command header - shared between output and no-output cases
4454
const commandHeader = (
4555
<text style={{ wrapMode: 'word' }}>
4656
<span fg={theme.success}>$ </span>
4757
<span fg={theme.foreground} attributes={TextAttributes.BOLD}>
4858
{command}
4959
</span>
60+
{timeoutLabel && (
61+
<span fg={theme.muted} attributes={TextAttributes.DIM}>
62+
{' '}({timeoutLabel})
63+
</span>
64+
)}
5065
</text>
5166
)
5267

cli/src/components/tools/__tests__/run-terminal-command.test.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ import type { ToolBlock } from '../types'
88
const createToolBlock = (
99
command: string,
1010
output?: string,
11+
timeoutSeconds?: number,
1112
): ToolBlock & { toolName: 'run_terminal_command' } => ({
1213
type: 'tool',
1314
toolName: 'run_terminal_command',
1415
toolCallId: 'test-tool-call-id',
15-
input: { command },
16+
input: { command, ...(timeoutSeconds !== undefined && { timeout_seconds: timeoutSeconds }) },
1617
output,
1718
})
1819

@@ -144,6 +145,39 @@ describe('RunTerminalCommandComponent', () => {
144145
})
145146
})
146147

148+
describe('timeout extraction', () => {
149+
const mockTheme = {} as any
150+
const mockOptions = {
151+
availableWidth: 80,
152+
indentationOffset: 0,
153+
labelWidth: 10,
154+
}
155+
156+
test('passes undefined timeoutSeconds when timeout_seconds not provided', () => {
157+
const toolBlock = createToolBlock('ls -la', createJsonOutput('output'))
158+
159+
const result = RunTerminalCommandComponent.render(toolBlock, mockTheme, mockOptions)
160+
161+
expect((result.content as any).props.timeoutSeconds).toBeUndefined()
162+
})
163+
164+
test('passes timeoutSeconds for positive timeout', () => {
165+
const toolBlock = createToolBlock('npm test', createJsonOutput('tests passed'), 60)
166+
167+
const result = RunTerminalCommandComponent.render(toolBlock, mockTheme, mockOptions)
168+
169+
expect((result.content as any).props.timeoutSeconds).toBe(60)
170+
})
171+
172+
test('passes timeoutSeconds for no timeout (-1)', () => {
173+
const toolBlock = createToolBlock('long-running-task', createJsonOutput('done'), -1)
174+
175+
const result = RunTerminalCommandComponent.render(toolBlock, mockTheme, mockOptions)
176+
177+
expect((result.content as any).props.timeoutSeconds).toBe(-1)
178+
})
179+
})
180+
147181
describe('parseTerminalOutput', () => {
148182
test('handles error messages', () => {
149183
const errorPayload = JSON.stringify([

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,10 @@ export const RunTerminalCommandComponent = defineToolComponent({
5050
toolName: 'run_terminal_command',
5151

5252
render(toolBlock): ToolRenderConfig {
53-
// Extract command from input
54-
const command =
55-
toolBlock.input && typeof (toolBlock.input as any).command === 'string'
56-
? (toolBlock.input as any).command.trim()
57-
: ''
53+
// Extract command and timeout from input
54+
const input = toolBlock.input as { command?: string; timeout_seconds?: number } | undefined
55+
const command = typeof input?.command === 'string' ? input.command.trim() : ''
56+
const timeoutSeconds = typeof input?.timeout_seconds === 'number' ? input.timeout_seconds : undefined
5857

5958
// Extract output and startingCwd from tool result
6059
const { output, startingCwd } = parseTerminalOutput(toolBlock.output)
@@ -67,6 +66,7 @@ export const RunTerminalCommandComponent = defineToolComponent({
6766
expandable={true}
6867
maxVisibleLines={5}
6968
cwd={startingCwd}
69+
timeoutSeconds={timeoutSeconds}
7070
/>
7171
)
7272

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { describe, expect, test } from 'bun:test'
2+
3+
import { formatTimeout } from '../format-timeout'
4+
5+
describe('formatTimeout', () => {
6+
describe('normal values', () => {
7+
test('returns seconds for values less than 60', () => {
8+
expect(formatTimeout(10)).toBe('10s timeout')
9+
expect(formatTimeout(30)).toBe('30s timeout')
10+
expect(formatTimeout(45)).toBe('45s timeout')
11+
})
12+
13+
test('returns minutes for values evenly divisible by 60', () => {
14+
expect(formatTimeout(60)).toBe('1m timeout')
15+
expect(formatTimeout(120)).toBe('2m timeout')
16+
expect(formatTimeout(300)).toBe('5m timeout')
17+
})
18+
19+
test('returns hours for values evenly divisible by 3600', () => {
20+
expect(formatTimeout(3600)).toBe('1h timeout')
21+
expect(formatTimeout(7200)).toBe('2h timeout')
22+
expect(formatTimeout(10800)).toBe('3h timeout')
23+
})
24+
25+
test('returns minutes for large values divisible by 60 but not 3600', () => {
26+
expect(formatTimeout(5400)).toBe('90m timeout')
27+
})
28+
29+
test('returns seconds for large values not evenly divisible by 60', () => {
30+
expect(formatTimeout(3700)).toBe('3700s timeout')
31+
})
32+
33+
test('returns seconds for values >= 60 not evenly divisible by 60', () => {
34+
expect(formatTimeout(90)).toBe('90s timeout')
35+
expect(formatTimeout(150)).toBe('150s timeout')
36+
})
37+
38+
test('returns "0s timeout" for 0', () => {
39+
expect(formatTimeout(0)).toBe('0s timeout')
40+
})
41+
})
42+
43+
describe('negative values', () => {
44+
test('returns "no timeout" for -1', () => {
45+
expect(formatTimeout(-1)).toBe('no timeout')
46+
})
47+
48+
test('returns "no timeout" for other negative values', () => {
49+
expect(formatTimeout(-5)).toBe('no timeout')
50+
expect(formatTimeout(-100)).toBe('no timeout')
51+
expect(formatTimeout(-0.5)).toBe('no timeout')
52+
})
53+
})
54+
55+
describe('non-finite values', () => {
56+
test('returns "no timeout" for NaN', () => {
57+
expect(formatTimeout(NaN)).toBe('no timeout')
58+
})
59+
60+
test('returns "no timeout" for Infinity', () => {
61+
expect(formatTimeout(Infinity)).toBe('no timeout')
62+
})
63+
64+
test('returns "no timeout" for -Infinity', () => {
65+
expect(formatTimeout(-Infinity)).toBe('no timeout')
66+
})
67+
})
68+
69+
describe('floating point values', () => {
70+
test('rounds floating point values to nearest integer', () => {
71+
expect(formatTimeout(30.4)).toBe('30s timeout')
72+
expect(formatTimeout(30.5)).toBe('31s timeout')
73+
expect(formatTimeout(30.9)).toBe('31s timeout')
74+
})
75+
76+
test('rounds floating point values for minute display', () => {
77+
expect(formatTimeout(59.5)).toBe('1m timeout')
78+
expect(formatTimeout(60.4)).toBe('1m timeout')
79+
expect(formatTimeout(119.6)).toBe('2m timeout')
80+
})
81+
82+
test('handles floating point values that round to non-minute values', () => {
83+
expect(formatTimeout(60.6)).toBe('61s timeout')
84+
expect(formatTimeout(89.5)).toBe('90s timeout')
85+
})
86+
})
87+
})

cli/src/utils/format-timeout.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* Formats a timeout value for display.
3+
* - Returns "no timeout" for non-finite values (NaN, Infinity, -Infinity)
4+
* - Returns "no timeout" for negative values (including -1)
5+
* - Returns hours (e.g., "1h timeout") for values >= 3600 that are evenly divisible by 3600
6+
* - Returns minutes (e.g., "2m timeout") for values >= 60 that are evenly divisible by 60
7+
* - Returns seconds (e.g., "90s timeout") otherwise
8+
* - Rounds floating point values to nearest integer
9+
*/
10+
export function formatTimeout(timeoutSeconds: number): string {
11+
// Handle NaN, Infinity, -Infinity
12+
if (!Number.isFinite(timeoutSeconds)) {
13+
return 'no timeout'
14+
}
15+
// Handle all negative values (including -1)
16+
if (timeoutSeconds < 0) {
17+
return 'no timeout'
18+
}
19+
// Round floating point values
20+
const rounded = Math.round(timeoutSeconds)
21+
if (rounded >= 3600 && rounded % 3600 === 0) {
22+
return `${rounded / 3600}h timeout`
23+
}
24+
if (rounded >= 60 && rounded % 60 === 0) {
25+
return `${rounded / 60}m timeout`
26+
}
27+
return `${rounded}s timeout`
28+
}

0 commit comments

Comments
 (0)