@hncode/hncode 0.4.2 → 0.5.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 (65) hide show
  1. package/README.md +1 -15
  2. package/README_CN.md +1 -15
  3. package/bin/hncode +22 -9
  4. package/package.json +3 -2
  5. package/src/agent.js +149 -35
  6. package/src/bootstrap.js +56 -0
  7. package/src/btw-panel.js +224 -0
  8. package/src/cache.js +3 -2
  9. package/src/cell-buffer.js +173 -0
  10. package/src/ci.js +4 -1
  11. package/src/clipboard.js +202 -43
  12. package/src/colors.js +478 -242
  13. package/src/config.js +409 -40
  14. package/src/context-bar.js +237 -0
  15. package/src/doctor.js +518 -0
  16. package/src/experiments.js +122 -0
  17. package/src/file-history.js +112 -0
  18. package/src/file-tree.js +255 -0
  19. package/src/flush-tick.js +56 -0
  20. package/src/fork.js +44 -0
  21. package/src/fx.js +148 -0
  22. package/src/index.js +123 -11
  23. package/src/keybindings.js +229 -0
  24. package/src/llm.js +355 -34
  25. package/src/lsp.js +401 -0
  26. package/src/memory-profile.js +247 -0
  27. package/src/notebook.js +232 -0
  28. package/src/output-styles.js +183 -0
  29. package/src/patch.js +248 -0
  30. package/src/permissions.js +130 -1
  31. package/src/plan.js +3 -2
  32. package/src/plugin.js +611 -58
  33. package/src/prompt-presets.js +1 -1
  34. package/src/registry-browser.js +31 -13
  35. package/src/remote.js +20 -2
  36. package/src/rollout.js +151 -0
  37. package/src/row-cache.js +148 -0
  38. package/src/schedule.js +294 -0
  39. package/src/session-approvals.js +86 -0
  40. package/src/session-hub.js +1 -0
  41. package/src/session.js +178 -8
  42. package/src/settings-schema.js +301 -0
  43. package/src/shell-safety.js +473 -0
  44. package/src/side-thread.js +240 -0
  45. package/src/skills.js +27 -0
  46. package/src/stream-buffer.js +350 -0
  47. package/src/subagent-control.js +160 -0
  48. package/src/teleport.js +260 -0
  49. package/src/term-caps.js +411 -0
  50. package/src/term.js +283 -8
  51. package/src/tool-result.js +264 -236
  52. package/src/tools/agent-control.js +98 -0
  53. package/src/tools/agent-swarm.js +11 -2
  54. package/src/tools/agent.js +35 -1
  55. package/src/tools/diagnostics.js +175 -0
  56. package/src/tools/edit.js +25 -10
  57. package/src/tools/grep.js +62 -20
  58. package/src/tools/ignore.js +38 -14
  59. package/src/tools/index.js +10 -0
  60. package/src/tools/notebook.js +113 -0
  61. package/src/tools/read-media-file.js +10 -3
  62. package/src/tools/shell.js +34 -6
  63. package/src/tui.js +5637 -746
  64. package/src/web-public/app.js +7 -0
  65. package/src/web.js +3 -1
package/src/term.js CHANGED
@@ -167,6 +167,113 @@ function isWideByDefault(c) {
167
167
  || (c >= 0x2B1B && c <= 0x2B1C) || c === 0x2B50 || c === 0x2B55;
168
168
  }
169
169
 
