Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/goal-objective-length-warning.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions .changeset/goal-objective-too-long-message.md
Original file line number Diff line number Diff line change
@@ -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.
59 changes: 56 additions & 3 deletions apps/kimi-code/src/tui/commands/goal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,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']);

Expand Down Expand Up @@ -114,7 +120,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 };
Expand All @@ -126,6 +133,8 @@ 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.
if (parsed.restoreInput === true) host.restoreInputText(`/goal ${args}`);
Comment thread
chengluyu marked this conversation as resolved.
Outdated
Comment thread
chengluyu marked this conversation as resolved.
Outdated
return;
case 'status':
await showGoalStatus(host);
Expand Down Expand Up @@ -167,12 +176,56 @@ 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);
// `/goalfoo` is a different (probably unknown) command, not `/goal`.
if (args.length > 0 && !/\s/.test(args.charAt(0))) return undefined;
Comment thread
chengluyu marked this conversation as resolved.
Outdated
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<ParsedGoalCommand, { kind: 'next-add' }>,
Expand Down
2 changes: 1 addition & 1 deletion apps/kimi-code/src/tui/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
21 changes: 16 additions & 5 deletions apps/kimi-code/src/tui/components/chrome/footer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof setInterval> | null = null;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -325,20 +337,19 @@ 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,
state.maxContextTokens,
);
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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
16 changes: 16 additions & 0 deletions apps/kimi-code/src/tui/controllers/editor-keyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export interface EditorKeyboardHost {
showError(msg: string): void;
track(event: string, props?: Record<string, unknown>): 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;
Expand Down Expand Up @@ -80,6 +82,20 @@ 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 leading paste marker may itself
// expand into a `/goal ...` line, so it must pass the gate as well.
const trimmed = text.trimStart();
if (
editor.inputMode !== 'bash' &&
(trimmed.startsWith('/goal') || trimmed.startsWith('[paste #'))
) {
host.updateGoalLengthWarning(editor.getExpandedText());
Comment thread
chengluyu marked this conversation as resolved.
Outdated
} else {
host.updateGoalLengthWarning(undefined);
}
};

// bash mode recalls only shell (`!`-prefixed) history entries; prompt mode
Expand Down
2 changes: 1 addition & 1 deletion apps/kimi-code/src/tui/goal-queue-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
16 changes: 16 additions & 0 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
BUILTIN_SLASH_COMMANDS,
buildPluginSlashCommands,
buildSkillSlashCommands,
goalObjectiveLengthWarning,
isExperimentalFlagEnabled,
setExperimentalFeatures,
sortSlashCommands,
Expand Down Expand Up @@ -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<void> {
const palette = await getColorPalette(themeName === 'auto' ? (resolved ?? 'dark') : themeName);
currentTheme.setPalette(palette);
Expand Down
67 changes: 65 additions & 2 deletions apps/kimi-code/test/tui/commands/goal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
dispatchInput,
goalArgumentCompletions,
goalObjectiveLengthWarning,
handleGoalCommand,
parseGoalCommand,
setExperimentalFeatures,
Expand Down Expand Up @@ -213,8 +214,51 @@ 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();
});
});

Expand Down Expand Up @@ -266,6 +310,25 @@ 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('asks before starting a goal in Manual mode', async () => {
const { host: manualHost, session: s } = makeHost({ permissionMode: 'manual' });

Expand Down
35 changes: 35 additions & 0 deletions apps/kimi-code/test/tui/components/chrome/footer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
Loading
Loading