@hncode/hncode 0.5.6 → 0.6.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.
Files changed (59) hide show
  1. package/package.json +2 -3
  2. package/src/agent.js +67 -2
  3. package/src/clipboard.js +221 -33
  4. package/src/image-row.js +111 -0
  5. package/src/latex.js +1557 -0
  6. package/src/llm.js +19 -4
  7. package/src/mermaid/ansi.js +55 -0
  8. package/src/mermaid/canvas.js +449 -0
  9. package/src/mermaid/class-style.js +57 -0
  10. package/src/mermaid/css-colors.js +153 -0
  11. package/src/mermaid/diagrams/class.js +289 -0
  12. package/src/mermaid/diagrams/er.js +201 -0
  13. package/src/mermaid/diagrams/flowchart.js +488 -0
  14. package/src/mermaid/diagrams/gitgraph.js +205 -0
  15. package/src/mermaid/diagrams/mindmap.js +101 -0
  16. package/src/mermaid/diagrams/pie.js +104 -0
  17. package/src/mermaid/diagrams/sequence.js +282 -0
  18. package/src/mermaid/diagrams/state.js +292 -0
  19. package/src/mermaid/diagrams/timeline.js +99 -0
  20. package/src/mermaid/graph.js +138 -0
  21. package/src/mermaid/index.js +59 -0
  22. package/src/mermaid/labels.js +339 -0
  23. package/src/mermaid/layout-seq.js +225 -0
  24. package/src/mermaid/layout.js +1454 -0
  25. package/src/mermaid/registry.js +54 -0
  26. package/src/mermaid/source-box.js +102 -0
  27. package/src/mermaid/statements.js +235 -0
  28. package/src/mermaid/types.js +1 -0
  29. package/src/mermaid/width.js +29 -0
  30. package/src/screen-diff.js +512 -0
  31. package/src/screen-paint.js +128 -0
  32. package/src/screen.js +277 -0
  33. package/src/subagent-types.js +136 -147
  34. package/src/term-caps.js +10 -4
  35. package/src/tex/font.js +292 -0
  36. package/src/tex/fonts/LICENSE.txt +11 -0
  37. package/src/tex/fonts/STIX2Math.ttf +0 -0
  38. package/src/tex/index.js +293 -0
  39. package/src/tex/kitty.js +186 -0
  40. package/src/tex/layout.js +446 -0
  41. package/src/tex/parse.js +331 -0
  42. package/src/tex/raster.js +216 -0
  43. package/src/tex/render.js +167 -0
  44. package/src/tex/sixel.js +326 -0
  45. package/src/tex/svg.js +237 -0
  46. package/src/tool-result.js +2 -2
  47. package/src/tools/agent-swarm.js +3 -15
  48. package/src/tools/agent.js +454 -464
  49. package/src/tools/ask-user-question.js +195 -206
  50. package/src/tools/bash.js +39 -3
  51. package/src/tools/edit.js +10 -112
  52. package/src/tools/fetch-url.js +130 -120
  53. package/src/tools/goal.js +93 -0
  54. package/src/tools/index.js +2 -0
  55. package/src/tools/memory.js +80 -88
  56. package/src/tools/read.js +27 -23
  57. package/src/tools/todo.js +8 -13
  58. package/src/tools/validate-args.js +78 -0
  59. package/src/tui.js +1718 -585
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hncode/hncode",
3
- "version": "0.5.6",
3
+ "version": "0.6.1",
4
4
  "description": "hncode — A cyan-blue TUI AI coding agent with zero runtime dependencies.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -33,6 +33,5 @@
33
33
  "coding",
34
34
  "ai",
35
35
  "tui"
36
- ],
37
- "dependencies": {}
36
+ ]
38
37
  }
package/src/agent.js CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { LLM, setToolsList } from './llm.js';
6
6
  import { tools, combinedTools, getTool, llmTools } from './tools/index.js';
7
+ import { validateToolArgs } from './tools/validate-args.js';
7
8
  import { DELEGATION_TOOLS } from './subagent-types.js';
8
9
  import { estimateMessagesTokens, estimateRequestOverhead, estimateTokens, measureToolDefTokens, messageTokens, TokenLedger, forgetMessageTokens } from './term.js';
9
10
  import { runHooks, runPatch, runPatchSync } from './plugin.js';
@@ -72,10 +73,46 @@ const COMPACTION_INSTRUCTION = [
72
73
  '',
73
74
  'Be concise and proportional: a long multi-step task warrants detail, a nearly finished exchange needs only a sentence or two.',
74
75
  '',
75
- 'Respond with text only. Do not call any tools.',
76
+ 'Respond with text only. Do not call any tools. Any tool-call syntax in your answer is discarded and scores the handoff as failed.'
76
77
  ].join('\n');
77
78
 
78
79
  // Trailing note: the model should RESUME, not answer the summary.