170
+ // ---------------------------------------------------------------------------
171
+ // East Asian AMBIGUOUS width
172
+ // ---------------------------------------------------------------------------
173
+ // Unicode gives every character an East Asian Width class. `A` (Ambiguous) means
174
+ // ONE column on a Western terminal and TWO on a CJK one — the terminal decides, the
175
+ // character does not say. hncode's chrome leans on these heavily, which is why they
176
+ // had to be handled rather than avoided:
177
+ //
178
+ // box rules ─ │ ╭ ╮ ╰ ╯ ┌ ┐ └ ┘ ├ ┤ ┬ ┴ ┼
179
+ // separators · … — –
180
+ // markers ↑ ↓ ← → ❯ ● ○ ✓ ✗
181
+ // math ± × ÷ ≠ ≤ ≥ ∞
182
+ //
183
+ // Measuring them as 1 column on a CJK terminal made every row containing one N columns
184
+ // short, so the row's right edge landed N columns past the panel it belonged to —
185
+ // "the text pushes the wall right". The panel's own background could not hide it
186
+ // either: the overflow is REAL cells on screen, not a measurement disagreement inside
187
+ // one row.
188
+ //
189
+ // Default: follow the LOCALE. A CJK locale is the standard reason a terminal treats
190
+ // EAW=A as wide, and it is the only signal available before anything is drawn — the
191
+ // code page does not say (a UTF-8 Windows console still renders them wide), and no
192
+ // terminal query reports it.
193
+ let ambiguousWide = null; // lazily resolved so a test can set the env first
194
+
195
+ /** EAW=A ranges (Unicode 15), ascending — `inRanges` binary-searches this. */
196
+ const AMBIGUOUS = [
197
+ [0x00A1, 0x00A1], [0x00A4, 0x00A4], [0x00A7, 0x00A8], [0x00AA, 0x00AA],
198
+ [0x00AD, 0x00AE], [0x00B0, 0x00B4], [0x00B6, 0x00BA], [0x00BC, 0x00BF],
199
+ [0x00C6, 0x00C6], [0x00D0, 0x00D0], [0x00D7, 0x00D8], [0x00DE, 0x00E1],
200
+ [0x00E6, 0x00E6], [0x00E8, 0x00EA], [0x00EC, 0x00ED], [0x00F0, 0x00F0],
201
+ [0x00F2, 0x00F3], [0x00F7, 0x00FA], [0x00FC, 0x00FC], [0x00FE, 0x00FE],
202
+ [0x0101, 0x0101], [0x0111, 0x0111], [0x0113, 0x0113], [0x011B, 0x011B],
203
+ [0x0126, 0x0127], [0x012B, 0x012B], [0x0131, 0x0133], [0x0138, 0x0138],
204
+ [0x013F, 0x0142], [0x0144, 0x0144], [0x0148, 0x014B], [0x014D, 0x014D],
205
+ [0x0152, 0x0153], [0x0166, 0x0167], [0x016B, 0x016B], [0x01CE, 0x01CE],
206
+ [0x01D0, 0x01D0], [0x01D2, 0x01D2], [0x01D4, 0x01D4], [0x01D6, 0x01D6],
207
+ [0x01D8, 0x01D8], [0x01DA, 0x01DA], [0x01DC, 0x01DC], [0x0251, 0x0251],
208
+ [0x0261, 0x0261], [0x02C4, 0x02C4], [0x02C7, 0x02C7], [0x02C9, 0x02CB],
209
+ [0x02CD, 0x02CD], [0x02D0, 0x02D0], [0x02D8, 0x02DB], [0x02DD, 0x02DD],
210
+ [0x02DF, 0x02DF], [0x0300, 0x036F], [0x0391, 0x03A1], [0x03A3, 0x03A9],
211
+ [0x03B1, 0x03C1], [0x03C3, 0x03C9], [0x0401, 0x0401], [0x0410, 0x044F],
212
+ [0x0451, 0x0451], [0x2010, 0x2010], [0x2013, 0x2016], [0x2018, 0x2019],
213
+ [0x201C, 0x201D], [0x2020, 0x2022], [0x2024, 0x2027], [0x2030, 0x2030],
214
+ [0x2032, 0x2033], [0x2035, 0x2035], [0x203B, 0x203B], [0x203E, 0x203E],
215
+ [0x2074, 0x2074], [0x207F, 0x207F], [0x2081, 0x2084], [0x20AC, 0x20AC],
216
+ [0x2103, 0x2103], [0x2105, 0x2105], [0x2109, 0x2109], [0x2113, 0x2113],
217
+ [0x2116, 0x2116], [0x2121, 0x2122], [0x2126, 0x2126], [0x212B, 0x212B],
218
+ [0x2153, 0x2154], [0x215B, 0x215E], [0x2160, 0x216B], [0x2170, 0x2179],
219
+ [0x2189, 0x2189], [0x2190, 0x2199], [0x21B8, 0x21B9], [0x21D2, 0x21D2],
220
+ [0x21D4, 0x21D4], [0x21E7, 0x21E7], [0x2200, 0x2200], [0x2202, 0x2203],
221
+ [0x2207, 0x2208], [0x220B, 0x220B], [0x220F, 0x220F], [0x2211, 0x2211],
222
+ [0x2215, 0x2215], [0x221A, 0x221A], [0x221D, 0x2220], [0x2223, 0x2223],
223
+ [0x2225, 0x2225], [0x2227, 0x222C], [0x222E, 0x222E], [0x2234, 0x2237],
224
+ [0x223C, 0x223D], [0x2248, 0x2248], [0x224C, 0x224C], [0x2252, 0x2252],
225
+ [0x2260, 0x2261], [0x2264, 0x2267], [0x226A, 0x226B], [0x226E, 0x226F],
226
+ [0x2282, 0x2283], [0x2286, 0x2287], [0x2295, 0x2295], [0x2299, 0x2299],
227
+ [0x22A5, 0x22A5], [0x22BF, 0x22BF], [0x2312, 0x2312], [0x2460, 0x24E9],
228
+ [0x24EB, 0x254B], [0x2550, 0x2573], [0x2580, 0x258F], [0x2592, 0x2595],
229
+ [0x25A0, 0x25A1], [0x25A3, 0x25A9], [0x25B2, 0x25B3], [0x25B6, 0x25B7],
230
+ [0x25BC, 0x25BD], [0x25C0, 0x25C1], [0x25C6, 0x25C8], [0x25CB, 0x25CB],
231
+ [0x25CE, 0x25D1], [0x25E2, 0x25E5], [0x25EF, 0x25EF], [0x2605, 0x2606],
232
+ [0x2609, 0x2609], [0x260E, 0x260F], [0x261C, 0x261C], [0x261E, 0x261E],
233
+ [0x2640, 0x2640], [0x2642, 0x2642], [0x2660, 0x2661], [0x2663, 0x2665],
234
+ [0x2667, 0x266A], [0x266C, 0x266D], [0x266F, 0x266F], [0x269E, 0x269F],
235
+ [0x26C6, 0x26CD], [0x26CF, 0x26D3], [0x26D5, 0x26E1], [0x26E3, 0x26E3],
236
+ [0x26E8, 0x26E9], [0x26EB, 0x26F1], [0x26F4, 0x26F4], [0x26F6, 0x26F9],
237
+ [0x26FB, 0x26FC], [0x26FE, 0x26FF], [0x273D, 0x273D], [0x2761, 0x2761],
238
+ [0x2776, 0x277F], [0x2B56, 0x2B59], [0x3248, 0x324F], [0xE000, 0xF8FF],
239
+ [0xFE00, 0xFE0F], [0xFFFD, 0xFFFD], [0x1F100, 0x1F10A],
240
+ [0x1F110, 0x1F12D], [0x1F130, 0x1F169], [0x1F170, 0x1F19A],
241
+ ];
242
+
243
+ /**
244
+ * Should EAW=Ambiguous characters measure 2 columns?
245
+ *
246
+ * Resolved once, from (in order):
247
+ * 1. `HNCODE_AMBIGUOUS_WIDTH` — an explicit override, for a terminal whose setting
248
+ * does not match its locale;
249
+ * 2. the locale — `zh`/`ja`/`ko` mean a CJK terminal, which is the standard reason a
250
+ * terminal renders these wide.
251
+ */
252
+ export function detectAmbiguousWide(env = process.env) {
253
+ const raw = env.HNCODE_AMBIGUOUS_WIDTH;
254
+ if (raw !== undefined && raw !== '') return /^(1|true|yes|on)$/i.test(raw);
255
+ let loc = '';
256
+ try { loc = (Intl.DateTimeFormat().resolvedOptions().locale) || ''; } catch { /* no Intl */ }
257
+ const tag = loc || env.LC_ALL || env.LC_CTYPE || env.LANG || '';
258
+ return /^(zh|ja|ko)\b/i.test(tag) || /^(zh|ja|ko)[-_]/i.test(tag);
259
+ }
260
+
261
+ export function isAmbiguousWide() {
262
+ if (ambiguousWide === null) ambiguousWide = detectAmbiguousWide();
263
+ return ambiguousWide;
264
+ }
265
+
266
+ /**
267
+ * Turn the ambiguous-as-wide rule on or off. Clears the width cache, which is keyed by
268
+ * string alone and would otherwise hand back widths measured under the other rule.
269
+ */
270
+ export function setAmbiguousWide(on) {
271
+ const next = !!on;
272
+ if (next === ambiguousWide) return;
273
+ ambiguousWide = next;
274
+ widthCache.clear();
275
+ }
276
+
170
277
  const inRanges = (c, ranges) => {
171
278
  let lo = 0, hi = ranges.length - 1;
172
279
  while (lo <= hi) {
@@ -255,9 +362,46 @@ function clusterGlyphWidth(cluster) {
255
362
  // tokens = ceil(ASCII chars / 4) + non-ASCII chars
256
363
  // ASCII characters average ~4 per token; CJK and other non-ASCII are ~1 token
257
364
  // each. This is far more accurate than a uniform chars/4 ratio.
365
+ // Token estimate. The character walk is O(len), and the context gauge re-prices
366
+ // whole message bodies (a Read result can be hundreds of KB) every turn.
367
+ //
368
+ // A value cache only helps COMPLETED messages: they are immutable, so the same
369
+ // string is measured again and again. It actively HURTS streaming text, where the
370
+ // string grows every frame — every call is a miss, so the cache pays hashing and an
371
+ // insert each time and (once full) an O(n) eviction sweep, measuring 1ms/call vs
372
+ // ~0.001ms for a plain walk (30k growing-text calls: 31s cached vs 30ms uncached).
373
+ //
374
+ // So the cache is limited to SHORT strings: completed metadata and role names hit
375
+ // it, while a long or growing body is just walked directly. At most 4 KB was chosen
376
+ // because a token estimate is only ever re-asked for values that are cheap to hold
377
+ // and genuinely stable; anything larger is better re-walked than hashed + stored.
378
+ const tokCache = new Map();
379
+ const TOK_CACHE_MAX = 20000;
380
+ const TOK_CACHE_MAX_LEN = 4096; // only memoise strings this short
381
+
258
382
  export function estimateTokens(text) {
383
+ const s = String(text ?? '');
384
+ if (s.length <= TOK_CACHE_MAX_LEN) {
385
+ const hit = tokCache.get(s);
386
+ if (hit !== undefined) return hit;
387
+ let ascii = 0, nonAscii = 0;
388
+ for (const ch of s) {
389
+ if (ch.codePointAt(0) <= 127) ascii++;
390
+ else nonAscii++;
391
+ }
392
+ const out = Math.ceil(ascii / 4) + nonAscii;
393
+ if (tokCache.size >= TOK_CACHE_MAX) {
394
+ // Drop the oldest half in one go: cheaper than one-at-a-time and keeps the
395
+ // recently measured entries that a per-entry eviction would churn.
396
+ let i = 0;
397
+ for (const k of tokCache.keys()) { tokCache.delete(k); if (++i >= TOK_CACHE_MAX / 2) break; }
398
+ }
399
+ tokCache.set(s, out);
400
+ return out;
401
+ }
402
+ // Long body: walk it. No memoise, no map churn.
259
403
  let ascii = 0, nonAscii = 0;
260
- for (const ch of String(text ?? '')) {
404
+ for (const ch of s) {
261
405
  if (ch.codePointAt(0) <= 127) ascii++;
262
406
  else nonAscii++;
263
407
  }
@@ -273,17 +417,148 @@ export function estimateTokens(text) {
273
417
  // estimateRequestOverhead().
274
418
  export function estimateMessagesTokens(messages, cfg) {
275
419
  let total = 0;
276
- for (const m of messages || []) {
277
- total += estimateTokens(m.role || '');
278
- const c = m.content;
279
- total += typeof c === 'string' ? estimateTokens(c) : estimateTokens(JSON.stringify(c || ''));
280
- if (m.toolCalls) for (const tc of m.toolCalls) {
281
- total += estimateTokens(tc.name || '') + estimateTokens(JSON.stringify(tc.args || ''));
282
- }
420
+ for (const m of messages || []) total += estimateMessageTokens(m, cfg);
421
+ return total;
422
+ }
423
+
424
+ /**
425
+ * Tokens for ONE message. Split out so callers that walk the history (e.g.
426
+ * planCompaction) can price each message ONCE instead of re-summing the whole
427
+ * array per message — that was O(n²) over the transcript.
428
+ */
429
+ export function estimateMessageTokens(m, cfg) {
430
+ if (!m) return 0;
431
+ let total = estimateTokens(m.role || '');
432
+ const c = m.content;
433
+ total += typeof c === 'string' ? estimateTokens(c) : estimateTokens(JSON.stringify(c || ''));
434
+ if (m.toolCalls) for (const tc of m.toolCalls) {
435
+ total += estimateTokens(tc.name || '') + estimateTokens(JSON.stringify(tc.args || ''));
283
436
  }
284
437
  return total;
285
438
  }
286
439
 
440
+ // ---- per-message token memo -----------------------------------------------
441
+ //
442
+ // Message objects -> { sig, tok }. WeakMap, so a message dropped from the history
443
+ // takes its estimate with it: no unbounded growth, and no manual eviction policy.
444
+ const msgTokMemo = new WeakMap();
445
+ //
446
+ // `estimateMessageTokens` above re-measures an immutable message every time it is
447
+ // asked, and the askers are frequent: the context gauge runs at every step, and
448
+ // compaction walks the whole history to find its cut point. A Read result can be
449
+ // hundreds of KB, so re-walking one costs milliseconds (measured: a 300 KB body is
450
+ // ~5.3 ms to price). Completed messages never change, so their estimate never has
451
+ // to be recomputed.
452
+ //
453
+ // The memo is a WeakMap keyed on the MESSAGE OBJECT, which gives two properties for
454
+ // free: it cannot leak (a dropped message takes its entry with it) and it cannot
455
+ // collide (two equal messages are still two objects).
456
+ //
457
+ // Correctness — the one place a stored message changes underneath us —
458
+ // --------------------------------------------------------------------
459
+ // `trimToolResults` rewrites `m.content` IN PLACE, replacing a large body with a
460
+ // short pointer. A memo that trusted object identity alone would keep serving the
461
+ // pre-trim count, and the gauge would claim the context was still full right after
462
+ // the very operation meant to shrink it — the trim would appear to do nothing and
463
+ // the next request would compact again for no reason.
464
+ //
465
+ // So every entry stores a cheap SIGNATURE of the content alongside the number, and
466
+ // a mismatch recomputes. `.length` on a string is O(1), which is what makes this
467
+ // affordable: streaming text (which grows every frame) misses on the signature and
468
+ // is re-walked, exactly as `estimateTokens`' own short-string cache already
469
+ // established. Content that is neither a string nor an array gets a weaker
470
+ // signature and is simply re-measured each time rather than risk a stale answer.
471
+ function msgSignature(m) {
472
+ const c = m.content;
473
+ let s;
474
+ if (typeof c === 'string') s = 's' + c.length;
475
+ else if (c == null) s = 'n';
476
+ else if (Array.isArray(c)) s = 'a' + c.length;
477
+ else s = ''; // unknown shape: never trust a memo
478
+ if (m.toolCalls) s += 't' + m.toolCalls.length;
479
+ return s;
480
+ }
481
+
482
+ /** Drop any memoised estimate for a message whose content was rewritten in place. */
483
+ export function forgetMessageTokens(m) {
484
+ if (m && typeof m === 'object') msgTokMemo.delete(m);
485
+ }
486
+
487
+ /**
488
+ * `estimateMessageTokens`, memoised per message object.
489
+ *
490
+ * Safe to call on STREAMING messages too: the signature check misses on every
491
+ * frame, so a growing body is simply re-measured and never served stale.
492
+ */
493
+ export function messageTokens(m, cfg) {
494
+ if (!m || typeof m !== 'object') return estimateMessageTokens(m, cfg);
495
+ const sig = msgSignature(m);
496
+ if (sig !== '') {
497
+ const hit = msgTokMemo.get(m);
498
+ if (hit !== undefined && hit.sig === sig) return hit.tok;
499
+ }
500
+ const tok = estimateMessageTokens(m, cfg);
501
+ if (sig !== '') msgTokMemo.set(m, { sig, tok });
502
+ return tok;
503
+ }
504
+
505
+ /**
506
+ * A rolling total over a message list that grows by appending.
507
+ *
508
+ * This is the idea borrowed from jcode's `ActiveCharEstimate`
509
+ * (`crates/jcode-base/src/compaction.rs`): keep a running count of the messages
510
+ * already priced, so the common append-only case never rescans history. jcode also
511
+ * notes why the cached value and its staleness flag are bundled into one type
512
+ * rather than two independent fields — a path that updated one without the other
513
+ * silently corrupted token accounting. Here the two are a single number,
514
+ * `_counted`: it is meaningless on its own and is only ever read together with the
515
+ * length it was computed against.
516
+ *
517
+ * `sync()` is O(new messages) when the list was only appended to, and O(n) after a
518
+ * compaction (which REPLACES the array, so the old count describes messages that no
519
+ * longer exist). Correctness does not depend on detecting appends perfectly: the
520
+ * boundary identities are spot-checked and any doubt falls back to a full recount,
521
+ * which is still cheap because the per-message memo makes each step a WeakMap hit.
522
+ */
523
+ export class TokenLedger {
524
+ constructor() {
525
+ this.tokens = 0;
526
+ this._counted = 0;
527
+ this._first = null;
528
+ this._last = null;
529
+ }
530
+
531
+ /** Total tokens for `messages`, reusing the previous count where it is still valid. */
532
+ sync(messages, cfg) {
533
+ const msgs = Array.isArray(messages) ? messages : [];
534
+ const n = msgs.length;
535
+ const canExtend = this._counted > 0 && this._counted <= n
536
+ && this._first === msgs[0]
537
+ && this._last === msgs[this._counted - 1];
538
+ if (canExtend) {
539
+ for (let i = this._counted; i < n; i++) this.tokens += messageTokens(msgs[i], cfg);
540
+ } else {
541
+ // Full recount. Still O(n) WEAK-MAP LOOKUPS rather than O(n) string walks,
542
+ // which is the difference between milliseconds and microseconds.
543
+ let total = 0;
544
+ for (let i = 0; i < n; i++) total += messageTokens(msgs[i], cfg);
545
+ this.tokens = total;
546
+ }
547
+ this._counted = n;
548
+ this._first = n ? msgs[0] : null;
549
+ this._last = n ? msgs[n - 1] : null;
550
+ return this.tokens;
551
+ }
552
+
553
+ /** Forget everything — call after an in-place edit of an already-counted message. */
554
+ reset() {
555
+ this.tokens = 0;
556
+ this._counted = 0;
557
+ this._first = null;
558
+ this._last = null;
559
+ }
560
+ }
561
+
287
562
  // The overhead a request carries BEYOND its messages: the system prompt plus the
288
563
  // tool definitions. Passed the assembled system text so the number matches what is
289
564
  // actually sent.