Skip to content
Closed
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/add-prompt-interactive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@moonshot-ai/kimi-code": minor
---

Add `--prompt-interactive` (`-P`) CLI option that sends an initial prompt to
the agent then keeps the interactive session open for follow-up conversation.
7 changes: 7 additions & 0 deletions apps/kimi-code/src/cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ export function createProgram(
'Run one prompt non-interactively and print the response.',
),
)
.addOption(
new Option(
'-P, --prompt-interactive <prompt>',
'Send prompt to the agent then keep the interactive session open.',
),
)
.addOption(
new Option(
'--output-format <format>',
Expand Down Expand Up @@ -88,6 +94,7 @@ export function createProgram(
model: raw['model'] as string | undefined,
outputFormat: raw['outputFormat'] as CLIOptions['outputFormat'],
prompt: raw['prompt'] as string | undefined,
promptInteractive: raw['promptInteractive'] as string | undefined,
skillsDirs: raw['skillsDir'] as string[],
};

Expand Down
15 changes: 15 additions & 0 deletions apps/kimi-code/src/cli/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface CLIOptions {
model: string | undefined;
outputFormat: PromptOutputFormat | undefined;
prompt: string | undefined;
promptInteractive: string | undefined;
skillsDirs: string[];
}

Expand All @@ -27,9 +28,18 @@ export class OptionConflictError extends Error {
export function validateOptions(opts: CLIOptions): ValidatedOptions {
const prompt = opts.prompt;
const promptMode = prompt !== undefined;
const promptInteractive = opts.promptInteractive;
const promptInteractiveMode = promptInteractive !== undefined;

if (promptMode && promptInteractiveMode) {
throw new OptionConflictError('Cannot combine --prompt with --prompt-interactive.');
}
if (promptMode && prompt.trim().length === 0) {
throw new OptionConflictError('Prompt cannot be empty.');
}
if (promptInteractiveMode && promptInteractive.trim().length === 0) {
throw new OptionConflictError('Prompt cannot be empty.');
}
if (opts.model !== undefined && opts.model.trim().length === 0) {
throw new OptionConflictError('Model cannot be empty.');
}
Expand All @@ -45,6 +55,11 @@ export function validateOptions(opts: CLIOptions): ValidatedOptions {
if (promptMode && opts.session === '') {
throw new OptionConflictError('Cannot use --session without an id in prompt mode.');
}
if (promptInteractiveMode && opts.session === '') {
throw new OptionConflictError(
'Cannot use --prompt-interactive with --session without an id.',
);
}
if (opts.continue && opts.session !== undefined) {
throw new OptionConflictError('Cannot combine --continue, --session.');
}
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-code/src/cli/run-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export async function runShell(
resolvedTheme,
migrationPlan,
migrateOnly: runOptions.migrateOnly,
initialCommand: opts.promptInteractive,
});

let firstLaunch = false;
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-code/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ const MIGRATE_CLI_OPTIONS: CLIOptions = {
model: undefined,
outputFormat: undefined,
prompt: undefined,
promptInteractive: undefined,
skillsDirs: [],
};

Expand Down
18 changes: 18 additions & 0 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,8 @@ export interface KimiTUIStartupInput {
readonly migrationPlan?: MigrationPlan | null;
/** When true, run only the migration screen, then exit (the `kimi migrate` command). */
readonly migrateOnly?: boolean;
/** Initial command to send after startup (--prompt-interactive). */
readonly initialCommand?: string;
}

export interface PendingExit {
Expand Down Expand Up @@ -567,6 +569,8 @@ export class KimiTUI {
private readonly migrationPlan: MigrationPlan | null;
// When true, the migration screen is the whole session: run it, then exit.
private readonly migrateOnly: boolean;
// Initial command supplied via --prompt-interactive, sent after startup.
private initialCommand: string | undefined;
// High-frequency model/tool deltas update draft state immediately, then use
// these flags to coalesce expensive component rebuilds into periodic flushes.
private streamingUiFlushTimer: ReturnType<typeof setTimeout> | undefined;
Expand Down Expand Up @@ -602,6 +606,7 @@ export class KimiTUI {
this.options = tuiOptions;
this.migrationPlan = startupInput.migrationPlan ?? null;
this.migrateOnly = startupInput.migrateOnly ?? false;
this.initialCommand = startupInput.initialCommand;
this.state = createTUIState(tuiOptions);
this.gitLsFilesCache = createGitLsFilesCache(tuiOptions.initialAppState.workDir);

Expand Down Expand Up @@ -745,6 +750,18 @@ export class KimiTUI {
}
}

// Sends the initial command from --prompt-interactive after startup completes.
private submitInitialCommand(): void {
if (!this.initialCommand || this.initialCommand.trim().length === 0) return;
const command = this.initialCommand;
this.initialCommand = undefined;
if (!this.session || this.state.appState.model.trim().length === 0) {
this.state.editor.setText(command);
return;
}
this.handleUserInput(command);
}

// Creates/resumes the session, renders the Welcome banner, configures
// autocomplete and input history, and mounts the editor. Returns whether
// transcript history should be replayed.
Expand Down Expand Up @@ -796,6 +813,7 @@ export class KimiTUI {
this.refreshSessionTitle();
}
void this.refreshSkillCommands(this.session);
this.submitInitialCommand();
}

// Warns tmux users when modified Enter shortcuts are likely to be swallowed.
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-code/test/cli/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ function defaultOpts(): CLIOptions {
model: undefined,
outputFormat: undefined,
prompt: undefined,
promptInteractive: undefined,
skillsDirs: [],
};
}
Expand Down
62 changes: 62 additions & 0 deletions apps/kimi-code/test/cli/options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,68 @@ describe('CLI options parsing', () => {
});
});

describe('--prompt-interactive / -P', () => {
it('parses -P as shell mode with promptInteractive set', () => {
const opts = parse(['-P', 'hello world']);
expect(opts.promptInteractive).toBe('hello world');
expect(validateOptions(opts).uiMode).toBe('shell');
});

it('parses --prompt-interactive=value', () => {
const opts = parse(['--prompt-interactive=hello world']);
expect(opts.promptInteractive).toBe('hello world');
expect(validateOptions(opts).uiMode).toBe('shell');
});

it('rejects empty promptInteractive values', () => {
const opts = parse(['-P', ' ']);
expect(() => validateOptions(opts)).toThrow(OptionConflictError);
expect(() => validateOptions(opts)).toThrow('Prompt cannot be empty.');
});

it('rejects --prompt combined with --prompt-interactive', () => {
const opts = parse(['-p', 'run', '-P', 'hello']);
expect(() => validateOptions(opts)).toThrow(OptionConflictError);
expect(() => validateOptions(opts)).toThrow('Cannot combine --prompt with --prompt-interactive.');
});

it('allows --prompt-interactive with --yolo', () => {
const opts = parse(['-P', 'hello', '--yolo']);
expect(opts.promptInteractive).toBe('hello');
expect(opts.yolo).toBe(true);
expect(validateOptions(opts).uiMode).toBe('shell');
});

it('allows --prompt-interactive with --plan', () => {
const opts = parse(['-P', 'hello', '--plan']);
expect(opts.promptInteractive).toBe('hello');
expect(opts.plan).toBe(true);
expect(validateOptions(opts).uiMode).toBe('shell');
});

it('rejects --prompt-interactive with bare --session (no id)', () => {
const opts = parse(['-P', 'hello', '--session']);
expect(() => validateOptions(opts)).toThrow(OptionConflictError);
expect(() => validateOptions(opts)).toThrow(
'Cannot use --prompt-interactive with --session without an id.',
);
});

it('allows --prompt-interactive with a concrete --session id', () => {
const opts = parse(['-P', 'hello', '--session', 'ses_123']);
expect(opts.promptInteractive).toBe('hello');
expect(opts.session).toBe('ses_123');
expect(validateOptions(opts).uiMode).toBe('shell');
});

it('allows --prompt-interactive with --continue', () => {
const opts = parse(['-P', 'hello', '--continue']);
expect(opts.promptInteractive).toBe('hello');
expect(opts.continue).toBe(true);
expect(validateOptions(opts).uiMode).toBe('shell');
});
});

describe('--skills-dir', () => {
it('collects repeated skill directories', () => {
expect(parse(['--skills-dir', '/one', '--skills-dir=/two']).skillsDirs).toEqual([
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-code/test/cli/run-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ function opts(overrides: Partial<Parameters<typeof runPrompt>[0]> = {}) {
model: undefined,
outputFormat: undefined,
prompt: 'say hello',
promptInteractive: undefined,
skillsDirs: [],
...overrides,
};
Expand Down
63 changes: 63 additions & 0 deletions apps/kimi-code/test/cli/run-shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ describe('runShell', () => {
model: undefined,
outputFormat: undefined,
prompt: undefined,
promptInteractive: undefined,
skillsDirs: [],
};

Expand Down Expand Up @@ -252,6 +253,7 @@ describe('runShell', () => {
model: undefined,
outputFormat: undefined,
prompt: undefined,
promptInteractive: undefined,
skillsDirs: [],
},
'1.2.3-test',
Expand Down Expand Up @@ -282,6 +284,7 @@ describe('runShell', () => {
model: undefined,
outputFormat: undefined,
prompt: undefined,
promptInteractive: undefined,
skillsDirs: [],
},
'1.2.3-test',
Expand Down Expand Up @@ -318,6 +321,7 @@ describe('runShell', () => {
model: undefined,
outputFormat: undefined,
prompt: undefined,
promptInteractive: undefined,
skillsDirs: [],
},
'1.2.3-test',
Expand Down Expand Up @@ -350,6 +354,7 @@ describe('runShell', () => {
model: undefined,
outputFormat: undefined,
prompt: undefined,
promptInteractive: undefined,
skillsDirs: [],
},
'1.2.3-test',
Expand Down Expand Up @@ -400,6 +405,7 @@ describe('runShell', () => {
model: undefined,
outputFormat: undefined,
prompt: undefined,
promptInteractive: undefined,
skillsDirs: [],
},
'1.2.3-test',
Expand Down Expand Up @@ -436,6 +442,7 @@ describe('runShell', () => {
model: undefined,
outputFormat: undefined,
prompt: undefined,
promptInteractive: undefined,
skillsDirs: [],
},
'1.2.3-test',
Expand Down Expand Up @@ -472,6 +479,7 @@ describe('runShell', () => {
model: undefined,
outputFormat: undefined,
prompt: undefined,
promptInteractive: undefined,
skillsDirs: [],
},
'1.2.3-test',
Expand Down Expand Up @@ -524,6 +532,7 @@ describe('runShell', () => {
model: undefined,
outputFormat: undefined,
prompt: undefined,
promptInteractive: undefined,
skillsDirs: [],
},
'1.2.3-test',
Expand All @@ -532,4 +541,58 @@ describe('runShell', () => {
).rejects.toThrow('Invalid configuration');
expect(mocks.tuiStart).not.toHaveBeenCalled();
});

it('forwards promptInteractive as initialCommand in KimiTUIStartupInput', async () => {
mocks.loadTuiConfig.mockResolvedValue({
theme: 'dark',
editorCommand: null,
notifications: { enabled: true, condition: 'unfocused' },
});
mocks.tuiStart.mockResolvedValue(undefined);

await runShell(
{
session: undefined,
continue: false,
yolo: false,
plan: false,
model: undefined,
outputFormat: undefined,
prompt: undefined,
promptInteractive: 'hello world',
skillsDirs: [],
},
'1.2.3-test',
);

const [, , startupInput] = mocks.kimiTuiConstructor.mock.calls[0]!;
expect(startupInput.initialCommand).toBe('hello world');
});

it('passes undefined initialCommand when promptInteractive is not set', async () => {
mocks.loadTuiConfig.mockResolvedValue({
theme: 'dark',
editorCommand: null,
notifications: { enabled: true, condition: 'unfocused' },
});
mocks.tuiStart.mockResolvedValue(undefined);

await runShell(
{
session: undefined,
continue: false,
yolo: false,
plan: false,
model: undefined,
outputFormat: undefined,
prompt: undefined,
promptInteractive: undefined,
skillsDirs: [],
},
'1.2.3-test',
);

const [, , startupInput] = mocks.kimiTuiConstructor.mock.calls[0]!;
expect(startupInput.initialCommand).toBeUndefined();
});
});
1 change: 1 addition & 0 deletions apps/kimi-code/test/tui/activity-pane.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ function makeStartupInput(): KimiTUIStartupInput {
model: undefined,
outputFormat: undefined,
prompt: undefined,
promptInteractive: undefined,
skillsDirs: [],
},
tuiConfig: {
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ function makeStartupInput(): KimiTUIStartupInput {
model: undefined,
outputFormat: undefined,
prompt: undefined,
promptInteractive: undefined,
skillsDirs: [],
},
tuiConfig: {
Expand Down
Loading