feat(tui): detach foreground tasks to background with Ctrl+B (#976)

* feat(tui): detach foreground tasks to background with Ctrl+B

- Add Ctrl+B shortcut to detach all foreground Bash/subagent tasks at once

- Show "Press Ctrl+B to run in background" hint in tool cards (Agent immediately, Bash after 10s) and in the agent group panel

- Mark detached foreground subagents as ◐ backgrounded instead of ✓ Completed

- Filter foreground tasks out of the /tasks panel (they appear after detach)

- Steer the model away from blocking on TaskOutput after a detach

* fix(tui): address Codex review on Ctrl+B detach

- Preserve `◐ backgrounded` for detached subagents inside AgentGroupComponent by reusing getDerivedSubagentPhase in getSubagentSnapshot

- Distinguish detached-from-foreground subagents from started-in-background ones so the latter still read as `done`

- Let Ctrl+B fall through to readline backward-char at the idle prompt instead of always consuming the key

* fix(tui): address follow-up Codex review on detach hints

- Auto-clear the "No foreground task running." hint via showDetachHint so it doesn't stick on the footer

- Don't clobber a newer transient hint (e.g. exit confirmation) when the detach hint timer fires

- Add FooterComponent.getTransientHint()

* fix(tui): address Codex review on backgrounded gating and /tasks counts

- Only mark foreground-running subagent cards as backgrounded (skip done/backgrounded cards so background resumes don't mutate older rows)

- Count /tasks header from the filtered (background-only) task set so foreground-only sessions don't read misleading counts
This commit is contained in:
liruifengv 2026-06-22 20:59:24 +08:00 committed by GitHub
parent d521932c3e
commit e7dd13804d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 628 additions and 19 deletions

View file

@ -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.

View file

@ -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

View file

@ -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);

View file

@ -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;

View file

@ -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) {

View file

@ -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<typeof setTimeout> | 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;
}

View file

@ -31,6 +31,7 @@ export interface EditorKeyboardHost {
updateEditorBorderHighlight(text?: string): void;
updateQueueDisplay(): void;
toggleToolOutputExpansion(): void;
detachCurrentForegroundTask(): void;
hideSessionPicker(): void;
stop(exitCode?: number): Promise<void>;
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');
};

View file

@ -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;

View file

@ -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). */

View file

@ -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<typeof setTimeout> | 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<void> {
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('/');

View file

@ -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];
}

View file

@ -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();
});
});

View file

@ -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);

View file

@ -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',

View file

@ -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> = {}): 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<BackgroundTaskInfo>);
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<BackgroundTaskInfo>);
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<BackgroundTaskInfo>);
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([]);
});
});

View file

@ -295,6 +295,7 @@ export class BashTool implements BuiltinTool<BashInput> {
brief: `Backgrounded ${taskId}`,
},
builder,
'foreground_detached',
);
}
@ -370,6 +371,7 @@ export class BashTool implements BuiltinTool<BashInput> {
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<BashInput> {
`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<BashInput> {
};
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 {

View file

@ -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');