@hncode/hncode 0.4.1 → 0.4.2

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.
@@ -24,9 +24,14 @@
24
24
  --orange: #ff8c00;
25
25
  --branch: #ffdb6e;
26
26
  --shell: #bd93f9;
27
- --red: #ff6b6b;
28
- --green: #5cff8f;
29
- --yellow: #ffd93d;
27
+ /* Semantic colours. The TUI prints 16-COLOUR codes for these on the dark theme
28
+ (colors.js `C.red = ESC[91m` etc.), so it inherits whatever the user's terminal
29
+ defines; the web has no palette to inherit and must hardcode one. These are the
30
+ values the standard xterm/VSCode palette renders those codes as, so the two
31
+ front-ends agree instead of the web looking more saturated than the terminal. */
32
+ --red: #cd3131;
33
+ --green: #0dbc79;
34
+ --yellow: #e5e510;
30
35
 
31
36
  --on-accent: #08090b;
32
37
 
@@ -127,18 +132,18 @@ button { font: inherit; color: inherit; }
127
132
  white-space: nowrap;
128
133
  }
129
134
  .pill-solid { background: var(--teal); color: var(--on-accent); border-color: var(--teal); }
130
- .pill-ok { color: var(--green); border-color: rgba(92, 255, 143, .4); }
131
- .pill-warn { color: var(--yellow); border-color: rgba(255, 217, 61, .4); }
132
- .pill-danger { color: var(--red); border-color: rgba(255, 107, 107, .4); }
133
- .pill-info { color: var(--teal); border-color: rgba(0, 184, 219, .4); }
135
+ .pill-ok { color: var(--green); border-color: color-mix(in srgb, var(--green) 40%, transparent); }
136
+ .pill-warn { color: var(--yellow); border-color: color-mix(in srgb, var(--yellow) 40%, transparent); }
137
+ .pill-danger { color: var(--red); border-color: color-mix(in srgb, var(--red) 40%, transparent); }
138
+ .pill-info { color: var(--teal); border-color: color-mix(in srgb, var(--teal) 40%, transparent); }
134
139
  .pill-soft { background: var(--s3); border-color: transparent; color: var(--fg1); }
135
140
 
136
141
  .pill-sub { color: var(--fg2); }
137
142
 
138
143
  /* Stop is the one destructive control in the composer, so it reads as a warning
139
144
  rather than as another Send. */
140
- .btn.is-stop { border-color: rgba(255, 107, 107, .5); color: var(--red); }
141
- .btn.is-stop:hover { border-color: var(--red); color: var(--red); background: rgba(255, 107, 107, .1); }
145
+ .btn.is-stop { border-color: color-mix(in srgb, var(--red) 50%, transparent); color: var(--red); }
146
+ .btn.is-stop:hover { border-color: var(--red); color: var(--red); background: color-mix(in srgb, var(--red) 10%, transparent); }
142
147
 
143
148
 
144
149
  .dot {
@@ -931,7 +936,15 @@ button { font: inherit; color: inherit; }
931
936
  border: 0;
932
937
  padding: 0;
933
938
  }
939
+ /* Plan / Focus / Swarm are MUTUALLY EXCLUSIVE modes, so at most one badge is shown —
940
+ and each carries its OWN accent, matching src/tui.js:
941
+ Plan -> cyan | Focus -> blue | Swarm -> spring (green-tinted cyan)
942
+ They used to collapse into one `--teal` badge, which made three different modes
943
+ look like the same state. */
934
944
  .sl-badge { color: var(--teal); font-weight: 600; flex: none; }