80
+ // A summariser can answer with tool-call syntax even when no tools were offered — the format is in its
81
+ // training data, and a model interrupted mid-workflow reaches for it. Nothing was called, so the text is
82
+ // noise, and left in a handoff summary the next turn reads it as a step that happened.
83
+ //
84
+ // The shapes covered are the ones these models actually emit: a special-token block (`<|tool_call|>`), a
85
+ // fenced ```tool_call block, and a bare JSON object that claims a `name` and `arguments`. Only a block that
86
+ // occupies whole lines is removed, so prose that merely mentions calling a tool survives.
87
+ function stripToolCallText(text) {
88
+ if (typeof text !== 'string' || !text) return text || '';
89
+
90
+ // NOTHING WAS CALLED, SO THE BLOCK IS NOISE — but removing it outright can leave nothing at all.
91
+ //
92
+ // A summariser that answers ONLY with a tool call produced no summary: stripping to '' makes the caller
93
+ // treat the compaction as failed, and the fallback there is to drop the history. So the stripped text is
94
+ // measured: if it lost its substance, the original is kept VERBATIM with a note saying what it is. A
95
+ // confusing summary is recoverable; a discarded one is not.
96
+ let out = text;
97
+ // A special-token block: closed form, or an unterminated one running to the end of its line.
98
+ out = out.replace(/<\|tool_call\|>[^\n]*(?:<\|\/tool_call\|>)?/g, '');
99
+ // Fenced block form.
100
+ out = out.replace(/```tool_call[\s\S]*?```/g, '');
101
+ // A bare JSON object on its own lines that declares a tool invocation.
102
+ out = out.replace(/^\s*\{[^\n]*"name"\s*:\s*"[^"]+"[^\n]*"arguments"[^\n]*\}\s*$/gm, '');
103
+ out = out.replace(/\n{3,}/g, '\n\n').trim();
104
+
105
+ if (out.length > 0) return out;
106
+
107
+ // Every remaining character was a tool call: keep the original, marked for what it is.
108
+ return [
109
+ '(The summariser answered with a tool call instead of a summary. No tool was actually run — the text is',
110
+ 'reproduced below unchanged so nothing is lost. Treat it as a summary that was never written.)',
111
+ '',
112
+ text.trim(),
113
+ ].join('\n');
114
+ }
115
+
79
116
  const COMPACTION_CONTINUATION_TEXT = 'Context compaction is complete — continue the work that was in progress when it began.';
80
117
 
81
118
  // Clip to a TOKEN budget, counting non-ASCII as one token per character (the same
@@ -842,7 +879,20 @@ const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
842
879
  // reasoning model they can consume the whole output budget — which
843
880
  // returns an EMPTY summary, and an empty summary is what turns a
844
881
  // compaction into a silent loss of history.
845
- try { summary = await llm.requestText(summaryPrompt, { noReasoning: true, noTools: true }); } catch { summary = ''; }
882
+ try {
883
+ // A DECLARATION IS NEEDED FOR THE REFUSAL.
884
+ //
885
+ // `tool_choice: 'none'` is rejected by both providers unless a tool list accompanies it, and the
886
+ // summariser is a fresh instance with none. One inert definition is declared so the refusal can be
887
+ // expressed: the model then cannot produce a tool call, rather than being asked not to.
888
+ llm._tools = [{
889
+ name: 'noop',
890
+ description: 'Takes no action. Present only so the request can refuse tool use.',
891
+ parameters: { type: 'object', properties: {}, additionalProperties: false },
892
+ }];
893
+ const raw = await llm.requestText(summaryPrompt, { noReasoning: true, toolChoice: 'none' });
894
+ summary = stripToolCallText(raw);
895
+ } catch { summary = ''; }
846
896
  this._summaryLlm = null;
847
897
  }
848
898
  // Interrupted mid-summary: do NOT trim. Aborting yields an empty summary, and
@@ -1123,6 +1173,21 @@ const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
1123
1173
  return { ok: false, result, editDiff: null, media: false };
1124
1174
  }
1125
1175
 
1176
+ // THE ARGUMENTS MUST MATCH THE TOOL'S OWN SCHEMA.
1177
+ //
1178
+ // An unknown parameter used to be accepted in silence, and the model had no way to learn that the
1179
+ // argument it took trouble to compute was thrown away: it sends `line_offset` to a tool that takes
1180
+ // `start_line`, gets a plausible answer computed from defaults, and concludes the tool is broken.
1181
+ // Refusing the call names the key and lists the accepted ones, which is the only thing that stops the
1182
+ // same mistake being retried with a different spelling.
1183
+ const argError = validateToolArgs(tc.args, tool.parameters);
1184
+ if (argError) {
1185
+ result = `Error: ${tc.name}: ${argError}`;
1186
+ this.onEvent({ type: 'tool_use', id: tc.id, name: tc.name, args: tc.args });
1187
+ return { ok: false, result, editDiff: null, media: false };
1188
+ }
1189
+
1190
+
1126
1191
  // SHELL HOOK: PreToolUse. A non-zero exit blocks the tool, and its
