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
5 changes: 5 additions & 0 deletions .changeset/wait-for-progress.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Show a live status line with elapsed time and remaining task count while the WaitFor tool is waiting.
5 changes: 5 additions & 0 deletions .changeset/wait-for-tui-display.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Improve the WaitFor tool's transcript display: the header shows the waited task and its outcome, and the body summarizes the finished task, other tasks that completed during the wait, and tasks still running, instead of dumping raw fields.
34 changes: 28 additions & 6 deletions apps/kimi-code/src/tui/components/messages/tool-call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { ShellExecutionComponent } from './shell-execution';
import { countNonEmptyLines, pickChip } from './tool-renderers/chip';
import { buildGoalToolHeader } from './tool-renderers/goal';
import { isGenericToolResult, pickResultRenderer } from './tool-renderers/registry';
import { buildWaitForHeader } from './tool-renderers/wait-for';

const MAX_ARG_LENGTH = 60;
const MAX_SUB_TOOL_CALLS_SHOWN = 4;
Expand Down Expand Up @@ -620,6 +621,7 @@ export class ToolCallComponent extends Container {
// spinner). Cleared when the result lands — the result is the
// authoritative final state.
private progressLines: string[] = [];
private progressStatusRows = 0;
private static readonly MAX_PROGRESS_LINES = 24;
private liveOutput = '';

Expand Down Expand Up @@ -731,6 +733,7 @@ export class ToolCallComponent extends Container {
// authoritative final state. Without this clear, a finished tool would
// show both the streamed status lines and the final output stacked.
this.progressLines = [];
this.progressStatusRows = 0;
this.liveOutput = '';
this.detachHintVisible = false;
this.stopDetachHintTimer();
Expand Down Expand Up @@ -759,15 +762,26 @@ export class ToolCallComponent extends Container {
/**
* Append a live progress line emitted by the tool via
* `onUpdate({kind:'status', text})`. Splits on newlines so multi-line
* status payloads render row-by-row. Old lines are dropped once the
* status payloads render row-by-row. With `options.replace`, the previous
* replaceable status block is swapped out first — periodic "still
* waiting" updates would otherwise pile up to the cap with stale rows.
* Old lines are dropped once the
* buffer fills past {@link ToolCallComponent.MAX_PROGRESS_LINES} so a
* misbehaving tool can't grow the box unboundedly.
*/
appendProgress(text: string): void {
appendProgress(text: string, options?: { readonly replace?: boolean }): void {
if (this.result !== undefined) return;
for (const line of text.split('\n')) {
if (options?.replace === true && this.progressStatusRows > 0) {
this.progressLines.splice(
Math.max(0, this.progressLines.length - this.progressStatusRows),
this.progressStatusRows,
);
}
const lines = text.split('\n');
for (const line of lines) {
this.progressLines.push(line);
}
this.progressStatusRows = options?.replace === true ? lines.length : 0;
while (this.progressLines.length > ToolCallComponent.MAX_PROGRESS_LINES) {
this.progressLines.shift();
}
Expand Down Expand Up @@ -1379,14 +1393,14 @@ export class ToolCallComponent extends Container {
this.ui?.requestRender();
}

appendSubToolLiveOutput(id: string, text: string): void {
appendSubToolLiveOutput(id: string, text: string, options?: { readonly replace?: boolean }): void {
if (text.length === 0) return;
const activity = this.subToolActivities.get(id);
const ongoing = this.ongoingSubCalls.get(id);
if (activity === undefined && ongoing === undefined) return;
const name = activity?.name ?? ongoing?.name ?? 'Tool';
const args = activity?.args ?? ongoing?.args ?? {};
const existingOutput = activity?.output ?? '';
const existingOutput = options?.replace === true ? '' : (activity?.output ?? '');
let output = existingOutput + text;
if (output.length > MAX_LIVE_OUTPUT_CHARS) {
output = `[...truncated]\n${output.slice(output.length - MAX_LIVE_OUTPUT_CHARS)}`;
Expand Down Expand Up @@ -1503,6 +1517,14 @@ export class ToolCallComponent extends Container {
});
if (goalHeader !== undefined) return goalHeader;

const waitForHeader = buildWaitForHeader({
toolCall,
result,
bullet,
chip: isFinished && result !== undefined ? this.buildHeaderChip(result) : '',
});
if (waitForHeader !== undefined) return waitForHeader;

if (this.isSingleSubagentView()) {
return this.buildSingleSubagentHeader();
}
Expand Down Expand Up @@ -1880,7 +1902,7 @@ export class ToolCallComponent extends Container {
current?.phase === 'ongoing' &&
current.output !== undefined &&
current.output.trim().length > 0 &&
(current.name === 'Bash' || isGenericToolResult(current.name))
(current.name === 'Bash' || current.name === 'WaitFor' || isGenericToolResult(current.name))
) {
return { text: current.output, tone: 'text' };
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types';
import { goalStatusChip } from './goal';
import { readMediaChip } from './media';
import { strArg } from './types';
import { waitForChip } from './wait-for';

export type ChipProvider = (toolCall: ToolCallBlockData, result: ToolResultBlockData) => string;

Expand Down Expand Up @@ -125,6 +126,7 @@ const REGISTRY: Record<string, ChipProvider> = {
WebSearch: webSearchChip,
CreateGoal: goalStatusOutputChip,
GetGoal: goalStatusOutputChip,
WaitFor: waitForChip,
};

export function pickChip(toolName: string): ChipProvider | undefined {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import { readMediaSummary } from './media';
import { shellExecutionResultRenderer } from '../shell-execution';
import { goalSummary } from './goal';
import { waitForSummary } from './wait-for';
import {
editSummary,
fetchSummary,
Expand Down Expand Up @@ -63,6 +64,8 @@ export function pickResultRenderer(toolName: string): ResultRenderer {
case 'SetGoalBudget':
case 'UpdateGoal':
return goalSummary;
case 'WaitFor':
return waitForSummary;
default:
return renderTruncated;
}
Expand Down
179 changes: 179 additions & 0 deletions apps/kimi-code/src/tui/components/messages/tool-renderers/wait-for.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/**
* WaitFor renderer — the wait result is a timeline (header fields, then
* `[finished]` / `[completed_during_wait]` / `[still_running]` sections),
* so the collapsed body shows what the wait came back with instead of the
* raw key-value dump: the finished task with its outcome, plus counts of
* tasks that finished alongside or are still running. A timeout is not an
* error (the tool says so itself), so it renders in the warning tone.
*/

import { Text, type Component } from '@moonshot-ai/pi-tui';

import { STATUS_BULLET } from '#/tui/constant/symbols';
import { currentTheme } from '#/tui/theme';
import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types';

import { formatGoalElapsed } from '../goal-format';
import { renderTruncated } from './truncated';
import type { ResultRenderer } from './types';

const DESCRIPTION_MAX = 72;
const RUNNING_SAMPLES = 3;

type WaitForStatus = 'completed' | 'timed_out' | 'no_tasks';

interface WaitForResultView {
readonly status: WaitForStatus;
readonly waitedMs: number;
readonly finishedTaskId?: string;
readonly finishedStatus?: string;
readonly finishedDescription?: string;
readonly extraCount: number;
readonly runningCount: number;
readonly runningSamples: readonly string[];
}

export const waitForSummary: ResultRenderer = (toolCall, result, ctx) => {
if (result.is_error) return renderTruncated(toolCall, result, ctx);
const view = parseWaitForOutput(result.output);
if (view === undefined) return renderTruncated(toolCall, result, ctx);

const out: Component[] = [];
for (const line of glanceLines(view)) {
out.push(new Text(` ${currentTheme.dim(line)}`, 0, 0));
}
if (ctx.expanded && result.output.length > 0) {
out.push(new Text(currentTheme.dim(result.output), 4, 0));
}
return out;
};

export function buildWaitForHeader(options: {
readonly toolCall: ToolCallBlockData;
readonly result: ToolResultBlockData | undefined;
readonly bullet: string;
readonly chip: string;
}): string | undefined {
const { toolCall, result, bullet, chip } = options;
if (toolCall.name !== 'WaitFor') return undefined;

const taskId = typeof toolCall.args['task_id'] === 'string' ? toolCall.args['task_id'] : undefined;
const argText =
taskId === undefined ? '' : currentTheme.dimFg('textDim', ` (${taskId})`);

if (result === undefined) {
const label =
taskId === undefined ? 'Waiting for any background task' : 'Waiting for background task';
return `${bullet}${currentTheme.boldFg('primary', label)}${argText}`;
}
if (result.is_error === true) {
return `${bullet}${currentTheme.boldFg('error', 'Could not wait for background task')}${argText}`;
}

const status = parseWaitForOutput(result.output)?.status;
if (status === 'timed_out') {
return `${currentTheme.fg('warning', STATUS_BULLET)}${currentTheme.boldFg('warning', 'Wait timed out')}${argText}${chip}`;
}
if (status === 'no_tasks') {
return `${bullet}${currentTheme.boldFg('primary', 'No background tasks running')}${chip}`;
}
const label = taskId === undefined ? 'Waited for a background task' : 'Waited for background task';
return `${bullet}${currentTheme.boldFg('primary', label)}${argText}${chip}`;
}

export const waitForChip = (_toolCall: ToolCallBlockData, result: ToolResultBlockData): string => {
if (result.is_error === true) return '';
const view = parseWaitForOutput(result.output);
if (view === undefined || view.status === 'no_tasks') return '';
return formatGoalElapsed(view.waitedMs);
};

function glanceLines(view: WaitForResultView): string[] {
switch (view.status) {
case 'no_tasks':
return [];
case 'timed_out': {
if (view.runningCount === 0) return [];
const summary = `${pluralizeTasks(view.runningCount)} still running`;
if (view.runningSamples.length === 0) return [summary];
const remaining = view.runningCount - view.runningSamples.length;
const tail = remaining > 0 ? `, +${String(remaining)} more` : '';
return [`${summary}: ${view.runningSamples.join(', ')}${tail}`];
}
case 'completed': {
const taskId = view.finishedTaskId ?? 'task';
const status = view.finishedStatus ?? 'completed';
const marker = status === 'completed' ? '✓' : '✗';
const description =
view.finishedDescription === undefined
? ''
: ` · ${truncateOneLine(view.finishedDescription, DESCRIPTION_MAX)}`;
const lines = [`${marker} ${taskId} ${status}${description}`];
const parts: string[] = [];
if (view.extraCount > 0) parts.push(`+${String(view.extraCount)} more finished during wait`);
if (view.runningCount > 0) parts.push(`${pluralizeTasks(view.runningCount)} still running`);
if (parts.length > 0) lines.push(parts.join(' · '));
return lines;
}
}
}

function pluralizeTasks(count: number): string {
return `${String(count)} background task${count === 1 ? '' : 's'}`;
}

function parseWaitForOutput(output: string): WaitForResultView | undefined {
const status = field(output, 'wait_status');
if (status !== 'completed' && status !== 'timed_out' && status !== 'no_tasks') return undefined;
const waitedMs = Number(field(output, 'waited_ms') ?? 0);
const finished = section(output, 'finished');
const duringWait = section(output, 'completed_during_wait');
const stillRunning = section(output, 'still_running');
const runningCount = stillRunning === undefined ? 0 : countField(stillRunning, 'active_background_tasks');
return {
status,
waitedMs: Number.isFinite(waitedMs) ? waitedMs : 0,
finishedTaskId: field(output, 'task_id'),
finishedStatus: finished === undefined ? undefined : field(finished, 'status'),
finishedDescription: finished === undefined ? undefined : field(finished, 'description'),
extraCount: duringWait === undefined ? 0 : countOccurrences(duringWait, /^task_id: /gm),
runningCount,
runningSamples:
stillRunning === undefined ? [] : sampleDescriptions(stillRunning, runningCount),
};
}

function field(text: string, name: string): string | undefined {
const match = new RegExp(`^${name}: (.+)$`, 'm').exec(text);
return match?.[1];
}

function countField(text: string, name: string): number {
const value = Number(field(text, name) ?? 0);
return Number.isFinite(value) ? value : 0;
}

function section(output: string, name: string): string | undefined {
const match = new RegExp(`^\\[${name}\\]$`, 'm').exec(output);
if (match === null) return undefined;
const rest = output.slice(match.index + match[0].length);
const next = /^\[/m.exec(rest);
return (next === null ? rest : rest.slice(0, next.index)).trim();
}

function countOccurrences(text: string, pattern: RegExp): number {
return text.match(pattern)?.length ?? 0;
}

function sampleDescriptions(stillRunning: string, runningCount: number): readonly string[] {
const descriptions = [...stillRunning.matchAll(/^description: (.+)$/gm)].map((match) =>
truncateOneLine(match[1] ?? '', 40),
);
return descriptions.slice(0, Math.min(RUNNING_SAMPLES, runningCount));
}

function truncateOneLine(text: string, max: number): string {
const firstLine = text.replaceAll(/\s+/g, ' ').trim();
if (firstLine.length <= max) return firstLine;
return `${firstLine.slice(0, Math.max(0, max - 1))}…`;
}
Original file line number Diff line number Diff line change
Expand Up @@ -663,7 +663,7 @@ export class SessionEventHandler {
const tc = this.host.streamingUI.getToolComponent(event.toolCallId);
if (tc === undefined) return;
if (event.update.kind === 'status') {
tc.appendProgress(text);
tc.appendProgress(text, { replace: event.update.replace === true });
return;
}
if (event.update.kind === 'stdout' || event.update.kind === 'stderr') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,8 @@ export class SubagentActivityStore {
return;
}
case 'tool.progress': {
if (event.update.kind !== 'stdout' && event.update.kind !== 'stderr') return;
const kind = event.update.kind;
if (kind !== 'stdout' && kind !== 'stderr' && kind !== 'status') return;
const text = event.update.text;
if (text === undefined || text.trim().length === 0) return;
const record = this.records.get(event.agentId);
Expand Down
10 changes: 8 additions & 2 deletions apps/kimi-code/src/tui/controllers/subagent-event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,16 @@ export class SubAgentEventHandler {
});
} else if (
event.type === 'tool.progress' &&
(event.update.kind === 'stdout' || event.update.kind === 'stderr') &&
(event.update.kind === 'stdout' ||
event.update.kind === 'stderr' ||
event.update.kind === 'status') &&
event.update.text !== undefined
) {
toolCall.appendSubToolLiveOutput(`${childAgentId}:${event.toolCallId}`, event.update.text);
toolCall.appendSubToolLiveOutput(
`${childAgentId}:${event.toolCallId}`,
event.update.text,
{ replace: event.update.replace === true },
);
Comment thread
chengluyu marked this conversation as resolved.
} else if (event.type === 'tool.result') {
toolCall.finishSubToolCall({
tool_call_id: `${childAgentId}:${event.toolCallId}`,
Expand Down
Loading
Loading