945
+ .sl-badge-plan { color: var(--cyan); }
946
+ .sl-badge-focus { color: var(--blue); }
947
+ .sl-badge-swarm { color: var(--spring); }
935
948
  .sl-model {
936
949
  flex: none;
937
950
  color: var(--fg);
@@ -321,7 +321,20 @@ function setScrollTop(v) {
321
321
  // every real height, so two rows can never overlap. Only rows inside the
322
322
  // window have DOM; the ranges above/below it are reserved by two padding divs,
323
323
  // sized from measured heights (PLACEHOLDER_H until a row is first mounted).
324
- const WIN_OVERSCAN = 8;
324
+ //
325
+ // The overscan is measured in PIXELS, not rows. A row-count overscan is unusable
326
+ // here because row heights differ by an order of magnitude — a folded tool result is
327
+ // ~180px, a plain system line ~20px — so "8 rows" could be 160px of slack in one
328
+ // stretch of the transcript and 1400px in another. In the thin stretch a row was
329
+ // unmounted while it was still on screen (the reported "gone before it left the
330
+ // viewport"), because the padding divs then took its place and the content jumped.
331
+ // Keeping one extra viewport above and below makes the window independent of how
332
+ // tall the rows happen to be.
333
+ const WIN_OVERSCAN_PX = 800;
334
+ // The row-count floor that goes with it (see renderWindow): whichever reaches further
335
+ // wins. Estimates cannot be trusted, so this is what actually guarantees a row is not
336
+ // unmounted while it is still visible.
337
+ const WIN_OVERSCAN_ROWS = 12;
325
338
  const NEAR_END_PX = 24;
326
339
  // TanStack Virtual's rule for estimateSize: "estimate the LARGEST possible
327
340
  // size (within comfort)". An underestimate makes the scroll range too short,
@@ -429,35 +442,66 @@ function renderWindow() {
429
442
  return;
430
443
  }
431
444
  const viewH = stream.clientHeight || 608;
445
+ // Overscan is expressed in BOTH pixels and rows, and whichever reaches FURTHER
446
+ // wins. Row heights differ by an order of magnitude (a folded tool result ~180px,
447
+ // a plain line ~20px), so a row count alone left almost no margin in a thin
448
+ // stretch and unmounted rows that were still on screen. A pixel budget alone is not
449
+ // enough either: an UNMEASURED row is priced at PLACEHOLDER_H, so walking 800px of
450
+ // estimates can cover far less than 800px of real content and the last rows fall
451
+ // outside the window. The row floor is the guarantee; the pixel budget trims the
452
+ // overshoot on tall rows.
453
+ const over = Math.max(WIN_OVERSCAN_PX, viewH);
454
+ const MIN_MARGIN_ROWS = WIN_OVERSCAN_ROWS;
432
455
  if (win.pinned) {
433
- // Fixed-size tail window (by count, not by cumulative pixels): the tail
434
- // rows are the newest, and a row-count window is exact regardless of how
435
- // tall the measured rows are. PLACEHOLDER_H only prices the mounts into
436
- // the two pads above/below the window; the pad math handles real heights.
437
- const want = Math.ceil(viewH / PLACEHOLDER_H) + WIN_OVERSCAN;
456
+ // Pinned to the tail: grow upward until BOTH budgets are satisfied.
438
457
  win.end = rowItems.length;
439
- win.start = Math.max(0, win.end - want);
458
+ let used = 0;
459
+ let start = win.end;
460
+ while (start > 0 && (used < viewH + over || win.end - start < MIN_MARGIN_ROWS)) {
461
+ start--;
462
+ used += knownHeight(start);
463
+ }
464
+ win.start = start;
440
465
  } else {
466
+ // Walk from the top accumulating heights until the scroll position is reached.
441
467
  let acc = 0;
442
468
  let first = rowItems.length - 1;
443
469
  for (let i = 0; i < rowItems.length; i++) {
444
- if (acc >= stream.scrollTop) { first = i; break; }
470
+ if (acc + knownHeight(i) > stream.scrollTop) { first = i; break; }
445
471
  acc += knownHeight(i);
446
472
  }
447
- const start = Math.max(0, first - WIN_OVERSCAN);
448
- let end = start;
449
- let used = 0;
450
- while (end < rowItems.length && used < viewH + WIN_OVERSCAN * PLACEHOLDER_H) {
473
+ // Above the viewport: the pixel budget OR the row floor, whichever is further.
474
+ let start = first;
475
+ let above = 0;
476
+ while (start > 0 && (above < over || first - start < MIN_MARGIN_ROWS)) {
477
+ start--;
478
+ above += knownHeight(start);
479
+ }
480
+ // Below the viewport: same rule.
481
+ let end = first + 1;
482
+ let used = knownHeight(first);
483
+ while (end < rowItems.length && (used < viewH + over || end - first < MIN_MARGIN_ROWS)) {
451
484
  used += knownHeight(end);
452
485
  end++;
453
486
  }
454
487
  win.start = start;
455
- win.end = Math.min(rowItems.length, end + WIN_OVERSCAN);
488
+ win.end = Math.min(rowItems.length, end);
456
489
  }
457
- const same = win.start === win._lastStart && win.end === win._lastEnd;
490
+ const sameRange = win.start === win._lastStart && win.end === win._lastEnd;
458
491
  win._lastStart = win.start;
459
492
  win._lastEnd = win.end;
460
- if (same) return;
493
+ // A height change does NOT need a different window — only different paddings. The
494
+ // pad sizes are recomputed below on every pass, so returning early here is safe.
495
+ // It used to be gated on `same` (start+end), which is why `_lastStart = -1` on
496
+ // every height change tore the whole window down and rebuilt it: each tool call
497
+ // changes a row's height, so the unmount/remount cycle re-created every visible
498
+ // row's DOM and the tool NAMES visibly flashed — not just the call that finished.
499
+ if (sameRange) {
500
+ win.topPad.style.height = heightAbove(win.start) + "px";
501
+ win.bottomPad.style.height = heightBelow(win.end) + "px";
502
+ if (win.pinned) setScrollTop(stream.scrollHeight);
503
+ return;
504
+ }
461
505
  for (const k of [...win.els.keys()]) {
462
506
  if (k < win.start || k >= win.end) unmountRow(k);
463
507
  }
@@ -886,14 +930,16 @@ function paintToolName(rec, name, pending, tick) {
886
930
 
887
931
  if (!pending) {
888
932
  unregisterSweep(rec);
889
- if (rec.namePending !== false) {
890
- box.replaceChildren(document.createTextNode(name));
891
- rec.namePending = false;
892
- rec.nameText = name;
893
- } else if (box.textContent !== name) {
894
- box.textContent = name;
895
- rec.nameText = name;
896
- }
933
+ // Leave the DOM alone when it already shows the plain name. `paintBody` rebuilds
934
+ // a row's children on every patch, so this node is normally FRESH (empty) and the
935
+ // branch below is what fills it; but when the name is unchanged the existing
936
+ // content is already correct, and rewriting it makes the name flicker on rows
937
+ // that finished long ago — the flash reported for "every tool name", not just the
938
+ // call that just ran.
939
+ if (rec.namePending === false && box.textContent === name) return;
940
+ box.replaceChildren(document.createTextNode(name));
941
+ rec.namePending = false;
942
+ rec.nameText = name;
897
943
  return;
898
944
  }
899
945
 
@@ -1053,31 +1099,38 @@ function paintResultRow(body, r) {
1053
1099
  const text = String(r.text == null ? '' : r.text);
1054
1100
  const diff = Array.isArray(r.diff) ? r.diff.filter((d) => d.type !== 'ctx') : [];
1055
1101
 
1056
- if (diff.length) {
1057
- if (!expandedRows.has(r.id)) {
1058
- body.appendChild(el('res-fold', tr('mark.result')));
1059
- body.appendChild(expandButton(r.id, diff.length));
1060
- return;
1061
- }
1062
- const width = Math.max(1, ...diff.map((d) => String(d.no || 0).length));
1063
- diff.forEach((d, i) => {
1102
+ if (diff.length) {
1103
+ // Same shape as the folded result below: show the first diff rows with the `↳`
1104
+ // on the first, and let the fold hint trail the block. The old form drew `↳` on
1105
+ // a line by itself with the hint on the NEXT line and no diff content at all.
1106
+ const rows = expandedRows.has(r.id) ? diff : diff.slice(0, DIFF_MAX_ROWS);
1107
+ const width = Math.max(1, ...rows.map((d) => String(d.no || 0).length));
1108
+ rows.forEach((d, i) => {
1064
1109
  const no = String(d.no || 0).padStart(width);
1065
1110
  const mark = d.type === 'add' ? '+' : '-';
1066
1111
  const prefix = i === 0 ? tr('mark.result') + ' ' : ' ';
1067
1112
  body.appendChild(el(d.type === 'add' ? 'diff-add' : 'diff-del',
1068
1113
  prefix + no + ' ' + mark + ' ' + String(d.text) + '\n'));
1069
1114
  });
1115
+ const hiddenDiff = diff.length - rows.length;
1116
+ if (hiddenDiff > 0) body.appendChild(moreButton(r.id, hiddenDiff));
1070
1117
  const receipt = text.trim()
1071
1118
  .split('\n').filter((ln) => !/^\[exit code:/.test(ln.trim())).join('\n')
1072
1119
  .trim();
1073
1120
  if (receipt) body.appendChild(el(failed ? 'diff-del' : 'res-more', receipt));
1074
- body.appendChild(collapseButton(r.id));
1121
+ if (expandedRows.has(r.id) && diff.length > DIFF_MAX_ROWS) body.appendChild(collapseButton(r.id));
1075
1122
  return;
1076
1123
  }
1077
-
1078
- // A finished result is FOLDED by default (12 lines, like the terminal's
1079
- // ctrl+o): a whole-file Read is thousands of DOM nodes in ONE row, which
1080
- // stutters scrolling. The "more" button expands it in place.
1124
+ // A finished result is FOLDED (like the terminal's ctrl+o): a whole-file Read is
1125
+ // thousands of DOM nodes in ONE row, which stutters scrolling. The preview keeps
1126
+ // the FIRST lines visible with the `↳` marker on the first one, and the fold hint
1127
+ // rides the END of the block — the terminal's shape:
1128
+ // ↳ 1 first line of the output
1129
+ // 2 second line
1130
+ // … (N more lines, expand)
1131
+ // It used to render `↳` alone on one line and the hint on the NEXT, so a folded
1132
+ // row showed a dangling marker and no output at all, and the hint read as a
1133
+ // separate paragraph rather than as "there is more of THIS".
1081
1134
  // `[exit code: N]` is bookkeeping for the model, never shown to the user.
1082
1135
  const lines = text.replace(/\r\n/g, '\n').split('\n')
1083
1136
  .filter((ln) => !/^\[exit code:/.test(ln.trim()));
@@ -1085,12 +1138,8 @@ if (diff.length) {
1085
1138
  while (lines.length && lines[lines.length - 1].trim() === '') lines.pop();
1086
1139
  if (!lines.length) return;
1087
1140
 
1088
- if (!expandedRows.has(r.id)) {
1089
- body.appendChild(el('res-fold', tr('mark.result')));
1090
- body.appendChild(expandButton(r.id, lines.length));
1091
- return;
1092
- }
1093
- const shown = lines;
1141
+ const folded = !expandedRows.has(r.id);
1142
+ const shown = folded ? lines.slice(0, RESULT_MAX_LINES) : lines;
1094
1143
  shown.forEach((ln, i) => {
1095
1144
  const isErr = /^\[error:/.test(ln.trim());
1096
1145
  const prefix = i === 0 ? tr('mark.result') + ' ' : ' ';
@@ -1098,19 +1147,10 @@ if (diff.length) {
1098
1147
  });
1099
1148
  const hidden = lines.length - shown.length;
1100
1149
  if (hidden > 0) body.appendChild(moreButton(r.id, hidden));
1101
- body.appendChild(collapseButton(r.id));
1150
+ if (expandedRows.has(r.id) && lines.length > RESULT_MAX_LINES) body.appendChild(collapseButton(r.id));
1102
1151
  }
1103
1152
 
1104
1153
 
1105
- function expandButton(id, n) {
1106
- const b = document.createElement('button');
1107
- b.type = 'button';
1108
- b.className = 'msg-more';
1109
- b.textContent = tr('tool.moreLines', { n: Number(n) || 0 });
1110
- b.addEventListener('click', () => { expandedRows.add(id); repaintRow(id); });
1111
- return b;
1112
- }
1113
-
1114
1154
  function moreButton(id, n) {
1115
1155
  const b = document.createElement('button');
1116
1156
  b.type = 'button';
@@ -1489,8 +1529,14 @@ function renderStatusLine(s) {
1489
1529
  const o = s.options || {};
1490
1530
 
1491
1531
  line.appendChild(popoverButton('sl-mode', modeLabel(s.mode), 'side.mode', () => modeItems(o)));
1492
- const badge = s.plan ? 'Plan' : s.focus ? 'Focus' : s.swarm ? 'Swarm' : '';
1493
- if (badge) line.appendChild(el('sl-badge', badge));
1532
+ // One badge, one accent — Plan/Focus/Swarm are mutually exclusive, so exactly one
1533
+ // of these is shown. The class carries the colour (see .sl-badge-* in app.css); the
1534
+ // three must NOT share it, or the statusline cannot say which mode is on.
1535
+ const badge = s.plan ? { text: 'Plan', cls: 'sl-badge-plan' }
1536
+ : s.focus ? { text: 'Focus', cls: 'sl-badge-focus' }
1537
+ : s.swarm ? { text: 'Swarm', cls: 'sl-badge-swarm' }
1538
+ : null;
1539
+ if (badge) line.appendChild(el('sl-badge ' + badge.cls, badge.text));
1494
1540
 
1495
1541
  if (s.model) {
1496
1542
  const label = s.model + (s.reasoning
@@ -19,8 +19,8 @@
19
19
 
20
20
  --teal: #00b8db;
21
21
  --cyan: #00d7ff;
22
- --red: #ff6b6b;
23
- --green: #5cff8f;
22
+ --red: #cd3131;
23
+ --green: #0dbc79;
24
24
 
25
25
  --mono: "JetBrains Mono", "Noto Sans SC", ui-monospace, SFMono-Regular,
26
26
  "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace;
@@ -100,7 +100,7 @@ h1 .dim { color: var(--fg3); }
100
100
 
101
101
  .err {
102
102
  color: var(--red);
103
- border: 1px solid rgba(255, 107, 107, .4);
103
+ border: 1px solid color-mix(in srgb, var(--red) 40%, transparent);
104
104
  border-radius: var(--radius);
105
105
  padding: 9px 12px;
106
106
  margin: 0 0 24px;
@@ -17,7 +17,7 @@
17
17
  --line2: #272d3a;
18
18
 
19
19
  --teal: #00b8db;
20
- --red: #ff6b6b;
20
+ --red: #cd3131;
21
21
 
22
22
  --mono: "JetBrains Mono", "Noto Sans SC", ui-monospace, SFMono-Regular,
23
23
  "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace;