Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
6 changes: 6 additions & 0 deletions .changeset/drop-forked-goals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@moonshot-ai/agent-core": patch
"@moonshot-ai/kimi-code": patch
---

Stop carrying active and queued goals into forked sessions.
5 changes: 5 additions & 0 deletions .changeset/upcoming-goal-queue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Users now can prepare several goals for the agent to work on sequentially. The agent will pick up the next goal from the queue once the current goal is completed. Use `/goal next <objective>` to queue a goal and `/goal next manage` to review and change the queue interactively.
229 changes: 213 additions & 16 deletions apps/kimi-code/src/tui/commands/goal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,51 @@ import {
GoalStartPermissionPromptComponent,
type GoalStartPermissionChoice,
} from '../components/dialogs/goal-start-permission-prompt';
import {
GoalQueueEditDialogComponent,
GoalQueueManagerComponent,
type GoalQueueEditResult,
type GoalQueueManagerAction,
} from '../components/dialogs/goal-queue-manager';
import {
GoalSetMessageComponent,
GoalStatusMessageComponent,
} from '../components/messages/goal-panel';
import { LLM_NOT_SET_MESSAGE } from '../constant/kimi-tui';
import {
appendGoalQueueItem,
moveGoalQueueItem,
readGoalQueue,
removeGoalQueueItem,
updateGoalQueueItem,
type GoalQueueSnapshot,
} from '../goal-queue-store';
import { formatErrorMessage } from '../utils/event-payload';
import type { SlashCommandHost } from './dispatch';

const MAX_GOAL_OBJECTIVE_LENGTH = 4000;
const RESUME_GOAL_INPUT = 'Resume the active goal.';

type GoalCommandHost = Pick<
SlashCommandHost,
| 'state'
| 'session'
| 'requireSession'
| 'setAppState'
| 'showError'
| 'showStatus'
| 'track'
| 'mountEditorReplacement'
| 'restoreEditor'
| 'restoreInputText'
| 'sendNormalUserInput'
>;

export interface GoalStartOptions {
readonly beforeSend?: () => boolean | Promise<boolean>;
readonly sendInput?: (objective: string) => void;
}

export type ParsedGoalCommand =
| { readonly kind: 'status' }
| { readonly kind: 'pause' }
Expand All @@ -25,6 +59,8 @@ export type ParsedGoalCommand =
readonly objective: string;
readonly replace: boolean;
}
| { readonly kind: 'next-add'; readonly objective: string }
| { readonly kind: 'next-manage' }
| { readonly kind: 'error'; readonly message: string; readonly severity?: 'error' | 'hint' };

const CONTROL_SUBCOMMANDS = new Set(['pause', 'resume', 'cancel']);
Expand All @@ -44,6 +80,9 @@ export function parseGoalCommand(rawArgs: string): ParsedGoalCommand {

const tokens = args.split(/\s+/);
const first = tokens[0];
if (first === 'next') {
return parseNextGoalCommand(tokens);
}
if (first !== undefined && CONTROL_SUBCOMMANDS.has(first) && tokens.length === 1) {
return { kind: first as 'pause' | 'resume' | 'cancel' };
}
Expand Down Expand Up @@ -98,35 +137,186 @@ export async function handleGoalCommand(host: SlashCommandHost, args: string): P
case 'cancel':
await cancelGoal(host);
return;
case 'next-add':
await queueNextGoal(host, parsed);
return;
case 'next-manage':
await showGoalQueueManager(host);
return;
case 'create':
await createGoal(host, parsed, args);
return;
}
}