1127
1192
  // message goes back to the model in place of the tool result — that is
1128
1193
  // the whole point of a pre-hook (e.g. refuse a write that fails a lint
package/src/clipboard.js CHANGED
@@ -62,33 +62,51 @@ function win() { return process.platform === 'win32'; }
62
62
  /**
63
63
  * Put text on the clipboard.
64
64
  *
65
- * WINDOWS: `Set-Clipboard` through PowerShell, NOT `clip.exe`.
65
+ * WINDOWS: `clip.exe` on STDIN, with the PowerShell path as the fallback.
66
66
  *
67
- * `clip.exe` takes UTF-16LE and needed a byte order mark to be read correctly — without it
68
- * non-ASCII arrived as mojibake (`中文测试` came back as `-N噀Km諎`, measured). But the mark is then
69
- * part of the clipboard text: every copy began with a zero-width U+FEFF that showed up as a stray
70
- * invisible character wherever it was pasted. Measured over six samples, `Set-Clipboard` was better
71
- * on every one:
67
+ * An earlier version chose `Set-Clipboard` because `clip.exe` prefixed every copy with a zero-width U+FEFF.
68
+ * That was a BOM problem, not a `clip.exe` problem: it reads stdin as a byte stream, so a leading `FF FE` is
69
+ * two literal characters. Sent as plain UTF-16LE with no mark, it round-trips exactly — verified on ASCII,
70
+ * Chinese, emoji, embedded newlines and 400000 characters.
72
71
  *
73
- * copied clip.exe + BOM Set-Clipboard
74
- * hello U+FEFF hello hello
75
- * 中文测试 U+FEFF 中文测试 中文测试
76
- * line1\nline2 U+FEFF line1 CRLF line2 line1\nline2 (line endings untouched)
77
- * emoji 🎉 ok U+FEFF emoji 🎉 ok emoji 🎉 ok
72
+ * Two measured reasons to prefer it:
78
73
  *
79
- * It also needs no escaping: PowerShell receives a .NET string rather than a byte stream, so there
80
- * is no console parser to satisfy — which is what the BOM and the quoting were working around.
74
+ * characters clip.exe pwsh one-shot
75
+ * 100 77ms 788ms
76
+ * 5000 56ms 748ms
77
+ * 20000 61ms ENAMETOOLONG <- the command line is capped near 32KB
78
+ * 400000 58ms ENAMETOOLONG
81
79
  *
82
- * `clip.exe` stays as a fallback for a machine with no PowerShell at all, where a copy carrying a
83
- * stray BOM beats no copy.
80
+ * `clip.exe` takes the text on STDIN, so its cost does not depend on the length and it has no ceiling; the
81
+ * PowerShell route puts the text on its COMMAND LINE and fails outright past ~20k characters. The one
82
+ * difference that remains: `clip.exe` normalises `\n` to `\r\n`, which is what the Windows clipboard expects
83
+ * anyway.
84
+ *
85
+ * `setClipboardText` stays for a machine with no `clip.exe`.
84
86
  */
85
87
  export function copyText(text) {
86
88
  const s = String(text == null ? '' : text);
87
89
  if (!s) return false;
88
90
  if (win()) {
89
- if (setClipboardText(s)) return true;
90
- cp.execSync('clip', { input: Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(s, 'utf16le')]), windowsHide: true, timeout: 5000 });
91
- return true;
91
+ // `clip.exe` FIRST, fed UTF-8 through a 65001 console.
92
+ //
93
+ // Measured 150-193ms at every size from 100 to 400000 characters, against 748-788ms for a PowerShell
94
+ // one-shot — and the PowerShell route puts the text on its COMMAND LINE, which Windows caps near 32KB, so
95
+ // a 20k-character copy failed outright with ENAMETOOLONG.
96
+ //
97
+ // THE ENCODING IS NOT UTF-16. `clip.exe` decodes stdin with the console code page, which is 936 (GBK) on a
98
+ // Chinese Windows: UTF-16LE bytes came back byte-swapped (`你好` U+4F60 U+597D arrived as U+604F U+597D)
99
+ // and UTF-8 bytes without the matching code page came back as mojibake. `chcp 65001` on the child and UTF-8
100
+ // in the pipe is what round-trips — verified on ASCII, Chinese, emoji and 400k characters.
101
+ //
102
+ // Synchronous, because the caller announces "Copied" the moment this returns.
103
+ const r = cp.spawnSync('cmd', ['/c', 'chcp 65001 >nul && clip'], {
104
+ input: Buffer.from(s, 'utf8'),
105
+ windowsHide: true,
106
+ timeout: 5000,
107
+ });
108
+ if (r && r.status === 0) return true;
109
+ return setClipboardText(s);
92
110
  }
