@ashx-j/lunr-tui 0.2.39 → 0.2.40

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/dist/tui.js CHANGED
@@ -129,7 +129,7 @@ export class TUI extends Container {
129
129
  previousWidth = 0;
130
130
  previousHeight = 0;
131
131
  focusedComponent = null;
132
- inputListeners = new Set();
132
+ inputListeners = new Map();
133
133
  /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
134
134
  onDebug;
135
135
  /** Kept for callers; left-drag no longer selects. Shift+drag stays native terminal selection. */
@@ -448,36 +448,6 @@ export class TUI extends Container {
448
448
  }
449
449
  return start;
450
450
  }
451
- findChatAnchor(previous, next, start, viewH) {
452
- for (let offset = 0; offset < Math.min(4, viewH, previous.length - start); offset++) {
453
- const line = previous[start + offset];
454
- if (!line || visibleWidth(line) === 0)
455
- continue;
456
- const following = previous[start + offset + 1];
457
- let best;
458
- let bestScore = 0;
459
- for (let match = next.indexOf(line); match !== -1; match = next.indexOf(line, match + 1)) {
460
- if (following !== undefined && next[match + 1] !== following)
461
- continue;
462
- const candidate = match - offset;
463
- if (candidate < 0)
464
- continue;
465
- let score = 1;
466
- while (score < 12 &&
467
- start + offset + score < previous.length &&
468
- previous[start + offset + score] === next[match + score])
469
- score++;
470
- if (score > bestScore ||
471
- (score === bestScore && (best === undefined || Math.abs(candidate - start) < Math.abs(best - start)))) {
472
- best = candidate;
473
- bestScore = score;
474
- }
475
- }
476
- if (best !== undefined)
477
- return best;
478
- }
479
- return undefined;
480
- }
481
451
  render(width) {
482
452
  const pinIndex = this.getPinIndex();
483
453
  if (pinIndex < 0) {
@@ -551,7 +521,8 @@ export class TUI extends Container {
551
521
  const scrollMax = scrollLines.length - viewH;
552
522
  if (this.chatScrollOffset > 0) {
553
523
  const anchor = cache && !fromCache
554
- ? (this.findChatAnchor(cache.scrollLines, scrollLines, this.lastChatStart, this.lastChatViewportHeight) ?? this.lastChatStart)
524
+ ? (findChatAnchor(cache.scrollLines, scrollLines, this.lastChatStart, this.lastChatViewportHeight) ??
525
+ this.lastChatStart)
555
526
  : this.lastChatStart;
556
527
  const pendingScroll = this.chatScrollOffset - (this.lastChatScrollMax - this.lastChatStart);
557
528
  this.chatScrollOffset = scrollMax - anchor + pendingScroll;
@@ -759,8 +730,12 @@ export class TUI extends Container {
759
730
  this.queryCellSize();
760
731
  this.requestRender();
761
732
  }
762
- addInputListener(listener) {
763
- this.inputListeners.add(listener);
733
+ /**
734
+ * Run `listener` on raw input before the focused component sees it. Listeners skip
735
+ * Kitty key-release events unless they pass `{ wantsKeyRelease: true }`.
736
+ */
737
+ addInputListener(listener, options = {}) {
738
+ this.inputListeners.set(listener, options);
764
739
  return () => {
765
740
  this.inputListeners.delete(listener);
766
741
  };
@@ -825,7 +800,10 @@ export class TUI extends Container {
825
800
  // Response format: CSI 6 ; height ; width t
826
801
  this.terminal.write("\x1b[16t");
827
802
  }
803
+ /** Restore the terminal. Safe to call more than once: later calls before the next `start()` do nothing. */
828
804
  stop() {
805
+ if (this.stopped)
806
+ return;
829
807
  this.stopped = true;
830
808
  this.started = false;
831
809
  if (this.renderTimer) {
@@ -1060,7 +1038,9 @@ export class TUI extends Container {
1060
1038
  }
1061
1039
  if (this.inputListeners.size > 0) {
1062
1040
  let current = data;
1063
- for (const listener of this.inputListeners) {
1041
+ for (const [listener, options] of this.inputListeners) {
1042
+ if (!options.wantsKeyRelease && isKeyRelease(current))
1043
+ continue;
1064
1044
  const result = listener(current);
1065
1045
  if (result?.consume) {
1066
1046
  return;
@@ -1347,16 +1327,50 @@ export class TUI extends Container {
1347
1327
  return result;
1348
1328
  }
1349
1329
  static SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07";
1350
- applyLineResets(lines) {
1330
+ /**
1331
+ * Normalize each text line, cut it to the terminal width and close its styles. Full and
1332
+ * partial redraws both write these lines, so an overflowing component cannot wrap the screen.
1333
+ * The first overflow per TUI writes the rendered lines to pi-crash.log for debugging.
1334
+ */
1335
+ applyLineResets(lines, width) {
1351
1336
  const reset = TUI.SEGMENT_RESET;
1352
1337
  for (let i = 0; i < lines.length; i++) {
1353
1338
  const line = lines[i];
1354
- if (!isImageLine(line)) {
1355
- lines[i] = normalizeTerminalOutput(line) + reset;
1339
+ if (isImageLine(line))
1340
+ continue;
1341
+ let normalized = normalizeTerminalOutput(line);
1342
+ if (visibleWidth(normalized) > width) {
1343
+ this.logWidthOverflow(lines, i, width);
1344
+ normalized = sliceByColumn(normalized, 0, width, true);
1356
1345
  }
1346
+ lines[i] = normalized + reset;
1357
1347
  }
1358
1348
  return lines;
1359
1349
  }
1350
+ widthOverflowLogged = false;
1351
+ logWidthOverflow(lines, index, width) {
1352
+ if (this.widthOverflowLogged)
1353
+ return;
1354
+ this.widthOverflowLogged = true;
1355
+ // lunr: tui cannot import coding-agent (layering) — env-first with .lunr fallback (was ~/.pi/agent)
1356
+ const logPath = path.join(process.env.PI_CODING_AGENT_DIR || path.join(os.homedir(), ".lunr", "agent"), "pi-crash.log");
1357
+ const data = [
1358
+ `Line wider than the terminal at ${new Date().toISOString()}; truncated. A component is not truncating its output (use truncateToWidth()).`,
1359
+ `Terminal width: ${width}`,
1360
+ `Line ${index} visible width: ${visibleWidth(lines[index] ?? "")}`,
1361
+ "",
1362
+ "=== All rendered lines ===",
1363
+ ...lines.map((l, idx) => `[${idx}] (w=${visibleWidth(l)}) ${l}`),
1364
+ "",
1365
+ ].join("\n");
1366
+ try {
1367
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
1368
+ fs.writeFileSync(logPath, data);
1369
+ }
1370
+ catch {
1371
+ // a debug log must not break rendering
1372
+ }
1373
+ }
1360
1374
  collectKittyImageIds(lines) {
1361
1375
  const ids = new Set();
1362
1376
  for (const line of lines) {
@@ -1505,7 +1519,7 @@ export class TUI extends Container {
1505
1519
  }
1506
1520
  // Extract cursor position before applying line resets (marker must be found first)
1507
1521
  const cursorPos = this.extractCursorPosition(newLines, height);
1508
- newLines = this.applyLineResets(newLines);
1522
+ newLines = this.applyLineResets(newLines, width);
1509
1523
  // Helper to clear scrollback and viewport and render all new lines
1510
1524
  const fullRender = (clear) => {
1511
1525
  this.fullRedrawCount += 1;
@@ -1735,33 +1749,6 @@ export class TUI extends Container {
1735
1749
  continue;
1736
1750
  }
1737
1751
  buffer += "\x1b[2K"; // Clear current line
1738
- if (!isImage && visibleWidth(line) > width) {
1739
- // Log all lines to crash file for debugging
1740
- // lunr: tui cannot import coding-agent (layering) — env-first with .lunr fallback (was ~/.pi/agent)
1741
- const crashLogPath = path.join(process.env.PI_CODING_AGENT_DIR || path.join(os.homedir(), ".lunr", "agent"), "pi-crash.log");
1742
- const crashData = [
1743
- `Crash at ${new Date().toISOString()}`,
1744
- `Terminal width: ${width}`,
1745
- `Line ${i} visible width: ${visibleWidth(line)}`,
1746
- "",
1747
- "=== All rendered lines ===",
1748
- ...newLines.map((l, idx) => `[${idx}] (w=${visibleWidth(l)}) ${l}`),
1749
- "",
1750
- ].join("\n");
1751
- fs.mkdirSync(path.dirname(crashLogPath), { recursive: true });
1752
- fs.writeFileSync(crashLogPath, crashData);
1753
- // Clean up terminal state before throwing
1754
- this.stop();
1755
- const errorMsg = [
1756
- `Rendered line ${i} exceeds terminal width (${visibleWidth(line)} > ${width}).`,
1757
- "",
1758
- "This is likely caused by a custom TUI component not truncating its output.",
1759
- "Use visibleWidth() to measure and truncateToWidth() to truncate lines.",
1760
- "",
1761
- `Debug log written to: ${crashLogPath}`,
1762
- ].join("\n");
1763
- throw new Error(errorMsg);
1764
- }
1765
1752
  buffer += line;
1766
1753
  }
1767
1754
  // Track where cursor ended up after rendering
@@ -1915,6 +1902,56 @@ export class TUI extends Container {
1915
1902
  });
1916
1903
  }
1917
1904
  }
1905
+ /**
1906
+ * Finds the start row in `next` that keeps a scrolled-up chat view on the same content it showed at
1907
+ * `previous[start]` after a re-layout. Up to four leading non-blank visible lines are tried in order.
1908
+ * For each, the copy in `next` whose following lines match longest (up to 12) wins, ties going to the
1909
+ * copy closest to `start` and then to the earlier one. Returns undefined when no line matches, so the
1910
+ * caller keeps the old position (the width-reflow case).
1911
+ *
1912
+ * Copies are visited nearest-first and the search stops once a copy matches the longest possible run,
1913
+ * so long runs of identical rows cost a few comparisons instead of a scan of the whole chat. When no
1914
+ * copy reaches that run (the visible lines changed), it still checks every copy.
1915
+ * Exported for tests only.
1916
+ */
1917
+ export function findChatAnchor(previous, next, start, viewH) {
1918
+ for (let offset = 0; offset < Math.min(4, viewH, previous.length - start); offset++) {
1919
+ const at = start + offset;
1920
+ const line = previous[at];
1921
+ if (!line || visibleWidth(line) === 0)
1922
+ continue;
1923
+ const following = previous[at + 1];
1924
+ const maxScore = Math.min(12, previous.length - at);
1925
+ // A copy at `match` anchors the view at `match - offset`, which must not be negative.
1926
+ const lo = Math.max(offset, next.indexOf(line));
1927
+ const hi = next.lastIndexOf(line);
1928
+ if (hi < lo)
1929
+ continue;
1930
+ let best;
1931
+ let bestScore = 0;
1932
+ // Visit rows at, at-1, at+1, at-2, at+2, ... so the order already encodes the distance tie-break
1933
+ // and only a strictly longer run replaces the best.
1934
+ const steps = 2 * Math.max(at - lo, hi - at);
1935
+ for (let step = 0; step <= steps && bestScore < maxScore; step++) {
1936
+ const distance = (step + 1) >> 1;
1937
+ const match = step % 2 === 1 ? at - distance : at + distance;
1938
+ if (match < lo || match > hi || next[match] !== line)
1939
+ continue;
1940
+ if (following !== undefined && next[match + 1] !== following)
1941
+ continue;
1942
+ let score = 1;
1943
+ while (score < 12 && at + score < previous.length && previous[at + score] === next[match + score])
1944
+ score++;
1945
+ if (score > bestScore) {
1946
+ best = match - offset;
1947
+ bestScore = score;
1948
+ }
1949
+ }
1950
+ if (best !== undefined)
1951
+ return best;
1952
+ }
1953
+ return undefined;
1954
+ }
1918
1955
  function padToWidth(line, width) {
1919
1956
  const vis = visibleWidth(line);
1920
1957
  if (vis >= width)