Skip to content
Draft
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-permission-mode-default.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Persist permission mode changes from the TUI as the default for new sessions.
90 changes: 49 additions & 41 deletions apps/kimi-code/src/tui/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,35 +86,27 @@ export async function handleYoloCommand(host: SlashCommandHost, args: string): P
const currentMode = host.state.appState.permissionMode;

if (subcmd === 'on') {
if (currentMode === 'yolo') {
host.showNotice('YOLO mode is already on');
return;
if (await applyPermissionMode(host, session, 'yolo')) {
host.showNotice('YOLO mode: ON', 'Workspace tools auto-approved.');
}
await session.setPermission('yolo');
host.setAppState({ permissionMode: 'yolo' });
host.showNotice('YOLO mode: ON', 'Workspace tools auto-approved.');
return;
}

if (subcmd === 'off') {
if (currentMode !== 'yolo') {
host.showNotice('YOLO mode is already off');
return;
if (await applyPermissionMode(host, session, 'manual')) {
host.showNotice('YOLO mode: OFF');
}
await session.setPermission('manual');
host.setAppState({ permissionMode: 'manual' });
host.showNotice('YOLO mode: OFF');
return;
}

// toggle
if (currentMode === 'yolo') {
await session.setPermission('manual');
host.setAppState({ permissionMode: 'manual' });
host.showNotice('YOLO mode: OFF');
} else {
await session.setPermission('yolo');
host.setAppState({ permissionMode: 'yolo' });
if (await applyPermissionMode(host, session, 'manual')) {
host.showNotice('YOLO mode: OFF');
}
return;
}
if (await applyPermissionMode(host, session, 'yolo')) {
host.showNotice('YOLO mode: ON', 'Workspace tools auto-approved.');
}
}
Expand All @@ -130,35 +122,27 @@ export async function handleAutoCommand(host: SlashCommandHost, args: string): P
const currentMode = host.state.appState.permissionMode;

if (subcmd === 'on') {
if (currentMode === 'auto') {
host.showNotice('Auto mode is already on');
return;
if (await applyPermissionMode(host, session, 'auto')) {
host.showNotice('Auto mode: ON', 'Tools auto-approved. Agent will not ask questions.');
}
await session.setPermission('auto');
host.setAppState({ permissionMode: 'auto' });
host.showNotice('Auto mode: ON', 'Tools auto-approved. Agent will not ask questions.');
return;
}

if (subcmd === 'off') {
if (currentMode !== 'auto') {
host.showNotice('Auto mode is already off');
return;
if (await applyPermissionMode(host, session, 'manual')) {
host.showNotice('Auto mode: OFF');
}
await session.setPermission('manual');
host.setAppState({ permissionMode: 'manual' });
host.showNotice('Auto mode: OFF');
return;
}

// toggle
if (currentMode === 'auto') {
await session.setPermission('manual');
host.setAppState({ permissionMode: 'manual' });
host.showNotice('Auto mode: OFF');
} else {
await session.setPermission('auto');
host.setAppState({ permissionMode: 'auto' });
if (await applyPermissionMode(host, session, 'manual')) {
host.showNotice('Auto mode: OFF');
}
return;
}
if (await applyPermissionMode(host, session, 'auto')) {
host.showNotice('Auto mode: ON', 'Tools auto-approved. Agent will not ask questions.');
}
}
Expand Down Expand Up @@ -585,21 +569,45 @@ export async function applyUpdatePreferenceChoice(
}

async function applyPermissionChoice(host: SlashCommandHost, mode: PermissionMode): Promise<void> {
if (mode === host.state.appState.permissionMode) {
host.showStatus(`Permission mode unchanged: ${mode}.`);
const session = host.session;
if (session === undefined) {
host.showError(NO_ACTIVE_SESSION_MESSAGE);
return;
}

const unchanged = mode === host.state.appState.permissionMode;
const applied = await applyPermissionMode(host, session, mode);
if (!applied) return;

if (unchanged) {
host.showStatus(`Permission mode unchanged: ${mode}; saved as default.`);
return;
}
host.showNotice(`Permission mode: ${mode}`);
}

async function applyPermissionMode(
host: SlashCommandHost,
session: Session,
mode: PermissionMode,
): Promise<boolean> {
try {
await host.requireSession().setPermission(mode);
await session.setPermission(mode);
} catch (error) {
const msg = formatErrorMessage(error);
host.showError(`Failed to set permission mode: ${msg}`);
return;
return false;
}

host.setAppState({ permissionMode: mode });
host.showNotice(`Permission mode: ${mode}`);
try {
await host.harness.setConfig({ defaultPermissionMode: mode });
} catch (error) {
const msg = formatErrorMessage(error);
host.showError(`Permission mode set for this session, but failed to save default: ${msg}`);
return false;
}
return true;
}

export function showSettingsSelector(host: SlashCommandHost): void {
Expand Down
105 changes: 105 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 @@ -28,6 +28,7 @@ import {
PluginRemoveConfirmComponent,
PluginsOverviewSelectorComponent,
} from '#/tui/components/dialogs/plugins-selector';
import { PermissionSelectorComponent } from '#/tui/components/dialogs/permission-selector';
import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui';
import type { StreamingUIController } from '#/tui/controllers/streaming-ui';
import { handleFeedbackCommand } from '#/tui/commands/info';
Expand Down Expand Up @@ -635,11 +636,13 @@ command = "vim"
it('routes /yolo through session permission state without app-layer telemetry duplication', async () => {
const { driver, session, harness } = await makeDriver();
harness.track.mockClear();
harness.setConfig.mockClear();

driver.handleUserInput('/yolo on');

await vi.waitFor(() => {
expect(session.setPermission).toHaveBeenCalledWith('yolo');
expect(harness.setConfig).toHaveBeenCalledWith({ defaultPermissionMode: 'yolo' });
});
expect(driver.state.appState).toMatchObject({
permissionMode: 'yolo',
Expand All @@ -648,6 +651,108 @@ command = "vim"
expect(harness.track).not.toHaveBeenCalledWith('yolo_toggle', expect.anything());
});

it.each([
{
input: '/yolo off',
initialMode: 'yolo',
persistedMode: 'manual',
notice: 'YOLO mode: OFF',
},
{
input: '/auto on',
initialMode: 'manual',
persistedMode: 'auto',
notice: 'Auto mode: ON',
},
{
input: '/auto off',
initialMode: 'auto',
persistedMode: 'manual',
notice: 'Auto mode: OFF',
},
] as const)('persists permission defaults for $input', async ({
input,
initialMode,
persistedMode,
notice,
}) => {
const { driver, session, harness } = await makeDriver();
driver.state.appState.permissionMode = initialMode;
session.setPermission.mockClear();
harness.setConfig.mockClear();

driver.handleUserInput(input);

await vi.waitFor(() => {
expect(session.setPermission).toHaveBeenCalledWith(persistedMode);
expect(harness.setConfig).toHaveBeenCalledWith({
defaultPermissionMode: persistedMode,
});
});
expect(driver.state.appState.permissionMode).toBe(persistedMode);
expect(stripSgr(renderTranscript(driver))).toContain(notice);
});

it('persists permission changes selected from /permission', async () => {
const { driver, session, harness } = await makeDriver();
session.setPermission.mockClear();
harness.setConfig.mockClear();

driver.handleUserInput('/permission');

await vi.waitFor(() => {
expect(driver.state.editorContainer.children[0]).toBeInstanceOf(PermissionSelectorComponent);
});
const picker = driver.state.editorContainer.children[0] as PermissionSelectorComponent;
picker.handleInput('\u001B[B');
picker.handleInput('\r');

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

it('does not persist permission defaults when runtime permission setup fails', async () => {
const session = makeSession({
setPermission: vi.fn(async () => {
throw new Error('permission denied');
}),
});
const { driver, harness } = await makeDriver(session);
harness.setConfig.mockClear();

driver.handleUserInput('/yolo on');

await vi.waitFor(() => {
expect(stripSgr(renderTranscript(driver))).toContain(
'Failed to set permission mode: permission denied',
);
});
expect(harness.setConfig).not.toHaveBeenCalled();
expect(driver.state.appState.permissionMode).toBe('manual');
});

it('reports default permission persistence failures without losing runtime state', async () => {
const setConfig = vi.fn(async () => {
throw new Error('disk full');
});
const { driver, session } = await makeDriver(makeSession(), { setConfig });
session.setPermission.mockClear();

driver.handleUserInput('/auto on');

await vi.waitFor(() => {
expect(stripSgr(renderTranscript(driver))).toContain(
'Permission mode set for this session, but failed to save default: disk full',
);
});
expect(session.setPermission).toHaveBeenCalledWith('auto');
expect(driver.state.appState.permissionMode).toBe('auto');
expect(stripSgr(renderTranscript(driver))).not.toContain('Auto mode: ON');
});

it('hydrates MCP server status after subscribing to session events', async () => {
const session = makeSession({
listMcpServers: vi.fn(async () => [
Expand Down