copperhead 0.7.0 → 0.8.1
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 +4 -6
- 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
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);
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|
package/src/util/dock.ts
ADDED
|
@@ -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
|
+
}
|