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
2 changes: 2 additions & 0 deletions apps/kimi-code/src/cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { registerDoctorCommand } from './sub/doctor';
import { registerExportCommand } from './sub/export';
import { registerLoginCommand } from './sub/login';
import { registerProviderCommand } from './sub/provider';
import { registerSessionCommand } from './sub/session';

export type MainCommandHandler = (opts: CLIOptions) => void;
export type MigrateCommandHandler = () => void;
Expand Down Expand Up @@ -80,6 +81,7 @@ export function createProgram(
registerAcpCommand(program);
registerLoginCommand(program);
registerDoctorCommand(program);
registerSessionCommand(program);
registerMigrateCommand(program, onMigrate);
program
.command('upgrade')
Expand Down
107 changes: 107 additions & 0 deletions apps/kimi-code/src/cli/sub/session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import {
createKimiHarness,
type KimiHarness,
type SessionSummary,
} from '@moonshot-ai/kimi-code-sdk';
import type { Command } from 'commander';

import { createCliTelemetryBootstrap, initializeCliTelemetry } from '#/cli/telemetry';
import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE } from '#/constant/app';
import { createKimiCodeHostIdentity } from '#/cli/version';
import { shutdownTelemetry, track, withTelemetryContext } from '@moonshot-ai/kimi-telemetry';

interface SessionDeps {
readonly archiveSession: (id: string) => Promise<SessionSummary>;
readonly unarchiveSession: (id: string) => Promise<SessionSummary>;
readonly stdout: { write(chunk: string): boolean };
readonly stderr: { write(chunk: string): boolean };
readonly exit: (code: number) => never;
}

export function registerSessionCommand(parent: Command, deps?: Partial<SessionDeps>): void {
const sessionCmd = parent.command('session').description('Manage sessions.');

sessionCmd
.command('archive <id>')
.description('Archive a session, hiding it from the default session picker.')
.action(async (id: string) => {
const commandDeps = createDefaultSessionDeps(deps);
try {
const summary = await commandDeps.archiveSession(id.trim());
commandDeps.stdout.write(`Archived session: ${summary.id}\n`);
} catch (error) {
commandDeps.stderr.write(`${errorMessage(error)}\n`);
commandDeps.exit(1);
}
});

sessionCmd
.command('unarchive <id>')
.description('Restore an archived session to the default session picker.')
.action(async (id: string) => {
const commandDeps = createDefaultSessionDeps(deps);
try {
const summary = await commandDeps.unarchiveSession(id.trim());
commandDeps.stdout.write(`Unarchived session: ${summary.id}\n`);
} catch (error) {
commandDeps.stderr.write(`${errorMessage(error)}\n`);
commandDeps.exit(1);
}
});
}

function createDefaultSessionDeps(overrides: Partial<SessionDeps> = {}): SessionDeps {
let harness: KimiHarness | undefined;
const identity = createKimiCodeHostIdentity();
const bootstrap = createCliTelemetryBootstrap();
const getHarness = (): KimiHarness => {
harness ??= createKimiHarness({
homeDir: bootstrap.homeDir,
identity,
telemetry: {
track,
withContext: withTelemetryContext,
setContext: () => {},
},
});
return harness;
};

const withTelemetry = async <T>(fn: (h: KimiHarness) => Promise<T>): Promise<T> => {
const h = getHarness();
await h.ensureConfigFile();
const config = await h.getConfig();
initializeCliTelemetry({
harness: h,
bootstrap,
config,
version: identity.version,
uiMode: CLI_UI_MODE,
});
try {
return await fn(h);
} finally {
await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS });
}
};

return {
archiveSession:
overrides.archiveSession ??
(async (id: string) => {
return withTelemetry((h) => h.archiveSession(id));
}),
unarchiveSession:
overrides.unarchiveSession ??
(async (id: string) => {
return withTelemetry((h) => h.unarchiveSession(id));
}),
stdout: overrides.stdout ?? process.stdout,
stderr: overrides.stderr ?? process.stderr,
exit: overrides.exit ?? ((code: number) => process.exit(code)),
};
}

