Skip to content

Commit 41ebe9f

Browse files
authored
fix: polish goal lifecycle messaging (#555)
1 parent d7407b0 commit 41ebe9f

35 files changed

Lines changed: 1415 additions & 125 deletions

File tree

.changeset/goal-mode-outcomes.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@moonshot-ai/agent-core": patch
3+
"@moonshot-ai/kimi-code": patch
4+
---
5+
6+
Improve goal mode outcome handling with follow-up messages, safer error pauses, and clearer TUI transcript display.

apps/kimi-code/src/tui/commands/goal.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -460,7 +460,6 @@ async function resumeGoal(host: SlashCommandHost): Promise<void> {
460460
return;
461461
}
462462
host.track('goal_resume');
463-
host.showStatus('Goal resumed.');
464463
host.sendNormalUserInput(RESUME_GOAL_INPUT);
465464
}
466465

@@ -478,7 +477,7 @@ async function cancelGoal(host: SlashCommandHost): Promise<void> {
478477
return;
479478
}
480479
host.track('goal_cancel');
481-
host.showStatus('Goal cancelled.');
480+
host.showNotice('Goal cancelled.');
482481
}
483482

484483
async function showGoalStatus(host: SlashCommandHost): Promise<void> {
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export function formatGoalElapsed(ms: number): string {
2+
const totalSeconds = Math.round(ms / 1000);
3+
if (totalSeconds < 60) return `${String(totalSeconds)}s`;
4+
const minutes = Math.floor(totalSeconds / 60);
5+
const seconds = totalSeconds % 60;
6+
if (minutes < 60) return `${String(minutes)}m ${seconds.toString().padStart(2, '0')}s`;
7+
const hours = Math.floor(minutes / 60);
8+
return `${String(hours)}h ${(minutes % 60).toString().padStart(2, '0')}m`;
9+
}
10+
11+
export function pluralizeGoalCount(n: number, singular: string, plural?: string): string {
12+
return `${String(n)} ${n === 1 ? singular : (plural ?? `${singular}s`)}`;
13+
}

apps/kimi-code/src/tui/components/messages/goal-markers.ts

Lines changed: 91 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,43 @@ import type { Component } from '@earendil-works/pi-tui';
1111
import type { GoalChange } from '@moonshot-ai/kimi-code-sdk';
1212
import chalk from 'chalk';
1313

14+
import { STATUS_BULLET } from '#/tui/constant/symbols';
1415
import type { ColorPalette } from '#/tui/theme/colors';
1516

1617
const HEAD_INDENT = ' ';
1718
const DETAIL_INDENT = ' ';
1819

20+
type GoalMarkerActor = 'user' | 'model' | 'runtime' | 'system';
21+
22+
interface GoalMarkerOptions {
23+
readonly marker?: string;
24+
readonly textHex?: string;
25+
readonly expandable?: boolean;
26+
readonly indent?: string;
27+
readonly leadingBlank?: boolean;
28+
}
29+
1930
export class GoalMarkerComponent implements Component {
2031
private expanded = false;
32+
private readonly marker: string;
33+
private readonly textHex: string;
34+
private readonly expandable: boolean;
35+
private readonly indent: string;
36+
private readonly leadingBlank: boolean;
2137

2238
constructor(
2339
private readonly headline: string,
2440
private readonly detail: string | undefined,
2541
private readonly colors: ColorPalette,
2642
private readonly accentHex: string,
27-
) {}
43+
options: GoalMarkerOptions = {},
44+
) {
45+
this.marker = options.marker ?? '◦';
46+
this.textHex = options.textHex ?? colors.textDim;
47+
this.expandable = options.expandable ?? true;
48+
this.indent = options.indent ?? HEAD_INDENT;
49+
this.leadingBlank = options.leadingBlank ?? false;
50+
}
2851

2952
invalidate(): void {}
3053

@@ -33,20 +56,29 @@ export class GoalMarkerComponent implements Component {
3356
}
3457

3558
render(width: number): string[] {
36-
const dot = chalk.hex(this.accentHex)('◦');
37-
const head = chalk.hex(this.colors.textDim)(this.headline);
59+
const dot = chalk.hex(this.accentHex)(this.marker);
60+
const head = chalk.hex(this.textHex)(this.headline);
3861
const hasDetail = this.detail !== undefined && this.detail.length > 0;
39-
if (!hasDetail) return [`${HEAD_INDENT}${dot} ${head}`];
62+
if (!hasDetail) return this.withLeadingBlank([`${this.indent}${dot} ${head}`]);
4063

64+
if (!this.expandable) {
65+
return this.withLeadingBlank([`${this.indent}${dot} ${head}`]);
66+
}
4167
if (!this.expanded) {
42-
return [`${HEAD_INDENT}${dot} ${head} ${chalk.hex(this.colors.textMuted)('(ctrl+o)')}`];
68+
return this.withLeadingBlank([
69+
`${this.indent}${dot} ${head} ${chalk.hex(this.colors.textMuted)('(ctrl+o)')}`,
70+
]);
4371
}
44-
const out = [`${HEAD_INDENT}${dot} ${head}`];
72+
const out = [`${this.indent}${dot} ${head}`];
4573
const wrapWidth = Math.max(20, width - DETAIL_INDENT.length);
4674
for (const line of wrap(this.detail!, wrapWidth)) {
4775
out.push(DETAIL_INDENT + chalk.hex(this.colors.textDim)(line));
4876
}
49-
return out;
77+
return this.withLeadingBlank(out);
78+
}
79+
80+
private withLeadingBlank(lines: string[]): string[] {
81+
return this.leadingBlank ? ['', ...lines] : lines;
5082
}
5183
}
5284

@@ -59,24 +91,37 @@ export function buildGoalMarker(
5991
change: GoalChange,
6092
colors: ColorPalette,
6193
expanded: boolean,
94+
actor?: GoalMarkerActor,
6295
): GoalMarkerComponent | null {
63-
const spec = markerSpec(change, colors);
96+
const spec = markerSpec(change, colors, actor);
6497
if (spec === null) return null;
65-
const marker = new GoalMarkerComponent(spec.headline, change.reason, colors, spec.accentHex);
98+
const marker = new GoalMarkerComponent(
99+
spec.headline,
100+
spec.detail ?? change.reason,
101+
colors,
102+
spec.accentHex,
103+
spec.options,
104+
);
66105
marker.setExpanded(expanded);
67106
return marker;
68107
}
69108

70109
function markerSpec(
71110
change: GoalChange,
72111
colors: ColorPalette,
73-
): { headline: string; accentHex: string } | null {
112+
actor?: GoalMarkerActor,
113+
): {
114+
headline: string;
115+
accentHex: string;
116+
detail?: string | undefined;
117+
options?: GoalMarkerOptions | undefined;
118+
} | null {
74119
if (change.kind === 'lifecycle') {
75120
switch (change.status) {
76121
case 'paused':
77-
return { headline: 'Goal paused', accentHex: colors.textDim };
122+
return prominentMarker(pausedHeadline(change.reason, actor), colors.warning);
78123
case 'active':
79-
return { headline: 'Goal resumed', accentHex: colors.primary };
124+
return prominentMarker(resumedHeadline(actor), colors.primary);
80125
case 'blocked':
81126
// The system stopped pursuing the goal; resumable via `/goal resume`.
82127
return { headline: 'Goal blocked', accentHex: colors.warning };
@@ -87,6 +132,40 @@ function markerSpec(
87132
return null; // completion -> posts its own message, not a marker
88133
}
89134

135+
function prominentMarker(headline: string, accentHex: string) {
136+
return {
137+
headline,
138+
accentHex,
139+
detail: undefined,
140+
options: {
141+
marker: STATUS_BULLET.trimEnd(),
142+
textHex: accentHex,
143+
expandable: false,
144+
indent: '',
145+
leadingBlank: true,
146+
},
147+
};
148+
}
149+
150+
function pausedHeadline(reason: string | undefined, actor: GoalMarkerActor | undefined): string {
151+
if (reason === 'Paused after interruption') return "Goal paused due to user's interruption";
152+
if (actor === 'user') return 'Goal paused by the user.';
153+
if (reason?.startsWith('Paused ') === true) return `Goal ${lowercaseFirst(reason)}`;
154+
if (reason !== undefined && reason.length > 0) return `Goal paused: ${reason}`;
155+
if (actor === 'model') return 'Goal paused by the agent.';
156+
return 'Goal paused';
157+
}
158+
159+
function resumedHeadline(actor: GoalMarkerActor | undefined): string {
160+
if (actor === 'user') return 'Goal resumed by the user.';
161+
if (actor === 'model') return 'Goal resumed by the agent.';
162+
return 'Goal resumed';
163+
}
164+
165+
function lowercaseFirst(text: string): string {
166+
return text.length === 0 ? text : `${text[0]!.toLowerCase()}${text.slice(1)}`;
167+
}
168+
90169
function wrap(text: string, width: number): string[] {
91170
const words = text.replace(/\s+/g, ' ').trim().split(' ');
92171
const lines: string[] = [];

apps/kimi-code/src/tui/components/messages/goal-panel.ts

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { MESSAGE_INDENT } from '#/tui/constant/rendering';
2222
import { STATUS_BULLET } from '#/tui/constant/symbols';
2323
import type { ColorPalette } from '#/tui/theme/colors';
2424
import { formatTokenCount } from '#/utils/usage/usage-format';
25+
import { formatGoalElapsed } from './goal-format';
2526
import { UsagePanelComponent } from './usage-panel';
2627

2728
const WRAP_WIDTH = 72;
@@ -161,7 +162,7 @@ export function buildGoalReportLines(options: GoalReportOptions): string[] {
161162
),
162163
);
163164
}
164-
lines.push(row('Running', value(formatElapsed(goal.wallClockMs))));
165+
lines.push(row('Running', value(formatGoalElapsed(goal.wallClockMs))));
165166
lines.push(row('Turns', value(`${goal.turnsUsed}`)));
166167
lines.push(row('Tokens', value(formatTokenCount(goal.tokensUsed))));
167168
if (!isComplete) {
@@ -186,7 +187,7 @@ function formatStopRow(goal: GoalSnapshot): string | null {
186187
parts.push(`at ${formatTokenCount(budget.tokenBudget)} tokens`);
187188
}
188189
if (budget.wallClockBudgetMs !== null) {
189-
parts.push(`after ${formatElapsed(budget.wallClockBudgetMs)}`);
190+
parts.push(`after ${formatGoalElapsed(budget.wallClockBudgetMs)}`);
190191
}
191192
return parts.length > 0 ? parts.join(', ') : null;
192193
}
@@ -204,16 +205,6 @@ function statusHex(status: GoalStatus, colors: ColorPalette): string {
204205
}
205206
}
206207

207-
function formatElapsed(ms: number): string {
208-
const totalSeconds = Math.round(ms / 1000);
209-
if (totalSeconds < 60) return `${totalSeconds}s`;
210-
const minutes = Math.floor(totalSeconds / 60);
211-
const seconds = totalSeconds % 60;
212-
if (minutes < 60) return `${minutes}m ${seconds.toString().padStart(2, '0')}s`;
213-
const hours = Math.floor(minutes / 60);
214-
return `${hours}h ${(minutes % 60).toString().padStart(2, '0')}m`;
215-
}
216-
217208
/** Word-wrap to `width`, capped at `maxLines` (last line gets an ellipsis when clipped). */
218209
function wrap(text: string, width: number, maxLines: number): string[] {
219210
const words = text.replaceAll(/\s+/g, ' ').trim().split(' ');

apps/kimi-code/src/tui/components/messages/tool-call.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { agentSwarmResultSummaryFromOutput } from './agent-swarm-progress';
3131
import { PlanBoxComponent } from './plan-box';
3232
import { ShellExecutionComponent } from './shell-execution';
3333
import { countNonEmptyLines, pickChip } from './tool-renderers/chip';
34+
import { buildGoalToolHeader } from './tool-renderers/goal';
3435
import { isGenericToolResult, pickResultRenderer } from './tool-renderers/registry';
3536
import { TruncatedOutputComponent } from './tool-renderers/truncated';
3637

@@ -1254,6 +1255,15 @@ export class ToolCallComponent extends Container {
12541255
return `${bullet}${tone.bold(label)}`;
12551256
}
12561257

1258+
const goalHeader = buildGoalToolHeader({
1259+
toolCall,
1260+
result,
1261+
colors,
1262+
bullet,
1263+
chip: isFinished && result !== undefined ? this.buildHeaderChip(result) : '',
1264+
});
1265+
if (goalHeader !== undefined) return goalHeader;
1266+
12571267
if (this.isSingleSubagentView()) {
12581268
return this.buildSingleSubagentHeader();
12591269
}

apps/kimi-code/src/tui/components/messages/tool-renderers/chip.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import { computeDiffLines } from '#/tui/components/media/diff-preview';
1212
import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types';
1313

14+
import { goalStatusChip } from './goal';
1415
import { readMediaChip } from './media';
1516
import { strArg } from './types';
1617

@@ -110,6 +111,9 @@ const webSearchChip: ChipProvider = (_toolCall, result) => {
110111
return pluralize(count, 'result');
111112
};
112113

114+
const goalStatusOutputChip: ChipProvider = (_toolCall, result) =>
115+
result.is_error ? '' : goalStatusChip(result.output);
116+
113117
const REGISTRY: Record<string, ChipProvider> = {
114118
Edit: editChip,
115119
Write: writeChip,
@@ -119,6 +123,8 @@ const REGISTRY: Record<string, ChipProvider> = {
119123
Glob: globChip,
120124
FetchURL: fetchChip,
121125
WebSearch: webSearchChip,
126+
CreateGoal: goalStatusOutputChip,
127+
GetGoal: goalStatusOutputChip,
122128
};
123129

124130
export function pickChip(toolName: string): ChipProvider | undefined {

0 commit comments

Comments
 (0)