async function createGoal(
function parseNextGoalCommand(tokens: readonly string[]): ParsedGoalCommand {
if (tokens.length === 2 && tokens[1] === 'manage') return { kind: 'next-manage' };
let index = 1;
if (tokens[index] === '--') index += 1;
const objective = tokens.slice(index).join(' ').trim();
if (objective.length === 0) {
return {
kind: 'error',
severity: 'hint',
message:
'Provide an upcoming goal objective, e.g. `/goal next Ship feature X`, or use `/goal next manage`.',
};
}
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.`,
};
}
return { kind: 'next-add', objective };
}

async function queueNextGoal(
host: SlashCommandHost,
parsed: Extract<ParsedGoalCommand, { kind: 'next-add' }>,
): Promise<void> {
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.');
}

async function showGoalQueueManager(
host: SlashCommandHost,
selectedGoalId?: string,
): Promise<void> {
let snapshot: GoalQueueSnapshot;
try {
snapshot = await readGoalQueue(host.requireSession());
} catch (error) {
host.showError(`Failed to load upcoming goals: ${formatErrorMessage(error)}`);
return;
}

host.track('goal_queue_manage');
host.mountEditorReplacement(
new GoalQueueManagerComponent({
goals: snapshot.goals,
selectedGoalId,
colors: host.state.theme.colors,
onAction: async (action) => {
try {
return await handleGoalQueueManagerAction(host, action);
} catch (error) {
host.showError(`Failed to update upcoming goals: ${formatErrorMessage(error)}`);
return undefined;
}
},
onCancel: () => {
host.restoreEditor();
},
}),
);
}

async function handleGoalQueueManagerAction(
host: SlashCommandHost,
action: GoalQueueManagerAction,
): Promise<GoalQueueSnapshot | void> {
const session = host.requireSession();
switch (action.kind) {
case 'move': {
const snapshot = await moveGoalQueueItem(session, {
goalId: action.goalId,
direction: action.direction,
});
host.track('goal_queue_move', { direction: action.direction });
return snapshot;
}
case 'delete': {
const snapshot = await removeGoalQueueItem(session, { goalId: action.goalId });
host.track('goal_queue_remove');
return snapshot;
}
case 'edit':
await showGoalQueueEditDialog(host, action.goalId);
return;
}
}

async function showGoalQueueEditDialog(
host: SlashCommandHost,
goalId: string,
): Promise<void> {
let snapshot: GoalQueueSnapshot;
try {
snapshot = await readGoalQueue(host.requireSession());
} catch (error) {
host.showError(`Failed to load upcoming goals: ${formatErrorMessage(error)}`);
return;
}

const goal = snapshot.goals.find((item) => item.id === goalId);
if (goal === undefined) {
host.showStatus('Queued goal no longer exists.');
await showGoalQueueManager(host);
return;
}

host.mountEditorReplacement(
new GoalQueueEditDialogComponent({
goal,
colors: host.state.theme.colors,
onDone: (result) => {
void handleGoalQueueEditResult(host, result).catch((error: unknown) => {
host.showError(`Failed to update upcoming goal: ${formatErrorMessage(error)}`);
});
},
}),
);
}

async function handleGoalQueueEditResult(
host: SlashCommandHost,
result: GoalQueueEditResult,
): Promise<void> {
if (result.kind === 'cancel') {
await showGoalQueueManager(host, result.goalId);
return;
}

await updateGoalQueueItem(host.requireSession(), {
goalId: result.goalId,
objective: result.objective,
});
host.track('goal_queue_update');
await showGoalQueueManager(host, result.goalId);
}

export async function createGoal(
host: GoalCommandHost,
parsed: Extract<ParsedGoalCommand, { kind: 'create' }>,
rawArgs?: string,
): Promise<void> {
options: GoalStartOptions = {},
): Promise<boolean> {
// A goal must be able to start a model turn; refuse to create one otherwise.
if (host.state.appState.model.trim().length === 0 || host.session === undefined) {
host.showError(LLM_NOT_SET_MESSAGE);
return;
return false;
}

if (host.state.appState.permissionMode === 'manual') {
showGoalStartPermissionPrompt(host, parsed, rawArgs ?? parsed.objective);
return;
showGoalStartPermissionPrompt(host, parsed, rawArgs ?? parsed.objective, options);
return false;
}

await startGoal(host, parsed);
return startGoal(host, parsed, options);
}

function showGoalStartPermissionPrompt(
host: SlashCommandHost,
host: GoalCommandHost,
parsed: Extract<ParsedGoalCommand, { kind: 'create' }>,
rawArgs: string,
options: GoalStartOptions,
): void {
const commandText = `/goal ${rawArgs.trim()}`;
const cancelStart = (): void => {
Expand All @@ -142,25 +332,26 @@ function showGoalStartPermissionPrompt(
return;
}
host.restoreEditor();
void startGoalWithPermission(host, parsed, choice);
void startGoalWithPermission(host, parsed, choice, options);
},
onCancel: cancelStart,
}),
);
}

async function startGoalWithPermission(
host: SlashCommandHost,
host: GoalCommandHost,
parsed: Extract<ParsedGoalCommand, { kind: 'create' }>,
choice: GoalStartPermissionChoice,
options: GoalStartOptions,
): Promise<void> {
if (choice === 'auto' || choice === 'yolo') {
if (!(await setPermissionForGoal(host, choice))) return;
}
await startGoal(host, parsed);
await startGoal(host, parsed, options);
}

async function setPermissionForGoal(host: SlashCommandHost, mode: PermissionMode): Promise<boolean> {
async function setPermissionForGoal(host: GoalCommandHost, mode: PermissionMode): Promise<boolean> {
try {
await host.requireSession().setPermission(mode);
} catch (error) {
Expand All @@ -172,9 +363,10 @@ async function setPermissionForGoal(host: SlashCommandHost, mode: PermissionMode
}

async function startGoal(
host: SlashCommandHost,
host: GoalCommandHost,
parsed: Extract<ParsedGoalCommand, { kind: 'create' }>,
): Promise<void> {
options: GoalStartOptions,
): Promise<boolean> {
try {
await host.requireSession().createGoal({
objective: parsed.objective,
Expand All @@ -185,15 +377,20 @@ async function startGoal(
host.showError(
'A goal is already active. Use `/goal replace <objective>` to replace it, or `/goal status` to inspect it.',
);
return;
return false;
}
host.showError(formatErrorMessage(error));
return;
return false;
}
if (options.beforeSend !== undefined && !(await options.beforeSend())) {
return false;
}
host.track('goal_create', { replace: parsed.replace });
host.state.transcriptContainer.addChild(new GoalSetMessageComponent(host.state.theme.colors));
host.state.ui.requestRender();
host.sendNormalUserInput(parsed.objective);
const sendInput = options.sendInput ?? host.sendNormalUserInput;
sendInput(parsed.objective);
return true;
}

async function pauseGoal(host: SlashCommandHost): Promise<void> {
Expand Down
15 changes: 15 additions & 0 deletions apps/kimi-code/src/tui/commands/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,24 @@ const GOAL_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [
{ value: 'resume', description: 'Resume a paused goal' },
{ value: 'cancel', description: 'Cancel and remove the current goal' },
{ value: 'replace', description: 'Replace the current goal with a new objective' },
{ value: 'next', description: 'Queue an upcoming goal' },
];

const GOAL_NEXT_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [
{ value: 'manage', description: 'Manage upcoming goals' },
];

/** Argument autocompletion for the `/goal` command (subcommands). */
export function goalArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
const nextMatch = argumentPrefix.match(/^next\s+(\S*)$/i);
if (nextMatch !== null) {
return (
completeLeadingArg(GOAL_NEXT_ARG_COMPLETIONS, nextMatch[1] ?? '')?.map((item) => ({
...item,
value: `next ${item.value}`,
})) ?? null
);
}
return completeLeadingArg(GOAL_ARG_COMPLETIONS, argumentPrefix);
}

Expand Down Expand Up @@ -156,6 +170,7 @@ export const BUILTIN_SLASH_COMMANDS = [
// resume start (or restart) a turn and so are idle-only.
availability: (args) => {
const trimmed = args.trim();
if (trimmed === 'next' || trimmed.startsWith('next ')) return 'always';
return trimmed === '' || trimmed === 'status' || trimmed === 'pause' || trimmed === 'cancel'
? 'always'
: 'idle-only';
Expand Down
Loading
Loading