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.
Files changed (79) hide show
  1. package/README.md +36 -4
  2. package/dist/agent/animate.js +76 -0
  3. package/dist/agent/animate.js.map +1 -0
  4. package/dist/agent/box.js +89 -0
  5. package/dist/agent/box.js.map +1 -0
  6. package/dist/agent/dock-renderer.js +173 -0
  7. package/dist/agent/dock-renderer.js.map +1 -0
  8. package/dist/agent/logo.js +21 -0
  9. package/dist/agent/logo.js.map +1 -0
  10. package/dist/agent/loop.js +15 -5
  11. package/dist/agent/loop.js.map +1 -1
  12. package/dist/agent/providers/claude-code.js +1 -212
  13. package/dist/agent/providers/claude-code.js.map +1 -1
  14. package/dist/agent/providers/cursor.js +317 -0
  15. package/dist/agent/providers/cursor.js.map +1 -0
  16. package/dist/agent/providers/tool-protocol.js +205 -0
  17. package/dist/agent/providers/tool-protocol.js.map +1 -0
  18. package/dist/agent/render.js +32 -15
  19. package/dist/agent/render.js.map +1 -1
  20. package/dist/agent/runmeta.js +4 -5
  21. package/dist/agent/runmeta.js.map +1 -1
  22. package/dist/agent/theme.js +84 -0
  23. package/dist/agent/theme.js.map +1 -0
  24. package/dist/cli.js +134 -13
  25. package/dist/cli.js.map +1 -1
  26. package/dist/commands/create.js +41 -32
  27. package/dist/commands/create.js.map +1 -1
  28. package/dist/commands/demo.js +146 -0
  29. package/dist/commands/demo.js.map +1 -0
  30. package/dist/commands/doctor.js +240 -0
  31. package/dist/commands/doctor.js.map +1 -0
  32. package/dist/commands/repl-inspect.js +342 -0
  33. package/dist/commands/repl-inspect.js.map +1 -0
  34. package/dist/commands/repl.js +618 -0
  35. package/dist/commands/repl.js.map +1 -0
  36. package/dist/config.js +5 -2
  37. package/dist/config.js.map +1 -1
  38. package/dist/kicad/cli.js +126 -6
  39. package/dist/kicad/cli.js.map +1 -1
  40. package/dist/util/cli-args.js +35 -0
  41. package/dist/util/cli-args.js.map +1 -0
  42. package/dist/util/dock.js +155 -0
  43. package/dist/util/dock.js.map +1 -0
  44. package/dist/util/git.js +129 -4
  45. package/dist/util/git.js.map +1 -1
  46. package/dist/util/live-prompt.js +542 -0
  47. package/dist/util/live-prompt.js.map +1 -0
  48. package/dist/util/paths.js +9 -0
  49. package/dist/util/paths.js.map +1 -1
  50. package/dist/util/select.js +172 -0
  51. package/dist/util/select.js.map +1 -0
  52. package/package.json +3 -2
  53. package/src/agent/animate.ts +90 -0
  54. package/src/agent/box.ts +99 -0
  55. package/src/agent/dock-renderer.ts +181 -0
  56. package/src/agent/logo.ts +23 -0
  57. package/src/agent/loop.ts +15 -5
  58. package/src/agent/providers/claude-code.ts +2 -216
  59. package/src/agent/providers/cursor.ts +364 -0
  60. package/src/agent/providers/tool-protocol.ts +212 -0
  61. package/src/agent/render.ts +33 -16
  62. package/src/agent/runmeta.ts +6 -7
  63. package/src/agent/theme.ts +91 -0
  64. package/src/cli.ts +139 -15
  65. package/src/commands/create.ts +81 -30
  66. package/src/commands/demo.ts +184 -0
  67. package/src/commands/doctor.ts +289 -0
  68. package/src/commands/repl-inspect.ts +353 -0
  69. package/src/commands/repl.ts +685 -0
  70. package/src/config.ts +6 -3
  71. package/src/kicad/cli.ts +132 -7
  72. package/src/layout/claude-ui-layout.md +72 -0
  73. package/src/layout/repl-ui-layout.md +139 -0
  74. package/src/util/cli-args.ts +42 -0
  75. package/src/util/dock.ts +161 -0
  76. package/src/util/git.ts +140 -4
  77. package/src/util/live-prompt.ts +595 -0
  78. package/src/util/paths.ts +10 -0
  79. 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.7.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.22.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
+ }
@@ -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 (1.1) is only correct when the response cache
209
- // is off: the cache replays turns a resumed session never saw. So enable it
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.