copperhead 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -4
- package/dist/agent/animate.js +76 -0
- package/dist/agent/animate.js.map +1 -0
- package/dist/agent/box.js +89 -0
- package/dist/agent/box.js.map +1 -0
- package/dist/agent/dock-renderer.js +173 -0
- package/dist/agent/dock-renderer.js.map +1 -0
- package/dist/agent/logo.js +21 -0
- package/dist/agent/logo.js.map +1 -0
- package/dist/agent/loop.js +15 -5
- package/dist/agent/loop.js.map +1 -1
- package/dist/agent/providers/claude-code.js +1 -212
- package/dist/agent/providers/claude-code.js.map +1 -1
- package/dist/agent/providers/cursor.js +317 -0
- package/dist/agent/providers/cursor.js.map +1 -0
- package/dist/agent/providers/tool-protocol.js +205 -0
- package/dist/agent/providers/tool-protocol.js.map +1 -0
- package/dist/agent/render.js +32 -15
- package/dist/agent/render.js.map +1 -1
- package/dist/agent/runmeta.js +4 -5
- package/dist/agent/runmeta.js.map +1 -1
- package/dist/agent/theme.js +84 -0
- package/dist/agent/theme.js.map +1 -0
- package/dist/cli.js +134 -13
- package/dist/cli.js.map +1 -1
- package/dist/commands/create.js +41 -32
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/demo.js +146 -0
- package/dist/commands/demo.js.map +1 -0
- package/dist/commands/doctor.js +240 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/repl-inspect.js +342 -0
- package/dist/commands/repl-inspect.js.map +1 -0
- package/dist/commands/repl.js +618 -0
- package/dist/commands/repl.js.map +1 -0
- package/dist/config.js +5 -2
- package/dist/config.js.map +1 -1
- package/dist/kicad/cli.js +126 -6
- package/dist/kicad/cli.js.map +1 -1
- package/dist/util/cli-args.js +35 -0
- package/dist/util/cli-args.js.map +1 -0
- package/dist/util/dock.js +155 -0
- package/dist/util/dock.js.map +1 -0
- package/dist/util/git.js +129 -4
- package/dist/util/git.js.map +1 -1
- package/dist/util/live-prompt.js +542 -0
- package/dist/util/live-prompt.js.map +1 -0
- package/dist/util/paths.js +9 -0
- package/dist/util/paths.js.map +1 -1
- package/dist/util/select.js +172 -0
- package/dist/util/select.js.map +1 -0
- package/package.json +3 -2
- package/src/agent/animate.ts +90 -0
- package/src/agent/box.ts +99 -0
- package/src/agent/dock-renderer.ts +181 -0
- package/src/agent/logo.ts +23 -0
- package/src/agent/loop.ts +15 -5
- package/src/agent/providers/claude-code.ts +2 -216
- package/src/agent/providers/cursor.ts +364 -0
- package/src/agent/providers/tool-protocol.ts +212 -0
- package/src/agent/render.ts +33 -16
- package/src/agent/runmeta.ts +6 -7
- package/src/agent/theme.ts +91 -0
- package/src/cli.ts +139 -15
- package/src/commands/create.ts +81 -30
- package/src/commands/demo.ts +184 -0
- package/src/commands/doctor.ts +289 -0
- package/src/commands/repl-inspect.ts +353 -0
- package/src/commands/repl.ts +685 -0
- package/src/config.ts +6 -3
- package/src/kicad/cli.ts +132 -7
- package/src/layout/claude-ui-layout.md +72 -0
- package/src/layout/repl-ui-layout.md +139 -0
- package/src/util/cli-args.ts +42 -0
- package/src/util/dock.ts +161 -0
- package/src/util/git.ts +140 -4
- package/src/util/live-prompt.ts +595 -0
- package/src/util/paths.ts +10 -0
- package/src/util/select.ts +192 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal arrow-key select menu for TTY prompts (no extra deps).
|
|
3
|
+
* Renders an in-place "dropdown" navigable with ↑/↓ + Enter.
|
|
4
|
+
*/
|
|
5
|
+
import { copper, dim } from '../agent/theme.js';
|
|
6
|
+
const HIDE = '\x1b[?25l';
|
|
7
|
+
const SHOW = '\x1b[?25h';
|
|
8
|
+
const CLEAR_LINE = '\r\x1b[2K';
|
|
9
|
+
function menuLines(title, items, index) {
|
|
10
|
+
const width = Math.max(...items.map((i) => i.label.length), 8);
|
|
11
|
+
return [
|
|
12
|
+
copper(` ${title}`),
|
|
13
|
+
dim(' ↑/↓ move · Enter select · Esc cancel'),
|
|
14
|
+
'',
|
|
15
|
+
...items.map((item, i) => {
|
|
16
|
+
const cursor = i === index ? copper('❯') : ' ';
|
|
17
|
+
const label = i === index ? copper(item.label.padEnd(width)) : dim(item.label.padEnd(width));
|
|
18
|
+
const desc = item.description ? dim(` ${item.description}`) : '';
|
|
19
|
+
return ` ${cursor} ${label}${desc}`;
|
|
20
|
+
}),
|
|
21
|
+
'',
|
|
22
|
+
];
|
|
23
|
+
}
|
|
24
|
+
async function* stdinKeys(input) {
|
|
25
|
+
// Listener-based on purpose: `for await` over a Readable destroys the
|
|
26
|
+
// stream when the consumer breaks out, which would kill stdin for
|
|
27
|
+
// whatever runs after the menu (e.g. the REPL's KeyReader).
|
|
28
|
+
const wasRaw = input.isRaw;
|
|
29
|
+
if (typeof input.setRawMode === 'function')
|
|
30
|
+
input.setRawMode(true);
|
|
31
|
+
input.resume();
|
|
32
|
+
input.setEncoding('utf8');
|
|
33
|
+
const queue = [];
|
|
34
|
+
let ended = false;
|
|
35
|
+
let notify = null;
|
|
36
|
+
const onData = (c) => {
|
|
37
|
+
queue.push(String(c));
|
|
38
|
+
notify?.();
|
|
39
|
+
};
|
|
40
|
+
const onEnd = () => {
|
|
41
|
+
ended = true;
|
|
42
|
+
notify?.();
|
|
43
|
+
};
|
|
44
|
+
input.on('data', onData);
|
|
45
|
+
input.on('end', onEnd);
|
|
46
|
+
try {
|
|
47
|
+
for (;;) {
|
|
48
|
+
while (queue.length) {
|
|
49
|
+
const s = queue.shift();
|
|
50
|
+
let i = 0;
|
|
51
|
+
while (i < s.length) {
|
|
52
|
+
if (s[i] === '\x1b' && s[i + 1] === '[') {
|
|
53
|
+
yield s.slice(i, i + 3);
|
|
54
|
+
i += 3;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
// Lone Esc
|
|
58
|
+
if (s[i] === '\x1b') {
|
|
59
|
+
yield '\x1b';
|
|
60
|
+
i += 1;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
yield s[i];
|
|
64
|
+
i += 1;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (ended)
|
|
68
|
+
return;
|
|
69
|
+
await new Promise((resolve) => {
|
|
70
|
+
notify = resolve;
|
|
71
|
+
});
|
|
72
|
+
notify = null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
input.off('data', onData);
|
|
77
|
+
input.off('end', onEnd);
|
|
78
|
+
input.pause();
|
|
79
|
+
if (typeof input.setRawMode === 'function')
|
|
80
|
+
input.setRawMode(wasRaw ?? false);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** Model choices offered when no model is configured anywhere. */
|
|
84
|
+
export const MODEL_CHOICES = [
|
|
85
|
+
{ value: 'claude-code', label: 'claude-code', description: 'Claude Code CLI (saved login)' },
|
|
86
|
+
{ value: 'codex', label: 'codex', description: 'Codex CLI (saved login)' },
|
|
87
|
+
{ value: 'cursor', label: 'cursor', description: 'Cursor CLI (saved login)' },
|
|
88
|
+
{ value: 'claude', label: 'claude', description: 'Anthropic API (needs ANTHROPIC_API_KEY)' },
|
|
89
|
+
{ value: 'gpt-5', label: 'gpt-5', description: 'OpenAI API (needs OPENAI_API_KEY)' },
|
|
90
|
+
];
|
|
91
|
+
/**
|
|
92
|
+
* Interactive model picker for a TTY session with no configured model.
|
|
93
|
+
* Resolves to the chosen model id, or null if cancelled.
|
|
94
|
+
*/
|
|
95
|
+
export async function pickModel(opts = {}) {
|
|
96
|
+
return selectMenu({ title: 'Select a model for this session', items: MODEL_CHOICES, ...opts });
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Show a selectable list. Resolves to the chosen value, or null if cancelled.
|
|
100
|
+
*/
|
|
101
|
+
export async function selectMenu(opts) {
|
|
102
|
+
const items = opts.items;
|
|
103
|
+
if (!items.length)
|
|
104
|
+
return null;
|
|
105
|
+
const input = opts.input ?? process.stdin;
|
|
106
|
+
const output = opts.output ?? process.stdout;
|
|
107
|
+
const title = opts.title ?? 'Select';
|
|
108
|
+
let index = 0;
|
|
109
|
+
let lineCount = 0;
|
|
110
|
+
const paint = () => {
|
|
111
|
+
const lines = menuLines(title, items, index);
|
|
112
|
+
if (lineCount > 0) {
|
|
113
|
+
// Move to the first line of the previous paint and rewrite in place.
|
|
114
|
+
output.write(`\x1b[${lineCount}A`);
|
|
115
|
+
}
|
|
116
|
+
output.write(HIDE);
|
|
117
|
+
for (const line of lines) {
|
|
118
|
+
output.write(CLEAR_LINE + line + '\n');
|
|
119
|
+
}
|
|
120
|
+
lineCount = lines.length;
|
|
121
|
+
};
|
|
122
|
+
const erase = () => {
|
|
123
|
+
if (lineCount <= 0) {
|
|
124
|
+
output.write(SHOW);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
output.write(`\x1b[${lineCount}A`);
|
|
128
|
+
for (let i = 0; i < lineCount; i++)
|
|
129
|
+
output.write(CLEAR_LINE + (i < lineCount - 1 ? '\n' : ''));
|
|
130
|
+
if (lineCount > 1)
|
|
131
|
+
output.write(`\x1b[${lineCount - 1}A`);
|
|
132
|
+
output.write('\r' + SHOW);
|
|
133
|
+
lineCount = 0;
|
|
134
|
+
};
|
|
135
|
+
paint();
|
|
136
|
+
const keys = opts.keys ?? stdinKeys(input);
|
|
137
|
+
try {
|
|
138
|
+
for await (const key of keys) {
|
|
139
|
+
if (key === '\x03' || key === '\x1b') {
|
|
140
|
+
erase();
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
if (key === '\r' || key === '\n') {
|
|
144
|
+
const chosen = items[index].value;
|
|
145
|
+
erase();
|
|
146
|
+
return chosen;
|
|
147
|
+
}
|
|
148
|
+
if (key === '\x1b[A' || key === 'k') {
|
|
149
|
+
index = (index - 1 + items.length) % items.length;
|
|
150
|
+
paint();
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (key === '\x1b[B' || key === 'j') {
|
|
154
|
+
index = (index + 1) % items.length;
|
|
155
|
+
paint();
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (key >= '1' && key <= '9') {
|
|
159
|
+
const n = Number(key) - 1;
|
|
160
|
+
if (n >= 0 && n < items.length) {
|
|
161
|
+
index = n;
|
|
162
|
+
paint();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
erase();
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
//# sourceMappingURL=select.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"select.js","sourceRoot":"","sources":["../../src/util/select.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "copperhead",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Cursor for circuit boards: an AI agent that designs, documents, and validates real PCBs on KiCad repositories",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Animesh Chouhan <animeshchouhan@outlook.com>",
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
"prepublishOnly": "npm run typecheck && npm run build",
|
|
49
49
|
"dev": "tsx src/cli.ts",
|
|
50
50
|
"demo:simple": "bash scripts/demo-simple.sh",
|
|
51
|
+
"demo:ui": "tsx scripts/ui-demo.ts",
|
|
51
52
|
"lint:md": "markdownlint-cli2 \"**/*.md\" \"#node_modules/**\" \"#**/node_modules/**\" \"#dist/**\" \"#**/dist/**\" \"#docs/.astro/**\" \"#demo-runs/**\" \"#.copperhead/runs/**\" \"#_site/**\"",
|
|
52
53
|
"test": "vitest run",
|
|
53
54
|
"test:watch": "vitest",
|
|
@@ -72,7 +73,7 @@
|
|
|
72
73
|
"devDependencies": {
|
|
73
74
|
"@openai/codex-sdk": "^0.144.6",
|
|
74
75
|
"@types/node": "^22.13.10",
|
|
75
|
-
"markdownlint-cli2": "0.
|
|
76
|
+
"markdownlint-cli2": "0.23.2",
|
|
76
77
|
"tsx": "^4.19.3",
|
|
77
78
|
"typescript": "^5.8.2",
|
|
78
79
|
"vitest": "^3.0.8"
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subtle TTY motion for attended copperhead chrome. Disabled when color is
|
|
3
|
+
* off, stdout is not a TTY, CI=1, or COPPERHEAD_NO_ANIM=1.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { copper, isColorEnabled } from './theme.js';
|
|
7
|
+
|
|
8
|
+
export function prefersAnimation(): boolean {
|
|
9
|
+
return (
|
|
10
|
+
isColorEnabled() &&
|
|
11
|
+
Boolean(process.stdout.isTTY) &&
|
|
12
|
+
!process.env.CI &&
|
|
13
|
+
!process.env.COPPERHEAD_NO_ANIM
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function sleep(ms: number): Promise<void> {
|
|
18
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const HIDE = '\x1b[?25l';
|
|
22
|
+
const SHOW = '\x1b[?25h';
|
|
23
|
+
|
|
24
|
+
/** Growing fiducial frames (3 rows), converging on the block mark. Exported for tests. */
|
|
25
|
+
export function fiducialBootFrames(): string[][] {
|
|
26
|
+
return [
|
|
27
|
+
[' ', ' ██ ', ' '],
|
|
28
|
+
[' ▗▄▄▖ ', ' █ █ ', ' ▝▀▀▘ '],
|
|
29
|
+
[' ▄▟▙▄ ', ' ██ ██ ', ' ▀▜▛▀ '],
|
|
30
|
+
[' ▄▟▙▄ ', ' ███ ███', ' ▀▜▛▀ '],
|
|
31
|
+
].map((frame) => frame.map((row) => copper(row)));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Pulse the fiducial mark in place over the already-rendered banner (rows
|
|
36
|
+
* `topRow..topRow+2`, column 1). The full screen loads first; this animates
|
|
37
|
+
* after, two grow cycles ending on the final mark. Frames are constant
|
|
38
|
+
* width so they overwrite each other without clearing the banner text
|
|
39
|
+
* beside them. Deliberately avoids DECSC/DECRC: the dock owns that slot for
|
|
40
|
+
* its caret-parking protocol; the caller repaints the dock afterwards to
|
|
41
|
+
* restore the caret.
|
|
42
|
+
*/
|
|
43
|
+
export async function animateMarkAt(
|
|
44
|
+
out: NodeJS.WriteStream,
|
|
45
|
+
topRow: number,
|
|
46
|
+
opts?: { slow?: boolean },
|
|
47
|
+
): Promise<void> {
|
|
48
|
+
if (!prefersAnimation()) return;
|
|
49
|
+
const frames = fiducialBootFrames();
|
|
50
|
+
const frameMs = opts?.slow ? 110 : 45;
|
|
51
|
+
const holdMs = opts?.slow ? 220 : 90;
|
|
52
|
+
try {
|
|
53
|
+
for (let cycle = 0; cycle < 2; cycle++) {
|
|
54
|
+
for (const frame of frames) {
|
|
55
|
+
let seq = HIDE;
|
|
56
|
+
frame.forEach((row, i) => {
|
|
57
|
+
seq += `\x1b[${topRow + i};1H${row}`;
|
|
58
|
+
});
|
|
59
|
+
out.write(seq);
|
|
60
|
+
await sleep(frameMs);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
await sleep(holdMs);
|
|
64
|
+
} finally {
|
|
65
|
+
// Never leave the terminal with a hidden cursor, even if a write or
|
|
66
|
+
// sleep throws mid-animation.
|
|
67
|
+
out.write(SHOW);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Copper horizontal rule (PCB-trace vibe). */
|
|
72
|
+
export function traceRule(width = 32): string {
|
|
73
|
+
return copper(' ' + '─'.repeat(Math.max(8, Math.min(width, 48))));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Cascade lines (help / demo sections). Instant when animation is off. */
|
|
77
|
+
export async function staggerWrite(
|
|
78
|
+
lines: string[],
|
|
79
|
+
write: (line: string) => void,
|
|
80
|
+
delayMs = 12,
|
|
81
|
+
): Promise<void> {
|
|
82
|
+
if (!prefersAnimation()) {
|
|
83
|
+
for (const line of lines) write(line);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
for (const line of lines) {
|
|
87
|
+
write(line);
|
|
88
|
+
await sleep(delayMs);
|
|
89
|
+
}
|
|
90
|
+
}
|
package/src/agent/box.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Box-drawing primitives for the bottom-docked REPL chrome: input box,
|
|
3
|
+
* status bar, and notice callouts. All width math ignores SGR sequences so
|
|
4
|
+
* colored segments never break padding or wrap accounting.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { copper, err, isColorEnabled, ruleDim, warn } from './theme.js';
|
|
8
|
+
|
|
9
|
+
const SGR = /\x1b\[[0-9;]*m/g;
|
|
10
|
+
|
|
11
|
+
/** Visible width ignoring SGR color codes. */
|
|
12
|
+
export function visibleWidth(s: string): number {
|
|
13
|
+
return s.replace(SGR, '').length;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Inverse video (synthetic caret / hover); no-op when color is off. */
|
|
17
|
+
export function inverse(s: string): string {
|
|
18
|
+
if (!isColorEnabled() || s === '') return s;
|
|
19
|
+
return `\x1b[7m${s}\x1b[0m`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A run of plain text with an optional style applied after slicing. */
|
|
23
|
+
export interface Span {
|
|
24
|
+
text: string;
|
|
25
|
+
paint?: (s: string) => string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Wrap styled spans into lines no wider than `width`. Styling is applied per
|
|
30
|
+
* slice, so a span can be cut at a wrap boundary without leaking SGR state.
|
|
31
|
+
*/
|
|
32
|
+
export function wrapSpans(spans: Span[], width: number): string[] {
|
|
33
|
+
const w = Math.max(1, width);
|
|
34
|
+
const lines: string[] = [];
|
|
35
|
+
let cur = '';
|
|
36
|
+
let curLen = 0;
|
|
37
|
+
for (const span of spans) {
|
|
38
|
+
let text = span.text;
|
|
39
|
+
while (text.length) {
|
|
40
|
+
const take = text.slice(0, w - curLen);
|
|
41
|
+
cur += span.paint ? span.paint(take) : take;
|
|
42
|
+
curLen += take.length;
|
|
43
|
+
text = text.slice(take.length);
|
|
44
|
+
if (curLen >= w) {
|
|
45
|
+
lines.push(cur);
|
|
46
|
+
cur = '';
|
|
47
|
+
curLen = 0;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (cur !== '' || !lines.length) lines.push(cur);
|
|
52
|
+
return lines;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Full-width horizontal rule (the input-area separators), #888888. */
|
|
56
|
+
export function rule(width: number): string {
|
|
57
|
+
return ruleDim('─'.repeat(Math.max(1, width)));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Truncate to a visible width, keeping SGR sequences intact and closed. */
|
|
61
|
+
export function truncateVisible(s: string, width: number): string {
|
|
62
|
+
if (visibleWidth(s) <= width) return s;
|
|
63
|
+
let out = '';
|
|
64
|
+
let vis = 0;
|
|
65
|
+
let i = 0;
|
|
66
|
+
let hadSgr = false;
|
|
67
|
+
while (i < s.length && vis < width) {
|
|
68
|
+
if (s[i] === '\x1b') {
|
|
69
|
+
const m = /^\x1b\[[0-9;]*m/.exec(s.slice(i));
|
|
70
|
+
if (m) {
|
|
71
|
+
out += m[0];
|
|
72
|
+
hadSgr = true;
|
|
73
|
+
i += m[0].length;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
out += s[i];
|
|
78
|
+
vis++;
|
|
79
|
+
i++;
|
|
80
|
+
}
|
|
81
|
+
return out + (hadSgr ? '\x1b[0m' : '');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** One status line with a left- and a right-justified half. */
|
|
85
|
+
export function statusBar(left: string, right: string, width: number): string {
|
|
86
|
+
const w = Math.max(8, width);
|
|
87
|
+
const l = visibleWidth(left);
|
|
88
|
+
const r = visibleWidth(right);
|
|
89
|
+
// Too narrow for both: the hints matter more than the meta.
|
|
90
|
+
if (l + r + 2 > w) return truncateVisible(left, w);
|
|
91
|
+
return left + ' '.repeat(w - l - r) + right;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Notice block: thin colored left bar + title + dim body (banner callouts). */
|
|
95
|
+
export function callout(kind: 'info' | 'warn' | 'err', title: string, body: string[]): string[] {
|
|
96
|
+
const paintBar = kind === 'err' ? err : kind === 'warn' ? warn : copper;
|
|
97
|
+
const bar = paintBar(' ▎');
|
|
98
|
+
return [`${bar} ${paintBar(title)}`, ...body.map((b) => `${bar} ${b}`)];
|
|
99
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REPL-session renderer: the single owner of the bottom dock during agent
|
|
3
|
+
* turns. Durable output (tool lines, turn markers, the outcome) flows into
|
|
4
|
+
* the content region through `emit`, which the REPL also records as
|
|
5
|
+
* scrollable history; the live observability line (spinner, turn, tokens,
|
|
6
|
+
* elapsed, busy text) is painted inside the dock, pinned to the bottom of
|
|
7
|
+
* the screen no matter how much output scrolls above it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { rule, statusBar } from './box.js';
|
|
11
|
+
import { copper, dim, styleOutcome, toolLine, warn } from './theme.js';
|
|
12
|
+
import { fmtDuration, fmtTokens, turnMarker, type ProgressRenderer } from './render.js';
|
|
13
|
+
import type { TerminalDock } from '../util/dock.js';
|
|
14
|
+
|
|
15
|
+
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
16
|
+
|
|
17
|
+
/** Claude Code-style working words, board-shop edition. One per turn. */
|
|
18
|
+
const WORKING = [
|
|
19
|
+
'Routing',
|
|
20
|
+
'Etching',
|
|
21
|
+
'Reflowing',
|
|
22
|
+
'Soldering',
|
|
23
|
+
'Drilling',
|
|
24
|
+
'Plating',
|
|
25
|
+
'Probing',
|
|
26
|
+
'Fluxing',
|
|
27
|
+
'Tinning',
|
|
28
|
+
'Laminating',
|
|
29
|
+
'Silkscreening',
|
|
30
|
+
'Panelizing',
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
/** Fixed word-slot width: longest word plus the static dots. */
|
|
34
|
+
const WORD_SLOT = Math.max(...WORKING.map((w) => w.length)) + 3;
|
|
35
|
+
|
|
36
|
+
export class DockRenderer implements ProgressRenderer {
|
|
37
|
+
private turn = 0;
|
|
38
|
+
private maxTurns = 0;
|
|
39
|
+
private tokensIn = 0;
|
|
40
|
+
private tokensOut = 0;
|
|
41
|
+
private streamedChars = 0;
|
|
42
|
+
private busy: string | null = null;
|
|
43
|
+
private frame = 0;
|
|
44
|
+
private runSeed = 0;
|
|
45
|
+
private startMs = Date.now();
|
|
46
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
47
|
+
|
|
48
|
+
constructor(
|
|
49
|
+
private readonly dock: TerminalDock,
|
|
50
|
+
/** Durable line sink: content region + session history. */
|
|
51
|
+
private readonly emit: (line: string) => void,
|
|
52
|
+
/** Dock chrome around the status row (meta right, bottom hints). */
|
|
53
|
+
private readonly chrome: () => { meta: string | null; hints: string | null },
|
|
54
|
+
) {}
|
|
55
|
+
|
|
56
|
+
log(line: string): void {
|
|
57
|
+
this.emit(line);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
toolResult(name: string, firstLine: string): void {
|
|
61
|
+
this.emit(toolLine(name, firstLine));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private turnStartMs = Date.now();
|
|
65
|
+
|
|
66
|
+
turnStart(turn: number, maxTurns: number, tokensIn: number, tokensOut: number): void {
|
|
67
|
+
if (turn === 1) {
|
|
68
|
+
this.startMs = Date.now();
|
|
69
|
+
this.runSeed++;
|
|
70
|
+
}
|
|
71
|
+
this.turnStartMs = Date.now();
|
|
72
|
+
this.turn = turn;
|
|
73
|
+
this.maxTurns = maxTurns;
|
|
74
|
+
this.tokensIn = tokensIn;
|
|
75
|
+
this.tokensOut = tokensOut;
|
|
76
|
+
this.emit(dim(turnMarker(turn, maxTurns, tokensIn, tokensOut)));
|
|
77
|
+
this.arm();
|
|
78
|
+
this.paint();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
status(text: string | null): void {
|
|
82
|
+
this.busy = text;
|
|
83
|
+
if (text === null) this.streamedChars = 0;
|
|
84
|
+
this.paint();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
heartbeat(info: { elapsedMs: number; streamedChars: number }): void {
|
|
88
|
+
this.streamedChars = info.streamedChars;
|
|
89
|
+
this.paint();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
finish(line: string): void {
|
|
93
|
+
this.disarm();
|
|
94
|
+
this.busy = null;
|
|
95
|
+
this.emit(styleOutcome(line));
|
|
96
|
+
// The next prompt's renderDock() takes the dock back over.
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Currently displayed working word; morphs letter by letter on change. */
|
|
100
|
+
private shownWord = '';
|
|
101
|
+
private targetWord = '';
|
|
102
|
+
/** -1 = settled; otherwise progress through erase-then-type transition. */
|
|
103
|
+
private morph = -1;
|
|
104
|
+
|
|
105
|
+
private arm(): void {
|
|
106
|
+
if (this.timer) return;
|
|
107
|
+
this.timer = setInterval(() => {
|
|
108
|
+
this.frame++;
|
|
109
|
+
// Sweep three positions per tick: a full word crossfade in under a second.
|
|
110
|
+
if (this.morph >= 0) this.morph += 3;
|
|
111
|
+
this.paint();
|
|
112
|
+
}, 120);
|
|
113
|
+
this.timer.unref?.();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private disarm(): void {
|
|
117
|
+
if (this.timer) clearInterval(this.timer);
|
|
118
|
+
this.timer = null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Pinned observability row painted inside the dock (same layout as the prompt). */
|
|
122
|
+
private paint(): void {
|
|
123
|
+
const w = Math.max(10, this.dock.cols() - 1);
|
|
124
|
+
const spinner = copper(FRAMES[this.frame % FRAMES.length]!);
|
|
125
|
+
// Claude Code-style working word, board-shop themed: rotates every ~6s
|
|
126
|
+
// while a turn runs, with a shimmering highlight sweeping the letters.
|
|
127
|
+
// A word change morphs letter by letter: the old word is erased into
|
|
128
|
+
// `_` slots left to right, then the new word types over them.
|
|
129
|
+
const wordIdx =
|
|
130
|
+
(this.runSeed + this.turn + Math.floor((Date.now() - this.turnStartMs) / 6000)) %
|
|
131
|
+
WORKING.length;
|
|
132
|
+
const target = WORKING[wordIdx]!;
|
|
133
|
+
if (this.shownWord === '') this.shownWord = target;
|
|
134
|
+
if (target !== this.targetWord) {
|
|
135
|
+
this.targetWord = target;
|
|
136
|
+
if (target !== this.shownWord) this.morph = 0;
|
|
137
|
+
}
|
|
138
|
+
let text = `${this.shownWord}...`;
|
|
139
|
+
if (this.morph >= 0) {
|
|
140
|
+
// Single left-to-right sweep over the whole dotted string (dots
|
|
141
|
+
// included): each position flips old char -> `_` -> new char, so the
|
|
142
|
+
// words cross-fade character by character.
|
|
143
|
+
const oldS = `${this.shownWord}...`;
|
|
144
|
+
const newS = `${this.targetWord}...`;
|
|
145
|
+
const width = Math.max(oldS.length, newS.length);
|
|
146
|
+
const k = this.morph;
|
|
147
|
+
if (k >= width) {
|
|
148
|
+
this.shownWord = this.targetWord;
|
|
149
|
+
this.morph = -1;
|
|
150
|
+
text = `${this.shownWord}...`;
|
|
151
|
+
} else {
|
|
152
|
+
let out = '';
|
|
153
|
+
for (let i = 0; i < width; i++) {
|
|
154
|
+
out += i < k ? (newS[i] ?? ' ') : i === k ? '_' : (oldS[i] ?? ' ');
|
|
155
|
+
}
|
|
156
|
+
text = out.trimEnd();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// Fixed-width slot (longest word + dots) so the stats after it never
|
|
160
|
+
// shift; dots and morph slots share the word's copper.
|
|
161
|
+
const word = copper(text.padEnd(WORD_SLOT));
|
|
162
|
+
const parts = [
|
|
163
|
+
dim(`turn ${this.turn}/${this.maxTurns}`),
|
|
164
|
+
dim(`${fmtTokens(this.tokensIn)} in / ${fmtTokens(this.tokensOut)} out`),
|
|
165
|
+
dim(fmtDuration(Date.now() - this.startMs)),
|
|
166
|
+
];
|
|
167
|
+
if (this.busy) {
|
|
168
|
+
parts.push(
|
|
169
|
+
warn(this.streamedChars ? `${this.busy} ~${fmtTokens(this.streamedChars)} ch` : this.busy),
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
const { meta, hints } = this.chrome();
|
|
173
|
+
this.dock.set([
|
|
174
|
+
...(meta ? [statusBar('', `${meta} `, w)] : []),
|
|
175
|
+
rule(w),
|
|
176
|
+
statusBar(`${spinner} ${word}`, `${parts.join(dim(' · '))} `, w),
|
|
177
|
+
rule(w),
|
|
178
|
+
...(hints ? [statusBar(` ${hints}`, '', w)] : []),
|
|
179
|
+
]);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal block-art mark derived from the website logo
|
|
3
|
+
* (docs/public/favicon.svg and docs.copperhead.sh): a via with a square
|
|
4
|
+
* drilled hole and long copper tracks routed out of both sides, copper
|
|
5
|
+
* #b87333 on dark. (`scripts/gen-logo.mjs` renders the exact favicon
|
|
6
|
+
* geometry at any size for reference.)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { copper } from './theme.js';
|
|
10
|
+
|
|
11
|
+
/** 3-row quadrant-block via with long tracks; rows are equal width. */
|
|
12
|
+
export function fiducialMark(): string[] {
|
|
13
|
+
return [
|
|
14
|
+
' ▄▟▙▄ ',
|
|
15
|
+
' ███ ███',
|
|
16
|
+
' ▀▜▛▀ ',
|
|
17
|
+
];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The mark painted in brand copper. */
|
|
21
|
+
export function fiducialLines(): string[] {
|
|
22
|
+
return fiducialMark().map((line) => copper(line));
|
|
23
|
+
}
|
package/src/agent/loop.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { loadConfig, CONFIG_DIR, type CopperheadConfig } from '../config.js';
|
|
|
11
11
|
import { Transcript, type ExitPath, type RunStats } from './transcript.js';
|
|
12
12
|
import { collectRunMeta, renderCliHeader, type RunMeta, type RunMetaInput } from './runmeta.js';
|
|
13
13
|
import { plainRenderer, fmtDuration, fmtTokens, type ProgressRenderer } from './render.js';
|
|
14
|
+
import { styleHeaderLines } from './theme.js';
|
|
14
15
|
import { ObligationsLedger } from './ledger.js';
|
|
15
16
|
import { gitPreflight, isDirty, snapshot, restore, commitAll, changedFiles, preserveFailedRun } from '../util/git.js';
|
|
16
17
|
import { withRetry, isRateLimit, sessionLimit } from '../util/retry.js';
|
|
@@ -20,6 +21,7 @@ import { OpenAIProvider } from './providers/openai.js';
|
|
|
20
21
|
import { AnthropicProvider } from './providers/anthropic.js';
|
|
21
22
|
import { CodexProvider } from './providers/codex.js';
|
|
22
23
|
import { ClaudeCodeProvider } from './providers/claude-code.js';
|
|
24
|
+
import { CursorProvider } from './providers/cursor.js';
|
|
23
25
|
import { openSynapMemory, type RunRecord, type SynapMemory } from '../memory/synap.js';
|
|
24
26
|
|
|
25
27
|
/** What the user sees at the moment they decide whether to keep going. */
|
|
@@ -100,6 +102,14 @@ export async function makeProvider(model: string, sessionResume = false): Promis
|
|
|
100
102
|
}
|
|
101
103
|
return new ClaudeCodeProvider(claudeCodeModel, undefined, undefined, sessionResume);
|
|
102
104
|
}
|
|
105
|
+
// Saved-login Cursor Agent CLI (`agent login`). Matched as its own namespace.
|
|
106
|
+
if (model === 'cursor' || model.startsWith('cursor:')) {
|
|
107
|
+
const cursorModel = model.startsWith('cursor:') ? model.slice('cursor:'.length) : undefined;
|
|
108
|
+
if (cursorModel === '') {
|
|
109
|
+
throw new Error('cursor model override cannot be empty; use "cursor" or "cursor:<model-id>"');
|
|
110
|
+
}
|
|
111
|
+
return new CursorProvider(cursorModel, undefined, sessionResume);
|
|
112
|
+
}
|
|
103
113
|
if (model === 'claude' || model.startsWith('claude')) {
|
|
104
114
|
return new AnthropicProvider(model === 'claude' ? undefined : model);
|
|
105
115
|
}
|
|
@@ -108,7 +118,7 @@ export async function makeProvider(model: string, sessionResume = false): Promis
|
|
|
108
118
|
|
|
109
119
|
function otherProvider(current: Provider): Provider | null {
|
|
110
120
|
// Only the two keyed providers fail over to each other. A rate-limited
|
|
111
|
-
// 'claude-code' run returns null here (no silent fallback to a paid API).
|
|
121
|
+
// 'claude-code' or 'cursor' run returns null here (no silent fallback to a paid API).
|
|
112
122
|
if (current.name === 'openai' && process.env.ANTHROPIC_API_KEY) return new AnthropicProvider();
|
|
113
123
|
if (current.name === 'anthropic' && process.env.OPENAI_API_KEY) return new OpenAIProvider();
|
|
114
124
|
return null;
|
|
@@ -205,9 +215,9 @@ async function runWithMemory(
|
|
|
205
215
|
finishRequest: null,
|
|
206
216
|
};
|
|
207
217
|
|
|
208
|
-
// Session resume for claude-code
|
|
209
|
-
// is off: the cache replays turns a resumed session never saw. So enable
|
|
210
|
-
// only when the env flag is set AND config.llmCache is disabled — the same
|
|
218
|
+
// Session resume for claude-code / cursor is only correct when the response
|
|
219
|
+
// cache is off: the cache replays turns a resumed session never saw. So enable
|
|
220
|
+
// it only when the env flag is set AND config.llmCache is disabled — the same
|
|
211
221
|
// condition under which we skip the CachingProvider wrap below.
|
|
212
222
|
const sessionResume = process.env.COPPERHEAD_CC_SESSION_RESUME === '1' && !config.llmCache;
|
|
213
223
|
let provider = opts.provider ?? (await makeProvider(opts.model, sessionResume));
|
|
@@ -238,7 +248,7 @@ async function runWithMemory(
|
|
|
238
248
|
interactive: opts.interactive ?? false,
|
|
239
249
|
input: opts.meta,
|
|
240
250
|
});
|
|
241
|
-
for (const line of renderCliHeader(meta)) log(line);
|
|
251
|
+
for (const line of styleHeaderLines(renderCliHeader(meta))) log(line);
|
|
242
252
|
// Revisit obligations deferred while their artifact didn't exist re-open now
|
|
243
253
|
// if it does (must run before loadConstraints so the prompt sees the updated
|
|
244
254
|
// registry). They land in this run's fresh ledger, so finish gates on them.
|