diff --git a/.changeset/foreground-task-detach.md b/.changeset/foreground-task-detach.md index e827fadea..1aae5f103 100644 --- a/.changeset/foreground-task-detach.md +++ b/.changeset/foreground-task-detach.md @@ -3,4 +3,4 @@ "@moonshot-ai/kimi-code": patch --- -Allow foreground shell and subagent tasks to be detached into background tasks. +Allow long-running foreground commands and subagents to be moved into background tasks with Ctrl+B, and inspect them via the `/tasks` panel. diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index 127009506..0d17dad63 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -55,6 +55,7 @@ const TOOLBAR_TIPS: readonly ToolbarTip[] = [ { text: 'shift+tab: plan mode' }, { text: '/model: switch model' }, { text: 'ctrl+s: steer mid-turn', priority: 2 }, + { text: 'ctrl+b: background task', priority: 2 }, { text: '/compact: compact context', priority: 2 }, { text: 'ctrl+o: expand tool output' }, { text: '/tasks: background tasks' }, @@ -264,6 +265,10 @@ export class FooterComponent implements Component { this.transientHint = hint; } + getTransientHint(): string | null { + return this.transientHint; + } + /** * Sync both background-task badges with live counts. Each non-zero * count produces its own bracketed badge on line 1; zeros hide them 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 7863c493c..0a47a2f83 100644 --- a/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts +++ b/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts @@ -130,8 +130,13 @@ function visibleTasks( tasks: readonly BackgroundTaskInfo[], filter: TasksFilter, ): BackgroundTaskInfo[] { - if (filter === 'all') return [...tasks]; - return tasks.filter((t) => !isTerminal(t.status)); + // The /tasks panel is for background task management. Foreground tasks + // (detached === false) are shown in the main transcript instead, and only + // appear here after being detached via Ctrl+B. `detached !== false` keeps + // reconcile ghosts whose `detached` field may be undefined. + const backgroundOnly = tasks.filter((t) => t.detached !== false); + if (filter === 'all') return [...backgroundOnly]; + return backgroundOnly.filter((t) => !isTerminal(t.status)); } function compareTasks(a: BackgroundTaskInfo, b: BackgroundTaskInfo): number { @@ -333,7 +338,11 @@ export class TasksBrowserApp extends Container implements Focusable { 'textMuted', ` filter=${this.props.filter === 'all' ? 'ALL' : 'ACTIVE'} `, ); - const counts = countByStatus(this.props.tasks); + // Count only the tasks actually listed (background tasks after the + // foreground-task filter), so a foreground-only session doesn't read + // "1 running / 1 total" above an empty list. + const visible = visibleTasks(this.props.tasks, this.props.filter); + const counts = countByStatus(visible); const countSegments: string[] = []; if (counts.running > 0) countSegments.push(currentTheme.fg('success', ` ${String(counts.running)} running `)); @@ -343,7 +352,7 @@ export class TasksBrowserApp extends Container implements Focusable { countSegments.push( currentTheme.fg('error', ` ${String(counts.terminalFailed)} interrupted `), ); - const totals = currentTheme.fg('textMuted', ` ${String(this.props.tasks.length)} total `); + const totals = currentTheme.fg('textMuted', ` ${String(visible.length)} total `); const composed = title + filterText + countSegments.join('') + totals; return fitExactly(composed, width); diff --git a/apps/kimi-code/src/tui/components/editor/custom-editor.ts b/apps/kimi-code/src/tui/components/editor/custom-editor.ts index 88a527862..3ef7af6da 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -119,6 +119,8 @@ export class CustomEditor extends Editor { public onToggleToolExpand?: () => void; public onOpenExternalEditor?: () => void; public onCtrlS?: () => void; + /** Return `true` to consume Ctrl+B; return `false`/`undefined` to fall through to the editor default (cursor-left). */ + public onCtrlB?: () => boolean; public onUndo?: () => void; public onInsertNewline?: () => void; public onTextPaste?: () => void; @@ -349,6 +351,13 @@ export class CustomEditor extends Editor { return; } + if (matchesKey(normalized, Key.ctrl('b'))) { + // Only consume the key when the handler actually detached something; + // otherwise fall through so readline's backward-char still works at the + // idle prompt. + if (this.onCtrlB?.() === true) return; + } + if (matchesKey(normalized, 'shift+tab')) { this.onShiftTab?.(); return; diff --git a/apps/kimi-code/src/tui/components/messages/agent-group.ts b/apps/kimi-code/src/tui/components/messages/agent-group.ts index ec8d32d2f..71c53332e 100644 --- a/apps/kimi-code/src/tui/components/messages/agent-group.ts +++ b/apps/kimi-code/src/tui/components/messages/agent-group.ts @@ -25,6 +25,8 @@ import type { ToolCallComponent, ToolCallSubagentSnapshot } from './tool-call'; const THROTTLE_MS = 200; +const DETACH_HINT_TEXT = 'Press Ctrl+B to run in background'; + interface AgentEntry { readonly toolCallId: string; readonly tc: ToolCallComponent; @@ -131,6 +133,9 @@ export class AgentGroupComponent extends Container { const isLast = idx === snapshots.length - 1; this.appendLines(snap, isLast); }); + if (this.shouldShowDetachHint(snapshots)) { + this.bodyContainer.addChild(new Text(currentTheme.dim(DETACH_HINT_TEXT), 2, 0)); + } this.lastFlushPhases.clear(); this.entries.forEach((entry, i) => { @@ -203,6 +208,21 @@ export class AgentGroupComponent extends Container { this.bodyContainer.addChild(new Text(` ${branch2} ${dim(activity)}`, 0, 0)); } + /** + * Show the Ctrl+B hint while at least one agent in the group is still + * running in the foreground (i.e. can be detached). Hide it once every + * agent is done, failed, or already backgrounded. + */ + private shouldShowDetachHint(snapshots: readonly ToolCallSubagentSnapshot[]): boolean { + return snapshots.some( + (s) => + s.phase === 'running' || + s.phase === 'queued' || + s.phase === 'spawning' || + s.phase === undefined, + ); + } + /** Releases throttle timers so destroyed components cannot refresh later. */ override invalidate(): void { if (this._invalidating) { 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 4bd2c61d0..9ddee072a 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -46,6 +46,10 @@ const PROGRESS_URL_RE = /https?:\/\/\S+/g; const ABORTED_MARK = '⊘'; const MAX_LIVE_OUTPUT_CHARS = 50_000; +/** Delay before a long-running foreground Bash/Agent card advertises Ctrl+B. */ +const DETACH_HINT_DELAY_MS = 10_000; +const DETACH_HINT_TEXT = 'Press Ctrl+B to run in background'; + type SubagentTextKind = 'thinking' | 'text'; type SubagentPhase = 'queued' | 'spawning' | 'running' | 'done' | 'failed' | 'backgrounded'; @@ -533,6 +537,14 @@ export class ToolCallComponent extends Container { private subagentThinkingText = ''; // ── Subagent lifecycle state from subagent.spawned/started/completed/failed ── private subagentPhase: SubagentPhase | undefined; + /** + * Distinguishes a foreground subagent that the user detached via Ctrl+B from + * one that started in the background. Both set `subagentPhase = 'backgrounded'`, + * but only the detached one should keep showing `◐ backgrounded` after its + * spawn-success ToolResult lands — a started-in-background agent reads as + * `done` once its result arrives. + */ + private detachedFromForeground = false; /** * Authoritative terminal phase for a backgrounded subagent. Set from * `BackgroundTaskInfo.status` via `setBackgroundTaskTerminalStatus` once @@ -565,6 +577,13 @@ export class ToolCallComponent extends Container { private static readonly MAX_PROGRESS_LINES = 24; private liveOutput = ''; + /** + * Advertises `Ctrl+B` on a foreground Bash/Agent card that has been running + * for {@link DETACH_HINT_DELAY_MS}. Cleared when the result lands. + */ + private detachHintTimer: ReturnType | undefined; + private detachHintVisible = false; + /** * Registered by a group container (`AgentGroupComponent` or * `ReadGroupComponent`) when this component is borrowed as a hidden state @@ -598,6 +617,7 @@ export class ToolCallComponent extends Container { this.buildSubagentBlock(); this.syncStreamingProgressTimer(); this.syncSubagentElapsedTimer(); + this.startDetachHintTimer(); } override invalidate(): void { @@ -624,6 +644,8 @@ export class ToolCallComponent extends Container { // show both the streamed status lines and the final output stacked. this.progressLines = []; this.liveOutput = ''; + this.detachHintVisible = false; + this.stopDetachHintTimer(); this.finalizeSubagentElapsedIfNeeded(); this.syncStreamingProgressTimer(); this.syncSubagentElapsedTimer(); @@ -682,6 +704,7 @@ export class ToolCallComponent extends Container { dispose(): void { this.stopStreamingProgressTimer(); this.stopSubagentElapsedTimer(); + this.stopDetachHintTimer(); } /** @@ -783,14 +806,11 @@ export class ToolCallComponent extends Container { // 'spawning' and keep showing `Initializing...`. // Intermediate states without a result still use `subagentPhase`. // `backgrounded` has no result because background agents do not enter the - // transcript. - const derivedPhase: ToolCallSubagentSnapshot['phase'] = - this.backgroundTaskTerminalPhase ?? - (this.result !== undefined - ? this.result.is_error - ? 'failed' - : 'done' - : this.subagentPhase); + // transcript — but a foreground subagent detached via Ctrl+B keeps + // `subagentPhase === 'backgrounded'` even after its ToolResult lands, so + // the group card shows `◐ backgrounded` rather than `✓ Completed`. Reuse + // the standalone derivation so both paths agree. + const derivedPhase = this.getDerivedSubagentPhase(); const errorText = this.subagentError ?? (derivedPhase === 'failed' ? this.result?.output : undefined); return { @@ -904,6 +924,46 @@ export class ToolCallComponent extends Container { this.streamingProgressTimer = undefined; } + /** Only foreground Bash/Agent calls can be detached via Ctrl+B. */ + private isDetachHintEligible(): boolean { + return this.toolCall.name === 'Bash' || this.toolCall.name === 'Agent'; + } + + private startDetachHintTimer(): void { + if (!this.isDetachHintEligible()) return; + if (this.result !== undefined) return; + if (this.ui === undefined) return; + if (this.toolCall.name === 'Agent') { + // Subagents are long-running by nature; advertise Ctrl+B immediately + // instead of waiting out the delay used for short Bash commands. + if (this.detachHintVisible) return; + this.detachHintVisible = true; + this.rebuildBody(); + this.ui?.requestRender(); + return; + } + if (this.detachHintTimer !== undefined) return; + this.detachHintTimer = setTimeout(() => { + this.detachHintTimer = undefined; + if (this.result !== undefined) return; + this.detachHintVisible = true; + this.rebuildBody(); + this.ui?.requestRender(); + }, DETACH_HINT_DELAY_MS); + } + + private stopDetachHintTimer(): void { + if (this.detachHintTimer === undefined) return; + clearTimeout(this.detachHintTimer); + this.detachHintTimer = undefined; + } + + private buildDetachHintBlock(): void { + if (!this.detachHintVisible) return; + if (this.result !== undefined) return; + this.addChild(new Text(currentTheme.dim(DETACH_HINT_TEXT), 2, 0)); + } + private syncSubagentElapsedTimer(): void { const phase = this.getDerivedSubagentPhase(); const shouldTick = @@ -1091,6 +1151,22 @@ export class ToolCallComponent extends Container { this.notifySnapshotChange(); } + /** + * Mark a foreground subagent as detached-to-background. Called when a + * `background.task.started` event arrives for this agent (i.e. the user + * pressed Ctrl+B). Keeps the card showing `◐ backgrounded` instead of + * flipping to `✓ Completed` when the spawn-success ToolResult lands. + */ + markBackgrounded(): void { + if (this.detachedFromForeground) return; + this.detachedFromForeground = true; + this.subagentPhase = 'backgrounded'; + this.headerText.setText(this.buildHeader()); + this.rebuildContent(); + this.notifySnapshotChange(); + this.ui?.requestRender(); + } + /** * Subagent id for the backing AgentTool call, used by routing to find a * tool call's backing subagent when reconciling background task lifecycle @@ -1340,6 +1416,7 @@ export class ToolCallComponent extends Container { this.children.pop(); } this.buildProgressBlock(); + this.buildDetachHintBlock(); this.buildLiveOutputBlock(); this.buildContent(); this.buildSubagentBlock(); @@ -1352,6 +1429,7 @@ export class ToolCallComponent extends Container { this.buildCallPreview(); this.callPreviewEndIndex = this.children.length; this.buildProgressBlock(); + this.buildDetachHintBlock(); this.buildLiveOutputBlock(); this.buildContent(); this.buildSubagentBlock(); @@ -1550,6 +1628,14 @@ export class ToolCallComponent extends Container { if (this.backgroundTaskTerminalPhase !== undefined) { return this.backgroundTaskTerminalPhase; } + // A foreground subagent detached via Ctrl+B keeps showing `backgrounded` + // even after its spawn-success ToolResult lands, so the card doesn't flip + // to `✓ Completed` and look like the work actually finished. Agents that + // started in the background (`detachedFromForeground === false`) read as + // `done` once their result lands. + if (this.detachedFromForeground && this.subagentPhase === 'backgrounded') { + return 'backgrounded'; + } if (this.result !== undefined) return this.result.is_error ? 'failed' : 'done'; return this.subagentPhase; } diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index 8038be32d..d27e858e1 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -31,6 +31,7 @@ export interface EditorKeyboardHost { updateEditorBorderHighlight(text?: string): void; updateQueueDisplay(): void; toggleToolOutputExpansion(): void; + detachCurrentForegroundTask(): void; hideSessionPicker(): void; stop(exitCode?: number): Promise; handlePlanToggle(next: boolean): void; @@ -181,6 +182,15 @@ export class EditorKeyboardController { host.state.ui.requestRender(); }; + editor.onCtrlB = (): boolean => { + if (host.state.appState.streamingPhase === 'idle' || host.state.appState.isCompacting) { + return false; + } + host.track('shortcut_background_task'); + host.detachCurrentForegroundTask(); + return true; + }; + editor.onUndo = () => { host.track('undo'); }; diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index c6c8036f0..075e87bca 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -998,6 +998,9 @@ export class SessionEventHandler { if (event.type === 'background.task.started') { if (info.kind === 'agent') { + // A foreground subagent detached via Ctrl+B: flip its card to + // `◐ backgrounded` so it doesn't look like it completed. + this.host.streamingUI.markSubagentBackgrounded(info.agentId); this.syncBackgroundTaskBadge(); this.host.tasksBrowserController.repaint(); return; diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index beb1b4e16..35aa951ca 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -265,6 +265,41 @@ export class StreamingUIController { return true; } + /** + * Mark a foreground subagent card as detached-to-background (`◐ backgrounded`). + * Routed from a `background.task.started` event whose `info.kind === 'agent'`, + * keyed by `agentId`. Returns true iff a matching component was found. + * + * Gated to cards that are currently foreground-running: `background.task.started` + * also fires for `Agent(run_in_background=true)` launches and for background + * resumes, and those must not mutate older completed rows that happen to share + * the same `agentId` (a resume's new card has no parsed `agent_id` yet, so the + * search can otherwise hit the previous completed card). + */ + markSubagentBackgrounded(agentId: string | undefined): boolean { + if (agentId === undefined) return false; + const visit = (tc: ToolCallComponent): boolean => { + if (tc.getSubagentAgentId() !== agentId) return false; + const phase = tc.getSubagentSnapshot().phase; + if (phase !== 'running' && phase !== 'queued' && phase !== 'spawning') return false; + tc.markBackgrounded(); + return true; + }; + for (const tc of this._pendingToolComponents.values()) { + if (visit(tc)) return true; + } + for (const child of this.host.state.transcriptContainer.children) { + if (child instanceof ToolCallComponent) { + if (visit(child)) return true; + } else if (child instanceof AgentGroupComponent) { + for (const tc of child.getToolComponents()) { + if (visit(tc)) return true; + } + } + } + return false; + } + /** Registers a tool call that arrived via tool.call.started. * Clears any pending streaming state for this id, updates or creates the * component, and returns whether the call was new (no previous entry). */ diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index f5aa62d31..c7f3951ec 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -123,6 +123,7 @@ import { import { isExpandable } from './utils/component-capabilities'; import { isDeadTerminalError } from './utils/dead-terminal'; import { formatErrorMessage } from './utils/event-payload'; +import { pickForegroundTasks } from './utils/foreground-task'; import { ImageAttachmentStore, type ImageAttachment } from './utils/image-attachment-store'; import { extractMediaAttachments } from './utils/image-placeholder'; import { hasPatchChanges } from './utils/object-patch'; @@ -208,6 +209,9 @@ interface SendMessageOptions { readonly hasMedia?: boolean; } +/** How long the one-shot "moved to background" footer hint stays visible. */ +const DETACH_HINT_DISPLAY_MS = 4_000; + export class KimiTUI { readonly harness: KimiHarness; readonly options: KimiTUIOptions; @@ -243,6 +247,9 @@ export class KimiTUI { readonly tasksBrowserController: TasksBrowserController; readonly editorKeyboard: EditorKeyboardController; + /** Timer that auto-clears the one-shot "moved to background" footer hint. */ + private detachHintClearTimer: ReturnType | undefined; + // The currently-mounted approval panel, if any. Kept so the full-screen // preview viewer can restore focus to the exact same instance (and its // selection / feedback state) when it closes. @@ -1758,6 +1765,71 @@ export class KimiTUI { this.state.ui.requestRender(); } + async detachCurrentForegroundTask(): Promise { + const session = this.session; + if (session === undefined) { + this.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + let tasks: readonly BackgroundTaskInfo[]; + try { + // activeOnly defaults to true; foreground running tasks are non-terminal + // and therefore included. We filter to `detached === false` ourselves. + tasks = await session.listBackgroundTasks(); + } catch (error) { + this.showError(`Failed to list tasks: ${formatErrorMessage(error)}`); + return; + } + + const targets = pickForegroundTasks(tasks); + if (targets.length === 0) { + this.showDetachHint('No foreground task running.'); + return; + } + + let detached = 0; + let alreadyFinished = 0; + for (const target of targets) { + try { + const info = await session.detachBackgroundTask(target.taskId); + if (info === undefined) alreadyFinished++; + else detached++; + } catch (error) { + this.showError(`Failed to detach ${target.taskId}: ${formatErrorMessage(error)}`); + } + } + + let hint: string; + if (detached === 0 && alreadyFinished > 0) { + hint = alreadyFinished === 1 ? 'Task already finished.' : 'Tasks already finished.'; + } else if (detached === targets.length) { + hint = detached === 1 ? 'Moved 1 task to background.' : `Moved ${detached} tasks to background.`; + } else { + hint = `Moved ${detached} of ${targets.length} tasks to background.`; + } + if (detached > 0) hint = `${hint} /tasks to view.`; + this.showDetachHint(hint); + } + + /** Show a one-shot footer hint that auto-clears after DETACH_HINT_DISPLAY_MS. */ + private showDetachHint(hint: string): void { + if (this.detachHintClearTimer !== undefined) { + clearTimeout(this.detachHintClearTimer); + this.detachHintClearTimer = undefined; + } + this.state.footer.setTransientHint(hint); + this.detachHintClearTimer = setTimeout(() => { + this.detachHintClearTimer = undefined; + // Don't clobber a newer transient hint (e.g. the exit-confirmation + // prompt) that took over while this timer was pending. + if (this.state.footer.getTransientHint() !== hint) return; + this.state.footer.setTransientHint(null); + this.state.ui.requestRender(); + }, DETACH_HINT_DISPLAY_MS); + this.state.ui.requestRender(); + } + updateEditorBorderHighlight(text?: string): void { const trimmed = (text ?? this.state.editor.getText()).trimStart(); const highlighted = this.state.appState.planMode || trimmed.startsWith('/'); diff --git a/apps/kimi-code/src/tui/utils/foreground-task.ts b/apps/kimi-code/src/tui/utils/foreground-task.ts new file mode 100644 index 000000000..ba2ac811b --- /dev/null +++ b/apps/kimi-code/src/tui/utils/foreground-task.ts @@ -0,0 +1,32 @@ +import type { BackgroundTaskInfo } from '@moonshot-ai/kimi-code-sdk'; + +function isDetachableForegroundTask(t: BackgroundTaskInfo): boolean { + return ( + t.detached === false && + t.status === 'running' && + (t.kind === 'process' || t.kind === 'agent') + ); +} + +/** + * Pick all foreground tasks that `Ctrl+B` should detach: `detached === false`, + * currently-running Bash (`process`) or subagent (`agent`) tasks, most recently + * started first. + */ +export function pickForegroundTasks( + tasks: readonly BackgroundTaskInfo[], +): BackgroundTaskInfo[] { + return tasks + .filter(isDetachableForegroundTask) + .sort((a, b) => b.startedAt - a.startedAt); +} + +/** + * Pick the single most recently started foreground task. Kept for callers that + * only need one; `Ctrl+B` uses {@link pickForegroundTasks} to detach them all. + */ +export function pickForegroundTask( + tasks: readonly BackgroundTaskInfo[], +): BackgroundTaskInfo | undefined { + return pickForegroundTasks(tasks)[0]; +} diff --git a/apps/kimi-code/test/tui/components/messages/agent-group.test.ts b/apps/kimi-code/test/tui/components/messages/agent-group.test.ts index 5018275cc..fc0e0ba30 100644 --- a/apps/kimi-code/test/tui/components/messages/agent-group.test.ts +++ b/apps/kimi-code/test/tui/components/messages/agent-group.test.ts @@ -91,6 +91,31 @@ describe('AgentGroupComponent', () => { waiting.dispose(); }); + it('shows the Ctrl+B hint while agents are running and hides it once all are backgrounded', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const ui = stubTui(); + const group = new AgentGroupComponent(ui); + const a = createAgent('call_agent_1', 'inspect project', 'explore', ui); + const b = createAgent('call_agent_2', 'write tests', 'coder', ui); + startAgent(a, 'call_agent_1', 'explore'); + startAgent(b, 'call_agent_2', 'coder'); + group.attach('call_agent_1', a); + group.attach('call_agent_2', b); + + expect(renderText(group)).toContain('Press Ctrl+B to run in background'); + + a.markBackgrounded(); + expect(renderText(group)).toContain('Press Ctrl+B to run in background'); + + b.markBackgrounded(); + expect(renderText(group)).not.toContain('Press Ctrl+B to run in background'); + + group.dispose(); + a.dispose(); + b.dispose(); + }); + it('uses still-working fallback for running agents without recent activity', () => { vi.useFakeTimers(); vi.setSystemTime(0); @@ -173,4 +198,36 @@ describe('AgentGroupComponent', () => { done.dispose(); running.dispose(); }); + + it('renders a detached foreground subagent as backgrounded in the group, even after its ToolResult lands', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const ui = stubTui(); + const group = new AgentGroupComponent(ui); + const a = createAgent('call_agent_1', 'inspect project', 'explore', ui); + const b = createAgent('call_agent_2', 'write tests', 'coder', ui); + startAgent(a, 'call_agent_1', 'explore'); + startAgent(b, 'call_agent_2', 'coder'); + group.attach('call_agent_1', a); + group.attach('call_agent_2', b); + + // Detach `a` (Ctrl+B), then its spawn-success ToolResult lands. + a.markBackgrounded(); + a.setResult({ + tool_call_id: 'call_agent_1', + output: 'agent_id: sub_call_agent_1\nactual_subagent_type: explore\n', + is_error: false, + }); + + const out = renderText(group); + // `a` must show as backgrounded, NOT completed. + expect(out).toContain('◐ backgrounded'); + expect(out).not.toContain('✓ Completed'); + // `b` is still running. + expect(out).toContain('Running'); + + group.dispose(); + a.dispose(); + b.dispose(); + }); }); 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 b3fb71e77..eb25f8379 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 @@ -49,6 +49,79 @@ describe('ToolCallComponent', () => { expect(out).not.toContain(`${String.fromCodePoint(0x23fa, 0xfe0e)} Used Read`); }); + describe('detach hint for long-running foreground Bash/Agent', () => { + it('shows the Ctrl+B hint after 10s for a running Bash call', () => { + vi.useFakeTimers(); + const component = new ToolCallComponent( + { id: 'call_bash_long', name: 'Bash', args: { command: 'sleep 30' } }, + undefined, + stubTui(30), + ); + + expect(strip(component.render(100).join('\n'))).not.toContain( + 'Press Ctrl+B to run in background', + ); + + vi.advanceTimersByTime(10_000); + expect(strip(component.render(100).join('\n'))).toContain( + 'Press Ctrl+B to run in background', + ); + + component.dispose(); + }); + + it('shows the hint immediately for a running Agent call', () => { + vi.useFakeTimers(); + const component = new ToolCallComponent( + { id: 'call_agent_long', name: 'Agent', args: { description: 'explore' } }, + undefined, + stubTui(30), + ); + + // No timer advancement — Agents advertise Ctrl+B immediately. + expect(strip(component.render(100).join('\n'))).toContain( + 'Press Ctrl+B to run in background', + ); + + component.dispose(); + }); + + it('does not show the hint for non-detachable tools', () => { + vi.useFakeTimers(); + const component = new ToolCallComponent( + { id: 'call_read_long', name: 'Read', args: { path: 'foo.ts' } }, + undefined, + stubTui(30), + ); + + vi.advanceTimersByTime(15_000); + expect(strip(component.render(100).join('\n'))).not.toContain( + 'Press Ctrl+B to run in background', + ); + + component.dispose(); + }); + + it('does not show the hint when the result lands before 10s', () => { + vi.useFakeTimers(); + const component = new ToolCallComponent( + { id: 'call_bash_short', name: 'Bash', args: { command: 'echo hi' } }, + undefined, + stubTui(30), + ); + + vi.advanceTimersByTime(5_000); + component.setResult({ tool_call_id: 'call_bash_short', output: 'hi', is_error: false }); + vi.advanceTimersByTime(10_000); + + expect(strip(component.render(100).join('\n'))).not.toContain( + 'Press Ctrl+B to run in background', + ); + + component.dispose(); + }); + }); + it('keeps collapsed tool-call lines within very narrow widths', () => { const component = new ToolCallComponent( { @@ -837,6 +910,50 @@ describe('ToolCallComponent', () => { expect(out).not.toContain('summary fallback'); }); + it('shows Backgrounded after a foreground subagent is detached, even after setResult', () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const component = new ToolCallComponent( + { + id: 'call_agent_detach', + name: 'Agent', + args: { description: 'long task' }, + }, + undefined, + stubTui(30), + ); + component.onSubagentSpawned({ + agentId: 'sub_detach_1', + agentName: 'explore', + runInBackground: false, + }); + component.onSubagentStarted({ + agentId: 'sub_detach_1', + agentName: 'explore', + runInBackground: false, + }); + + // Sanity: running before detach. + expect(strip(component.render(120).join('\n'))).toContain('Running'); + + component.markBackgrounded(); + let out = strip(component.render(120).join('\n')); + expect(out).toContain('Backgrounded'); + expect(out).not.toContain('Completed'); + + // The spawn-success ToolResult landing must NOT flip the card to Completed. + component.setResult({ + tool_call_id: 'call_agent_detach', + output: 'agent_id: sub_detach_1\nactual_subagent_type: explore\n', + is_error: false, + }); + out = strip(component.render(120).join('\n')); + expect(out).toContain('Backgrounded'); + expect(out).not.toContain('Completed'); + + component.dispose(); + }); + it('keeps the single subagent tool area to the latest four activities', () => { vi.useFakeTimers(); vi.setSystemTime(0); diff --git a/apps/kimi-code/test/tui/tasks-browser.test.ts b/apps/kimi-code/test/tui/tasks-browser.test.ts index 3f0e95fa0..11bc06287 100644 --- a/apps/kimi-code/test/tui/tasks-browser.test.ts +++ b/apps/kimi-code/test/tui/tasks-browser.test.ts @@ -218,6 +218,41 @@ describe('TasksBrowserApp — full-screen rendering', () => { expect(out).not.toContain('bash-bbbbbbbb'); }); + it('filters out foreground tasks (detached === false)', () => { + const tasks = [ + task({ taskId: 'bash-foreground', detached: false, status: 'running' }), + task({ taskId: 'bash-background', detached: true, status: 'running' }), + ]; + const out = strip(makeApp({ tasks, filter: 'all' }).render(120).join('\n')); + expect(out).not.toContain('bash-foreground'); + expect(out).toContain('bash-background'); + }); + + it('keeps background tasks with detached === true even when terminal', () => { + const tasks = [task({ taskId: 'bash-done', detached: true, status: 'completed' })]; + const out = strip(makeApp({ tasks, filter: 'all' }).render(120).join('\n')); + expect(out).toContain('bash-done'); + }); + + it('keeps ghost tasks whose detached field is undefined', () => { + // task() leaves `detached` undefined by default, mimicking reconcile ghosts. + const tasks = [task({ taskId: 'bash-ghost', status: 'lost' })]; + const out = strip(makeApp({ tasks, filter: 'all' }).render(120).join('\n')); + expect(out).toContain('bash-ghost'); + }); + + it('applies active filter after excluding foreground tasks', () => { + const tasks = [ + task({ taskId: 'bash-fg-running', detached: false, status: 'running' }), + task({ taskId: 'bash-bg-running', detached: true, status: 'running' }), + task({ taskId: 'bash-bg-done', detached: true, status: 'completed' }), + ]; + const out = strip(makeApp({ tasks, filter: 'active' }).render(120).join('\n')); + expect(out).not.toContain('bash-fg-running'); + expect(out).toContain('bash-bg-running'); + expect(out).not.toContain('bash-bg-done'); + }); + it('renders without throwing for every BackgroundTaskStatus', () => { const statuses: BackgroundTaskStatus[] = [ 'running', diff --git a/apps/kimi-code/test/tui/utils/foreground-task.test.ts b/apps/kimi-code/test/tui/utils/foreground-task.test.ts new file mode 100644 index 000000000..c7d0e2079 --- /dev/null +++ b/apps/kimi-code/test/tui/utils/foreground-task.test.ts @@ -0,0 +1,92 @@ +import type { BackgroundTaskInfo } from '@moonshot-ai/kimi-code-sdk'; +import { describe, expect, it } from 'vitest'; + +import { pickForegroundTask, pickForegroundTasks } from '@/tui/utils/foreground-task'; + +function task(overrides: Partial = {}): BackgroundTaskInfo { + return { + taskId: 'bash-aaaaaaaa', + kind: 'process', + command: 'sleep 10', + description: 'Bash: sleep 10', + status: 'running', + detached: false, + pid: 1234, + exitCode: null, + startedAt: 1000, + endedAt: null, + ...overrides, + } as BackgroundTaskInfo; +} + +describe('pickForegroundTask', () => { + it('returns undefined for an empty list', () => { + expect(pickForegroundTask([])).toBeUndefined(); + }); + + it('returns undefined when all tasks are detached (already background)', () => { + expect(pickForegroundTask([task({ detached: true })])).toBeUndefined(); + }); + + it('returns undefined when foreground tasks are not running', () => { + expect(pickForegroundTask([task({ status: 'completed' })])).toBeUndefined(); + expect(pickForegroundTask([task({ status: 'killed' })])).toBeUndefined(); + }); + + it('excludes question tasks', () => { + const question = task({ + kind: 'question', + questionCount: 1, + } as Partial); + expect(pickForegroundTask([question])).toBeUndefined(); + }); + + it('returns the most recently started foreground running task', () => { + const older = task({ taskId: 'bash-old', startedAt: 1000 }); + const newer = task({ taskId: 'bash-new', startedAt: 2000 }); + expect(pickForegroundTask([older, newer])?.taskId).toBe('bash-new'); + }); + + it('ignores detached running tasks even if newer', () => { + const fg = task({ taskId: 'bash-fg', detached: false, startedAt: 1000 }); + const bg = task({ taskId: 'bash-bg', detached: true, startedAt: 9999 }); + expect(pickForegroundTask([bg, fg])?.taskId).toBe('bash-fg'); + }); + + it('accepts agent (subagent) foreground tasks', () => { + const agent = task({ + taskId: 'agent-aaaaaaaa', + kind: 'agent', + agentId: 'child-1', + subagentType: 'coder', + } as Partial); + expect(pickForegroundTask([agent])?.taskId).toBe('agent-aaaaaaaa'); + }); +}); + +describe('pickForegroundTasks', () => { + it('returns all foreground running tasks, most recently started first', () => { + const a = task({ taskId: 'bash-a', startedAt: 1000 }); + const b = task({ taskId: 'agent-b', kind: 'agent', startedAt: 3000 }); + const c = task({ taskId: 'bash-c', startedAt: 2000 }); + expect(pickForegroundTasks([a, b, c]).map((t) => t.taskId)).toEqual([ + 'agent-b', + 'bash-c', + 'bash-a', + ]); + }); + + it('excludes detached, terminal, and question tasks', () => { + const fg = task({ taskId: 'bash-fg' }); + const detached = task({ taskId: 'bash-bg', detached: true }); + const done = task({ taskId: 'bash-done', status: 'completed' }); + const question = task({ taskId: 'q', kind: 'question' } as Partial); + expect(pickForegroundTasks([fg, detached, done, question]).map((t) => t.taskId)).toEqual([ + 'bash-fg', + ]); + }); + + it('returns an empty array when nothing matches', () => { + expect(pickForegroundTasks([task({ detached: true })])).toEqual([]); + }); +}); diff --git a/packages/agent-core/src/tools/builtin/shell/bash.ts b/packages/agent-core/src/tools/builtin/shell/bash.ts index 57ad8c6b9..3e124c2b5 100644 --- a/packages/agent-core/src/tools/builtin/shell/bash.ts +++ b/packages/agent-core/src/tools/builtin/shell/bash.ts @@ -295,6 +295,7 @@ export class BashTool implements BuiltinTool { brief: `Backgrounded ${taskId}`, }, builder, + 'foreground_detached', ); } @@ -370,6 +371,7 @@ export class BashTool implements BuiltinTool { description: string, labels: { title: string; brief: string }, builder = new ToolResultBuilder(), + scenario: 'background_started' | 'foreground_detached' = 'background_started', ): ExecutableToolResult { const status = this.backgroundManager.getTask(taskId)?.status ?? 'running'; const metadata = @@ -378,11 +380,7 @@ export class BashTool implements BuiltinTool { `description: ${description}\n` + `status: ${status}\n` + `automatic_notification: true\n` + - 'next_step: You will be automatically notified when it completes.\n' + - (this.allowBackground - ? 'next_step: Use TaskOutput with this task_id for a non-blocking status/output snapshot.\n' + - 'next_step: Use TaskStop only if the task must be cancelled.\n' - : '') + + this.nextStepLines(taskId, scenario) + 'human_shell_hint: Tell the human to run /tasks to open the interactive background-task panel.'; const foregroundResult = builder.ok(''); @@ -404,6 +402,31 @@ export class BashTool implements BuiltinTool { }; return result; } + + private nextStepLines( + taskId: string, + scenario: 'background_started' | 'foreground_detached', + ): string { + if (scenario === 'foreground_detached') { + // The user explicitly moved a foreground call to the background to avoid + // blocking the current turn. Steer the model away from waiting on it. + // Only mention TaskOutput when the tool is actually available. + const avoid = this.allowBackground ? 'do NOT wait, poll, or call TaskOutput on it' : 'do NOT wait or poll'; + return ( + 'next_step: The task now runs in the background. You will be automatically notified ' + + `when it completes — ${avoid}; continue with your current work.\n` + ); + } + // background_started: the model chose to launch in the background. + if (!this.allowBackground) { + return 'next_step: You will be automatically notified when it completes.\n'; + } + return ( + 'next_step: The completion arrives automatically in a later turn — no polling needed. ' + + `To peek at progress without blocking, call TaskOutput(task_id="${taskId}", block=false).\n` + + 'next_step: Use TaskStop only if the task must be cancelled.\n' + ); + } } function backgroundResultMessage(title: string, suffix: string): string { diff --git a/packages/agent-core/test/tools/bash.test.ts b/packages/agent-core/test/tools/bash.test.ts index 8b0ce68a7..32e0a306a 100644 --- a/packages/agent-core/test/tools/bash.test.ts +++ b/packages/agent-core/test/tools/bash.test.ts @@ -603,6 +603,9 @@ describe('BashTool', () => { expect(result.output).not.toContain('after detach\n'); expect(result.output).toContain(`task_id: ${task.taskId}`); expect(result.output).toContain('automatic_notification: true'); + // Detach must steer the model away from blocking on the task it just + // backgrounded — otherwise the whole point of detaching is defeated. + expect(result.output).toContain('do NOT wait, poll, or call TaskOutput'); expect(manager.getTask(task.taskId)).toMatchObject({ detached: true }); await vi.waitFor(async () => { await expect(manager.readOutput(task.taskId)).resolves.toContain('after detach\n'); @@ -637,7 +640,8 @@ describe('BashTool', () => { const result = await running; expect(result.output).toContain(`task_id: ${task.taskId}`); - expect(result.output).toContain('next_step: You will be automatically notified'); + expect(result.output).toContain('You will be automatically notified when it completes'); + expect(result.output).toContain('do NOT wait or poll'); expect(result.output).not.toContain('TaskOutput'); expect(result.output).not.toContain('TaskStop');