Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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.
16 changes: 14 additions & 2 deletions apps/kimi-code/src/tui/commands/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,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);
Comment thread
chengluyu marked this conversation as resolved.
return;
case 'invalid':
host.track('input_command_invalid', {
Expand Down Expand Up @@ -310,7 +313,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));
}
Expand Down Expand Up @@ -351,10 +354,16 @@ async function handleBuiltInSlashCommand(
host: SlashCommandHost,
name: BuiltinSlashCommandName,
args: string,
input: string,
): Promise<void> {
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 already typed a new draft meanwhile.
if (host.state.editor.getText().length === 0) host.restoreInputText(input);
Comment thread
chengluyu marked this conversation as resolved.
Outdated
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).
Expand All @@ -369,6 +378,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 (host.state.editor.getText().length === 0) host.restoreInputText(input);
return;
}
}
Expand Down
62 changes: 59 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,11 @@ 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 typed a new draft (possible after
// the async lazy-session creation on the v2 engine).
if (parsed.restoreInput === true && host.state.editor.getText().length === 0)
host.restoreInputText(`/goal ${args}`);
return;
case 'status':
await showGoalStatus(host);
Expand Down Expand Up @@ -167,12 +179,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
20 changes: 20 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,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
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
Loading
Loading