diff --git a/packages/cli/src/ui/components/AnsiOutput.tsx b/packages/cli/src/ui/components/AnsiOutput.tsx index c13f4f606..1821aacee 100644 --- a/packages/cli/src/ui/components/AnsiOutput.tsx +++ b/packages/cli/src/ui/components/AnsiOutput.tsx @@ -11,7 +11,7 @@ import type { AnsiOutput, AnsiToken, } from '@qwen-code/qwen-code-core'; -import { formatDuration, formatMemoryUsage } from '../utils/formatters.js'; +import { formatMemoryUsage } from '../utils/formatters.js'; import { theme } from '../semantic-colors.js'; import { MaxSizedBox } from './shared/MaxSizedBox.js'; @@ -62,23 +62,18 @@ export const AnsiOutputText: React.FC = ({ export interface ShellStatsBarProps { totalLines?: number; totalBytes?: number; - timeoutMs?: number; displayHeight?: number; } export const ShellStatsBar: React.FC = ({ totalLines, totalBytes, - timeoutMs, displayHeight = DEFAULT_HEIGHT, }) => { const parts: string[] = []; if (totalLines && totalLines > displayHeight) { parts.push(`+${totalLines - displayHeight} lines`); } - if (timeoutMs) { - parts.push(`timeout ${formatDuration(timeoutMs)}`); - } if (totalBytes && totalBytes > 0) { parts.push(formatMemoryUsage(totalBytes)); } diff --git a/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.test.tsx b/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.test.tsx new file mode 100644 index 000000000..e0180c3d1 --- /dev/null +++ b/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.test.tsx @@ -0,0 +1,93 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render } from 'ink-testing-library'; +import { Text } from 'ink'; +import { CompactToolGroupDisplay } from './CompactToolGroupDisplay.js'; +import { ToolCallStatus } from '../../types.js'; +import type { IndividualToolCallDisplay } from '../../types.js'; + +// ToolStatusIndicator pulls in GeminiRespondingSpinner which requires +// StreamingContext; stub it out so we can test the elapsed/timeout +// plumbing in isolation. +vi.mock('../shared/ToolStatusIndicator.js', () => ({ + ToolStatusIndicator: () => , + STATUS_INDICATOR_WIDTH: 2, +})); + +const NOW = 1_700_000_000_000; + +function shellTool( + overrides: Partial = {}, +): IndividualToolCallDisplay { + return { + callId: 'c1', + name: 'Shell', + description: 'sleep 10', + status: ToolCallStatus.Executing, + executionStartTime: NOW, + resultDisplay: undefined, + confirmationDetails: undefined, + ...overrides, + }; +} + +describe('', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('surfaces shell timeoutMs inline via ToolElapsedTime', () => { + const tool = shellTool({ + resultDisplay: { + ansiOutput: [], + totalLines: 0, + totalBytes: 0, + timeoutMs: 30_000, + }, + }); + const { lastFrame } = render( + , + ); + expect(lastFrame()).toContain('(0s · timeout 30s)'); + }); + + it('falls back to quiet elapsed-only when no timeout is surfaced', () => { + const tool = shellTool({ + resultDisplay: { + ansiOutput: [], + totalLines: 0, + totalBytes: 0, + }, + }); + const { lastFrame } = render( + , + ); + // Sub-3s without a timeout budget → indicator is quiet. + expect(lastFrame()).not.toContain('timeout'); + expect(lastFrame()).not.toContain('0s'); + }); + + it('ignores non-ansi resultDisplay shapes', () => { + const tool = shellTool({ + resultDisplay: 'plain text output', + }); + const { lastFrame, rerender } = render( + , + ); + vi.advanceTimersByTime(5_000); + rerender(); + // No timeout in display → legacy 3s-threshold elapsed. + expect(lastFrame()).toContain('5s'); + expect(lastFrame()).not.toContain('timeout'); + }); +}); diff --git a/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.tsx b/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.tsx index 2514f8bec..063a0c283 100644 --- a/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.tsx +++ b/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.tsx @@ -8,6 +8,7 @@ import type React from 'react'; import { Box, Text } from 'ink'; import type { IndividualToolCallDisplay } from '../../types.js'; import { ToolCallStatus } from '../../types.js'; +import type { AnsiOutputDisplay } from '@qwen-code/qwen-code-core'; import { SHELL_COMMAND_NAME, SHELL_NAME } from '../../constants.js'; import { theme } from '../../semantic-colors.js'; import { t } from '../../../i18n/index.js'; @@ -47,6 +48,24 @@ function getActiveTool( ); } +// Pull the configured shell timeout off an AnsiOutputDisplay result so +// ToolElapsedTime can surface it inline (matches the expanded +// ToolMessage path). Non-ansi resultDisplay → undefined → legacy +// quiet-then-elapsed behavior. +function getShellTimeoutMs( + tool: IndividualToolCallDisplay, +): number | undefined { + const display = tool.resultDisplay; + if ( + typeof display === 'object' && + display !== null && + 'ansiOutput' in display + ) { + return (display as AnsiOutputDisplay).timeoutMs; + } + return undefined; +} + export const CompactToolGroupDisplay: React.FC< CompactToolGroupDisplayProps > = ({ toolCalls, contentWidth }) => { @@ -107,6 +126,7 @@ export const CompactToolGroupDisplay: React.FC< diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index 0068c0c35..64f616cfc 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -147,7 +147,6 @@ const useResultDisplayRenderer = ( stats: { totalLines: display.totalLines, totalBytes: display.totalBytes, - timeoutMs: display.timeoutMs, }, }; } @@ -314,6 +313,21 @@ export const ToolMessage: React.FC = ({ } }, [resultDisplay]); + // Shell tools surface their configured timeout via AnsiOutputDisplay as + // soon as streaming starts. Feed it into ToolElapsedTime so the budget is + // shown inline (`(elapsed · timeout N)`) instead of in a separate stats + // row. + const shellTimeoutMs = React.useMemo(() => { + if ( + typeof resultDisplay === 'object' && + resultDisplay !== null && + 'ansiOutput' in resultDisplay + ) { + return (resultDisplay as AnsiOutputDisplay).timeoutMs; + } + return undefined; + }, [resultDisplay]); + React.useEffect(() => { if (!lastUpdateTime) { return; @@ -410,6 +424,7 @@ export const ToolMessage: React.FC = ({ {emphasis === 'high' && } diff --git a/packages/cli/src/ui/components/shared/ToolElapsedTime.test.tsx b/packages/cli/src/ui/components/shared/ToolElapsedTime.test.tsx new file mode 100644 index 000000000..e4a157454 --- /dev/null +++ b/packages/cli/src/ui/components/shared/ToolElapsedTime.test.tsx @@ -0,0 +1,141 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render } from 'ink-testing-library'; +import { ToolCallStatus } from '../../types.js'; +import { ToolElapsedTime } from './ToolElapsedTime.js'; + +describe('', () => { + const NOW = 1_700_000_000_000; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('renders nothing for non-executing status', () => { + const { lastFrame } = render( + , + ); + expect(lastFrame()).toBe(''); + }); + + it('stays quiet for the first 3s when no timeout is set', () => { + const { lastFrame, rerender } = render( + , + ); + expect(lastFrame()).toBe(''); + + vi.advanceTimersByTime(2_000); + rerender( + , + ); + expect(lastFrame()).toBe(''); + }); + + it('shows elapsed seconds past the 3s threshold (no timeout)', () => { + const { lastFrame, rerender } = render( + , + ); + vi.advanceTimersByTime(5_000); + rerender( + , + ); + expect(lastFrame()).toContain('5s'); + }); + + it('renders combined (elapsed · timeout N) from t=0 when timeout is set', () => { + const { lastFrame } = render( + , + ); + expect(lastFrame()).toContain('(0s · timeout 30s)'); + }); + + it('keeps fractional timeout precision', () => { + const { lastFrame } = render( + , + ); + expect(lastFrame()).toContain('(0s · timeout 5.5s)'); + }); + + it('advances elapsed inside the combined format', () => { + const { lastFrame, rerender } = render( + , + ); + vi.advanceTimersByTime(7_000); + rerender( + , + ); + expect(lastFrame()).toContain('(7s · timeout 30s)'); + }); + + it('formats combined output once elapsed crosses into the minute range', () => { + const { lastFrame, rerender } = render( + , + ); + vi.advanceTimersByTime(65_000); + rerender( + , + ); + expect(lastFrame()).toContain('(1m 5s · timeout 5m)'); + }); + + it('ignores non-positive timeouts (falls back to elapsed-only mode)', () => { + const { lastFrame } = render( + , + ); + // With no effective timeout, sub-3s = quiet. + expect(lastFrame()).toBe(''); + }); +}); diff --git a/packages/cli/src/ui/components/shared/ToolElapsedTime.tsx b/packages/cli/src/ui/components/shared/ToolElapsedTime.tsx index d4740d89b..5c0e09875 100644 --- a/packages/cli/src/ui/components/shared/ToolElapsedTime.tsx +++ b/packages/cli/src/ui/components/shared/ToolElapsedTime.tsx @@ -9,39 +9,34 @@ import { useState, useEffect } from 'react'; import { Box, Text } from 'ink'; import { ToolCallStatus } from '../../types.js'; import { theme } from '../../semantic-colors.js'; - -/** - * Formats elapsed seconds as compact text. - * Under 60s: "3s", "45s". - * 60s+: "1m", "1m 30s", "2h 15m". - */ -export function formatElapsed(seconds: number): string { - if (seconds < 60) return `${seconds}s`; - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - if (minutes < 60) { - return remainingSeconds > 0 - ? `${minutes}m ${remainingSeconds}s` - : `${minutes}m`; - } - const hours = Math.floor(minutes / 60); - const remainingMinutes = minutes % 60; - return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`; -} +import { formatDuration } from '../../utils/formatters.js'; interface ToolElapsedTimeProps { status: ToolCallStatus; executionStartTime?: number; + /** + * When provided, the elapsed indicator becomes a combined budget display: + * `(elapsed · timeout N)` visible from t=0 so the timeout is always on + * screen. When absent, the indicator keeps the 3-second quiet threshold + * and renders just the elapsed time. + */ + timeoutMs?: number; } /** - * Right-aligned elapsed-time indicator for an executing tool. Renders - * nothing until the tool has been running for at least 3 seconds, so quick - * tools stay visually quiet. + * Right-aligned elapsed-time indicator for an executing tool. + * + * Two modes: + * - no `timeoutMs`: suppressed for the first 3 seconds so fast tools stay + * visually quiet. + * - with `timeoutMs`: rendered as `(elapsed · timeout N)` from t=0 so the + * user can see both how long the tool has been running and how much + * budget remains. */ export const ToolElapsedTime: React.FC = ({ status, executionStartTime, + timeoutMs, }) => { const [elapsedSeconds, setElapsedSeconds] = useState(0); @@ -57,11 +52,21 @@ export const ToolElapsedTime: React.FC = ({ return () => clearInterval(interval); }, [status, executionStartTime]); - if (status !== ToolCallStatus.Executing || elapsedSeconds < 3) return null; + if (status !== ToolCallStatus.Executing) return null; + + const hasTimeout = timeoutMs != null && timeoutMs > 0; + if (!hasTimeout && elapsedSeconds < 3) return null; + + const elapsedStr = formatDuration(elapsedSeconds * 1000, { + hideTrailingZeros: true, + }); + const label = hasTimeout + ? `(${elapsedStr} · timeout ${formatDuration(timeoutMs, { hideTrailingZeros: true })})` + : elapsedStr; return ( - {formatElapsed(elapsedSeconds)} + {label} ); }; diff --git a/packages/cli/src/ui/utils/formatters.test.ts b/packages/cli/src/ui/utils/formatters.test.ts index 09173e10e..7ef73fd94 100644 --- a/packages/cli/src/ui/utils/formatters.test.ts +++ b/packages/cli/src/ui/utils/formatters.test.ts @@ -154,6 +154,32 @@ describe('formatters', () => { it('should handle negative durations', () => { expect(formatDuration(-100)).toBe('0s'); }); + + describe('with hideTrailingZeros', () => { + it('drops .0 suffix for whole seconds under a minute', () => { + expect(formatDuration(5000, { hideTrailingZeros: true })).toBe('5s'); + expect(formatDuration(10000, { hideTrailingZeros: true })).toBe('10s'); + expect(formatDuration(30000, { hideTrailingZeros: true })).toBe('30s'); + }); + + it('keeps fractional seconds under a minute', () => { + expect(formatDuration(5500, { hideTrailingZeros: true })).toBe('5.5s'); + expect(formatDuration(12345, { hideTrailingZeros: true })).toBe( + '12.3s', + ); + }); + + it('does not affect ms-range output', () => { + expect(formatDuration(500, { hideTrailingZeros: true })).toBe('500ms'); + }); + + it('does not affect multi-unit output', () => { + expect(formatDuration(123000, { hideTrailingZeros: true })).toBe( + '2m 3s', + ); + expect(formatDuration(3600000, { hideTrailingZeros: true })).toBe('1h'); + }); + }); }); describe('formatTokenCount', () => { diff --git a/packages/cli/src/ui/utils/formatters.ts b/packages/cli/src/ui/utils/formatters.ts index 38afaaa30..36ed878d4 100644 --- a/packages/cli/src/ui/utils/formatters.ts +++ b/packages/cli/src/ui/utils/formatters.ts @@ -65,7 +65,19 @@ export const formatTokenCount = (count: number): string => { return `${Math.floor(count / 1000)}k`; }; -export const formatDuration = (milliseconds: number): string => { +export interface FormatDurationOptions { + /** + * When true, drops a trailing `.0` in the sub-minute range so that whole + * seconds render as `5s` rather than `5.0s`. Non-integer values keep their + * decimal (e.g. `5.5s`). Matches Claude Code's `ShellTimeDisplay` style. + */ + hideTrailingZeros?: boolean; +} + +export const formatDuration = ( + milliseconds: number, + options?: FormatDurationOptions, +): string => { if (milliseconds <= 0) { return '0s'; } @@ -77,7 +89,11 @@ export const formatDuration = (milliseconds: number): string => { const totalSeconds = milliseconds / 1000; if (totalSeconds < 60) { - return `${totalSeconds.toFixed(1)}s`; + const formatted = totalSeconds.toFixed(1); + if (options?.hideTrailingZeros && formatted.endsWith('.0')) { + return `${formatted.slice(0, -2)}s`; + } + return `${formatted}s`; } const hours = Math.floor(totalSeconds / 3600);