diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 25310bba0c..0c6caf7839 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -8184,6 +8184,122 @@ describe('Maka Pi TUI runner', () => { assert.equal(exits[0]?.code, 1); assert.match(exits[0]?.error?.message ?? '', /Could not resume session remote-session/); }); + + test('/copy writes the last reply to the clipboard via OSC 52', async () => { + const terminal = new FakeTerminal(); + const driver = new CopyReplyDriver(); + const run = runMakaPiTui({ + title: 'Maka', + locale: 'en', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + await waitForTuiPaint(terminal); + terminal.input('hi'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('COPY-ME-REPLY')); + // /copy refuses mid-turn, so wait for the turn to settle before copying. + await waitFor(() => terminal.progressStates.at(-1) === false); + + terminal.input('/copy'); + terminal.input('\r'); + + // The base64 payload is the stable part of the OSC 52 sequence to assert on. + const payload = Buffer.from('COPY-ME-REPLY', 'utf8').toString('base64'); + await waitFor(() => terminal.output().includes(`;c;${payload}`)); + await waitFor(() => + plainTerminalOutput(terminal.output()).includes('Sent the last reply to the terminal'), + ); + + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + + test('/copy all writes the whole conversation with role labels via OSC 52', async () => { + const terminal = new FakeTerminal(); + const driver = new CopyReplyDriver(); + const run = runMakaPiTui({ + title: 'Maka', + locale: 'en', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + }); + + await waitForTuiPaint(terminal); + terminal.input('hi'); + terminal.input('\r'); + await waitFor(() => plainTerminalOutput(terminal.output()).includes('COPY-ME-REPLY')); + await waitFor(() => terminal.progressStates.at(-1) === false); + + terminal.input('/copy all'); + terminal.input('\r'); + + // Exercises copiedAll + the roleUser/roleAssistant labels end to end: the + // serialized transcript is the user turn and the reply under their labels. + const payload = Buffer.from('You:\nhi\n\nMaka:\nCOPY-ME-REPLY', 'utf8').toString('base64'); + await waitFor(() => terminal.output().includes(`;c;${payload}`)); + await waitFor(() => + plainTerminalOutput(terminal.output()).includes('Sent the conversation to the terminal'), + ); + + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + + test('/copy is refused mid-turn instead of copying the half-written reply', async () => { + const terminal = new FakeTerminal(); + const driver = new SteeringTurnDriver(); + const run = runMakaPiTui({ + title: 'Maka', + locale: 'en', + driver, + cwd: '/repo', + model: 'm', + connectionSlug: 'c', + permissionMode: 'bypass', + terminal, + }); + + terminal.input('start the work'); + terminal.input('\r'); + await waitFor(() => terminal.progressStates.at(-1) === true); + + terminal.input('/copy'); + terminal.input('\r'); + await waitFor(() => + plainTerminalOutput(terminal.output()).includes('Cannot run /copy while a turn is running'), + ); + // It was refused, not steered into the running turn, and wrote no clipboard. + assert.deepEqual(driver.steered, []); + assert.equal(terminal.output().includes('\x1b]52;'), false); + + terminal.input('\x1b'); + terminal.input('\x1b'); + await waitFor(() => terminal.progressStates.at(-1) === false); + terminal.input('\x03'); + terminal.input('/exit'); + terminal.input('\r'); + await run; + }); }); function editorInputText(terminal: FakeTerminal): string | undefined { @@ -8953,6 +9069,26 @@ class StreamingPastViewportDriver extends ToolOutputDriver { } } +class CopyReplyDriver extends ToolOutputDriver { + override async *promptEvents(_prompt: string): AsyncIterable { + yield { + type: 'text_delta', + id: 'event-text-1', + turnId: 'turn-1', + ts: 1, + messageId: 'message-1', + text: 'COPY-ME-REPLY', + }; + yield { + type: 'complete', + id: 'event-complete', + turnId: 'turn-1', + ts: 2, + stopReason: 'end_turn', + }; + } +} + function pipeOutput(stdout = '', stderr = '') { return { mode: 'pipes' as const, diff --git a/packages/cli/src/__tests__/tui-clipboard.test.ts b/packages/cli/src/__tests__/tui-clipboard.test.ts new file mode 100644 index 0000000000..0219215aa1 --- /dev/null +++ b/packages/cli/src/__tests__/tui-clipboard.test.ts @@ -0,0 +1,90 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { + copyToClipboard, + MAX_CLIPBOARD_TEXT_BYTES, + osc52ClipboardSequence, +} from '../tui-clipboard.js'; + +describe('osc52ClipboardSequence', () => { + test('wraps base64-encoded UTF-8 in the OSC 52 clipboard sequence', () => { + const base64 = Buffer.from('héllo', 'utf8').toString('base64'); + assert.equal(osc52ClipboardSequence('héllo'), `\x1b]52;c;${base64}\x07`); + }); + + test('encodes an empty string as an empty payload', () => { + assert.equal(osc52ClipboardSequence(''), '\x1b]52;c;\x07'); + }); + + test('emits a bare sequence with no tmux DCS passthrough wrapper', () => { + // The bare sequence is the correct primitive; tmux forwards it only with + // `set-clipboard on` (default `external` drops it) and an `Ms` terminfo cap. + // DCS passthrough is avoided: it needs `allow-passthrough on`, off by default. + const sequence = osc52ClipboardSequence('hi'); + assert.equal(sequence.startsWith('\x1b]52;'), true); + assert.equal(sequence.includes('\x1bPtmux;'), false); + }); +}); + +describe('copyToClipboard', () => { + test('writes the OSC 52 sequence to the terminal and reports the byte count', () => { + const writes: string[] = []; + const result = copyToClipboard({ write: (d) => writes.push(d) }, 'hi'); + assert.deepEqual(writes, [osc52ClipboardSequence('hi')]); + assert.deepEqual(result, { ok: true, bytes: 2 }); + }); + + test('accepts a payload exactly at the byte limit', () => { + const writes: string[] = []; + const text = 'a'.repeat(MAX_CLIPBOARD_TEXT_BYTES); + const result = copyToClipboard({ write: (d) => writes.push(d) }, text); + assert.equal(result.ok, true); + assert.equal(writes.length, 1); + }); + + test('refuses an oversized payload and writes nothing', () => { + // Past a terminal's OSC-string buffer the sequence is silently truncated, + // not echoed, so an oversized copy must fail readably rather than emit. + const writes: string[] = []; + const text = 'a'.repeat(MAX_CLIPBOARD_TEXT_BYTES + 1); + const result = copyToClipboard({ write: (d) => writes.push(d) }, text); + assert.deepEqual(result, { + ok: false, + reason: 'too_large', + bytes: MAX_CLIPBOARD_TEXT_BYTES + 1, + limit: MAX_CLIPBOARD_TEXT_BYTES, + }); + assert.deepEqual(writes, []); + }); + + test('measures the limit in UTF-8 bytes, not JS string length', () => { + // 2000 '€' is 2000 JS chars (under the limit) but 6000 UTF-8 bytes (over it), + // so a length-based check would wrongly accept it. + const writes: string[] = []; + const text = '€'.repeat(2000); + assert.ok(text.length <= MAX_CLIPBOARD_TEXT_BYTES); + assert.ok(Buffer.byteLength(text, 'utf8') > MAX_CLIPBOARD_TEXT_BYTES); + const result = copyToClipboard({ write: (d) => writes.push(d) }, text); + assert.equal(result.ok, false); + assert.deepEqual(writes, []); + }); +}); diff --git a/packages/cli/src/__tests__/tui-copy-catalog.test.ts b/packages/cli/src/__tests__/tui-copy-catalog.test.ts index 0a1ef6b4ea..d5ce560710 100644 --- a/packages/cli/src/__tests__/tui-copy-catalog.test.ts +++ b/packages/cli/src/__tests__/tui-copy-catalog.test.ts @@ -27,6 +27,7 @@ const MESSAGE_VALUES = { count: 2, detail: 'HTTP 401', hasDetail: true, + bytes: 40_000, serverId: 'filesystem', names: 'Alpha', failures: '/skill:nope (not found)', diff --git a/packages/cli/src/__tests__/tui-copy-command.test.ts b/packages/cli/src/__tests__/tui-copy-command.test.ts new file mode 100644 index 0000000000..fa9dc3cd52 --- /dev/null +++ b/packages/cli/src/__tests__/tui-copy-command.test.ts @@ -0,0 +1,163 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import type { MakaPiTranscriptEntry, MakaPiTranscriptState } from '../pi-transcript.js'; +import { getTuiCopyCopy, lastAssistantText, serializeTranscriptText } from '../tui-copy-command.js'; + +function stateWith(entries: MakaPiTranscriptEntry[]): MakaPiTranscriptState { + return { entries } as MakaPiTranscriptState; +} + +const LABELS = { + user: 'You:', + assistant: 'Maka:', + goalContinuation: 'Goal:', + legacyAutomation: 'Automation:', +}; + +describe('lastAssistantText', () => { + test('returns the text of the most recent assistant entry', () => { + const state = stateWith([ + { kind: 'assistant', messageId: 'a1', text: 'first' }, + { kind: 'user', messageId: 'u1', text: 'question' }, + { kind: 'assistant', messageId: 'a2', text: 'second' }, + ]); + assert.equal(lastAssistantText(state), 'second'); + }); + + test('ignores trailing non-assistant entries', () => { + const state = stateWith([ + { kind: 'assistant', messageId: 'a1', text: 'reply' }, + { kind: 'notice', level: 'info', text: 'a notice' }, + ]); + assert.equal(lastAssistantText(state), 'reply'); + }); + + test('returns undefined when there is no assistant entry', () => { + assert.equal(lastAssistantText(stateWith([])), undefined); + assert.equal( + lastAssistantText(stateWith([{ kind: 'user', messageId: 'u1', text: 'hi' }])), + undefined, + ); + }); + + test('skips an empty trailing assistant entry and returns the earlier reply', () => { + // A tool-only or aborted turn (and durable recovery) can leave a text-less + // assistant entry at the tail; it must not mask the real reply before it. + const state = stateWith([ + { kind: 'assistant', messageId: 'a1', text: 'the real reply' }, + { kind: 'assistant', messageId: 'a2', text: ' ' }, + ]); + assert.equal(lastAssistantText(state), 'the real reply'); + }); +}); + +describe('serializeTranscriptText', () => { + test('serializes user and assistant turns in order with role labels', () => { + const state = stateWith([ + { kind: 'user', messageId: 'u1', text: 'hello' }, + { kind: 'thinking', messageId: 't1', text: 'hmm', expanded: false }, + { kind: 'assistant', messageId: 'a1', text: 'hi there' }, + { kind: 'notice', level: 'info', text: 'ignored' }, + { kind: 'user', messageId: 'u2', text: 'bye' }, + ]); + assert.equal( + serializeTranscriptText(state, LABELS), + 'You:\nhello\n\nMaka:\nhi there\n\nYou:\nbye', + ); + }); + + test('collapses consecutive assistant steps under one label and drops empty ones', () => { + const state = stateWith([ + { kind: 'user', messageId: 'u1', text: 'do it' }, + { kind: 'assistant', messageId: 'a1', text: "I'll inspect it." }, + { kind: 'tool', toolUseId: 't1', toolName: 'Bash', input: {}, resultVersion: 0 } as never, + { kind: 'assistant', messageId: 'a2', text: '' }, + { kind: 'assistant', messageId: 'a3', text: 'Final answer.' }, + ]); + assert.equal( + serializeTranscriptText(state, LABELS), + "You:\ndo it\n\nMaka:\nI'll inspect it.\n\nFinal answer.", + ); + }); + + test('keeps two adjacent user turns in separate blocks', () => { + // Queued steering (Alt+Enter) appends a user entry before any assistant + // text, so two user entries can sit adjacent. They are distinct messages, + // not one message with two paragraphs, and must not merge. + const state = stateWith([ + { kind: 'user', messageId: 'u1', text: 'do the thing' }, + { kind: 'user', messageId: 'u2', text: 'actually, stop' }, + { kind: 'assistant', messageId: 'a1', text: 'ok' }, + ]); + assert.equal( + serializeTranscriptText(state, LABELS), + 'You:\ndo the thing\n\nYou:\nactually, stop\n\nMaka:\nok', + ); + }); + + test('a text-less assistant entry between two user turns does not join them', () => { + // The skip of an empty assistant entry must not fuse the surrounding user + // blocks: a tool-only / aborted / recovery turn leaves exactly this shape. + const state = stateWith([ + { kind: 'user', messageId: 'u1', text: 'first question' }, + { kind: 'assistant', messageId: 'a1', text: '' }, + { kind: 'user', messageId: 'u2', text: 'second question' }, + ]); + assert.equal( + serializeTranscriptText(state, LABELS), + 'You:\nfirst question\n\nYou:\nsecond question', + ); + }); + + test('labels goal_continuation and legacy_automation by provenance, not as the user', () => { + // Both are non-user-triggered driving turns (TurnOrigin); they get their own + // labels — never `You:` — and, being their own blocks, keep the assistant + // turns on either side from merging into one answer. + const state = stateWith([ + { kind: 'user', messageId: 'u1', text: 'start the goal' }, + { kind: 'assistant', messageId: 'a1', text: 'Step one done.' }, + { kind: 'goal_continuation', text: 'keep going' }, + { kind: 'assistant', messageId: 'a2', text: 'Step two done.' }, + { kind: 'legacy_automation', text: 'automated nudge' }, + { kind: 'assistant', messageId: 'a3', text: 'Step three done.' }, + ]); + assert.equal( + serializeTranscriptText(state, LABELS), + 'You:\nstart the goal\n\nMaka:\nStep one done.\n\nGoal:\nkeep going\n\n' + + 'Maka:\nStep two done.\n\nAutomation:\nautomated nudge\n\nMaka:\nStep three done.', + ); + }); + + test('returns an empty string when there are no conversation turns', () => { + assert.equal(serializeTranscriptText(stateWith([]), LABELS), ''); + }); +}); + +describe('getTuiCopyCopy', () => { + test('resolves localized copy for each locale', () => { + assert.equal(typeof getTuiCopyCopy('en').nothingToCopy, 'string'); + assert.equal(typeof getTuiCopyCopy('zh').nothingToCopy, 'string'); + assert.ok(getTuiCopyCopy('en').copiedLast.includes('{count')); + const tooLarge = getTuiCopyCopy('en').tooLarge; + assert.ok(tooLarge.includes('{bytes}') && tooLarge.includes('{limit}')); + }); +}); diff --git a/packages/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index cc6172fa4e..9bd7ec8ba3 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -127,6 +127,8 @@ import { runMakaPiTuiTurn, type MakaPiTuiTurnRequest } from './pi-tui-turn.js'; import { editorTheme, selectListTheme } from './tui-ansi.js'; import { MakaAutocompleteAboveEditorComponent } from './tui-autocomplete-layout.js'; import { TranscriptViewerOverlay } from './pi-tui-transcript-viewer.js'; +import { copyToClipboard } from './tui-clipboard.js'; +import { getTuiCopyCopy, lastAssistantText, serializeTranscriptText } from './tui-copy-command.js'; import { McpManagementOverlay } from './pi-tui-mcp-status.js'; import type { TuiMcpManagement } from './tui-mcp-control.js'; import { createShellRunElapsedTicker } from './shell-run-elapsed-ticker.js'; @@ -409,6 +411,7 @@ function sessionConnectionIdentityNotice( export async function runMakaPiTui(input: MakaPiTuiInput): Promise { const locale = input.locale ?? 'en'; const pickerCopy = getTuiPickerCopy(locale); + const copyCopy = getTuiCopyCopy(locale); const primaryGuidance = getTuiPrimaryGuidance(locale); const terminal = input.terminal ?? new ProcessTerminal(); const taskbarProgress = resolveTaskbarProgress(input.taskbarProgress); @@ -3289,6 +3292,67 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { void runControl(compactSession); }, }, + copy: { + description: primaryGuidance.commands.copy, + // Refused mid-turn: copy grabs the finished reply, and while a turn streams + // the last assistant entry is the half-written one — copying that would + // silently hand back a partial message. Wait for the turn (or Esc) first. + midTurn: 'refuse', + run: (parts: string[]) => { + const scope = parts.length >= 2 ? parts[1] : undefined; + if (parts.length > 2 || (scope !== undefined && scope !== 'all')) { + state.entries.push({ + kind: 'notice', + level: 'error', + text: 'Usage: /copy [all]', + }); + requestRender(); + return; + } + const copyAll = scope === 'all'; + const text = copyAll + ? serializeTranscriptText(state, { + user: copyCopy.roleUser, + assistant: copyCopy.roleAssistant, + goalContinuation: copyCopy.roleGoalContinuation, + legacyAutomation: copyCopy.roleLegacyAutomation, + }) + : lastAssistantText(state); + if (!text) { + state.entries.push({ + kind: 'notice', + level: 'info', + text: copyCopy.nothingToCopy, + }); + requestRender(); + return; + } + const result = copyToClipboard(terminal, text); + if (!result.ok) { + state.entries.push({ + kind: 'notice', + level: 'error', + text: formatUiMessage( + copyCopy.tooLarge, + { bytes: result.bytes, limit: result.limit }, + locale, + ), + }); + requestRender(); + return; + } + state.entries.push({ + kind: 'notice', + level: 'info', + text: formatUiMessage( + copyAll ? copyCopy.copiedAll : copyCopy.copiedLast, + { count: text.length }, + locale, + ), + }); + requestRender(); + }, + }, exit: { description: primaryGuidance.commands.exit, // isExitPrompt (which also matches bare "quit"/"exit" without a slash) diff --git a/packages/cli/src/tui-clipboard.ts b/packages/cli/src/tui-clipboard.ts new file mode 100644 index 0000000000..aa49dd3046 --- /dev/null +++ b/packages/cli/src/tui-clipboard.ts @@ -0,0 +1,106 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * OSC 52 clipboard writes for the terminal TUI. + * + * OSC 52 asks the *terminal emulator* to set the system clipboard, so it works + * across SSH (the sequence rides the same stream as the rendered UI) and needs + * no `pbcopy`/`xclip`/`wl-copy` binary. The trade-off is that the write is + * fire-and-forget: the protocol has no acknowledgement, so we cannot tell + * whether the terminal honoured it, ignored it (e.g. macOS Terminal.app), or + * silently dropped it. + * + * Payload size is the sharp edge. Past a terminal's OSC-string buffer the + * sequence is not parsed to completion, so the clipboard is never set to the + * intended text: kitty logs `OSC sequence too long, truncating` and drops the + * write, st (pre-0.8.3) returns once its buffer fills. Nothing is echoed to the + * screen, so the failure is silent. Those buffers vary widely (kitty ~8 KB, + * Tabby ~1 KB, xterm ~1 MB) and are undetectable from here. We do two things + * about it: refuse above {@link MAX_CLIPBOARD_TEXT_BYTES}, sized so the emitted + * sequence clears the ~8 KB buffer of mainstream terminals like kitty and an + * oversized `/copy all` fails on a readable error rather than a silent drop; + * and — because a terminal with a smaller buffer (e.g. Tabby) can still drop a + * below-limit write — word the confirmation as best-effort rather than a promise + * of success (see `tui-copy-catalog.ts`). + * + * Under tmux the bare sequence is still the right primitive to emit, but note + * it does not land on a default tmux: `set-clipboard` has defaulted to + * `external` since tmux 2.6, which *ignores* an application's attempt to set a + * tmux buffer, so forwarding needs `set-clipboard on`. Forwarding also requires + * an `Ms` capability in the outer terminfo, which nested tmux and the inner side + * of GNU screen lack (screen itself only recognises OSC 52 wrapped in DCS). + * Wrapping in tmux's DCS passthrough is deliberately avoided anyway: it needs + * `allow-passthrough on` (off by default in modern tmux) and bypasses tmux's own + * clipboard handling, so it would drop the copy on a default configuration too. + */ + +/** + * Upper bound on the source text of a single OSC 52 write, in UTF-8 bytes. Its + * base64 encoding (~4/3) plus the 8-byte `ESC ] 52 ; c ; … BEL` framing has to + * fit a terminal's OSC-string buffer: at 4 KiB the sequence is ~5.5 KB, under + * the ~8 KB buffer terminals like kitty use (kitty drops a ~6.1 KB reply, whose + * sequence just tops 8 KB), so an emitted copy lands there. Larger content is + * refused rather than emitted and silently truncated; the residual variance + * below this (e.g. Tabby ~1 KB) is what keeps the confirmation best-effort. + */ +export const MAX_CLIPBOARD_TEXT_BYTES = 4096; + +/** OSC 52 targets the system clipboard selection (`c`). */ +const CLIPBOARD_SELECTION = 'c'; + +/** + * Build the OSC 52 escape sequence that sets the system clipboard to `text`: + * `ESC ] 52 ; c ; BEL`. The BEL (`\x07`) terminator is accepted more + * widely than the ST (`ESC \`) form. + */ +export function osc52ClipboardSequence(text: string): string { + const base64 = Buffer.from(text, 'utf8').toString('base64'); + return `\x1b]52;${CLIPBOARD_SELECTION};${base64}\x07`; +} + +/** The subset of the pi-tui Terminal this module writes to. */ +export interface ClipboardTerminal { + write(data: string): void; +} + +/** + * Outcome of a clipboard write. `ok: false` means nothing was emitted because + * the payload exceeded {@link MAX_CLIPBOARD_TEXT_BYTES}; `bytes`/`limit` let the + * caller word a readable refusal. `ok: true` is best-effort: see the file + * header for why there is no delivery confirmation. + */ +export type ClipboardWriteResult = + | { ok: true; bytes: number } + | { ok: false; reason: 'too_large'; bytes: number; limit: number }; + +/** + * Copy `text` to the system clipboard via OSC 52, refusing an oversized payload + * rather than emitting a sequence a terminal would silently truncate. On + * success the write is fire-and-forget — see the file header for why there is + * no delivery result. + */ +export function copyToClipboard(terminal: ClipboardTerminal, text: string): ClipboardWriteResult { + const bytes = Buffer.byteLength(text, 'utf8'); + if (bytes > MAX_CLIPBOARD_TEXT_BYTES) { + return { ok: false, reason: 'too_large', bytes, limit: MAX_CLIPBOARD_TEXT_BYTES }; + } + terminal.write(osc52ClipboardSequence(text)); + return { ok: true, bytes }; +} diff --git a/packages/cli/src/tui-copy-catalog.ts b/packages/cli/src/tui-copy-catalog.ts index 28bd48ba35..5c205dc530 100644 --- a/packages/cli/src/tui-copy-catalog.ts +++ b/packages/cli/src/tui-copy-catalog.ts @@ -18,6 +18,34 @@ */ export const TUI_COPY_RESOURCES = { + copy: { + en: { + copiedLast: + 'Sent the last reply to the terminal via OSC 52 · {count, plural, one {# char} other {# chars}} — it reaches the clipboard only if your terminal (and tmux, if used) allows OSC 52 writes.', + copiedAll: + 'Sent the conversation to the terminal via OSC 52 · {count, plural, one {# char} other {# chars}} — it reaches the clipboard only if your terminal (and tmux, if used) allows OSC 52 writes.', + tooLarge: + 'Too large to copy over OSC 52 · {bytes} bytes exceeds the {limit}-byte limit; a larger payload is silently dropped by the terminal.', + nothingToCopy: 'Nothing to copy yet.', + roleUser: 'You:', + roleAssistant: 'Maka:', + roleGoalContinuation: 'Goal continuation (autonomous):', + roleLegacyAutomation: 'Legacy automation (history only):', + }, + zh: { + copiedLast: + '已将最后一条回复发送到终端 · {count} 个字符——仅当终端(以及 tmux,如使用)允许写入剪贴板时才会真正复制。', + copiedAll: + '已将整段对话发送到终端 · {count} 个字符——仅当终端(以及 tmux,如使用)允许写入剪贴板时才会真正复制。', + tooLarge: + '内容过大,无法复制 · {bytes} 字节超过 {limit} 字节上限;更大的内容会被终端静默丢弃。', + nothingToCopy: '暂无可复制的内容。', + roleUser: '你:', + roleAssistant: 'Maka:', + roleGoalContinuation: '目标续跑(自主):', + roleLegacyAutomation: '旧版自动化(仅历史):', + }, + }, 'mcp-status': { en: { title: 'MCP SERVERS', @@ -405,6 +433,7 @@ export const TUI_COPY_RESOURCES = { commands: { compact: 'Compact session context', context: 'Show latest request context usage', + copy: 'Copy the last reply (or /copy all) to the clipboard', exit: 'Exit Maka', goal: 'Show autonomous goal status', graph: 'Show, enable, disable, or run one Graph turn', @@ -455,6 +484,7 @@ export const TUI_COPY_RESOURCES = { commands: { compact: '压缩会话上下文', context: '查看最近一次请求的上下文用量', + copy: '复制最后一条回复(或 /copy all)到剪贴板', exit: '退出 Maka', goal: '查看自主目标状态', graph: '查看、启用、停用 Graph 模式,或执行一次 Graph 任务', diff --git a/packages/cli/src/tui-copy-command.ts b/packages/cli/src/tui-copy-command.ts new file mode 100644 index 0000000000..120a2b441d --- /dev/null +++ b/packages/cli/src/tui-copy-command.ts @@ -0,0 +1,139 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Text extraction and localized copy for the `/copy` slash command. + * + * The transcript's user/assistant entries already hold plain markdown text (no + * ANSI), so copy pulls straight from `state.entries` rather than stripping the + * rendered projection. See `MakaPiTranscriptEntry` in `pi-transcript.ts`. + */ + +import { + defineUiMessageCatalog, + resolveUiMessageCatalog, + type UiLocale, +} from '@maka/core/ui-locale'; +import { TUI_COPY_RESOURCES } from './tui-copy-catalog.js'; +import type { MakaPiTranscriptState } from './pi-transcript.js'; + +export interface TuiCopyCopy { + /** ICU: confirmation for `/copy` (last assistant reply). Takes `{count}`. */ + readonly copiedLast: string; + /** ICU: confirmation for `/copy all` (whole conversation). Takes `{count}`. */ + readonly copiedAll: string; + /** ICU: refusal when the payload exceeds the clipboard cap. Takes `{bytes}`, `{limit}`. */ + readonly tooLarge: string; + /** Shown when there is nothing to copy yet. */ + readonly nothingToCopy: string; + /** Role label for user turns in `/copy all`. */ + readonly roleUser: string; + /** Role label for assistant turns in `/copy all`. */ + readonly roleAssistant: string; + /** Provenance label for autonomous goal-continuation turns in `/copy all`. */ + readonly roleGoalContinuation: string; + /** Provenance label for legacy-automation turns in `/copy all`. */ + readonly roleLegacyAutomation: string; +} + +const TUI_COPY_COMMAND_COPY = resolveUiMessageCatalog( + defineUiMessageCatalog()(TUI_COPY_RESOURCES.copy), +); + +export function getTuiCopyCopy(locale: UiLocale): TuiCopyCopy { + return TUI_COPY_COMMAND_COPY[locale]; +} + +/** + * The last assistant reply's plain text, or undefined if there is none yet. + * + * Empty assistant entries are skipped: a tool-only or aborted turn — and + * durable recovery, which materializes an assistant entry per stored message + * regardless of text — can leave a text-less entry at the tail that would + * otherwise mask an earlier real reply. + */ +export function lastAssistantText(state: MakaPiTranscriptState): string | undefined { + for (let i = state.entries.length - 1; i >= 0; i -= 1) { + const entry = state.entries[i]; + if (entry?.kind === 'assistant' && entry.text.trim() !== '') return entry.text; + } + return undefined; +} + +/** + * Serialize the whole conversation to plain text with role labels, in order. + * Thinking, tool calls, and notices are omitted so the copy reads as the + * conversation, not the machinery around it. + * + * `goal_continuation` and `legacy_automation` are non-user-triggered driving + * turns (see `TurnOrigin` in `turn-origin.ts`) that the TUI renders with their + * own provenance headers, so they carry distinct labels here rather than the + * user label: relabeling them `You:` would misattribute autonomous prompts to + * the human, and dropping them would both erase the prompts that drove a run and + * merge the assistant turns on either side into one answer. + * + * Only *consecutive assistant* entries collapse under one label, so an assistant + * turn whose text is split across several internal steps (e.g. text before and + * after a tool call) reads as one `Maka:` block. Every non-assistant turn opens + * its own block — two queued user messages, or two turns separated only by a + * skipped text-less assistant entry, must not merge. Empty assistant steps are + * dropped, and because a new block only ever opens for a non-assistant turn or a + * fresh assistant run, that skip never joins the blocks around it. + */ +export function serializeTranscriptText( + state: MakaPiTranscriptState, + labels: { + user: string; + assistant: string; + goalContinuation: string; + legacyAutomation: string; + }, +): string { + const blocks: { role: string; label: string; text: string }[] = []; + for (const entry of state.entries) { + let role: string; + let label: string; + switch (entry.kind) { + case 'user': + role = 'user'; + label = labels.user; + break; + case 'goal_continuation': + role = 'goal_continuation'; + label = labels.goalContinuation; + break; + case 'legacy_automation': + role = 'legacy_automation'; + label = labels.legacyAutomation; + break; + case 'assistant': + if (entry.text.trim() === '') continue; + role = 'assistant'; + label = labels.assistant; + break; + default: + continue; + } + const last = blocks[blocks.length - 1]; + // Collapse only a running assistant turn's steps; every other turn is its own. + if (role === 'assistant' && last?.role === 'assistant') last.text += `\n\n${entry.text}`; + else blocks.push({ role, label, text: entry.text }); + } + return blocks.map((block) => `${block.label}\n${block.text}`).join('\n\n'); +} diff --git a/packages/core/src/slash-command-catalog.ts b/packages/core/src/slash-command-catalog.ts index 8bbdd428f6..b244596573 100644 --- a/packages/core/src/slash-command-catalog.ts +++ b/packages/core/src/slash-command-catalog.ts @@ -30,6 +30,7 @@ export interface SlashCommandSpec { export const SLASH_COMMAND_CATALOG = [ { id: 'compact', session: 'required', surfaces: ['desktop', 'tui'] }, { id: 'context', session: 'required', surfaces: ['tui'] }, + { id: 'copy', session: 'required', surfaces: ['tui'] }, { id: 'exit', aliases: ['quit'], session: 'none', surfaces: ['tui'] }, { id: 'goal', session: 'required', surfaces: ['tui'] }, { id: 'graph', session: 'none', surfaces: ['desktop', 'tui'] },