mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 11:41:04 +00:00
feat(cli): combine elapsed + timeout in shell time indicator (#3512)
Some checks are pending
Qwen Code CI / Lint (push) Waiting to run
Qwen Code CI / Test (push) Blocked by required conditions
Qwen Code CI / Test-1 (push) Blocked by required conditions
Qwen Code CI / Test-2 (push) Blocked by required conditions
Qwen Code CI / Test-3 (push) Blocked by required conditions
Qwen Code CI / Test-4 (push) Blocked by required conditions
Qwen Code CI / Test-5 (push) Blocked by required conditions
Qwen Code CI / Test-6 (push) Blocked by required conditions
Qwen Code CI / Test-7 (push) Blocked by required conditions
Qwen Code CI / Test-8 (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
Some checks are pending
Qwen Code CI / Lint (push) Waiting to run
Qwen Code CI / Test (push) Blocked by required conditions
Qwen Code CI / Test-1 (push) Blocked by required conditions
Qwen Code CI / Test-2 (push) Blocked by required conditions
Qwen Code CI / Test-3 (push) Blocked by required conditions
Qwen Code CI / Test-4 (push) Blocked by required conditions
Qwen Code CI / Test-5 (push) Blocked by required conditions
Qwen Code CI / Test-6 (push) Blocked by required conditions
Qwen Code CI / Test-7 (push) Blocked by required conditions
Qwen Code CI / Test-8 (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
* feat(cli): combine elapsed + timeout in shell time indicator Render shell tools that have an explicit timeout as `(elapsed · timeout N)` inline with the Running… status from t=0, instead of splitting the information across the right-aligned elapsed indicator and the ShellStatsBar row. - formatters: add a `hideTrailingZeros` option so whole seconds render as `5s` rather than `5.0s` while fractional values like `5.5s` stay intact - ToolElapsedTime: accept optional `timeoutMs`; when set, skip the 3s quiet threshold and render the combined `(elapsed · timeout N)` label - ToolMessage: extract `timeoutMs` from AnsiOutputDisplay and feed it to ToolElapsedTime - ShellStatsBar: drop its `timeoutMs` field (now inline); keeps `+N lines` and memory usage only - Unify both modes on `formatDuration` so hour-range output is consistent (`1h 2m 6s` across timeout and no-timeout paths) * feat(cli): thread shell timeoutMs through compact tool group display The combined `(elapsed · timeout N)` format introduced in the previous commit was only wired through the expanded ToolMessage path. Compact tool groups kept rendering ToolElapsedTime without timeoutMs, so shell tools displayed in compact mode silently dropped the timeout budget. - CompactToolGroupDisplay: add getShellTimeoutMs() to pull timeoutMs off the active tool's AnsiOutputDisplay result (same shape used by ToolMessage) and feed it to ToolElapsedTime - add CompactToolGroupDisplay.test.tsx covering the three paths: ansi display with timeoutMs, ansi display without timeoutMs, and non-ansi resultDisplay (string)
This commit is contained in:
parent
d40fe7cdba
commit
69da115dcf
8 changed files with 344 additions and 33 deletions
|
|
@ -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<AnsiOutputProps> = ({
|
|||
export interface ShellStatsBarProps {
|
||||
totalLines?: number;
|
||||
totalBytes?: number;
|
||||
timeoutMs?: number;
|
||||
displayHeight?: number;
|
||||
}
|
||||
|
||||
export const ShellStatsBar: React.FC<ShellStatsBarProps> = ({
|
||||
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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: () => <Text>•</Text>,
|
||||
STATUS_INDICATOR_WIDTH: 2,
|
||||
}));
|
||||
|
||||
const NOW = 1_700_000_000_000;
|
||||
|
||||
function shellTool(
|
||||
overrides: Partial<IndividualToolCallDisplay> = {},
|
||||
): IndividualToolCallDisplay {
|
||||
return {
|
||||
callId: 'c1',
|
||||
name: 'Shell',
|
||||
description: 'sleep 10',
|
||||
status: ToolCallStatus.Executing,
|
||||
executionStartTime: NOW,
|
||||
resultDisplay: undefined,
|
||||
confirmationDetails: undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('<CompactToolGroupDisplay />', () => {
|
||||
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(
|
||||
<CompactToolGroupDisplay toolCalls={[tool]} contentWidth={80} />,
|
||||
);
|
||||
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(
|
||||
<CompactToolGroupDisplay toolCalls={[tool]} contentWidth={80} />,
|
||||
);
|
||||
// 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(
|
||||
<CompactToolGroupDisplay toolCalls={[tool]} contentWidth={80} />,
|
||||
);
|
||||
vi.advanceTimersByTime(5_000);
|
||||
rerender(<CompactToolGroupDisplay toolCalls={[tool]} contentWidth={80} />);
|
||||
// No timeout in display → legacy 3s-threshold elapsed.
|
||||
expect(lastFrame()).toContain('5s');
|
||||
expect(lastFrame()).not.toContain('timeout');
|
||||
});
|
||||
});
|
||||
|
|
@ -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<
|
|||
<ToolElapsedTime
|
||||
status={overallStatus}
|
||||
executionStartTime={activeTool.executionStartTime}
|
||||
timeoutMs={getShellTimeoutMs(activeTool)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
|
|
|
|||
|
|
@ -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<ToolMessageProps> = ({
|
|||
}
|
||||
}, [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<ToolMessageProps> = ({
|
|||
<ToolElapsedTime
|
||||
status={status}
|
||||
executionStartTime={executionStartTime}
|
||||
timeoutMs={shellTimeoutMs}
|
||||
/>
|
||||
{emphasis === 'high' && <TrailingIndicator />}
|
||||
</Box>
|
||||
|
|
|
|||
141
packages/cli/src/ui/components/shared/ToolElapsedTime.test.tsx
Normal file
141
packages/cli/src/ui/components/shared/ToolElapsedTime.test.tsx
Normal file
|
|
@ -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('<ToolElapsedTime />', () => {
|
||||
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(
|
||||
<ToolElapsedTime
|
||||
status={ToolCallStatus.Success}
|
||||
executionStartTime={NOW}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toBe('');
|
||||
});
|
||||
|
||||
it('stays quiet for the first 3s when no timeout is set', () => {
|
||||
const { lastFrame, rerender } = render(
|
||||
<ToolElapsedTime
|
||||
status={ToolCallStatus.Executing}
|
||||
executionStartTime={NOW}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toBe('');
|
||||
|
||||
vi.advanceTimersByTime(2_000);
|
||||
rerender(
|
||||
<ToolElapsedTime
|
||||
status={ToolCallStatus.Executing}
|
||||
executionStartTime={NOW}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toBe('');
|
||||
});
|
||||
|
||||
it('shows elapsed seconds past the 3s threshold (no timeout)', () => {
|
||||
const { lastFrame, rerender } = render(
|
||||
<ToolElapsedTime
|
||||
status={ToolCallStatus.Executing}
|
||||
executionStartTime={NOW}
|
||||
/>,
|
||||
);
|
||||
vi.advanceTimersByTime(5_000);
|
||||
rerender(
|
||||
<ToolElapsedTime
|
||||
status={ToolCallStatus.Executing}
|
||||
executionStartTime={NOW}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toContain('5s');
|
||||
});
|
||||
|
||||
it('renders combined (elapsed · timeout N) from t=0 when timeout is set', () => {
|
||||
const { lastFrame } = render(
|
||||
<ToolElapsedTime
|
||||
status={ToolCallStatus.Executing}
|
||||
executionStartTime={NOW}
|
||||
timeoutMs={30_000}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toContain('(0s · timeout 30s)');
|
||||
});
|
||||
|
||||
it('keeps fractional timeout precision', () => {
|
||||
const { lastFrame } = render(
|
||||
<ToolElapsedTime
|
||||
status={ToolCallStatus.Executing}
|
||||
executionStartTime={NOW}
|
||||
timeoutMs={5_500}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toContain('(0s · timeout 5.5s)');
|
||||
});
|
||||
|
||||
it('advances elapsed inside the combined format', () => {
|
||||
const { lastFrame, rerender } = render(
|
||||
<ToolElapsedTime
|
||||
status={ToolCallStatus.Executing}
|
||||
executionStartTime={NOW}
|
||||
timeoutMs={30_000}
|
||||
/>,
|
||||
);
|
||||
vi.advanceTimersByTime(7_000);
|
||||
rerender(
|
||||
<ToolElapsedTime
|
||||
status={ToolCallStatus.Executing}
|
||||
executionStartTime={NOW}
|
||||
timeoutMs={30_000}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toContain('(7s · timeout 30s)');
|
||||
});
|
||||
|
||||
it('formats combined output once elapsed crosses into the minute range', () => {
|
||||
const { lastFrame, rerender } = render(
|
||||
<ToolElapsedTime
|
||||
status={ToolCallStatus.Executing}
|
||||
executionStartTime={NOW}
|
||||
timeoutMs={5 * 60 * 1000}
|
||||
/>,
|
||||
);
|
||||
vi.advanceTimersByTime(65_000);
|
||||
rerender(
|
||||
<ToolElapsedTime
|
||||
status={ToolCallStatus.Executing}
|
||||
executionStartTime={NOW}
|
||||
timeoutMs={5 * 60 * 1000}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toContain('(1m 5s · timeout 5m)');
|
||||
});
|
||||
|
||||
it('ignores non-positive timeouts (falls back to elapsed-only mode)', () => {
|
||||
const { lastFrame } = render(
|
||||
<ToolElapsedTime
|
||||
status={ToolCallStatus.Executing}
|
||||
executionStartTime={NOW}
|
||||
timeoutMs={0}
|
||||
/>,
|
||||
);
|
||||
// With no effective timeout, sub-3s = quiet.
|
||||
expect(lastFrame()).toBe('');
|
||||
});
|
||||
});
|
||||
|
|
@ -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<ToolElapsedTimeProps> = ({
|
||||
status,
|
||||
executionStartTime,
|
||||
timeoutMs,
|
||||
}) => {
|
||||
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||
|
||||
|
|
@ -57,11 +52,21 @@ export const ToolElapsedTime: React.FC<ToolElapsedTimeProps> = ({
|
|||
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 (
|
||||
<Box flexShrink={0} marginLeft={1}>
|
||||
<Text color={theme.text.secondary}>{formatElapsed(elapsedSeconds)}</Text>
|
||||
<Text color={theme.text.secondary}>{label}</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue