Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
import { describe, it, expect, vi } from 'vitest';
import { PassThrough } from 'node:stream';
import {
parseSlash,
runRepl,
shortPath,
helpText,
banner,
examplesText,
completeSlash,
SLASH_COMMANDS,
} from '../src/commands/repl.js';
import { demoTourText, scaffoldDemoRepo, defaultBriefPath } from '../src/commands/demo.js';
import { pickModel, selectMenu } from '../src/util/select.js';
import {
keySequence,
inputRowRows,
KeyReader,
promptWithSlashHints,
pushKeys,
suggestionLines,
} from '../src/util/live-prompt.js';
import { fiducialBootFrames, prefersAnimation } from '../src/agent/animate.js';
import { plainRenderer } from '../src/agent/render.js';
import { setColorEnabled } from '../src/agent/theme.js';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { existsSync } from 'node:fs';
function fakeTty(): { input: PassThrough; output: PassThrough; lines: string[] } {
const input = new PassThrough();
const output = new PassThrough();
(input as unknown as { isTTY: boolean }).isTTY = true;
(output as unknown as { isTTY: boolean }).isTTY = true;
const lines: string[] = [];
return { input, output, lines };
}
function baseOpts(extra: Partial<Parameters<typeof runRepl>[0]> = {}) {
const { input, output, lines } = fakeTty();
return {
input,
output,
lines,
opts: {
repoRoot: '/tmp/repo',
model: 'gpt-5',
modelSource: 'flag' as const,
version: '0.7.0',
kicadCliVersion: '9.0.0',
renderer: plainRenderer((l) => lines.push(l)),
log: (l: string) => lines.push(l),
input,
output,
runRequest: vi.fn(async () => ({ outcome: 'success' as const })),
runCheckCmd: vi.fn(async () => {
lines.push('check ok');
}),
...extra,
},
};
}
describe('parseSlash', () => {
it('detects slash commands and leaves normal requests alone', () => {
expect(parseSlash('add an LED')).toBeNull();
expect(parseSlash('/')).toEqual({ cmd: '/', args: '' });
expect(parseSlash('/quit')).toEqual({ cmd: '/quit', args: '' });
expect(parseSlash('/help me')).toEqual({ cmd: '/help', args: 'me' });
});
it('maps bare quit/clear/help so they never start an agent run', () => {
expect(parseSlash('quit')).toEqual({ cmd: '/quit', args: '' });
expect(parseSlash('clear')).toEqual({ cmd: '/clear', args: '' });
expect(parseSlash('help')).toEqual({ cmd: '/help', args: '' });
expect(parseSlash('EXIT')).toEqual({ cmd: '/quit', args: '' });
});
it('tab-completes slash commands', () => {
expect(completeSlash('/d')).toContain('/demo');
expect(completeSlash('/d')).toContain('/drift');
expect(completeSlash('/st')).toEqual(['/status']);
expect(completeSlash('/sy')).toEqual(['/sync']);
expect(completeSlash('/con')).toEqual(expect.arrayContaining(['/config', '/constraints']));
expect(completeSlash('add')).toEqual([]);
});
it('lists the new inspect slash commands', () => {
const values = SLASH_COMMANDS.map((c) => c.value);
for (const cmd of [
'/sync',
'/drift',
'/constraints',
'/config',
'/git',
'/runs',
'/parts',
'/nets',
'/bom',
'/openspec',
'/last',
]) {
expect(values).toContain(cmd);
expect(helpText()).toContain(cmd);
}
});
});
describe('selectMenu', () => {
it('returns the item selected with Enter after ↓', async () => {
setColorEnabled(false);
const output = new PassThrough();
async function* keys() {
yield '\x1b[B'; // down
yield '\r';
}
const chosen = await selectMenu({
title: 'Commands',
items: [
{ value: '/demo', label: '/demo' },
{ value: '/quit', label: '/quit' },
],
output: output as unknown as NodeJS.WriteStream,
keys: keys(),
});
expect(chosen).toBe('/quit');
});
it('cancels on Esc', async () => {
setColorEnabled(false);
const output = new PassThrough();
async function* keys() {
yield '\x1b';
}
const chosen = await selectMenu({
title: 'Commands',
items: [{ value: '/demo', label: '/demo' }],
output: output as unknown as NodeJS.WriteStream,
keys: keys(),
});
expect(chosen).toBeNull();
});
it('leaves the input stream alive after a pick (regression: for-await destroyed stdin)', async () => {
setColorEnabled(false);
const input = new PassThrough();
const output = new PassThrough();
const picking = selectMenu({
title: 'M',
items: [{ value: 'a', label: 'a' }],
input: input as unknown as NodeJS.ReadStream,
output: output as unknown as NodeJS.WriteStream,
});
input.write('\r');
const chosen = await picking;
expect(chosen).toBe('a');
expect((input as unknown as { destroyed: boolean }).destroyed).toBe(false);
// The stream must still deliver data to a later consumer.
const next = new Promise<string>((resolve) => input.once('data', (c) => resolve(String(c))));
input.write('later');
input.resume();
expect(await next).toBe('later');
});
it('pickModel offers the provider choices and returns the selection', async () => {
setColorEnabled(false);
const output = new PassThrough();
let painted = '';
output.on('data', (c) => {
painted += String(c);
});
async function* keys() {
yield '\x1b[B'; // down: claude-code -> codex
yield '\r';
}
const chosen = await pickModel({
output: output as unknown as NodeJS.WriteStream,
keys: keys(),
});
expect(chosen).toBe('codex');
expect(painted).toContain('claude-code');
expect(painted).toContain('Cursor CLI');
});
});
describe('animation helpers', () => {
it('fiducial boot has growing frames and respects NO_ANIM', () => {
setColorEnabled(true);
const prev = process.env.COPPERHEAD_NO_ANIM;
process.env.COPPERHEAD_NO_ANIM = '1';
expect(prefersAnimation()).toBe(false);
if (prev === undefined) delete process.env.COPPERHEAD_NO_ANIM;
else process.env.COPPERHEAD_NO_ANIM = prev;
const frames = fiducialBootFrames();
expect(frames.length).toBeGreaterThanOrEqual(3);
expect(frames[frames.length - 1]!.join('\n')).toContain('▟');
setColorEnabled(false);
});
});
describe('repl chrome', () => {
it('shortens home paths and keeps banner / help readable', () => {
setColorEnabled(false);
process.env.COPPERHEAD_NO_ANIM = '1';
expect(shortPath(`${process.env.HOME}/OpenSource/circuits/copperhead`)).toMatch(/^~/);
const text = banner({
repoRoot: '/tmp/demo-board',
model: 'cursor',
modelSource: 'flag',
version: '0.7.0',
kicadCliVersion: '10.0.5',
renderer: plainRenderer(() => {}),
}).join('\n');
expect(text).toContain('copperhead');
expect(text).toContain('v0.7.0');
expect(text).toContain('███ ███'); // brand via block mark (favicon.svg)
expect(text).toContain('cursor');
expect(text).toContain('kicad-cli');
expect(helpText()).toContain('/check');
expect(helpText()).toContain('/demo');
expect(helpText()).toContain('/status');
expect(helpText()).toContain('live command');
expect(helpText()).toContain('<request>');
expect(demoTourText()).toContain('What copperhead does');
expect(examplesText()).toContain('add reverse-polarity');
});
});
describe('promptWithSlashHints', () => {
it('shows hovered command description above the list', () => {
setColorEnabled(false);
const lines = suggestionLines(
[
{ value: '/demo', label: '/demo', description: 'tour text' },
{ value: '/quit', label: '/quit', description: 'leave the shell' },
],
1,
);
const joined = lines.join('\n');
expect(joined).toContain('/quit');
expect(joined).toContain('leave the shell');
});
it('counts wrap rows for long pasted lines', () => {
expect(inputRowRows('> ', 'x'.repeat(20), 10)).toBeGreaterThan(1);
});
it('coalesces paste via drainPrintable', async () => {
setColorEnabled(false);
const output = new PassThrough();
(output as unknown as { columns: number }).columns = 40;
const queued = ['p', 'p', 'e', 'n', 'd'];
let i = 0;
const seq = ['A', ...queued, '\r'];
const line = await promptWithSlashHints({
prompt: '> ',
commands: SLASH_COMMANDS,
output: output as unknown as NodeJS.WriteStream,
readKey: async () => (i < seq.length ? seq[i++]! : null),
drainPrintable: () => {
let out = '';
while (queued.length) out += queued.shift()!;
i += out.length; // skip drained keys in readKey sequence
return out;
},
});
expect(line).toBe('Append');
});
it('shows matches as soon as / is typed and Enter picks the highlight', async () => {
setColorEnabled(false);
const output = new PassThrough();
let painted = '';
output.on('data', (c) => {
painted += String(c);
});
const line = await promptWithSlashHints({
prompt: '> ',
commands: SLASH_COMMANDS,
output: output as unknown as NodeJS.WriteStream,
readKey: keySequence(['/', 'd', '\r']),
});
expect(line).toBe('/demo');
expect(painted).toContain('/demo');
// Final committed line in scrollback must show the selected command.
expect(painted).toMatch(/> \/demo\n/);
});
it('↓ moves the highlight before Enter', async () => {
setColorEnabled(false);
const output = new PassThrough();
let painted = '';
output.on('data', (c) => {
painted += String(c);
});
const line = await promptWithSlashHints({
prompt: '> ',
commands: SLASH_COMMANDS,
output: output as unknown as NodeJS.WriteStream,
readKey: keySequence(['/', '\x1b[B', '\r']),
});
expect(line).toBe('/examples');
expect(painted).toContain('example change-request prompts');
});
it('assembles arrow keys that arrive split across chunks', () => {
const pending = { buf: '' };
const keys: string[] = [];
pushKeys(pending, '\x1b', (k) => keys.push(k));
expect(keys).toEqual([]); // ambiguous: could still become an arrow
pushKeys(pending, '[B', (k) => keys.push(k));
expect(keys).toEqual(['\x1b[B']);
});
it('delivers a lone Escape once the disambiguation timer fires', async () => {
// Regression: a bare Esc was held in the assembler forever, because it is
// also the first byte of every arrow key. The dismiss handler therefore
// only fired one keystroke late, if at all.
vi.useFakeTimers();
try {
const input = new PassThrough();
(input as unknown as { isTTY: boolean }).isTTY = true;
const reader = new KeyReader(input as unknown as NodeJS.ReadStream, 40);
const first = reader.next();
input.write('\x1b');
await vi.advanceTimersByTimeAsync(39);
// Still ambiguous, so still nothing delivered.
let settled = false;
void first.then(() => (settled = true));
await Promise.resolve();
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(2);
expect(await first).toBe('\x1b');
reader.close();
} finally {
vi.useRealTimers();
}
});
it('does not fire the Escape timer when the rest of an arrow arrives in time', async () => {
vi.useFakeTimers();
try {
const input = new PassThrough();
(input as unknown as { isTTY: boolean }).isTTY = true;
const reader = new KeyReader(input as unknown as NodeJS.ReadStream, 40);
const first = reader.next();
input.write('\x1b');
await vi.advanceTimersByTimeAsync(10);
input.write('[A');
await vi.advanceTimersByTimeAsync(100);
expect(await first).toBe('\x1b[A'); // one arrow, not Esc then '[A'
reader.close();
} finally {
vi.useRealTimers();
}
});
it('does not fire the Escape timer on a paste-end marker split at its ESC byte', async () => {
// Regression: the timer armed on any trailing lone `\x1b`, including the
// first byte of a split `\x1b[201~`. Over a slow link (large paste, ssh,
// tmux) the remaining five bytes could take longer than the 40ms window,
// so the timer delivered a phantom Escape and consumed the ESC the marker
// needed. Paste mode then never closed, and every later byte folded to a
// space: Enter could not submit and Ctrl+C could not interrupt, leaving no
// way out of the session from the keyboard.
vi.useFakeTimers();
try {
const input = new PassThrough();
(input as unknown as { isTTY: boolean }).isTTY = true;
const reader = new KeyReader(input as unknown as NodeJS.ReadStream, 40);
const keys: string[] = [];
let stop = false;
void (async () => {
while (!stop) {
const k = await reader.next();
if (k === null) break;
keys.push(k);
}
})();
input.write('\x1b[200~hello');
await vi.advanceTimersByTimeAsync(1);
input.write('\x1b'); // the marker's first byte, alone
await vi.advanceTimersByTimeAsync(50); // longer than the Esc window
input.write('[201~'); // the rest of it, late
input.write('\r');
input.write('\x03');
await vi.advanceTimersByTimeAsync(50);
stop = true;
// The paste closed: no phantom Escape, no marker text, and Enter and
// Ctrl+C are live again.
expect(keys.join('')).toBe('hello\r\x03');
reader.close();
} finally {
vi.useRealTimers();
}
});
it('treats a bracketed paste as literal text, newlines and all', () => {
// Regression: with no paste mode, a pasted newline hit the submit branch,
// so a two-line change request started an agent run on only its first line.
const pending = { buf: '' };
const keys: string[] = [];
pushKeys(pending, `\x1b[200~rename R1 to R10\nand update the BOM\x1b[201~`, (k) => keys.push(k));
expect(keys.join('')).toBe('rename R1 to R10 and update the BOM');
expect(keys).not.toContain('\n');
expect(keys).not.toContain('\r');
});
it('holds back a paste-end marker split across reads', () => {
const pending = { buf: '' };
const keys: string[] = [];
pushKeys(pending, '\x1b[200~abc', (k) => keys.push(k));
expect(keys.join('')).toBe('abc');
// The marker arrives two bytes at a time; none of it may leak as text.
pushKeys(pending, '\x1b[2', (k) => keys.push(k));
expect(keys.join('')).toBe('abc');
pushKeys(pending, '01~z', (k) => keys.push(k));
expect(keys.join('')).toBe('abcz');
});
it('resumes normal key handling after the paste closes', () => {
const pending = { buf: '' };
const keys: string[] = [];
pushKeys(pending, '\x1b[200~hi\x1b[201~\x1b[B\r', (k) => keys.push(k));
expect(keys).toEqual(['h', 'i', '\x1b[B', '\r']);
});
it('ignores a stray paste-end marker with no paste open', () => {
const pending = { buf: '' };
const keys: string[] = [];
pushKeys(pending, 'a\x1b[201~b', (k) => keys.push(k));
expect(keys).toEqual(['a', 'b']);
});
it('a pasted multi-line request is submitted whole, not at its first newline', async () => {
setColorEnabled(false);
const output = new PassThrough();
const chunk = `\x1b[200~rename R1 to R10\nand update the BOM\x1b[201~\r`;
const pending = { buf: '' };
const queue: string[] = [];
pushKeys(pending, chunk, (k) => queue.push(k));
let i = 0;
const line = await promptWithSlashHints({
prompt: '> ',
commands: SLASH_COMMANDS,
output: output as unknown as NodeJS.WriteStream,
readKey: async () => (i < queue.length ? queue[i++]! : null),
});
expect(line).toBe('rename R1 to R10 and update the BOM');
});
it('SS3 arrow sequences work (\\x1bOA)', async () => {
setColorEnabled(false);
const output = new PassThrough();
const line = await promptWithSlashHints({
prompt: '> ',
commands: SLASH_COMMANDS,
output: output as unknown as NodeJS.WriteStream,
readKey: keySequence(['/', '\x1bOB', '\x1bOB', '\r']),
});
// /demo → /examples → /status
expect(line).toBe('/status');
});
});
describe('session log file', () => {
it('mirrors session lines to .copperhead/runs/repl-*.log with keys redacted', async () => {
setColorEnabled(false);
const repo = await mkdtemp(path.join(tmpdir(), 'copperhead-repl-log-'));
try {
const { input, output } = fakeTty();
const done = runRepl({
repoRoot: repo,
model: 'gpt-5',
modelSource: 'flag',
version: '0.7.0',
kicadCliVersion: '9.0.0',
renderer: plainRenderer(() => {}),
input,
output,
runRequest: vi.fn(async (_req: string, log?: (l: string) => void) => {
log?.('leaked sk-SECRET_KEY_123 in output');
return { outcome: 'success' as const };
}),
});
await new Promise((r) => setTimeout(r, 30));
input.write('do the thing\n');
await new Promise((r) => setTimeout(r, 30));
input.write('/quit\n');
await done;
const runsDir = path.join(repo, '.copperhead', 'runs');
const { readdirSync, readFileSync } = await import('node:fs');
const logName = readdirSync(runsDir).find((f) => /^repl-.*\.log$/.test(f));
expect(logName).toBeDefined();
const text = readFileSync(path.join(runsDir, logName!), 'utf8');
expect(text).toContain('copperhead v0.7.0'); // banner captured, SGR stripped
expect(text).toContain('do the thing'); // echoed request
expect(text).toContain('[REDACTED]'); // AC-4.1 redaction
expect(text).not.toContain('sk-SECRET_KEY_123');
expect(text).not.toContain('\x1b[');
} finally {
await rm(repo, { recursive: true, force: true });
}
});
it('redacts every AC-4.1 pattern, not just sk- keys', async () => {
// Regression: the log used to carry its own inline /sk-.../ regex, so a
// Bearer / npm_ / ghp_ token echoed by a tool result was persisted in
// cleartext to a file the transcripts' shared redactor would have caught.
setColorEnabled(false);
const repo = await mkdtemp(path.join(tmpdir(), 'copperhead-repl-log-'));
const secrets = [
'sk-SECRET_KEY_123',
'Bearer abcdefghijklmnopqrstuvwxyz012345',
`npm_${'a'.repeat(36)}`,
`ghp_${'b'.repeat(36)}`,
`github_pat_${'c'.repeat(22)}`,
];
try {
const { input, output } = fakeTty();
const done = runRepl({
repoRoot: repo,
model: 'gpt-5',
modelSource: 'flag',
version: '0.7.0',
kicadCliVersion: '9.0.0',
renderer: plainRenderer(() => {}),
input,
output,
runRequest: vi.fn(async (_req: string, log?: (l: string) => void) => {
for (const s of secrets) log?.(`tool output: ${s} trailing`);
return { outcome: 'success' as const };
}),
});
await new Promise((r) => setTimeout(r, 30));
input.write('publish it\n');
await new Promise((r) => setTimeout(r, 30));
input.write('/quit\n');
await done;
const runsDir = path.join(repo, '.copperhead', 'runs');
const { readdirSync, readFileSync } = await import('node:fs');
const logName = readdirSync(runsDir).find((f) => /^repl-.*\.log$/.test(f));
const text = readFileSync(path.join(runsDir, logName!), 'utf8');
for (const s of secrets) expect(text, s).not.toContain(s);
expect(text).toContain('[REDACTED]');
expect(text).toContain('trailing'); // surrounding context survives
} finally {
await rm(repo, { recursive: true, force: true });
}
});
});
describe('demo scaffold', () => {
it('creates a git repo with config ready for create', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'copperhead-demo-'));
try {
await scaffoldDemoRepo(dir);
expect(existsSync(path.join(dir, '.git'))).toBe(true);
expect(existsSync(path.join(dir, '.copperhead/config.json'))).toBe(true);
expect(existsSync(defaultBriefPath())).toBe(true);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
});
describe('runRepl', () => {
it('refuses a non-TTY shell without a seed', async () => {
setColorEnabled(false);
const lines: string[] = [];
const input = new PassThrough();
const output = new PassThrough();
const res = await runRepl({
repoRoot: '/tmp/repo',
model: 'gpt-5',
modelSource: 'flag',
version: '0.7.0',
kicadCliVersion: '9.0.0',
renderer: plainRenderer(() => {}),
log: (l) => lines.push(l),
input,
output,
});
expect(res.ok).toBe(false);
expect(lines.join('\n')).toContain('requires a TTY');
});
it('runs a seed on non-TTY as a one-shot', async () => {
setColorEnabled(false);
const lines: string[] = [];
const runRequest = vi.fn(async () => ({ outcome: 'success' as const }));
const res = await runRepl({
repoRoot: '/tmp/repo',
model: 'gpt-5',
modelSource: 'flag',
version: '0.7.0',
kicadCliVersion: '9.0.0',
seed: 'add ESD diodes',
renderer: plainRenderer(() => {}),
log: (l) => lines.push(l),
input: new PassThrough(),
output: new PassThrough(),
runRequest,
});
expect(res).toEqual({ ok: true, turns: 1 });
expect(runRequest).toHaveBeenCalledWith('add ESD diodes', expect.any(Function), expect.anything());
});
it('handles /help /demo /examples /model /check /quit on a TTY', async () => {
setColorEnabled(false);
const { input, output, lines, opts } = baseOpts();
const done = runRepl(opts);
// Drive the prompt after the banner is written.
await new Promise((r) => setTimeout(r, 20));
input.write('/help\n');
await new Promise((r) => setTimeout(r, 20));
input.write('/demo\n');
await new Promise((r) => setTimeout(r, 20));
input.write('/examples\n');
await new Promise((r) => setTimeout(r, 20));
input.write('/model\n');
await new Promise((r) => setTimeout(r, 20));
input.write('\x1b'); // cancel the model picker: keeps the current model
await new Promise((r) => setTimeout(r, 20));
input.write('/check\n');
await new Promise((r) => setTimeout(r, 20));
input.write('/quit\n');
const res = await done;
expect(res.ok).toBe(true);
expect(res.turns).toBe(0);
expect(lines.join('\n')).toContain('███ ███');
expect(lines.join('\n')).toContain('/quit');
expect(lines.join('\n')).toContain('What copperhead does');
expect(lines.join('\n')).toContain('Example prompts');
expect(lines.join('\n')).toContain('gpt-5');
expect(lines.join('\n')).toContain('via flag');
expect(lines).toContain('check ok');
expect(lines.join('\n')).toContain('session ended');
expect(opts.runRequest).not.toHaveBeenCalled();
});
it('switches the session model via the /model picker', async () => {
setColorEnabled(false);
const { input, lines, opts } = baseOpts();
const done = runRepl(opts);
await new Promise((r) => setTimeout(r, 20));
input.write('/model\n');
await new Promise((r) => setTimeout(r, 30));
input.write('\x1b[B\r'); // hover codex, select
await new Promise((r) => setTimeout(r, 30));
input.write('/quit\n');
const res = await done;
expect(res.ok).toBe(true);
expect(lines.join('\n')).toContain('model switched to codex');
expect(opts.model).toBe('codex');
expect(opts.modelSource).toBe('picker');
});
it('owns the alternate screen and restores it on quit', async () => {
setColorEnabled(false);
const { input, output, opts } = baseOpts();
let raw = '';
output.on('data', (c) => {
raw += String(c);
});
const done = runRepl(opts);
await new Promise((r) => setTimeout(r, 20));
input.write('/quit\n');
await done;
const enter = raw.indexOf('\x1b[?1049h');
const leave = raw.indexOf('\x1b[?1049l');
expect(enter).toBeGreaterThanOrEqual(0);
expect(leave).toBeGreaterThan(enter);
expect(raw).toContain('\x1b[r'); // scroll fence reset before leaving
expect(raw).toContain('❯ '); // prompt chevron with nbsp
});
it('typing /demo via live hints then /quit works end-to-end', async () => {
setColorEnabled(false);
const { input, lines, opts } = baseOpts();
const done = runRepl(opts);
await new Promise((r) => setTimeout(r, 30));
input.write('/demo\n');
await new Promise((r) => setTimeout(r, 40));
input.write('/quit\n');
const res = await done;
expect(res.ok).toBe(true);
expect(lines.join('\n')).toContain('What copperhead does');
expect(lines.join('\n')).toContain('session ended');
});
it('runs a natural-language request then continues until /quit', async () => {
setColorEnabled(false);
const { input, output, lines, opts } = baseOpts();
const done = runRepl(opts);
await new Promise((r) => setTimeout(r, 20));
input.write('rename net KEY_DAH to KEY_DASH\n');
await new Promise((r) => setTimeout(r, 20));
input.write('/quit\n');
const res = await done;
expect(res).toEqual({ ok: true, turns: 1 });
expect(opts.runRequest).toHaveBeenCalledWith('rename net KEY_DAH to KEY_DASH', expect.any(Function), expect.anything());
expect(lines.join('\n')).toContain('session ended');
void output; // keep stream alive for typing
});
});
c62f54059This file is pinned to the selected release. Download the original to open it in your design tool.