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
136 changes: 136 additions & 0 deletions packages/cli/src/__tests__/pi-tui-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -8953,6 +9069,26 @@ class StreamingPastViewportDriver extends ToolOutputDriver {
}
}

class CopyReplyDriver extends ToolOutputDriver {
override async *promptEvents(_prompt: string): AsyncIterable<SessionEvent> {
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,
Expand Down
90 changes: 90 additions & 0 deletions packages/cli/src/__tests__/tui-clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -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, []);
});
});
1 change: 1 addition & 0 deletions packages/cli/src/__tests__/tui-copy-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)',
Expand Down
Loading