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
7 changes: 7 additions & 0 deletions .changeset/reload-session-tui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@moonshot-ai/agent-core": minor
"@moonshot-ai/kimi-code-sdk": minor
"@moonshot-ai/kimi-code": minor
---

Add /reload to reload the current session and apply updated config files, plus /reload-tui to reload only TUI preferences.
9 changes: 9 additions & 0 deletions apps/kimi-code/src/tui/commands/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { handleGoalCommand } from './goal';
import { handleProviderCommand } from './provider';
import { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info';
import { handlePluginsCommand } from './plugins';
import { handleReloadCommand, handleReloadTuiCommand } from './reload';
import {
handleExportDebugZipCommand,
handleExportMdCommand,
Expand Down Expand Up @@ -77,6 +78,7 @@ export {
showUsage,
} from './info';
export { handlePluginsCommand } from './plugins';
export { handleReloadCommand, handleReloadTuiCommand } from './reload';
export { handleGoalCommand } from './goal';
export {
handleExportDebugZipCommand,
Expand Down Expand Up @@ -111,6 +113,7 @@ export interface SlashCommandHost {
// Session
requireSession(): Session;
switchToSession(session: Session, message: string): Promise<void>;
reloadCurrentSessionView(session: Session, message: string): Promise<void>;
beginSessionRequest(): void;
failSessionRequest(message: string): void;
sendQueuedMessage(session: Session, item: QueuedMessage): void;
Expand Down Expand Up @@ -235,6 +238,12 @@ async function handleBuiltInSlashCommand(
case 'plugins':
void handlePluginsCommand(host, args);
return;
case 'reload':
await handleReloadCommand(host);
return;
case 'reload-tui':
await handleReloadTuiCommand(host);
return;
case 'editor':
await handleEditorCommand(host, args);
return;
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-code/src/tui/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export {
showUsage,
} from './info';
export { handlePluginsCommand } from './plugins';
export { handleReloadCommand, handleReloadTuiCommand } from './reload';
export { handleGoalCommand, parseGoalCommand } from './goal';
export { goalArgumentCompletions } from './registry';
export {
Expand Down
14 changes: 14 additions & 0 deletions apps/kimi-code/src/tui/commands/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,20 @@ export const BUILTIN_SLASH_COMMANDS = [
priority: 60,
availability: 'always',
},
{
name: 'reload',
aliases: [],
description: 'Reload session and apply config.toml settings plus tui.toml UI preferences',
priority: 60,
availability: 'idle-only',
},
{
name: 'reload-tui',
aliases: [],
description: 'Reload only tui.toml UI preferences',
priority: 60,
availability: 'always',
},
{
name: 'compact',
aliases: [],
Expand Down
52 changes: 52 additions & 0 deletions apps/kimi-code/src/tui/commands/reload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { KimiConfig } from '@moonshot-ai/kimi-code-sdk';

import { loadTuiConfig, type TuiConfig } from '../config';
import type { SlashCommandHost } from './dispatch';

export async function handleReloadTuiCommand(host: SlashCommandHost): Promise<void> {
const tuiConfig = await loadTuiConfig();
applyReloadedTuiConfig(host, tuiConfig);
host.showStatus('TUI config reloaded.', host.state.theme.colors.success);
}

export async function handleReloadCommand(host: SlashCommandHost): Promise<void> {
const tuiConfig = await loadTuiConfig();

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 Handle invalid TUI config during reload

When tui.toml has a parse or schema error, loadTuiConfig() throws TuiConfigParseError; startup catches that error and applies its fallback defaults, but this new /reload path does not. In that scenario /reload aborts before reloading the active session or runtime config at all, even though the error message says defaults are being used, so a bad UI preference file prevents users from applying fixed config.toml changes until they restart or manually fix the TUI file.

Useful? React with 👍 / 👎.

const session = host.session;

if (session !== undefined) {
await session.reloadSession();

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 Mark reload as busy before rebuilding the session

Because slash-command execution is fire-and-forget and this active-session path does not call beginSessionRequest() or otherwise set streamingPhase away from idle, a user can submit another prompt while /reload is still closing and resuming the core session. During that window KimiCore.reloadSession() closes/deletes the old session before resumeSession() completes, so the prompt can race into SESSION_NOT_FOUND or the old runtime instead of being queued until reload finishes.

Useful? React with 👍 / 👎.

await host.reloadCurrentSessionView(session, 'Session reloaded.');
}

const config = await host.harness.getConfig({ reload: true });
applyRuntimeConfig(host, config);
Comment thread
liruifengv marked this conversation as resolved.
applyReloadedTuiConfig(host, tuiConfig);

if (session === undefined) {
host.showStatus(
'Runtime and TUI config reloaded; no active session.',
host.state.theme.colors.success,
);
}
}

export function applyReloadedTuiConfig(
host: SlashCommandHost,
config: TuiConfig,
): void {
const resolved = config.theme === 'auto' ? host.state.theme.resolvedTheme : config.theme;
host.applyTheme(config.theme, resolved);
host.refreshTerminalThemeTracking();
host.setAppState({
editorCommand: config.editorCommand,
notifications: config.notifications,
upgrade: config.upgrade,
});
}

function applyRuntimeConfig(host: SlashCommandHost, config: KimiConfig): void {
host.setAppState({
availableModels: config.models ?? {},
availableProviders: config.providers ?? {},
Comment thread
liruifengv marked this conversation as resolved.
});
}
28 changes: 28 additions & 0 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1169,6 +1169,34 @@ export class KimiTUI {
this.showStatus(statusMessage);
}

async reloadCurrentSessionView(session: Session, statusMessage: string): Promise<void> {
this.sessionEventUnsubscribe?.();
this.sessionEventUnsubscribe = undefined;
this.clearReverseRpcPanels();
session.setApprovalHandler(undefined);
session.setQuestionHandler(undefined);
this.approvalController.cancelAll('reloading session');
this.questionController.cancelAll('reloading session');

this.resetSessionRuntime();
this.session = session;
this.harness.setTelemetryContext({ sessionId: session.id });
this.registerSessionHandlers(session);
await this.syncRuntimeState(session);
this.updateTerminalTitle();
try {
await this.refreshSkillCommands(session);
} catch {
/* keep the reloaded session usable even if dynamic skills fail */
}
this.sessionEventHandler.startSubscription();
Comment thread
liruifengv marked this conversation as resolved.
const resumeState = session.getResumeState();
if (resumeState?.warning !== undefined) {
this.showStatus(`Warning: ${resumeState.warning}`, this.state.theme.colors.warning);
}
this.showStatus(statusMessage);
}

async createNewSession(): Promise<void> {
if (this.state.appState.isReplaying) {
this.showError('Cannot start a new session while history is replaying.');
Expand Down
12 changes: 12 additions & 0 deletions apps/kimi-code/test/tui/commands/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ describe('built-in slash command registry', () => {
'new',
'permission',
'plan',
'reload',
'reload-tui',
'sessions',
'settings',
'status',
Expand All @@ -124,4 +126,14 @@ describe('built-in slash command registry', () => {
]),
);
});

it('keeps TUI reload always available and full reload idle-only', () => {
const reload = findBuiltInSlashCommand('reload');
const reloadTui = findBuiltInSlashCommand('reload-tui');

expect(reload).toBeDefined();
expect(reloadTui).toBeDefined();
expect(resolveSlashCommandAvailability(reload!, '')).toBe('idle-only');
expect(resolveSlashCommandAvailability(reloadTui!, '')).toBe('always');
});
});
139 changes: 139 additions & 0 deletions apps/kimi-code/test/tui/commands/reload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { afterEach, describe, expect, it, vi } from 'vitest';

import {
handleReloadCommand,
handleReloadTuiCommand,
} from '#/tui/commands/reload';
import type { SlashCommandHost } from '#/tui/commands';

const tempDirs: string[] = [];
const originalKimiCodeHome = process.env['KIMI_CODE_HOME'];

afterEach(async () => {
for (const dir of tempDirs.splice(0)) {
await rm(dir, { recursive: true, force: true });
}
if (originalKimiCodeHome === undefined) {
delete process.env['KIMI_CODE_HOME'];
} else {
process.env['KIMI_CODE_HOME'] = originalKimiCodeHome;
}
});

describe('reload slash commands', () => {
it('reloads tui.toml without touching Core session state', async () => {
await writeTuiConfig(`
theme = "light"

[editor]
command = "vim"

[notifications]
enabled = false
notification_condition = "always"

[upgrade]
auto_install = false
`);
const session = { reloadSession: vi.fn() };
const host = makeHost({ session });

await handleReloadTuiCommand(host);

expect(host.harness.getConfig).not.toHaveBeenCalled();
expect(session.reloadSession).not.toHaveBeenCalled();
expect(host.state.appState).toMatchObject({
theme: 'light',
editorCommand: 'vim',
notifications: { enabled: false, condition: 'always' },
upgrade: { autoInstall: false },
});
expect(host.showStatus).toHaveBeenCalledWith(
'TUI config reloaded.',
host.state.theme.colors.success,
);
});

it('reloads the active session, refreshes runtime config, and applies tui.toml', async () => {
await writeTuiConfig('theme = "light"\n');
const session = { id: 'ses-1', reloadSession: vi.fn(async () => ({})) };
const host = makeHost({ session });

await handleReloadCommand(host);

expect(session.reloadSession).toHaveBeenCalledOnce();
expect(host.reloadCurrentSessionView).toHaveBeenCalledWith(
session,
'Session reloaded.',
);
expect(host.harness.getConfig).toHaveBeenCalledWith({ reload: true });
expect(host.state.appState.theme).toBe('light');
expect(host.state.appState.availableModels).toEqual({
fresh: { provider: 'test', model: 'fresh-model', maxContextSize: 1000 },
});
});
});

async function writeTuiConfig(text: string): Promise<void> {
const dir = join(tmpdir(), `kimi-tui-reload-${Date.now()}-${Math.random().toString(36).slice(2)}`);
tempDirs.push(dir);
await mkdir(dir, { recursive: true });
process.env['KIMI_CODE_HOME'] = dir;
await writeFile(join(dir, 'tui.toml'), text, 'utf-8');
}

function makeHost({
session,
}: {
readonly session?: Record<string, unknown>;
} = {}) {
const state = {
appState: {
theme: 'dark',
editorCommand: null,
notifications: { enabled: true, condition: 'unfocused' },
upgrade: { autoInstall: true },
availableModels: {},
availableProviders: {},
},
theme: {
resolvedTheme: 'dark',
colors: {
success: '#00ff00',
},
},
};
return {
state,
session,
harness: {
getConfig: vi.fn(async () => ({
models: {
fresh: { provider: 'test', model: 'fresh-model', maxContextSize: 1000 },
},
providers: {
test: { type: 'kimi', apiKey: 'test-key' },
},
})),
},
setAppState: vi.fn((patch: Record<string, unknown>) => {
Object.assign(state.appState, patch);
}),
applyTheme: vi.fn((theme: string) => {
state.appState.theme = theme;
}),
refreshTerminalThemeTracking: vi.fn(),
reloadCurrentSessionView: vi.fn(async () => {}),
showStatus: vi.fn(),
} as unknown as SlashCommandHost & {
readonly harness: {
readonly getConfig: ReturnType<typeof vi.fn>;
};
readonly reloadCurrentSessionView: ReturnType<typeof vi.fn>;
readonly showStatus: ReturnType<typeof vi.fn>;
};
}
20 changes: 20 additions & 0 deletions apps/kimi-code/test/tui/commands/resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ describe('resolveSlashCommandInput', () => {
commandName: 'undo',
reason: 'streaming',
});
expect(resolve('/reload', { isStreaming: true })).toEqual({
kind: 'blocked',
commandName: 'reload',
reason: 'streaming',
});
});

it('blocks model and session pickers while compacting', () => {
Expand All @@ -87,6 +92,11 @@ describe('resolveSlashCommandInput', () => {
commandName: 'resume',
reason: 'compacting',
});
expect(resolve('/reload', { isCompacting: true })).toEqual({
kind: 'blocked',
commandName: 'reload',
reason: 'compacting',
});
});

it('allows always-available built-ins while streaming', () => {
Expand All @@ -105,6 +115,16 @@ describe('resolveSlashCommandInput', () => {
name: 'mcp',
args: '',
});
expect(resolve('/reload-tui', { isStreaming: true })).toMatchObject({
kind: 'builtin',
name: 'reload-tui',
args: '',
});
expect(resolve('/reload-tui', { isCompacting: true })).toMatchObject({
kind: 'builtin',
name: 'reload-tui',
args: '',
});
expect(resolve('/btw side question', { isStreaming: true })).toMatchObject({
kind: 'builtin',
name: 'btw',
Expand Down
Loading
Loading