diff --git a/.changeset/agent-task-live-progress.md b/.changeset/agent-task-live-progress.md new file mode 100644 index 00000000000..6cccf2910be --- /dev/null +++ b/.changeset/agent-task-live-progress.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Show live progress for subagents and background tasks: the Agent tool card lists each sub-tool the subagent runs, background tasks show a running spinner, elapsed time, and current activity, the /tasks list shows live elapsed time, and the WaitFor card animates while waiting. diff --git a/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts b/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts index 1b33a5bf3a6..52e1b3782a7 100644 --- a/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts +++ b/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts @@ -25,6 +25,7 @@ import { import type { BackgroundTaskInfo, BackgroundTaskStatus } from '@moonshot-ai/kimi-code-sdk'; import { SELECT_POINTER } from '@/tui/constant/symbols'; +import { BRAILLE_SPINNER_FRAMES, BRAILLE_SPINNER_INTERVAL_MS } from '#/tui/constant/rendering'; import { currentTheme } from '#/tui/theme'; import { printableChar } from '@/tui/utils/printable-key'; import { sanitizeShellOutput } from '#/tui/utils/shell-output'; @@ -109,6 +110,21 @@ function formatRelativeTime(ts: number | null | undefined): string { return `${String(days)}d ago`; } +/** + * `⠋ running mm:ss` for a running row. The frame samples the wall clock, so + * the controller's 1 Hz poll re-renders advance the spinner without any state. + */ +function runningStatusText(startedAt: number, now: number): string { + const frameIndex = + Math.floor(now / BRAILLE_SPINNER_INTERVAL_MS) % BRAILLE_SPINNER_FRAMES.length; + const frame = BRAILLE_SPINNER_FRAMES[frameIndex] ?? ''; + const elapsedSeconds = Math.floor(Math.max(0, now - startedAt) / 1000); + const minutes = Math.floor(elapsedSeconds / 60); + const seconds = elapsedSeconds % 60; + const clock = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; + return `${frame} ${STATUS_LABEL.running} ${clock}`; +} + function singleLine(text: string): string { return text.replaceAll(/\s+/g, ' ').trim(); } @@ -483,7 +499,10 @@ export class TasksBrowserApp extends Container implements Focusable { : currentTheme.fg(idColor, task.taskId); const idPad = ' '.repeat(Math.max(0, 17 - task.taskId.length)); - const status = STATUS_LABEL[task.status]; + const status = + task.status === 'running' + ? runningStatusText(task.startedAt, Date.now()) + : STATUS_LABEL[task.status]; const statusBadge = currentTheme.fg(statusColor(task.status), status); const prefix = `${pointerStyled}${idText}${idPad} ${statusBadge}`; diff --git a/apps/kimi-code/src/tui/components/messages/background-agent-status.ts b/apps/kimi-code/src/tui/components/messages/background-agent-status.ts index 9c1a3d815b3..dbfabe60a80 100644 --- a/apps/kimi-code/src/tui/components/messages/background-agent-status.ts +++ b/apps/kimi-code/src/tui/components/messages/background-agent-status.ts @@ -1,41 +1,176 @@ import { Text, truncateToWidth, type Component } from '@moonshot-ai/pi-tui'; -import { MESSAGE_INDENT } from '#/tui/constant/rendering'; +import { BRAILLE_SPINNER_FRAMES, MESSAGE_INDENT } from '#/tui/constant/rendering'; import { FAILURE_MARK, STATUS_BULLET } from '#/tui/constant/symbols'; +import type { + SubagentActivityRecord, + SubagentActivityStore, +} from '#/tui/controllers/subagent-activity-store'; import { currentTheme } from '#/tui/theme'; import type { ColorPalette } from '#/tui/theme/colors'; import type { BackgroundAgentStatusData } from '#/tui/types'; +const LIVE_REFRESH_INTERVAL_MS = 1000; +const LIVE_ACTIVITY_MAX_CHARS = 160; +const HEADLINE_STARTED_SUFFIX = ' started in background'; + +interface LiveStatusContent { + readonly phase: 'running' | 'completed' | 'failed'; + readonly headline: string; + readonly detail?: string; + readonly activity?: string; +} + +function formatElapsed(seconds: number): string { + if (seconds < 60) return `${String(seconds)}s`; + const minutes = Math.floor(seconds / 60); + const remainder = seconds % 60; + return `${String(minutes)}m ${String(remainder)}s`; +} + +/** Build the one-line activity hint: the latest tool call or the latest + * assistant text, plus the step count. */ +function activitySummary(record: SubagentActivityRecord): string | undefined { + const lastStep = record.steps.at(-1); + const lastToolCall = lastStep?.toolCalls.at(-1); + const parts: string[] = []; + if (lastToolCall !== undefined && lastToolCall.name.length > 0) { + parts.push(`${lastToolCall.status === 'running' ? 'Using' : 'Used'} ${lastToolCall.name}`); + } else { + const text = lastStep?.textTail.replaceAll(/\s+/g, ' ').trim(); + if (text !== undefined && text.length > 0) { + parts.push( + text.length <= LIVE_ACTIVITY_MAX_CHARS + ? text + : `…${text.slice(text.length - LIVE_ACTIVITY_MAX_CHARS)}`, + ); + } + } + if (record.totalSteps > 0) { + parts.push(`${String(record.totalSteps)} step${record.totalSteps === 1 ? '' : 's'}`); + } + return parts.length > 0 ? parts.join(' · ') : undefined; +} + export class BackgroundAgentStatusComponent implements Component { - constructor(private readonly data: BackgroundAgentStatusData) {} + private spinnerFrame = 0; + private timer: ReturnType | undefined; + + constructor( + private readonly data: BackgroundAgentStatusData, + private readonly activityStore?: SubagentActivityStore, + private readonly requestRender?: () => void, + ) { + this.startRefresh(); + } invalidate(): void {} + dispose(): void { + if (this.timer === undefined) return; + clearInterval(this.timer); + this.timer = undefined; + } + render(width: number): string[] { const safeWidth = Math.max(0, width); if (safeWidth <= 0) return ['']; + const live = this.liveContent(); + // A terminal record, or a store cleared on session switch, ends the refresh + // loop; the static fallback below still renders the spawn data. + if (live === undefined || live.phase !== 'running') this.dispose(); + + const headline = live?.headline ?? this.data.headline; + const detail = live === undefined ? this.data.detail : live.detail; + const phase = live?.phase ?? this.data.phase; + const tone: keyof ColorPalette = - this.data.phase === 'started' - ? 'primary' - : this.data.phase === 'completed' - ? 'success' - : 'error'; - - const bullet = - this.data.phase === 'failed' ? currentTheme.fg(tone, FAILURE_MARK) : currentTheme.fg(tone, STATUS_BULLET); + phase === 'completed' ? 'success' : phase === 'failed' ? 'error' : 'primary'; + const marker = + phase === 'failed' + ? currentTheme.fg(tone, FAILURE_MARK) + : phase === 'running' + ? currentTheme.fg( + tone, + `${BRAILLE_SPINNER_FRAMES[this.spinnerFrame] ?? BRAILLE_SPINNER_FRAMES[0]} `, + ) + : currentTheme.fg(tone, STATUS_BULLET); const text = - currentTheme.fg(tone, this.data.headline) + - (this.data.detail !== undefined && this.data.detail.length > 0 - ? currentTheme.fg('textDim', ` (${this.data.detail})`) + currentTheme.fg(tone, headline) + + (detail !== undefined && detail.length > 0 + ? currentTheme.fg('textDim', ` (${detail})`) : ''); const textComponent = new Text(text, 0, 0); const contentWidth = Math.max(1, safeWidth - MESSAGE_INDENT.length); const contentLines = textComponent.render(contentWidth); - return [ + const lines = [ '', - ...contentLines.map((line, index) => (index === 0 ? bullet : MESSAGE_INDENT) + line), - ].map((line) => truncateToWidth(line, safeWidth, '…')); + ...contentLines.map((line, index) => (index === 0 ? marker : MESSAGE_INDENT) + line), + ]; + if (live?.activity !== undefined) { + lines.push(MESSAGE_INDENT + currentTheme.dim(live.activity)); + } + return lines.map((line) => truncateToWidth(line, safeWidth, '…')); + } + + private liveContent(): LiveStatusContent | undefined { + const { agentId } = this.data; + if ( + this.data.phase !== 'started' || + agentId === undefined || + this.activityStore === undefined + ) { + return undefined; + } + const record = this.activityStore.get(agentId); + if (record === undefined) return undefined; + + const subject = this.subjectPrefix(); + if (record.status === 'running') { + return { + phase: 'running', + headline: `${subject} running in background`, + detail: this.elapsedLabel(), + activity: activitySummary(record), + }; + } + const detail = record.status === 'completed' ? record.resultSummary : record.error; + return { + phase: record.status, + headline: `${subject} ${record.status} in background`, + detail: detail ?? this.data.detail, + }; + } + + private subjectPrefix(): string { + const suffixIndex = this.data.headline.indexOf(HEADLINE_STARTED_SUFFIX); + return suffixIndex >= 0 ? this.data.headline.slice(0, suffixIndex) : this.data.headline; + } + + private elapsedLabel(): string | undefined { + const { startedAtMs } = this.data; + if (startedAtMs === undefined) return undefined; + return formatElapsed(Math.max(0, Math.floor((Date.now() - startedAtMs) / 1000))); + } + + private startRefresh(): void { + if (this.requestRender === undefined || this.timer !== undefined) return; + if (this.data.phase !== 'started' || this.data.agentId === undefined) return; + if (this.activityStore === undefined) return; + const requestRender = this.requestRender; + this.timer = setInterval(() => { + const live = this.liveContent(); + if (live === undefined || live.phase !== 'running') { + this.dispose(); + return; + } + this.spinnerFrame = (this.spinnerFrame + 1) % BRAILLE_SPINNER_FRAMES.length; + requestRender(); + }, LIVE_REFRESH_INTERVAL_MS); + if (typeof this.timer === 'object' && 'unref' in this.timer) { + this.timer.unref(); + } } } diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index 4ca5541bbbc..979374f0f1e 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -607,10 +607,11 @@ export class ToolCallComponent extends Container { private subagentResultSummary: string | undefined; private subagentError: string | undefined; private streamingProgressTimer: ReturnType | undefined; - private subagentElapsedTimer: ReturnType | undefined; + /** Advances the header braille frame and refreshes subagent elapsed seconds. */ + private spinnerTimer: ReturnType | undefined; private subagentStartedAtMs: number | undefined; private subagentEndedAtMs: number | undefined; - private subagentSpinnerFrame = 0; + private spinnerFrame = 0; // ── Live progress lines ────────────────────────────────────────── // @@ -664,7 +665,7 @@ export class ToolCallComponent extends Container { this.buildContent(); this.buildSubagentBlock(); this.syncStreamingProgressTimer(); - this.syncSubagentElapsedTimer(); + this.syncSpinnerTimer(); this.startDetachHintTimer(); } @@ -739,7 +740,7 @@ export class ToolCallComponent extends Container { this.stopDetachHintTimer(); this.finalizeSubagentElapsedIfNeeded(); this.syncStreamingProgressTimer(); - this.syncSubagentElapsedTimer(); + this.syncSpinnerTimer(); this.headerText.setText(this.buildHeader()); // rebuildBody (not rebuildContent) so the call preview re-renders // with the collapsed cap applied — Write streaming previews and @@ -805,7 +806,7 @@ export class ToolCallComponent extends Container { dispose(): void { this.stopStreamingProgressTimer(); - this.stopSubagentElapsedTimer(); + this.stopSpinnerTimer(); this.stopDetachHintTimer(); } @@ -1068,37 +1069,46 @@ export class ToolCallComponent extends Container { this.addChild(new Text(currentTheme.dim(DETACH_HINT_TEXT), 2, 0)); } - private syncSubagentElapsedTimer(): void { - const phase = this.getDerivedSubagentPhase(); - const shouldTick = - this.isSingleSubagentView() && - this.subagentStartedAtMs !== undefined && - (phase === 'queued' || phase === 'spawning' || phase === 'running'); - if (!shouldTick) { - this.stopSubagentElapsedTimer(); + private syncSpinnerTimer(): void { + if (!this.hasSpinningHeader()) { + this.stopSpinnerTimer(); return; } - if (this.ui === undefined || this.subagentElapsedTimer !== undefined) return; - this.subagentElapsedTimer = setInterval(() => { - const latestPhase = this.getDerivedSubagentPhase(); - if (latestPhase !== 'queued' && latestPhase !== 'spawning' && latestPhase !== 'running') { - this.stopSubagentElapsedTimer(); + if (this.ui === undefined || this.spinnerTimer !== undefined) return; + this.spinnerTimer = setInterval(() => { + if (!this.hasSpinningHeader()) { + this.stopSpinnerTimer(); return; } // Drives both the braille spinner in the header and the elapsed-seconds // refresh. Only the header text changes on a tick, so we avoid rebuilding // the body (which would defeat the per-component render caches). - this.subagentSpinnerFrame = (this.subagentSpinnerFrame + 1) % BRAILLE_SPINNER_FRAMES.length; + this.spinnerFrame = (this.spinnerFrame + 1) % BRAILLE_SPINNER_FRAMES.length; this.headerText.setText(this.buildHeader()); this.notifySnapshotChange(); this.ui?.requestRender(); }, BRAILLE_SPINNER_INTERVAL_MS); } - private stopSubagentElapsedTimer(): void { - if (this.subagentElapsedTimer === undefined) return; - clearInterval(this.subagentElapsedTimer); - this.subagentElapsedTimer = undefined; + /** True while the header marker animates — a live subagent or a blocked WaitFor. */ + private hasSpinningHeader(): boolean { + if (this.isWaitingForTask()) return true; + const phase = this.getDerivedSubagentPhase(); + return ( + this.isSingleSubagentView() && + this.subagentStartedAtMs !== undefined && + (phase === 'queued' || phase === 'spawning' || phase === 'running') + ); + } + + private isWaitingForTask(): boolean { + return this.toolCall.name === 'WaitFor' && this.result === undefined; + } + + private stopSpinnerTimer(): void { + if (this.spinnerTimer === undefined) return; + clearInterval(this.spinnerTimer); + this.spinnerTimer = undefined; } private finalizeSubagentElapsedIfNeeded(): void { @@ -1127,7 +1137,7 @@ export class ToolCallComponent extends Container { this.subagentPhase = meta.runInBackground ? 'backgrounded' : 'queued'; this.subagentStartedAtMs = Date.now(); this.subagentEndedAtMs = undefined; - this.syncSubagentElapsedTimer(); + this.syncSpinnerTimer(); this.headerText.setText(this.buildHeader()); this.rebuildContent(); this.notifySnapshotChange(); @@ -1148,7 +1158,7 @@ export class ToolCallComponent extends Container { ) { this.subagentPhase = 'running'; } - this.syncSubagentElapsedTimer(); + this.syncSpinnerTimer(); this.headerText.setText(this.buildHeader()); this.rebuildContent(); this.notifySnapshotChange(); @@ -1175,7 +1185,7 @@ export class ToolCallComponent extends Container { if (this.subagentText.trim().length === 0 && this.subagentResultSummary !== undefined) { this.subagentText = this.subagentResultSummary; } - this.syncSubagentElapsedTimer(); + this.syncSpinnerTimer(); this.headerText.setText(this.buildHeader()); this.rebuildContent(); this.notifySnapshotChange(); @@ -1212,7 +1222,7 @@ export class ToolCallComponent extends Container { this.subagentPhase = 'failed'; this.subagentEndedAtMs ??= Date.now(); this.subagentError = payload.error; - this.syncSubagentElapsedTimer(); + this.syncSpinnerTimer(); this.headerText.setText(this.buildHeader()); this.rebuildContent(); this.notifySnapshotChange(); @@ -1260,7 +1270,7 @@ export class ToolCallComponent extends Container { if (phaseUnchanged && !errorChanged) return; this.backgroundTaskTerminalPhase = phase; this.subagentEndedAtMs ??= Date.now(); - this.syncSubagentElapsedTimer(); + this.syncSpinnerTimer(); this.headerText.setText(this.buildHeader()); this.rebuildContent(); this.notifySnapshotChange(); @@ -1453,6 +1463,11 @@ export class ToolCallComponent extends Container { bullet = isError ? currentTheme.fg('error', '✗ ') : currentTheme.fg('success', STATUS_BULLET); } else if (isTruncated) { bullet = currentTheme.fg('error', '✗ '); + } else if (this.isWaitingForTask()) { + // WaitFor can block for minutes; a braille frame reads as alive where + // the static in-flight bullet looked frozen. + const frame = BRAILLE_SPINNER_FRAMES[this.spinnerFrame] ?? BRAILLE_SPINNER_FRAMES[0]; + bullet = currentTheme.fg('primary', `${frame} `); } else { // Solid bullet for in-flight tools — the previous marker ↔ blank // toggle caused visible flicker on every re-render. @@ -1852,17 +1867,17 @@ export class ToolCallComponent extends Container { if (phase === 'backgrounded') return currentTheme.dim('◐ '); // Active (queued / spawning / running): a braille spinner reads as alive // where a static bullet looked frozen. - const frame = BRAILLE_SPINNER_FRAMES[this.subagentSpinnerFrame] ?? BRAILLE_SPINNER_FRAMES[0]; + const frame = BRAILLE_SPINNER_FRAMES[this.spinnerFrame] ?? BRAILLE_SPINNER_FRAMES[0]; return currentTheme.fg('primary', `${frame} `); } private buildSingleSubagentBlock(): void { const phase = this.getDerivedSubagentPhase(); - // Every state shares the same skeleton — header, a one-line tool summary, + // Every state shares the same skeleton — header, the per-sub-tool list, // and a fixed two-row content window — so the card height is identical // while running and after it finishes (no end-of-run shrink). - this.addChild(new Text(this.buildSingleSubagentSummaryLine(), 0, 0)); + this.buildSingleSubagentToolList(); if (phase === 'failed') { this.addChild(this.buildSingleSubagentResultWindow('error')); @@ -1918,24 +1933,41 @@ export class ToolCallComponent extends Container { return undefined; } - private buildSingleSubagentSummaryLine(): string { - const toolCount = this.subToolActivities.size; - const countLabel = `${String(toolCount)} tool${toolCount === 1 ? '' : 's'}`; - const current = this.getCurrentSubToolActivity(); - if (current === undefined) { - return currentTheme.dim(` · ${countLabel}`); - } - const verb = current.phase === 'ongoing' ? 'Using' : 'Used'; - const keyArg = extractKeyArgument(current.name, current.args, this.workspaceDir); - const nameCol = currentTheme.fg('primary', current.name); - const argCol = keyArg ? currentTheme.dim(` (${keyArg})`) : ''; - const mark = - current.phase === 'failed' - ? currentTheme.fg('error', ' ✗') - : current.phase === 'done' - ? currentTheme.fg('success', ' ✓') - : ''; - return `${currentTheme.dim(` · ${countLabel} · `)}${verb} ${nameCol}${argCol}${mark}`; + /** + * Per-sub-tool rows, oldest first, matching the grouped card: finished and + * failed tools read as `Used ()`, ongoing ones as + * `Using ()`. `subToolActivities` grows without bound, so only + * the newest {@link MAX_SUB_TOOL_CALLS_SHOWN} rows render and the older ones + * are reported as a hidden count. + */ + private buildSingleSubagentToolList(): void { + const activities = [...this.subToolActivities.values()].toSorted( + (a, b) => a.orderSeq - b.orderSeq, + ); + const hidden = activities.length - MAX_SUB_TOOL_CALLS_SHOWN; + if (hidden > 0) { + const suffix = hidden > 1 ? 's' : ''; + this.addChild( + new Text( + currentTheme.italic(currentTheme.dim(` ${String(hidden)} more tool call${suffix} ...`)), + 0, + 0, + ), + ); + } + for (const activity of activities.slice(-MAX_SUB_TOOL_CALLS_SHOWN)) { + const mark = + activity.phase === 'failed' + ? currentTheme.fg('error', '✗') + : activity.phase === 'done' + ? currentTheme.fg('success', '✓') + : currentTheme.dim('…'); + const verb = activity.phase === 'ongoing' ? 'Using' : 'Used'; + const keyArg = extractKeyArgument(activity.name, activity.args, this.workspaceDir); + const nameCol = currentTheme.fg('primary', activity.name); + const argCol = keyArg ? currentTheme.dim(` (${keyArg})`) : ''; + this.addChild(new Text(` ${mark} ${verb} ${nameCol}${argCol}`, 0, 0)); + } } private buildSingleSubagentActiveWindow(): Component { diff --git a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts index 9b9005cdf04..47cce3ea0ba 100644 --- a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts @@ -316,6 +316,9 @@ export class SubAgentEventHandler { } const extras = event.resultSummary === undefined ? undefined : { resultSummary: event.resultSummary }; + // A live started entry renders this terminal state in place; only a + // resumed agent (no started entry in this process) needs a fresh entry. + if (this.hasLiveStartedEntry(event.subagentId)) return; this.appendBackgroundAgentEntry('completed', backgroundMeta, extras); return; } @@ -355,6 +358,8 @@ export class SubAgentEventHandler { if (taskId !== undefined) { this.deps.backgroundTaskTranscriptedTerminal.add(taskId); } + // Same as the completed path: the live started entry transitions in place. + if (this.hasLiveStartedEntry(event.subagentId)) return; this.appendBackgroundAgentEntry('failed', backgroundMeta, { error: event.error }); return; } @@ -435,11 +440,24 @@ export class SubAgentEventHandler { renderMode: 'plain', content: status.headline, detail: status.detail, - backgroundAgentStatus: status, + backgroundAgentStatus: + phase === 'started' + ? { ...status, agentId: meta.agentId, startedAtMs: Date.now() } + : status, }; this.host.appendTranscriptEntry(entry); } + /** True when this process appended the live started entry for the agent, so + * the transcript component can show the terminal state in place. */ + private hasLiveStartedEntry(agentId: string): boolean { + return this.host.state.transcriptEntries.some( + (entry) => + entry.backgroundAgentStatus?.phase === 'started' && + entry.backgroundAgentStatus.agentId === agentId, + ); + } + private rememberSubagent( event: SubagentLifecycleEventOf<'subagent.spawned'>, ): void { diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index d2037a771ef..a71ee8d6c09 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -141,6 +141,7 @@ import { createTUIState, type TUIState } from './tui-state'; import { INITIAL_LIVE_PANE, type AppState, + type BackgroundAgentStatusData, type InlineSkillActivation, type KimiTUIOptions, type LivePaneState, @@ -2926,14 +2927,14 @@ export class KimiTUI { return tc; } if (entry.backgroundAgentStatus !== undefined) { - return new BackgroundAgentStatusComponent(entry.backgroundAgentStatus); + return this.createBackgroundAgentStatusComponent(entry.backgroundAgentStatus); } return entry.renderMode === 'notice' ? new NoticeMessageComponent(entry.content, entry.detail) : new StatusMessageComponent(entry.content, entry.color); case 'status': if (entry.backgroundAgentStatus !== undefined) { - return new BackgroundAgentStatusComponent(entry.backgroundAgentStatus); + return this.createBackgroundAgentStatusComponent(entry.backgroundAgentStatus); } return entry.renderMode === 'notice' ? new NoticeMessageComponent(entry.content, entry.detail) @@ -2945,6 +2946,21 @@ export class KimiTUI { } } + /** Wire the live activity store into a started background-agent line so the + * component can show progress and transition in place. Replay entries have + * no live record, so the component falls back to the static data. */ + private createBackgroundAgentStatusComponent( + data: BackgroundAgentStatusData, + ): BackgroundAgentStatusComponent { + return new BackgroundAgentStatusComponent( + data, + this.sessionEventHandler.subAgentEventHandler.activityStore, + () => { + this.state.ui.requestRender(); + }, + ); + } + appendTranscriptEntry(entry: TranscriptEntry): void { this.state.transcriptEntries.push(entry); const component = this.createTranscriptComponent(entry); diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 6c8266f5905..8b97c306115 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -166,6 +166,10 @@ export interface BackgroundAgentStatusData { readonly phase: BackgroundAgentStatusPhase; readonly headline: string; readonly detail?: string; + /** Set on the `started` entry so the component can follow the live record. */ + readonly agentId?: string; + /** Spawn time of a live background agent, used for the elapsed label. */ + readonly startedAtMs?: number; } export interface CompactionTranscriptData { diff --git a/apps/kimi-code/test/tui/components/messages/background-agent-status.test.ts b/apps/kimi-code/test/tui/components/messages/background-agent-status.test.ts index e8f395ec879..b3799b60af8 100644 --- a/apps/kimi-code/test/tui/components/messages/background-agent-status.test.ts +++ b/apps/kimi-code/test/tui/components/messages/background-agent-status.test.ts @@ -1,13 +1,92 @@ +import type { Event } from '@moonshot-ai/kimi-code-sdk'; import { visibleWidth } from '@moonshot-ai/pi-tui'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { BackgroundAgentStatusComponent } from '#/tui/components/messages/background-agent-status'; -import { STATUS_BULLET } from '#/tui/constant/symbols'; +import { BRAILLE_SPINNER_FRAMES, MESSAGE_INDENT } from '#/tui/constant/rendering'; +import { FAILURE_MARK, STATUS_BULLET } from '#/tui/constant/symbols'; +import { SubagentActivityStore } from '#/tui/controllers/subagent-activity-store'; +import type { BackgroundAgentStatusData } from '#/tui/types'; function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); } +const START_MS = 1_700_000_000_000; +const LIVE_ACTIVITY_MAX_CHARS = 160; + +function startedData(): BackgroundAgentStatusData { + return { + phase: 'started', + headline: 'explore agent started in background', + detail: 'Explore project structure', + agentId: 'agent-1', + startedAtMs: START_MS, + }; +} + +function stepStartedEvent(agentId: string, step: number): Event { + return { + sessionId: 's1', + agentId, + type: 'turn.step.started', + turnId: 1, + step, + } as unknown as Event; +} + +function toolStartedEvent(agentId: string, name: string): Event { + return { + sessionId: 's1', + agentId, + type: 'tool.call.started', + turnId: 1, + step: 0, + toolCallId: `${name}-1`, + name, + args: {}, + } as unknown as Event; +} + +function toolResultEvent(agentId: string, name: string): Event { + return { + sessionId: 's1', + agentId, + type: 'tool.result', + turnId: 1, + step: 0, + toolCallId: `${name}-1`, + output: 'ok', + isError: false, + } as unknown as Event; +} + +function assistantDeltaEvent(agentId: string, delta: string): Event { + return { + sessionId: 's1', + agentId, + type: 'assistant.delta', + turnId: 1, + step: 0, + delta, + } as unknown as Event; +} + +function makeActivityStore(): SubagentActivityStore { + const store = new SubagentActivityStore(); + store.ensureRecord({ + agentId: 'agent-1', + agentName: 'explore', + parentToolCallId: 'tc-agent-1', + }); + store.applyEvent(stepStartedEvent('agent-1', 0)); + return store; +} + +function renderLines(component: BackgroundAgentStatusComponent, width = 120): string[] { + return component.render(width).map((line) => strip(line).trimEnd()); +} + describe('BackgroundAgentStatusComponent', () => { it('renders started/completed with the shared bullet and failed with a red x marker', () => { const started = new BackgroundAgentStatusComponent({ @@ -59,3 +138,304 @@ describe('BackgroundAgentStatusComponent', () => { } }); }); + +describe('BackgroundAgentStatusComponent — live background agent', () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('shows a spinner, elapsed time, and the running tool while the agent works', () => { + vi.useFakeTimers(); + vi.setSystemTime(START_MS); + const store = makeActivityStore(); + store.applyEvent(toolStartedEvent('agent-1', 'Read')); + const requestRender = vi.fn(); + const component = new BackgroundAgentStatusComponent(startedData(), store, requestRender); + + vi.advanceTimersByTime(12_000); + const lines = renderLines(component); + + expect(lines[1]).toBe( + `${BRAILLE_SPINNER_FRAMES[12 % BRAILLE_SPINNER_FRAMES.length]} explore agent running in background (12s)`, + ); + expect(lines[2]).toBe(`${MESSAGE_INDENT}Using Read · 1 step`); + expect(requestRender).toHaveBeenCalledTimes(12); + expect(requestRender).toHaveBeenCalledWith(); + }); + + it('marks the tool call as used once its result lands', () => { + vi.useFakeTimers(); + vi.setSystemTime(START_MS); + const store = makeActivityStore(); + store.applyEvent(toolStartedEvent('agent-1', 'Read')); + store.applyEvent(toolResultEvent('agent-1', 'Read')); + const component = new BackgroundAgentStatusComponent(startedData(), store, vi.fn()); + + const lines = renderLines(component); + + expect(lines[2]).toBe(`${MESSAGE_INDENT}Used Read · 1 step`); + }); + + it('formats a minute-scale elapsed label', () => { + vi.useFakeTimers(); + vi.setSystemTime(START_MS); + const component = new BackgroundAgentStatusComponent(startedData(), makeActivityStore(), vi.fn()); + + vi.advanceTimersByTime(90_000); + const lines = renderLines(component); + + expect(lines[1]).toBe( + `${BRAILLE_SPINNER_FRAMES[90 % BRAILLE_SPINNER_FRAMES.length]} explore agent running in background (1m 30s)`, + ); + }); + + it('clamps a start time in the future to zero elapsed', () => { + vi.useFakeTimers(); + vi.setSystemTime(START_MS); + const component = new BackgroundAgentStatusComponent( + { ...startedData(), startedAtMs: START_MS + 5_000 }, + makeActivityStore(), + vi.fn(), + ); + + const lines = renderLines(component); + + expect(lines[1]).toBe( + `${BRAILLE_SPINNER_FRAMES[0]} explore agent running in background (0s)`, + ); + }); + + it('falls back to the headline when it carries no started suffix', () => { + vi.useFakeTimers(); + vi.setSystemTime(START_MS); + const component = new BackgroundAgentStatusComponent( + { ...startedData(), headline: 'explore agent' }, + makeActivityStore(), + vi.fn(), + ); + + const lines = renderLines(component); + + expect(lines[1]).toBe(`${BRAILLE_SPINNER_FRAMES[0]} explore agent running in background (0s)`); + }); + + it('falls back to the latest assistant text when no tool call started', () => { + vi.useFakeTimers(); + vi.setSystemTime(START_MS); + const store = makeActivityStore(); + store.applyEvent(assistantDeltaEvent('agent-1', 'Reading src/a.ts\nmore files')); + const component = new BackgroundAgentStatusComponent(startedData(), store, vi.fn()); + + const lines = renderLines(component); + + expect(lines[2]).toBe(`${MESSAGE_INDENT}Reading src/a.ts more files · 1 step`); + }); + + it('caps a long latest-activity line to a trailing window', () => { + vi.useFakeTimers(); + vi.setSystemTime(START_MS); + const store = makeActivityStore(); + store.applyEvent(assistantDeltaEvent('agent-1', 'x'.repeat(200))); + const component = new BackgroundAgentStatusComponent(startedData(), store, vi.fn()); + + const lines = renderLines(component, 200); + + expect(lines[2]).toBe(`${MESSAGE_INDENT}…${'x'.repeat(LIVE_ACTIVITY_MAX_CHARS)} · 1 step`); + }); + + it('renders the completed state in place and stops refreshing', () => { + vi.useFakeTimers(); + vi.setSystemTime(START_MS); + const store = makeActivityStore(); + const requestRender = vi.fn(); + const component = new BackgroundAgentStatusComponent(startedData(), store, requestRender); + vi.advanceTimersByTime(3_000); + + store.markCompleted('agent-1', 'Reviewed the long-running work.'); + const lines = renderLines(component); + requestRender.mockClear(); + vi.advanceTimersByTime(5_000); + + expect(lines[1]).toBe( + `${STATUS_BULLET}explore agent completed in background (Reviewed the long-running work.)`, + ); + expect(requestRender).not.toHaveBeenCalled(); + }); + + it('renders the failed state with the error from the activity record', () => { + vi.useFakeTimers(); + vi.setSystemTime(START_MS); + const store = makeActivityStore(); + const component = new BackgroundAgentStatusComponent(startedData(), store, vi.fn()); + + store.markFailed('agent-1', 'boom'); + const lines = renderLines(component); + + expect(lines[1]).toBe(`${FAILURE_MARK}explore agent failed in background (boom)`); + }); + + it('keeps the spawn detail when the terminal record carries no summary', () => { + vi.useFakeTimers(); + vi.setSystemTime(START_MS); + const store = makeActivityStore(); + const component = new BackgroundAgentStatusComponent(startedData(), store, vi.fn()); + + store.markCompleted('agent-1'); + const lines = renderLines(component); + + expect(lines[1]).toBe( + `${STATUS_BULLET}explore agent completed in background (Explore project structure)`, + ); + }); + + it('keeps the static started line when the store has no record for the agent', () => { + vi.useFakeTimers(); + vi.setSystemTime(START_MS); + const requestRender = vi.fn(); + const component = new BackgroundAgentStatusComponent( + startedData(), + new SubagentActivityStore(), + requestRender, + ); + + const lines = renderLines(component); + vi.advanceTimersByTime(5_000); + + expect(lines[1]).toBe( + `${STATUS_BULLET}explore agent started in background (Explore project structure)`, + ); + expect(requestRender).not.toHaveBeenCalled(); + }); + + it('keeps the static started line when no activity store is provided', () => { + const component = new BackgroundAgentStatusComponent(startedData()); + + expect(renderLines(component)[1]).toBe( + `${STATUS_BULLET}explore agent started in background (Explore project structure)`, + ); + }); + + it('omits the elapsed label when the start time is unknown', () => { + vi.useFakeTimers(); + vi.setSystemTime(START_MS); + const component = new BackgroundAgentStatusComponent( + { ...startedData(), startedAtMs: undefined }, + makeActivityStore(), + vi.fn(), + ); + + const lines = renderLines(component); + + expect(lines[1]).toBe(`${BRAILLE_SPINNER_FRAMES[0]} explore agent running in background`); + }); + + it('does not refresh a non-started entry even with a live store', () => { + vi.useFakeTimers(); + const requestRender = vi.fn(); + const component = new BackgroundAgentStatusComponent( + { phase: 'completed', headline: 'explore agent completed in background' }, + makeActivityStore(), + requestRender, + ); + + vi.advanceTimersByTime(5_000); + + expect(renderLines(component)[1]).toBe( + `${STATUS_BULLET}explore agent completed in background`, + ); + expect(requestRender).not.toHaveBeenCalled(); + }); + + it('does not refresh when the entry carries no agent id', () => { + vi.useFakeTimers(); + const requestRender = vi.fn(); + const component = new BackgroundAgentStatusComponent( + { ...startedData(), agentId: undefined }, + makeActivityStore(), + requestRender, + ); + + vi.advanceTimersByTime(5_000); + + expect(renderLines(component)[1]).toBe( + `${STATUS_BULLET}explore agent started in background (Explore project structure)`, + ); + expect(requestRender).not.toHaveBeenCalled(); + }); + + it('does not refresh without an activity store', () => { + vi.useFakeTimers(); + const requestRender = vi.fn(); + const component = new BackgroundAgentStatusComponent(startedData(), undefined, requestRender); + + vi.advanceTimersByTime(5_000); + + expect(renderLines(component)[1]).toBe( + `${STATUS_BULLET}explore agent started in background (Explore project structure)`, + ); + expect(requestRender).not.toHaveBeenCalled(); + }); + + it('stops refreshing once disposed', () => { + vi.useFakeTimers(); + vi.setSystemTime(START_MS); + const store = makeActivityStore(); + const requestRender = vi.fn(); + const component = new BackgroundAgentStatusComponent(startedData(), store, requestRender); + + component.dispose(); + vi.advanceTimersByTime(5_000); + + expect(requestRender).not.toHaveBeenCalled(); + }); + + it('stops the refresh timer when the record turns terminal before the next render', () => { + vi.useFakeTimers(); + vi.setSystemTime(START_MS); + const store = makeActivityStore(); + const requestRender = vi.fn(); + const component = new BackgroundAgentStatusComponent(startedData(), store, requestRender); + vi.advanceTimersByTime(2_000); + requestRender.mockClear(); + + store.markCompleted('agent-1', 'done'); + vi.advanceTimersByTime(5_000); + + expect(requestRender).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + expect(renderLines(component)[1]).toBe( + `${STATUS_BULLET}explore agent completed in background (done)`, + ); + }); + + it('unrefs the refresh timer so it cannot hold the process open', () => { + const unref = vi.fn(); + vi.spyOn(globalThis, 'setInterval').mockReturnValue( + { unref } as unknown as ReturnType, + ); + const component = new BackgroundAgentStatusComponent( + startedData(), + makeActivityStore(), + vi.fn(), + ); + + component.dispose(); + + expect(unref).toHaveBeenCalledWith(); + }); + + it('keeps the live line within very narrow widths', () => { + vi.useFakeTimers(); + vi.setSystemTime(START_MS); + const store = makeActivityStore(); + store.applyEvent(toolStartedEvent('agent-1', 'Read')); + const component = new BackgroundAgentStatusComponent(startedData(), store, vi.fn()); + + for (const width of [1, 2, 4, 10, 39]) { + for (const line of component.render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts index 367e62a56c9..27e4898f145 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts @@ -3,6 +3,7 @@ import chalk from 'chalk'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { ToolCallComponent } from '#/tui/components/messages/tool-call'; +import { BRAILLE_SPINNER_FRAMES, BRAILLE_SPINNER_INTERVAL_MS } from '#/tui/constant/rendering'; import { STATUS_BULLET } from '#/tui/constant/symbols'; import { darkColors } from '#/tui/theme/colors'; @@ -1105,89 +1106,122 @@ describe('ToolCallComponent', () => { component.dispose(); }); - it('summarizes subagent tools as a count plus the current tool', () => { - vi.useFakeTimers(); - vi.setSystemTime(0); - const component = new ToolCallComponent( - { - id: 'call_agent_tools', - name: 'Agent', - args: { description: 'inspect tools' }, - }, - undefined, - ); - component.onSubagentSpawned({ - agentId: 'sub_tools', - agentName: 'explore', - runInBackground: false, + describe('single subagent tool list', () => { + function makeSingleSubagentComponent(id: string): ToolCallComponent { + vi.useFakeTimers(); + vi.setSystemTime(0); + const component = new ToolCallComponent( + { id, name: 'Agent', args: { description: 'inspect tools' } }, + undefined, + ); + component.onSubagentSpawned({ + agentId: `${id}:sub`, + agentName: 'explore', + runInBackground: false, + }); + return component; + } + + it('lists one row per sub-tool with Used and Using markers in start order', () => { + const component = makeSingleSubagentComponent('call_agent_list'); + component.appendSubToolCall({ id: 'list:read', name: 'Read', args: { path: 'src/a.ts' } }); + component.finishSubToolCall({ tool_call_id: 'list:read', output: 'ok', is_error: false }); + component.appendSubToolCall({ id: 'list:edit', name: 'Edit', args: { path: 'src/b.ts' } }); + component.finishSubToolCall({ tool_call_id: 'list:edit', output: 'boom', is_error: true }); + component.appendSubToolCall({ id: 'list:grep', name: 'Grep', args: { pattern: 'auth' } }); + + const out = strip(component.render(120).join('\n')); + + expect(out).toContain('Explore Agent Running (inspect tools) · 3 tools · 0s'); + expect(out).toContain('✓ Used Read (src/a.ts)'); + expect(out).toContain('✗ Used Edit (src/b.ts)'); + expect(out).toContain('… Using Grep (auth)'); + expect(out.indexOf('Used Read')).toBeLessThan(out.indexOf('Used Edit')); + expect(out.indexOf('Used Edit')).toBeLessThan(out.indexOf('Using Grep')); + expect(out).not.toContain('more tool call'); + component.dispose(); }); - for (let i = 1; i <= 4; i++) { - const id = `sub_tools:read-${String(i)}`; - component.appendSubToolCall({ id, name: 'Read', args: { path: `file${String(i)}.ts` } }); - component.finishSubToolCall({ tool_call_id: id, output: 'ok', is_error: false }); - } - component.appendSubToolCall({ - id: 'sub_tools:grep', - name: 'Grep', - args: { pattern: 'auth' }, + it('caps the rows at four and reports the hidden tool calls', () => { + const component = makeSingleSubagentComponent('call_agent_cap'); + for (let i = 1; i <= 6; i++) { + const id = `cap:read-${String(i)}`; + component.appendSubToolCall({ id, name: 'Read', args: { path: `file${String(i)}.ts` } }); + component.finishSubToolCall({ tool_call_id: id, output: 'ok', is_error: false }); + } + + const out = strip(component.render(120).join('\n')); + const rows = out.split('\n').filter((line) => line.includes('Used Read')); + + expect(out).toContain('2 more tool calls ...'); + expect(out).toContain('✓ Used Read (file6.ts)'); + expect(out).not.toContain('file1.ts'); + expect(out).not.toContain('file2.ts'); + expect(rows.length).toBe(4); + component.dispose(); }); - const out = strip(component.render(120).join('\n')); - expect(out).toContain('Explore Agent Running (inspect tools) · 5 tools · 0s'); - // Only the current (most recent ongoing) tool appears in the summary line. - expect(out).toContain('Using Grep (auth)'); - // No per-tool activity rows are rendered. - expect(out).not.toContain('file1.ts'); - expect(out).not.toContain('file2.ts'); - expect(out).not.toContain('file3.ts'); - expect(out).not.toContain('file4.ts'); - expect(out).not.toContain('Used Read'); - }); + it('shows every row without a hidden count at exactly the cap', () => { + const component = makeSingleSubagentComponent('call_agent_cap_exact'); + for (let i = 1; i <= 4; i++) { + const id = `cap4:read-${String(i)}`; + component.appendSubToolCall({ id, name: 'Read', args: { path: `file${String(i)}.ts` } }); + component.finishSubToolCall({ tool_call_id: id, output: 'ok', is_error: false }); + } - it('keeps the subagent tool summary pinned to the most recent tool', () => { - vi.useFakeTimers(); - vi.setSystemTime(0); - const component = new ToolCallComponent( - { - id: 'call_agent_stable_tools', - name: 'Agent', - args: { description: 'inspect tools' }, - }, - undefined, - ); - component.onSubagentSpawned({ - agentId: 'sub_tools', - agentName: 'explore', - runInBackground: false, + const out = strip(component.render(120).join('\n')); + const rows = out.split('\n').filter((line) => line.includes('Used Read')); + + expect(rows.length).toBe(4); + expect(out).toContain('✓ Used Read (file1.ts)'); + expect(out).not.toContain('more tool call'); + component.dispose(); }); - for (let i = 1; i <= 5; i++) { - component.appendSubToolCall({ - id: `sub_tools:read-${String(i)}`, + it('uses the singular wording for a single hidden tool call', () => { + const component = makeSingleSubagentComponent('call_agent_cap_one'); + for (let i = 1; i <= 5; i++) { + const id = `cap1:read-${String(i)}`; + component.appendSubToolCall({ id, name: 'Read', args: { path: `file${String(i)}.ts` } }); + component.finishSubToolCall({ tool_call_id: id, output: 'ok', is_error: false }); + } + + const out = strip(component.render(120).join('\n')); + + expect(out).toContain('1 more tool call ...'); + expect(out).not.toContain('1 more tool calls ...'); + component.dispose(); + }); + + it('keeps the newest rows when an older sub-tool is updated and finished', () => { + const component = makeSingleSubagentComponent('call_agent_stable_tools'); + for (let i = 1; i <= 5; i++) { + component.appendSubToolCall({ + id: `stable:read-${String(i)}`, + name: 'Read', + args: { path: `file${String(i)}.ts` }, + }); + } + component.appendSubToolCallDelta({ + id: 'stable:read-1', name: 'Read', - args: { path: `file${String(i)}.ts` }, + argumentsPart: '{"path":"file1-updated.ts"}', + }); + component.finishSubToolCall({ + tool_call_id: 'stable:read-1', + output: 'ok', + is_error: false, }); - } - component.appendSubToolCallDelta({ - id: 'sub_tools:read-1', - name: 'Read', - argumentsPart: '{"path":"file1-updated.ts"}', - }); - component.finishSubToolCall({ - tool_call_id: 'sub_tools:read-1', - output: 'ok', - is_error: false, - }); - const out = strip(component.render(120).join('\n')); - // The updated/finished older tool must not surface in the summary. - expect(out).not.toContain('file1-updated.ts'); - expect(out).not.toContain('file2.ts'); - expect(out).not.toContain('file3.ts'); - expect(out).not.toContain('file4.ts'); - // Only the most recent ongoing tool is shown. - expect(out).toContain('Using Read (file5.ts)'); + const out = strip(component.render(120).join('\n')); + + // read-1 keeps its original order sequence, so updating and finishing it + // does not pull it back into the four-row window. + expect(out).not.toContain('file1-updated.ts'); + expect(out).toContain('… Using Read (file5.ts)'); + expect(out).toContain('1 more tool call ...'); + component.dispose(); + }); }); it('wraps the single subagent active window with a hanging gutter', () => { @@ -1978,6 +2012,78 @@ describe('ToolCallComponent', () => { component.dispose(); }); + it('spins the header marker while the wait is still running', () => { + vi.useFakeTimers(); + const component = new ToolCallComponent( + { + id: 'call_wait_spin', + name: 'WaitFor', + args: { task_id: 'question-80w0h7nw', timeout: 300 }, + }, + undefined, + stubTui(30), + ); + + const first = strip(component.render(100).join('\n')); + vi.advanceTimersByTime(BRAILLE_SPINNER_INTERVAL_MS); + const next = strip(component.render(100).join('\n')); + + expect(first).toContain( + `${BRAILLE_SPINNER_FRAMES[0] ?? ''} Waiting for background task (question-80w0h7nw)`, + ); + expect(next).toContain( + `${BRAILLE_SPINNER_FRAMES[1] ?? ''} Waiting for background task (question-80w0h7nw)`, + ); + expect(next).not.toContain(`${STATUS_BULLET}Waiting for background task`); + + component.dispose(); + }); + + it('drops the spinner for the finished marker once the wait returns', () => { + vi.useFakeTimers(); + const requestRender = vi.fn(); + const component = new ToolCallComponent( + { + id: 'call_wait_spin_done', + name: 'WaitFor', + args: { task_id: 'question-80w0h7nw', timeout: 300 }, + }, + undefined, + { terminal: { rows: 30 }, requestRender } as unknown as TUI, + ); + + vi.advanceTimersByTime(BRAILLE_SPINNER_INTERVAL_MS); + component.setResult({ + tool_call_id: 'call_wait_spin_done', + output: waitForCompletedOutput, + is_error: false, + }); + vi.advanceTimersByTime(BRAILLE_SPINNER_INTERVAL_MS * 3); + + const out = strip(component.render(100).join('\n')); + expect(out).toContain(`${STATUS_BULLET}Waited for background task (question-80w0h7nw)`); + expect(BRAILLE_SPINNER_FRAMES.filter((frame) => out.includes(frame))).toEqual([]); + expect(requestRender).toHaveBeenCalledTimes(1); + + component.dispose(); + }); + + it('keeps the static marker for other running tools', () => { + vi.useFakeTimers(); + const component = new ToolCallComponent( + { id: 'call_read_spin_guard', name: 'Read', args: { path: 'foo.ts' } }, + undefined, + stubTui(30), + ); + + vi.advanceTimersByTime(BRAILLE_SPINNER_INTERVAL_MS * 3); + + const out = strip(component.render(100).join('\n')); + expect(out).toContain(`${STATUS_BULLET}Using Read (foo.ts)`); + + component.dispose(); + }); + it('shows the waited tense with the elapsed chip once completed', () => { const component = new ToolCallComponent( { diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-background-task.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-background-task.test.ts index cb72468cb49..cf9ae277b3c 100644 --- a/apps/kimi-code/test/tui/controllers/session-event-handler-background-task.test.ts +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-background-task.test.ts @@ -7,6 +7,7 @@ import { type SubagentLifecycleEvent, } from '#/tui/controllers/subagent-event-handler'; import { getBuiltInPalette } from '#/tui/theme'; +import type { TranscriptEntry } from '#/tui/types'; function makeStreamingUIStub() { return { @@ -27,14 +28,18 @@ function makeStreamingUIStub() { function makeSubagentHandler() { const backgroundTasks = new Map(); + const transcriptEntries: TranscriptEntry[] = []; const host = { state: { appState: { availableModels: {} }, ui: { requestRender: vi.fn() }, transcriptContainer: { addChild: vi.fn() }, + transcriptEntries, }, streamingUI: makeStreamingUIStub(), - appendTranscriptEntry: vi.fn(), + appendTranscriptEntry: vi.fn((entry: TranscriptEntry) => { + transcriptEntries.push(entry); + }), btwPanelController: { routeEvent: vi.fn(() => false) }, updateActivityPane: vi.fn(), }; @@ -43,7 +48,7 @@ function makeSubagentHandler() { backgroundTaskTranscriptedTerminal: new Set(), syncBackgroundAgentBadge: vi.fn(), }); - return { handler, backgroundTasks }; + return { handler, backgroundTasks, transcriptEntries, host }; } function spawnEvent(subagentId: string, runInBackground: boolean): SubagentLifecycleEvent { @@ -70,6 +75,17 @@ function completedEvent(subagentId: string): SubagentLifecycleEvent { } as unknown as SubagentLifecycleEvent; } +function failedEvent(subagentId: string, error: string): SubagentLifecycleEvent { + return { + sessionId: 's1', + agentId: 'main', + type: 'subagent.failed', + subagentId, + parentToolCallId: `tc-${subagentId}`, + error, + } as unknown as SubagentLifecycleEvent; +} + describe('SubAgentEventHandler — activity record pruning', () => { it('drops the record of a foreground-only subagent at terminal state', () => { const { handler } = makeSubagentHandler(); @@ -123,6 +139,7 @@ function makeSessionEventHost() { toolOutputExpanded: false, todoPanel: { getTodos: vi.fn(() => []) }, transcriptContainer: { addChild: vi.fn() }, + transcriptEntries: [], tasksBrowser: undefined, footer: { setBackgroundCounts: vi.fn() }, ui: { requestRender: vi.fn() }, @@ -219,3 +236,65 @@ describe('SessionEventHandler — background.task.terminated', () => { expect(store.get('agent-7')).toBeUndefined(); }); }); + +describe('SubAgentEventHandler — background agent transcript entries', () => { + const START_MS = 1_700_000_000_000; + + it('records the agent id and start time on the started entry', () => { + vi.useFakeTimers(); + vi.setSystemTime(START_MS); + const { handler, transcriptEntries } = makeSubagentHandler(); + + handler.handleLifecycleEvent(spawnEvent('bg-1', true)); + vi.useRealTimers(); + + const status = transcriptEntries[0]?.backgroundAgentStatus; + expect(status?.phase).toBe('started'); + expect(status?.agentId).toBe('bg-1'); + expect(status?.startedAtMs).toBe(START_MS); + }); + + it('does not append a second entry when the background agent completes', () => { + const { handler, transcriptEntries } = makeSubagentHandler(); + handler.handleLifecycleEvent(spawnEvent('bg-2', true)); + + handler.handleLifecycleEvent(completedEvent('bg-2')); + + expect(transcriptEntries).toHaveLength(1); + expect(transcriptEntries[0]?.backgroundAgentStatus?.phase).toBe('started'); + expect(handler.activityStore.get('bg-2')?.status).toBe('completed'); + expect(handler.activityStore.get('bg-2')?.resultSummary).toBe('done'); + }); + + it('does not append a second entry when the background agent fails, keeping the terminal side effects', () => { + const { handler, transcriptEntries, host } = makeSubagentHandler(); + handler.handleLifecycleEvent(spawnEvent('bg-3', true)); + + handler.handleLifecycleEvent(failedEvent('bg-3', 'boom')); + + expect(transcriptEntries).toHaveLength(1); + expect(transcriptEntries[0]?.backgroundAgentStatus?.phase).toBe('started'); + expect(handler.activityStore.get('bg-3')?.status).toBe('failed'); + expect(handler.activityStore.get('bg-3')?.error).toBe('boom'); + expect(host.streamingUI.applyBackgroundTaskTerminalStatus).toHaveBeenCalledWith({ + agentId: 'bg-3', + description: 'task bg-3', + status: 'failed', + errorText: 'boom', + }); + }); + + it('appends a terminal entry when no live started entry exists for a resumed agent', () => { + const { handler, transcriptEntries } = makeSubagentHandler(); + handler.backgroundAgentMetadata.set('bg-4', { + agentId: 'bg-4', + parentToolCallId: 'task-bg-4', + description: 'resumed task', + }); + + handler.handleLifecycleEvent(completedEvent('bg-4')); + + expect(transcriptEntries).toHaveLength(1); + expect(transcriptEntries[0]?.backgroundAgentStatus?.phase).toBe('completed'); + }); +}); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index c486fe5e2ae..0228921b760 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -6594,7 +6594,15 @@ command = "vim" sendQueued, ); - expect(stripSgr(renderTranscript(driver))).toContain('k2-cheap'); + const entry = driver.state.transcriptEntries.find( + (candidate) => candidate.backgroundAgentStatus?.agentId === 'agent-1', + ); + expect(entry?.backgroundAgentStatus?.detail).toContain('k2-cheap'); + // The live line shows progress instead of the spawn detail; the model stays + // on the entry for the static and terminal fallbacks. + expect(stripSgr(renderTranscript(driver))).toContain( + 'explore agent running in background (0s)', + ); }); it('does not let later transcript entries reduce the AgentSwarm grid height', async () => { diff --git a/apps/kimi-code/test/tui/tasks-browser.test.ts b/apps/kimi-code/test/tui/tasks-browser.test.ts index d173af25ff6..487a30949eb 100644 --- a/apps/kimi-code/test/tui/tasks-browser.test.ts +++ b/apps/kimi-code/test/tui/tasks-browser.test.ts @@ -1,6 +1,8 @@ import type { Terminal } from '@moonshot-ai/pi-tui'; import type { BackgroundTaskInfo, BackgroundTaskStatus, Event } from '@moonshot-ai/kimi-code-sdk'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { BRAILLE_SPINNER_FRAMES, BRAILLE_SPINNER_INTERVAL_MS } from '#/tui/constant/rendering'; import { TasksBrowserApp, @@ -87,6 +89,16 @@ function makeApp( return new TasksBrowserApp(makeProps(props), fakeTerminal(rows, columns)); } +/** + * The rendered body line that carries `taskId`. The list row shares that line + * with the first rows of the right-hand detail pane, so the returned text + * carries both. + */ +function rowFor(props: Partial, taskId: string): string { + const lines = makeApp(props).render(120).map(strip); + return lines.find((line) => line.includes(taskId)) ?? ''; +} + describe('TasksBrowserApp — full-screen rendering', () => { it('fills exactly terminal.rows lines (height takeover)', () => { const rows = 30; @@ -342,6 +354,106 @@ describe('TasksBrowserApp — full-screen rendering', () => { }); }); +describe('TasksBrowserApp — running row live indicator', () => { + // Fixed epoch: 1_700_000_000_000 / BRAILLE_SPINNER_INTERVAL_MS lands exactly + // on frame index 0, so every expectation below is deterministic. + const NOW_MS = 1_700_000_000_000; + + const frameAt = (now: number): string => + BRAILLE_SPINNER_FRAMES[ + Math.floor(now / BRAILLE_SPINNER_INTERVAL_MS) % BRAILLE_SPINNER_FRAMES.length + ] ?? ''; + + function runningRow(startedAt: number): string { + return rowFor( + { + tasks: [task({ taskId: 'bash-aaaaaaaa', status: 'running', startedAt })], + selectedTaskId: 'bash-aaaaaaaa', + }, + 'bash-aaaaaaaa', + ); + } + + afterEach(() => { + vi.useRealTimers(); + }); + + it('renders a spinner frame and mm:ss elapsed on a running row', () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW_MS); + + const row = runningRow(NOW_MS - 5_000); + + expect(row).toContain(`${frameAt(NOW_MS)} running 00:05`); + }); + + it('counts the elapsed clock and the spinner frame up on a later render', () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW_MS); + const app = makeApp({ + tasks: [task({ taskId: 'bash-aaaaaaaa', status: 'running', startedAt: NOW_MS - 5_000 })], + selectedTaskId: 'bash-aaaaaaaa', + }); + const before = strip(app.render(120).join('\n')); + + vi.setSystemTime(NOW_MS + 1_000); + const after = strip(app.render(120).join('\n')); + + expect(before).toContain(`${frameAt(NOW_MS)} running 00:05`); + expect(after).toContain(`${frameAt(NOW_MS + 1_000)} running 00:06`); + expect(frameAt(NOW_MS)).not.toBe(frameAt(NOW_MS + 1_000)); + }); + + it.each([ + { elapsedMs: 0, clock: '00:00' }, + { elapsedMs: 999, clock: '00:00' }, + { elapsedMs: 1_000, clock: '00:01' }, + { elapsedMs: 59_999, clock: '00:59' }, + { elapsedMs: 60_000, clock: '01:00' }, + { elapsedMs: 3_599_999, clock: '59:59' }, + { elapsedMs: 3_600_000, clock: '60:00' }, + ])('renders $elapsedMs ms of elapsed time as $clock', ({ elapsedMs, clock }) => { + vi.useFakeTimers(); + vi.setSystemTime(NOW_MS); + + const row = runningRow(NOW_MS - elapsedMs); + + expect(row).toContain(`running ${clock}`); + }); + + it('clamps a start time in the future to 00:00', () => { + vi.useFakeTimers(); + vi.setSystemTime(NOW_MS); + + const row = runningRow(NOW_MS + 5_000); + + expect(row).toContain(`${frameAt(NOW_MS)} running 00:00`); + }); + + it.each([ + ['completed', 'completed'], + ['failed', 'failed'], + ['timed_out', 'timed out'], + ['killed', 'killed'], + ['lost', 'lost'], + ] as const)('leaves a %s row showing the plain status word', (status, label) => { + vi.useFakeTimers(); + vi.setSystemTime(NOW_MS); + + const row = rowFor( + { + tasks: [task({ taskId: 'bash-aaaaaaaa', status, endedAt: NOW_MS - 1_000 })], + selectedTaskId: 'bash-aaaaaaaa', + }, + 'bash-aaaaaaaa', + ); + + expect(row).toContain(label); + expect(row).not.toContain('⠋'); + expect(row).not.toMatch(/\d{2}:\d{2}/); + }); +}); + describe('TasksBrowserApp — input handling', () => { it('Esc invokes onCancel', () => { const onCancel = vi.fn();