Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/persist-default-permission-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Add a persistent default permission mode picker and `/permission default <mode>` command for new sessions.
110 changes: 109 additions & 1 deletion apps/kimi-code/src/tui/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ import type { SlashCommandHost } from './dispatch';
// Plan / Config commands
// ---------------------------------------------------------------------------

const PERMISSION_COMMAND_USAGE =
'Usage: /permission [manual|auto|yolo] or /permission default <manual|auto|yolo>';

function parsePermissionMode(value: string): PermissionMode | undefined {
if (value === 'manual' || value === 'auto' || value === 'yolo') return value;
return undefined;
}

export async function handlePlanCommand(host: SlashCommandHost, args: string): Promise<void> {
const session = host.session;
if (session === undefined) {
Expand Down Expand Up @@ -150,6 +158,35 @@ export async function handleAutoCommand(host: SlashCommandHost, args: string): P
}
}

export async function handlePermissionCommand(host: SlashCommandHost, args: string): Promise<void> {
const tokens = args
.trim()
.toLowerCase()
.split(/\s+/)
.filter((token) => token.length > 0);
if (tokens.length === 0) {
showPermissionPicker(host);
return;
}

if (tokens[0] === 'default') {
const mode = tokens.length === 2 ? parsePermissionMode(tokens[1] ?? '') : undefined;
if (mode === undefined) {
host.showError(PERMISSION_COMMAND_USAGE);
return;
}
await applyDefaultPermissionChoice(host, mode);
return;
}

const mode = tokens.length === 1 ? parsePermissionMode(tokens[0] ?? '') : undefined;
if (mode === undefined) {
host.showError(PERMISSION_COMMAND_USAGE);
return;
}
await applyPermissionChoice(host, mode);
}

