Skip to content
12 changes: 9 additions & 3 deletions apps/kimi-code/src/tui/commands/add-dir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ type AddDirChoice = 'session' | 'remember' | 'cancel';

export async function handleAddDirCommand(host: SlashCommandHost, args: string): Promise<void> {
const input = args.trim();
const session = host.session;
let session = host.session;

if (input.length === 0 || input.toLowerCase() === 'list') {
const additionalDirs = session?.summary?.additionalDirs ?? [];
Comment on lines +9 to 12

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use pending dirs when listing before lazy session

When v2 starts session-less and startupInput.additionalDirs is already populated, host.session is still undefined but appState.additionalDirs contains the directories that will be passed into the lazy-created session. This read-only branch only looks at session?.summary, so /add-dir or /add-dir list incorrectly reports that no additional directories are configured until some other action creates the session; fall back to the app state when there is no active session.

Useful? React with 👍 / 👎.

Expand All @@ -19,8 +19,14 @@ export async function handleAddDirCommand(host: SlashCommandHost, args: string):
}

if (session === undefined) {
host.showError(NO_ACTIVE_SESSION_MESSAGE);
return;
if (!host.engineV2) {
host.showError(NO_ACTIVE_SESSION_MESSAGE);
return;
}
// The path-adding form needs a live session; lazy-create it on first use
// (the read-only `list`/bare forms above tolerate a missing session).
session = await host.ensureSession();
if (session === undefined) return;
}

host.mountEditorReplacement(
Expand Down
120 changes: 112 additions & 8 deletions apps/kimi-code/src/tui/commands/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,17 @@ import { handleAddDirCommand } from './add-dir';
import { parseSlashInput } from './parse';
import { handlePluginsCommand } from './plugins';
import { handleProviderCommand } from './provider';
import type { BuiltinSlashCommandName } from './registry';
import {
findBuiltInSlashCommand,
resolveSlashCommandAvailability,
type BuiltinSlashCommandName,
} from './registry';
import { handleReloadCommand, handleReloadTuiCommand } from './reload';
import { resolveSlashCommandInput, slashBusyMessage } from './resolve';
import {
resolveSlashCommandInput,
slashBusyMessage,
slashCommandBusyReason,
} from './resolve';
import {
handleExportDebugZipCommand,
handleExportMdCommand,
Expand Down Expand Up @@ -103,6 +111,8 @@ export interface SlashCommandHost {
state: TUIState;
session: Session | undefined;
readonly harness: KimiHarness;
/** agent-core-v2 engine (KIMI_CODE_EXPERIMENTAL_FLAG); enables lazy session creation. */
readonly engineV2: boolean;
cancelInFlight: (() => void) | undefined;
deferUserMessages: boolean;

Expand All @@ -120,6 +130,12 @@ export interface SlashCommandHost {

// Session
requireSession(): Session;
/**
* Lazy-create the session on first use (v2 engine). Returns the existing
* session, or undefined (with the error already surfaced) when creation
* fails.
*/
ensureSession(): Promise<Session | undefined>;
switchToSession(session: Session, message: string): Promise<void>;
reloadCurrentSessionView(session: Session, message: string): Promise<void>;
beginSessionRequest(): void;
Expand Down Expand Up @@ -204,11 +220,26 @@ async function executeSlashCommand(host: SlashCommandHost, input: string): Promi
host.showError(`Invalid slash command: /${intent.commandName}`);
return;
case 'skill': {
const session = host.session;
if (host.state.appState.model.trim().length === 0 || session === undefined) {
if (host.state.appState.model.trim().length === 0) {
host.showError(LLM_NOT_SET_MESSAGE);
return;
}
let session = host.session;
if (session === undefined) {
session = await ensureSessionForCommand(host);
if (session === undefined) return;
// A first prompt may have started a turn while the session was being
// created; skill commands are always busy-gated, so re-check the gate
// resolved before the await.
const busyReason = slashCommandBusyReason({
isStreaming: host.state.appState.streamingPhase !== 'idle',
isCompacting: host.state.appState.isCompacting,
});
if (busyReason !== undefined) {
host.showError(slashBusyMessage(intent.commandName, busyReason));
return;
}
}
host.track('input_command', {
command: intent.commandName,
skill_name: intent.skillName,
Expand All @@ -221,10 +252,20 @@ async function executeSlashCommand(host: SlashCommandHost, input: string): Promi
host.showError(LLM_NOT_SET_MESSAGE);
return;
}
const session = host.session;
let session = host.session;
if (session === undefined) {
host.showError(LLM_NOT_SET_MESSAGE);
return;
session = await ensureSessionForCommand(host);
if (session === undefined) return;
// Same busy re-check as the skill path: plugin commands are always
// busy-gated too.
const busyReason = slashCommandBusyReason({
isStreaming: host.state.appState.streamingPhase !== 'idle',
isCompacting: host.state.appState.isCompacting,
});
if (busyReason !== undefined) {
host.showError(slashBusyMessage(intent.commandName, busyReason));
return;
}
}
host.track('input_command', { command: `${intent.pluginId}:${intent.commandName}` });
host.activatePluginCommand(session, intent.pluginId, intent.commandName, intent.args);
Expand Down Expand Up @@ -253,11 +294,67 @@ async function executeSlashCommand(host: SlashCommandHost, input: string): Promi
}
}

/**
* Lazy-create the session for a slash command that needs one (v2 engine).
* v1 keeps the historical "no active session" error; on v2 a missing session
* means the TUI started session-less, so commands create it on first use.
* Returns undefined (error already shown) when creation fails.
*/
async function ensureSessionForCommand(host: SlashCommandHost): Promise<Session | undefined> {
if (!host.engineV2) {
host.showError(LLM_NOT_SET_MESSAGE);
return undefined;
}
return host.ensureSession();
Comment thread
liruifengv marked this conversation as resolved.
}

/** Builtin commands that need an active session; lazy-created on the v2 engine. */
const SESSION_REQUIRING_COMMANDS: ReadonlySet<BuiltinSlashCommandName> = new Set([
'auto',
'btw',
'compact',
'export-debug-zip',
'export-md',
'fork',
'goal',
'init',
'mcp',
'permission',
'plan',
'status',
'swarm',
'title',
'undo',
'usage',
'web',
'yolo',
]);

async function handleBuiltInSlashCommand(
host: SlashCommandHost,
name: BuiltinSlashCommandName,
args: string,
): Promise<void> {
if (host.session === undefined && SESSION_REQUIRING_COMMANDS.has(name)) {
const session = await ensureSessionForCommand(host);
if (session === undefined) 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).
const command = findBuiltInSlashCommand(name);
const busyReason = slashCommandBusyReason({
isStreaming: host.state.appState.streamingPhase !== 'idle',
isCompacting: host.state.appState.isCompacting,
});
if (
busyReason !== undefined &&
command !== undefined &&
resolveSlashCommandAvailability(command, args) === 'idle-only'
) {
host.showError(slashBusyMessage(name, busyReason));
return;
}
}
switch (name) {
case 'exit':
void host.stop();
Expand All @@ -282,7 +379,14 @@ async function handleBuiltInSlashCommand(
void showMcpServers(host);
return;
case 'plugins':
void handlePluginsCommand(host, args);
// `handlePluginsCommand` throws when no session is active (its own
// requireSession), so catch here instead of letting the `void` call
// reject unhandled.
try {
await handlePluginsCommand(host, args);
} catch (error) {
host.showError(formatErrorMessage(error));
}
return;
case 'add-dir':
await handleAddDirCommand(host, args);
Expand Down
66 changes: 50 additions & 16 deletions apps/kimi-code/src/tui/commands/plugins.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { homedir as osHomedir } from 'node:os';
import { isAbsolute, join, resolve } from 'node:path';

import type { PluginInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk';
import type { PluginInfo, PluginSummary, Session } from '@moonshot-ai/kimi-code-sdk';

import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui';
import {
PluginInstallTrustConfirmComponent,
PluginMcpSelectorComponent,
Expand Down Expand Up @@ -50,11 +51,46 @@ interface ShowPluginMcpPickerOptions {
readonly serverHint?: PluginMcpServerHint;
}

/** The plugin-management surface `/plugins` operates on. */
type PluginApi = Pick<
Session,
| 'listPlugins'
| 'installPlugin'
| 'setPluginEnabled'
| 'setPluginMcpServerEnabled'
| 'removePlugin'
| 'reloadPlugins'
| 'getPluginInfo'
>;

/**
* Resolve the plugin-management API. On the v2 engine plugin state is
* app-global, so a session-less startup still gets a working `/plugins`
* through the harness's global facade; on v1 (and once a session exists) the
* session's own API is used.
*/
async function resolvePluginApi(host: SlashCommandHost): Promise<PluginApi> {
if (host.session !== undefined) return host.session;
if (!host.engineV2) {
throw new Error(NO_ACTIVE_SESSION_MESSAGE);
}
return {
listPlugins: () => host.harness.listPlugins(),
installPlugin: (source) => host.harness.installPlugin(source),
setPluginEnabled: (id, enabled) => host.harness.setPluginEnabled(id, enabled),
setPluginMcpServerEnabled: (id, server, enabled) =>
host.harness.setPluginMcpServerEnabled(id, server, enabled),
removePlugin: (id) => host.harness.removePlugin(id),
reloadPlugins: () => host.harness.reloadPlugins(),
getPluginInfo: (id) => host.harness.getPluginInfo(id),
};
}

export async function handlePluginsCommand(host: SlashCommandHost, rawArgs: string): Promise<void> {
const args = rawArgs.trim().split(/\s+/).filter((part) => part.length > 0);
const sub = args[0];
const rest = args.slice(1);
const session = host.requireSession();
const session = await resolvePluginApi(host);

try {
if (sub === undefined) {
Expand Down Expand Up @@ -163,7 +199,7 @@ async function showPluginsPicker(
): Promise<void> {
let plugins: readonly PluginSummary[];
try {
plugins = await host.requireSession().listPlugins();
plugins = await (await resolvePluginApi(host)).listPlugins();
} catch (error) {
host.showError(`Failed to load plugins: ${formatErrorMessage(error)}`);
return;
Expand Down Expand Up @@ -230,7 +266,7 @@ async function showPluginMcpPicker(
): Promise<void> {
let info: PluginInfo;
try {
info = await host.requireSession().getPluginInfo(id);
info = await (await resolvePluginApi(host)).getPluginInfo(id);
} catch (error) {
host.showError(`Failed to load plugin MCP servers: ${formatErrorMessage(error)}`);
return;
Expand Down Expand Up @@ -259,7 +295,7 @@ async function showPluginMcpPicker(
async function confirmRemovePlugin(host: SlashCommandHost, id: string): Promise<boolean> {
let displayName = id;
try {
displayName = (await host.requireSession().getPluginInfo(id)).displayName;
displayName = (await (await resolvePluginApi(host)).getPluginInfo(id)).displayName;
} catch {
// Keep the confirmation available even when plugin details cannot be loaded.
}
Expand Down Expand Up @@ -345,7 +381,7 @@ async function applyPluginEnabled(
enabled: boolean,
showStatus = true,
): Promise<string> {
const session = host.requireSession();
const session = await resolvePluginApi(host);
await session.setPluginEnabled(id, enabled);
let info: PluginInfo | undefined;
try {
Expand Down Expand Up @@ -432,11 +468,9 @@ async function handlePluginMcpSelection(
): Promise<void> {
switch (selection.kind) {
case 'toggle':
await host.requireSession().setPluginMcpServerEnabled(
selection.pluginId,
selection.server,
selection.enabled,
);
await (
await resolvePluginApi(host)
).setPluginMcpServerEnabled(selection.pluginId, selection.server, selection.enabled);
await showPluginMcpPicker(host, selection.pluginId, {
selectedServer: selection.server,
serverHint: {
Expand All @@ -452,7 +486,7 @@ async function handlePluginMcpSelection(
}

async function removePlugin(host: SlashCommandHost, id: string): Promise<void> {
await host.requireSession().removePlugin(id);
await (await resolvePluginApi(host)).removePlugin(id);
host.showStatus(`Removed ${id}.`);
host.showStatus(PLUGIN_RELOAD_HINT, 'warning');
}
Expand All @@ -461,7 +495,7 @@ async function renderPluginsList(
host: SlashCommandHost,
plugins?: readonly PluginSummary[],
): Promise<void> {
const currentPlugins = plugins ?? (await host.requireSession().listPlugins());
const currentPlugins = plugins ?? (await (await resolvePluginApi(host)).listPlugins());
const title = ` Plugins (${currentPlugins.length}) `;
const panel = new UsagePanelComponent(
() => buildPluginsListLines({ plugins: currentPlugins }),
Expand All @@ -473,7 +507,7 @@ async function renderPluginsList(
}

async function renderPluginInfo(host: SlashCommandHost, id: string): Promise<void> {
const info = await host.requireSession().getPluginInfo(id);
const info = await (await resolvePluginApi(host)).getPluginInfo(id);
const panel = new UsagePanelComponent(
() => buildPluginsInfoLines({ info }),
'primary',
Expand All @@ -487,7 +521,7 @@ async function installPluginFromSource(
host: SlashCommandHost,
source: string,
): Promise<void> {
const session = host.requireSession();
const session = await resolvePluginApi(host);
const beforeList = await session.listPlugins();
const summary = await session.installPlugin(
resolvePluginInstallSource(source, host.state.appState.workDir),
Expand Down Expand Up @@ -558,7 +592,7 @@ function truncateForStatus(input: string): string {
}

async function reloadPlugins(host: SlashCommandHost): Promise<void> {
const summary = await host.requireSession().reloadPlugins();
const summary = await (await resolvePluginApi(host)).reloadPlugins();
const line = `Reload: +${summary.added.length} -${summary.removed.length}` +
(summary.errors.length > 0 ? ` (${summary.errors.length} errors)` : '');
host.showStatus(line);
Comment on lines 594 to 598

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh plugin slash commands after global reloads

When /plugins reload runs before the first v2 session exists, this now reloads the app-global plugin service, but the TUI's pluginCommandMap is still the snapshot fetched at startup and is not rebuilt here. Any newly added or enabled plugin slash command continues to be parsed as a normal prompt until a session is created or the TUI restarts, so the sessionless reload path does not actually apply command changes in the current UI.

Useful? React with 👍 / 👎.

Expand Down
16 changes: 16 additions & 0 deletions apps/kimi-code/src/tui/controllers/auth-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type KimiHarness,
type OAuthRef,
type Session,
type ThinkingEffort,
} from '@moonshot-ai/kimi-code-sdk';

import { createKimiCodeUserAgent } from '#/cli/version';
Expand Down Expand Up @@ -32,6 +33,7 @@ export interface AuthFlowHost {
session: Session | undefined;
readonly harness: KimiHarness;
readonly options: KimiTUIOptions;
readonly engineV2: boolean;

setAppState(patch: Partial<AppState>): void;
setStartupReady(): void;
Expand Down Expand Up @@ -83,6 +85,20 @@ export class AuthFlowController {
return;
}

if (host.engineV2) {
// Lazy session creation (v2 engine): configure the model only; the
// session is created on the first message. The effort is carried as the
// first session's thinking override so a session-only choice (Alt+S)
// made before any session exists is applied on creation.
const patch: Partial<AppState> = { model };
if (effort !== undefined) {
patch.thinkingEffort = effort as ThinkingEffort;
patch.lazySessionThinking = effort as ThinkingEffort;
}
host.setAppState(patch);
return;
}

const options: MutableCreateSessionOptions = {
workDir: host.state.appState.workDir,
model,
Expand Down
Loading
Loading