@hncode/hncode 0.6.4 → 0.6.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hncode/hncode",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
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",
package/src/agent.js CHANGED
@@ -942,7 +942,7 @@ const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
942
942
  // cannot fit, the provider's context-length error is an honest, recoverable
943
943
  // failure instead of a vanished transcript.
944
944
  if (!summary && this.messages.length > 0) {
945
- this.onEvent({ type: 'compaction_cancelled' });
945
+ this.onEvent({ type: 'compaction_cancelled', reason: 'the summary request failed, so nothing was trimmed' });
946
946
  } else {
947
947
  // NOW trim, at a split point the boundary rule allows.
948
948
  const { split, dropped: droppedCount, kept } = this.applyCompactionSplit(maxCtx, summary);
@@ -952,6 +952,10 @@ const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
952
952
  // point of mutation instead of relying on that check to fire.
953
953
  this._ledger.reset();
954
954
  usedNow = this.usedTokens();
955
+ // THE REPORT DESCRIBES THE REQUEST THAT WENT OUT, so after a trim it still describes the OLD one.
956
+ // Drop it: the next turn falls back to the local estimate until the provider reports again, instead of
957
+ // reading a stale figure and compacting a history that was just compacted.
958
+ this.ctxReportedTokens = 0;
955
959
  this.onEvent({
956
960
  type: 'compacted', before, after: usedNow, kept,
957
961
  dropped: droppedCount, summary,
package/src/tools/edit.js CHANGED
@@ -429,7 +429,12 @@ function buildEditDiff({ fileLines, oldText, newText, startLine }) {
429
429
  //
430
430
  // `net` is the number of lines the edit added (negative when it shrank the file), so every line below
431
431
  // the replaced block moves by exactly that much.
432
- const net = normalizeText(newText).split('\n').length - replacedCount;
432
+ // AN EMPTY REPLACEMENT IS ZERO LINES, NOT ONE. `''.split('\n')` is `['']`, so a deletion counted itself as
433
+ // a one-line replacement and `net` came out 0 instead of -1 — the AFTER context was then numbered as if
434
+ // nothing had been removed, and the diff carried a phantom `+` row holding an empty string, which reads as an
435
+ // insertion where the file in fact lost a line.
436
+ const newLines = normalizeText(newText) === '' ? [] : normalizeText(newText).split('\n');
437
+ const net = newLines.length - replacedCount;
433
438
  for (let n = lastReplaced + 1; n <= lastReplaced + DIFF_CONTEXT; n++) {
434
439
  if (n >= 1 && n <= lines.length) after.push({ n: n + net, text: lines[n - 1] });
435
440
  }
package/src/tui.js CHANGED
@@ -1524,7 +1524,13 @@ const DIFF_CONTEXT_LINES = 5;
1524
1524
  */
1525
1525
  export function editDiffRows(diff) {
1526
1526
  if (!diff || typeof diff.old !== 'string' || typeof diff.new !== 'string') return [];
1527
- const split = (s) => String(s).replace(/\r\n/g, '\n').split('\n');
1527
+ // An empty string is ZERO lines: `''.split('\n')` is `['']`, which would draw a phantom blank row.
1528
+ // An empty side is ZERO lines and a trailing newline is not a line: `''.split('\n')` is `['']`,
1529
+ // which drew a phantom blank row, and `'a\n'` split to `['a', '']`, which drew one past the end.
1530
+ const split = (s) => {
1531
+ const t = String(s).replace(/\r\n/g, '\n').replace(/\n$/, '');
1532
+ return t === '' ? [] : t.split('\n');
1533
+ };
1528
1534
  const ctx = diff.context || {};
1529
1535
  const before = Array.isArray(ctx.before) ? ctx.before : [];
1530
1536
  const after = Array.isArray(ctx.after) ? ctx.after : [];
@@ -2900,7 +2906,8 @@ function messageLines(msg, width, workspace, expanded, spin, pulseStart, rawMode
2900
2906
  ? col(` (Ctrl-O to ${expanded ? 'hide' : 'show'} compaction summary)`, C.gray) : '';
2901
2907
  head = bullet + col('Compaction complete', C.green + C.bold) + detail + hint;
2902
2908
  } else if (canceled) {
2903
- head = bullet + col('Compaction cancelled', C.yellow + C.bold);
2909
+ head = bullet + col('Compaction cancelled', C.yellow + C.bold)
2910
+ + (msg.reason ? col(' (' + msg.reason + ')', C.gray) : '');
2904
2911
  } else {
2905
2912
  head = bullet + col('Compacting context…', C.cyan + C.bold);
2906
2913
  }
@@ -6557,25 +6564,23 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
6557
6564
  // earlier question with the mouse; without these the labels were plain text and a click did nothing.
6558
6565
  const tabHits = [];
6559
6566
  {
6560
- // ONE QUESTION MEANS NO TABS. There is nothing to switch to, and for a free-text question the only
6561
- // tab would read "Other" beside a field that is already the answer.
6562
- if (qs.items.length > 1) {
6563
- let tabs = '';
6564
- const tabName = (i) => {
6565
- const it = qs.items[i];
6566
- return String((it && it.header) || `Q${i + 1}`).trim() || `Q${i + 1}`;
6567
- };
6568
- for (let i = 0; i <= qs.items.length; i++) {
6569
- const name = i === qs.items.length ? OTHER_LABEL : tabName(i);
6570
- const on = i === qs.index;
6571
- const colStart = visualCol(tabs);
6572
- const label = ' ' + name + ' ';
6573
- tabs += on ? col(label, C.selBg + C.cyan + C.bold) : col(label, C.gray);
6574
- tabHits.push({ index: i, colStart, colEnd: colStart + visualCol(label) - 1 });
6575
- tabs += ' ';
6576
- }
6577
- box.push(boxRow(fitAnsi(tabs, innerW - 2)));
6567
+ // ALWAYS DRAWN. The trailing Other tab means even one question has two destinations, and
6568
+ // hiding the strip hid that the note existed at all.
6569
+ let tabs = '';
6570
+ const tabName = (i) => {
6571
+ const it = qs.items[i];
6572
+ return String((it && it.header) || `Q${i + 1}`).trim() || `Q${i + 1}`;
6573
+ };
6574
+ for (let i = 0; i <= qs.items.length; i++) {
6575
+ const name = i === qs.items.length ? OTHER_LABEL : tabName(i);
6576
+ const on = i === qs.index;
6577
+ const colStart = visualCol(tabs);
6578
+ const label = ' ' + name + ' ';
6579
+ tabs += on ? col(label, C.selBg + C.cyan + C.bold) : col(label, C.gray);
6580
+ tabHits.push({ index: i, colStart, colEnd: colStart + visualCol(label) - 1 });
6581
+ tabs += ' ';
6578
6582
  }
6583
+ box.push(boxRow(fitAnsi(tabs, innerW - 2)));
6579
6584
  }
6580
6585
  const itemRows = [];
6581
6586
  let caretInBox = -1;
@@ -6611,15 +6616,51 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
6611
6616
  // THE LIVE BUFFER, not the committed value. Typing writes `editingText`; `otherText` only holds what was
6612
6617
  // committed when the field was left, so rendering it made the field look empty while the user typed into it.
6613
6618
  const typed = qs.editing === 'other' ? String(qs.editingText || '') : (qs.otherText || '');
6614
- // A PLACEHOLDER SITS INSIDE THE FIELD, grey, and vanishes on the first keystroke. As a row underneath it read
6615
- // as a caption explaining a field that already looked self-explanatory.
6616
- const shown = typed || 'type your answer, then Enter';
6617
- const paint = typed ? C.white : C.gray;
6618
- const head = col('❯ ', C.cyan) + col(shown, paint);
6619
- const fill = ' '.repeat(Math.max(0, innerW - 2 - visualCol(shown)));
6620
- caretInBox = box.length;
6621
- caretColInBox = 2 + visualCol(typed);
6622
- box.push(boxRow(col(head + fill, '')));
6619
+ const caretAt = Math.max(0, Math.min(typed.length, qs.editingCaret == null ? typed.length : qs.editingCaret));
6620
+ // The marker plus the padding on each side is what the text cannot use.
6621
+ const bodyW = Math.max(1, innerW - 2 - visualCol('❯ '));
6622
+ // A PLACEHOLDER SITS INSIDE THE FIELD, grey, and vanishes on the first keystroke.
6623
+ const empty = typed === '';
6624
+ const shown = empty ? 'type your answer, then Enter' : typed;
6625
+ const paint = empty ? C.gray : C.white;
6626
+ // WRAP, so a long answer stays visible instead of being clipped at the box edge.
6627
+ // WRAP BY PARAGRAPH, then by width. `wrapWithOffsets` cuts on width only, so an explicit newline has to be
6628
+ // split out first or it is drawn as the two characters `\` and `n`.
6629
+ const segs = [];
6630
+ {
6631
+ let base = 0;
6632
+ const paras = shown.split('\n');
6633
+ for (let pi = 0; pi < paras.length; pi++) {
6634
+ const para = paras[pi];
6635
+ for (const w of wrapWithOffsets(para, bodyW)) segs.push({ text: w.text, start: base + w.start });
6636
+ base += para.length + 1; // + the newline we split on
6637
+ }
6638
+ // A trailing empty line matters while typing: it is where the next character will go. Without it a full
6639
+ // row leaves the caret with nowhere to sit.
6640
+ if (!empty && caretAt === typed.length && !/\n$/.test(typed)) segs.push({ text: '', start: typed.length });
6641
+ }
6642
+ // The caret belongs to the segment that CONTAINS it; the LAST match wins on a boundary so the cursor sits at
6643
+ // the end of the text rather than jumping to the next row.
6644
+ let caretRow = Math.max(0, segs.length - 1);
6645
+ let caretCol = 0;
6646
+ for (let si = 0; si < segs.length; si++) {
6647
+ const seg = segs[si];
6648
+ const segEnd = seg.start + seg.text.length;
6649
+ if (caretAt >= seg.start && caretAt <= segEnd) {
6650
+ caretRow = si;
6651
+ caretCol = visualCol(seg.text.slice(0, caretAt - seg.start));
6652
+ }
6653
+ // The first row carries the prompt glyph; the rest are indented to match it.
6654
+ const marker = si === 0 ? col('❯ ', C.cyan) : ' ';
6655
+ // Padded to the full body width: a short row would leave the right wall short and the next row would appear
6656
+ // to run into it.
6657
+ const body = col(seg.text, paint) + ' '.repeat(Math.max(0, bodyW - visualCol(seg.text)));
6658
+ box.push(boxRow(col(marker + body, '')));
6659
+ }
6660
+ // How many rows the field took, so the caret row can be located in the box.
6661
+ qs.freeTextRows = segs.length;
6662
+ caretInBox = box.length - segs.length + Math.min(caretRow, segs.length - 1);
6663
+ caretColInBox = 2 + caretCol;
6623
6664
  } else {
6624
6665
  const opts = cur.options.map((o) => ({ label: o.label, description: o.description, kind: 'option' }));
6625
6666
  opts.push({ label: OTHER_LABEL, description: '', kind: 'other' });
@@ -6642,8 +6683,15 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
6642
6683
  else marker = isSel ? '> ' : ' ';
6643
6684
  // The current row is marked by `> ` (single) / the filled square (multi); no
6644
6685
  // background highlight.
6645
- const markerPaint = isSel ? C.cyan : (chosen ? C.cyan : C.gray);
6646
- const labelPaint = isSel ? (C.cyan + C.bold) : (chosen ? C.cyan : C.white);
6686
+ // A CHOSEN ROW IS BOLD; THE HOVERED ROW IS TINTED AND PLAIN.
6687
+ //
6688
+ // They used to share `C.cyan`, so there was no way to tell which rows were ticked and which one the cursor
6689
+ // happened to be on — the one distinction the screen exists to make. Weight carries the choice, the
6690
+ // background carries the cursor, and the marker carries the cursor when colour is unavailable.
6691
+ const markerPaint = isSel ? (chosen ? (C.cyan + C.bold) : C.cyan) : (chosen ? C.cyan : C.gray);
6692
+ const labelPaint = chosen
6693
+ ? (C.cyan + C.bold)
6694
+ : (isSel ? (C.selBg + C.cyan) : C.white);
6647
6695
  const typed = (o.kind === 'other' && qs.otherText) ? `: ${qs.otherText}` : '';
6648
6696
  const head = col(marker, markerPaint) + col(String(o.label) + typed, labelPaint);
6649
6697
  const desc = o.description ? col(' ' + o.description, C.gray) : '';
@@ -7043,9 +7091,9 @@ const queueH = takeover ? 0 : queuePanelHeight(state);
7043
7091
  const textPart = styledRow.slice(contWidth);
7044
7092
  const textStart = rowMeta.start != null ? rowMeta.start : 0;
7045
7093
  const highlighted = (sel && sel.anchor !== sel.head)
7046
- ? highlightSelection(styledRow, textStart, sel.anchor, sel.head)
7047
- : styledRow;
7048
- rowAnsi = ' ' + C.white + fitAnsi(highlighted, textW) + ' ';
7094
+ ? highlightSelection(textPart, textStart, sel.anchor, sel.head)
7095
+ : textPart;
7096
+ rowAnsi = ' ' + indent + C.white + fitAnsi(highlighted, Math.max(1, textW - contWidth)) + ' ';
7049
7097
  }
7050
7098
  lines.push(
7051
7099
  CPAD + col('│', shellBorder) + rowAnsi + C.reset + col('│', shellBorder)
@@ -17333,7 +17381,7 @@ export async function startTUI(opts) {
17333
17381
  // the user left them.
17334
17382
  if (qs.index < total) {
17335
17383
  if (!qs.remember) qs.remember = new Map();
17336
- qs.remember.set(qs.index, { sel: qs.sel, picked: new Set(qs.picked) });
17384
+ qs.remember.set(qs.index, { sel: qs.sel, picked: new Set(qs.picked), otherText: qs.otherText || '' });
17337
17385
  }
17338
17386
  if (qs.index < total - 1) {
17339
17387
  qs.index++;
@@ -17345,7 +17393,10 @@ export async function startTUI(opts) {
17345
17393
  const q = qs.items[qs.index];
17346
17394
  if (q && q.freeText) {
17347
17395
  qs.editing = 'other';
17348
- qs.editingText = qs.otherText || '';
17396
+ // THE RECORDED TEXT for THIS question: `otherText` is shared by every question, so it holds whichever
17397
+ // answer was edited most recently — or nothing, once submitting cleared it.
17398
+ qs.editingText = (back && back.otherText) || '';
17399
+ qs.otherText = qs.editingText;
17349
17400
  qs.editingCaret = qs.editingText.length;
17350
17401
  }
17351
17402
  return;
@@ -18580,6 +18631,15 @@ export async function startTUI(opts) {
18580
18631
  const back = qs.index < total0 && qs.remember ? qs.remember.get(qs.index) : null;
18581
18632
  qs.sel = back ? back.sel : 0;
18582
18633
  qs.picked = back ? new Set(back.picked) : new Set();
18634
+ // A FREE-TEXT QUESTION REOPENS ITS FIELD, with this question's text: `editing` is what makes the row
18635
+ // exist at all, so without this the question came back with nothing to type into.
18636
+ const qBack = qs.items[qs.index];
18637
+ if (qBack && qBack.freeText) {
18638
+ qs.editing = 'other';
18639
+ qs.editingText = (back && back.otherText) || '';
18640
+ qs.otherText = qs.editingText;
18641
+ qs.editingCaret = qs.editingText.length;
18642
+ }
18583
18643
  if (qs.index === total) {
18584
18644
  // Landing on the Other tab opens its text field directly.
18585
18645
  qs.editing = 'supplement';