@hncode/hncode 0.4.2 → 0.5.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 (60) hide show
  1. package/README.md +1 -15
  2. package/README_CN.md +1 -15
  3. package/bin/hncode +22 -9
  4. package/package.json +3 -2
  5. package/src/agent.js +145 -33
  6. package/src/bootstrap.js +56 -0
  7. package/src/btw-panel.js +224 -0
  8. package/src/cache.js +3 -2
  9. package/src/cell-buffer.js +173 -0
  10. package/src/ci.js +4 -1
  11. package/src/clipboard.js +202 -43
  12. package/src/colors.js +175 -30
  13. package/src/config.js +376 -34
  14. package/src/context-bar.js +237 -0
  15. package/src/doctor.js +320 -0
  16. package/src/experiments.js +122 -0
  17. package/src/file-history.js +112 -0
  18. package/src/file-tree.js +255 -0
  19. package/src/flush-tick.js +56 -0
  20. package/src/fork.js +44 -0
  21. package/src/fx.js +148 -0
  22. package/src/index.js +123 -11
  23. package/src/keybindings.js +229 -0
  24. package/src/llm.js +355 -34
  25. package/src/lsp.js +401 -0
  26. package/src/notebook.js +232 -0
  27. package/src/output-styles.js +183 -0
  28. package/src/patch.js +248 -0
  29. package/src/permissions.js +130 -1
  30. package/src/plan.js +3 -2
  31. package/src/plugin.js +611 -58
  32. package/src/prompt-presets.js +1 -1
  33. package/src/registry-browser.js +31 -13
  34. package/src/remote.js +20 -2
  35. package/src/rollout.js +151 -0
  36. package/src/row-cache.js +143 -0
  37. package/src/schedule.js +294 -0
  38. package/src/session-approvals.js +86 -0
  39. package/src/session.js +178 -8
  40. package/src/shell-safety.js +473 -0
  41. package/src/side-thread.js +240 -0
  42. package/src/skills.js +27 -0
  43. package/src/stream-buffer.js +350 -0
  44. package/src/subagent-control.js +160 -0
  45. package/src/teleport.js +260 -0
  46. package/src/term-caps.js +411 -0
  47. package/src/term.js +283 -8
  48. package/src/tools/agent-control.js +98 -0
  49. package/src/tools/agent-swarm.js +11 -2
  50. package/src/tools/agent.js +35 -1
  51. package/src/tools/diagnostics.js +175 -0
  52. package/src/tools/edit.js +25 -10
  53. package/src/tools/grep.js +62 -20
  54. package/src/tools/ignore.js +38 -14
  55. package/src/tools/index.js +10 -0
  56. package/src/tools/notebook.js +113 -0
  57. package/src/tools/read-media-file.js +10 -3
  58. package/src/tools/shell.js +34 -6
  59. package/src/tui.js +5219 -792
  60. package/src/web-public/app.js +7 -0
package/src/clipboard.js CHANGED
@@ -7,6 +7,27 @@
7
7
  // on an already-running shell costs ~100-230ms instead of seconds.
8
8
  // If the helper is unusable, fall back to a one-shot PowerShell.
9
9
  // Non-Windows paths (pbcopy/pbpaste/xclip) are cheap enough to run per call.
10
+ //
11
+ // THREE THINGS MADE THE FIRST PASTE FAIL, all measured on the shipping code:
12
+ // * The helper was "ready" the instant `spawn` returned, but the shell needs
13
+ // ~2-3s before it can answer anything. A read in that window was sent to a
14
+ // shell that had not started reading stdin yet, so it burned `helperRun`'s
15
+ // full 3s deadline and then fell through to the cold one-shot.
16
+ // * Worse, the warm sequence's sentinel was written WITHOUT a queued resolver
17
+ // ("so it drains from the buffer"). When a read pushed its resolver before
18
+ // that sentinel came back, the SENTINEL consumed the read's slot and resolved
19
+ // it with its own empty output — so the paste reported "Clipboard empty or
20
+ // unavailable" while the real answer (with the actual clipboard content) was
21
+ // dropped on the floor. Reproduced deliberately: the read got "", and the
22
+ // payload arrived afterwards with nobody left to receive it.
23
+ // * The fallback ran `powershell` (5.1) with an 8s timeout. Loading
24
+ // WinForms + Drawing there measured 3.8s idle and 11.5s on a busy machine —
25
+ // past the timeout, so `execSync` threw and the paste reported "unavailable"
26
+ // for a clipboard that was fine. `pwsh` does the same work in 1.9s.
27
+ // The warm-up also used to spawn a THROWAWAY shell (`pwsh -Command exit`) through
28
+ // `execSync` to decide which exe to use, blocking the TUI's startup path for ~1.9s
29
+ // before its first paint. The exe is discovered from the spawn's own `error` event
30
+ // now, so nothing blocks.
10
31
 
11
32
  import cp from 'node:child_process';
12
33
  import fs from 'node:fs';
@@ -16,8 +37,23 @@ import path from 'node:path';
16
37
  const SENT = '@@HNCODE_CLIP@@';
17
38
  let helper = null; // long-lived PowerShell child
18
39
  let helperBuf = ''; // stdout accumulated since the last sentinel
19
- let helperQueue = []; // pending read() resolvers
40
+ let helperQueue = []; // pending read() resolvers, ONE per sentinel written
20
41
  let helperBusy = false; // one command in flight at a time
42
+ let helperReady = false; // the warm sequence has answered: the shell can take commands
43
+
44
+ /** How long a read will wait for the helper to finish warming. It is already coming
45
+ * up, and waiting for a ~10ms answer beats the multi-second cold fallback. */
46
+ const HELPER_READY_MS = 6000;
47
+
48
+ /** Deadline for ONE command on a warm shell. Only reached when the shell has already
49
+ * answered, so it is a hang detector, not a warm-up budget. */
50
+ const HELPER_CMD_MS = 3000;
51
+
52
+ /** Deadline for the cold one-shot fallback. Raised from 8s: loading WinForms and
53
+ * Drawing in powershell 5.1 measured 11.5s under load, and the old timeout turned
54
+ * that into a spurious "clipboard unavailable". This is the last resort, so it is
55
+ * allowed to be slow — failing here helps nobody. */
56
+ const ONESHOT_MS = 20000;
21
57
 
22
58
  function win() { return process.platform === 'win32'; }
23
59
 
@@ -39,26 +75,95 @@ export function copyText(text) {
39
75
  return true;
40
76
  }
41
77
 
78
+ // Which PowerShell the helper runs. `pwsh` (7) starts far faster than the built-in
79
+ // `powershell` (5.1) — measured 1.9s vs 11.5s for the same image probe — and the image
80
+ // path needs the WinForms/Drawing assemblies. This is only the PREFERRED name: it is
81
+ // resolved from the spawn's own `error` event (see startHelper), so discovering that a
82
+ // box has no pwsh costs nothing instead of blocking the TUI's startup path with a
83
+ // throwaway shell.
84
+ let _psExe = 'pwsh';
85
+
86
+ /** One-shot PowerShell, preferring the exe the helper resolved. Returns stdout, or
87
+ * null when neither exe could answer. A machine without `pwsh` has to be able to
88
+ * fall back WITHOUT the startup-time probe that used to decide this. */
89
+ function runPowershell(args, opts) {
90
+ const tried = [];
91
+ for (const exe of [_psExe, 'powershell']) {
92
+ if (tried.includes(exe)) continue;
93
+ tried.push(exe);
94
+ try { return cp.execSync(`${exe} ${args}`, opts); } catch { /* try the next exe */ }
95
+ }
96
+ return null;
97
+ }
98
+
42
99
  // ------------------------------------------------------- PowerShell helper
43
100
  export function warmClipboard() {
44
101
  if (!win() || helper) return;
102
+ startHelper(_psExe);
103
+ }
104
+
105
+ /** Spawn the long-lived shell. `exe` is tried first; if that exe does not exist,
106
+ * the spawn reports ENOENT on its `error` event and this retries with 5.1. */
107
+ function startHelper(exe) {
45
108
  try {
46
- helper = cp.spawn('powershell', ['-NoProfile', '-NoLogo', '-Command', '-'], { windowsHide: true, stdio: ['pipe', 'pipe', 'ignore'] });
47
- helper.stdout.on('data', (d) => {
48
- helperBuf += d.toString('binary');
49
- let i;
50
- while ((i = helperBuf.indexOf(SENT)) >= 0) {
51
- const chunk = helperBuf.slice(0, i);
52
- helperBuf = helperBuf.slice(i + SENT.length);
53
- const w = helperQueue.shift();
54
- helperBusy = false;
55
- if (w) w(chunk);
56
- }
57
- });
58
- helper.on('exit', () => { helper = null; helperBusy = false; });
59
- helper.on('error', () => { helper = null; helperBusy = false; });
60
- helper.stdin.write('$ProgressPreference = "SilentlyContinue"\n');
61
- } catch { helper = null; }
109
+ helper = cp.spawn(exe, ['-NoProfile', '-NoLogo', '-Command', '-'], { windowsHide: true, stdio: ['pipe', 'pipe', 'ignore'] });
110
+ } catch { helper = null; return; }
111
+ helperReady = false;
112
+ helper.on('error', () => {
113
+ const missing = helper;
114
+ dropHelper();
115
+ // `pwsh` absent (or unusable): fall back to the built-in shell once.
116
+ if (exe !== 'powershell' && missing) { _psExe = 'powershell'; warmClipboard(); }
117
+ });
118
+ helper.on('exit', () => dropHelper());
119
+ helper.stdout.on('data', (d) => {
120
+ helperBuf += d.toString('binary');
121
+ let i;
122
+ while ((i = helperBuf.indexOf(SENT)) >= 0) {
123
+ const chunk = helperBuf.slice(0, i);
124
+ helperBuf = helperBuf.slice(i + SENT.length);
125
+ const w = helperQueue.shift();
126
+ helperBusy = false;
127
+ if (w) w(chunk);
128
+ }
129
+ });
130
+ helper.stdin.write('$ProgressPreference = "SilentlyContinue"\n');
131
+ // Pre-load the image assemblies ONCE, at warm time. Loading them lazily inside
132
+ // the probe cost ~3.6s on 5.1 — longer than the probe timeout — so the FIRST
133
+ // image paste always failed. Doing it up front moves that cost off the paste
134
+ // path entirely (the shell is warmed at TUI start, not on a keypress).
135
+ //
136
+ // This sentinel is QUEUED like every other command answer, and that is the whole
137
+ // point: written without a resolver it used to be claimed by whatever read pushed
138
+ // its resolver first, which handed that read THIS empty output and dropped the
139
+ // read's own answer — the first paste after startup reported "Clipboard empty or
140
+ // unavailable" for a clipboard that had content. The queued resolver here is what
141
+ // marks the shell as ready, so one sentinel still has exactly one owner.
142
+ helperQueue.push(() => { helperReady = true; });
143
+ helper.stdin.write("try { Add-Type -AssemblyName System.Windows.Forms; Add-Type -AssemblyName System.Drawing } catch {}\n'" + SENT + "'\n");
144
+ }
145
+
146
+ /** Tear down the helper and release every waiter. A read must never be left hanging on
147
+ * a shell that has died — it has a cold fallback to get to. */
148
+ function dropHelper() {
149
+ helper = null;
150
+ helperBusy = false;
151
+ helperReady = false;
152
+ helperBuf = '';
153
+ const waiting = helperQueue;
154
+ helperQueue = [];
155
+ for (const w of waiting) w(null);
156
+ }
157
+
158
+ /** Resolve once the shell can take commands, or false when it never got there. */
159
+ async function waitHelperReady(ms) {
160
+ const deadline = Date.now() + ms;
161
+ while (Date.now() < deadline) {
162
+ if (helperReady) return true;
163
+ if (!helper) return false;
164
+ await new Promise((r) => setTimeout(r, 25));
165
+ }
166
+ return helperReady;
62
167
  }
