Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/add-copy-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": minor
---

Add the /copy slash command to copy the last assistant message to the clipboard.
41 changes: 41 additions & 0 deletions apps/kimi-code/src/tui/commands/copy.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)}`);
}
}
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 @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-code/src/tui/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions apps/kimi-code/src/tui/commands/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand Down
1 change: 1 addition & 0 deletions apps/kimi-code/src/tui/controllers/streaming-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
7 changes: 7 additions & 0 deletions apps/kimi-code/src/tui/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
40 changes: 40 additions & 0 deletions apps/kimi-code/src/utils/clipboard/clipboard-osc52.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
25 changes: 22 additions & 3 deletions apps/kimi-code/src/utils/clipboard/clipboard-text.ts
Original file line number Diff line number Diff line change
@@ -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 });
Expand Down Expand Up @@ -40,16 +41,34 @@ async function copyWithPlatformCommand(text: string): Promise<void> {
throw new Error('No clipboard command is available.');
}

export async function copyTextToClipboard(text: string): Promise<void> {
/** 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<ClipboardCopyMethod> {
// 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;
}
}
142 changes: 142 additions & 0 deletions apps/kimi-code/test/tui/commands/copy.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>;
showError: ReturnType<typeof vi.fn>;
};
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');
});
});
Loading
Loading