@hncode/hncode 0.4.1 → 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.
- package/README.md +3 -16
- package/README_CN.md +1 -15
- package/bin/hncode +22 -9
- package/package.json +3 -2
- package/src/agent.js +145 -33
- package/src/bootstrap.js +56 -0
- package/src/btw-panel.js +224 -0
- package/src/cache.js +3 -2
- package/src/cell-buffer.js +173 -0
- package/src/ci.js +4 -1
- package/src/clipboard.js +202 -43
- package/src/colors.js +175 -30
- package/src/config.js +376 -34
- package/src/context-bar.js +237 -0
- package/src/doctor.js +320 -0
- package/src/experiments.js +122 -0
- package/src/file-history.js +112 -0
- package/src/file-tree.js +255 -0
- package/src/flush-tick.js +56 -0
- package/src/fork.js +44 -0
- package/src/fx.js +148 -0
- package/src/index.js +123 -11
- package/src/keybindings.js +229 -0
- package/src/llm.js +355 -34
- package/src/lsp.js +401 -0
- package/src/notebook.js +232 -0
- package/src/output-styles.js +183 -0
- package/src/patch.js +248 -0
- package/src/permissions.js +130 -1
- package/src/plan.js +3 -2
- package/src/plugin.js +691 -43
- package/src/prompt-presets.js +1 -1
- package/src/registry-browser.js +317 -0
- package/src/remote.js +289 -0
- package/src/rollout.js +151 -0
- package/src/row-cache.js +143 -0
- package/src/schedule.js +294 -0
- package/src/session-approvals.js +86 -0
- package/src/session.js +178 -8
- package/src/shell-safety.js +473 -0
- package/src/side-thread.js +240 -0
- package/src/skills.js +27 -0
- package/src/stream-buffer.js +350 -0
- package/src/subagent-control.js +160 -0
- package/src/tasks-browser.js +54 -11
- package/src/teleport.js +260 -0
- package/src/term-caps.js +411 -0
- package/src/term.js +283 -8
- package/src/tools/agent-control.js +98 -0
- package/src/tools/agent-swarm.js +11 -2
- package/src/tools/agent.js +35 -1
- package/src/tools/bash.js +17 -3
- package/src/tools/diagnostics.js +175 -0
- package/src/tools/edit.js +25 -10
- package/src/tools/grep.js +62 -20
- package/src/tools/ignore.js +38 -14
- package/src/tools/index.js +10 -0
- package/src/tools/notebook.js +113 -0
- package/src/tools/read-media-file.js +10 -3
- package/src/tools/shell.js +134 -0
- package/src/tui.js +16682 -11665
- package/src/web-public/app.css +22 -9
- package/src/web-public/app.js +107 -54
- package/src/web-public/home.css +3 -3
- package/src/web-public/login.css +1 -1
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(
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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 =
|
|
82
|
-
'
|
|
83
|
-
{ encoding: 'ascii', windowsHide: true, timeout:
|
|
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 =
|
|
95
|
-
if (win()
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
159
|
-
// so
|
|
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,
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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 *
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
// read as
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
|
164
|
-
//
|
|
165
|
-
// background
|
|
166
|
-
|
|
167
|
-
|
|
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]));
|