63
168
 
64
169
  function helperRun(script, timeoutMs) {
@@ -78,10 +183,11 @@ function helperRun(script, timeoutMs) {
78
183
  // text we read base64 ASCII and decode it ourselves.
79
184
  function readOnce() {
80
185
  if (win()) {
81
- const b64 = cp.execSync(
82
- 'powershell -NoProfile -Command "[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes((Get-Clipboard -Raw) + \'\'))"',
83
- { encoding: 'ascii', windowsHide: true, timeout: 8000 },
186
+ const b64 = runPowershell(
187
+ '-NoProfile -Command "[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes((Get-Clipboard -Raw) + \'\'))"',
188
+ { encoding: 'ascii', windowsHide: true, timeout: ONESHOT_MS },
84
189
  );
190
+ if (b64 == null) throw new Error('no powershell');
85
191
  const s = String(b64).replace(/[^A-Za-z0-9+/=]/g, '');
86
192
  return Buffer.from(s, 'base64').toString('utf8');
87
193
  }
@@ -91,15 +197,20 @@ function readOnce() {
91
197
 
92
198
  // Async read: uses the warm helper when available. Returns { text, via } so the
93
199
  // caller can tell the user which path answered.
94
- export async function readText(timeoutMs = 3000) {
95
- if (win() && helper && !helperBusy) {
96
- helperBusy = true;
97
- const out = await helperRun('[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes((Get-Clipboard -Raw) + ""))', timeoutMs);
98
- if (out != null) {
99
- const b64 = out.replace(/\r/g, '').split('\n').filter((l) => l.trim()).pop() || '';
100
- try { return { text: Buffer.from(b64.trim(), 'base64').toString('utf8'), via: 'helper' }; } catch {}
200
+ export async function readText(timeoutMs = HELPER_CMD_MS) {
201
+ if (win()) {
202
+ warmClipboard();
203
+ // Same readiness gate as the probe: a read sent to a shell that has not finished
204
+ // warming is answered by nothing, and the cold path is the expensive one.
205
+ if (helper && await waitHelperReady(HELPER_READY_MS)) {
206
+ helperBusy = true;
207
+ const out = await helperRun('[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes((Get-Clipboard -Raw) + ""))', timeoutMs);
208
+ if (out != null) {
209
+ const b64 = out.replace(/\r/g, '').split('\n').filter((l) => l.trim()).pop() || '';
210
+ try { return { text: Buffer.from(b64.trim(), 'base64').toString('utf8'), via: 'helper' }; } catch {}
211
+ }
212
+ helperBusy = false;
101
213
  }
102
- helperBusy = false;
103
214
  }
104
215
  try { return { text: readOnce(), via: 'oneshot' }; } catch { return { text: '', via: 'none' }; }
105
216
  }
@@ -125,13 +236,21 @@ export async function readText(timeoutMs = 3000) {
125
236
  // PowerShell expression (silently returned nothing), which made FILE pastes fall
126
237
  // back to the slow cold spawn. Emitting one F:: line per file is simpler and
127
238
  // also supports multi-file copies.
239
+ //
240
+ // The image branch uses the .NET WinForms clipboard, NOT `Get-Clipboard -Image`:
241
+ // that parameter only exists on some builds (and is absent even from the pwsh 7
242
+ // in use here), so it silently failed everywhere and image paste never worked.
243
+ // `[Windows.Forms.Clipboard]::GetImage()` exists on Windows PowerShell 5.1 AND
244
+ // pwsh 7 — verified on 5.1 — so it is the portable way to read a copied image.
128
245
  const CLIP_PROBE = [
129
246
  "$ErrorActionPreference='SilentlyContinue'",
130
247
  "$f=Get-Clipboard -Format FileDropList",
131
248
  "if($f -and $f.Count -gt 0){$f|ForEach-Object{'F::'+$_.FullName};return}",
132
249
  "$t=Get-Clipboard -Raw",
133
250
  "if($t){'T::'+[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($t));return}",
134
- "$i=Get-Clipboard -Image",
251
+ // The assemblies are loaded once by warmClipboard(); no Add-Type here, so the
252
+ // probe stays fast even the first time an image is on the clipboard.
253
+ "$i=[System.Windows.Forms.Clipboard]::GetImage()",
135
254
  "if($null -ne $i){$p=Join-Path $env:TEMP ('hncode-paste-'+[guid]::NewGuid().ToString('N')+'.png');$i.Save($p,[System.Drawing.Imaging.ImageFormat]::Png);'I::'+$p}",
136
255
  ].join('; ');
137
256
 
@@ -142,7 +261,7 @@ function parseClipResult(out) {
142
261
  const fileLines = lines.filter((l) => l.startsWith('F::'));
143
262
  if (fileLines.length) return { text: fileLines.map((l) => l.slice(3).trim()).join('\n'), via: 'files' };
144
263
  const imgLine = lines.find((l) => l.startsWith('I::'));
145
- if (imgLine) return { text: imgLine.slice(3).trim(), via: 'image' };
264
+ if (imgLine) { const p = imgLine.slice(3).trim(); trackPastedImage(p); return { text: p, via: 'image' }; }
146
265
  const txtLine = lines.find((l) => l.startsWith('T::'));
147
266
  if (txtLine) {
148
267
  try { return { text: Buffer.from(txtLine.slice(3).trim(), 'base64').toString('utf8'), via: 'text' }; }
@@ -151,18 +270,53 @@ function parseClipResult(out) {
151
270
  return null;
152
271
  }
153
272
 
273
+ // ---- pasted-image lifecycle ------------------------------------------------
274
+ // A pasted image is written to a temp .png (the model needs a PATH to hand to
275
+ // ReadMediaFile), so something has to remove it again or the temp dir fills up
276
+ // one screenshot at a time. Every path the probe reports is remembered here and
277
+ // the TUI deletes the whole set on exit — see cleanupPastedImages().
278
+ const pastedImages = new Set();
279
+
280
+ function trackPastedImage(p) {
281
+ if (p) pastedImages.add(p);
282
+ }
283
+
284
+ /**
285
+ * Delete every image this process wrote to a temp file. Safe to call from a
286
+ * `process.on('exit')` handler: it is fully synchronous and swallows errors (a
287
+ * locked file must not turn a clean exit into a failed one).
288
+ * Returns the number of files removed.
289
+ */
290
+ export function cleanupPastedImages() {
291
+ let removed = 0;
292
+ for (const p of pastedImages) {
293
+ try { fs.rmSync(p, { force: true }); removed++; } catch { /* locked/missing: ignore */ }
294
+ }
295
+ pastedImages.clear();
296
+ return removed;
297
+ }
298
+
299
+ /** How many pasted images are still on disk (diagnostics/tests). */
300
+ export function pastedImageCount() { return pastedImages.size; }
301
+
154
302
  // Run the probe on the warm helper (fast path). Resolves null if it is not ready.
155
303
  async function readViaHelper() {
156
304
  warmClipboard();
157
305
  if (!helper) return null;
158
- // Wait briefly for any in-flight command (e.g. a concurrent readText) to drain,
159
- // so we still hit the warm shell instead of falling back to a cold spawn.
306
+ // Wait for the shell to finish warming FIRST. The warm sequence is already in
307
+ // flight, so this waits for something that is going to happen anyway; sending the
308
+ // probe early meant it was answered by nobody, which cost the full command
309
+ // deadline and then fell through to the cold one-shot — several seconds for a
310
+ // clipboard the helper could have read in ~10ms.
311
+ if (!helperReady && !await waitHelperReady(HELPER_READY_MS)) return null;
312
+ // Then wait briefly for any in-flight command (e.g. a concurrent readText) to
313
+ // drain, so we still hit the warm shell instead of falling back to a cold spawn.
160
314
  for (let i = 0; i < 20 && helperBusy; i++) {
161
315
  await new Promise((r) => setTimeout(r, 25));
162
316
  }
163
- if (helperBusy) return null;
317
+ if (helperBusy || !helper) return null;
164
318
  helperBusy = true;
165
- const out = await helperRun(CLIP_PROBE, 3000);
319
+ const out = await helperRun(CLIP_PROBE, HELPER_CMD_MS);
166
320
  return out == null ? null : parseClipResult(out);
167
321
  }
168
322
 
@@ -171,16 +325,18 @@ export function readClipboardContent() {
171
325
  try { return { text: readOnce(), via: 'oneshot' }; } catch { return { text: '', via: 'none' }; }
172
326
  }
173
327
  // Synchronous API is required by the caller, so use a one-shot PowerShell here
174
- // and let the async path (readClipboardContentAsync) use the warm helper.
328
+ // and let the async path (readClipboardContentAsync) use the warm helper. The exe
329
+ // is the one the helper resolved — `powershell` 5.1 measured 11.5s on the same
330
+ // probe that `pwsh` did in 1.9s, and the old 8s deadline turned that into a
331
+ // spurious "clipboard unavailable".
175
332
  const scriptFile = clipScriptPath();
176
333
  if (!scriptFile) return { text: '', via: 'none' };
177
- try {
178
- const out = cp.execSync(
179
- `powershell -NoProfile -ExecutionPolicy Bypass -File "${scriptFile}"`,
180
- { encoding: 'utf8', windowsHide: true, timeout: 8000 },
181
- );
182
- return parseClipResult(out) || { text: '', via: 'none' };
183
- } catch { return { text: '', via: 'none' }; }
334
+ const out = runPowershell(
335
+ `-NoProfile -ExecutionPolicy Bypass -File "${scriptFile}"`,
336
+ { encoding: 'utf8', windowsHide: true, timeout: ONESHOT_MS },
337
+ );
338
+ if (out == null) return { text: '', via: 'none' };
339
+ return parseClipResult(out) || { text: '', via: 'none' };
184
340
  }
185
341
 
186
342
  // Async version that prefers the warm helper (~10ms) over a cold spawn (~2.9s).
@@ -206,7 +362,10 @@ function clipScriptPath() {
206
362
  'if ($files -and $files.Count -gt 0) { $files | ForEach-Object { "F::" + $_.FullName }; exit }',
207
363
  '$txt = Get-Clipboard -Raw',
208
364
  'if ($txt) { "T::" + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($txt)); exit }',
209
- '$img = Get-Clipboard -Image',
365
+ // .NET clipboard, not `Get-Clipboard -Image` (see the CLIP_PROBE note).
366
+ 'Add-Type -AssemblyName System.Windows.Forms',
367
+ 'Add-Type -AssemblyName System.Drawing',
368
+ '$img = [System.Windows.Forms.Clipboard]::GetImage()',
210
369
  'if ($null -ne $img) {',
211
370
  ' $path = Join-Path $env:TEMP ("hncode-paste-" + [guid]::NewGuid().ToString("N") + ".png")',
212
371
  ' $img.Save($path, [System.Drawing.Imaging.ImageFormat]::Png)',
package/src/colors.js CHANGED
@@ -10,18 +10,42 @@ export const TRUECOLOR = DEPTH >= 24 || process.env.COLORTERM === 'truecolor' ||
10
10
  function rgb(r, g, b) { return TRUECOLOR ? `\x1b[38;2;${r};${g};${b}m` : `\x1b[38;5;${ansi256(r, g, b)}m`; }
11
11
  function brgb(r, g, b) { return TRUECOLOR ? `\x1b[48;2;${r};${g};${b}m` : `\x1b[48;5;${ansi256(r, g, b)}m`; }
12
12
 
13
- // Quantize a truecolor triple to the nearest 256-color xterm index.
13
+ // Quantize a truecolor triple to the nearest 256-colour xterm index.
14
+ //
15
+ // Two different palettes live in xterm-256 and they must not be confused:
16
+ // * the 6x6x6 COLOUR CUBE (16..231): each channel has 6 levels — 0, then
17
+ // 95,135,175,215,255 — so the step is 40 above a floor of 35, not 51;
18
+ // * the GREY RAMP (232..255): 24 levels from rgb(8) to rgb(238), steps of 10.
19
+ //
20
+ // The grey case used to be `232 + Math.round(v / 51)`, which collapses a whole range of
21
+ // greys onto a few ramp entries: rgb(46,46,46) — the panel surface — quantised to 233,
22
+ // i.e. rgb(18,18,18), three steps darker than asked for. The panel then read as a
23
+ // near-black hole instead of a raised surface. Quantising against the ramp's own step
24
+ // fixes that.
14
25
  function ansi256(r, g, b) {
15
- const ri = Math.round(r / 51), gi = Math.round(g / 51), bi = Math.round(b / 51);
16
- if (ri === gi && gi === bi) {
17
- if (ri === 0) return 16;
18
- if (ri === 255 / 51) return 232 + ri;
19
- return 232 + ri; // grayscale ramp
26
+ const cube = (v) => (v < 48 ? 0 : v < 115 ? 1 : Math.min(5, Math.round((v - 35) / 40)));
27
+ // A triple that is (nearly) neutral belongs on the grey ramp, which has finer steps
28
+ // than the cube's 6 levels: rgb(46,46,46) is not representable in the cube at all.
29
+ const max = Math.max(r, g, b);
30
+ const min = Math.min(r, g, b);
31
+ if (max - min <= 8) {
32
+ const v = (r + g + b) / 3;
33
+ const n = Math.max(0, Math.min(23, Math.round((v - 8) / 10)));
34
+ return 232 + n;
20
35
  }
21
- return 16 + 36 * ri + 6 * gi + bi;
36
+ return 16 + 36 * cube(r) + 6 * cube(g) + cube(b);
22
37
  }
23
38
 
24
39
  // Brand accent: cyan-blue.
40
+ // A BACKGROUND escape at the terminal's own colour depth: the exact triple under
41
+ // truecolor, the nearest palette index otherwise.
42
+ //
43
+ // Exported because a caller computing a NEW colour at paint time — the backdrop dimming
44
+ // does, when it pulls a fill toward the page — has to state its result in the same notation
45
+ // the untouched fills use. Emitting `48;2;…` unconditionally loses the fill entirely on a
46
+ // 256-colour terminal, which silently ignores what it does not implement.
47
+ export const bgRgb = brgb;
48
+
25
49
  export const TEAL = rgb(0, 184, 219);
26
50
  export const CYAN = rgb(0, 215, 255);
27
51
  export const BLUE = rgb(66, 135, 245);
@@ -36,12 +60,14 @@ const THEMES = {
36
60
  auto: {
37
61
  fg: [0, 184, 219], bg: [0, 18, 26],
38
62
  teal: [0, 184, 219], cyan: [0, 215, 255], blue: [66, 135, 245], border: [70, 130, 180],
39
- hover: [190, 245, 255], selBg: [0, 70, 84]
63
+ hover: [190, 245, 255], selBg: [0, 70, 84],
64
+ surface: [46, 46, 46],
40
65
  },
41
66
  dark: {
42
67
  fg: [255, 255, 255], bg: [0, 18, 26], // White foreground for normal text
43
68
  teal: [0, 184, 219], cyan: [0, 215, 255], blue: [66, 135, 245], border: [70, 130, 180],
44
- hover: [190, 245, 255], selBg: [0, 70, 84]
69
+ hover: [190, 245, 255], selBg: [0, 70, 84],
70
+ surface: [46, 46, 46],
45
71
  },
46
72
  light: {
47
73
  fg: [26, 26, 26], bg: [255, 255, 255],
@@ -49,8 +75,71 @@ const THEMES = {
49
75
  hover: [0, 78, 102], selBg: [200, 230, 239],
50
76
  red: [179, 38, 30], green: [30, 122, 60], yellow: [160, 120, 0], orange: [178, 90, 0],
51
77
  white: [26, 26, 26], gray: [85, 85, 85],
78
+ // A LIGHT surface is a grey that is darker than the white page but far lighter than
79
+ // the dark themes' — a panel has to read as raised in whichever direction the theme
80
+ // goes, and reusing #2e2e2e on white would be a black box.
81
+ surface: [232, 232, 232],
82
+ },
83
+ // The four below are the same SHAPE as `dark` with the accent hue moved, so every
84
+ // derived colour in `setTheme` (the scrollbar shades, hover, and the light/dark branch
85
+ // test) keeps working with no special case. Each keeps ONE accent family varying only in
86
+ // lightness, because the context bar's five fills read as a gradient and a second hue in
87
+ // that ramp makes the bar look broken.
88
+ nord: {
89
+ fg: [216, 222, 233], bg: [46, 52, 64],
90
+ teal: [136, 192, 208], cyan: [143, 188, 187], blue: [129, 161, 193], border: [94, 129, 172],
91
+ hover: [236, 239, 244], selBg: [59, 66, 82],
92
+ red: [191, 97, 106], green: [163, 190, 140], yellow: [235, 203, 139], orange: [208, 135, 112],
93
+ white: [236, 239, 244], gray: [129, 135, 148],
94
+ surface: [59, 66, 82],
95
+ },
96
+ dracula: {
97
+ fg: [248, 248, 242], bg: [40, 42, 54],
98
+ teal: [139, 233, 253], cyan: [139, 233, 253], blue: [98, 114, 164], border: [98, 114, 164],
99
+ hover: [255, 121, 198], selBg: [68, 71, 90],
100
+ red: [255, 85, 85], green: [80, 250, 123], yellow: [241, 250, 140], orange: [255, 184, 108],
101
+ white: [248, 248, 242], gray: [98, 114, 164],
102
+ surface: [68, 71, 90],
103
+ },
104
+ gruvbox: {
105
+ fg: [235, 219, 178], bg: [40, 40, 40],
106
+ teal: [142, 192, 124], cyan: [131, 165, 152], blue: [69, 133, 136], border: [146, 131, 116],
107
+ hover: [250, 189, 47], selBg: [60, 56, 54],
108
+ red: [251, 73, 52], green: [184, 187, 38], yellow: [250, 189, 47], orange: [254, 128, 25],
109
+ white: [235, 219, 178], gray: [146, 131, 116],
110
+ surface: [60, 56, 54],
52
111
  },
112
+ mono: {
113
+ // No hue at all: every accent is a grey, so the UI is readable on a monochrome
114
+ // terminal or for anyone who needs colour off without losing the structure that
115
+ // colour was carrying. The lightness steps are what keep the context bar legible.
116
+ fg: [230, 230, 230], bg: [18, 18, 18],
117
+ teal: [190, 190, 190], cyan: [210, 210, 210], blue: [150, 150, 150], border: [110, 110, 110],
118
+ hover: [255, 255, 255], selBg: [60, 60, 60],
119
+ red: [200, 90, 90], green: [140, 190, 140], yellow: [200, 190, 120], orange: [210, 160, 110],
120
+ white: [240, 240, 240], gray: [130, 130, 130],
121
+ surface: [42, 42, 42],
122
+ },
123
+ };
124
+ /** Theme name -> one-line description, for the /theme picker. */
125
+ export const THEME_LABELS = {
126
+ auto: 'brand cyan-blue on the terminal’s own palette',
127
+ dark: 'dark teal background, brand accents',
128
+ light: 'for light terminals; darkened accents',
129
+ nord: 'cool blue-grey',
130
+ dracula: 'high-contrast purple and pink',
131
+ gruvbox: 'warm retro earth tones',
132
+ mono: 'greyscale, no hue',
53
133
  };
134
+
135
+ /** True when a theme paints on a light background. `setTheme` uses this too. */
136
+ export function isLightTheme(name) {
137
+ const t = THEMES[name];
138
+ return !!(t && Array.isArray(t.bg) && t.bg[0] > 128);
139
+ }
140
+
141
+ /** Whether a theme name is one we know. */
142
+ export function hasTheme(name) { return Object.prototype.hasOwnProperty.call(THEMES, name); }
54
143
  export const THEME_NAMES = Object.keys(THEMES);
55
144
 
56
145
  export const C = {
@@ -72,7 +161,33 @@ export const C = {
72
161
  bg: BG,
73
162
  bgTeal: brgb(0, 184, 219),
74
163
  bgDim: brgb(12, 24, 30),
75
- bgPanel: TRUECOLOR ? '\x1b[48;5;236m' : '\x1b[48;5;236m',
164
+ // Fills for the segmented context bar: five blues in one family, deepening towards
165
+ // black, plus a neutral free segment. Background colours, because the bar is read by
166
+ // FILL rather than by any text — a segment is usually only a few columns wide, so a
167
+ // letter inside it would be unreadable noise.
168
+ //
169
+ // The steps are deliberately narrow. Widening them makes the bar easier to tell apart
170
+ // at a glance but harder to keep a dark terminal readable, and the darkest member has
171
+ // to stay distinguishable from the page background it sits on.
172
+ ctxSystem: brgb(35, 48, 95), // deepest navy
173
+ ctxPrompt: brgb(43, 61, 120), // navy
174
+ ctxAssistant: brgb(52, 74, 146), // indigo
175
+ ctxThinking: brgb(77, 107, 254), // brand blue, the brightest used segment
176
+ ctxTools: brgb(90, 124, 255), // light blue
177
+ ctxFree: brgb(46, 46, 46), // neutral grey: "nothing here yet"
178
+ // The SURFACE colour for a floating panel (picker menus, dialogs). A NEUTRAL GREY,
179
+ // deliberately separate from the theme's `bg`:
180
+ //
181
+ // * the theme background is `[0, 18, 26]` — a dark TEAL. A raised surface should be
182
+ // neutral in every theme; only the ACCENTS carry the brand hue. Reusing `bg` for
183
+ // the panel made every popup read blue, which is not what a panel is.
184
+ // * `applyTheme` used to overwrite this with `t.bg`, so the value here only applied
185
+ // before a theme was set. It is no longer touched by the theme.
186
+ //
187
+ // xterm-256's grey ramp runs 232..255 from rgb(8) to rgb(238) in steps of 10, so 236
188
+ // is rgb(48,48,48) = #303030 — clearly lifted off a near-black terminal without
189
+ // washing the text out. Truecolor gets `#2e2e2e`, the same shade written exactly.
190
+ bgPanel: TRUECOLOR ? '\x1b[48;2;46;46;46m' : '\x1b[48;5;236m',
76
191
  border: BORDER,
77
192
  red: '\x1b[91m',
78
193
  green: '\x1b[92m',
@@ -107,16 +222,31 @@ export const C = {
107
222
  // a selection highlight so text after it keeps its own colour (a bare ESC[0m
108
223
  // would also reset the foreground, turning a cyan/white run grey).
109
224
  bgReset: '\x1b[49m',
110
- scrollTrack: '\x1b[90m', // dim gutter
111
- scrollThumb: TRUECOLOR ? rgb(0, 184, 219) : '\x1b[38;5;37m', // brand cyan-blue
112
- // Scrollbar interaction states:
113
- // hover -> a touch whiter/brighter than the brand colour
114
- // active -> BOLD + the brand colour (pressed/dragging)
115
- // The pressed state used to be a DARKER colour, which on a dark background
116
- // read as "the thumb disappeared". Making it bold-and-bright means a press is
117
- // always unmistakably visible. Idle swaps back to scrollThumb automatically.
118
- scrollThumbHover: TRUECOLOR ? rgb(120, 226, 245) : '\x1b[38;5;123m',
119
- scrollThumbActive: '\x1b[1m' + (TRUECOLOR ? rgb(0, 220, 255) : '\x1b[38;5;51m'),
225
+ // Scrollbar, styled after OpenTUI (what cline uses): a solid block thumb on a
226
+ // near-black track. Only the FOREGROUND colour is stateful — the track is always the
227
+ // background — because that is how OpenTUI's SliderRenderable paints a cell:
228
+ // foreground = thumb, background = track. A `█` covers both columns with the
229
+ // foreground, while `▀`/`▄` expose one half to the track, so the two colours must
230
+ // stay DISTINCT; giving a half-cell the thumb colour as its background is what made
231
+ // it read as a second square, and it also survived the modal backdrop's faint
232
+ // attribute, which fades the foreground only.
233
+ // track #252527 thumb #9a9ea3 (OpenTUI's defaults, inlined)
234
+ // hover #d2d6da active #ffffff (brighter thumb, not a new hue)
235
+ scrollTrackBg: TRUECOLOR ? brgb(0x25, 0x25, 0x27) : '\x1b[48;5;235m',
236
+ scrollTrackFg: TRUECOLOR ? rgb(0x25, 0x25, 0x27) : '\x1b[38;5;235m',
237
+ scrollThumbBg: TRUECOLOR ? brgb(0x9a, 0x9e, 0xa3) : '\x1b[48;5;246m',
238
+ scrollThumbFg: TRUECOLOR ? rgb(0x9a, 0x9e, 0xa3) : '\x1b[38;5;246m',
239
+ scrollThumbHoverBg: TRUECOLOR ? brgb(0xd2, 0xd6, 0xda) : '\x1b[48;5;252m',
240
+ scrollThumbHoverFg: TRUECOLOR ? rgb(0xd2, 0xd6, 0xda) : '\x1b[38;5;252m',
241
+ scrollThumbActiveBg: TRUECOLOR ? brgb(0xff, 0xff, 0xff) : '\x1b[48;5;231m',
242
+ scrollThumbActiveFg: TRUECOLOR ? rgb(0xff, 0xff, 0xff) : '\x1b[38;5;231m',
243
+ // FOREGROUND aliases kept for the todo/queue resize rule, which draws a `─` line
244
+ // and therefore needs a foreground, not the block's background pair.
245
+ scrollThumbActive: TRUECOLOR ? rgb(0xff, 0xff, 0xff) : '\x1b[38;5;231m',
246
+ scrollThumbHover: TRUECOLOR ? rgb(0xd2, 0xd6, 0xda) : '\x1b[38;5;252m',
247
+ // Legacy foreground forms, still referenced by the gutter-hover path.
248
+ scrollTrack: '\x1b[90m',
249
+ scrollThumb: TRUECOLOR ? rgb(0, 184, 219) : '\x1b[38;5;37m',
120
250
  // Hover highlight for clickable rows (menu items, picker options, form
121
251
  // fields, composer rows). An explicit bright foreground + BOLD rather than a
122
252
  // bare BOLD: BOLD only brightens the 16-colour palette (it does nothing to a
@@ -153,24 +283,39 @@ export function setTheme(name) {
153
283
  C.cyan = rgb(...t.cyan);
154
284
  C.blue = rgb(...t.blue);
155
285
  C.border = rgb(...t.border);
156
-
157
286
  // Update background colors
158
287
  C.bg = brgb(...t.bg);
159
288
  C.bgTeal = brgb(...t.teal);
160
- C.bgPanel = brgb(...t.bg);
289
+ // The panel surface is NEUTRAL GREY and comes from the theme's own `surface` triple,
290
+ // NOT from `bg`. It used to be set to `t.bg` (a dark teal), which made every popup
291
+ // carry a blue cast; a raised surface must be neutral, with only the accents following
292
+ // the brand hue. A light theme gets a light grey so the panel still reads as raised.
293
+ if (t.surface) C.bgPanel = brgb(...t.surface);
161
294
  C.selBg = brgb(...t.selBg);
162
295
 
163
- // Scrollbar colours. The active (pressed/dragging) thumb is BOLD + a brighter
164
- // shade of the theme colour. It used to be a DARKER shade, which on a dark
165
- // background read as "the thumb vanished" mid-drag.
166
- C.scrollTrack = '\x1b[90m'; // dim gutter
167
- C.scrollThumb = rgb(...t.teal);
168
- C.scrollThumbHover = rgb(...t.hover);
169
- C.scrollThumbActive = '\x1b[1m' + rgb(
296
+ // Scrollbar colours. The thumb is a BLOCK, so its colour goes on BOTH the
297
+ // foreground and the background (a half-cell `▀`/`▄` shows the foreground, a full
298
+ // `█` shows the background — they must match). Hover / drag are brighter shades
299
+ // of the same hue, applied to the same pair, so the whole thumb changes together.
300
+ const active = [
170
301
  Math.min(255, Math.round(t.teal[0] * 0.6 + 255 * 0.4)),
171
302
  Math.min(255, Math.round(t.teal[1] * 0.6 + 255 * 0.4)),
172
303
  Math.min(255, Math.round(t.teal[2] * 0.6 + 255 * 0.4)),
173
- );
304
+ ];
305
+ const hover = t.hover || [190, 245, 255];
306
+ C.scrollTrackFg = rgb(0x25, 0x25, 0x27);
307
+ C.scrollTrackBg = brgb(0x25, 0x25, 0x27);
308
+ C.scrollThumbFg = rgb(...t.teal);
309
+ C.scrollThumbBg = brgb(...t.teal);
310
+ C.scrollThumbHoverFg = rgb(...hover);
311
+ C.scrollThumbHoverBg = brgb(...hover);
312
+ C.scrollThumbActiveFg = rgb(...active);
313
+ C.scrollThumbActiveBg = brgb(...active);
314
+ // Foreground aliases for the todo/queue resize rule.
315
+ C.scrollTrack = '\x1b[90m';
316
+ C.scrollThumb = rgb(...t.teal);
317
+ C.scrollThumbHover = rgb(...hover);
318
+ C.scrollThumbActive = rgb(...active);
174
319
 
175
320
  // Hover stays visible on this theme's background (bright on dark, dark on light).
176
321
  C.hover = '\x1b[1m' + rgb(...(t.hover || [190, 245, 255]));