export async function handleCompactCommand(host: SlashCommandHost, args: string): Promise<void> {
const session = host.session;
if (session === undefined) {
Expand Down Expand Up @@ -403,13 +440,19 @@ export function showPermissionPicker(host: SlashCommandHost): void {
}

async function applyPermissionChoice(host: SlashCommandHost, mode: PermissionMode): Promise<void> {
const session = host.session;
if (session === undefined) {
host.showError(NO_ACTIVE_SESSION_MESSAGE);
return;
}

if (mode === host.state.appState.permissionMode) {
host.showStatus(`Permission mode unchanged: ${mode}.`);
return;
}

try {
await host.requireSession().setPermission(mode);
await session.setPermission(mode);
} catch (error) {
const msg = formatErrorMessage(error);
host.showError(`Failed to set permission mode: ${msg}`);
Expand All @@ -420,6 +463,70 @@ async function applyPermissionChoice(host: SlashCommandHost, mode: PermissionMod
host.showNotice(`Permission mode: ${mode}`);
}

function showDefaultPermissionPicker(host: SlashCommandHost): void {
void (async () => {
let currentValue: PermissionMode = 'manual';
try {
const config = await host.harness.getConfig({ reload: true });
currentValue = config.defaultPermissionMode ?? 'manual';
} catch {
currentValue = host.state.appState.permissionMode;
}

host.mountEditorReplacement(
new PermissionSelectorComponent({
currentValue,
colors: host.state.theme.colors,
onSelect: (value) => {
host.restoreEditor();
void applyDefaultPermissionChoice(host, value);
},
onCancel: () => {
host.restoreEditor();
},
}),
);
})();
}

async function applyDefaultPermissionChoice(host: SlashCommandHost, mode: PermissionMode): Promise<void> {
let persisted = false;
try {
const config = await host.harness.getConfig({ reload: true });
if (config.defaultPermissionMode !== mode) {
await host.harness.setConfig({ defaultPermissionMode: mode });
persisted = true;
}
} catch (error) {
const msg = formatErrorMessage(error);
host.showError(`Failed to save default permission mode: ${msg}`);
return;
}

let currentUpdated = false;
const session = host.session;
if (session !== undefined && mode !== host.state.appState.permissionMode) {
try {
await session.setPermission(mode);
host.setAppState({ permissionMode: mode });
currentUpdated = true;
} catch (error) {
const msg = formatErrorMessage(error);
const prefix = persisted
? `Default permission mode set to ${mode}`
: `Default permission mode already ${mode}`;
host.showError(`${prefix}, but failed to update current session: ${msg}`);
return;
}
}

const status = persisted
? `Default permission mode set to ${mode}.`
: `Default permission mode already ${mode}.`;
const detail = currentUpdated ? ' Current session updated.' : '';
host.showStatus(`${status}${detail}`, host.state.theme.colors.success);
}

export function showSettingsSelector(host: SlashCommandHost): void {
host.mountEditorReplacement(
new SettingsSelectorComponent({
Expand All @@ -439,6 +546,7 @@ function handleSettingsSelection(host: SlashCommandHost, value: SettingsSelectio
switch (value) {
case 'model': showModelPicker(host); return;
case 'permission': showPermissionPicker(host); return;
case 'default-permission': showDefaultPermissionPicker(host); return;
case 'theme': showThemePicker(host); return;
case 'editor': showEditorPicker(host); return;
case 'usage': void showUsage(host); return;
Expand Down
4 changes: 3 additions & 1 deletion apps/kimi-code/src/tui/commands/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
handleCompactCommand,
handleEditorCommand,
handleModelCommand,
handlePermissionCommand,
handlePlanCommand,
handleThemeCommand,
handleYoloCommand,
Expand Down Expand Up @@ -58,6 +59,7 @@ export {
handleCompactCommand,
handleEditorCommand,
handleModelCommand,
handlePermissionCommand,
handlePlanCommand,
handleThemeCommand,
handleYoloCommand,
Expand Down Expand Up @@ -233,7 +235,7 @@ async function handleBuiltInSlashCommand(
await handleProviderCommand(host);
return;
case 'permission':
showPermissionPicker(host);
await handlePermissionCommand(host, args);
return;
case 'settings':
showSettingsSelector(host);
Expand Down
2 changes: 1 addition & 1 deletion apps/kimi-code/src/tui/commands/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const BUILTIN_SLASH_COMMANDS = [
{
name: 'permission',
aliases: [],
description: 'Select permission mode',
description: 'Select or persist permission mode',
priority: 100,
availability: 'always',
},
Expand Down
14 changes: 13 additions & 1 deletion apps/kimi-code/src/tui/components/dialogs/settings-selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ import { ChoicePickerComponent, type ChoiceOption } from './choice-picker';

import type { ColorPalette } from '#/tui/theme/colors';

export type SettingsSelection = 'model' | 'theme' | 'editor' | 'permission' | 'usage';
export type SettingsSelection =
| 'model'
| 'theme'
| 'editor'
| 'permission'
| 'default-permission'
| 'usage';

const SETTINGS_OPTIONS: readonly ChoiceOption[] = [
{
Expand All @@ -15,6 +21,11 @@ const SETTINGS_OPTIONS: readonly ChoiceOption[] = [
label: 'Permission',
description: 'Choose how tool actions are approved.',
},
{
value: 'default-permission',
label: 'Default Permission',
description: 'Persist the permission mode for new sessions.',
},
{
value: 'theme',
label: 'Theme',
Expand All @@ -38,6 +49,7 @@ function isSettingsSelection(value: string): value is SettingsSelection {
value === 'theme' ||
value === 'editor' ||
value === 'permission' ||
value === 'default-permission' ||
value === 'usage'
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ describe('ChoicePickerComponent', () => {
const settingsOutput = settings.render(120).map(strip);
expect(settingsOutput).toContain(' ❯ Model');
expect(settingsOutput).toContain(' Switch the active model and thinking mode.');
expect(settingsOutput).toContain(' Default Permission');
expect(settingsOutput).toContain(' Persist the permission mode for new sessions.');
});

it('submits the selected model and inline thinking state', () => {
Expand Down
35 changes: 35 additions & 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 @@ -529,6 +529,41 @@ describe('KimiTUI message flow', () => {
expect(harness.track).not.toHaveBeenCalledWith('yolo_toggle', expect.anything());
});

it('sets permission mode directly from /permission arguments', async () => {
const { driver, session, harness } = await makeDriver();

driver.handleUserInput('/permission auto');

await vi.waitFor(() => {
expect(session.setPermission).toHaveBeenCalledWith('auto');
});
expect(driver.state.appState.permissionMode).toBe('auto');
expect(harness.setConfig).not.toHaveBeenCalled();
});

it('persists /permission default and applies it to the active session', async () => {
const session = makeSession();
const getConfig = vi.fn(async () => ({
models: {
k2: { model: 'moonshot-v1', maxContextSize: 100 },
},
defaultPermissionMode: 'manual',
}));
const setConfig = vi.fn(async () => ({
providers: {},
defaultPermissionMode: 'yolo',
}));
const { driver } = await makeDriver(session, { getConfig, setConfig });

driver.handleUserInput('/permission default yolo');

await vi.waitFor(() => {
expect(setConfig).toHaveBeenCalledWith({ defaultPermissionMode: 'yolo' });
expect(session.setPermission).toHaveBeenCalledWith('yolo');
});
expect(driver.state.appState.permissionMode).toBe('yolo');
});

it('hydrates MCP server status after subscribing to session events', async () => {
const session = makeSession({
listMcpServers: vi.fn(async () => [
Expand Down
2 changes: 1 addition & 1 deletion docs/en/reference/slash-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Some commands are only available in the idle state. Running them while the sessi
| `/provider` | — | Open the interactive provider manager to view, add, and delete configured providers. See [Providers and models — `/provider` and provider management](../configuration/providers.md#provider-and-provider-management). | Yes |
| `/model` | — | Switch the LLM model used by the current session. | Yes |
| `/settings` | `/config` | Open the settings panel inside the TUI. | Yes |
| `/permission` | — | Choose a permission mode. | Yes |
| `/permission [manual\|auto\|yolo]` | — | Choose a permission mode for the current session; run `/permission default <mode>` to persist the default for new sessions. | Yes |
| `/editor` | — | Configure the external editor launched by `Ctrl-G`. | Yes |
| `/theme` | — | Switch the terminal UI color theme. | Yes |

Expand Down
2 changes: 1 addition & 1 deletion docs/zh/reference/slash-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
| `/provider` | — | 打开交互式供应商管理器,查看、添加和删除已配置的供应商。详见 [平台与模型 — `/provider` 与供应商管理](../configuration/providers.md#provider-与供应商管理)。 | 是 |
| `/model` | — | 切换当前会话使用的 LLM 模型。 | 是 |
| `/settings` | `/config` | 打开 TUI 内的设置面板。 | 是 |
| `/permission` | — | 选择权限模式(permission mode。 | 是 |
| `/permission [manual\|auto\|yolo]` | — | 选择当前会话的权限模式;运行 `/permission default <mode>` 可持久化新会话默认模式。 | 是 |
| `/editor` | — | 配置 `Ctrl-G` 调起的外部编辑器。 | 是 |
| `/theme` | — | 切换终端 UI 配色主题。 | 是 |

Expand Down