diff --git a/.changeset/fork-print-resume-command.md b/.changeset/fork-print-resume-command.md new file mode 100644 index 000000000..1420788ed --- /dev/null +++ b/.changeset/fork-print-resume-command.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Print the full `kimi --resume` command after `/fork` and copy it to the clipboard, so the fork can be entered directly from a new CLI process. diff --git a/apps/kimi-code/src/tui/commands/session.ts b/apps/kimi-code/src/tui/commands/session.ts index 1a80c1947..df853fde5 100644 --- a/apps/kimi-code/src/tui/commands/session.ts +++ b/apps/kimi-code/src/tui/commands/session.ts @@ -5,7 +5,9 @@ import { pathToFileURL } from 'node:url'; import type { Session } from '@moonshot-ai/kimi-code-sdk'; import { detectInstallSource } from '#/cli/update/source'; +import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; import { detectShellEnvironment } from '#/utils/process/shell-env'; +import { quoteShellArg } from '#/utils/shell-quote'; import { toTerminalHyperlink } from '#/utils/terminal-hyperlink'; import { LLM_NOT_SET_MESSAGE, NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; import { isAbortError } from '../utils/errors'; @@ -76,9 +78,26 @@ export async function handleForkCommand(host: SlashCommandHost, args: string): P } // Stay in the source session: switching to the fork would close the // source, killing its in-flight turn and background tasks. The fork is - // an independent copy the user can switch to explicitly via /sessions. + // an independent copy the user can switch to explicitly via /sessions, + // or enter from a new CLI process with the printed resume command. + const command = forkResumeCommand(host.state.appState.workDir, forkId); + let clipboardNote: string; + try { + const method = await copyTextToClipboard(command); + // OSC 52 delivery is fire-and-forget: terminals without OSC 52 support + // silently drop the sequence, so only native delivery may claim success + // (same wording convention as /copy). + clipboardNote = + method === 'native' + ? 'Command copied to clipboard' + : 'Command copied via terminal escape sequence (unverified)'; + } catch { + clipboardNote = 'Failed to copy command to clipboard'; + } host.showStatus( - `Session forked (${forkId}). Still in the original session; switch to the fork via /sessions.`, + `Session forked (${forkId}). Still in the original session; switch to the fork via /sessions.\n` + + ` To enter the fork in a new process, run: ${command}\n` + + ` ${clipboardNote}`, ); } catch (error) { const msg = formatErrorMessage(error); @@ -86,6 +105,16 @@ export async function handleForkCommand(host: SlashCommandHost, args: string): P } } +function forkResumeCommand(workDir: string, forkId: string): string { + const dir = quoteShellArg(workDir); + // cmd.exe's `cd` only updates the given drive's remembered directory — a + // terminal on a different drive stays put, and the resume then runs in the + // wrong working directory. `pushd` switches drive + directory in both + // cmd.exe and PowerShell (`cd /d` would break PowerShell). + const changeDir = process.platform === 'win32' ? `pushd ${dir}` : `cd ${dir}`; + return `${changeDir} && kimi --resume ${quoteShellArg(forkId)}`; +} + function forkSourceTitle(host: SlashCommandHost, session: Session): string { const currentTitle = host.state.appState.sessionTitle?.trim(); if (currentTitle !== undefined && currentTitle.length > 0) return currentTitle; diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 619ecf2c2..0981b2067 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -47,6 +47,7 @@ import { import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; import type { StreamingUIController } from '#/tui/controllers/streaming-ui'; import { handleFeedbackCommand } from '#/tui/commands/info'; +import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; import { openUrl } from '#/utils/open-url'; import { createFeedbackArchivePath } from '../../src/feedback/archive'; import { packageCodebase, scanCodebase } from '../../src/feedback/codebase'; @@ -97,6 +98,12 @@ vi.mock('../../src/feedback/archive', async (importOriginal) => { // out so the test suite never spawns a browser window. vi.mock('#/utils/open-url', () => ({ openUrl: vi.fn() })); +// Clipboard access spawns platform tools (pbcopy/wl-copy …) and emits OSC 52 — +// stub it out so the suite never touches the real clipboard or stdout. +vi.mock('#/utils/clipboard/clipboard-text', () => ({ + copyTextToClipboard: vi.fn(async () => 'native'), +})); + const ESC = String.fromCodePoint(0x1b); const BEL = String.fromCodePoint(0x07); @@ -6381,6 +6388,14 @@ command = "vim" 'Session forked (ses-fork). Still in the original session; switch to the fork via /sessions.', ); }); + expect(copyTextToClipboard).toHaveBeenCalledWith( + "cd '/tmp/proj-a' && kimi --resume 'ses-fork'", + ); + const transcript = driver.state.transcriptContainer.render(120).join('\n'); + expect(transcript).toContain( + "To enter the fork in a new process, run: cd '/tmp/proj-a' && kimi --resume 'ses-fork'", + ); + expect(transcript).toContain('Command copied to clipboard'); expect(driver.getCurrentSessionId()).toBe('ses-source'); expect(source.close).not.toHaveBeenCalled(); expect(forked.close).toHaveBeenCalledOnce(); @@ -6393,6 +6408,70 @@ command = "vim" } }); + it('still prints the fork resume command when the clipboard copy fails', async () => { + vi.mocked(copyTextToClipboard).mockRejectedValueOnce(new Error('no clipboard')); + const source = makeSession({ id: 'ses-source' }); + const forked = makeSession({ id: 'ses-fork' }); + const forkSession = vi.fn(async () => forked); + const { driver } = await makeDriver(source, { forkSession }); + + driver.handleUserInput('/fork'); + + await vi.waitFor(() => { + const transcript = driver.state.transcriptContainer.render(120).join('\n'); + expect(transcript).toContain( + "To enter the fork in a new process, run: cd '/tmp/proj-a' && kimi --resume 'ses-fork'", + ); + expect(transcript).toContain('Failed to copy command to clipboard'); + }); + expect(driver.getCurrentSessionId()).toBe('ses-source'); + }); + + it('labels OSC 52 clipboard delivery as unverified after a fork', async () => { + vi.mocked(copyTextToClipboard).mockResolvedValueOnce('osc52'); + const source = makeSession({ id: 'ses-source' }); + const forked = makeSession({ id: 'ses-fork' }); + const forkSession = vi.fn(async () => forked); + const { driver } = await makeDriver(source, { forkSession }); + + driver.handleUserInput('/fork'); + + await vi.waitFor(() => { + expect(driver.state.transcriptContainer.render(120).join('\n')).toContain( + 'Command copied via terminal escape sequence (unverified)', + ); + }); + expect(driver.getCurrentSessionId()).toBe('ses-source'); + }); + + it('prints a pushd-based fork resume command on Windows', async () => { + const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { value: 'win32' }); + try { + const source = makeSession({ id: 'ses-source' }); + const forked = makeSession({ id: 'ses-fork' }); + const forkSession = vi.fn(async () => forked); + const { driver } = await makeDriver(source, { forkSession }, { + ...makeStartupInput(), + workDir: 'D:\\proj', + }); + + driver.handleUserInput('/fork'); + + // cmd.exe's `cd` does not switch drives; pushd works in cmd + PowerShell. + await vi.waitFor(() => { + expect(copyTextToClipboard).toHaveBeenCalledWith( + 'pushd "D:\\proj" && kimi --resume "ses-fork"', + ); + }); + expect(driver.getCurrentSessionId()).toBe('ses-source'); + } finally { + if (platformDescriptor !== undefined) { + Object.defineProperty(process, 'platform', platformDescriptor); + } + } + }); + it('keeps the current session when fork fails', async () => { const forkSession = vi.fn(async () => { throw new Error('fork unavailable'); diff --git a/docs/en/guides/sessions.md b/docs/en/guides/sessions.md index 39aa2cb9e..62f7afa28 100644 --- a/docs/en/guides/sessions.md +++ b/docs/en/guides/sessions.md @@ -87,6 +87,8 @@ To explore a new direction without disrupting the current conversation, use `/fo Forking does not switch you away: you stay in the original session and the conversation continues untouched. The fork is an independent copy you can switch to at any time using `/sessions`. A saved `/goal` is not copied to the fork. Start a new goal there if you want autonomous goal work. +After forking, the CLI prints a ready-to-run `kimi --resume` command (also copied to the clipboard) so you can enter the fork directly from a new terminal process. + ## Exporting a session Use `kimi export` to package a session as a ZIP file — useful for sharing, archiving, or filing a bug report: diff --git a/docs/zh/guides/sessions.md b/docs/zh/guides/sessions.md index 6501d29a1..fde31a44f 100644 --- a/docs/zh/guides/sessions.md +++ b/docs/zh/guides/sessions.md @@ -87,6 +87,8 @@ kimi --session fork 后你仍停留在原会话,对话不受影响、可以直接继续;派生出的副本与原会话彼此独立,可以随时通过 `/sessions` 切换过去。已保存的 `/goal` 不会复制到派生会话。如果你想在派生会话中进行自主 goal 工作,需要在那里开始一个新 goal。 +fork 完成后,CLI 会打印一条可直接运行的 `kimi --resume` 命令(并自动复制到剪贴板),方便你在新终端进程中直接进入派生会话。 + ## 导出会话 用 `kimi export` 把会话打包为 ZIP,适合分享、归档或提交问题反馈: