Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/foreground-task-detach.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
"@moonshot-ai/kimi-code": patch
Comment thread
liruifengv marked this conversation as resolved.
---

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.
5 changes: 5 additions & 0 deletions apps/kimi-code/src/tui/components/chrome/footer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -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
Expand Down
17 changes: 13 additions & 4 deletions apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
liruifengv marked this conversation as resolved.
if (filter === 'all') return [...backgroundOnly];
return backgroundOnly.filter((t) => !isTerminal(t.status));
}

function compareTasks(a: BackgroundTaskInfo, b: BackgroundTaskInfo): number {
Expand Down Expand Up @@ -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 `));
Expand All @@ -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);
Expand Down
9 changes: 9 additions & 0 deletions apps/kimi-code/src/tui/components/editor/custom-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
20 changes: 20 additions & 0 deletions apps/kimi-code/src/tui/components/messages/agent-group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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) {
Expand Down
102 changes: 94 additions & 8 deletions apps/kimi-code/src/tui/components/messages/tool-call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -598,6 +617,7 @@ export class ToolCallComponent extends Container {
this.buildSubagentBlock();
this.syncStreamingProgressTimer();
this.syncSubagentElapsedTimer();
this.startDetachHintTimer();
}

override invalidate(): void {
Expand All @@ -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();
Expand Down Expand Up @@ -682,6 +704,7 @@ export class ToolCallComponent extends Container {
dispose(): void {
this.stopStreamingProgressTimer();
this.stopSubagentElapsedTimer();
this.stopDetachHintTimer();
}

/**
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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';
}
Comment thread
liruifengv marked this conversation as resolved.

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 =
Expand Down Expand Up @@ -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';
Comment thread
liruifengv marked this conversation as resolved.
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
Expand Down Expand Up @@ -1340,6 +1416,7 @@ export class ToolCallComponent extends Container {
this.children.pop();
}
this.buildProgressBlock();
this.buildDetachHintBlock();
this.buildLiveOutputBlock();
this.buildContent();
this.buildSubagentBlock();
Expand All @@ -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();
Expand Down Expand Up @@ -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;
}
Expand Down
10 changes: 10 additions & 0 deletions apps/kimi-code/src/tui/controllers/editor-keyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
};
Expand Down
3 changes: 3 additions & 0 deletions apps/kimi-code/src/tui/controllers/session-event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
liruifengv marked this conversation as resolved.
this.syncBackgroundTaskBadge();
this.host.tasksBrowserController.repaint();
return;
Expand Down
35 changes: 35 additions & 0 deletions apps/kimi-code/src/tui/controllers/streaming-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
Loading
Loading