function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
5 changes: 5 additions & 0 deletions apps/kimi-code/src/tui/commands/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import { handlePluginsCommand } from './plugins';
import { handleReloadCommand, handleReloadTuiCommand } from './reload';
import { handleSwarmCommand } from './swarm';
import {
handleArchiveCommand,
handleExportDebugZipCommand,
handleExportMdCommand,
handleForkCommand,
Expand Down Expand Up @@ -90,6 +91,7 @@ export { handlePluginsCommand } from './plugins';
export { handleReloadCommand, handleReloadTuiCommand } from './reload';
export { handleGoalCommand } from './goal';
export {
handleArchiveCommand,
handleExportDebugZipCommand,
handleExportMdCommand,
handleForkCommand,
Expand Down Expand Up @@ -299,6 +301,9 @@ async function handleBuiltInSlashCommand(
case 'title':
await handleTitleCommand(host, args);
return;
case 'archive':
await handleArchiveCommand(host, args);
return;
case 'yolo':
await handleYoloCommand(host, args);
return;
Expand Down
7 changes: 7 additions & 0 deletions apps/kimi-code/src/tui/commands/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,13 @@ export const BUILTIN_SLASH_COMMANDS = [
priority: 60,
availability: 'always',
},
{
name: 'archive',
aliases: [],
description: 'Archive or unarchive the current session',
priority: 60,
availability: 'always',
},
{
name: 'usage',
aliases: [],
Expand Down
36 changes: 36 additions & 0 deletions apps/kimi-code/src/tui/commands/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,42 @@ export async function handleExportDebugZipCommand(host: SlashCommandHost): Promi
}
}

export async function handleArchiveCommand(host: SlashCommandHost, args: string): Promise<void> {
const session = host.session;
if (session === undefined) {
host.showError(NO_ACTIVE_SESSION_MESSAGE);
return;
}

const subcmd = args.trim().toLowerCase();
const currentlyArchived = session.summary?.archived === true;
const shouldArchive = subcmd !== 'off' && subcmd !== 'unarchive';

if (shouldArchive && currentlyArchived) {
host.showStatus('Session is already archived.');
return;
}
if (!shouldArchive && !currentlyArchived) {
host.showStatus('Session is not archived.');
return;
}

try {
if (shouldArchive) {
await session.archive();
host.track('session_archived', { session_id: session.id });
host.showNotice('Session archived', 'Hidden from the default session picker.');
} else {
await session.unarchive();
host.track('session_unarchived', { session_id: session.id });
host.showNotice('Session unarchived', 'Visible in the default session picker.');
}
} catch (error) {
const msg = formatErrorMessage(error);
host.showError(`Failed to update archive state: ${msg}`);
}
}

export async function handleInitCommand(host: SlashCommandHost): Promise<void> {
const session = host.session;
if (host.state.appState.model.trim().length === 0 || session === undefined) {
Expand Down
6 changes: 4 additions & 2 deletions apps/kimi-code/src/tui/components/dialogs/session-picker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface SessionRow {
readonly last_prompt?: string | null;
readonly work_dir: string;
readonly updated_at: number;
readonly archived?: boolean | undefined;
readonly metadata?: Readonly<Record<string, unknown>> | undefined;
}

Expand Down Expand Up @@ -225,11 +226,12 @@ export class SessionPickerComponent extends Container implements Focusable {

const time = formatRelativeTime(session.updated_at);
const badge = isCurrent ? CURRENT_MARK : '';
const archivedBadge = session.archived === true ? 'archived' : '';
const rawTitle = (session.title ?? session.id).trim() || session.id;
const titleSource = formatSessionLabel({ title: rawTitle, metadata: session.metadata });

// Inline trailing parts after the title: "<title> <time> ← current".
const trailingParts = [time, badge].filter((p) => p.length > 0);
// Inline trailing parts after the title: "<title> <time> ← current archived".
const trailingParts = [time, badge, archivedBadge].filter((p) => p.length > 0);
const trailingText = trailingParts.length > 0 ? ' ' + trailingParts.join(' ') : '';
const trailingWidth = visibleWidth(trailingText);
const headerPrefixWidth = visibleWidth(pointer) + 1; // pointer + space
Expand Down
2 changes: 2 additions & 0 deletions apps/kimi-code/src/tui/utils/session-picker-rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ export function sessionRowsForPicker(
currentSessionHasContent: boolean,
): SessionRow[] {
return sessions
.filter((session) => !session.archived)
.filter((session) => currentSessionHasContent || session.id !== currentSessionId)
.map((session) => ({
id: session.id,
title: session.title ?? null,
last_prompt: session.lastPrompt ?? null,
work_dir: session.workDir,
updated_at: session.updatedAt ?? session.createdAt ?? 0,
archived: session.archived === true,
metadata: session.metadata,
}));
}
1 change: 1 addition & 0 deletions apps/kimi-code/test/cli/options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,7 @@ describe('CLI options parsing', () => {
'acp',
'login',
'doctor',
'session',
'migrate',
'upgrade',
]);
Expand Down
55 changes: 55 additions & 0 deletions apps/kimi-code/test/cli/session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, it, vi } from 'vitest';

import { registerSessionCommand } from '#/cli/sub/session';
import type { SessionSummary } from '@moonshot-ai/kimi-code-sdk';

function createProgramWithDeps(overrides: {
readonly archiveSession?: () => Promise<SessionSummary>;
readonly unarchiveSession?: () => Promise<SessionSummary>;
} = {}) {
const { Command } = require('commander');
const program = new Command('kimi');
const stdout: string[] = [];
const stderr: string[] = [];
const exits: number[] = [];
registerSessionCommand(program, {
archiveSession: overrides.archiveSession ?? vi.fn().mockResolvedValue({ id: 'ses-1' } as SessionSummary),
unarchiveSession: overrides.unarchiveSession ?? vi.fn().mockResolvedValue({ id: 'ses-1' } as SessionSummary),
stdout: { write: (chunk: string) => { stdout.push(chunk); return true; } },
stderr: { write: (chunk: string) => { stderr.push(chunk); return true; } },
exit: (code: number) => { exits.push(code); throw new Error(`exit ${code}`); },
});
return { program, stdout, stderr, exits };
}

describe('session CLI subcommand', () => {
it('archives a session by id', async () => {
const archiveSession = vi.fn().mockResolvedValue({ id: 'ses-abc' } as SessionSummary);
const { program, stdout } = createProgramWithDeps({ archiveSession });

await program.parseAsync(['node', 'kimi', 'session', 'archive', 'ses-abc']);

expect(archiveSession).toHaveBeenCalledWith('ses-abc');
expect(stdout.join('')).toContain('Archived session: ses-abc');
});

it('unarchives a session by id', async () => {
const unarchiveSession = vi.fn().mockResolvedValue({ id: 'ses-abc' } as SessionSummary);
const { program, stdout } = createProgramWithDeps({ unarchiveSession });

await program.parseAsync(['node', 'kimi', 'session', 'unarchive', 'ses-abc']);

expect(unarchiveSession).toHaveBeenCalledWith('ses-abc');
expect(stdout.join('')).toContain('Unarchived session: ses-abc');
});

it('prints an error and exits when archiving fails', async () => {
const archiveSession = vi.fn().mockRejectedValue(new Error('session not found'));
const { program, stderr, exits } = createProgramWithDeps({ archiveSession });

await expect(program.parseAsync(['node', 'kimi', 'session', 'archive', 'missing'])).rejects.toThrow('exit 1');
expect(archiveSession).toHaveBeenCalledWith('missing');
expect(stderr.join('')).toContain('session not found');
expect(exits).toEqual([1]);
});
});
80 changes: 80 additions & 0 deletions apps/kimi-code/test/tui/commands/session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, expect, it, vi } from 'vitest';

import { handleArchiveCommand } from '#/tui/commands/session';
import type { SlashCommandHost } from '#/tui/commands/dispatch';

function makeHost(overrides: {
readonly hasSession?: boolean;
readonly archived?: boolean;
} = {}) {
const session = {
id: 'ses-1',
summary: { archived: overrides.archived === true },
archive: vi.fn(async () => {}),
unarchive: vi.fn(async () => {}),
};
const host = {
session: overrides.hasSession === false ? undefined : session,
showError: vi.fn(),
showStatus: vi.fn(),
showNotice: vi.fn(),
track: vi.fn(),
} as unknown as SlashCommandHost;
return { host, session };
}

describe('handleArchiveCommand', () => {
it('shows an error when there is no active session', async () => {
const { host } = makeHost({ hasSession: false });
await handleArchiveCommand(host, '');
expect(host.showError).toHaveBeenCalled();
expect(host.session).toBeUndefined();
});

it('archives the current session when no subcommand is given', async () => {
const { host, session } = makeHost({ archived: false });
await handleArchiveCommand(host, '');
expect(session.archive).toHaveBeenCalledOnce();
expect(session.unarchive).not.toHaveBeenCalled();
expect(host.showNotice).toHaveBeenCalledWith(
'Session archived',
'Hidden from the default session picker.',
);
expect(host.track).toHaveBeenCalledWith('session_archived', { session_id: 'ses-1' });
});

it('unarchives the current session when given "off"', async () => {
const { host, session } = makeHost({ archived: true });
await handleArchiveCommand(host, 'off');
expect(session.unarchive).toHaveBeenCalledOnce();
expect(session.archive).not.toHaveBeenCalled();
expect(host.showNotice).toHaveBeenCalledWith(
'Session unarchived',
'Visible in the default session picker.',
);
expect(host.track).toHaveBeenCalledWith('session_unarchived', { session_id: 'ses-1' });
});

it('unarchives the current session when given "unarchive"', async () => {
const { host, session } = makeHost({ archived: true });
await handleArchiveCommand(host, 'unarchive');
expect(session.unarchive).toHaveBeenCalledOnce();
expect(session.archive).not.toHaveBeenCalled();
});

it('reports when the session is already archived', async () => {
const { host, session } = makeHost({ archived: true });
await handleArchiveCommand(host, '');
expect(session.archive).not.toHaveBeenCalled();
expect(session.unarchive).not.toHaveBeenCalled();
expect(host.showStatus).toHaveBeenCalledWith('Session is already archived.');
});

it('reports when the session is not archived', async () => {
const { host, session } = makeHost({ archived: false });
await handleArchiveCommand(host, 'off');
expect(session.archive).not.toHaveBeenCalled();
expect(session.unarchive).not.toHaveBeenCalled();
expect(host.showStatus).toHaveBeenCalledWith('Session is not archived.');
});
});
Loading