@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.
- package/package.json +2 -3
- package/src/agent.js +67 -2
- package/src/clipboard.js +221 -33
- package/src/image-row.js +111 -0
- package/src/latex.js +1557 -0
- package/src/llm.js +19 -4
- package/src/mermaid/ansi.js +55 -0
- package/src/mermaid/canvas.js +449 -0
- package/src/mermaid/class-style.js +57 -0
- package/src/mermaid/css-colors.js +153 -0
- package/src/mermaid/diagrams/class.js +289 -0
- package/src/mermaid/diagrams/er.js +201 -0
- package/src/mermaid/diagrams/flowchart.js +488 -0
- package/src/mermaid/diagrams/gitgraph.js +205 -0
- package/src/mermaid/diagrams/mindmap.js +101 -0
- package/src/mermaid/diagrams/pie.js +104 -0
- package/src/mermaid/diagrams/sequence.js +282 -0
- package/src/mermaid/diagrams/state.js +292 -0
- package/src/mermaid/diagrams/timeline.js +99 -0
- package/src/mermaid/graph.js +138 -0
- package/src/mermaid/index.js +59 -0
- package/src/mermaid/labels.js +339 -0
- package/src/mermaid/layout-seq.js +225 -0
- package/src/mermaid/layout.js +1454 -0
- package/src/mermaid/registry.js +54 -0
- package/src/mermaid/source-box.js +102 -0
- package/src/mermaid/statements.js +235 -0
- package/src/mermaid/types.js +1 -0
- package/src/mermaid/width.js +29 -0
- package/src/screen-diff.js +512 -0
- package/src/screen-paint.js +128 -0
- package/src/screen.js +277 -0
- package/src/subagent-types.js +136 -147
- package/src/term-caps.js +10 -4
- package/src/tex/font.js +292 -0
- package/src/tex/fonts/LICENSE.txt +11 -0
- package/src/tex/fonts/STIX2Math.ttf +0 -0
- package/src/tex/index.js +293 -0
- package/src/tex/kitty.js +186 -0
- package/src/tex/layout.js +446 -0
- package/src/tex/parse.js +331 -0
- package/src/tex/raster.js +216 -0
- package/src/tex/render.js +167 -0
- package/src/tex/sixel.js +326 -0
- package/src/tex/svg.js +237 -0
- package/src/tool-result.js +2 -2
- package/src/tools/agent-swarm.js +3 -15
- package/src/tools/agent.js +454 -464
- package/src/tools/ask-user-question.js +195 -206
- package/src/tools/bash.js +39 -3
- package/src/tools/edit.js +10 -112
- package/src/tools/fetch-url.js +130 -120
- package/src/tools/goal.js +93 -0
- package/src/tools/index.js +2 -0
- package/src/tools/memory.js +80 -88
- package/src/tools/read.js +27 -23
- package/src/tools/todo.js +8 -13
- package/src/tools/validate-args.js +78 -0
- package/src/tui.js +1718 -585
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hncode/hncode",
|
|
3
|
-
"version": "0.
|
|
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 {
|
|
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: `
|
|
65
|
+
* WINDOWS: `clip.exe` on STDIN, with the PowerShell path as the fallback.
|
|
66
66
|
*
|
|
67
|
-
* `clip.exe`
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
80
|
-
*
|
|
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`
|
|
83
|
-
*
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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((
|
|
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
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
452
|
-
|
|
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
|
-
|
|
465
|
-
|
|
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")',
|
package/src/image-row.js
ADDED
|
@@ -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
|
+
}
|