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
package/src/config.ts CHANGED
@@ -96,7 +96,7 @@ export async function loadConfig(repoRoot: string): Promise<CopperheadConfig> {
96
96
  }
97
97
 
98
98
  /** Which level of the model-selection precedence chain won. */
99
- export type ModelSource = 'flag' | 'env' | 'config' | 'openai-key' | 'anthropic-key';
99
+ export type ModelSource = 'flag' | 'env' | 'config' | 'openai-key' | 'anthropic-key' | 'picker';
100
100
 
101
101
  export interface ResolvedModel {
102
102
  model: string;
@@ -111,6 +111,8 @@ export interface ResolvedModel {
111
111
  * Accepted values (same set for `--model`, COPPERHEAD_MODEL, and `model` in
112
112
  * .copperhead/config.json):
113
113
  *
114
+ * - `cursor` : the Cursor Agent CLI using saved login (`agent login`).
115
+ * - `cursor:<id>` : the same provider on a specific model id.
114
116
  * - `claude-code` : the Claude Code saved-login provider on its default
115
117
  * model. Needs NO API key — it reuses the logged-in Claude
116
118
  * Code CLI / CLAUDE_CODE_OAUTH_TOKEN via the Agent SDK.
@@ -131,7 +133,8 @@ export interface ResolvedModel {
131
133
  * a typo like `claud-sonnet-5` silently routes to OpenAI and fails there.
132
134
  * Anthropic and direct OpenAI providers require their API keys; `codex` requires
133
135
  * a locally installed and authenticated Codex CLI, and `claude-code` requires a
134
- * Claude Code login (CLAUDE_CODE_OAUTH_TOKEN); neither needs a model API key.
136
+ * Claude Code login (CLAUDE_CODE_OAUTH_TOKEN); `cursor` requires `agent login`.
137
+ * None of the saved-login providers need a model API key.
135
138
  */
136
139
  export function resolveModel(flag: string | undefined, config: CopperheadConfig, env = process.env): ResolvedModel {
137
140
  if (flag) return { model: flag, source: 'flag' };
@@ -140,6 +143,6 @@ export function resolveModel(flag: string | undefined, config: CopperheadConfig,
140
143
  if (env.OPENAI_API_KEY) return { model: 'gpt-5', source: 'openai-key' };
141
144
  if (env.ANTHROPIC_API_KEY) return { model: 'claude', source: 'anthropic-key' };
142
145
  throw new Error(
143
- 'no model configured: pass --model codex (uses your local Codex login), set COPPERHEAD_MODEL, set model in .copperhead/config.json, or provide OPENAI_API_KEY/ANTHROPIC_API_KEY',
146
+ 'no model configured: pass --model, set COPPERHEAD_MODEL, or export an API key; see https://docs.copperhead.sh/reference/configuration/',
144
147
  );
145
148
  }
package/src/kicad/cli.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execa, ExecaError } from 'execa';
2
+ import { existsSync } from 'node:fs';
2
3
  import { mkdtemp, readFile, rm } from 'node:fs/promises';
3
4
  import { tmpdir } from 'node:os';
4
5
  import path from 'node:path';
@@ -13,6 +14,7 @@ export class KicadCliMissingError extends PreflightError {
13
14
  [
14
15
  'install KiCad ≥ 8: https://www.kicad.org/download/',
15
16
  'ensure the kicad-cli binary is on PATH (on macOS it ships inside KiCad.app/Contents/MacOS)',
17
+ 'or set COPPERHEAD_KICAD_CLI=/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli',
16
18
  'confirm with "kicad-cli version", then rerun',
17
19
  ],
18
20
  );
@@ -20,12 +22,134 @@ export class KicadCliMissingError extends PreflightError {
20
22
  }
21
23
  }
22
24
 
25
+ /**
26
+ * `COPPERHEAD_KICAD_CLI` points somewhere that does not exist. Distinct from
27
+ * KicadCliMissingError because the advice is the opposite: the override was
28
+ * seen and rejected, so telling the user to set it would be nonsense.
29
+ */
30
+ export class KicadCliBadOverrideError extends PreflightError {
31
+ constructor(configured: string) {
32
+ super(
33
+ `COPPERHEAD_KICAD_CLI points to a path that does not exist: ${configured}`,
34
+ 'the override wins over PATH, so falling back silently would run a different binary than the one you named and make the failure impossible to diagnose',
35
+ [
36
+ `check the path: ls -l "${configured}"`,
37
+ 'on macOS the binary lives at /Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli',
38
+ 'or unset COPPERHEAD_KICAD_CLI to fall back to PATH',
39
+ 'confirm with "$COPPERHEAD_KICAD_CLI version", then rerun',
40
+ ],
41
+ );
42
+ this.name = 'KicadCliBadOverrideError';
43
+ }
44
+ }
45
+
46
+ /** Well-known install locations when `kicad-cli` is not on PATH (macOS app bundle). */
47
+ const FALLBACK_BINARIES = [
48
+ '/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli',
49
+ '/Applications/KiCad-10.0/KiCad.app/Contents/MacOS/kicad-cli',
50
+ '/Applications/KiCad-9.0/KiCad.app/Contents/MacOS/kicad-cli',
51
+ '/Applications/KiCad-8.0/KiCad.app/Contents/MacOS/kicad-cli',
52
+ ];
53
+
54
+ let fallbackBinaries: readonly string[] = FALLBACK_BINARIES;
55
+
56
+ let cachedBinary: string | null | undefined;
57
+
58
+ /**
59
+ * Resolve the kicad-cli executable: `COPPERHEAD_KICAD_CLI` > PATH name
60
+ * (`kicad-cli`). On PATH miss, `runKicad` falls back to macOS KiCad.app paths.
61
+ */
62
+ export function resolveKicadCli(): string {
63
+ if (cachedBinary !== undefined) {
64
+ if (cachedBinary === null) throw new KicadCliMissingError();
65
+ return cachedBinary;
66
+ }
67
+ const fromEnv = envOverride();
68
+ if (fromEnv) {
69
+ cachedBinary = fromEnv;
70
+ return fromEnv;
71
+ }
72
+ cachedBinary = 'kicad-cli';
73
+ return cachedBinary;
74
+ }
75
+
76
+ /**
77
+ * The `COPPERHEAD_KICAD_CLI` override, or null when unset. Set-but-missing is
78
+ * a hard error rather than a silent fallthrough to PATH: the user told us
79
+ * which binary to run, and quietly running a different one (or reporting
80
+ * "not found on PATH" with advice to set the variable they already set) is
81
+ * the worst possible answer.
82
+ */
83
+ function envOverride(): string | null {
84
+ const fromEnv = process.env.COPPERHEAD_KICAD_CLI?.trim();
85
+ if (!fromEnv) return null;
86
+ if (!existsSync(fromEnv)) throw new KicadCliBadOverrideError(fromEnv);
87
+ return fromEnv;
88
+ }
89
+
90
+ /**
91
+ * After ENOENT on PATH, retry with a known macOS install path.
92
+ *
93
+ * Deliberately does not re-read `COPPERHEAD_KICAD_CLI`: this is reached only
94
+ * when resolveKicadCli() cached the bare PATH name, which in turn happens only
95
+ * when the override was unset (set-but-missing throws there instead), so an
96
+ * override re-check here could never fire.
97
+ */
98
+ function fallbackAfterMissing(): string {
99
+ for (const candidate of fallbackBinaries) {
100
+ if (existsSync(candidate)) {
101
+ cachedBinary = candidate;
102
+ return candidate;
103
+ }
104
+ }
105
+ cachedBinary = null;
106
+ throw new KicadCliMissingError();
107
+ }
108
+
109
+ async function runKicad(args: string[], opts?: { reject?: boolean }): Promise<Awaited<ReturnType<typeof execa>>> {
110
+ let bin = resolveKicadCli();
111
+ let res = await execa(bin, args, { reject: false });
112
+ if (res.failed && (res as unknown as ExecaError).code === 'ENOENT') {
113
+ if (bin === 'kicad-cli') {
114
+ bin = fallbackAfterMissing();
115
+ res = await execa(bin, args, { reject: false });
116
+ } else {
117
+ throw new KicadCliMissingError();
118
+ }
119
+ }
120
+ if (opts?.reject === false) return res;
121
+ if (res.failed && (res as unknown as ExecaError).code === 'ENOENT') {
122
+ throw new KicadCliMissingError();
123
+ }
124
+ if (res.failed) {
125
+ throw Object.assign(new Error(res.stderr || res.stdout || `kicad-cli exited ${res.exitCode}`), res);
126
+ }
127
+ return res;
128
+ }
129
+
130
+ /** Test helper: clear the resolved-binary cache. */
131
+ export function resetKicadCliCache(): void {
132
+ cachedBinary = undefined;
133
+ }
134
+
135
+ /**
136
+ * Test helper: point the app-bundle probe at fixture paths, or call with no
137
+ * argument to restore the real list. Without this the fallback branch is only
138
+ * exercisable on a macOS host that happens to have KiCad installed, which
139
+ * makes the outcome depend on the developer's machine.
140
+ */
141
+ export function setKicadFallbackBinaries(paths?: readonly string[]): void {
142
+ fallbackBinaries = paths ?? FALLBACK_BINARIES;
143
+ }
144
+
23
145
  export async function kicadCliVersion(): Promise<string> {
24
146
  try {
25
- const { stdout } = await execa('kicad-cli', ['version']);
26
- return stdout.trim();
147
+ const res = await runKicad(['version']);
148
+ return String(res.stdout ?? '').trim();
27
149
  } catch (err) {
28
150
  if ((err as ExecaError).code === 'ENOENT') throw new KicadCliMissingError();
151
+ // runKicad already maps PATH ENOENT → fallback → KicadCliMissingError
152
+ if (err instanceof KicadCliMissingError) throw err;
29
153
  throw err;
30
154
  }
31
155
  }
@@ -39,8 +163,7 @@ async function runCheck(
39
163
  const out = path.join(dir, `${kind}.json`);
40
164
  const sub = kind === 'erc' ? ['sch', 'erc'] : ['pcb', 'drc'];
41
165
  try {
42
- const res = await execa(
43
- 'kicad-cli',
166
+ const res = await runKicad(
44
167
  [...sub, '--format', 'json', '--exit-code-violations', '--output', out, ...extraArgs, filePath],
45
168
  { reject: false },
46
169
  );
@@ -94,7 +217,7 @@ export async function kicadLoadError(filePath: string): Promise<string | null> {
94
217
  ? ['sch', 'export', 'netlist', '--output', path.join(dir, 'probe.net'), filePath]
95
218
  : ['pcb', 'export', 'pos', '--output', path.join(dir, 'probe.pos'), filePath];
96
219
  try {
97
- const res = await execa('kicad-cli', args, { reject: false });
220
+ const res = await runKicad(args, { reject: false });
98
221
  if (res.failed && (res as unknown as ExecaError).code === 'ENOENT') throw new KicadCliMissingError();
99
222
  if (res.exitCode === 0) return null;
100
223
  return [res.stderr, res.stdout].filter(Boolean).join('\n').trim() || `kicad-cli exited ${res.exitCode}`;
@@ -131,9 +254,10 @@ export async function exportFab(pcbPath: string, schPath: string | null, outDir:
131
254
  }
132
255
  for (const job of jobs) {
133
256
  try {
134
- await execa('kicad-cli', job.args);
257
+ await runKicad(job.args);
135
258
  result.produced.push(job.artifact);
136
259
  } catch (err) {
260
+ if (err instanceof KicadCliMissingError) throw err;
137
261
  if ((err as ExecaError).code === 'ENOENT') throw new KicadCliMissingError();
138
262
  result.failed.push({ artifact: job.artifact, reason: String((err as ExecaError).stderr ?? (err as Error).message).slice(0, 200) });
139
263
  }
@@ -148,8 +272,9 @@ export async function exportSvg(kind: 'sch' | 'pcb', filePath: string, outDir: s
148
272
  ? ['sch', 'export', 'svg', '--output', outDir, filePath]
149
273
  : ['pcb', 'export', 'svg', '--output', path.join(outDir, 'board.svg'), '--layers', 'F.Cu,B.Cu,Edge.Cuts', filePath];
150
274
  try {
151
- await execa('kicad-cli', args);
275
+ await runKicad(args);
152
276
  } catch (err) {
277
+ if (err instanceof KicadCliMissingError) throw err;
153
278
  if ((err as ExecaError).code === 'ENOENT') throw new KicadCliMissingError();
154
279
  throw err;
155
280
  }
@@ -0,0 +1,72 @@
1
+ # Claude Code UI layout (measured reference)
2
+
3
+ Captured from the real `claude` CLI (v2.1.220) under a pty + VT102 emulator at
4
+ 100x30, with per-cell SGR attributes extracted, so every color below is
5
+ measured, not eyeballed. This is the reference to diff
6
+ [repl-ui-layout.md](repl-ui-layout.md) against when editing copperhead's
7
+ chrome.
8
+
9
+ ## State 1: idle prompt
10
+
11
+ ```text
12
+ 2| ▐▛███▜▌ Claude Code v2.1.220 <- logo[#d77757] name[bold, default fg] version[#999999]
13
+ 3|▝▜█████▛▘ Fable 5 with high effort · Claude Max <- logo[#d77757] model+plan[#999999]
14
+ 4| ▘▘ ▝▝ ~/Github/chouhan-industries/copperhead <- logo[#d77757] cwd[#999999]
15
+ 5|
16
+ 6| Tackle your toughest work with Opus 5. ... <- notice body[default fg]
17
+ 7| +1 more · /status <- [#999999]
18
+ ..| <- content region, output scrolls here
19
+ 25| ● high · /effort <- meta right-aligned, all [#999999]
20
+ 26|───────────────────────────────────────────────────── <- rule[#888888], full width
21
+ 27|❯ Try "create a util logging.py that..." <- ❯ + nbsp[default], placeholder[faint], caret = real cursor
22
+ 28|───────────────────────────────────────────────────── <- rule[#888888], full width
23
+ 29| ⚠ Transcript saving is off · ... <- warning line (only when present)
24
+ 30| ⏸ manual mode on · ? for shortcuts <- status left[#999999]
25
+ ```
26
+
27
+ ## State 2: slash menu open (upward overlay, input row fixed at 27)
28
+
29
+ ```text
30
+ 22| /pr-review Review a copperhead pull request... <- hovered: label+desc[#b1b9f9], no inverse video
31
+ 23| spec workflow. Use when the user... <- description wraps to a second row
32
+ 24| /review Review a GitHub pull request; ... <- unhovered: [#999999]
33
+ 26|─────────────────────────────────────────────────────
34
+ 27|❯ / <- typed filter[default fg]
35
+ 28|─────────────────────────────────────────────────────
36
+ 30| ⏸ manual mode on <- status shrinks while menu is open
37
+ ```
38
+
39
+ ## State 3: Ctrl+C pressed once
40
+
41
+ ```text
42
+ 30| Press Ctrl-C again to exit <- [#999999], replaces the status line
43
+ ```
44
+
45
+ ## Measured color palette
46
+
47
+ | Role | Value | Notes |
48
+ | --------------------------- | ----------------- | --------------------------------------- |
49
+ | Accent (logo) | `#d77757` | truecolor; the only saturated color |
50
+ | Title (`Claude Code`) | bold, default fg | not white-forced: follows the theme |
51
+ | Secondary text | `#999999` | truecolor gray, softer than SGR 90 |
52
+ | Rules | `#888888` | one step darker than secondary text |
53
+ | Menu hover | `#b1b9f9` | periwinkle, hover is a color change |
54
+ | Placeholder | faint (SGR 2) | not fg-colored, uses the faint attr |
55
+ | Caret | terminal cursor | real cursor, not a synthetic inverse |
56
+
57
+ ## Structural notes vs copperhead's current implementation
58
+
59
+ 1. Grays: adopted. copperhead's `dim`/`ruleDim` are truecolor
60
+ `#999999`/`#888888` (with an SGR 90 fallback when truecolor is off).
61
+ 2. Menu hover: adopted as a color change. Claude recolors the row with
62
+ periwinkle `#b1b9f9`; copperhead recolors with `copperLight` (#eec9a5)
63
+ instead of inverse video.
64
+ 3. Hovered menu descriptions: adopted. Both wrap to a second row.
65
+ 4. Caret: adopted. copperhead parks the real terminal cursor in the input
66
+ row instead of drawing a synthetic inverse block.
67
+ 5. The name `Claude Code` is bold in the terminal's default foreground, so it
68
+ adapts to light/dark themes, it is not hard-coded white.
69
+ 6. There is a non-breaking space after `❯` in the prompt.
70
+
71
+ To adopt any of these in copperhead, edit the corresponding line in
72
+ [repl-ui-layout.md](repl-ui-layout.md) and paste it back.
@@ -0,0 +1,139 @@
1
+ # REPL UI layout spec
2
+
3
+ Captured from a real run (pty + VT102 emulator, 100x30). This file is the
4
+ editing surface for the interactive shell's chrome: change any line below,
5
+ paste it back, and the implementation follows. Every visual token maps to one
6
+ function in `src/agent/theme.ts`, so color changes are one-line edits.
7
+
8
+ ## State 1: idle prompt
9
+
10
+ ```text
11
+ 1| <- blank
12
+ 2| ▄▟▙▄ copperhead v0.7.0 <- mark[copper] name[bold] version[dim]
13
+ 3| ███ ███ claude via flag · kicad-cli 9.0.4 <- mark[copper] meta[dim]
14
+ 4| ▀▜▛▀ ~/Github/chouhan-industries/copperhead <- mark[copper] cwd[dim]
15
+ 5| <- blank
16
+ 6| ▎ New repository? <- bar[copper] title[copper]
17
+ 7| ▎ `copperhead init` scaffolds docs/ from an existing schematic <- bar[copper] body[default]
18
+ 8| ▎ `copperhead demo` runs the USB-C breakout create pipeline <- bar[copper] body[default]
19
+ 9| ▎ Docs: https://docs.copperhead.sh <- bar[copper] body[copper]
20
+ 10| <- content region: echoes + agent
21
+ ..| ❯ rename net KEY_DAH to KEY_DASH <- output scroll here, oldest
22
+ ..| ▸ run_erc clean — 0 violations <- scrolls off the top
23
+ 26| ● claude · main* <- meta right-aligned: dot[copper] text[dim]
24
+ 27|────────────────────────────────────────────────────────────────────── <- rule[dim], full width
25
+ 28|❯ Try "add reverse-polarity protection on VIN" <- prompt[copper]+nbsp, caret = real cursor, placeholder[dim] typed[bright]
26
+ 29|────────────────────────────────────────────────────────────────────── <- rule[dim], full width
27
+ 30| / for commands · pgup history · ctrl+c twice to quit In copperhead <- left[dim] right[dim]
28
+ ```
29
+
30
+ ## State 2: slash menu open (overlays upward, input row never moves)
31
+
32
+ ```text
33
+ 16| ❯ /demo what copperhead does + how to try it <- hovered: ❯[copper] label+desc[copperLight], desc wraps to a 2nd row
34
+ 17| /examples example change-request prompts <- label[default] desc[default]
35
+ ..| ...up to 10 items...
36
+ 26| ↓ 10 more <- overflow marker[dim]
37
+ 27|────────────────────────────────────────────────────────────────────── <- rule[dim]
38
+ 28|❯ / <- typed filter[bright]
39
+ 29|────────────────────────────────────────────────────────────────────── <- rule[dim]
40
+ 30| / for commands · pgup history · ctrl+c twice to quit In copperhead
41
+ ```
42
+
43
+ ## State 3: agent turn running (observability row pinned in the dock)
44
+
45
+ ```text
46
+ 26| ● claude · main* <- meta stays
47
+ 27|──────────────────────────────────────────────────────────────────────
48
+ 28|⠹ Reflowing... turn 2/40 · 1.2k in / 300 out · 12s · ERC <- word left[copper], stats right[dim] busy[warn]
49
+ 29|──────────────────────────────────────────────────────────────────────
50
+ 30| ctrl+c interrupts the run · output above scrolls into history
51
+ ```
52
+
53
+ The working word is a PCB term (Routing, Etching, Reflowing, Soldering,
54
+ Drilling, Plating, Probing, Fluxing, Tinning, Laminating, Silkscreening,
55
+ Panelizing), one per turn, with animated dots: Claude Code's working verbs,
56
+ board-shop edition; on rotation the old word crossfades char by char
57
+ through `_` slots, dots included. Durable output (tool lines, turn markers, the outcome)
58
+ scrolls in the content region; the observability row never moves. Between
59
+ submit and the first turn a passive `… working` row shows briefly.
60
+
61
+ First Ctrl+C at the prompt: input clears, row 30 becomes `press ctrl+c again to exit` [warn].
62
+
63
+ History: every content line is kept in a session buffer (cap 5000). PgUp at
64
+ the prompt scrolls the content region back through it (arrow keys / mouse
65
+ wheel line-scroll too at an empty prompt), PgDn scrolls forward,
66
+ any other key snaps back to the live tail; row 30 shows
67
+ `history ↑N · pgup/pgdn scroll · any key returns` while scrolled. The same
68
+ lines are mirrored by default to `.copperhead/runs/repl-<timestamp>.log`
69
+ (plain text: SGR stripped, `sk-` keys redacted per AC-4.1); the path is
70
+ printed when the session ends. Injected loggers (tests/embeds) disable the
71
+ file sink.
72
+
73
+ Startup: the full screen loads instantly (banner, callout, input dock), then
74
+ the mark pulses in place twice over rows 2-4 (dot, thin ring, thick
75
+ ring, full via) while the prompt is already usable. First run in a repo (no
76
+ `.copperhead/` yet) uses slow timing (110ms/frame) and shows the New
77
+ repository callout; later runs pulse fast (45ms/frame) and hide it.
78
+
79
+ ## Color tokens (src/agent/theme.ts)
80
+
81
+ | Token | SGR | Current value | Used for |
82
+ | ------------- | ------------------ | ------------------------- | -------------------------------------- |
83
+ | `copper` | `38;2;184;115;51` | #b87333 (brand: #b87333) | mark, prompt ❯, callout bar, meta dot |
84
+ | `copperLight` | `38;2;238;201;165` | #eec9a5 (accent-high) | hovered menu row |
85
+ | `bold` | `1` | bold, default fg | `copperhead` name (theme-adaptive) |
86
+ | `bright` | `97` | white | typed input text |
87
+ | `dim` | `38;2;153;153;153` | #999999 (SGR 90 fallback) | hints, placeholder, version, paths |
88
+ | `ruleDim` | `38;2;136;136;136` | #888888 (SGR 90 fallback) | input-area rules |
89
+ | `ok` | `32` | green | success lines (`check: all green`) |
90
+ | `warn` | `33` | amber | ctrl+c hint, cautions |
91
+ | `err` | `31` | red | failures |
92
+
93
+ Note: `copper` is the exact brand #b87333 on truecolor terminals
94
+ (COLORTERM=truecolor/24bit); terminals without truecolor fall back to
95
+ 256-color 173. See [claude-ui-layout.md](claude-ui-layout.md) for the measured
96
+ Claude Code reference palette to diff against.
97
+
98
+ ## Region -> source map
99
+
100
+ | Region | Source |
101
+ | --------------------- | ----------------------------------------------- |
102
+ | Banner + callout | `banner()` in `src/commands/repl.ts` |
103
+ | Meta line, status bar | `ask()` options in `src/commands/repl.ts` |
104
+ | Input rows, menu | `src/util/live-prompt.ts` (`renderDock`) |
105
+ | Rules, callout, bars | `src/agent/box.ts` |
106
+ | Screen ownership | `src/util/dock.ts` (alt screen + DECSTBM fence) |
107
+
108
+ To iterate: edit the annotated lines above (text, alignment, or `[token]`
109
+ tags), paste the block back, and the code gets updated to match. Verify with
110
+ `npm run demo:ui` (see below).
111
+
112
+ ## Demo recording (npm run demo:ui)
113
+
114
+ `npm run demo:ui` from the repo root runs `scripts/ui-demo.ts` via tsx: the
115
+ real REPL UI with a canned agent run, no build step, no API key, no repo
116
+ mutations. Same script every take, so recordings are reproducible.
117
+
118
+ Standard take:
119
+
120
+ 1. Let the banner settle and the via mark pulse
121
+ 2. Type `/`, hover a few commands with the arrow keys, Esc to dismiss
122
+ 3. Type `rename net KEY_DAH to KEY_DASH`, Enter, let the mock run play
123
+ (~17s, four sections: propose, edit, verify, remember, each closed by a
124
+ summary line; the pinned observability row animates and the working word
125
+ morphs at each section)
126
+ 4. `/check` for the mock green ERC/DRC/drift pass
127
+ 5. Ctrl+C twice to exit
128
+
129
+ The demo uses the current directory as the repo. A directory that already has
130
+ `.copperhead/` gets the fast mark pulse and no callout; for the full
131
+ first-run intro (slow pulse + New repository callout), run from a fresh
132
+ directory:
133
+
134
+ ```bash
135
+ cd $(mktemp -d) && node <repo>/node_modules/.bin/tsx <repo>/scripts/ui-demo.ts
136
+ ```
137
+
138
+ The mark itself can be regenerated from the website logo geometry at any
139
+ size with `node scripts/gen-logo.mjs <rows>`.
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Pure argument helpers for the CLI surface. They live here rather than in
3
+ * cli.ts because that module wires up commander and parses argv at import
4
+ * time, which makes it untestable in-process: importing it runs the program.
5
+ * Anything with a branch worth pinning belongs on this side of the line.
6
+ */
7
+
8
+ import path from 'node:path';
9
+ import type { BudgetExhaustedStats } from '../agent/loop.js';
10
+
11
+ /** Resolve `--repo` against the working directory; absolute paths pass through. */
12
+ export function repoOf(opts: { repo?: string }): string {
13
+ return path.resolve(opts.repo ?? process.cwd());
14
+ }
15
+
16
+ /** `--max-turns` accepts only a positive integer; "5oops" and "NaN" refuse to start. */
17
+ export function parseMaxTurns(raw: string): number {
18
+ const n = Number(raw);
19
+ if (!Number.isInteger(n) || n <= 0) {
20
+ throw new Error(`--max-turns must be a positive integer, got "${raw}"`);
21
+ }
22
+ return n;
23
+ }
24
+
25
+ /**
26
+ * Turns offered when the budget runs out: ceil of the ORIGINAL budget (design
27
+ * D1), so repeat extensions offer the same increment instead of escalating
28
+ * with the already-extended turn count.
29
+ */
30
+ export function budgetExtraTurns(stats: Pick<BudgetExhaustedStats, 'maxTurns'>): number {
31
+ return Math.ceil(stats.maxTurns / 2);
32
+ }
33
+
34
+ /** The attended "continue?" question, with the cost of the run so far spelled out. */
35
+ export function budgetPromptText(stats: BudgetExhaustedStats): string {
36
+ const k = (n: number): string => `${(n / 1000).toFixed(1)}k`;
37
+ return (
38
+ `Turn budget exhausted (${stats.turnsUsed} turns, ${k(stats.tokensIn)} in / ${k(stats.tokensOut)} out, ` +
39
+ `${stats.filesTouched.length} file(s) touched, ${stats.openObligations} open obligation(s)). ` +
40
+ `Continue with ${budgetExtraTurns(stats)} more turns?`
41
+ );
42
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Bottom-of-viewport dock built on DECSTBM scroll regions: content is fenced
3
+ * into rows [1, H - dockHeight] and the dock is painted with absolute cursor
4
+ * addressing below the scroll margin, so no amount of content output (or
5
+ * mouse wheel) can ever move it. Runs inside the alternate screen buffer the
6
+ * REPL enters at startup. This is the FrankenTUI/ratatui-inline technique;
7
+ * see also DECSTBM (CSI r).
8
+ */
9
+
10
+ import { truncateVisible } from '../agent/box.js';
11
+
12
+ const HIDE = '\x1b[?25l';
13
+ const SHOW = '\x1b[?25h';
14
+ /** Synchronized-output guards; terminals without support ignore them. */
15
+ const SYNC_ON = '\x1b[?2026h';
16
+ const SYNC_OFF = '\x1b[?2026l';
17
+ /** Reset the scroll region to the full screen. Homes the cursor (hence DECSC/DECRC around it). */
18
+ const REGION_RESET = '\x1b[r';
19
+ const SAVE = '\x1b7';
20
+ const RESTORE = '\x1b8';
21
+
22
+ export class TerminalDock {
23
+ /** Rows the dock currently occupies at the bottom of the screen. */
24
+ private dockH = 0;
25
+ /**
26
+ * True while the real cursor is parked at a caret position inside the dock
27
+ * (the DECSC slot then holds the content-region position to return to).
28
+ */
29
+ private parked = false;
30
+ /** Last set() payload, replayable after an external overdraw (animations). */
31
+ private last: { lines: string[]; caret?: { row: number; col: number } } | null = null;
32
+
33
+ constructor(private readonly out: NodeJS.WriteStream) {}
34
+
35
+ cols(): number {
36
+ return typeof this.out.columns === 'number' && this.out.columns ? this.out.columns : 80;
37
+ }
38
+
39
+ rows(): number {
40
+ return typeof this.out.rows === 'number' && this.out.rows ? this.out.rows : 24;
41
+ }
42
+
43
+ /**
44
+ * Replace the docked region. On a height change the content scroll region
45
+ * is re-fenced; same-height repaints are pure absolute-addressed writes and
46
+ * can never scroll anything. With `caret` (dock-relative 1-based row and
47
+ * column) the real terminal cursor is left parked there, Claude Code-style.
48
+ */
49
+ set(lines: string[], caret?: { row: number; col: number }): void {
50
+ if (!lines.length) {
51
+ this.release();
52
+ return;
53
+ }
54
+ const h = this.rows();
55
+ const shown = lines.slice(0, Math.max(1, h - 3)).map((l) => truncateVisible(l, this.cols() - 1));
56
+ const newH = shown.length;
57
+
58
+ let seq = SYNC_ON + HIDE;
59
+ if (this.parked) {
60
+ // Return to the content position first so the SAVE/RESTORE pairs below
61
+ // keep preserving it, not the old caret.
62
+ seq += RESTORE;
63
+ this.parked = false;
64
+ }
65
+ if (this.dockH === 0) {
66
+ // First activation: free the bottom rows by scrolling exactly as much
67
+ // as needed (preserves the banner above), then fence content into
68
+ // [1, h - newH]. DECSTBM homes the cursor; save/restore keeps it.
69
+ seq += SAVE + REGION_RESET + RESTORE;
70
+ seq += '\n'.repeat(newH) + `\x1b[${newH}A`;
71
+ seq += SAVE + `\x1b[1;${h - newH}r` + RESTORE;
72
+ } else if (newH !== this.dockH) {
73
+ // Height change while active (menu open/close): overlay mode. Re-fence
74
+ // and clear any rows freed by a shrink; never scroll content.
75
+ seq += SAVE + `\x1b[1;${h - newH}r`;
76
+ for (let r = h - Math.max(this.dockH, newH) + 1; r <= h - newH; r++) {
77
+ seq += `\x1b[${r};1H\x1b[2K`;
78
+ }
79
+ seq += RESTORE;
80
+ }
81
+ seq += SAVE;
82
+ for (let i = 0; i < newH; i++) {
83
+ seq += `\x1b[${h - newH + 1 + i};1H\x1b[2K${shown[i]!}`;
84
+ }
85
+ seq += RESTORE;
86
+ if (caret) {
87
+ const row = Math.min(h, h - newH + Math.max(1, caret.row));
88
+ const col = Math.max(1, Math.min(this.cols(), caret.col));
89
+ seq += SAVE + `\x1b[${row};${col}H`;
90
+ this.parked = true;
91
+ }
92
+ seq += SHOW + SYNC_OFF;
93
+ this.out.write(seq);
94
+ this.dockH = newH;
95
+ this.last = caret ? { lines, caret } : { lines };
96
+ }
97
+
98
+ /** Replay the last set() (e.g. after an animation overdrew part of the screen). */
99
+ repaint(): void {
100
+ if (this.last) this.set(this.last.lines, this.last.caret);
101
+ }
102
+
103
+ /** Rows available to content above the dock. */
104
+ contentRows(): number {
105
+ return Math.max(1, this.rows() - this.dockH);
106
+ }
107
+
108
+ /**
109
+ * Absolute-paint the whole content region (rows 1..contentRows) from the
110
+ * given lines — used by history scrolling to show any window of the session
111
+ * log. Does not touch the DECSC slot or the fence; callers repaint() the
112
+ * dock afterwards to restore the caret.
113
+ */
114
+ paintContent(lines: string[]): void {
115
+ const rowsN = this.contentRows();
116
+ const shown = lines.slice(-rowsN).map((l) => truncateVisible(l, this.cols() - 1));
117
+ let seq = SYNC_ON + HIDE;
118
+ for (let r = 1; r <= rowsN; r++) {
119
+ seq += `\x1b[${r};1H\x1b[2K${shown[r - 1] ?? ''}`;
120
+ }
121
+ seq += SYNC_OFF;
122
+ this.out.write(seq);
123
+ }
124
+
125
+ /**
126
+ * Write a scrollback line above the dock. With the fence in place a plain
127
+ * write cannot touch the dock rows; if the cursor is parked at the caret,
128
+ * hop back to the content region for the write and re-park after.
129
+ */
130
+ log(line: string): void {
131
+ if (!this.parked) {
132
+ this.out.write(line + '\n');
133
+ return;
134
+ }
135
+ this.out.write(SYNC_ON + RESTORE + line + '\n' + SAVE + SYNC_OFF);
136
+ // The slot now holds the advanced content position; the caret will be
137
+ // re-established by the next set() with a caret argument.
138
+ this.parked = false;
139
+ }
140
+
141
+ /** Drop the fence, clear the dock rows, and restore the cursor. */
142
+ release(): void {
143
+ if (!this.dockH) {
144
+ this.out.write(SHOW);
145
+ return;
146
+ }
147
+ const h = this.rows();
148
+ let seq = SYNC_ON;
149
+ if (this.parked) {
150
+ seq += RESTORE;
151
+ this.parked = false;
152
+ }
153
+ seq += SAVE + REGION_RESET + RESTORE;
154
+ seq += SAVE;
155
+ for (let r = h - this.dockH + 1; r <= h; r++) seq += `\x1b[${r};1H\x1b[2K`;
156
+ seq += RESTORE + SHOW + SYNC_OFF;
157
+ this.out.write(seq);
158
+ this.dockH = 0;
159
+ this.last = null;
160
+ }
161
+ }