diff --git a/.changeset/goal-objective-length-warning.md b/.changeset/goal-objective-length-warning.md new file mode 100644 index 00000000000..f336484c07d --- /dev/null +++ b/.changeset/goal-objective-length-warning.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Warn in the footer while a typed `/goal` objective exceeds the 4000-character limit, and restore the input instead of losing it when an over-limit objective is rejected. The error message now suggests putting long content in a file and referencing the file path. diff --git a/.changeset/goal-objective-too-long-message.md b/.changeset/goal-objective-too-long-message.md new file mode 100644 index 00000000000..1fff3346976 --- /dev/null +++ b/.changeset/goal-objective-too-long-message.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/agent-core-v2": patch +--- + +Include the file-reference workaround in the `GOAL_OBJECTIVE_TOO_LONG` error message so clients surface how to submit long objectives. diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index 3e1b1f0c657..08138365bf4 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -51,6 +51,7 @@ import { import { handleReloadCommand, handleReloadTuiCommand } from './reload'; import type { SkillListSession } from './skills'; import { + canRestoreSubmittedInput, resolveSlashCommandInput, slashBusyMessage, slashCommandBusyReason, @@ -235,6 +236,9 @@ async function executeSlashCommand(host: SlashCommandHost, input: string): Promi case 'blocked': host.track('input_command_invalid', { reason: 'blocked', command: intent.commandName }); host.showError(slashBusyMessage(intent.commandName, intent.reason)); + // The editor buffer was already cleared on submit; give the rejected + // command line back so hand-typed input is not lost. + host.restoreInputText(input); return; case 'invalid': host.track('input_command_invalid', { @@ -310,7 +314,7 @@ async function executeSlashCommand(host: SlashCommandHost, input: string): Promi host.track('clear'); } try { - await handleBuiltInSlashCommand(host, intent.name, intent.args); + await handleBuiltInSlashCommand(host, intent.name, intent.args, input); } catch (error) { host.showError(formatErrorMessage(error)); } @@ -351,10 +355,16 @@ async function handleBuiltInSlashCommand( host: SlashCommandHost, name: BuiltinSlashCommandName, args: string, + input: string, ): Promise { if (host.session === undefined && SESSION_REQUIRING_COMMANDS.has(name)) { const session = await ensureSessionForCommand(host); - if (session === undefined) return; + if (session === undefined) { + // Creation failed after submit cleared the buffer; give the input + // back unless the user moved on — a newer draft or an opened panel. + if (canRestoreSubmittedInput(host)) host.restoreInputText(input); + return; + } // A first prompt may have started a turn while the session was being // created; re-check the availability gate that was resolved before the // await (idle-only commands are blocked while a turn is active). @@ -369,6 +379,9 @@ async function handleBuiltInSlashCommand( resolveSlashCommandAvailability(command, args) === 'idle-only' ) { host.showError(slashBusyMessage(name, busyReason)); + // Same as the dispatch blocked branch: give the cleared input back, + // guarded the same way — session creation awaited above. + if (canRestoreSubmittedInput(host)) host.restoreInputText(input); return; } } diff --git a/apps/kimi-code/src/tui/commands/goal.ts b/apps/kimi-code/src/tui/commands/goal.ts index de790b90608..169234b4552 100644 --- a/apps/kimi-code/src/tui/commands/goal.ts +++ b/apps/kimi-code/src/tui/commands/goal.ts @@ -25,6 +25,7 @@ import { type GoalQueueSnapshot, } from '../goal-queue-store'; import { formatErrorMessage } from '../utils/event-payload'; +import { canRestoreSubmittedInput } from './resolve'; import type { SlashCommandHost } from './dispatch'; const MAX_GOAL_OBJECTIVE_LENGTH = 4000; @@ -63,7 +64,13 @@ export type ParsedGoalCommand = } | { readonly kind: 'next-add'; readonly objective: string } | { readonly kind: 'next-manage' } - | { readonly kind: 'error'; readonly message: string; readonly severity?: 'error' | 'hint' }; + | { + readonly kind: 'error'; + readonly message: string; + readonly severity?: 'error' | 'hint'; + /** Restore the typed `/goal ...` line into the editor so the input is not lost. */ + readonly restoreInput?: boolean; + }; const CONTROL_SUBCOMMANDS = new Set(['pause', 'resume', 'cancel']); @@ -114,7 +121,8 @@ export function parseGoalCommand(rawArgs: string): ParsedGoalCommand { if (objective.length > MAX_GOAL_OBJECTIVE_LENGTH) { return { kind: 'error', - message: `Goal objective is too long (max ${MAX_GOAL_OBJECTIVE_LENGTH} characters). Reference long details by file path.`, + restoreInput: true, + message: `Goal objective is too long (max ${MAX_GOAL_OBJECTIVE_LENGTH} characters). Put long content in a file and reference the file path.`, }; } return { kind: 'create', objective, replace }; @@ -126,6 +134,12 @@ export async function handleGoalCommand(host: SlashCommandHost, args: string): P case 'error': if (parsed.severity === 'hint') host.showStatus(parsed.message); else host.showError(parsed.message); + // Give rejected input back so a long hand-typed objective is not + // lost — unless the user already moved on (a newer draft or an + // opened panel), which is possible after the async lazy-session + // creation on the v2 engine. + if (parsed.restoreInput === true && canRestoreSubmittedInput(host)) + host.restoreInputText(`/goal ${args}`); return; case 'status': await showGoalStatus(host); @@ -167,12 +181,57 @@ function parseNextGoalCommand(tokens: readonly string[]): ParsedGoalCommand { if (objective.length > MAX_GOAL_OBJECTIVE_LENGTH) { return { kind: 'error', - message: `Goal objective is too long (max ${MAX_GOAL_OBJECTIVE_LENGTH} characters). Reference long details by file path.`, + restoreInput: true, + message: `Goal objective is too long (max ${MAX_GOAL_OBJECTIVE_LENGTH} characters). Put long content in a file and reference the file path.`, }; } return { kind: 'next-add', objective }; } +/** + * Live pre-send check for the main editor: when the typed text is a `/goal` + * create/next command whose objective already exceeds the length limit, + * returns a warning to show while typing — before anything is submitted or + * sent to the server. Returns undefined for non-goal input and for control + * forms (`status`/`pause`/`resume`/`cancel`/`next manage`). + */ +export function goalObjectiveLengthWarning(text: string): string | undefined { + // Submitted text is trimmed before dispatch, so match leading whitespace. + const trimmed = text.trimStart(); + if (!trimmed.startsWith('/goal')) return undefined; + const args = trimmed.slice('/goal'.length); + // parseSlashInput splits the command name at a literal space only, so a + // newline/tab boundary (`/goal⏎…`, `/goalfoo`) is not the goal command. + if (args.length > 0 && args.charAt(0) !== ' ') return undefined; + const objective = extractGoalObjective(args); + if (objective === undefined || objective.length <= MAX_GOAL_OBJECTIVE_LENGTH) return undefined; + return `Goal objective is too long (${objective.length}/${MAX_GOAL_OBJECTIVE_LENGTH} characters); put long content in a file and reference the file path.`; +} + +/** + * Mirrors the parse grammar above: strips `next` / `replace` / `--` and + * returns the objective text, or undefined when the args form a control + * command that carries no objective. + */ +function extractGoalObjective(rawArgs: string): string | undefined { + const args = rawArgs.trim(); + if (args.length === 0 || args === 'status') return undefined; + const tokens = args.split(/\s+/); + const first = tokens[0]; + let index = 0; + if (first === 'next') { + if (tokens.length === 2 && tokens[1] === 'manage') return undefined; + index = 1; + } else { + if (first !== undefined && CONTROL_SUBCOMMANDS.has(first) && tokens.length === 1) { + return undefined; + } + if (tokens[index] === 'replace') index += 1; + } + if (tokens[index] === '--') index += 1; + return tokens.slice(index).join(' ').trim(); +} + async function queueNextGoal( host: SlashCommandHost, parsed: Extract, diff --git a/apps/kimi-code/src/tui/commands/index.ts b/apps/kimi-code/src/tui/commands/index.ts index 7449dba9bc7..cf409c0a33e 100644 --- a/apps/kimi-code/src/tui/commands/index.ts +++ b/apps/kimi-code/src/tui/commands/index.ts @@ -26,7 +26,7 @@ export { handleSwarmCommand } from './swarm'; export { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info'; export { handlePluginsCommand } from './plugins'; export { handleReloadCommand, handleReloadTuiCommand } from './reload'; -export { handleGoalCommand, parseGoalCommand } from './goal'; +export { handleGoalCommand, parseGoalCommand, goalObjectiveLengthWarning } from './goal'; export { goalArgumentCompletions } from './registry'; export { handleForkCommand, handleInitCommand, handleTitleCommand } from './session'; export { handleUndoCommand } from './undo'; diff --git a/apps/kimi-code/src/tui/commands/resolve.ts b/apps/kimi-code/src/tui/commands/resolve.ts index e67457a94b3..18b99347180 100644 --- a/apps/kimi-code/src/tui/commands/resolve.ts +++ b/apps/kimi-code/src/tui/commands/resolve.ts @@ -6,6 +6,7 @@ import { } from './registry'; import { isExperimentalFlagEnabled } from './experimental-flags'; import { parseSlashInput } from './parse'; +import type { TUIState } from '../tui-state'; import type { KimiSlashCommand, SlashCommandBusyReason, @@ -149,3 +150,12 @@ export function slashBusyMessage( } return `Cannot /${commandName} while compacting — wait for compaction to finish first.`; } + +/** + * Whether a delayed input restore is still safe: the editor must be empty + * (no newer draft) and still mounted (no editor-replacement panel opened + * meanwhile). Restores that run synchronously with submit do not need this. + */ +export function canRestoreSubmittedInput(host: { state: TUIState }): boolean { + return host.state.editor.getText().length === 0 && !host.state.editorReplacementMounted; +} diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index 4bb8a75f9b3..9588fe4eabd 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -194,6 +194,7 @@ export class FooterComponent implements Component { private gitCache: GitStatusCache; private gitCacheWorkDir: string; private transientHint: string | null = null; + private warningHint: string | null = null; private goalSnapshotKey: string | null = null; private goalObservedAtMs = Date.now(); private goalTimer: ReturnType | null = null; @@ -258,6 +259,17 @@ export class FooterComponent implements Component { return this.transientHint; } + /** + * Longer-lived warning for line 2 (e.g. the over-long `/goal` objective + * warning). Unlike the transient hint it has no owner/timeout: the caller + * sets and clears it directly. A transient hint takes precedence while + * present; the warning returns as soon as the transient hint clears. + * Pass `null` to clear. + */ + setWarningHint(hint: string | null): void { + this.warningHint = hint; + } + /** * Sync both background-task badges with live counts. Each non-zero * count produces its own bracketed badge on line 1; zeros hide them @@ -325,7 +337,7 @@ export class FooterComponent implements Component { } } - // ── Line 2: transient hint (bottom-left) + context (right) ── + // ── Line 2: hint (bottom-left) + context (right) ── const contextText = formatContextStatus( state.contextUsage, state.contextTokens, @@ -333,12 +345,11 @@ export class FooterComponent implements Component { ); const contextWidth = visibleWidth(contextText); let line2: string; - if (this.transientHint) { + const hint = this.transientHint ?? this.warningHint; + if (hint) { const maxHintWidth = Math.max(0, width - contextWidth - 1); const shownHint = - visibleWidth(this.transientHint) <= maxHintWidth - ? this.transientHint - : truncateToWidth(this.transientHint, maxHintWidth, '…'); + visibleWidth(hint) <= maxHintWidth ? hint : truncateToWidth(hint, maxHintWidth, '…'); const hintWidth = visibleWidth(shownHint); const pad = Math.max(0, width - hintWidth - contextWidth); line2 = 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 b5c2e7ac1ba..4a45d624359 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 @@ -295,7 +295,7 @@ export class GoalQueueEditDialogComponent extends Container implements Focusable return; } if (objective.length > MAX_GOAL_OBJECTIVE_LENGTH) { - this.error = `Goal objective cannot exceed ${MAX_GOAL_OBJECTIVE_LENGTH} characters.`; + this.error = `Goal objective cannot exceed ${MAX_GOAL_OBJECTIVE_LENGTH} characters; put long content in a file and reference the file path.`; return; } this.opts.onDone({ kind: 'save', goalId: this.opts.goal.id, objective }); diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index dc86d312a5c..ce7b8ba848b 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -44,6 +44,8 @@ export interface EditorKeyboardHost { showError(msg: string): void; track(event: string, props?: Record): void; updateEditorBorderHighlight(text?: string): void; + /** `undefined` means the input cannot be a `/goal` command (clear without measuring). */ + updateGoalLengthWarning(text: string | undefined): void; updateQueueDisplay(): void; toggleToolOutputExpansion(): void; toggleTodoPanelExpansion(): void; @@ -80,6 +82,24 @@ export class EditorKeyboardController { editor.onChange = (text: string) => { if (this.pendingExit) this.clearPendingExit(); host.updateEditorBorderHighlight(text); + // Expanding paste markers costs a full-text pass, and only `/goal` + // input can trip the objective length limit — so skip the expansion + // for ordinary prompts. Submitted text is trimmed before dispatch, so + // gate on the trimmed text too. A paste marker may itself expand into + // part of the command (`[paste #…]` → `/goal …`, or completing a + // partial prefix like `/go[paste #1 …]` → `/goal …`), so any input + // containing a marker that can still become a `/goal` command must + // pass the gate as well. + const trimmed = text.trimStart(); + const mightBeGoal = + trimmed.startsWith('/goal') || + trimmed.startsWith('[paste #') || + (trimmed.startsWith('/') && trimmed.includes('[paste #')); + if (editor.inputMode !== 'bash' && mightBeGoal) { + host.updateGoalLengthWarning(editor.getExpandedText()); + } else { + host.updateGoalLengthWarning(undefined); + } }; // bash mode recalls only shell (`!`-prefixed) history entries; prompt mode diff --git a/apps/kimi-code/src/tui/goal-queue-store.ts b/apps/kimi-code/src/tui/goal-queue-store.ts index 0b98eda676c..e3b0ccb47bb 100644 --- a/apps/kimi-code/src/tui/goal-queue-store.ts +++ b/apps/kimi-code/src/tui/goal-queue-store.ts @@ -210,7 +210,7 @@ function normalizeObjective(value: string): string { if (objective.length > MAX_GOAL_OBJECTIVE_LENGTH) { throw new KimiError( ErrorCodes.GOAL_OBJECTIVE_TOO_LONG, - `Goal objective cannot exceed ${MAX_GOAL_OBJECTIVE_LENGTH} characters`, + `Goal objective cannot exceed ${MAX_GOAL_OBJECTIVE_LENGTH} characters. Put long content in a file and reference the file path.`, ); } return objective; diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 614b6946683..28bb78d53d6 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -45,6 +45,7 @@ import { BUILTIN_SLASH_COMMANDS, buildPluginSlashCommands, buildSkillSlashCommands, + goalObjectiveLengthWarning, isExperimentalFlagEnabled, setExperimentalFeatures, sortSlashCommands, @@ -3121,6 +3122,21 @@ export class KimiTUI { this.state.ui.requestRender(); } + /** + * Live pre-send warning in the footer while the typed `/goal` objective + * exceeds the length limit, so the user can trim it (or move it into a + * file) before submitting instead of losing the input to a rejection. + * `undefined` input means the text cannot be a `/goal` command and is not + * measured at all. The footer keeps this warning in its own slot, so + * transient hints (exit confirm, detach, image paste) only displace it + * temporarily. + */ + updateGoalLengthWarning(text: string | undefined): void { + const warning = text === undefined ? undefined : goalObjectiveLengthWarning(text); + this.state.footer.setWarningHint(warning ?? null); + this.state.ui.requestRender(); + } + async applyTheme(themeName: ThemeName, resolved?: ResolvedTheme): Promise { const palette = await getColorPalette(themeName === 'auto' ? (resolved ?? 'dark') : themeName); currentTheme.setPalette(palette); @@ -3222,6 +3238,7 @@ export class KimiTUI { // ========================================================================= mountEditorReplacement(panel: Component & Focusable): void { + this.state.editorReplacementMounted = true; this.state.editorContainer.clear(); this.state.editorContainer.addChild(panel); this.state.ui.setFocus(panel); @@ -3229,6 +3246,7 @@ export class KimiTUI { } restoreEditor(): void { + this.state.editorReplacementMounted = false; this.state.editorContainer.clear(); this.state.editorContainer.addChild(this.state.editor); this.state.ui.setFocus(this.state.editor); diff --git a/apps/kimi-code/src/tui/tui-state.ts b/apps/kimi-code/src/tui/tui-state.ts index c749cb633d6..b5568ecbf5b 100644 --- a/apps/kimi-code/src/tui/tui-state.ts +++ b/apps/kimi-code/src/tui/tui-state.ts @@ -66,6 +66,12 @@ export interface TUIState { sessionsLoadingMore: boolean; sessionsScope: 'cwd' | 'all'; activeDialog: 'session-picker' | 'help' | 'trust-prompt' | 'cache-hint' | null; + /** + * True while an editor-replacement panel (help, trust prompt, goal queue + * manager, …) is mounted in place of the editor. Delayed input restores + * must not run in that state — they would displace the newer panel. + */ + editorReplacementMounted: boolean; tasksBrowser: TasksBrowserState | undefined; externalEditorRunning: boolean; queuedMessages: QueuedMessage[]; @@ -179,6 +185,7 @@ export function createTUIState(options: KimiTUIOptions): TUIState { sessionsLoadingMore: false, sessionsScope: 'cwd', activeDialog: null, + editorReplacementMounted: false, tasksBrowser: undefined, externalEditorRunning: false, queuedMessages: [], diff --git a/apps/kimi-code/test/tui/commands/goal.test.ts b/apps/kimi-code/test/tui/commands/goal.test.ts index ea59d4fae2f..d9c4a5d34a7 100644 --- a/apps/kimi-code/test/tui/commands/goal.test.ts +++ b/apps/kimi-code/test/tui/commands/goal.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { dispatchInput, goalArgumentCompletions, + goalObjectiveLengthWarning, handleGoalCommand, parseGoalCommand, setExperimentalFeatures, @@ -106,6 +107,7 @@ function makeHost( streamingPhase: overrides.streaming ? 'streaming' : 'idle', isCompacting: false, }, + editor: { getText: vi.fn(() => '') }, transcriptContainer, ui: { requestRender: vi.fn() }, theme: { palette: getBuiltInPalette('dark') }, @@ -213,8 +215,61 @@ describe('parseGoalCommand', () => { }); }); - it('rejects objectives longer than 4000 characters', () => { - expect(parseGoalCommand('x'.repeat(4001))).toMatchObject({ kind: 'error' }); + it('rejects objectives longer than 4000 characters with a file-reference hint', () => { + expect(parseGoalCommand('x'.repeat(4001))).toEqual({ + kind: 'error', + restoreInput: true, + message: + 'Goal objective is too long (max 4000 characters). Put long content in a file and reference the file path.', + }); + expect(parseGoalCommand(`next ${'x'.repeat(4001)}`)).toEqual({ + kind: 'error', + restoreInput: true, + message: + 'Goal objective is too long (max 4000 characters). Put long content in a file and reference the file path.', + }); + }); +}); + +describe('goalObjectiveLengthWarning', () => { + it('warns once the typed /goal objective exceeds the limit', () => { + const warning = goalObjectiveLengthWarning(`/goal ${'x'.repeat(4001)}`); + expect(warning).toContain('(4001/4000 characters)'); + expect(warning).toContain('reference the file path'); + }); + + it('ignores leading whitespace because submitted text is trimmed', () => { + expect(goalObjectiveLengthWarning(` /goal ${'x'.repeat(4001)}`)).toBeDefined(); + }); + + it('warns for over-limit /goal next and /goal replace objectives', () => { + expect(goalObjectiveLengthWarning(`/goal next ${'x'.repeat(4001)}`)).toBeDefined(); + expect(goalObjectiveLengthWarning(`/goal replace ${'x'.repeat(4001)}`)).toBeDefined(); + expect(goalObjectiveLengthWarning(`/goal -- ${'x'.repeat(4001)}`)).toBeDefined(); + }); + + it('stays quiet for valid objectives and non-goal input', () => { + expect(goalObjectiveLengthWarning(`/goal ${'x'.repeat(4000)}`)).toBeUndefined(); + expect(goalObjectiveLengthWarning('/goal Ship feature X')).toBeUndefined(); + expect(goalObjectiveLengthWarning('Ship feature X')).toBeUndefined(); + }); + + it('stays quiet for control forms and lookalike commands', () => { + expect(goalObjectiveLengthWarning('/goal')).toBeUndefined(); + expect(goalObjectiveLengthWarning('/goal status')).toBeUndefined(); + expect(goalObjectiveLengthWarning('/goal pause')).toBeUndefined(); + expect(goalObjectiveLengthWarning('/goal next manage')).toBeUndefined(); + expect(goalObjectiveLengthWarning(`/goalie ${'x'.repeat(4001)}`)).toBeUndefined(); + }); + + it('stays quiet when the boundary is a newline or tab (dispatch sends those as plain messages)', () => { + expect(goalObjectiveLengthWarning(`/goal\n${'x'.repeat(4001)}`)).toBeUndefined(); + expect(goalObjectiveLengthWarning(`/goal\t${'x'.repeat(4001)}`)).toBeUndefined(); + }); + + it('still warns for multiline objectives after a literal-space boundary', () => { + const objective = `${'x'.repeat(2000)}\n${'x'.repeat(2001)}`; + expect(goalObjectiveLengthWarning(`/goal ${objective}`)).toBeDefined(); }); }); @@ -266,6 +321,34 @@ describe('handleGoalCommand', () => { expect(calls).toEqual([{ receiver: host, text: 'Ship feature X' }]); }); + it('rejects an over-limit objective before sending and restores the typed input', async () => { + const args = 'x'.repeat(4001); + await handleGoalCommand(host, args); + + expect(session.createGoal).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + expect(host.showError).toHaveBeenCalledWith( + 'Goal objective is too long (max 4000 characters). Put long content in a file and reference the file path.', + ); + expect(host.restoreInputText).toHaveBeenCalledWith(`/goal ${args}`); + }); + + it('does not restore input for the empty-objective usage hint', async () => { + await handleGoalCommand(host, 'replace'); + + expect(host.showStatus).toHaveBeenCalled(); + expect(host.restoreInputText).not.toHaveBeenCalled(); + }); + + it('does not restore over a draft typed while validation was pending', async () => { + vi.mocked(host.state.editor.getText).mockReturnValue('a newer draft'); + + await handleGoalCommand(host, 'x'.repeat(4001)); + + expect(host.showError).toHaveBeenCalled(); + expect(host.restoreInputText).not.toHaveBeenCalled(); + }); + it('asks before starting a goal in Manual mode', async () => { const { host: manualHost, session: s } = makeHost({ permissionMode: 'manual' }); @@ -723,6 +806,102 @@ describe('dispatchInput /goal integration', () => { expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); expect(host.sendNormalUserInput).not.toHaveBeenCalledWith('/goal Ship feature X'); }); + + it('restores the input when /goal is rejected by the busy gate while streaming', async () => { + const { host, session } = makeHost({ streaming: true }); + + dispatchInput(host, '/goal Ship feature X'); + + await vi.waitFor(() => { + expect(host.showError).toHaveBeenCalledWith( + 'Cannot /goal while streaming — press Esc or Ctrl-C first.', + ); + }); + expect(session.createGoal).not.toHaveBeenCalled(); + expect(host.restoreInputText).toHaveBeenCalledWith('/goal Ship feature X'); + }); + + it('restores the input when the post-creation busy re-check rejects /goal', async () => { + const { host, session } = makeHost({ hasSession: false }); + Object.assign(host, { + engineV2: true, + // A first prompt starts a turn while the lazy session creation awaits. + ensureSession: vi.fn(async () => { + host.state.appState.streamingPhase = 'thinking'; + return session; + }), + }); + + dispatchInput(host, '/goal Ship feature X'); + + await vi.waitFor(() => { + expect(host.showError).toHaveBeenCalledWith( + 'Cannot /goal while streaming — press Esc or Ctrl-C first.', + ); + }); + expect(session.createGoal).not.toHaveBeenCalled(); + expect(host.restoreInputText).toHaveBeenCalledWith('/goal Ship feature X'); + }); + + it('does not restore over a draft typed while lazy session creation was pending', async () => { + const { host, session } = makeHost({ hasSession: false }); + Object.assign(host, { + engineV2: true, + ensureSession: vi.fn(async () => { + host.state.appState.streamingPhase = 'thinking'; + // The user kept typing after submitting /goal. + vi.mocked(host.state.editor.getText).mockReturnValue('a newer draft'); + return session; + }), + }); + + dispatchInput(host, '/goal Ship feature X'); + + await vi.waitFor(() => { + expect(host.showError).toHaveBeenCalledWith( + 'Cannot /goal while streaming — press Esc or Ctrl-C first.', + ); + }); + expect(session.createGoal).not.toHaveBeenCalled(); + expect(host.restoreInputText).not.toHaveBeenCalled(); + }); + + it('restores the input when lazy session creation fails before /goal runs', async () => { + const { host, session } = makeHost({ hasSession: false }); + Object.assign(host, { + engineV2: true, + ensureSession: vi.fn(async () => undefined), + }); + + dispatchInput(host, '/goal Ship feature X'); + + await vi.waitFor(() => { + expect(host.restoreInputText).toHaveBeenCalledWith('/goal Ship feature X'); + }); + expect(session.createGoal).not.toHaveBeenCalled(); + }); + + it('does not restore when an editor-replacement panel opened during creation', async () => { + const { host, session } = makeHost({ hasSession: false }); + Object.assign(host, { + engineV2: true, + ensureSession: vi.fn(async () => { + // The user opened a panel (e.g. /help) while creation was pending. + Object.assign(host.state, { editorReplacementMounted: true }); + return undefined; + }), + }); + + dispatchInput(host, '/goal Ship feature X'); + + await vi.waitFor(() => { + expect(host.state.editorReplacementMounted).toBe(true); + }); + // Allow the post-creation branch to run before asserting. + await new Promise((resolve) => setImmediate(resolve)); + expect(session.createGoal).not.toHaveBeenCalled(); + expect(host.restoreInputText).not.toHaveBeenCalled(); + }); }); describe('goalArgumentCompletions', () => { diff --git a/apps/kimi-code/test/tui/components/chrome/footer.test.ts b/apps/kimi-code/test/tui/components/chrome/footer.test.ts index 79abf826e8e..f67512ba019 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer.test.ts @@ -188,3 +188,38 @@ describe('FooterComponent displayName override', () => { expect(footer.render(120).join('\n')).not.toContain('Remote Name'); }); }); + +describe('FooterComponent line-2 hints', () => { + function stripAnsi(text: string): string { + return text.replaceAll(/\[[0-9;]*m/g, ''); + } + + it('shows the warning hint on line 2', () => { + const footer = new FooterComponent(appState); + footer.setWarningHint('Goal objective is too long'); + + const line2 = stripAnsi(footer.render(120)[1] ?? ''); + + expect(line2).toContain('Goal objective is too long'); + }); + + it('gives the transient hint precedence, then restores the warning hint', () => { + const footer = new FooterComponent(appState); + footer.setWarningHint('Goal objective is too long'); + + footer.setTransientHint('Press Ctrl+C again to exit'); + expect(stripAnsi(footer.render(120)[1] ?? '')).toContain('Press Ctrl+C again to exit'); + expect(stripAnsi(footer.render(120)[1] ?? '')).not.toContain('Goal objective is too long'); + + footer.setTransientHint(null); + expect(stripAnsi(footer.render(120)[1] ?? '')).toContain('Goal objective is too long'); + }); + + it('clears the warning hint with null', () => { + const footer = new FooterComponent(appState); + footer.setWarningHint('Goal objective is too long'); + footer.setWarningHint(null); + + expect(stripAnsi(footer.render(120)[1] ?? '')).not.toContain('Goal objective is too long'); + }); +}); diff --git a/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts b/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts index 049d2e4801e..074f154fea7 100644 --- a/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts +++ b/apps/kimi-code/test/tui/controllers/editor-keyboard.test.ts @@ -46,6 +46,8 @@ function createHarness(options: { streamingPhase?: string; isCompacting?: boolea btwPanelController: { cancelRunning: btwCancelRunning, closeOrCancel: btwCloseOrCancel }, openUndoSelector, cancelRunningShellCommand, + updateEditorBorderHighlight: vi.fn(), + updateGoalLengthWarning: vi.fn(), } as unknown as EditorKeyboardHost; const controller = new EditorKeyboardController( @@ -286,6 +288,101 @@ describe('EditorKeyboardController shell history recall', () => { }); }); +describe('EditorKeyboardController input changes', () => { + function installExpandedText( + editor: Harness['editor'], + expanded: string, + ): ReturnType { + const getExpandedText = vi.fn(() => expanded); + editor['getExpandedText'] = getExpandedText as unknown as (...args: never[]) => unknown; + return getExpandedText; + } + + it('forwards text changes to the border highlight and goal length warning', () => { + const { host, editor } = createHarness(); + installExpandedText(editor, '/goal Ship feature X'); + const onChange = editor['onChange'] as unknown as (text: string) => void; + + onChange('/goal Ship feature X'); + + expect(host.updateEditorBorderHighlight).toHaveBeenCalledWith('/goal Ship feature X'); + expect(host.updateGoalLengthWarning).toHaveBeenCalledWith('/goal Ship feature X'); + }); + + it('measures the goal length warning on paste-expanded text, not the collapsed marker', () => { + const { host, editor } = createHarness(); + const expanded = `/goal ${'x'.repeat(4001)}`; + installExpandedText(editor, expanded); + const onChange = editor['onChange'] as unknown as (text: string) => void; + + // The visible text only holds the collapsed paste marker. + onChange('/goal [paste #1 +4000 chars]'); + + expect(host.updateGoalLengthWarning).toHaveBeenCalledWith(expanded); + }); + + it('expands a leading paste marker because its content may start with /goal', () => { + const { host, editor } = createHarness(); + const expanded = `/goal ${'x'.repeat(4001)}`; + const getExpandedText = installExpandedText(editor, expanded); + const onChange = editor['onChange'] as unknown as (text: string) => void; + + onChange('[paste #1 +4000 chars]'); + + expect(getExpandedText).toHaveBeenCalled(); + expect(host.updateGoalLengthWarning).toHaveBeenCalledWith(expanded); + }); + + it('expands a paste that can complete a partially typed /goal command', () => { + const { host, editor } = createHarness(); + const expanded = `/goal ${'x'.repeat(4001)}`; + const getExpandedText = installExpandedText(editor, expanded); + const onChange = editor['onChange'] as unknown as (text: string) => void; + + // Visible text is `/go[paste #1 …]`; the paste completes the command. + onChange('/go[paste #1 +3999 chars]'); + + expect(getExpandedText).toHaveBeenCalled(); + expect(host.updateGoalLengthWarning).toHaveBeenCalledWith(expanded); + }); + + it('skips paste expansion entirely for non-goal input', () => { + const { host, editor } = createHarness(); + const getExpandedText = installExpandedText(editor, 'whatever'); + const onChange = editor['onChange'] as unknown as (text: string) => void; + + onChange('just a normal prompt'); + onChange('/help'); + + expect(getExpandedText).not.toHaveBeenCalled(); + expect(host.updateGoalLengthWarning).toHaveBeenCalledWith(undefined); + }); + + it('gates on trimmed text because submit trims leading whitespace', () => { + const { host, editor } = createHarness(); + const expanded = `/goal ${'x'.repeat(4001)}`; + const getExpandedText = installExpandedText(editor, expanded); + const onChange = editor['onChange'] as unknown as (text: string) => void; + + onChange(` /goal ${'x'.repeat(4001)}`); + + expect(getExpandedText).toHaveBeenCalled(); + expect(host.updateGoalLengthWarning).toHaveBeenCalledWith(expanded); + }); + + it('skips the goal length warning in bash mode', () => { + const { host, editor } = createHarness(); + const getExpandedText = installExpandedText(editor, '/goal x'); + (editor as unknown as { inputMode: string }).inputMode = 'bash'; + const onChange = editor['onChange'] as unknown as (text: string) => void; + + onChange('/goal x'); + + expect(getExpandedText).not.toHaveBeenCalled(); + expect(host.updateGoalLengthWarning).toHaveBeenCalledWith(undefined); + }); +}); + describe('EditorKeyboardController Shift-Tab plan toggle', () => { function createShiftTabHarness(options: { sessionless?: boolean; engineV2?: boolean } = {}) { const editor: Record unknown) | undefined> = { diff --git a/packages/agent-core-v2/src/agent/goal/goalService.ts b/packages/agent-core-v2/src/agent/goal/goalService.ts index 98911f53d2d..375bf08cc54 100644 --- a/packages/agent-core-v2/src/agent/goal/goalService.ts +++ b/packages/agent-core-v2/src/agent/goal/goalService.ts @@ -526,7 +526,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { if (objective.length > MAX_GOAL_OBJECTIVE_LENGTH) { throw new Error2( ErrorCodes.GOAL_OBJECTIVE_TOO_LONG, - `Goal objective cannot exceed ${MAX_GOAL_OBJECTIVE_LENGTH} characters`, + `Goal objective cannot exceed ${MAX_GOAL_OBJECTIVE_LENGTH} characters. Put long content in a file and reference the file path.`, ); } return objective; diff --git a/packages/agent-core/src/agent/goal/index.ts b/packages/agent-core/src/agent/goal/index.ts index 7fb2e2946d7..50a9b5b028f 100644 --- a/packages/agent-core/src/agent/goal/index.ts +++ b/packages/agent-core/src/agent/goal/index.ts @@ -357,7 +357,7 @@ export class GoalMode { if (objective.length > MAX_GOAL_OBJECTIVE_LENGTH) { throw new KimiError( ErrorCodes.GOAL_OBJECTIVE_TOO_LONG, - `Goal objective cannot exceed ${MAX_GOAL_OBJECTIVE_LENGTH} characters`, + `Goal objective cannot exceed ${MAX_GOAL_OBJECTIVE_LENGTH} characters. Put long content in a file and reference the file path.`, ); }