@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/components/select-list.d.ts.map +1 -1
- package/dist/components/select-list.js +14 -2
- package/dist/components/select-list.js.map +1 -1
- package/dist/keybindings.d.ts +2 -2
- package/dist/keybindings.d.ts.map +1 -1
- package/dist/keybindings.js +2 -2
- package/dist/keybindings.js.map +1 -1
- package/dist/keys.d.ts.map +1 -1
- package/dist/keys.js +7 -0
- package/dist/keys.js.map +1 -1
- package/dist/stdin-buffer.d.ts +8 -2
- package/dist/stdin-buffer.d.ts.map +1 -1
- package/dist/stdin-buffer.js +11 -5
- package/dist/stdin-buffer.js.map +1 -1
- package/dist/terminal.d.ts.map +1 -1
- package/dist/terminal.js +3 -1
- package/dist/terminal.js.map +1 -1
- package/dist/tui.d.ts +31 -2
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +103 -66
- package/dist/tui.js.map +1 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +16 -2
- package/dist/utils.js.map +1 -1
- package/package.json +1 -1
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
|
|
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
|
-
? (
|
|
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
|
-
|
|
763
|
-
|
|
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
|
-
|
|
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 (
|
|
1355
|
-
|
|
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)
|