feat(tui): print the fork resume command and copy it to the clipboard (#2940)
Some checks are pending
CI / build (push) Waiting to run
CI / test (1) (push) Waiting to run
CI / test (2) (push) Waiting to run
CI / test (3) (push) Waiting to run
CI / test (4) (push) Waiting to run
CI / test (5) (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-vscode-legacy (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions

* feat(tui): print the fork resume command and copy it to the clipboard

* fix(tui): use pushd in the Windows fork resume command so it switches drives

cmd.exe's `cd` only updates the target drive's remembered directory, so a
terminal on another drive would run `kimi --resume` in the wrong working
directory. `pushd` switches drive + directory in both cmd.exe and
PowerShell (`cd /d` would break PowerShell). Addresses the Codex review
comment.

* fix(tui): label OSC 52 clipboard delivery as unverified after fork

copyTextToClipboard falls back to an OSC 52 escape when no native
clipboard provider works; terminals without OSC 52 support silently
drop the sequence, so only native delivery may claim success. Matches
the wording convention of /copy. Addresses the Codex review comment.

---------

Co-authored-by: bj456736 <bj456736@users.noreply.github.com>
This commit is contained in:
bj456736 2026-08-15 14:09:43 +08:00 committed by GitHub
parent d96cd03770
commit 6b72345f8b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 119 additions and 2 deletions

View file

@ -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.

View file

@ -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;

View file

@ -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');

View file

@ -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:

View file

@ -87,6 +87,8 @@ kimi --session
fork 后你仍停留在原会话,对话不受影响、可以直接继续;派生出的副本与原会话彼此独立,可以随时通过 `/sessions` 切换过去。已保存的 `/goal` 不会复制到派生会话。如果你想在派生会话中进行自主 goal 工作,需要在那里开始一个新 goal。
fork 完成后CLI 会打印一条可直接运行的 `kimi --resume` 命令(并自动复制到剪贴板),方便你在新终端进程中直接进入派生会话。
## 导出会话
`kimi export` 把会话打包为 ZIP适合分享、归档或提交问题反馈