mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
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:
parent
d521932c3e
commit
e7dd13804d
17 changed files with 628 additions and 19 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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('/');
|
||||
|
|
|
|||
32
apps/kimi-code/src/tui/utils/foreground-task.ts
Normal file
32
apps/kimi-code/src/tui/utils/foreground-task.ts
Normal 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];
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
92
apps/kimi-code/test/tui/utils/foreground-task.test.ts
Normal file
92
apps/kimi-code/test/tui/utils/foreground-task.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue