diff --git a/.changeset/blue-goals-shine.md b/.changeset/blue-goals-shine.md new file mode 100644 index 000000000..c9ed1e93a --- /dev/null +++ b/.changeset/blue-goals-shine.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Show the upcoming-goal confirmation with the same accent treatment as goal lifecycle messages. diff --git a/.changeset/calm-goals-send.md b/.changeset/calm-goals-send.md new file mode 100644 index 000000000..5912c757a --- /dev/null +++ b/.changeset/calm-goals-send.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix slash command autocomplete so goal text can be submitted when the cursor is before existing text. diff --git a/.changeset/fair-goals-rest.md b/.changeset/fair-goals-rest.md new file mode 100644 index 000000000..e131c51b1 --- /dev/null +++ b/.changeset/fair-goals-rest.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix queued goals so failed promotion attempts do not lose or duplicate queued work. diff --git a/.changeset/neat-goals-wait.md b/.changeset/neat-goals-wait.md new file mode 100644 index 000000000..5a532c993 --- /dev/null +++ b/.changeset/neat-goals-wait.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix upcoming-goal queue handling while editing or pasting queued goals. diff --git a/.changeset/quiet-goals-bow.md b/.changeset/quiet-goals-bow.md new file mode 100644 index 000000000..5506fda52 --- /dev/null +++ b/.changeset/quiet-goals-bow.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Ask before starting goals in YOLO mode so users can switch to Auto for unattended work. diff --git a/.changeset/soft-bags-learn.md b/.changeset/soft-bags-learn.md new file mode 100644 index 000000000..304411681 --- /dev/null +++ b/.changeset/soft-bags-learn.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Start upcoming goals immediately when there is no active goal to wait for. +Support multiline edits when managing upcoming goals. diff --git a/.changeset/warm-goals-glow.md b/.changeset/warm-goals-glow.md new file mode 100644 index 000000000..088e7c29e --- /dev/null +++ b/.changeset/warm-goals-glow.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Highlight goal queue subcommands while typing slash commands. diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index fff595d16..e3c1616d2 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -120,6 +120,7 @@ export interface SlashCommandHost { beginSessionRequest(): void; failSessionRequest(message: string): void; sendQueuedMessage(session: Session, item: QueuedMessage): void; + requestQueuedGoalPromotion?(): void; // UI showLoginProgressSpinner(label: string): LoginProgressSpinnerHandle; diff --git a/apps/kimi-code/src/tui/commands/goal.ts b/apps/kimi-code/src/tui/commands/goal.ts index bd5aff583..79c7efdd4 100644 --- a/apps/kimi-code/src/tui/commands/goal.ts +++ b/apps/kimi-code/src/tui/commands/goal.ts @@ -13,6 +13,7 @@ import { import { GoalSetMessageComponent, GoalStatusMessageComponent, + UpcomingGoalAddedMessageComponent, } from '../components/messages/goal-panel'; import { LLM_NOT_SET_MESSAGE } from '../constant/kimi-tui'; import { @@ -28,6 +29,7 @@ import type { SlashCommandHost } from './dispatch'; const MAX_GOAL_OBJECTIVE_LENGTH = 4000; const RESUME_GOAL_INPUT = 'Resume the active goal.'; +const START_NEXT_GOAL_NOW_MESSAGE = 'No active goal. Starting this goal now.'; type GoalCommandHost = Pick< SlashCommandHost, @@ -175,14 +177,38 @@ async function queueNextGoal( host: SlashCommandHost, parsed: Extract, ): Promise { + const session = host.requireSession(); + let hasCurrentGoal: boolean; try { - await appendGoalQueueItem(host.requireSession(), { objective: parsed.objective }); + const { goal } = await session.getGoal(); + hasCurrentGoal = goal !== null; + } catch (error) { + host.showError(`Failed to inspect current goal: ${formatErrorMessage(error)}`); + return; + } + + if (!hasCurrentGoal && !isBusy(host)) { + host.showStatus(START_NEXT_GOAL_NOW_MESSAGE); + await createGoal( + host, + { kind: 'create', objective: parsed.objective, replace: false }, + `next ${parsed.objective}`, + ); + return; + } + + try { + await appendGoalQueueItem(session, { objective: parsed.objective }); } catch (error) { host.showError(formatErrorMessage(error)); return; } host.track('goal_queue_append'); - host.showStatus('Upcoming goal added. It will start after the current goal is complete.'); + if (!hasCurrentGoal) host.requestQueuedGoalPromotion?.(); + host.state.transcriptContainer.addChild( + new UpcomingGoalAddedMessageComponent(host.state.theme.colors), + ); + host.state.ui.requestRender(); } async function showGoalQueueManager( @@ -304,7 +330,10 @@ export async function createGoal( return false; } - if (host.state.appState.permissionMode === 'manual') { + if ( + host.state.appState.permissionMode === 'manual' || + host.state.appState.permissionMode === 'yolo' + ) { showGoalStartPermissionPrompt(host, parsed, rawArgs ?? parsed.objective, options); return false; } @@ -326,6 +355,7 @@ function showGoalStartPermissionPrompt( host.mountEditorReplacement( new GoalStartPermissionPromptComponent({ colors: host.state.theme.colors, + mode: host.state.appState.permissionMode === 'yolo' ? 'yolo' : 'manual', onSelect: (choice) => { if (choice === 'cancel') { cancelStart(); @@ -345,7 +375,7 @@ async function startGoalWithPermission( choice: GoalStartPermissionChoice, options: GoalStartOptions, ): Promise { - if (choice === 'auto' || choice === 'yolo') { + if (choice !== host.state.appState.permissionMode && (choice === 'auto' || choice === 'yolo')) { if (!(await setPermissionForGoal(host, choice))) return; } await startGoal(host, parsed, options); @@ -467,3 +497,7 @@ async function showGoalStatus(host: SlashCommandHost): Promise { function isStreaming(host: SlashCommandHost): boolean { return host.state.appState.streamingPhase !== 'idle'; } + +function isBusy(host: SlashCommandHost): boolean { + return isStreaming(host) || host.state.appState.isCompacting; +} diff --git a/apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts b/apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts index 1c37ca207..bf5f72356 100644 --- a/apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts +++ b/apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts @@ -1,8 +1,8 @@ import { Container, - Input, Key, matchesKey, + CURSOR_MARKER, truncateToWidth, visibleWidth, type Focusable, @@ -20,8 +20,15 @@ import { printableChar } from '#/tui/utils/printable-key'; import { SearchableList } from '#/tui/utils/searchable-list'; const MAX_GOAL_OBJECTIVE_LENGTH = 4000; +const MAX_EDIT_INPUT_LINES = 8; const ELLIPSIS = '…'; -const END_KEY = '\u001B[F'; +const BRACKET_PASTE_START = '\u001B[200~'; +const BRACKET_PASTE_END = '\u001B[201~'; +const SHIFT_ENTER_LEGACY = '\u001B\r'; +const SHIFT_ENTER_CSI = '\u001B[13;2~'; +const SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); +// oxlint-disable-next-line no-control-regex -- ESC (\x1b) is required to strip pasted terminal control sequences +const ANSI_CSI = /\u001B\[[0-?]*[ -/]*[@-~]/g; export type GoalQueueManagerAction = | { @@ -151,7 +158,11 @@ export class GoalQueueManagerComponent extends Container implements Focusable { const labelWidth = visibleWidth(labelPrefix); const stateWidth = visibleWidth(stateLabel); const objectiveWidth = Math.max(1, width - 5 - labelWidth - stateWidth); - const objective = truncateToWidth(goal.objective, objectiveWidth, ELLIPSIS); + const objective = truncateToWidth( + formatListObjective(goal.objective), + objectiveWidth, + ELLIPSIS, + ); const textStyle = selected ? chalk.hex(colors.primary).bold : chalk.hex(colors.text); let line = prefix + textStyle(labelPrefix + objective); if (moving) line += chalk.hex(colors.success)(stateLabel); @@ -195,7 +206,7 @@ export class GoalQueueManagerComponent extends Container implements Focusable { export class GoalQueueEditDialogComponent extends Container implements Focusable { focused = false; - private readonly input = new Input(); + private readonly input = new MultilineGoalInput(); private readonly opts: GoalQueueEditDialogOptions; private done = false; private error: string | undefined; @@ -204,7 +215,6 @@ export class GoalQueueEditDialogComponent extends Container implements Focusable super(); this.opts = opts; this.input.setValue(opts.goal.objective); - this.input.handleInput(END_KEY); this.input.onSubmit = (value) => { this.submit(value); }; @@ -250,13 +260,13 @@ export class GoalQueueEditDialogComponent extends Container implements Focusable innerWidth, ELLIPSIS, ); - const inputLine = this.input.render(innerWidth)[0] ?? '> '; + const inputLines = this.input.render(innerWidth); const footer = truncateToWidth( - chalk.hex(colors.textDim)('Enter submit · Esc cancel'), + chalk.hex(colors.textDim)('Enter submit · Shift-Enter/Ctrl-J newline · Esc cancel'), innerWidth, ELLIPSIS, ); - const contentLines = [title, '', subtitle, '', inputLine, '', footer]; + const contentLines = [title, '', subtitle, '', ...inputLines, '', footer]; const lines = [ '', border('╭' + '─'.repeat(safeWidth - 2) + '╮'), @@ -288,3 +298,345 @@ export class GoalQueueEditDialogComponent extends Container implements Focusable this.opts.onDone({ kind: 'save', goalId: this.opts.goal.id, objective }); } } + +class MultilineGoalInput { + focused = false; + onSubmit?: (value: string) => void; + + private value = ''; + private cursor = 0; + private pasteBuffer: string | undefined; + + getValue(): string { + return this.value; + } + + setValue(value: string): void { + this.value = normalizeNewlines(value); + this.cursor = this.value.length; + } + + handleInput(data: string): void { + if (this.handleBracketedPaste(data)) return; + + if (isNewlineInput(data)) { + this.insert('\n'); + return; + } + + if (matchesKey(data, Key.enter) || matchesKey(data, Key.return)) { + this.onSubmit?.(this.value); + return; + } + + if (matchesKey(data, Key.backspace)) { + this.deleteBeforeCursor(); + return; + } + + if (matchesKey(data, Key.delete)) { + this.deleteAfterCursor(); + return; + } + + if (matchesKey(data, Key.left)) { + this.cursor = previousGraphemeStart(this.value, this.cursor); + return; + } + + if (matchesKey(data, Key.right)) { + this.cursor = nextGraphemeEnd(this.value, this.cursor); + return; + } + + if (matchesKey(data, Key.up)) { + this.moveVertical(-1); + return; + } + + if (matchesKey(data, Key.down)) { + this.moveVertical(1); + return; + } + + if (matchesKey(data, Key.home) || matchesKey(data, Key.ctrl('a'))) { + this.cursor = this.currentLineStart(); + return; + } + + if (matchesKey(data, Key.end) || matchesKey(data, Key.ctrl('e'))) { + this.cursor = this.currentLineEnd(); + return; + } + + const decoded = printableChar(data); + if (isPrintableText(decoded)) { + this.insert(decoded); + } + } + + invalidate(): void { + // No cached layout. + } + + render(width: number): string[] { + const safeWidth = Math.max(4, width); + const logicalLines = this.value.split('\n'); + const cursor = this.cursorLocation(); + const range = visibleLineRange(logicalLines.length, cursor.line); + const rendered: string[] = []; + + if (range.start > 0) { + rendered.push(padInputLine(` ${ELLIPSIS} ${String(range.start)} previous`, safeWidth)); + } + + for (let lineIndex = range.start; lineIndex < range.end; lineIndex++) { + const line = logicalLines[lineIndex] ?? ''; + const prefix = lineIndex === 0 ? '> ' : ' '; + rendered.push( + lineIndex === cursor.line + ? renderCursorLine(line, cursor.column, prefix, safeWidth, this.focused) + : renderTextLine(line, prefix, safeWidth), + ); + } + + const remaining = logicalLines.length - range.end; + if (remaining > 0) { + rendered.push(padInputLine(` ${ELLIPSIS} ${String(remaining)} more`, safeWidth)); + } + + return rendered; + } + + private insert(text: string): void { + const normalized = normalizeNewlines(text); + this.value = + this.value.slice(0, this.cursor) + normalized + this.value.slice(this.cursor); + this.cursor += normalized.length; + } + + private deleteBeforeCursor(): void { + if (this.cursor === 0) return; + const start = previousGraphemeStart(this.value, this.cursor); + this.value = this.value.slice(0, start) + this.value.slice(this.cursor); + this.cursor = start; + } + + private deleteAfterCursor(): void { + if (this.cursor >= this.value.length) return; + const end = nextGraphemeEnd(this.value, this.cursor); + this.value = this.value.slice(0, this.cursor) + this.value.slice(end); + } + + private moveVertical(delta: -1 | 1): void { + const starts = lineStarts(this.value); + const location = this.cursorLocation(starts); + const targetLine = location.line + delta; + if (targetLine < 0 || targetLine >= starts.length) return; + + const targetStart = starts[targetLine] ?? 0; + const targetEnd = lineEndForStart(this.value, starts, targetLine); + this.cursor = Math.min(targetStart + location.column, targetEnd); + } + + private currentLineStart(): number { + return this.value.lastIndexOf('\n', Math.max(0, this.cursor - 1)) + 1; + } + + private currentLineEnd(): number { + return lineEndAt(this.value, this.cursor); + } + + private cursorLocation(starts = lineStarts(this.value)): { line: number; column: number } { + let line = 0; + for (let i = 0; i < starts.length; i++) { + const start = starts[i] ?? 0; + if (start > this.cursor) break; + line = i; + } + const lineStart = starts[line] ?? 0; + return { line, column: this.cursor - lineStart }; + } + + private handleBracketedPaste(data: string): boolean { + if (this.pasteBuffer !== undefined) { + this.appendPasteChunk(data); + return true; + } + + const start = data.indexOf(BRACKET_PASTE_START); + if (start === -1) return false; + + this.pasteBuffer = ''; + const before = data.slice(0, start); + if (isPrintableText(before)) this.insert(before); + this.appendPasteChunk(data.slice(start + BRACKET_PASTE_START.length)); + return true; + } + + private appendPasteChunk(data: string): void { + if (this.pasteBuffer === undefined) return; + + this.pasteBuffer += data; + const end = this.pasteBuffer.indexOf(BRACKET_PASTE_END); + if (end === -1) return; + + const pasted = this.pasteBuffer.slice(0, end); + const remaining = this.pasteBuffer.slice(end + BRACKET_PASTE_END.length); + this.pasteBuffer = undefined; + this.insert(sanitizePastedText(pasted)); + if (remaining.length > 0) this.handleInput(remaining); + } +} + +function isNewlineInput(data: string): boolean { + return ( + data === '\n' || + data === SHIFT_ENTER_LEGACY || + data === SHIFT_ENTER_CSI || + matchesKey(data, Key.ctrl('j')) + ); +} + +function normalizeNewlines(text: string): string { + return text.replaceAll('\r\n', '\n').replaceAll('\r', '\n'); +} + +function formatListObjective(objective: string): string { + return objective.replaceAll(/\s+/g, ' ').trim(); +} + +function sanitizePastedText(text: string): string { + const normalized = normalizeNewlines(text).replaceAll(ANSI_CSI, ''); + let out = ''; + for (let i = 0; i < normalized.length;) { + const code = normalized.codePointAt(i); + if (code === undefined) break; + const char = String.fromCodePoint(code); + if (char === '\n' || isPrintableText(char)) { + out += char; + } + i += code > 0xffff ? 2 : 1; + } + return out; +} + +function isPrintableText(text: string): boolean { + if (text.length === 0) return false; + for (let i = 0; i < text.length;) { + const code = text.codePointAt(i); + if (code === undefined) return false; + if (code < 0x20 || code === 0x7f || (code >= 0x80 && code <= 0x9f)) return false; + i += code > 0xffff ? 2 : 1; + } + return true; +} + +function lineStarts(text: string): number[] { + const starts = [0]; + for (let i = 0; i < text.length; i++) { + if (text[i] === '\n') starts.push(i + 1); + } + return starts; +} + +function lineEndAt(text: string, offset: number): number { + const end = text.indexOf('\n', offset); + return end === -1 ? text.length : end; +} + +function lineEndForStart(text: string, starts: readonly number[], line: number): number { + const nextStart = starts[line + 1]; + return nextStart === undefined ? text.length : nextStart - 1; +} + +function previousGraphemeStart(text: string, offset: number): number { + if (offset <= 0) return 0; + let previous = 0; + for (const segment of SEGMENTER.segment(text.slice(0, offset))) { + previous = segment.index; + } + return previous; +} + +function nextGraphemeEnd(text: string, offset: number): number { + if (offset >= text.length) return text.length; + const segment = SEGMENTER.segment(text.slice(offset))[Symbol.iterator]().next().value; + return segment === undefined ? text.length : offset + segment.segment.length; +} + +function visibleLineRange(totalLines: number, cursorLine: number): { start: number; end: number } { + if (totalLines <= MAX_EDIT_INPUT_LINES) return { start: 0, end: totalLines }; + + const half = Math.floor(MAX_EDIT_INPUT_LINES / 2); + const start = Math.min( + Math.max(0, cursorLine - half), + Math.max(0, totalLines - MAX_EDIT_INPUT_LINES), + ); + return { start, end: start + MAX_EDIT_INPUT_LINES }; +} + +function renderTextLine(line: string, prefix: string, width: number): string { + const prefixWidth = visibleWidth(prefix); + const textWidth = Math.max(1, width - prefixWidth); + const text = truncateToWidth(line, textWidth, ELLIPSIS); + return padInputLine(prefix + text, width); +} + +function renderCursorLine( + line: string, + column: number, + prefix: string, + width: number, + focused: boolean, +): string { + const prefixWidth = visibleWidth(prefix); + const textWidth = Math.max(1, width - prefixWidth); + const cursorEnd = nextGraphemeEnd(line, column); + const before = line.slice(0, column); + const cursorText = line.slice(column, cursorEnd) || ' '; + const after = line.slice(cursorEnd); + const cursorWidth = Math.max(1, visibleWidth(cursorText)); + const beforeWidth = Math.max(0, textWidth - cursorWidth); + const beforeView = takeEndByWidth(before, beforeWidth); + const afterView = takeStartByWidth( + after, + Math.max(0, textWidth - visibleWidth(beforeView) - cursorWidth), + ); + const marker = focused ? CURSOR_MARKER : ''; + return padInputLine( + prefix + beforeView + marker + chalk.inverse(cursorText) + afterView, + width, + ); +} + +function takeStartByWidth(text: string, width: number): string { + let out = ''; + let used = 0; + for (const segment of SEGMENTER.segment(text)) { + const segmentWidth = visibleWidth(segment.segment); + if (used + segmentWidth > width) break; + out += segment.segment; + used += segmentWidth; + } + return out; +} + +function takeEndByWidth(text: string, width: number): string { + let out = ''; + let used = 0; + const segments = [...SEGMENTER.segment(text)]; + for (let i = segments.length - 1; i >= 0; i--) { + const segment = segments[i]; + if (segment === undefined) continue; + const segmentWidth = visibleWidth(segment.segment); + if (used + segmentWidth > width) break; + out = segment.segment + out; + used += segmentWidth; + } + return out; +} + +function padInputLine(line: string, width: number): string { + return line + ' '.repeat(Math.max(0, width - visibleWidth(line))); +} diff --git a/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts b/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts index df5beaf7c..636a6baa4 100644 --- a/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts +++ b/apps/kimi-code/src/tui/components/dialogs/goal-start-permission-prompt.ts @@ -8,6 +8,7 @@ import { } from '@earendil-works/pi-tui'; import chalk from 'chalk'; +import { SELECT_POINTER } from '#/tui/constant/symbols'; import type { ColorPalette } from '#/tui/theme/colors'; export type GoalStartPermissionChoice = 'auto' | 'yolo' | 'manual' | 'cancel'; @@ -20,11 +21,12 @@ interface GoalStartOption { export interface GoalStartPermissionPromptOptions { readonly colors: ColorPalette; + readonly mode: 'manual' | 'yolo'; readonly onSelect: (choice: GoalStartPermissionChoice) => void; readonly onCancel: () => void; } -const OPTIONS: readonly GoalStartOption[] = [ +const MANUAL_OPTIONS: readonly GoalStartOption[] = [ { value: 'auto', label: 'Switch to Auto and start', @@ -50,12 +52,38 @@ const OPTIONS: readonly GoalStartOption[] = [ }, ]; -const NOTICE_LINES = [ +const YOLO_OPTIONS: readonly GoalStartOption[] = [ + { + value: 'auto', + label: 'Switch to Auto and start', + description: + 'Best if you want Kimi Code to keep working while you are away. Tools are approved automatically, and questions are skipped.', + }, + { + value: 'yolo', + label: 'Keep YOLO and start', + description: + 'Tools and plan changes stay approved automatically. Kimi Code may still ask you questions.', + }, + { + value: 'cancel', + label: 'Do not start', + description: 'Return to the input box with your goal command.', + }, +]; + +const MANUAL_NOTICE_LINES = [ 'Manual mode asks you before Kimi Code runs commands, edits files, or takes other risky actions.', 'Manual mode is not suitable for unattended goal work.', 'You can go back without losing your command.', ] as const; +const YOLO_NOTICE_LINES = [ + 'YOLO mode approves tools and plan changes automatically.', + 'YOLO mode can still stop for questions.', + 'Switch to Auto if you want questions skipped during goal work.', +] as const; + export class GoalStartPermissionPromptComponent implements Component, Focusable { focused = false; private selectedIndex = 0; @@ -74,11 +102,11 @@ export class GoalStartPermissionPromptComponent implements Component, Focusable return; } if (matchesKey(data, Key.down)) { - this.selectedIndex = Math.min(OPTIONS.length - 1, this.selectedIndex + 1); + this.selectedIndex = Math.min(this.options.length - 1, this.selectedIndex + 1); return; } if (matchesKey(data, Key.enter) || matchesKey(data, Key.space)) { - this.opts.onSelect(OPTIONS[this.selectedIndex]!.value); + this.opts.onSelect(this.options[this.selectedIndex]!.value); } } @@ -87,23 +115,23 @@ export class GoalStartPermissionPromptComponent implements Component, Focusable const rule = chalk.hex(colors.primary)('─'.repeat(width)); const lines = [ rule, - chalk.hex(colors.primary).bold(' Start a goal with approvals on?'), - chalk.hex(colors.textMuted)(' ↑↓ navigate · Enter select · Esc return to input box'), + chalk.hex(colors.primary).bold(` ${this.title}`), + chalk.hex(colors.textMuted)(' ↑↓ navigate · Enter select · Esc cancel'), '', ]; const textWidth = Math.max(20, width - 2); - for (const paragraph of NOTICE_LINES) { + for (const paragraph of this.noticeLines) { for (const line of wrapPlain(paragraph, textWidth)) { lines.push(` ${styleModeNames(line, colors, colors.textMuted)}`); } lines.push(''); } - for (let i = 0; i < OPTIONS.length; i += 1) { - const option = OPTIONS[i]!; + for (let i = 0; i < this.options.length; i += 1) { + const option = this.options[i]!; const selected = i === this.selectedIndex; - const pointer = selected ? '❯' : ' '; + const pointer = selected ? SELECT_POINTER : ' '; lines.push( chalk.hex(selected ? colors.primary : colors.textDim)(` ${pointer} `) + styleLabel(option.label, selected, colors), @@ -117,6 +145,20 @@ export class GoalStartPermissionPromptComponent implements Component, Focusable lines.push(rule); return lines.map((line) => truncateToWidth(line, width)); } + + private get options(): readonly GoalStartOption[] { + return this.opts.mode === 'yolo' ? YOLO_OPTIONS : MANUAL_OPTIONS; + } + + private get noticeLines(): readonly string[] { + return this.opts.mode === 'yolo' ? YOLO_NOTICE_LINES : MANUAL_NOTICE_LINES; + } + + private get title(): string { + return this.opts.mode === 'yolo' + ? 'Start a goal in YOLO mode?' + : 'Start a goal with approvals on?'; + } } function styleLabel(label: string, selected: boolean, colors: ColorPalette): string { diff --git a/apps/kimi-code/src/tui/components/editor/custom-editor.ts b/apps/kimi-code/src/tui/components/editor/custom-editor.ts index c77f4af24..a7dfb87fb 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -346,6 +346,7 @@ export class CustomEditor extends Editor { /** * Return a copy of `line` with the first `/token` coloured using `hex`. + * For `/goal next manage`, also colour the command-path tokens. * `line` may already contain SGR escapes (cursor inverse, etc.); we * locate `/` via visible-index math so ANSI pass-through survives. * Returns `undefined` if no token is found. @@ -368,12 +369,60 @@ export function highlightFirstSlashToken(line: string, hex: string): string | un } const visibleToken = visible.slice(slashIdx, endVisible); if (visibleToken.slice(1).includes('/')) return undefined; - const rawStart = mapVisibleIdxToRaw(line, slashIdx); - const rawEnd = mapVisibleIdxToRaw(line, endVisible); - const before = line.slice(0, rawStart); - const token = line.slice(rawStart, rawEnd); - const after = line.slice(rawEnd); - return before + chalk.hex(hex).bold(token) + after; + const ranges = [{ start: slashIdx, end: endVisible }]; + if (visibleToken === '/goal') { + ranges.push(...goalCommandPathRanges(visible, endVisible)); + } + return highlightVisibleRanges(line, ranges, hex); +} + +function goalCommandPathRanges( + visible: string, + commandEnd: number, +): Array<{ start: number; end: number }> { + const nextRange = readTokenRange(visible, commandEnd); + if (nextRange === null || visible.slice(nextRange.start, nextRange.end) !== 'next') { + return []; + } + const ranges = [nextRange]; + const manageRange = readTokenRange(visible, nextRange.end); + if (manageRange !== null && visible.slice(manageRange.start, manageRange.end) === 'manage') { + ranges.push(manageRange); + } + return ranges; +} + +function readTokenRange( + visible: string, + start: number, +): { start: number; end: number } | null { + let tokenStart = start; + while (tokenStart < visible.length && isTokenSpace(visible[tokenStart])) tokenStart++; + if (tokenStart >= visible.length) return null; + let tokenEnd = tokenStart; + while (tokenEnd < visible.length && !isTokenSpace(visible[tokenEnd])) tokenEnd++; + return { start: tokenStart, end: tokenEnd }; +} + +function isTokenSpace(ch: string | undefined): boolean { + return ch === ' ' || ch === '\t'; +} + +function highlightVisibleRanges( + line: string, + ranges: Array<{ start: number; end: number }>, + hex: string, +): string { + let out = ''; + let rawCursor = 0; + for (const range of ranges) { + const rawStart = mapVisibleIdxToRaw(line, range.start); + const rawEnd = mapVisibleIdxToRaw(line, range.end); + out += line.slice(rawCursor, rawStart); + out += chalk.hex(hex).bold(line.slice(rawStart, rawEnd)); + rawCursor = rawEnd; + } + return out + line.slice(rawCursor); } /** diff --git a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts index d2a7d39c1..dee3192d1 100644 --- a/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts +++ b/apps/kimi-code/src/tui/components/editor/file-mention-provider.ts @@ -69,12 +69,23 @@ export class FileMentionProvider implements AutocompleteProvider { cursorCol: number, options: { signal: AbortSignal; force?: boolean }, ): Promise { - const textBeforeCursor = (lines[cursorLine] ?? '').slice(0, cursorCol); + const currentLine = lines[cursorLine] ?? ''; + const textBeforeCursor = currentLine.slice(0, cursorCol); const atPrefix = extractAtPrefix(textBeforeCursor); // Non-`@` branch (slash commands, `/path`, quoted paths) — pi-tui - // already owns the edge cases. No intercept. + // already owns the edge cases. We only suppress slash-argument + // completions when accepting one would insert before existing text. if (atPrefix === null) { + if ( + shouldSuppressSlashArgumentCompletion( + textBeforeCursor, + currentLine.slice(cursorCol), + options.force, + ) + ) { + return null; + } return this.inner.getSuggestions(lines, cursorLine, cursorCol, options); } @@ -142,6 +153,17 @@ function extractAtPrefix(text: string): string | null { return text.slice(tokenStart); } +function shouldSuppressSlashArgumentCompletion( + textBeforeCursor: string, + textAfterCursor: string, + force: boolean | undefined, +): boolean { + if (force === true) return false; + if (!textBeforeCursor.startsWith('/')) return false; + if (!textBeforeCursor.includes(' ')) return false; + return textAfterCursor.trimStart().length > 0; +} + /** True when any path segment starts with a dot (e.g. `.github/x.yml`). */ function containsDotSegment(path: string): boolean { for (const segment of path.split('/')) { diff --git a/apps/kimi-code/src/tui/components/messages/goal-panel.ts b/apps/kimi-code/src/tui/components/messages/goal-panel.ts index 17c6ffaaa..f5914e779 100644 --- a/apps/kimi-code/src/tui/components/messages/goal-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/goal-panel.ts @@ -29,6 +29,12 @@ const MAX_OBJECTIVE_LINES = 6; const MAX_CRITERION_LINES = 3; const LABEL_WIDTH = 11; +function renderLifecycleLine(label: string, colors: ColorPalette): string[] { + const marker = chalk.hex(colors.primary).bold(STATUS_BULLET); + const text = chalk.hex(colors.primary).bold(label); + return ['', marker + text]; +} + /** * The "Goal set" confirmation shown after `/goal `. The objective is * rendered as the following user prompt, so this message only marks the state @@ -40,9 +46,20 @@ export class GoalSetMessageComponent implements Component { invalidate(): void {} render(_width: number): string[] { - const marker = chalk.hex(this.colors.primary).bold(STATUS_BULLET); - const label = chalk.hex(this.colors.primary).bold('Goal set'); - return ['', marker + label]; + return renderLifecycleLine('Goal set', this.colors); + } +} + +export class UpcomingGoalAddedMessageComponent implements Component { + constructor(private readonly colors: ColorPalette) {} + + invalidate(): void {} + + render(_width: number): string[] { + return renderLifecycleLine( + 'Upcoming goal added. It will start after the current goal is complete.', + this.colors, + ); } } @@ -182,7 +199,7 @@ function statusHex(status: GoalStatus, colors: ColorPalette): string { return colors.success; case 'blocked': return colors.warning; - default: // paused + case 'paused': return colors.textDim; } } @@ -199,7 +216,7 @@ function formatElapsed(ms: number): string { /** Word-wrap to `width`, capped at `maxLines` (last line gets an ellipsis when clipped). */ function wrap(text: string, width: number, maxLines: number): string[] { - const words = text.replace(/\s+/g, ' ').trim().split(' '); + const words = text.replaceAll(/\s+/g, ' ').trim().split(' '); const lines: string[] = []; let current = ''; for (const word of words) { diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 3c80f5ce5..ee7f8977e 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -50,7 +50,12 @@ import { serializeToolResultOutput, stringValue, } from '../utils/event-payload'; -import { readGoalQueue, removeGoalQueueItem, restoreGoalQueueItem } from '../goal-queue-store'; +import { + readGoalQueue, + removeGoalQueueItem, + restoreGoalQueueItem, + type UpcomingGoal, +} from '../goal-queue-store'; import { formatBackgroundAgentTranscript } from '../utils/background-agent-status'; import { formatBackgroundTaskTranscript } from '../utils/background-task-status'; import { formatHookResultMarkdown, formatHookResultPlain } from '../utils/hook-result-format'; @@ -122,6 +127,7 @@ export class SessionEventHandler { private goalCompletionAwaitingClear = false; private goalCompletionTurnEnded = false; private queuedGoalPromotionPending = false; + private queuedGoalPromotionInFlight = false; private queuedGoalPromotionTimer: ReturnType | undefined; resetRuntimeState(): void { @@ -135,6 +141,7 @@ export class SessionEventHandler { this.goalCompletionAwaitingClear = false; this.goalCompletionTurnEnded = false; this.queuedGoalPromotionPending = false; + this.queuedGoalPromotionInFlight = false; this.clearQueuedGoalPromotionTimer(); this.stopAllMcpServerStatusSpinners(); } @@ -621,19 +628,29 @@ export class SessionEventHandler { private scheduleQueuedGoalPromotion(): void { if (!this.queuedGoalPromotionPending || !this.goalCompletionTurnEnded) return; + if (this.queuedGoalPromotionInFlight) return; if (this.queuedGoalPromotionTimer !== undefined) return; this.queuedGoalPromotionTimer = setTimeout(() => { this.queuedGoalPromotionTimer = undefined; if (!this.queuedGoalPromotionPending || !this.goalCompletionTurnEnded) return; - if ( - this.host.state.appState.streamingPhase !== 'idle' || - this.host.state.queuedMessages.length > 0 - ) { + if (this.queuedGoalPromotionInFlight) return; + if (!this.isReadyForQueuedGoalPromotion()) { return; } - this.queuedGoalPromotionPending = false; - this.goalCompletionTurnEnded = false; - void this.promoteNextQueuedGoal(); + this.queuedGoalPromotionInFlight = true; + void this.promoteNextQueuedGoal() + .then((complete) => { + if (complete) { + this.queuedGoalPromotionPending = false; + this.goalCompletionTurnEnded = false; + return; + } + this.goalCompletionTurnEnded = false; + }) + .finally(() => { + this.queuedGoalPromotionInFlight = false; + this.scheduleQueuedGoalPromotion(); + }); }, 0); } @@ -643,49 +660,67 @@ export class SessionEventHandler { this.queuedGoalPromotionTimer = undefined; } - private async promoteNextQueuedGoal(): Promise { + requestQueuedGoalPromotion(): void { + this.queuedGoalPromotionPending = true; + this.goalCompletionTurnEnded = true; + this.scheduleQueuedGoalPromotion(); + } + + retryQueuedGoalPromotion(): void { + this.scheduleQueuedGoalPromotion(); + } + + private isReadyForQueuedGoalPromotion(session?: Session): boolean { + return ( + (session === undefined || this.host.session === session) && + !this.host.aborted && + this.host.state.appState.streamingPhase === 'idle' && + this.host.state.queuedMessages.length === 0 + ); + } + + private async promoteNextQueuedGoal(): Promise { const { host } = this; const session = host.session; - if (session === undefined || host.aborted) return; + if (session === undefined || host.aborted) return true; let queue; try { queue = await readGoalQueue(session); } catch (error) { host.showError(`Failed to read upcoming goals: ${formatErrorMessage(error)}`); - return; + return false; } - if (host.session !== session || host.aborted) return; + if (host.session !== session || host.aborted) return true; const next = queue.goals[0]; - if (next === undefined) return; + if (next === undefined) return true; - await startGoalCommand( + if (!this.isReadyForQueuedGoalPromotion(session)) return false; + + const started = await startGoalCommand( host, { kind: 'create', objective: next.objective, replace: false }, next.objective, { beforeSend: async () => { - if (host.session !== session || host.aborted) return false; + if (!this.isReadyForQueuedGoalPromotion(session)) { + await this.cancelStartedQueuedGoal(session); + return false; + } try { await removeGoalQueueItem(session, { goalId: next.id }); } catch (error) { host.showError( `Queued goal started, but could not be removed from the queue: ${formatErrorMessage(error)}`, ); + await this.cancelStartedQueuedGoal(session); return false; } - if (host.session === session && !host.aborted) return true; - try { - await restoreGoalQueueItem(session, next); - } catch (error) { - host.showError(`Queued goal could not be restored: ${formatErrorMessage(error)}`); - } - try { - await session.cancelGoal(); - } catch (error) { - host.showError(`Queued goal could not be cancelled: ${formatErrorMessage(error)}`); + if (this.isReadyForQueuedGoalPromotion(session)) { + return true; } + await this.restoreAndCancelStartedQueuedGoal(session, next); return false; }, sendInput: (objective) => { @@ -693,6 +728,27 @@ export class SessionEventHandler { }, }, ); + return started || host.session !== session || host.aborted; + } + + private async restoreAndCancelStartedQueuedGoal( + session: Session, + goal: UpcomingGoal, + ): Promise { + try { + await restoreGoalQueueItem(session, goal); + } catch (error) { + this.host.showError(`Queued goal could not be restored: ${formatErrorMessage(error)}`); + } + await this.cancelStartedQueuedGoal(session); + } + + private async cancelStartedQueuedGoal(session: Session): Promise { + try { + await session.cancelGoal(); + } catch (error) { + this.host.showError(`Queued goal could not be cancelled: ${formatErrorMessage(error)}`); + } } private async notifyQueuedGoalWaitingOnBlocked(): Promise { diff --git a/apps/kimi-code/src/tui/goal-queue-store.ts b/apps/kimi-code/src/tui/goal-queue-store.ts index 3848632e6..0b98eda67 100644 --- a/apps/kimi-code/src/tui/goal-queue-store.ts +++ b/apps/kimi-code/src/tui/goal-queue-store.ts @@ -151,10 +151,11 @@ async function readQueueFile(session: GoalQueueSession): Promise let parsed: unknown; try { parsed = JSON.parse(raw); - } catch { - const empty = emptyQueueFile(); - await writeQueueFile(session, empty); - return empty; + } catch (error) { + throw new KimiError( + ErrorCodes.CONFIG_INVALID, + `Invalid JSON in goal queue: ${describeError(error)}`, + ); } if (!isGoalQueueFile(parsed)) { @@ -262,3 +263,7 @@ function timestampAfter(previous: string): string { function isErrno(error: unknown, code: string): boolean { return isRecord(error) && error['code'] === code; } + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index cd777a0f6..b1b5f7643 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -827,6 +827,10 @@ export class KimiTUI { }); } + requestQueuedGoalPromotion(): void { + this.sessionEventHandler.requestQueuedGoalPromotion(); + } + private sendMessageInternal(session: Session, input: string, options?: SendMessageOptions): void { const imageAttachmentIds = options?.imageAttachmentIds !== undefined && options.imageAttachmentIds.length > 0 @@ -965,7 +969,10 @@ export class KimiTUI { if ('planMode' in patch) this.updateEditorBorderHighlight(); this.state.footer.setState(this.state.appState); this.updateActivityPane(); - if (busyChanged) this.updateQueueDisplay(); + if (busyChanged) { + this.updateQueueDisplay(); + this.sessionEventHandler.retryQueuedGoalPromotion(); + } this.state.ui.requestRender(); } diff --git a/apps/kimi-code/test/tui/commands/goal.test.ts b/apps/kimi-code/test/tui/commands/goal.test.ts index 8c532f233..9b55fbe3b 100644 --- a/apps/kimi-code/test/tui/commands/goal.test.ts +++ b/apps/kimi-code/test/tui/commands/goal.test.ts @@ -92,7 +92,9 @@ function makeHost( const session = { setPermission: vi.fn(async () => {}), createGoal: vi.fn(async () => fakeSnapshot()), - getGoal: vi.fn(async () => ({ goal: null })), + getGoal: vi.fn(async (): Promise<{ goal: ReturnType | null }> => ({ + goal: null, + })), pauseGoal: vi.fn(async () => fakeSnapshot()), resumeGoal: vi.fn(async () => fakeSnapshot()), cancelGoal: vi.fn(async () => fakeSnapshot()), @@ -123,6 +125,7 @@ function makeHost( restoreEditor: vi.fn(), restoreInputText: vi.fn(), sendNormalUserInput: vi.fn(), + requestQueuedGoalPromotion: vi.fn(), cancelInFlight: vi.fn(), track: vi.fn(), } as unknown as SlashCommandHost; @@ -357,6 +360,66 @@ describe('handleGoalCommand', () => { expect(s.createGoal).not.toHaveBeenCalled(); }); + it('asks before starting a goal in YOLO mode', async () => { + const { host: yoloHost, session: s } = makeHost({ permissionMode: 'yolo' }); + + await handleGoalCommand(yoloHost, 'Ship feature X'); + + expect(yoloHost.mountEditorReplacement).toHaveBeenCalledOnce(); + expect(s.createGoal).not.toHaveBeenCalled(); + expect(yoloHost.sendNormalUserInput).not.toHaveBeenCalled(); + const text = stripAnsi(mountedPicker(yoloHost).render(80).join('\n')); + expect(text).toContain('YOLO mode can still stop for questions'); + expect(text).toContain('Keep YOLO and start'); + expect(text).not.toContain('Start in Manual'); + }); + + it('defaults to Auto when confirming a YOLO-mode goal start', async () => { + const { host: yoloHost, session: s } = makeHost({ permissionMode: 'yolo' }); + + await handleGoalCommand(yoloHost, 'Ship feature X'); + mountedPicker(yoloHost).handleInput(ENTER); + + await vi.waitFor(() => { + expect(s.createGoal).toHaveBeenCalledWith( + expect.objectContaining({ objective: 'Ship feature X' }), + ); + }); + expect(s.setPermission).toHaveBeenCalledWith('auto'); + expect(yoloHost.setAppState).toHaveBeenCalledWith({ permissionMode: 'auto' }); + expect(yoloHost.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); + }); + + it('can keep YOLO when starting a YOLO-mode goal', async () => { + const { host: yoloHost, session: s } = makeHost({ permissionMode: 'yolo' }); + + await handleGoalCommand(yoloHost, 'Ship feature X'); + const picker = mountedPicker(yoloHost); + picker.handleInput(DOWN); + picker.handleInput(ENTER); + + await vi.waitFor(() => { + expect(s.createGoal).toHaveBeenCalledWith( + expect.objectContaining({ objective: 'Ship feature X' }), + ); + }); + expect(s.setPermission).not.toHaveBeenCalled(); + expect(yoloHost.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); + }); + + it('returns the command to the input box when a YOLO-mode goal start is cancelled', async () => { + const { host: yoloHost, session: s } = makeHost({ permissionMode: 'yolo' }); + + await handleGoalCommand(yoloHost, 'replace Ship feature Y'); + const picker = mountedPicker(yoloHost); + picker.handleInput(DOWN); + picker.handleInput(DOWN); + picker.handleInput(ENTER); + + expect(yoloHost.restoreInputText).toHaveBeenCalledWith('/goal replace Ship feature Y'); + expect(s.createGoal).not.toHaveBeenCalled(); + }); + it('does not pass budget limits (flags were removed)', async () => { await handleGoalCommand(host, 'Ship feature X'); const arg = (session.createGoal as ReturnType).mock.calls[0]?.[0] as Record< @@ -380,27 +443,93 @@ describe('handleGoalCommand', () => { }); it('/goal next queues an upcoming goal and does not send it to the agent', async () => { + session.getGoal.mockResolvedValueOnce({ goal: fakeSnapshot() }); + await handleGoalCommand(host, 'next Ship release notes'); + + expect(session.getGoal).toHaveBeenCalledOnce(); expect(appendGoalQueueItem).toHaveBeenCalledWith(session, { objective: 'Ship release notes', }); expect(host.track).toHaveBeenCalledWith('goal_queue_append'); - expect(host.showStatus).toHaveBeenCalledWith( + expect(host.showStatus).not.toHaveBeenCalledWith( 'Upcoming goal added. It will start after the current goal is complete.', ); + const addChild = host.state.transcriptContainer.addChild as ReturnType; + const message = addChild.mock.calls[0]?.[0] as { render(width: number): string[] }; + expect(stripAnsi(message.render(80).join('\n'))).toBe( + '\n● Upcoming goal added. It will start after the current goal is complete.', + ); + expect(host.state.ui.requestRender).toHaveBeenCalled(); expect(host.sendNormalUserInput).not.toHaveBeenCalled(); expect(session.createGoal).not.toHaveBeenCalled(); }); - it('/goal next does not require a configured model', async () => { + it('/goal next starts immediately when there is no current goal', async () => { + await handleGoalCommand(host, 'next Ship release notes'); + + expect(session.getGoal).toHaveBeenCalledOnce(); + expect(appendGoalQueueItem).not.toHaveBeenCalled(); + expect(session.createGoal).toHaveBeenCalledWith( + expect.objectContaining({ objective: 'Ship release notes', replace: false }), + ); + expect(host.showStatus).toHaveBeenCalledWith( + 'No active goal. Starting this goal now.', + ); + expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship release notes'); + }); + + it('/goal next queues instead of starting immediately while streaming with no current goal', async () => { + const { host: streamingHost, session: s } = makeHost({ streaming: true }); + + await handleGoalCommand(streamingHost, 'next Ship release notes'); + + expect(s.getGoal).toHaveBeenCalledOnce(); + expect(appendGoalQueueItem).toHaveBeenCalledWith(s, { + objective: 'Ship release notes', + }); + expect(streamingHost.requestQueuedGoalPromotion).toHaveBeenCalledOnce(); + expect(s.createGoal).not.toHaveBeenCalled(); + expect(streamingHost.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('/goal next follows the normal goal-start prompt when there is no current goal', async () => { + const { host: manualHost, session: s } = makeHost({ permissionMode: 'manual' }); + + await handleGoalCommand(manualHost, 'next Ship release notes'); + + expect(s.getGoal).toHaveBeenCalledOnce(); + expect(appendGoalQueueItem).not.toHaveBeenCalled(); + expect(manualHost.mountEditorReplacement).toHaveBeenCalledOnce(); + expect(s.createGoal).not.toHaveBeenCalled(); + + mountedPicker(manualHost).handleInput(ESCAPE); + expect(manualHost.restoreInputText).toHaveBeenCalledWith('/goal next Ship release notes'); + }); + + it('/goal next does not require a configured model when queueing after a current goal', async () => { const { host: noModelHost, session: s } = makeHost({ model: '' }); + s.getGoal.mockResolvedValueOnce({ goal: fakeSnapshot() }); + await handleGoalCommand(noModelHost, 'next Ship release notes'); + expect(appendGoalQueueItem).toHaveBeenCalledWith(s, { objective: 'Ship release notes', }); expect(noModelHost.showError).not.toHaveBeenCalled(); }); + it('/goal next requires a configured model when it starts immediately', async () => { + const { host: noModelHost, session: s } = makeHost({ model: '' }); + + await handleGoalCommand(noModelHost, 'next Ship release notes'); + + expect(s.getGoal).toHaveBeenCalledOnce(); + expect(appendGoalQueueItem).not.toHaveBeenCalled(); + expect(s.createGoal).not.toHaveBeenCalled(); + expect(noModelHost.showError).toHaveBeenCalled(); + }); + it('/goal next manage opens the upcoming goal manager without sending input', async () => { await handleGoalCommand(host, 'next manage'); diff --git a/apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts b/apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts index 1e075dc74..23062ea2b 100644 --- a/apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts @@ -12,6 +12,9 @@ import type { GoalQueueSnapshot, UpcomingGoal } from '#/tui/goal-queue-store'; const ANSI = /\u001B\[[0-9;]*m/g; const strip = (s: string): string => s.replaceAll(ANSI, ''); const ESC = String.fromCodePoint(27); +const CTRL_J = '\u001B[106;5u'; +const BRACKET_PASTE_START = '\u001B[200~'; +const BRACKET_PASTE_END = '\u001B[201~'; const UP = `${ESC}[A`; const DOWN = `${ESC}[B`; @@ -165,6 +168,20 @@ describe('GoalQueueManagerComponent', () => { } } }); + + it('renders multiline objectives as a single selectable row', () => { + const manager = new GoalQueueManagerComponent({ + goals: [goal('g1', 'First line\nSecond line')], + colors: darkColors, + onAction: vi.fn(), + onCancel: vi.fn(), + }); + + const lines = manager.render(100).map(strip); + + expect(lines.some((line) => line.includes('❯ 1. First line Second line'))).toBe(true); + expect(lines.some((line) => line.trim() === 'Second line')).toBe(false); + }); }); describe('GoalQueueEditDialogComponent', () => { @@ -186,6 +203,61 @@ describe('GoalQueueEditDialogComponent', () => { }); }); + it('supports multiline objective edits', () => { + const onDone = vi.fn(); + const dialog = new GoalQueueEditDialogComponent({ + goal: goal('g1', 'Ship queued goal'), + colors: darkColors, + onDone, + }); + + dialog.handleInput(CTRL_J); + dialog.handleInput('with a second line'); + dialog.handleInput('\r'); + + expect(onDone).toHaveBeenCalledWith({ + kind: 'save', + goalId: 'g1', + objective: 'Ship queued goal\nwith a second line', + }); + }); + + it('sanitizes bracketed paste while preserving newlines', () => { + const onDone = vi.fn(); + const dialog = new GoalQueueEditDialogComponent({ + goal: goal('g1', 'Ship queued goal'), + colors: darkColors, + onDone, + }); + + dialog.handleInput( + `${BRACKET_PASTE_START} \u001B[31mred\u001B[0m\nnext\u0007 line${BRACKET_PASTE_END}`, + ); + dialog.handleInput('\r'); + + expect(onDone).toHaveBeenCalledWith({ + kind: 'save', + goalId: 'g1', + objective: 'Ship queued goal red\nnext line', + }); + }); + + it('renders multiline edits inside the dialog width', () => { + const dialog = new GoalQueueEditDialogComponent({ + goal: goal('g1', 'First line\nSecond line'), + colors: darkColors, + onDone: vi.fn(), + }); + + const out = text(dialog, 36); + + expect(out).toContain('> First line'); + expect(out).toContain(' Second line'); + for (const line of dialog.render(36)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(36); + } + }); + it('keeps accepting input after save returns control to the mounted dialog', () => { const onDone = vi.fn(); const dialog = new GoalQueueEditDialogComponent({ diff --git a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts index 496aa4ce3..209ab050b 100644 --- a/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts +++ b/apps/kimi-code/test/tui/components/editor/file-mention-provider.test.ts @@ -27,6 +27,19 @@ function ctrl(): AbortSignal { } const NO_FD = null; +const GOAL_COMMAND = { + name: 'goal', + description: 'Start or manage a goal', + getArgumentCompletions: (prefix: string) => + prefix.length === 0 + ? [ + { + value: 'status', + label: 'status', + }, + ] + : null, +}; describe('FileMentionProvider — @ prefix detection + git-backed suggestions', () => { it('returns null when there is no @ mention and the dir is empty', async () => { @@ -36,6 +49,22 @@ describe('FileMentionProvider — @ prefix detection + git-backed suggestions', expect(result).toBeNull(); }); + it('does not complete slash arguments before existing free text', async () => { + const provider = new FileMentionProvider([GOAL_COMMAND], '/repo', NO_FD, stubGitCache([])); + const line = '/goal Fix the checkout docs'; + const result = await provider.getSuggestions([line], 0, '/goal '.length, { signal: ctrl() }); + expect(result).toBeNull(); + }); + + it('still completes slash arguments at the end of an empty argument', async () => { + const provider = new FileMentionProvider([GOAL_COMMAND], '/repo', NO_FD, stubGitCache([])); + const line = '/goal '; + const result = await provider.getSuggestions([line], 0, line.length, { signal: ctrl() }); + expect(result).not.toBeNull(); + expect(result!.prefix).toBe(''); + expect(result!.items.map((item) => item.value)).toEqual(['status']); + }); + it('bare @ surfaces the first files as a starting list', async () => { const files = ['a.ts', 'b.ts', 'src/c.ts']; const provider = new FileMentionProvider([], '/repo', NO_FD, stubGitCache(files)); diff --git a/apps/kimi-code/test/tui/components/editor/slash-highlight.test.ts b/apps/kimi-code/test/tui/components/editor/slash-highlight.test.ts index 04826e604..1ac26c70e 100644 --- a/apps/kimi-code/test/tui/components/editor/slash-highlight.test.ts +++ b/apps/kimi-code/test/tui/components/editor/slash-highlight.test.ts @@ -14,6 +14,10 @@ function strip(s: string): string { return s.replaceAll(/\u001B\[[0-9;]*m/g, ''); } +function expectHighlighted(out: string, token: string): void { + expect(out).toMatch(new RegExp(`\\u001B\\[[0-9;]*m${token}\\u001B\\[`)); +} + describe('highlightFirstSlashToken', () => { it('colours /cmd when line starts with a slash', () => { const out = highlightFirstSlashToken(' /help rest of input', '#ff00aa'); @@ -21,7 +25,25 @@ describe('highlightFirstSlashToken', () => { // Visible text unchanged expect(strip(out!)).toBe(' /help rest of input'); // SGR escapes surround /help - expect(out!).toMatch(/\u001B\[.*m\/help\u001B\[/); + expectHighlighted(out!, '/help'); + }); + + it('colours next in /goal next', () => { + const out = highlightFirstSlashToken('/goal next Ship feature X', '#ff00aa'); + expect(out).toBeDefined(); + expect(strip(out!)).toBe('/goal next Ship feature X'); + expectHighlighted(out!, '/goal'); + expectHighlighted(out!, 'next'); + expect(out!).toContain(' Ship feature X'); + }); + + it('colours manage in /goal next manage', () => { + const out = highlightFirstSlashToken('/goal next manage', '#ff00aa'); + expect(out).toBeDefined(); + expect(strip(out!)).toBe('/goal next manage'); + expectHighlighted(out!, '/goal'); + expectHighlighted(out!, 'next'); + expectHighlighted(out!, 'manage'); }); it('returns undefined when the line has no slash', () => { diff --git a/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts b/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts index 419355b7f..e56bde8c2 100644 --- a/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/goal-panel.test.ts @@ -6,6 +6,7 @@ import { GoalCompletionMessageComponent, GoalSetMessageComponent, GoalStatusMessageComponent, + UpcomingGoalAddedMessageComponent, goalPanelTitle, } from '#/tui/components/messages/goal-panel'; import { STATUS_BULLET } from '#/tui/constant/symbols'; @@ -116,6 +117,22 @@ describe('GoalSetMessageComponent', () => { }); }); +describe('UpcomingGoalAddedMessageComponent', () => { + it('renders the upcoming-goal confirmation like the goal-set lifecycle line', () => { + const rendered = new UpcomingGoalAddedMessageComponent(darkColors).render(80); + + expect(strip(rendered)).toBe( + '\n● Upcoming goal added. It will start after the current goal is complete.', + ); + expect(rendered[1]).toBe( + chalk.hex(darkColors.primary).bold(STATUS_BULLET) + + chalk.hex(darkColors.primary).bold( + 'Upcoming goal added. It will start after the current goal is complete.', + ), + ); + }); +}); + describe('GoalStatusMessageComponent', () => { it('adds a blank line before the status box', () => { const rendered = new GoalStatusMessageComponent(goal(), darkColors).render(80); diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts index 2b220d241..e3fa47500 100644 --- a/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts @@ -237,6 +237,31 @@ describe('SessionEventHandler goal queue promotion', () => { expect(session.createGoal).toHaveBeenCalledOnce(); }); + it('retries the queued goal on a later idle event after startup fails', async () => { + const { host, session } = makeHost(); + session.createGoal.mockRejectedValueOnce(new Error('create failed')); + const handler = new SessionEventHandler(host); + const sendQueued = sendQueuedViaHost(host, session); + + handler.handleEvent(completionEvent(), sendQueued); + handler.handleEvent(clearedEvent(), sendQueued); + handler.handleEvent(turnEndedEvent(), sendQueued); + + await vi.waitFor(() => { + expect(host.showError).toHaveBeenCalledWith(expect.stringContaining('create failed')); + }); + expect(removeGoalQueueItem).not.toHaveBeenCalled(); + expect(host.sendQueuedMessage).not.toHaveBeenCalled(); + + handler.handleEvent(turnEndedEvent(), sendQueued); + + await vi.waitFor(() => { + expect(session.createGoal).toHaveBeenCalledTimes(2); + }); + expect(removeGoalQueueItem).toHaveBeenCalledWith(session, { goalId: 'q1' }); + expect(host.sendQueuedMessage).toHaveBeenCalledWith(session, { text: 'Ship queued goal' }); + }); + it('does not send the queued objective when removal fails after goal creation', async () => { vi.mocked(removeGoalQueueItem).mockRejectedValueOnce(new Error('remove failed')); const { host, session } = makeHost(); @@ -253,6 +278,8 @@ describe('SessionEventHandler goal queue promotion', () => { objective: 'Ship queued goal', replace: false, }); + expect(session.cancelGoal).toHaveBeenCalledOnce(); + expect(restoreGoalQueueItem).not.toHaveBeenCalled(); expect(host.sendNormalUserInput).not.toHaveBeenCalled(); expect(host.sendQueuedMessage).not.toHaveBeenCalled(); }); @@ -281,6 +308,30 @@ describe('SessionEventHandler goal queue promotion', () => { expect(host.sendQueuedMessage).not.toHaveBeenCalled(); }); + it('restores and cancels when the host becomes busy before sending the promoted goal', async () => { + const { host, session } = makeHost(); + vi.mocked(removeGoalQueueItem).mockImplementationOnce(async () => { + host.setAppState({ streamingPhase: 'waiting' }); + return { goals: [] }; + }); + const handler = new SessionEventHandler(host); + + handler.handleEvent(completionEvent(), vi.fn()); + handler.handleEvent(clearedEvent(), vi.fn()); + handler.handleEvent(turnEndedEvent(), sendQueuedViaHost(host, session)); + + await vi.waitFor(() => { + expect(restoreGoalQueueItem).toHaveBeenCalledWith(session, { + id: 'q1', + objective: 'Ship queued goal', + createdAt: '', + updatedAt: '', + }); + }); + expect(session.cancelGoal).toHaveBeenCalledOnce(); + expect(host.sendQueuedMessage).not.toHaveBeenCalled(); + }); + it('shows a notice when a blocked goal has queued goals', async () => { const { host, session } = makeHost(); const handler = new SessionEventHandler(host); diff --git a/apps/kimi-code/test/tui/goal-queue-store.test.ts b/apps/kimi-code/test/tui/goal-queue-store.test.ts index 721d24d18..d1efba07b 100644 --- a/apps/kimi-code/test/tui/goal-queue-store.test.ts +++ b/apps/kimi-code/test/tui/goal-queue-store.test.ts @@ -145,6 +145,15 @@ describe('goal queue store', () => { await expect(readQueueFile()).resolves.toEqual({ version: 1, goals: [] }); }); + it('does not clear the queue file when JSON cannot be parsed', async () => { + const partial = '{"version":1,"goals":['; + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, QUEUE_FILE), partial, 'utf-8'); + + await expect(readGoalQueue(session())).rejects.toThrow('Invalid JSON in goal queue'); + await expect(readFile(join(dir, QUEUE_FILE), 'utf-8')).resolves.toBe(partial); + }); + it('throws when the session summary does not expose a session directory', async () => { await expect(readGoalQueue({ id: 'missing', summary: undefined })).rejects.toThrow( 'Session missing does not expose a session directory', diff --git a/docs/en/guides/goals.md b/docs/en/guides/goals.md index d9d1770b1..49c6e27db 100644 --- a/docs/en/guides/goals.md +++ b/docs/en/guides/goals.md @@ -114,6 +114,8 @@ Write stop conditions into the objective. `/goal` does not have a separate stop- ## Queue upcoming goals +Agents sometimes complete a goal too quickly. Users can be disappointed that they can assign only one goal at a time. Many people already know the upcoming goals they want to pursue. They had to wait for the current goal to complete, open the TUI, and submit the next goal manually. + Use `/goal next` when you have more work ready but do not want to interrupt the current goal: ```sh @@ -122,19 +124,15 @@ Use `/goal next` when you have more work ready but do not want to interrupt the Upcoming goals are not visible to the agent while the current goal is running. When the current goal completes, Kimi Code starts the first upcoming goal in the same way as users enter `/goal `. -Manage upcoming goals interactively with `/goal next manage`: +If no goal is active, `/goal next ` starts that objective immediately. It behaves like `/goal ` and shows a status message before the goal starts. + +Manage upcoming goals interactively: ```sh /goal next manage ``` -| Key | Action | -| --- | --- | -| / | Browse the list | -| Space | Select a goal for moving, then / to reorder | -| E | Edit | -| D | Delete | -| Esc | Cancel | +In the manager, use / to browse, Space to select a goal for moving, / to reorder it, E to edit, D to delete, and Esc to cancel. When editing, use Shift-Enter or Ctrl-J to add a new line, and Enter to save. If the current goal is paused, canceled, or blocked, Kimi Code does not start the next upcoming goal. When a goal blocks and upcoming goals exist, the TUI reminds you that they wait for completion. diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index ad55c7862..d9a1cc8f9 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -80,8 +80,8 @@ KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND=1 kimi | `/goal resume` | Resume a paused or blocked goal | Idle only | | `/goal cancel` | Remove the current goal | Always available | | `/goal replace ` | Replace the saved goal with a new objective | Idle only | -| `/goal next ` | Queue an upcoming goal for this session. The agent does not see it until the current goal completes | Always available | -| `/goal next manage` | Open the upcoming-goal manager. Use `↑`/`↓` to browse, `Space` to select a goal for moving, selected `↑`/`↓` to reorder it, `E` to edit, `D` to delete, and `Esc` to cancel | Always available | +| `/goal next ` | Queue an upcoming goal for this session. If no goal is active, start it immediately. The agent does not see queued goals until the current goal completes | Always available | +| `/goal next manage` | Open the upcoming-goal manager. Use / to browse, Space to select a goal for moving, selected / to reorder it, E to edit, D to delete, and Esc to cancel. In the edit field, use Shift-Enter or Ctrl-J for a new line and Enter to save | Always available | The words `status`, `pause`, `resume`, `cancel`, `replace`, and `next` act as subcommands only when they are the first word after `/goal`. If your objective needs to start with one of those words, put `--` before it: diff --git a/docs/zh/guides/goals.md b/docs/zh/guides/goals.md index 569ee0bef..bb69878c3 100644 --- a/docs/zh/guides/goals.md +++ b/docs/zh/guides/goals.md @@ -114,6 +114,8 @@ Kimi Code 会保存该目标,把它作为下一条用户消息发送,并进 ## 安排后续目标 +Agent 有时会很快完成一个目标。如果一次只能安排一个目标,用户可能会失望。很多人已经知道接下来想完成哪些后续目标,但原来需要等当前目标完成后,打开 TUI,再手动提交下一个目标。 + 如果已经准备好更多工作,但不想中断当前目标,使用 `/goal next`: ```sh @@ -122,19 +124,15 @@ Kimi Code 会保存该目标,把它作为下一条用户消息发送,并进 当前目标运行期间,安排的后续目标对 Agent 不可见。当前目标完成后,Kimi Code 会用与 `/goal ` 相同的效果开始第一个后续目标。 -使用 `/goal next manage` 交互式管理后续目标队列: +如果当前没有目标,`/goal next ` 会立即开始这个目标。它的效果与 `/goal ` 相同,并会在目标开始前显示一条状态消息。 + +交互式管理后续目标: ```sh /goal next manage ``` -| 按键 | 作用 | -| --- | --- | -| / | 浏览列表 | -| Space | 选中目标以便移动,再按 / 调整顺序 | -| E | 编辑 | -| D | 删除 | -| Esc | 取消 | +在管理器中,用 / 浏览,Space 选择一个目标以便移动,选中后用 / 调整顺序,E 编辑,D 删除,Esc 取消。编辑时,用 Shift-EnterCtrl-J 添加新行,用 Enter 保存。 如果当前目标被暂停、取消或阻塞,Kimi Code 不会开始下一个后续目标。当目标进入「阻塞(`blocked`)」状态且存在后续目标时,TUI 会提醒你,这些后续目标会等待当前目标完成。 diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index b62e44d4f..eebb55ac1 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -78,8 +78,8 @@ KIMI_CODE_EXPERIMENTAL_GOAL_COMMAND=1 kimi | `/goal resume` | 继续被暂停或被阻塞的目标 | 仅空闲时 | | `/goal cancel` | 移除当前目标 | 随时可用 | | `/goal replace ` | 用新目标替换已保存的目标 | 仅空闲时 | -| `/goal next ` | 为当前会话安排一个后续目标。当前目标完成前,Agent 不会看到它 | 随时可用 | -| `/goal next manage` | 打开后续目标管理器。用 `↑`/`↓` 浏览,`Space` 选择一个目标以便移动,选中后用 `↑`/`↓` 调整顺序,`E` 编辑,`D` 删除,`Esc` 取消 | 随时可用 | +| `/goal next ` | 为当前会话安排一个后续目标。如果当前没有目标,则立即开始它。当前目标完成前,Agent 不会看到已排队的目标 | 随时可用 | +| `/goal next manage` | 打开后续目标管理器。用 / 浏览,Space 选择一个目标以便移动,选中后用 / 调整顺序,E 编辑,D 删除,Esc 取消。编辑输入框中,用 Shift-EnterCtrl-J 添加新行,用 Enter 保存 | 随时可用 | `status`、`pause`、`resume`、`cancel`、`replace` 和 `next` 只有作为 `/goal` 后的第一个词时才是子命令。如果你的目标需要以这些词开头,请在目标前加 `--`: diff --git a/plans/2026-06-03-upcoming-goals.md b/plans/2026-06-03-upcoming-goals.md deleted file mode 100644 index 154846d4d..000000000 --- a/plans/2026-06-03-upcoming-goals.md +++ /dev/null @@ -1,228 +0,0 @@ -# Upcoming Goals Queue Plan - -## Goal - -Add a private, per-session queue of upcoming goals in the TUI. - -The user can queue autonomous tasks that should run after the current goal completes. The agent only sees the active goal. It must not see upcoming goals. - -## User Value - -We found that agents sometimes complete a goal too quickly. Users can be disappointed that they can assign only one goal at a time. - -Many users already know the next independent tasks they want to run. Today they need to wait for the current goal to complete, return to the TUI, and submit the next goal manually. - -An upcoming-goals queue lets users prepare several autonomous tasks in one session. The agent still works on one goal at a time, but the TUI can start the next queued goal after the current goal is complete. - -This avoids giving the agent a broad combined goal. It also keeps the next tasks hidden until they become the active goal. - -## Current Goal Behavior - -The current `/goal` command creates one active goal through the session API. - -The goal driver keeps running turns while the goal is `active`. The model ends the loop by calling `UpdateGoal`. - -The TUI receives `goal.updated` events and can observe when the current goal completes. - -## Proposed Commands - -Add these TUI commands: - -```text -/goal next -/goal next manage -``` - -`/goal next ` appends an objective to the upcoming goals queue. - -`/goal next manage` opens an interactive management list. - -Use `--` to force objective parsing when the objective starts with a reserved word: - -```text -/goal next -- manage the release notes -``` - -## Management List - -The management list shows all upcoming goals for the current session. - -Expected controls: - -- Up and Down browse goals. -- Space enters or exits move mode for the focused goal. -- In move mode, Up and Down reorder the selected goal. -- `e` edits the focused goal. -- `d` removes the focused goal. -- Escape closes the list. - -The footer should show the active controls. - -Use wording like: - -```text -↑↓ browse · Space move · e edit · d delete · Esc close -``` - -When move mode is active, the footer should make that state clear. - -## Queue State - -Store the queue per session. - -The queue should survive closing and resuming the TUI session. - -The agent must not receive the queue in prompt injection, tool output, goal reminders, or normal user messages. - -Recommended state shape: - -```ts -interface UpcomingGoal { - id: string; - objective: string; - createdAt: string; - updatedAt: string; -} -``` - -The queue should live outside `metadata.custom.goal`. - -Recommended storage location: - -- `/upcoming-goals.json` - -The SDK `SessionSummary` already exposes `sessionDir` to the TUI. - -This keeps the queue session-scoped without adding upcoming-goal methods or types to RPC or SDK. - -## Promotion Rules - -When the active goal completes: - -1. The TUI observes the `goal.updated` completion event. -2. The TUI reads the first upcoming goal. -3. The TUI removes that item from the queue. -4. The TUI calls `session.createGoal({ objective })`. -5. The TUI sends the objective as normal user input. - -Do not promote the next goal when the current goal becomes `paused`. - -Do not promote the next goal when the current goal is cancelled. - -Do not promote the next goal when the current goal becomes `blocked`. - -When the current goal is blocked and the queue is non-empty, show a TUI notice: - -```text -Goal blocked. The next queued goal will start only after this goal is complete. -``` - -## No Current Goal - -Decision: `/goal next ` queues the goal even when there is no active, paused, or blocked current goal. - -This keeps `/goal next` literal and avoids surprising automatic starts. - -## Architecture - -Keep the feature TUI-owned. - -The TUI should provide persistence and small queue operations. It should not add upcoming goals to agent-core RPC, the node SDK, prompt injection, or agent context. - -Suggested split: - -- `apps/kimi-code/src/tui/goal-queue-store.ts` - - Owns queue state validation, queue operations, and file persistence. - - Stores queue state inside the current session directory. -- `apps/kimi-code/src/tui/commands/goal.ts` - - Parses `/goal next`. - - Adds the queue append and manage entry points. -- `apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts` - - Implements the interactive management list. -- `apps/kimi-code/src/tui/controllers/session-event-handler.ts` - - Promotes the next queued goal after completion. - - Shows the blocked notice when needed. - -## UI Placement - -The management list should be a dialog component mounted with `mountEditorReplacement`. - -Follow the existing selector and dialog patterns in `apps/kimi-code/src/tui/components/dialogs/`. - -Use theme tokens from `ColorPalette`. Do not use chalk named colors. - -## Event Flow - -Completion flow: - -```text -goal.updated(completion) - -> TUI renders completion message - -> TUI checks upcoming queue - -> TUI starts next goal if one exists - -> TUI sends next objective as normal input -``` - -Blocked flow: - -```text -goal.updated(lifecycle, blocked) - -> TUI renders blocked marker - -> TUI checks upcoming queue - -> TUI shows notice if queue is non-empty -``` - -Paused and cancelled flow: - -```text -goal.updated(paused or null) - -> TUI does not start a queued goal -``` - -## Tests - -Add or update tests in the nearest existing test files. - -Recommended coverage: - -- `/goal next ` parses as queue append. -- `/goal next manage` opens the manager. -- `--` lets users queue objectives that start with reserved words. -- The queue persists in session metadata. -- Resuming a session restores the queue. -- Completion promotes the next goal and sends the next objective as normal input. -- Blocked goals do not promote the next item. -- Paused goals do not promote the next item. -- Cancelled goals do not promote the next item. -- The management list can browse, move, edit, and delete entries. - -## Deferred Choices - -- Should there be a footer badge that shows the upcoming goal count? -- Should deleting an item ask for confirmation after the first version? -- Should the queue emit a live `goal.queue.updated` event for non-TUI clients? - -## Suggested First Implementation Slice - -Build the feature in this order: - -1. Add TUI-owned session-level queue persistence. -2. Add `/goal next ` and queue status display. -3. Promote the next queued goal only after completion. -4. Add blocked notice. -5. Add `/goal next manage`. -6. Add reorder, edit, and delete behavior in the manager. - -This keeps each slice testable and avoids mixing persistence, promotion, and interactive editing in one change. - -## Detailed Implementation Plan - -Use these files for the implementation-level plan: - -- `plans/upcoming-goals/00-index.md` -- `plans/upcoming-goals/01-session-queue-store.md` -- `plans/upcoming-goals/02-tui-queue-store.md` -- `plans/upcoming-goals/03-goal-next-command.md` -- `plans/upcoming-goals/04-completion-promotion.md` -- `plans/upcoming-goals/05-management-dialog.md` -- `plans/upcoming-goals/06-verification-docs-changeset.md` diff --git a/plans/upcoming-goals/00-index.md b/plans/upcoming-goals/00-index.md deleted file mode 100644 index 07569a59e..000000000 --- a/plans/upcoming-goals/00-index.md +++ /dev/null @@ -1,49 +0,0 @@ -# Upcoming Goals Queue Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a private, per-session queue of upcoming goals that the TUI can promote after the current goal completes. - -**Architecture:** The TUI owns queue persistence in a session-local file. The TUI owns command parsing, the manager dialog, and promotion after completion. RPC and SDK do not expose upcoming-goal methods or types. - -**Tech Stack:** TypeScript, Vitest, `@earendil-works/pi-tui`, Kimi Code SDK, agent-core session RPC. - ---- - -## Decisions - -- `/goal next ` always queues the objective. -- It queues even when there is no current goal. -- `/goal next manage` opens the manager dialog. -- `/upcoming-goals.json` stores the queue. -- RPC and SDK do not expose upcoming-goal queue methods or types. -- The agent must not see queued goals in prompt injection, system reminders, tools, or user messages. -- The TUI promotes a queued goal only after a completion event and the follow-up `goal.updated` event with `snapshot: null`. -- The TUI must not promote after pause, cancel, or blocked. -- When blocked and the queue is non-empty, the TUI shows a notice that the next goal starts only after completion. -- The first implementation does not add a queue count badge or queue events. - -## Plan Files - -- `01-session-queue-store.md` defines the queue file and TUI store. -- `02-tui-queue-store.md` records the RPC and SDK boundary rule. -- `03-goal-next-command.md` adds `/goal next `. -- `04-completion-promotion.md` promotes queued goals after completion. -- `05-management-dialog.md` adds `/goal next manage`. -- `06-verification-docs-changeset.md` covers final tests, docs, and changeset work. - -## Implementation Order - -- [ ] Build and test the TUI queue store. -- [ ] Wire commands and event handling to the TUI queue store. -- [ ] Add queue append command parsing and handling. -- [ ] Add completion promotion and blocked notice. -- [ ] Add the management dialog with reorder, edit, and delete. -- [ ] Run focused tests, update docs, and create a changeset. - -## Risk Checks - -- Promotion must wait for the null clear event. Creating a new goal during the completion event can race with the current goal clear. -- Queue add and manage must work while the agent is streaming. -- Queue objectives must not be appended to the transcript until the queued goal is promoted. -- Create failure during promotion must leave the queued item in place. diff --git a/plans/upcoming-goals/01-session-queue-store.md b/plans/upcoming-goals/01-session-queue-store.md deleted file mode 100644 index ac82220e0..000000000 --- a/plans/upcoming-goals/01-session-queue-store.md +++ /dev/null @@ -1,163 +0,0 @@ -# Upcoming Goals Queue Store Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Persist upcoming goals in a TUI-owned session file without adding queue methods or types to RPC or SDK. - -**Architecture:** The TUI reads and writes `/upcoming-goals.json`. The store uses `Session.summary.sessionDir`, which already exists in the SDK session summary. - -**Tech Stack:** TypeScript, Node file I/O, Kimi Code TUI, Vitest. - ---- - -### Task 1: Add The Queue Store - -**Files:** - -- Create: `apps/kimi-code/src/tui/goal-queue-store.ts` -- Test: `apps/kimi-code/test/tui/goal-queue-store.test.ts` - -- [ ] **Step 1: Write store tests** - -Cover these cases: - -```ts -it('reads an empty queue when the file is missing', async () => {}); -it('appends a trimmed upcoming goal and writes the session file', async () => {}); -it('updates an upcoming goal objective', async () => {}); -it('removes an upcoming goal by id', async () => {}); -it('moves an upcoming goal up and down', async () => {}); -it('rejects empty and over-long objectives', async () => {}); -it('normalizes malformed queue files to an empty queue', async () => {}); -it('throws when the session summary does not expose a session directory', async () => {}); -``` - -Expected before implementation: the import from `#/tui/goal-queue-store` fails. - -- [ ] **Step 2: Add store types** - -Create `apps/kimi-code/src/tui/goal-queue-store.ts`. - -Use these exported types: - -```ts -export interface UpcomingGoal { - readonly id: string; - readonly objective: string; - readonly createdAt: string; - readonly updatedAt: string; -} - -export interface GoalQueueSnapshot { - readonly goals: readonly UpcomingGoal[]; -} - -export type GoalQueueMoveDirection = 'up' | 'down'; -``` - -Use this private file shape: - -```ts -interface GoalQueueFile { - readonly version: 1; - readonly goals: readonly UpcomingGoal[]; -} -``` - -- [ ] **Step 3: Resolve the file path from the session** - -Use `Session.summary.sessionDir`. - -```ts -const GOAL_QUEUE_FILE = 'upcoming-goals.json'; - -function goalQueuePath(session: Pick): string { - const sessionDir = session.summary?.sessionDir; - if (sessionDir === undefined || sessionDir.trim().length === 0) { - throw new Error(`Session ${session.id} does not expose a session directory`); - } - return join(sessionDir, GOAL_QUEUE_FILE); -} -``` - -Import `Session` from `@moonshot-ai/kimi-code-sdk`. - -- [ ] **Step 4: Implement queue operations** - -Export these functions: - -```ts -export async function readGoalQueue( - session: Pick, -): Promise; - -export async function appendGoalQueueItem( - session: Pick, - input: { readonly objective: string }, -): Promise; - -export async function updateGoalQueueItem( - session: Pick, - input: { readonly goalId: string; readonly objective: string }, -): Promise; - -export async function removeGoalQueueItem( - session: Pick, - input: { readonly goalId: string }, -): Promise; - -export async function moveGoalQueueItem( - session: Pick, - input: { readonly goalId: string; readonly direction: GoalQueueMoveDirection }, -): Promise; -``` - -Use `randomUUID()` for ids. - -Use `mkdir(dirname(file), { recursive: true })` before writes. - -- [ ] **Step 5: Validate objectives** - -Use the existing public errors from `@moonshot-ai/kimi-code-sdk`: - -```ts -ErrorCodes.GOAL_OBJECTIVE_EMPTY -ErrorCodes.GOAL_OBJECTIVE_TOO_LONG -ErrorCodes.GOAL_NOT_FOUND -KimiError -``` - -Keep the max objective length at 4000. - -- [ ] **Step 6: Normalize malformed files** - -When the queue file is missing, return: - -```ts -{ goals: [] } -``` - -When the queue file exists but does not match the expected shape, overwrite it with: - -```json -{ "version": 1, "goals": [] } -``` - -- [ ] **Step 7: Run store tests** - -Run: - -```bash -pnpm --filter @moonshot-ai/kimi-code test apps/kimi-code/test/tui/goal-queue-store.test.ts -``` - -Expected: all TUI queue store tests pass. - -- [ ] **Step 8: Commit this slice** - -Use: - -```bash -git add apps/kimi-code/src/tui/goal-queue-store.ts apps/kimi-code/test/tui/goal-queue-store.test.ts -git commit -m "feat: add tui upcoming goal queue store" -``` diff --git a/plans/upcoming-goals/02-tui-queue-store.md b/plans/upcoming-goals/02-tui-queue-store.md deleted file mode 100644 index 84c62844f..000000000 --- a/plans/upcoming-goals/02-tui-queue-store.md +++ /dev/null @@ -1,86 +0,0 @@ -# Upcoming Goals RPC And SDK Boundary Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Keep upcoming-goal queue data out of RPC and SDK. - -**Architecture:** The feature uses only the TUI store from `apps/kimi-code/src/tui/goal-queue-store.ts`. RPC and SDK continue to expose only the current goal lifecycle. - -**Tech Stack:** TypeScript, `rg`, Kimi Code TUI. - ---- - -### Task 1: Confirm The Current Boundary - -**Files:** - -- Read: `packages/agent-core/src/rpc/core-api.ts` -- Read: `packages/agent-core/src/session/rpc.ts` -- Read: `packages/node-sdk/src/session.ts` -- Read: `packages/node-sdk/src/types.ts` - -- [ ] **Step 1: Search RPC and SDK for queue symbols** - -Run: - -```bash -rg -n "GoalQueue|UpcomingGoal|goalQueue|appendGoalQueue|getGoalQueue|moveGoalQueue|removeGoalQueue|updateGoalQueue" packages/agent-core packages/node-sdk -``` - -Expected: no matches in source files. - -- [ ] **Step 2: Keep current goal methods unchanged** - -Do not change these existing methods: - -```ts -createGoal -getGoal -pauseGoal -resumeGoal -cancelGoal -``` - -They are for the active goal only. - -### Task 2: Avoid New RPC Or SDK Types - -**Files:** - -- Do not modify: `packages/agent-core/src/rpc/core-api.ts` -- Do not modify: `packages/agent-core/src/rpc/core-impl.ts` -- Do not modify: `packages/agent-core/src/session/rpc.ts` -- Do not modify: `packages/agent-core/src/rpc/events.ts` -- Do not modify: `packages/node-sdk/src/session.ts` -- Do not modify: `packages/node-sdk/src/rpc.ts` -- Do not modify: `packages/node-sdk/src/types.ts` -- Do not modify: `packages/node-sdk/src/events.ts` - -- [ ] **Step 1: Use TUI imports only** - -When command, event handler, or dialog code needs the queue, import from: - -```ts -import { - appendGoalQueueItem, - moveGoalQueueItem, - readGoalQueue, - removeGoalQueueItem, - updateGoalQueueItem, - type GoalQueueMoveDirection, - type GoalQueueSnapshot, - type UpcomingGoal, -} from '#/tui/goal-queue-store'; -``` - -- [ ] **Step 2: Add a final boundary check** - -After implementation, run the same `rg` command from Task 1. - -Expected: no matches in `packages/agent-core` or `packages/node-sdk`. - -- [ ] **Step 3: Commit no source changes for this slice** - -This slice is a boundary rule and verification step. - -Do not create a commit unless you had to remove accidental RPC or SDK changes. diff --git a/plans/upcoming-goals/03-goal-next-command.md b/plans/upcoming-goals/03-goal-next-command.md deleted file mode 100644 index 9c0a4b591..000000000 --- a/plans/upcoming-goals/03-goal-next-command.md +++ /dev/null @@ -1,171 +0,0 @@ -# Goal Next Command Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add `/goal next ` so users can queue work while the current goal is running. - -**Architecture:** Extend the existing `/goal` parser and handler. Queueing writes session metadata through the SDK and does not send a user message to the agent. - -**Tech Stack:** TypeScript, Kimi Code TUI slash commands, Vitest. - ---- - -### Task 1: Parse `/goal next` - -**Files:** - -- Modify: `apps/kimi-code/src/tui/commands/goal.ts` -- Modify: `apps/kimi-code/src/tui/commands/registry.ts` -- Test: `apps/kimi-code/test/tui/commands/goal.test.ts` -- Test: `apps/kimi-code/test/tui/commands/registry.test.ts` - -- [ ] **Step 1: Add parser tests** - -Add these assertions to `parseGoalCommand` tests: - -```ts -expect(parseGoalCommand('next Ship release notes')).toEqual({ - kind: 'next-add', - objective: 'Ship release notes', -}); - -expect(parseGoalCommand('next manage')).toEqual({ kind: 'next-manage' }); - -expect(parseGoalCommand('next -- manage release notes')).toEqual({ - kind: 'next-add', - objective: 'manage release notes', -}); - -expect(parseGoalCommand('next')).toEqual({ - kind: 'error', - severity: 'hint', - message: - 'Provide an upcoming goal objective, e.g. `/goal next Ship feature X`, or use `/goal next manage`.', -}); -``` - -- [ ] **Step 2: Extend the parsed command union** - -Add: - -```ts -| { readonly kind: 'next-add'; readonly objective: string } -| { readonly kind: 'next-manage' } -``` - -- [ ] **Step 3: Parse `next` before `replace`** - -In `parseGoalCommand`, handle `tokens[0] === 'next'` before the existing `replace` parsing. - -Rules: - -- `next manage` maps to `next-manage`. -- `next -- manage` queues an objective that starts with `manage`. -- `next ` queues the objective. -- Empty `next` returns a hint. -- The same 4000-character limit applies. - -- [ ] **Step 4: Add autocomplete and availability** - -Add `next` to `GOAL_ARG_COMPLETIONS`: - -```ts -{ value: 'next', description: 'Queue an upcoming goal' }, -``` - -In `/goal` availability, make `next` and any argument string that starts with `next ` available while streaming: - -```ts -if (trimmed === 'next' || trimmed.startsWith('next ')) return 'always'; -``` - -Keep `resume`, `replace`, and direct goal creation as idle-only. - -### Task 2: Queue The Objective - -**Files:** - -- Modify: `apps/kimi-code/src/tui/commands/goal.ts` -- Test: `apps/kimi-code/test/tui/commands/goal.test.ts` - -- [ ] **Step 1: Mock the TUI queue store** - -In `apps/kimi-code/test/tui/commands/goal.test.ts`, mock: - -```ts -vi.mock('#/tui/goal-queue-store', () => ({ - appendGoalQueueItem: vi.fn(async () => ({ - goals: [{ id: 'q1', objective: 'obj', createdAt: '', updatedAt: '' }], - })), -})); -``` - -- [ ] **Step 2: Add command handler tests** - -Add tests for: - -```ts -await handleGoalCommand(host, 'next Ship release notes'); -expect(appendGoalQueueItem).toHaveBeenCalledWith(session, { - objective: 'Ship release notes', -}); -expect(host.sendNormalUserInput).not.toHaveBeenCalled(); -expect(host.showStatus).toHaveBeenCalledWith( - 'Upcoming goal added. It will start after the current goal is complete.', -); -``` - -Also test that queueing does not require a configured model. - -- [ ] **Step 3: Implement `queueNextGoal`** - -Add a helper in `goal.ts`: - -```ts -async function queueNextGoal( - host: SlashCommandHost, - parsed: Extract, -): Promise { - try { - await appendGoalQueueItem(host.requireSession(), { objective: parsed.objective }); - } catch (error) { - host.showError(formatErrorMessage(error)); - return; - } - host.track('goal_queue_append'); - host.showStatus('Upcoming goal added. It will start after the current goal is complete.'); -} -``` - -Import `appendGoalQueueItem` from `#/tui/goal-queue-store`. - -Do not call `sendNormalUserInput`. - -- [ ] **Step 4: Add a temporary manager response** - -Until the manager dialog is added, route `next-manage` to a small status message: - -```ts -host.showStatus('Upcoming goal manager is not available yet.'); -``` - -The management-dialog slice will replace this with the real dialog. - -- [ ] **Step 5: Run command tests** - -Run: - -```bash -pnpm --filter @moonshot-ai/kimi-code test apps/kimi-code/test/tui/commands/goal.test.ts apps/kimi-code/test/tui/commands/registry.test.ts -``` - -Expected: parsing, availability, and queue append tests pass. - -- [ ] **Step 6: Commit this slice** - -Use: - -```bash -git add apps/kimi-code/src/tui/commands/goal.ts apps/kimi-code/src/tui/commands/registry.ts apps/kimi-code/test/tui/commands/goal.test.ts apps/kimi-code/test/tui/commands/registry.test.ts -git commit -m "feat: add goal next queue command" -``` diff --git a/plans/upcoming-goals/04-completion-promotion.md b/plans/upcoming-goals/04-completion-promotion.md deleted file mode 100644 index f903f0860..000000000 --- a/plans/upcoming-goals/04-completion-promotion.md +++ /dev/null @@ -1,233 +0,0 @@ -# Upcoming Goals Promotion Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Start the next queued goal only after the current goal completes and clears. - -**Architecture:** The session event handler observes goal lifecycle events. It marks completion as pending, waits for the follow-up null snapshot, then starts the first queued goal. - -**Tech Stack:** TypeScript, Kimi Code TUI event handling, Kimi Code SDK, Vitest. - ---- - -### Task 1: Promote After The Clear Event - -**Files:** - -- Modify: `apps/kimi-code/src/tui/controllers/session-event-handler.ts` -- Create: `apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts` - -- [ ] **Step 1: Write the promotion event test** - -Create a host with: - -```ts -const session = { - createGoal: vi.fn(async () => fakeGoalSnapshot('Ship queued goal')), -}; -``` - -Mock the TUI queue store: - -```ts -vi.mock('#/tui/goal-queue-store', () => ({ - readGoalQueue: vi.fn(async () => ({ - goals: [{ id: 'q1', objective: 'Ship queued goal', createdAt: '', updatedAt: '' }], - })), - removeGoalQueueItem: vi.fn(async () => ({ goals: [] })), -})); -``` - -Call `handler.handleEvent()` twice: - -```ts -handler.handleEvent(goalCompletionEvent, vi.fn()); -handler.handleEvent(goalClearedEvent, vi.fn()); -``` - -Assert: - -```ts -await vi.waitFor(() => { - expect(session.createGoal).toHaveBeenCalledWith({ objective: 'Ship queued goal' }); -}); -expect(removeGoalQueueItem).toHaveBeenCalledWith(session, { goalId: 'q1' }); -expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship queued goal'); -``` - -Expected before implementation: the test fails because no promotion runs. - -- [ ] **Step 2: Track completion awaiting clear** - -Add a private field: - -```ts -private goalCompletionAwaitingClear = false; -``` - -In the completion branch of `handleGoalUpdated`, set it before returning: - -```ts -this.goalCompletionAwaitingClear = true; -``` - -- [ ] **Step 3: Promote only on `snapshot: null`** - -At the start of `handleGoalUpdated`, after `setAppState`, add: - -```ts -if (event.snapshot === null && this.goalCompletionAwaitingClear) { - this.goalCompletionAwaitingClear = false; - void this.promoteNextQueuedGoal(); -} -``` - -This avoids racing with `SessionGoalStore.markComplete()`, which emits completion before it clears the durable goal. - -- [ ] **Step 4: Implement `promoteNextQueuedGoal`** - -Add a private async method: - -```ts -private async promoteNextQueuedGoal(): Promise { - const session = this.host.session; - if (session === undefined || this.host.aborted) return; - - let next; - try { - const queue = await readGoalQueue(session); - next = queue.goals[0]; - } catch (error) { - this.host.showError(`Failed to read upcoming goals: ${formatErrorMessage(error)}`); - return; - } - if (next === undefined) return; - - try { - await session.createGoal({ objective: next.objective }); - } catch (error) { - this.host.showError(`Failed to start queued goal: ${formatErrorMessage(error)}`); - return; - } - - try { - await removeGoalQueueItem(session, { goalId: next.id }); - } catch (error) { - this.host.showError(`Queued goal started, but could not be removed from the queue: ${formatErrorMessage(error)}`); - } - - this.host.state.transcriptContainer.addChild( - new GoalSetMessageComponent(this.host.state.theme.colors), - ); - this.host.state.ui.requestRender(); - this.host.sendNormalUserInput(next.objective); -} -``` - -Import `GoalSetMessageComponent` from `../components/messages/goal-panel`. - -Import `formatErrorMessage` from `../utils/event-payload` if the file does not already import it. - -Import `readGoalQueue` and `removeGoalQueueItem` from `#/tui/goal-queue-store`. - -- [ ] **Step 5: Keep create failures non-destructive** - -Add a test where `session.createGoal` rejects. - -Assert: - -```ts -expect(removeGoalQueueItem).not.toHaveBeenCalled(); -expect(host.sendNormalUserInput).not.toHaveBeenCalled(); -expect(host.showError).toHaveBeenCalled(); -``` - -This ensures the queued goal stays in the queue when it cannot start. - -### Task 2: Do Not Promote On Blocked, Paused, Or Cancelled - -**Files:** - -- Modify: `apps/kimi-code/src/tui/controllers/session-event-handler.ts` -- Test: `apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts` - -- [ ] **Step 1: Add blocked notice test** - -Use a blocked lifecycle event: - -```ts -const event = { - type: 'goal.updated', - sessionId: 's1', - agentId: 'main', - snapshot: { ...fakeGoalSnapshot('Blocked'), status: 'blocked' }, - change: { kind: 'lifecycle', status: 'blocked', reason: 'waiting for access' }, -} as const; -``` - -Assert: - -```ts -await vi.waitFor(() => { - expect(host.showNotice).toHaveBeenCalledWith( - 'Goal blocked.', - 'The next queued goal will start only after this goal is complete.', - ); -}); -expect(session.createGoal).not.toHaveBeenCalled(); -``` - -- [ ] **Step 2: Implement blocked notice** - -In `handleGoalUpdated`, when `change.kind === 'lifecycle' && change.status === 'blocked'`, call: - -```ts -void this.notifyQueuedGoalWaitingOnBlocked(); -``` - -Add: - -```ts -private async notifyQueuedGoalWaitingOnBlocked(): Promise { - const session = this.host.session; - if (session === undefined || this.host.aborted) return; - try { - const queue = await readGoalQueue(session); - if (queue.goals.length === 0) return; - } catch { - return; - } - this.host.showNotice( - 'Goal blocked.', - 'The next queued goal will start only after this goal is complete.', - ); -} -``` - -- [ ] **Step 3: Add paused and cancelled tests** - -Add tests that send: - -- a lifecycle event with `status: 'paused'` -- a clear event with `snapshot: null` and no prior completion - -Assert `createGoal` is not called. - -- [ ] **Step 4: Run event handler tests** - -Run: - -```bash -pnpm --filter @moonshot-ai/kimi-code test apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts -``` - -Expected: promotion, blocked notice, paused, and cancelled tests pass. - -- [ ] **Step 5: Commit this slice** - -Use: - -```bash -git add apps/kimi-code/src/tui/controllers/session-event-handler.ts apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts -git commit -m "feat: promote queued goals after completion" -``` diff --git a/plans/upcoming-goals/05-management-dialog.md b/plans/upcoming-goals/05-management-dialog.md deleted file mode 100644 index ea88771a2..000000000 --- a/plans/upcoming-goals/05-management-dialog.md +++ /dev/null @@ -1,234 +0,0 @@ -# Upcoming Goals Management Dialog Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add `/goal next manage` so users can browse, reorder, edit, and delete upcoming goals. - -**Architecture:** Add a focused dialog component mounted with `mountEditorReplacement`. The dialog calls SDK queue methods and never sends queued objectives to the agent. - -**Tech Stack:** TypeScript, `@earendil-works/pi-tui`, Kimi Code TUI dialogs, Vitest. - ---- - -### Task 1: Build The Manager Component - -**Files:** - -- Create: `apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts` -- Create: `apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts` - -- [ ] **Step 1: Write render and key tests** - -Cover: - -```ts -it('renders queued goals with the standard list header', () => {}); -it('renders an empty state with the add command', () => {}); -it('moves the cursor with Up and Down', () => {}); -it('toggles move mode with Space', () => {}); -it('calls onMove while move mode is active', () => {}); -it('calls onEdit when E or e is pressed', () => {}); -it('calls onDelete when D or d is pressed', () => {}); -it('calls onCancel on Escape', () => {}); -``` - -Use `stripAnsi()` like other dialog tests. - -- [ ] **Step 2: Add component options** - -Use this shape: - -```ts -export interface GoalQueueManagerOptions { - readonly goals: readonly UpcomingGoal[]; - readonly colors: ColorPalette; - readonly requestRender: () => void; - readonly onMove: ( - goalId: string, - direction: GoalQueueMoveDirection, - ) => Promise; - readonly onEdit: (goal: UpcomingGoal) => void; - readonly onDelete: (goalId: string) => Promise; - readonly onCancel: () => void; -} -``` - -Import `UpcomingGoal`, `GoalQueueMoveDirection`, and `GoalQueueSnapshot` from `#/tui/goal-queue-store`. - -- [ ] **Step 3: Follow the TUI dialog design** - -Render with: - -```text -───────────────────────────────────────── - Upcoming goals - ↑↓ navigate · Space move · E edit · D delete · Esc cancel - - ❯ 1. Ship release notes - 2. Update docs - -───────────────────────────────────────── -``` - -When move mode is active, use: - -```text -↑↓ reorder · Space done · Esc cancel -``` - -Use `SELECT_POINTER`, `SearchableList`, `truncateToWidth`, `visibleWidth`, and `printableChar()`. - -Use `chalk.hex(colors.)`. - -- [ ] **Step 4: Implement move mode** - -Behavior: - -- Normal Up and Down browse. -- Space toggles move mode for the selected item. -- In move mode, Up calls `onMove(goalId, 'up')`. -- In move mode, Down calls `onMove(goalId, 'down')`. -- After `onMove` resolves, replace the local goals list with `snapshot.goals`. -- Keep focus on the moved goal by id. -- Call `requestRender()` after async updates. - -- [ ] **Step 5: Implement delete** - -Behavior: - -- `D` and `d` delete the focused goal. -- No confirmation dialog in the first version. -- After delete resolves, replace the local list with `snapshot.goals`. -- Clamp the cursor to the new list length. - -### Task 2: Build The Edit Dialog - -**Files:** - -- Modify: `apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts` -- Test: `apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts` - -- [ ] **Step 1: Add edit dialog tests** - -Cover: - -```ts -it('prefills the current objective', () => {}); -it('submits a trimmed objective on Enter', () => {}); -it('rejects an empty objective', () => {}); -it('rejects objectives over 4000 characters', () => {}); -it('cancels on Escape', () => {}); -``` - -- [ ] **Step 2: Add `GoalQueueEditDialogComponent`** - -Use an `Input` inside a rounded box. - -Use: - -```ts -this.input.setValue(opts.initialObjective); -this.input.onSubmit = (value) => this.submit(value); -``` - -Return: - -```ts -export type GoalQueueEditResult = - | { readonly kind: 'ok'; readonly objective: string } - | { readonly kind: 'cancel' }; -``` - -Use the same max length as `goal.ts`: - -```ts -const MAX_GOAL_OBJECTIVE_LENGTH = 4000; -``` - -### Task 3: Wire `/goal next manage` - -**Files:** - -- Modify: `apps/kimi-code/src/tui/commands/goal.ts` -- Test: `apps/kimi-code/test/tui/commands/goal.test.ts` - -- [ ] **Step 1: Replace the temporary manager response** - -Add `showGoalQueueManager(host)` in `goal.ts`. - -It should: - -```ts -const session = host.requireSession(); -const snapshot = await readGoalQueue(session); -host.mountEditorReplacement( - new GoalQueueManagerComponent({ - goals: snapshot.goals, - colors: host.state.theme.colors, - requestRender: () => { - host.state.ui.requestRender(); - }, - onMove: (goalId, direction) => moveGoalQueueItem(session, { goalId, direction }), - onDelete: (goalId) => removeGoalQueueItem(session, { goalId }), - onEdit: (goal) => { - showGoalQueueEditDialog(host, goal); - }, - onCancel: () => { - host.restoreEditor(); - }, - }), -); -``` - -Callbacks: - -- `onMove` calls `moveGoalQueueItem`. -- `onDelete` calls `removeGoalQueueItem`. -- `onEdit` mounts `GoalQueueEditDialogComponent`. -- `onCancel` calls `host.restoreEditor()`. - -- [ ] **Step 2: Edit selected goal** - -On edit submit: - -```ts -const updated = await updateGoalQueueItem(session, { - goalId: goal.id, - objective: result.objective, -}); -``` - -Then remount the manager with `updated.goals`. - -On edit cancel, remount the manager with the current queue from `readGoalQueue(session)`. - -- [ ] **Step 3: Add command tests** - -Assert: - -```ts -await handleGoalCommand(host, 'next manage'); -expect(readGoalQueue).toHaveBeenCalledWith(session); -expect(host.mountEditorReplacement).toHaveBeenCalled(); -``` - -Assert manager actions call the TUI store methods. - -- [ ] **Step 4: Run TUI tests** - -Run: - -```bash -pnpm --filter @moonshot-ai/kimi-code test apps/kimi-code/test/tui/commands/goal.test.ts apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts -``` - -Expected: manager command and dialog tests pass. - -- [ ] **Step 5: Commit this slice** - -Use: - -```bash -git add apps/kimi-code/src/tui/commands/goal.ts apps/kimi-code/src/tui/components/dialogs/goal-queue-manager.ts apps/kimi-code/test/tui/commands/goal.test.ts apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts -git commit -m "feat: manage upcoming goals in the tui" -``` diff --git a/plans/upcoming-goals/06-verification-docs-changeset.md b/plans/upcoming-goals/06-verification-docs-changeset.md deleted file mode 100644 index b63e51e1a..000000000 --- a/plans/upcoming-goals/06-verification-docs-changeset.md +++ /dev/null @@ -1,183 +0,0 @@ -# Upcoming Goals Verification And Release Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Verify the upcoming goals feature, update user docs, and add the release metadata. - -**Architecture:** Run focused tests first, then broader package tests. Update docs because the slash command behavior changes. Add a changeset for the CLI package. - -**Tech Stack:** pnpm, Vitest, VitePress docs, changesets. - ---- - -### Task 1: Run Focused Verification - -**Files:** - -- Read: changed source and test files from the previous slices. - -- [ ] **Step 1: Run TUI queue store tests** - -Run: - -```bash -pnpm --filter @moonshot-ai/kimi-code test apps/kimi-code/test/tui/goal-queue-store.test.ts -``` - -Expected: all queue store tests pass. - -- [ ] **Step 2: Run TUI command tests** - -Run: - -```bash -pnpm --filter @moonshot-ai/kimi-code test apps/kimi-code/test/tui/commands/goal.test.ts apps/kimi-code/test/tui/commands/registry.test.ts -``` - -Expected: `/goal next` parsing, queueing, and availability tests pass. - -- [ ] **Step 3: Run TUI dialog and event tests** - -Run: - -```bash -pnpm --filter @moonshot-ai/kimi-code test apps/kimi-code/test/tui/components/dialogs/goal-queue-manager.test.ts apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts -``` - -Expected: manager and promotion tests pass. - -- [ ] **Step 4: Verify RPC and SDK stay clean** - -Run: - -```bash -rg -n "GoalQueue|UpcomingGoal|goalQueue|appendGoalQueue|getGoalQueue|moveGoalQueue|removeGoalQueue|updateGoalQueue" packages/agent-core packages/node-sdk -``` - -Expected: no matches. - -### Task 2: Run Package Verification - -**Files:** - -- Read: `package.json` -- Read: package `package.json` files for changed packages. - -- [ ] **Step 1: Run changed package tests** - -Run: - -```bash -pnpm --filter @moonshot-ai/kimi-code test -``` - -Expected: all changed package tests pass. - -- [ ] **Step 2: Run root checks if package tests expose cross-package issues** - -Run: - -```bash -pnpm test -``` - -Expected: root Vitest suite passes. - -### Task 3: Update User Docs - -**Files:** - -- Modify: docs pages that list slash commands and goal behavior. -- Read: `docs/AGENTS.md` -- Use skill: `.agents/skills/gen-docs/SKILL.md` - -- [ ] **Step 1: Use the docs skill** - -Run the `gen-docs` skill after the implementation diff exists. - -Update the English and Chinese docs that describe `/goal`. - -The docs should cover: - -- `/goal next ` queues an upcoming goal. -- `/goal next manage` opens the manager. -- Queued goals start only after the current goal completes. -- Queued goals do not start after pause, cancel, or blocked. -- `--` lets an objective start with `manage`. - -- [ ] **Step 2: Keep docs user-focused** - -Use wording like: - -```text -Use `/goal next ` to queue another goal for the same session. -Kimi Code starts it after the current goal completes. -Use `/goal next manage` to reorder, edit, or remove queued goals. -``` - -Do not describe internal metadata keys in user docs. - -### Task 4: Add A Changeset - -**Files:** - -- Create: `.changeset/.md` -- Use skill: `.agents/skills/gen-changesets/SKILL.md` - -- [ ] **Step 1: Use the changeset skill** - -Run the `gen-changesets` skill after all source changes are in place. - -This feature is a backwards-compatible user-facing CLI feature, so the likely bump is `minor`. - -The changed package should be: - -```markdown -"@moonshot-ai/kimi-code": minor -``` - -Use this changelog style: - -```markdown -Add an upcoming goals queue for autonomous goal work in the TUI. -``` - -- [ ] **Step 2: Commit docs and changeset** - -Use: - -```bash -git add docs .changeset -git commit -m "docs: document upcoming goal queue" -``` - -### Task 5: Final Diff Review - -**Files:** - -- Read: `git diff --stat` -- Read: `git diff` - -- [ ] **Step 1: Check privacy boundaries** - -Confirm: - -- queued objectives are not added to agent context until promotion -- queued objectives are not included in goal prompt injection -- queued objectives are not written as user transcript messages until promotion -- blocked, paused, and cancelled do not promote - -- [ ] **Step 2: Check plain command behavior** - -Confirm: - -- `/goal next ` works while streaming -- `/goal next manage` opens the dialog -- `e` edits the selected goal -- `d` deletes the selected goal -- Space toggles move mode -- Up and Down reorder only while move mode is active - -- [ ] **Step 3: Commit final fixes** - -Use focused commits for any fixes found during review.