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..bb77b25155 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/copy.ts @@ -0,0 +1,41 @@ +import { copyTextToClipboard } from '#/utils/clipboard/clipboard-text'; +import type { TranscriptEntry } from '../types'; +import { formatErrorMessage } from '../utils/event-payload'; +import type { SlashCommandHost } from './dispatch'; + +/** + * 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. 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' || entry.modelText !== true) continue; + if (entry.content.trim().length > 0) return entry.content; + } + return ''; +} + +export async function handleCopyCommand(host: SlashCommandHost): Promise { + const text = findLastAssistantText(host.state.transcriptEntries); + if (text.length === 0) { + host.showStatus('No assistant message to copy.', 'warning'); + return; + } + + try { + 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/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..037d2477a7 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -384,6 +384,12 @@ 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, + }, { name: 'web', aliases: [], 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/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..8738c9ede4 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 }); @@ -40,16 +41,34 @@ 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. + const osc52Emitted = writeClipboardOSC52(text); + const clipboardModule = clipboard; if (clipboardModule?.setText !== undefined) { try { await clipboardModule.setText(text); - return; + return 'native'; } catch { // Fall back to platform clipboard commands below. } } - await copyWithPlatformCommand(text); + 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 '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 new file mode 100644 index 0000000000..6e980aa5ff --- /dev/null +++ b/apps/kimi-code/test/tui/commands/copy.test.ts @@ -0,0 +1,142 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +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(), +})); + +vi.mock('#/utils/clipboard/clipboard-text', () => ({ + copyTextToClipboard: mocks.copyTextToClipboard, +})); + +let nextEntryId = 0; + +function entry(kind: TranscriptEntry['kind'], content: string): TranscriptEntry { + return { + id: `entry-${String(nextEntryId++)}`, + kind, + renderMode: 'markdown', + content, + }; +} + +function assistantEntry(content: string): TranscriptEntry { + return { ...entry('assistant', content), modelText: true }; +} + +function makeHost(entries: TranscriptEntry[]) { + const host = { + state: { transcriptEntries: entries }, + 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 idle-only built-in', () => { + const command = findBuiltInSlashCommand('copy'); + expect(command).toBeDefined(); + expect(resolveSlashCommandAvailability(command!, '')).toBe('idle-only'); + }); +}); + +describe('findLastAssistantText', () => { + it('returns an empty string for an empty transcript', () => { + expect(findLastAssistantText([])).toBe(''); + }); + + 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(entries)).toBe('second answer'); + }); + + it('skips assistant entries with empty or whitespace-only content', () => { + const entries = [assistantEntry('real answer'), assistantEntry(' \n ')]; + + expect(findLastAssistantText(entries)).toBe('real answer'); + }); + + 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(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', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.copyTextToClipboard.mockResolvedValue('native'); + }); + + 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); + + expect(mocks.copyTextToClipboard).toHaveBeenCalledWith('final summary'); + expect(host.showStatus).toHaveBeenCalledWith( + `Copied to clipboard (${String('final summary'.length)} characters).`, + ); + 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([entry('user', 'hi'), assistantEntry('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([entry('user', 'hi')]); + + await handleCopyCommand(host); + + expect(mocks.copyTextToClipboard).not.toHaveBeenCalled(); + expect(host.showStatus).toHaveBeenCalledWith('No assistant message to copy.', 'warning'); + }); + + it('shows an error when the clipboard write fails', async () => { + mocks.copyTextToClipboard.mockRejectedValue(new Error('pbcopy exited')); + const host = makeHost([assistantEntry('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..510d60ba59 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(); }); @@ -31,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"'); }); @@ -41,10 +65,11 @@ 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 () => { + 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.toBe('osc52'); + + 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..38b9174559 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 | 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 7692e06c9a..64449d6572 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` | 否 | ## 模式与运行控制