93
111
  const cmd = process.platform === 'darwin' ? 'pbcopy' : 'xclip -selection clipboard';
94
112
  cp.execSync(cmd, { input: Buffer.from(s, 'utf8'), timeout: 5000 });
@@ -113,6 +131,41 @@ function setClipboardText(s) {
113
131
  return false;
114
132
  }
115
133
 
134
+ /**
135
+ * Put text on the clipboard WITHOUT blocking the event loop.
136
+ *
137
+ * `copyText` is synchronous because most callers want to know the outcome before they say "Copied". A copy is
138
+ * 150-190ms of child process on Windows, which is long enough to be felt as a stutter when it runs between two
139
+ * paints. The TUI uses this where a reply is not needed.
140
+ *
141
+ * @param {string} text
142
+ * @returns {Promise<boolean>}
143
+ */
144
+ export function copyTextAsync(text) {
145
+ const s = String(text == null ? '' : text);
146
+ if (!s) return Promise.resolve(false);
147
+ if (!win()) {
148
+ try { copyText(s); return Promise.resolve(true); } catch { return Promise.resolve(false); }
149
+ }
150
+ return new Promise((resolve) => {
151
+ try {
152
+ // Same command and encoding as `copyText`: the code page has to be set on the child, and the payload is
153
+ // UTF-8. See the note there for why UTF-16 does not work.
154
+ const child = cp.spawn('cmd', ['/c', 'chcp 65001 >nul && clip'], {
155
+ windowsHide: true,
156
+ stdio: ['pipe', 'ignore', 'ignore'],
157
+ });
158
+ child.on('error', () => resolve(false));
159
+ child.on('close', (code) => resolve(code === 0));
160
+ child.stdin.on('error', () => { /* a broken pipe is reported by `close` */ });
161
+ child.stdin.end(Buffer.from(s, 'utf8'));
162
+ } catch {
163
+ // No clip.exe: fall back to the blocking route rather than losing the copy.
164
+ try { resolve(setClipboardText(s)); } catch { resolve(false); }
165
+ }
166
+ });
167
+ }
168
+
116
169
  // Which PowerShell the helper runs. `pwsh` (7) starts far faster than the built-in
117
170
  // `powershell` (5.1) — measured 1.9s vs 11.5s for the same image probe — and the image
118
171
  // path needs the WinForms/Drawing assemblies. This is only the PREFERRED name: it is
@@ -193,7 +246,13 @@ function startHelper(exe) {
193
246
  // unavailable" for a clipboard that had content. The queued resolver here is what
194
247
  // marks the shell as ready, so one sentinel still has exactly one owner.
195
248
  helperQueue.push(() => { helperReady = true; });
196
- helper.stdin.write("try { Add-Type -AssemblyName System.Windows.Forms; Add-Type -AssemblyName System.Drawing } catch {}\n'" + SENT + "'\n");
249
+ // The sequence probe's P/Invoke declaration is compiled here too: the first `Add-Type` on a cold shell
250
+ // measured ~900ms, which is exactly the cost this warm-up exists to move off the paste path.
251
+ helper.stdin.write(
252
+ "try { Add-Type -AssemblyName System.Windows.Forms; Add-Type -AssemblyName System.Drawing } catch {}\n"
253
+ + "try { if(-not ('HnClip' -as [type])){Add-Type -Namespace '' -Name 'HnClip' -MemberDefinition '[DllImport(\"user32.dll\")] public static extern uint GetClipboardSequenceNumber();'} } catch {}\n"
254
+ + "'" + SENT + "'\n",
255
+ );
197
256
  }
198
257
 
