From e570f145f27fdb2827f6bf2af5effedf40789b06 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Fri, 17 Jul 2026 15:09:22 +0800 Subject: [PATCH 1/4] feat(tui): add /copy slash command to copy the last assistant message The command copies the latest assistant reply (text parts only, skipping thinking, tool-call-only turns, error and internal messages) to the clipboard. Clipboard writes now also emit an OSC 52 sequence (tmux-aware) so copying keeps working over SSH and in containers where no native clipboard tool exists. --- .changeset/add-copy-command.md | 5 + apps/kimi-code/src/tui/commands/copy.ts | 44 ++++++ apps/kimi-code/src/tui/commands/dispatch.ts | 5 + apps/kimi-code/src/tui/commands/index.ts | 1 + apps/kimi-code/src/tui/commands/registry.ts | 7 + .../src/utils/clipboard/clipboard-osc52.ts | 40 +++++ .../src/utils/clipboard/clipboard-text.ts | 16 +- apps/kimi-code/test/tui/commands/copy.test.ts | 148 ++++++++++++++++++ .../utils/clipboard/clipboard-text.test.ts | 61 ++++++++ docs/en/reference/slash-commands.md | 1 + docs/zh/reference/slash-commands.md | 1 + 11 files changed, 328 insertions(+), 1 deletion(-) create mode 100644 .changeset/add-copy-command.md create mode 100644 apps/kimi-code/src/tui/commands/copy.ts create mode 100644 apps/kimi-code/src/utils/clipboard/clipboard-osc52.ts create mode 100644 apps/kimi-code/test/tui/commands/copy.test.ts diff --git a/.changeset/add-copy-command.md b/.changeset/add-copy-command.md new file mode 100644 index 0000000000..1f3200ea43 --- /dev/null +++ b/.changeset/add-copy-command.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add the /copy slash command to copy the last assistant message to the clipboard. diff --git a/apps/kimi-code/src/tui/commands/copy.ts b/apps/kimi-code/src/tui/commands/copy.ts new file mode 100644 index 0000000000..ad8219bf8b --- /dev/null +++ b/apps/kimi-code/src/tui/commands/copy.ts @@ -0,0 +1,44 @@ +import type { ContextMessage } from '@moonshot-ai/kimi-code-sdk'; + +import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; +import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; +import { formatErrorMessage } from '../utils/event-payload'; +import { isInternalMessage } from '../utils/export-markdown'; +import type { SlashCommandHost } from './dispatch'; + +/** Last assistant text in the history, newest first; empty string when none. */ +export function findLastAssistantText(history: readonly ContextMessage[]): string { + for (let i = history.length - 1; i >= 0; i--) { + const msg = history[i]; + if (msg === undefined) continue; + if (msg.role !== 'assistant' || msg.isError === true || isInternalMessage(msg)) continue; + const text = msg.content + .filter((part): part is { type: 'text'; text: string } => part.type === 'text') + .map((part) => part.text) + .join('\n\n'); + if (text.trim().length > 0) return text; + } + return ''; +} + +export async function handleCopyCommand(host: SlashCommandHost): Promise { + const session = host.session; + if (session === undefined) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return; + } + + try { + const { history } = await session.getContext(); + const text = findLastAssistantText(history); + if (text.length === 0) { + host.showStatus('No assistant message to copy.', 'warning'); + return; + } + + await copyTextToClipboard(text); + host.showStatus(`Copied to clipboard (${String(text.length)} characters).`); + } catch (error) { + host.showError(`Failed to copy to clipboard: ${formatErrorMessage(error)}`); + } +} diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index 7508bc06a5..e1b3fb6168 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -21,6 +21,7 @@ import type { import { formatErrorMessage } from '../utils/event-payload'; import { handleLoginCommand, handleLogoutCommand } from './auth'; import { handleBtwCommand } from './btw'; +import { handleCopyCommand } from './copy'; import { handleAutoCommand, handleCompactCommand, @@ -61,6 +62,7 @@ import { handleWebCommand } from './web'; export { handleLoginCommand, handleLogoutCommand } from './auth'; export { handleBtwCommand } from './btw'; +export { handleCopyCommand } from './copy'; export { handleAddDirCommand } from './add-dir'; export { handleAutoCommand, @@ -351,6 +353,9 @@ async function handleBuiltInSlashCommand( case 'export-debug-zip': await handleExportDebugZipCommand(host); return; + case 'copy': + await handleCopyCommand(host); + return; case 'login': await handleLoginCommand(host); return; diff --git a/apps/kimi-code/src/tui/commands/index.ts b/apps/kimi-code/src/tui/commands/index.ts index 784ef7e615..7449dba9bc 100644 --- a/apps/kimi-code/src/tui/commands/index.ts +++ b/apps/kimi-code/src/tui/commands/index.ts @@ -9,6 +9,7 @@ export * from './types'; export { dispatchInput, type SlashCommandHost } from './dispatch'; export { handleLoginCommand, handleLogoutCommand } from './auth'; export { handleBtwCommand } from './btw'; +export { handleCopyCommand } from './copy'; export { handleCompactCommand, handleEditorCommand, diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 8e5a163867..34e052d6d6 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -384,6 +384,13 @@ export const BUILTIN_SLASH_COMMANDS = [ description: 'Export current session as a debug ZIP archive', priority: 40, }, + { + name: 'copy', + aliases: [], + description: 'Copy the last assistant message to the clipboard', + priority: 40, + availability: 'always', + }, { name: 'web', aliases: [], diff --git a/apps/kimi-code/src/utils/clipboard/clipboard-osc52.ts b/apps/kimi-code/src/utils/clipboard/clipboard-osc52.ts new file mode 100644 index 0000000000..f69976849a --- /dev/null +++ b/apps/kimi-code/src/utils/clipboard/clipboard-osc52.ts @@ -0,0 +1,40 @@ +const ESC = '\u001B'; +const BEL = '\u0007'; +const ST = '\\'; + +function isInsideTmux(): boolean { + return (process.env['TMUX'] ?? '').length > 0; +} + +/** + * Build an OSC 52 sequence that asks the terminal emulator to put `text` on + * the system clipboard. The sequence reaches the *local* clipboard through + * stdout alone, so it keeps working over SSH and inside containers where no + * native clipboard tool exists. Terminals without OSC 52 support silently + * ignore it. + * + * tmux swallows bare OSC sequences, so inside tmux the sequence is wrapped in + * a DCS passthrough with doubled ESC bytes (same convention as + * `buildTerminalNotificationSequences`). + */ +export function buildClipboardOSC52(text: string, insideTmux = isInsideTmux()): string { + const payload = Buffer.from(text, 'utf8').toString('base64'); + const sequence = `${ESC}]52;c;${payload}${BEL}`; + if (!insideTmux) return sequence; + const escaped = sequence.replaceAll(ESC, `${ESC}${ESC}`); + return `${ESC}Ptmux;${escaped}${ESC}${ST}`; +} + +/** + * Write the OSC 52 sequence to stdout. Returns false when stdout is not a + * terminal (the sequence would pollute piped output) or the write failed. + */ +export function writeClipboardOSC52(text: string): boolean { + if (!process.stdout.isTTY) return false; + try { + process.stdout.write(buildClipboardOSC52(text)); + return true; + } catch { + return false; + } +} diff --git a/apps/kimi-code/src/utils/clipboard/clipboard-text.ts b/apps/kimi-code/src/utils/clipboard/clipboard-text.ts index 8a8295f57a..5aa5672486 100644 --- a/apps/kimi-code/src/utils/clipboard/clipboard-text.ts +++ b/apps/kimi-code/src/utils/clipboard/clipboard-text.ts @@ -1,6 +1,7 @@ import { spawnSync } from 'node:child_process'; import { clipboard } from './clipboard-native'; +import { writeClipboardOSC52 } from './clipboard-osc52'; function runClipboardCommand(command: string, args: readonly string[], input: string): void { const result = spawnSync(command, args, { encoding: 'utf8', input }); @@ -41,6 +42,11 @@ async function copyWithPlatformCommand(text: string): Promise { } export async function copyTextToClipboard(text: string): Promise { + // OSC 52 travels over stdout to the local terminal emulator, so it reaches + // the clipboard even over SSH or in containers with no native clipboard + // tool. Emit it up front; every failure path below can fall back on it. + const osc52Emitted = writeClipboardOSC52(text); + const clipboardModule = clipboard; if (clipboardModule?.setText !== undefined) { try { @@ -51,5 +57,13 @@ export async function copyTextToClipboard(text: string): Promise { } } - await copyWithPlatformCommand(text); + try { + await copyWithPlatformCommand(text); + } catch (error) { + // The native clipboard is unreachable (headless server, SSH session, + // missing wl-copy/xclip …) but the terminal may still have delivered the + // text via OSC 52; without a terminal there is nothing left to try. + if (osc52Emitted) return; + throw error; + } } diff --git a/apps/kimi-code/test/tui/commands/copy.test.ts b/apps/kimi-code/test/tui/commands/copy.test.ts new file mode 100644 index 0000000000..57a3a3452b --- /dev/null +++ b/apps/kimi-code/test/tui/commands/copy.test.ts @@ -0,0 +1,148 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ContextMessage } from '@moonshot-ai/kimi-code-sdk'; + +import { findLastAssistantText, handleCopyCommand } from '#/tui/commands/copy'; +import type { SlashCommandHost } from '#/tui/commands/dispatch'; +import { findBuiltInSlashCommand, resolveSlashCommandAvailability } from '#/tui/commands/index'; + +const mocks = vi.hoisted(() => ({ + copyTextToClipboard: vi.fn(), +})); + +vi.mock('#/utils/clipboard/clipboard-text', () => ({ + copyTextToClipboard: mocks.copyTextToClipboard, +})); + +function assistantText(text: string, extra: Partial = {}): ContextMessage { + return { + role: 'assistant', + content: [{ type: 'text', text }], + toolCalls: [], + ...extra, + }; +} + +function userText(text: string): ContextMessage { + return { role: 'user', content: [{ type: 'text', text }], toolCalls: [] }; +} + +function makeHost(history: ContextMessage[]) { + const host = { + session: { + id: 'ses-1', + getContext: vi.fn(async () => ({ history, tokenCount: 0 })), + }, + showStatus: vi.fn(), + showError: vi.fn(), + } as unknown as SlashCommandHost & { + showStatus: ReturnType; + showError: ReturnType; + }; + return host; +} + +describe('copy slash command', () => { + it('is registered as an always-available built-in', () => { + const command = findBuiltInSlashCommand('copy'); + expect(command).toBeDefined(); + expect(resolveSlashCommandAvailability(command!, '')).toBe('always'); + }); +}); + +describe('findLastAssistantText', () => { + it('returns an empty string for empty history', () => { + expect(findLastAssistantText([])).toBe(''); + }); + + it('returns the newest assistant text across later user/tool noise', () => { + const history = [ + assistantText('first answer'), + userText('follow-up question'), + assistantText('second answer'), + userText('typing…'), + ]; + + expect(findLastAssistantText(history)).toBe('second answer'); + }); + + it('joins multiple text parts with a blank line', () => { + const history: ContextMessage[] = [ + { + role: 'assistant', + content: [ + { type: 'text', text: 'part one' }, + { type: 'think', think: 'hidden reasoning' }, + { type: 'text', text: 'part two' }, + ], + toolCalls: [], + }, + ]; + + expect(findLastAssistantText(history)).toBe('part one\n\npart two'); + }); + + it('skips error, internal, and text-less assistant messages', () => { + const history = [ + assistantText('real answer'), + assistantText('api failure', { isError: true }), + assistantText('hook noise', { origin: { kind: 'hook_result', event: 'Stop' } }), + { + role: 'assistant', + content: [], + toolCalls: [ + { type: 'function', id: 'call-1', name: 'Bash', arguments: '{"command":"ls"}' }, + ], + } as ContextMessage, + ]; + + expect(findLastAssistantText(history)).toBe('real answer'); + }); +}); + +describe('handleCopyCommand', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.copyTextToClipboard.mockResolvedValue(undefined); + }); + + it('copies the last assistant text and reports the character count', async () => { + const host = makeHost([userText('hi'), assistantText('final summary')]); + + await handleCopyCommand(host); + + expect(mocks.copyTextToClipboard).toHaveBeenCalledWith('final summary'); + expect(host.showStatus).toHaveBeenCalledWith( + `Copied to clipboard (${String('final summary'.length)} characters).`, + ); + expect(host.showError).not.toHaveBeenCalled(); + }); + + it('warns when there is no assistant message to copy', async () => { + const host = makeHost([userText('hi')]); + + await handleCopyCommand(host); + + expect(mocks.copyTextToClipboard).not.toHaveBeenCalled(); + expect(host.showStatus).toHaveBeenCalledWith('No assistant message to copy.', 'warning'); + }); + + it('shows an error when there is no active session', async () => { + const host = makeHost([]); + (host as { session?: unknown }).session = undefined; + + await handleCopyCommand(host); + + expect(host.showError).toHaveBeenCalledOnce(); + expect(mocks.copyTextToClipboard).not.toHaveBeenCalled(); + }); + + it('shows an error when the clipboard write fails', async () => { + mocks.copyTextToClipboard.mockRejectedValue(new Error('pbcopy exited')); + const host = makeHost([assistantText('final summary')]); + + await handleCopyCommand(host); + + expect(host.showError).toHaveBeenCalledWith('Failed to copy to clipboard: pbcopy exited'); + }); +}); diff --git a/apps/kimi-code/test/utils/clipboard/clipboard-text.test.ts b/apps/kimi-code/test/utils/clipboard/clipboard-text.test.ts index 72b036352a..21576e917e 100644 --- a/apps/kimi-code/test/utils/clipboard/clipboard-text.test.ts +++ b/apps/kimi-code/test/utils/clipboard/clipboard-text.test.ts @@ -2,6 +2,7 @@ import { spawnSync } from 'node:child_process'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { clipboard } from '#/utils/clipboard/clipboard-native'; +import { buildClipboardOSC52 } from '#/utils/clipboard/clipboard-osc52'; import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; vi.mock('node:child_process', () => ({ @@ -17,7 +18,30 @@ vi.mock('#/utils/clipboard/clipboard-native', () => ({ const clipboardMock = clipboard as unknown as { setText: ReturnType }; const spawnSyncMock = vi.mocked(spawnSync); +const originalIsTTYDescriptor = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY'); + +function restoreIsTTY(): void { + if (originalIsTTYDescriptor !== undefined) { + Object.defineProperty(process.stdout, 'isTTY', originalIsTTYDescriptor); + } +} + +function stubStdoutTTY(isTTY: boolean): void { + Object.defineProperty(process.stdout, 'isTTY', { + configurable: true, + writable: true, + value: isTTY, + }); +} + +function base64(text: string): string { + return Buffer.from(text, 'utf8').toString('base64'); +} + afterEach(() => { + restoreIsTTY(); + vi.restoreAllMocks(); + vi.unstubAllEnvs(); vi.clearAllMocks(); }); @@ -45,6 +69,7 @@ describe('copyTextToClipboard', () => { }); it('throws an Error when all platform clipboard commands fail', async () => { + stubStdoutTTY(false); clipboardMock.setText = undefined as unknown as ReturnType; spawnSyncMock.mockReturnValue({ status: 1, stderr: 'missing' } as ReturnType); @@ -54,3 +79,39 @@ describe('copyTextToClipboard', () => { ); }); }); + +describe('buildClipboardOSC52', () => { + it('emits a bare OSC 52 sequence outside tmux', () => { + expect(buildClipboardOSC52('hi', false)).toBe(`\u001B]52;c;${base64('hi')}\u0007`); + }); + + it('wraps the sequence in a tmux passthrough with doubled ESC bytes', () => { + expect(buildClipboardOSC52('hi', true)).toBe( + `\u001BPtmux;\u001B\u001B]52;c;${base64('hi')}\u0007\u001B\\`, + ); + }); +}); + +describe('OSC 52 fallback in copyTextToClipboard', () => { + it('resolves via OSC 52 when native clipboards fail on a terminal', async () => { + stubStdoutTTY(true); + clipboardMock.setText = undefined as unknown as ReturnType; + spawnSyncMock.mockReturnValue({ status: 1, stderr: 'missing' } as ReturnType); + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + await expect(copyTextToClipboard('hello world')).resolves.toBeUndefined(); + + const written = writeSpy.mock.calls.map(([chunk]) => String(chunk)).join(''); + expect(written).toContain(`]52;c;${base64('hello world')}`); + }); + + it('does not write escape sequences when stdout is not a terminal', async () => { + stubStdoutTTY(false); + clipboardMock.setText = vi.fn().mockResolvedValue(undefined); + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + await copyTextToClipboard('hello'); + + expect(writeSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index a84271a146..93588b3f88 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -38,6 +38,7 @@ Some commands are only available in the idle state. Executing these commands whi | `/init` | — | Analyze the current codebase and generate `AGENTS.md` | No | | `/export-md []` | `/export` | Export the current session as a Markdown file | No | | `/export-debug-zip` | — | Export the current session as a debug ZIP archive (same behavior as [`kimi export`](./kimi-command.md#kimi-export)) | No | +| `/copy` | — | Copy the last assistant message to the clipboard | Yes | | `/add-dir []` | — | Add an extra workspace directory to the current session. Run without a path (or with `list`) to list configured directories. When adding, choose whether to remember the directory for the project in `.kimi-code/local.toml` | No | ## Modes & Run Control diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index 7692e06c9a..90b09860ad 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -36,6 +36,7 @@ | `/init` | — | 分析当前代码库并生成 `AGENTS.md` | 否 | | `/export-md []` | `/export` | 将当前会话导出为 Markdown 文件 | 否 | | `/export-debug-zip` | — | 将当前会话导出为调试用 ZIP 压缩包(与 [`kimi export`](./kimi-command.md#kimi-export) 行为一致) | 否 | +| `/copy` | — | 将最后一条 AI 回复复制到剪贴板 | 是 | | `/add-dir []` | — | 为当前会话添加额外的工作目录。不带路径(或传入 `list`)运行时列出已配置的目录。添加时可选择是否将目录记入项目的 `.kimi-code/local.toml` | 否 | ## 模式与运行控制 From efd048b2dc29bd109b0a67932cb83b4c15c2114f Mon Sep 17 00:00:00 2001 From: liruifengv Date: Fri, 17 Jul 2026 15:39:30 +0800 Subject: [PATCH 2/4] fix(tui): gate /copy to idle and report OSC 52-only copies as unverified During streaming the in-flight assistant text is not in getContext() history yet, so /copy would silently copy an older message; make the command idle-only like /export-md. copyTextToClipboard now returns how the text was delivered so /copy can say when only an unverified OSC 52 escape carried it. --- apps/kimi-code/src/tui/commands/copy.ts | 8 ++++++-- apps/kimi-code/src/tui/commands/registry.ts | 1 - .../src/utils/clipboard/clipboard-text.ts | 11 ++++++++--- apps/kimi-code/test/tui/commands/copy.test.ts | 18 +++++++++++++++--- .../utils/clipboard/clipboard-text.test.ts | 6 +++--- docs/en/reference/slash-commands.md | 2 +- docs/zh/reference/slash-commands.md | 2 +- 7 files changed, 34 insertions(+), 14 deletions(-) diff --git a/apps/kimi-code/src/tui/commands/copy.ts b/apps/kimi-code/src/tui/commands/copy.ts index ad8219bf8b..1deac81487 100644 --- a/apps/kimi-code/src/tui/commands/copy.ts +++ b/apps/kimi-code/src/tui/commands/copy.ts @@ -36,8 +36,12 @@ export async function handleCopyCommand(host: SlashCommandHost): Promise { return; } - await copyTextToClipboard(text); - host.showStatus(`Copied to clipboard (${String(text.length)} characters).`); + const method = await copyTextToClipboard(text); + host.showStatus( + method === 'native' + ? `Copied to clipboard (${String(text.length)} characters).` + : `Copied via terminal escape sequence (unverified, ${String(text.length)} characters).`, + ); } catch (error) { host.showError(`Failed to copy to clipboard: ${formatErrorMessage(error)}`); } diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 34e052d6d6..037d2477a7 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -389,7 +389,6 @@ export const BUILTIN_SLASH_COMMANDS = [ aliases: [], description: 'Copy the last assistant message to the clipboard', priority: 40, - availability: 'always', }, { name: 'web', diff --git a/apps/kimi-code/src/utils/clipboard/clipboard-text.ts b/apps/kimi-code/src/utils/clipboard/clipboard-text.ts index 5aa5672486..8738c9ede4 100644 --- a/apps/kimi-code/src/utils/clipboard/clipboard-text.ts +++ b/apps/kimi-code/src/utils/clipboard/clipboard-text.ts @@ -41,7 +41,11 @@ async function copyWithPlatformCommand(text: string): Promise { throw new Error('No clipboard command is available.'); } -export async function copyTextToClipboard(text: string): Promise { +/** How the text was delivered: a verified local clipboard tool, or an + * unverified OSC 52 escape emitted to the terminal as a last resort. */ +export type ClipboardCopyMethod = 'native' | 'osc52'; + +export async function copyTextToClipboard(text: string): Promise { // OSC 52 travels over stdout to the local terminal emulator, so it reaches // the clipboard even over SSH or in containers with no native clipboard // tool. Emit it up front; every failure path below can fall back on it. @@ -51,7 +55,7 @@ export async function copyTextToClipboard(text: string): Promise { if (clipboardModule?.setText !== undefined) { try { await clipboardModule.setText(text); - return; + return 'native'; } catch { // Fall back to platform clipboard commands below. } @@ -59,11 +63,12 @@ export async function copyTextToClipboard(text: string): Promise { try { await copyWithPlatformCommand(text); + return 'native'; } catch (error) { // The native clipboard is unreachable (headless server, SSH session, // missing wl-copy/xclip …) but the terminal may still have delivered the // text via OSC 52; without a terminal there is nothing left to try. - if (osc52Emitted) return; + if (osc52Emitted) return 'osc52'; throw error; } } diff --git a/apps/kimi-code/test/tui/commands/copy.test.ts b/apps/kimi-code/test/tui/commands/copy.test.ts index 57a3a3452b..4bf0ba567e 100644 --- a/apps/kimi-code/test/tui/commands/copy.test.ts +++ b/apps/kimi-code/test/tui/commands/copy.test.ts @@ -43,10 +43,10 @@ function makeHost(history: ContextMessage[]) { } describe('copy slash command', () => { - it('is registered as an always-available built-in', () => { + it('is registered as an idle-only built-in', () => { const command = findBuiltInSlashCommand('copy'); expect(command).toBeDefined(); - expect(resolveSlashCommandAvailability(command!, '')).toBe('always'); + expect(resolveSlashCommandAvailability(command!, '')).toBe('idle-only'); }); }); @@ -103,7 +103,7 @@ describe('findLastAssistantText', () => { describe('handleCopyCommand', () => { beforeEach(() => { vi.clearAllMocks(); - mocks.copyTextToClipboard.mockResolvedValue(undefined); + mocks.copyTextToClipboard.mockResolvedValue('native'); }); it('copies the last assistant text and reports the character count', async () => { @@ -118,6 +118,18 @@ describe('handleCopyCommand', () => { expect(host.showError).not.toHaveBeenCalled(); }); + it('marks the copy as unverified when only the terminal escape delivered it', async () => { + mocks.copyTextToClipboard.mockResolvedValue('osc52'); + const host = makeHost([userText('hi'), assistantText('final summary')]); + + await handleCopyCommand(host); + + expect(host.showStatus).toHaveBeenCalledWith( + `Copied via terminal escape sequence (unverified, ${String('final summary'.length)} characters).`, + ); + expect(host.showError).not.toHaveBeenCalled(); + }); + it('warns when there is no assistant message to copy', async () => { const host = makeHost([userText('hi')]); diff --git a/apps/kimi-code/test/utils/clipboard/clipboard-text.test.ts b/apps/kimi-code/test/utils/clipboard/clipboard-text.test.ts index 21576e917e..510d60ba59 100644 --- a/apps/kimi-code/test/utils/clipboard/clipboard-text.test.ts +++ b/apps/kimi-code/test/utils/clipboard/clipboard-text.test.ts @@ -55,7 +55,7 @@ describe('copyTextToClipboard', () => { it('copies text with the native clipboard when available', async () => { clipboardMock.setText.mockResolvedValue(undefined); - await expect(copyTextToClipboard('cd "/tmp/proj-b"')).resolves.toBeUndefined(); + await expect(copyTextToClipboard('cd "/tmp/proj-b"')).resolves.toBe('native'); expect(clipboardMock.setText).toHaveBeenCalledWith('cd "/tmp/proj-b"'); }); @@ -65,7 +65,7 @@ describe('copyTextToClipboard', () => { expect(text).toBe('cd "/tmp/proj-b"'); }); - await expect(copyTextToClipboard('cd "/tmp/proj-b"')).resolves.toBeUndefined(); + await expect(copyTextToClipboard('cd "/tmp/proj-b"')).resolves.toBe('native'); }); it('throws an Error when all platform clipboard commands fail', async () => { @@ -99,7 +99,7 @@ describe('OSC 52 fallback in copyTextToClipboard', () => { spawnSyncMock.mockReturnValue({ status: 1, stderr: 'missing' } as ReturnType); const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); - await expect(copyTextToClipboard('hello world')).resolves.toBeUndefined(); + await expect(copyTextToClipboard('hello world')).resolves.toBe('osc52'); const written = writeSpy.mock.calls.map(([chunk]) => String(chunk)).join(''); expect(written).toContain(`]52;c;${base64('hello world')}`); diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index 93588b3f88..38b9174559 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -38,7 +38,7 @@ Some commands are only available in the idle state. Executing these commands whi | `/init` | — | Analyze the current codebase and generate `AGENTS.md` | No | | `/export-md []` | `/export` | Export the current session as a Markdown file | No | | `/export-debug-zip` | — | Export the current session as a debug ZIP archive (same behavior as [`kimi export`](./kimi-command.md#kimi-export)) | No | -| `/copy` | — | Copy the last assistant message to the clipboard | Yes | +| `/copy` | — | Copy the last assistant message to the clipboard | No | | `/add-dir []` | — | Add an extra workspace directory to the current session. Run without a path (or with `list`) to list configured directories. When adding, choose whether to remember the directory for the project in `.kimi-code/local.toml` | No | ## Modes & Run Control diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index 90b09860ad..64449d6572 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -36,7 +36,7 @@ | `/init` | — | 分析当前代码库并生成 `AGENTS.md` | 否 | | `/export-md []` | `/export` | 将当前会话导出为 Markdown 文件 | 否 | | `/export-debug-zip` | — | 将当前会话导出为调试用 ZIP 压缩包(与 [`kimi export`](./kimi-command.md#kimi-export) 行为一致) | 否 | -| `/copy` | — | 将最后一条 AI 回复复制到剪贴板 | 是 | +| `/copy` | — | 将最后一条 AI 回复复制到剪贴板 | 否 | | `/add-dir []` | — | 为当前会话添加额外的工作目录。不带路径(或传入 `list`)运行时列出已配置的目录。添加时可选择是否将目录记入项目的 `.kimi-code/local.toml` | 否 | ## 模式与运行控制 From d531e33c71f717175ebe825a0264a9950e19d42b Mon Sep 17 00:00:00 2001 From: liruifengv Date: Fri, 17 Jul 2026 15:50:57 +0800 Subject: [PATCH 3/4] fix(tui): source /copy text from the visible transcript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After /compact the model context keeps only user messages plus a user-role summary, so scanning it found no assistant message even though the last reply is still on screen. Read the last assistant transcript entry instead — it matches what the user sees and survives compaction and resume. --- apps/kimi-code/src/tui/commands/copy.ts | 41 +++----- apps/kimi-code/test/tui/commands/copy.test.ts | 98 +++++++------------ 2 files changed, 51 insertions(+), 88 deletions(-) diff --git a/apps/kimi-code/src/tui/commands/copy.ts b/apps/kimi-code/src/tui/commands/copy.ts index 1deac81487..05ec8c4545 100644 --- a/apps/kimi-code/src/tui/commands/copy.ts +++ b/apps/kimi-code/src/tui/commands/copy.ts @@ -1,41 +1,32 @@ -import type { ContextMessage } from '@moonshot-ai/kimi-code-sdk'; - import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; -import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; +import type { TranscriptEntry } from '../types'; import { formatErrorMessage } from '../utils/event-payload'; -import { isInternalMessage } from '../utils/export-markdown'; import type { SlashCommandHost } from './dispatch'; -/** Last assistant text in the history, newest first; empty string when none. */ -export function findLastAssistantText(history: readonly ContextMessage[]): string { - for (let i = history.length - 1; i >= 0; i--) { - const msg = history[i]; - if (msg === undefined) continue; - if (msg.role !== 'assistant' || msg.isError === true || isInternalMessage(msg)) continue; - const text = msg.content - .filter((part): part is { type: 'text'; text: string } => part.type === 'text') - .map((part) => part.text) - .join('\n\n'); - if (text.trim().length > 0) return text; +/** + * Visible text of the last assistant transcript entry, newest first; empty + * string when none. Sourced from the rendered transcript rather than the + * model context so it survives compaction and session resume: after + * `/compact` the context keeps user messages plus a user-role summary only, + * while the last reply is still on screen. + */ +export function findLastAssistantText(entries: readonly TranscriptEntry[]): string { + for (let i = entries.length - 1; i >= 0; i--) { + const entry = entries[i]; + if (entry === undefined || entry.kind !== 'assistant') continue; + if (entry.content.trim().length > 0) return entry.content; } return ''; } export async function handleCopyCommand(host: SlashCommandHost): Promise { - const session = host.session; - if (session === undefined) { - host.showError(NO_ACTIVE_SESSION_MESSAGE); + const text = findLastAssistantText(host.state.transcriptEntries); + if (text.length === 0) { + host.showStatus('No assistant message to copy.', 'warning'); return; } try { - const { history } = await session.getContext(); - const text = findLastAssistantText(history); - if (text.length === 0) { - host.showStatus('No assistant message to copy.', 'warning'); - return; - } - const method = await copyTextToClipboard(text); host.showStatus( method === 'native' diff --git a/apps/kimi-code/test/tui/commands/copy.test.ts b/apps/kimi-code/test/tui/commands/copy.test.ts index 4bf0ba567e..79c737b118 100644 --- a/apps/kimi-code/test/tui/commands/copy.test.ts +++ b/apps/kimi-code/test/tui/commands/copy.test.ts @@ -1,10 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { ContextMessage } from '@moonshot-ai/kimi-code-sdk'; - import { findLastAssistantText, handleCopyCommand } from '#/tui/commands/copy'; import type { SlashCommandHost } from '#/tui/commands/dispatch'; import { findBuiltInSlashCommand, resolveSlashCommandAvailability } from '#/tui/commands/index'; +import type { TranscriptEntry } from '#/tui/types'; const mocks = vi.hoisted(() => ({ copyTextToClipboard: vi.fn(), @@ -14,25 +13,24 @@ vi.mock('#/utils/clipboard/clipboard-text', () => ({ copyTextToClipboard: mocks.copyTextToClipboard, })); -function assistantText(text: string, extra: Partial = {}): ContextMessage { +let nextEntryId = 0; + +function entry(kind: TranscriptEntry['kind'], content: string): TranscriptEntry { return { - role: 'assistant', - content: [{ type: 'text', text }], - toolCalls: [], - ...extra, + id: `entry-${String(nextEntryId++)}`, + kind, + renderMode: 'markdown', + content, }; } -function userText(text: string): ContextMessage { - return { role: 'user', content: [{ type: 'text', text }], toolCalls: [] }; +function assistantEntry(content: string): TranscriptEntry { + return entry('assistant', content); } -function makeHost(history: ContextMessage[]) { +function makeHost(entries: TranscriptEntry[]) { const host = { - session: { - id: 'ses-1', - getContext: vi.fn(async () => ({ history, tokenCount: 0 })), - }, + state: { transcriptEntries: entries }, showStatus: vi.fn(), showError: vi.fn(), } as unknown as SlashCommandHost & { @@ -51,52 +49,36 @@ describe('copy slash command', () => { }); describe('findLastAssistantText', () => { - it('returns an empty string for empty history', () => { + it('returns an empty string for an empty transcript', () => { expect(findLastAssistantText([])).toBe(''); }); - it('returns the newest assistant text across later user/tool noise', () => { - const history = [ - assistantText('first answer'), - userText('follow-up question'), - assistantText('second answer'), - userText('typing…'), + it('returns the newest assistant entry across later non-assistant entries', () => { + const entries = [ + assistantEntry('first answer'), + entry('user', 'follow-up question'), + assistantEntry('second answer'), + entry('user', 'typing…'), + entry('status', 'Working…'), ]; - expect(findLastAssistantText(history)).toBe('second answer'); + expect(findLastAssistantText(entries)).toBe('second answer'); }); - it('joins multiple text parts with a blank line', () => { - const history: ContextMessage[] = [ - { - role: 'assistant', - content: [ - { type: 'text', text: 'part one' }, - { type: 'think', think: 'hidden reasoning' }, - { type: 'text', text: 'part two' }, - ], - toolCalls: [], - }, - ]; + it('skips assistant entries with empty or whitespace-only content', () => { + const entries = [assistantEntry('real answer'), assistantEntry(' \n ')]; - expect(findLastAssistantText(history)).toBe('part one\n\npart two'); + expect(findLastAssistantText(entries)).toBe('real answer'); }); - it('skips error, internal, and text-less assistant messages', () => { - const history = [ - assistantText('real answer'), - assistantText('api failure', { isError: true }), - assistantText('hook noise', { origin: { kind: 'hook_result', event: 'Stop' } }), - { - role: 'assistant', - content: [], - toolCalls: [ - { type: 'function', id: 'call-1', name: 'Bash', arguments: '{"command":"ls"}' }, - ], - } as ContextMessage, + it('ignores thinking and other non-visible-reply kinds', () => { + const entries = [ + assistantEntry('visible reply'), + entry('thinking', 'hidden reasoning'), + entry('tool_call', 'Bash ls'), ]; - expect(findLastAssistantText(history)).toBe('real answer'); + expect(findLastAssistantText(entries)).toBe('visible reply'); }); }); @@ -106,8 +88,8 @@ describe('handleCopyCommand', () => { mocks.copyTextToClipboard.mockResolvedValue('native'); }); - it('copies the last assistant text and reports the character count', async () => { - const host = makeHost([userText('hi'), assistantText('final summary')]); + it('copies the last visible assistant text and reports the character count', async () => { + const host = makeHost([entry('user', 'hi'), assistantEntry('final summary')]); await handleCopyCommand(host); @@ -120,7 +102,7 @@ describe('handleCopyCommand', () => { it('marks the copy as unverified when only the terminal escape delivered it', async () => { mocks.copyTextToClipboard.mockResolvedValue('osc52'); - const host = makeHost([userText('hi'), assistantText('final summary')]); + const host = makeHost([entry('user', 'hi'), assistantEntry('final summary')]); await handleCopyCommand(host); @@ -131,7 +113,7 @@ describe('handleCopyCommand', () => { }); it('warns when there is no assistant message to copy', async () => { - const host = makeHost([userText('hi')]); + const host = makeHost([entry('user', 'hi')]); await handleCopyCommand(host); @@ -139,19 +121,9 @@ describe('handleCopyCommand', () => { expect(host.showStatus).toHaveBeenCalledWith('No assistant message to copy.', 'warning'); }); - it('shows an error when there is no active session', async () => { - const host = makeHost([]); - (host as { session?: unknown }).session = undefined; - - await handleCopyCommand(host); - - expect(host.showError).toHaveBeenCalledOnce(); - expect(mocks.copyTextToClipboard).not.toHaveBeenCalled(); - }); - it('shows an error when the clipboard write fails', async () => { mocks.copyTextToClipboard.mockRejectedValue(new Error('pbcopy exited')); - const host = makeHost([assistantText('final summary')]); + const host = makeHost([assistantEntry('final summary')]); await handleCopyCommand(host); From 457707c764128f59dbb99edfae74d98c8c2f792c Mon Sep 17 00:00:00 2001 From: liruifengv Date: Fri, 17 Jul 2026 16:04:34 +0800 Subject: [PATCH 4/4] fix(tui): mark real model text in the transcript so /copy skips synthetic cards Hook-result and goal-completion cards are also 'assistant' transcript entries appended after the real reply, so /copy could copy a hook card instead of the answer. Tag the single entry-creation site for genuine model text (both live and replay flow through it) and have /copy only accept tagged entries. --- apps/kimi-code/src/tui/commands/copy.ts | 6 ++++-- apps/kimi-code/src/tui/controllers/streaming-ui.ts | 1 + apps/kimi-code/src/tui/types.ts | 7 +++++++ apps/kimi-code/test/tui/commands/copy.test.ts | 12 +++++++++++- 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/apps/kimi-code/src/tui/commands/copy.ts b/apps/kimi-code/src/tui/commands/copy.ts index 05ec8c4545..bb77b25155 100644 --- a/apps/kimi-code/src/tui/commands/copy.ts +++ b/apps/kimi-code/src/tui/commands/copy.ts @@ -8,12 +8,14 @@ import type { SlashCommandHost } from './dispatch'; * string when none. Sourced from the rendered transcript rather than the * model context so it survives compaction and session resume: after * `/compact` the context keeps user messages plus a user-role summary only, - * while the last reply is still on screen. + * while the last reply is still on screen. Only entries tagged `modelText` + * count — hook-result and goal-completion cards share kind 'assistant' but + * are not replies. */ export function findLastAssistantText(entries: readonly TranscriptEntry[]): string { for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i]; - if (entry === undefined || entry.kind !== 'assistant') continue; + if (entry === undefined || entry.kind !== 'assistant' || entry.modelText !== true) continue; if (entry.content.trim().length > 0) return entry.content; } return ''; diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index ae5eac7681..3f9eebc9a0 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -596,6 +596,7 @@ export class StreamingUIController { turnId: this._currentTurnId, renderMode: 'markdown' as const, content: '', + modelText: true, }; const component = new AssistantMessageComponent(); this._streamingBlock = { component, entry }; diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index e79d2c8b40..d895b11e12 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -166,6 +166,13 @@ export interface TranscriptEntry { turnId?: string; renderMode: 'markdown' | 'plain' | 'notice'; content: string; + /** + * True only for entries holding real model-authored text (created by the + * assistant stream). Derived cards — hook results, goal completions, goal + * reminders — share kind 'assistant' but are not replies, so /copy must + * skip them. + */ + modelText?: boolean; color?: ColorToken; detail?: string; /** Optional override for the leading bullet of a 'user' message entry. An empty string suppresses the bullet entirely (used by shell-command echoes so `$` replaces the sparkles marker). */ diff --git a/apps/kimi-code/test/tui/commands/copy.test.ts b/apps/kimi-code/test/tui/commands/copy.test.ts index 79c737b118..6e980aa5ff 100644 --- a/apps/kimi-code/test/tui/commands/copy.test.ts +++ b/apps/kimi-code/test/tui/commands/copy.test.ts @@ -25,7 +25,7 @@ function entry(kind: TranscriptEntry['kind'], content: string): TranscriptEntry } function assistantEntry(content: string): TranscriptEntry { - return entry('assistant', content); + return { ...entry('assistant', content), modelText: true }; } function makeHost(entries: TranscriptEntry[]) { @@ -80,6 +80,16 @@ describe('findLastAssistantText', () => { expect(findLastAssistantText(entries)).toBe('visible reply'); }); + + it('skips synthetic assistant cards like hook results and goal completions', () => { + const entries = [ + assistantEntry('real reply'), + entry('assistant', '*PostToolUse hook* ran something'), + entry('assistant', 'Goal completed: shipped the feature'), + ]; + + expect(findLastAssistantText(entries)).toBe('real reply'); + }); }); describe('handleCopyCommand', () => {