199
258
  /** Tear down the helper and release every waiter. A read must never be left hanging on
@@ -249,7 +308,10 @@ function helperRun(script, timeoutMs) {
249
308
  function readOnce() {
250
309
  if (win()) {
251
310
  const b64 = runPowershell(
252
- '-NoProfile -Command "[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes((Get-Clipboard -Raw) + \'\'))"',
311
+ // `Clipboard::GetText()` instead of `Get-Clipboard -Raw`: measured on a 20k-character text, the cmdlet
312
+ // took 169ms and the .NET call 13ms. `Get-Clipboard` goes through PowerShell's COM wrapper, which
313
+ // copies the string several times before it reaches the pipeline.
314
+ '-NoProfile -Command "Add-Type -AssemblyName System.Windows.Forms; [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([System.Windows.Forms.Clipboard]::GetText() + \'\'))"',
253
315
  { encoding: 'ascii', windowsHide: true, timeout: ONESHOT_MS },
254
316
  );
255
317
  if (b64 == null) throw new Error('no powershell');
@@ -269,7 +331,7 @@ export async function readText(timeoutMs = HELPER_CMD_MS) {
269
331
  // warming is answered by nothing, and the cold path is the expensive one.
270
332
  if (helper && await waitHelperReady(HELPER_READY_MS)) {
271
333
  helperBusy = true;
272
- const out = await helperRun('[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes((Get-Clipboard -Raw) + ""))', timeoutMs);
334
+ const out = await helperRun('[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes([System.Windows.Forms.Clipboard]::GetText() + ""))', timeoutMs);
273
335
  if (out != null) {
274
336
  const b64 = out.replace(/\r/g, '').split('\n').filter((l) => l.trim()).pop() || '';
275
337
  try { return { text: Buffer.from(b64.trim(), 'base64').toString('utf8'), via: 'helper' }; } catch {}
@@ -301,7 +363,38 @@ export async function readText(timeoutMs = HELPER_CMD_MS) {
301
363
  // PowerShell expression (silently returned nothing), which made FILE pastes fall
302
364
  // back to the slow cold spawn. Emitting one F:: line per file is simpler and
303
365
  // also supports multi-file copies.
304
- //
366
+ /**
367
+ * Just the clipboard's TEXT, or '' — no file list, no image.
368
+ *
369
+ * Asked before paying for a PNG: a browser's image copy carries the pixels AND the address, and only the text
370
+ * formats can hold that address. Cheap on the warm helper, which is already running by the time anyone pastes.
371
+ *
372
+ * @returns {Promise<string>}
373
+ */
374
+ export async function readClipboardTextOnly() {
375
+ if (!win()) { try { return readOnce() || ''; } catch { return ''; } }
376
+ warmClipboard();
377
+ if (!helper) {
378
+ try { return readOnce() || ''; } catch { return ''; }
379
+ }
380
+ if (!helperReady && !await waitHelperReady(HELPER_READY_MS)) {
381
+ try { return readOnce() || ''; } catch { return ''; }
382
+ }
383
+ for (let i = 0; i < 20 && helperBusy; i++) await new Promise((r) => setTimeout(r, 25));
384
+ if (helperBusy || !helper) {
385
+ try { return readOnce() || ''; } catch { return ''; }
386
+ }
387
+ helperBusy = true;
388
+ let out;
389
+ try {
390
+ out = await helperRun(CLIP_PROBE_TEXT, HELPER_CMD_MS);
391
+ } finally {
392
+ helperBusy = false;
393
+ }
394
+ const line = String(out || '').split('\n').map((l) => l.trim()).find((l) => l.startsWith('T::'));
395
+ if (!line) return '';
396
+ try { return Buffer.from(line.slice(3), 'base64').toString('utf8'); } catch { return ''; }
397
+ }
305
398
  // The image branch uses the .NET WinForms clipboard, NOT `Get-Clipboard -Image`:
306
399
  // that parameter only exists on some builds (and is absent even from the pwsh 7
307
400
  // in use here), so it silently failed everywhere and image paste never worked.
@@ -321,15 +414,24 @@ export async function readText(timeoutMs = HELPER_CMD_MS) {
321
414
  */
322
415
  const CLIP_PROBE_TEXT = [
323
416
  "$ErrorActionPreference='SilentlyContinue'",
324
- "$t=Get-Clipboard -Raw",
417
+ // .NET, not the cmdlet: 13ms against 169ms on the same 20k-character text.
418
+ "$t=[System.Windows.Forms.Clipboard]::GetText()",
325
419
  "if($t){'T::'+[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($t))}",
326
420
  ].join('; ');
327
421
 
328
422
  const CLIP_PROBE = [
329
423
  "$ErrorActionPreference='SilentlyContinue'",
330
- "$f=Get-Clipboard -Format FileDropList",
331
- "if($f -and $f.Count -gt 0){$f|ForEach-Object{'F::'+$_.FullName};return}",
332
- "$t=Get-Clipboard -Raw",
424
+ // FILES COME FROM THE .NET CLIPBOARD, not `Get-Clipboard -Format FileDropList`.
425
+ //
426
+ // That parameter does not exist in PowerShell 7 — `Get-Clipboard: A parameter cannot be found that matches
427
+ // parameter name 'Format'` — and the `SilentlyContinue` above turns the error into an empty result, so a
428
+ // copied file read as "clipboard unavailable" while copied text worked. `Clipboard::GetFileDropList()` is the
429
+ // same call on both versions; verified on the 5.1 build in System32 and on pwsh 7 here.
430
+ "Add-Type -AssemblyName System.Windows.Forms",
431
+ "$f=[System.Windows.Forms.Clipboard]::GetFileDropList()",
432
+ "if($f -and $f.Count -gt 0){$f|ForEach-Object{'F::'+$_};return}",
433
+ // .NET, not the cmdlet: 13ms against 169ms on the same 20k-character text.
434
+ "$t=[System.Windows.Forms.Clipboard]::GetText()",
333
435
  "if($t){'T::'+[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($t));return}",
334
436
  // The assemblies are loaded once by warmClipboard(); no Add-Type here, so the
335
437
  // probe stays fast even the first time an image is on the clipboard.
@@ -337,6 +439,22 @@ const CLIP_PROBE = [
337
439
  "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}",
338
440
  ].join('; ');
339
441
 
442
+ /**
443
+ * The clipboard's sequence number, one integer.
444
+ *
445
+ * Windows bumps it on every clipboard write, so comparing two readings answers "did the content change"
446
+ * without reading the content. `Get-Clipboard` has no equivalent, so this P/Invokes `user32` — once, because
447
+ * the type stays compiled for the life of the warm shell.
448
+ *
449
+ * A real read costs time proportional to the payload (measured: 14ms at 1k characters, 48ms at 400k); this
450
+ * does not, which is what makes a repeat paste of a long text instant.
451
+ */
452
+ const CLIP_SEQ_PROBE = [
453
+ "$ErrorActionPreference='SilentlyContinue'",
454
+ "if(-not ('HnClip' -as [type])){Add-Type -Namespace '' -Name 'HnClip' -MemberDefinition '[DllImport(\"user32.dll\")] public static extern uint GetClipboardSequenceNumber();'}",
455
+ "'S::'+[HnClip]::GetClipboardSequenceNumber()",
456
+ ].join('; ');
457
+
340
458
  // Parse the probe output into { text, via }. Handles one-or-more F:: lines.
341
459
  function parseClipResult(out) {
342
460
  const lines = String(out == null ? '' : out).replace(/\r/g, '').split('\n').map((l) => l.trim()).filter(Boolean);
@@ -353,6 +471,38 @@ function parseClipResult(out) {
353
471
  return null;
354
472
  }
355
473
 
474
+ /**
475
+ * A URL hidden in the TEXT of a copied image, or null.
476
+ *
477
+ * Copying an image out of a browser puts several formats on the clipboard at once: the pixels, an HTML
478
+ * fragment (`<img src="…">`, sometimes a `data:` URL) and, for a plain image, the image's own address. The
479
+ * pixel route costs a PNG written to disk for something the model can fetch directly — so the text formats
480
+ * are checked FIRST and only a clipboard with no address at all becomes a file.
481
+ *
482
+ * Only http(s) and `data:` are accepted. A `file:` URL is the same thing as a copied file, which the file
483
+ * drop list already covers.
484
+ *
485
+ * @param {string} text The clipboard's text, possibly an HTML fragment.
486
+ * @returns {string|null}
487
+ */
488
+ export function imageUrlFromClipboardText(text) {
489
+ const s = String(text || '').trim();
490
+ if (!s) return null;
491
+ // A bare URL: the image's own address was copied.
492
+ const bare = /^(https?:\/\/\S+|data:image\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/=\s]+)$/i.exec(s);
493
+ if (bare) return bare[1].replace(/\s+/g, '').trim();
494
+ // An HTML fragment: take the src of the first <img>, which is what a browser's image copy produces.
495
+ const img = /<img\b[^>]*\bsrc\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(s);
496
+ if (img) {
497
+ const url = (img[2] || img[3] || img[4] || '').trim();
498
+ if (/^(https?:\/\/|data:image\/)/i.test(url)) return url.replace(/\s+/g, '');
499
+ }
500
+ // A plain address on its own line inside a longer text selection.
501
+ const line = s.split(/\r?\n/).map((l) => l.trim())
502
+ .find((l) => /^https?:\/\/\S+\.(png|jpe?g|gif|webp|bmp|svg)(\?\S*)?$/i.test(l));
503
+ return line || null;
504
+ }
505
+
356
506
  // ---- pasted-image lifecycle ------------------------------------------------
357
507
  // A pasted image is written to a temp .png (the model needs a PATH to hand to
358
508
  // ReadMediaFile), so something has to remove it again or the temp dir fills up
@@ -442,14 +592,49 @@ export function readClipboardContent() {
442
592
  return parseClipResult(out) || { text: '', via: 'none' };
443
593
  }
444
594
 
595
+ // ---- the fast path's memory --------------------------------------------------
596
+ //
597
+ // `GetClipboardSequenceNumber` is one integer, and Windows bumps it on every clipboard write. Asking the
598
+ // helper for it is a single cheap call; when it matches the last read, the previous result is returned without
599
+ // touching the clipboard at all. That is what makes a repeat paste instant regardless of how long the text is —
600
+ // the read cost scaled with the payload, but the sequence check does not.
601
+ let lastSeq = null;
602
+ let lastResult = null;
603
+ /** The sequence number alone, read on the warm helper. Null when it is not available. */
604
+ async function readClipboardSequence() {
605
+ warmClipboard();
606
+ if (!helper) return null;
607
+ if (!helperReady && !await waitHelperReady(HELPER_READY_MS)) return null;
608
+ if (helperBusy) return null; // somebody is mid-command; the caller can just read normally
609
+ helperBusy = true;
610
+ let out;
611
+ try {
612
+ out = await helperRun(CLIP_SEQ_PROBE, HELPER_CMD_MS);
613
+ } finally {
614
+ helperBusy = false;
615
+ }
616
+ const line = String(out || '').split('\n').map((l) => l.trim()).find((l) => l.startsWith('S::'));
617
+ if (!line) return null;
618
+ const n = Number(line.slice(3));
619
+ return Number.isFinite(n) ? n : null;
620
+ }
621
+
445
622
  // Async version that prefers the warm helper (~10ms) over a cold spawn (~2.9s).
446
623
  export async function readClipboardContentAsync() {
447
624
  if (!win()) {
448
625
  try { return { text: readOnce(), via: 'oneshot' }; } catch { return { text: '', via: 'none' }; }
449
626
  }
627
+ // A CLIPBOARD THAT HAS NOT CHANGED IS NOT READ AGAIN.
628
+ //
629
+ // The measured cost of a read is dominated by the payload (14ms for 1k characters, 48ms for 400k) and the
630
+ // user pays it on every paste even when the clipboard holds the same thing. The sequence number answers "did
631
+ // it change" in one cheap call, so a repeat paste of the same large text is instant.
632
+ const seq = await readClipboardSequence();
633
+ if (seq !== null && seq === lastSeq && lastResult) return { ...lastResult };
450
634
  const viaHelper = await readViaHelper();
451
- if (viaHelper) return viaHelper;
452
- return readClipboardContent();
635
+ const result = viaHelper || readClipboardContent();
636
+ if (seq !== null) { lastSeq = seq; lastResult = result; }
637
+ return result;
453
638
  }
454
639
 
455
640
  // The PowerShell logic for the one-shot fallback lives in a temp .ps1 file:
@@ -461,13 +646,16 @@ function clipScriptPath() {
461
646
  const p = path.join(os.tmpdir(), 'hncode-clipread.ps1');
462
647
  const script = [
463
648
  "$ErrorActionPreference = 'SilentlyContinue'",
464
- '$files = Get-Clipboard -Format FileDropList',
465
- 'if ($files -and $files.Count -gt 0) { $files | ForEach-Object { "F::" + $_.FullName }; exit }',
466
- '$txt = Get-Clipboard -Raw',
467
- 'if ($txt) { "T::" + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($txt)); exit }',
468
- // .NET clipboard, not `Get-Clipboard -Image` (see the CLIP_PROBE note).
649
+ // .NET clipboard for BOTH files and images. `Get-Clipboard -Format FileDropList` is missing from
650
+ // PowerShell 7, and with SilentContinue that turned a copied file into "nothing on the clipboard".
469
651
  'Add-Type -AssemblyName System.Windows.Forms',
470
652
  'Add-Type -AssemblyName System.Drawing',
653
+ '$files = [System.Windows.Forms.Clipboard]::GetFileDropList()',
654
+ 'if ($files -and $files.Count -gt 0) { $files | ForEach-Object { "F::" + $_ }; exit }',
655
+ // .NET, not the cmdlet: the cmdlet copies the string several times before the pipeline sees it.
656
+ '$txt = [System.Windows.Forms.Clipboard]::GetText()',
657
+ 'if ($txt) { "T::" + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($txt)); exit }',
658
+ // .NET clipboard, not `Get-Clipboard -Image` (see the CLIP_PROBE note).
471
659
  '$img = [System.Windows.Forms.Clipboard]::GetImage()',
472
660
  'if ($null -ne $img) {',
473
661
  ' $path = Join-Path $env:TEMP ("hncode-paste-" + [guid]::NewGuid().ToString("N") + ".png")',
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Where a bitmap sits in a text frame.
3
+ *
4
+ * The TUI composes a frame as an array of strings and writes only the rows that changed, each addressed
5
+ * absolutely. A bitmap breaks that model twice: it is not text, so it cannot live in a row's bytes, and it
6
+ * is more than one row tall, so the rows under it must stay blank or the next repaint paints over it.
7
+ *
8
+ * So a row that carries an image is written as a sentinel — a single private-use character plus the
9
+ * payload — and this module turns a frame into the bytes that place it: address the row, emit the
10
+ * sequence, then remember which rows the image covers so the following text rows can be skipped.
11
+ *
12
+ * A SIXEL PAYLOAD DRAWN INLINE IS JUST CHARACTERS. That is why the first attempt showed nothing: the
13
+ * terminal consumed the bytes as text, scrolled them, and the formula never appeared. Placement needs a
14
+ * cursor move first, which is what `\x1b[<row>;<col>H` does.
15
+ */
16
+
17
+ /** Marks a row whose content is a placed bitmap. A private-use code point no text will contain. */
18
+ export const IMAGE_MARK = '\u0001IMG:';
19
+
20
+ /**
21
+ * Separates the payload from the source that produced it.
22
+ *
23
+ * NOT A NUL BYTE. `SPIN_PLACEHOLDER` in the transcript renderer is `\u0000`, and every row is run through a
24
+ * substitution that replaces it with the spinner frame — so a tag built on it arrived as `⠋SRC:…` and the
25
+ * source was unreadable. `\u0001` is what `IMAGE_MARK` already uses and nothing substitutes it.
26
+ */
27
+ export const SOURCE_TAG = '\u0001SRC:';
28
+
29
+
30
+ /**
31
+ * Encode an image row: the mark, then the payload and its geometry, base64-free but length-prefixed so a
32
+ * payload containing the mark cannot be misparsed.
33
+ *
34
+ * `source` is optional and travels with the row for pictures that can be REGENERATED: a formula that straddles
35
+ * the top of the window must be redrawn from its first visible row, and only its TeX can produce that raster.
36
+ * It is stored last, after the sequence, so a row written before this field existed still parses.
37
+ *
38
+ * @param {string} sequence The terminal sequence to emit.
39
+ * @param {number} rows Rows of text the image covers.
40
+ * @param {number} cols Columns the image covers.
41
+ * @param {string} [source] What produced the sequence, for a picture that may need cropping later.
42
+ */
43
+ export function encodeImageRow(sequence, rows, cols, source) {
44
+ const head = IMAGE_MARK + rows + ':' + cols + ':';
45
+ return typeof source === 'string' && source !== ''
46
+ ? head + sequence + SOURCE_TAG + source
47
+ : head + sequence;
48
+ }
49
+
50
+ /**
51
+ * True when a composed row is an image rather than text.
52
+ *
53
+ * The row may carry the transcript's indent prefix — `messageLines` gives every line one — so the marker is
54
+ * not necessarily at index 0. Matching it anywhere in the first few characters is what makes this work
55
+ * without the row builder having to special-case images.
56
+ */
57
+ export function isImageRow(line) {
58
+ if (typeof line !== 'string') return false;
59
+ const at = line.indexOf(IMAGE_MARK);
60
+ return at >= 0 && at < 8 && /^\s*$/.test(line.slice(0, at));
61
+ }
62
+
63
+ /**
64
+ * Split an image row back into its parts.
65
+ * @returns {{sequence: string, rows: number, cols: number, source: string}|null}
66
+ */
67
+ export function parseImageRow(line) {
68
+ if (!isImageRow(line)) return null;
69
+ const at = line.indexOf(IMAGE_MARK);
70
+ const rest = line.slice(at + IMAGE_MARK.length);
71
+ const firstColon = rest.indexOf(':');
72
+ if (firstColon < 0) return null;
73
+ const secondColon = rest.indexOf(':', firstColon + 1);
74
+ if (secondColon < 0) return null;
75
+ const rows = Number(rest.slice(0, firstColon));
76
+ const cols = Number(rest.slice(firstColon + 1, secondColon));
77
+ if (!Number.isFinite(rows) || !Number.isFinite(cols)) return null;
78
+ let body = rest.slice(secondColon + 1);
79
+ let source = '';
80
+ const tag = body.lastIndexOf(SOURCE_TAG);
81
+ if (tag >= 0) {
82
+ source = body.slice(tag + SOURCE_TAG.length);
83
+ body = body.slice(0, tag);
84
+ }
85
+ return { rows, cols, sequence: body, source };
86
+ }
87
+
88
+
89
+ /**
90
+ * The bytes that place one image at its row, and how many rows it covers.
91
+ *
92
+ * @param {string} line The image row.
93
+ * @param {number} rowIndex 0-based screen row the row was composed for.
94
+ * @param {string} reset SGR reset to restore after the sequence.
95
+ * @returns {{bytes: string, covered: number}|null}
96
+ */
97
+ export function imageBytes(line, rowIndex, reset) {
98
+ const parsed = parseImageRow(line);
99
+ if (!parsed) return null;
100
+
101
+ // DEC PRIVATE MODE 80 IS THE SIXEL SCROLLING MODE.
102
+ //
103
+ // With it enabled (the default) a terminal that draws a bitmap pushes the text below it down — and in a
104
+ // full-screen TUI every bitmap therefore scrolls the whole interface. That is what produced the repeated
105
+ // composer: each formula shoved the frame up and the next paint drew it again further down.
106
+ //
107
+ // `?80l` turns the mode off for the duration of the draw and `?80h` restores it. dsh-tui wraps its own
108
+ // placement the same way.
109
+ const bytes = `\x1b[?80l\x1b[${rowIndex + 1};1H${reset}${parsed.sequence}\x1b[?80h`;
110
+ return { bytes, covered: parsed.rows };
111
+ }