fix(tui): make fullscreen transcript search scale linearly

This commit is contained in:
Armin Ronacher 2026-09-04 10:21:08 +02:00
parent 0dbf2b76fe
commit 2d41163332
5 changed files with 206 additions and 48 deletions

View file

@ -9,6 +9,7 @@
### Changed
- Moved the streaming working indicator into the default editor border and matched its default spinner and label to the thinking-level border color. Custom editors retain the standalone indicator unless they opt in to embedding it.
- Reduced inherited fullscreen transcript search latency on large transcripts by caching unchanged search results, indexing ASCII runs, and limiting highlight work to visible matches.
### Fixed

View file

@ -10,6 +10,10 @@
- Added a `TuiAltScreen` `scrollToEndIndicator` option that renders a clickable jump-to-end label on a follow-end primary scroll view while it is scrolled away from the end ([#9080](https://github.com/earendil-works/pi/pull/9080) by [@rwachtler](https://github.com/rwachtler)).
### Changed
- Reduced fullscreen transcript search latency on large transcripts by caching unchanged search results, indexing ASCII runs, and limiting highlight work to visible matches.
## [0.84.4] - 2026-08-28
### Added

View file

@ -6,9 +6,17 @@ import { getGraphemeSegmenter, stripTerminalSequences, truncateToWidth, visibleW
const segmenter = getGraphemeSegmenter();
interface SearchSourceSpan {
textStart: number;
textEnd: number;
row: number;
startCol: number;
endCol: number;
linearColumns: boolean;
}
interface SearchCorpus {
text: string;
spans: SearchSourceSpan[];
}
export interface AltScreenSearchSegment {
@ -21,44 +29,80 @@ export interface AltScreenSearchMatch {
segments: AltScreenSearchSegment[];
}
function appendMappedText(
text: string,
span: SearchSourceSpan | undefined,
corpus: { text: string; source: Array<SearchSourceSpan | undefined> },
): void {
corpus.text += text;
for (let index = 0; index < text.length; index++) corpus.source.push(span);
}
const PRINTABLE_ASCII = /^[\x20-\x7e]*$/;
function buildSearchCorpus(lines: readonly string[]): {
text: string;
source: Array<SearchSourceSpan | undefined>;
} {
const corpus: { text: string; source: Array<SearchSourceSpan | undefined> } = { text: "", source: [] };
function buildSearchCorpus(lines: readonly string[]): SearchCorpus {
const chunks: string[] = [];
const spans: SearchSourceSpan[] = [];
let textLength = 0;
let pendingSeparator = false;
const appendSeparator = (): void => {
if (!pendingSeparator) return;
chunks.push(" ");
textLength += 1;
pendingSeparator = false;
};
for (let row = 0; row < lines.length; row++) {
const line = stripTerminalSequences(lines[row] ?? "");
let column = 0;
for (const grapheme of segmenter.segment(line)) {
const text = grapheme.segment;
const width = visibleWidth(text);
if (/^\s+$/u.test(text)) {
if (corpus.text.length > 0) pendingSeparator = true;
// Rendered transcripts are overwhelmingly ASCII. Index complete non-space
// runs at once instead of segmenting and allocating one mapping per cell.
if (PRINTABLE_ASCII.test(line)) {
let index = 0;
while (index < line.length) {
if (line.charCodeAt(index) === 0x20) {
if (textLength > 0) pendingSeparator = true;
column += 1;
index += 1;
continue;
}
let end = index + 1;
while (end < line.length && line.charCodeAt(end) !== 0x20) end += 1;
appendSeparator();
const text = line.slice(index, end);
chunks.push(text);
spans.push({
textStart: textLength,
textEnd: textLength + text.length,
row,
startCol: column,
endCol: column + text.length,
linearColumns: true,
});
textLength += text.length;
column += text.length;
index = end;
}
} else {
for (const grapheme of segmenter.segment(line)) {
const text = grapheme.segment;
const width = visibleWidth(text);
if (/^\s+$/u.test(text)) {
if (textLength > 0) pendingSeparator = true;
column += width;
continue;
}
appendSeparator();
chunks.push(text);
spans.push({
textStart: textLength,
textEnd: textLength + text.length,
row,
startCol: column,
endCol: column + width,
linearColumns: false,
});
textLength += text.length;
column += width;
continue;
}
if (pendingSeparator) {
appendMappedText(" ", undefined, corpus);
pendingSeparator = false;
}
appendMappedText(text, { row, startCol: column, endCol: column + width }, corpus);
column += width;
}
if (corpus.text.length > 0) pendingSeparator = true;
if (textLength > 0) pendingSeparator = true;
}
return corpus;
return { text: chunks.join(""), spans };
}
function normalizeQuery(query: string): string {
@ -69,34 +113,81 @@ function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function findAltScreenSearchMatches(lines: readonly string[], query: string): AltScreenSearchMatch[] {
const normalizedQuery = normalizeQuery(query);
function findSearchCorpusMatches(corpus: SearchCorpus, normalizedQuery: string): AltScreenSearchMatch[] {
if (!normalizedQuery) return [];
const corpus = buildSearchCorpus(lines);
const expression = new RegExp(escapeRegExp(normalizedQuery), "giu");
const matches: AltScreenSearchMatch[] = [];
let spanIndex = 0;
for (const match of corpus.text.matchAll(expression)) {
const start = match.index;
const end = start + match[0].length;
while (spanIndex < corpus.spans.length && corpus.spans[spanIndex]!.textEnd <= start) spanIndex += 1;
const segments: AltScreenSearchSegment[] = [];
for (let index = start; index < end; index++) {
const span = corpus.source[index];
if (!span) continue;
for (let index = spanIndex; index < corpus.spans.length; index++) {
const span = corpus.spans[index]!;
if (span.textStart >= end) break;
if (span.textEnd <= start) continue;
const startCol = span.linearColumns
? span.startCol + Math.max(start, span.textStart) - span.textStart
: span.startCol;
const endCol = span.linearColumns ? span.startCol + Math.min(end, span.textEnd) - span.textStart : span.endCol;
const previous = segments[segments.length - 1];
if (previous && previous.row === span.row && span.startCol <= previous.endCol) {
previous.endCol = Math.max(previous.endCol, span.endCol);
if (previous && previous.row === span.row && startCol <= previous.endCol) {
previous.endCol = Math.max(previous.endCol, endCol);
} else {
segments.push({ ...span });
segments.push({ row: span.row, startCol, endCol });
}
}
while (spanIndex < corpus.spans.length && corpus.spans[spanIndex]!.textEnd <= end) spanIndex += 1;
if (segments.length > 0) matches.push({ segments });
}
return matches;
}
export interface AltScreenSearchResult {
matches: AltScreenSearchMatch[];
changed: boolean;
}
/** Cache the searchable corpus and matches while rendered transcript lines remain unchanged. */
export class AltScreenSearchIndex {
private sourceLines: string[] | undefined;
private corpus: SearchCorpus | undefined;
private normalizedQuery: string | undefined;
private matches: AltScreenSearchMatch[] = [];
search(lines: readonly string[], query: string): AltScreenSearchResult {
let sourceChanged = this.sourceLines?.length !== lines.length;
if (!sourceChanged && this.sourceLines) {
for (let index = 0; index < lines.length; index++) {
if (this.sourceLines[index] === lines[index]) continue;
sourceChanged = true;
break;
}
}
if (sourceChanged || !this.corpus) {
this.sourceLines = Array.from(lines);
this.corpus = buildSearchCorpus(lines);
}
const normalizedQuery = normalizeQuery(query);
const changed = sourceChanged || normalizedQuery !== this.normalizedQuery;
if (changed) {
this.normalizedQuery = normalizedQuery;
this.matches = findSearchCorpusMatches(this.corpus, normalizedQuery);
}
return { matches: this.matches, changed };
}
}
export function findAltScreenSearchMatches(lines: readonly string[], query: string): AltScreenSearchMatch[] {
const normalizedQuery = normalizeQuery(query);
return normalizedQuery ? findSearchCorpusMatches(buildSearchCorpus(lines), normalizedQuery) : [];
}
export function getAltScreenSearchMatchKey(match: AltScreenSearchMatch): string {
const first = match.segments[0];
const last = match.segments[match.segments.length - 1];

View file

@ -1,7 +1,7 @@
import {
AltScreenSearchComponent,
AltScreenSearchIndex,
type AltScreenSearchMatch,
findAltScreenSearchMatches,
getAltScreenSearchMatchKey,
} from "./alt-screen-search.ts";
import { AltScreenFlashContainer } from "./components/alt-screen-flash.ts";
@ -145,6 +145,7 @@ type SearchSelectionMode = "query" | "retain" | "next" | "previous";
interface ActiveSearch {
component: AltScreenSearchComponent;
index: AltScreenSearchIndex;
overlay?: OverlayHandle;
query: string;
matches: AltScreenSearchMatch[];
@ -502,6 +503,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
);
const search: ActiveSearch = {
component,
index: new AltScreenSearchIndex(),
query: "",
matches: [],
selectedIndex: -1,
@ -581,15 +583,27 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
}
const shouldRevealSelection = search.selectionMode !== "retain";
const matches = findAltScreenSearchMatches(lines, search.query);
const exactIndex = search.selectedKey
? matches.findIndex((match) => getAltScreenSearchMatchKey(match) === search.selectedKey)
: -1;
const result = search.index.search(lines, search.query);
const matches = result.matches;
search.matches = matches;
if (!result.changed && search.selectionMode === "retain") return false;
const exactIndex = result.changed
? search.selectedKey
? matches.findIndex((match) => getAltScreenSearchMatchKey(match) === search.selectedKey)
: -1
: search.selectedIndex;
let selectedIndex = -1;
if (matches.length > 0) {
if (search.selectionMode === "query") {
selectedIndex = matches.findIndex((match) => (match.segments[0]?.row ?? 0) >= search.anchorRow);
if (selectedIndex < 0) selectedIndex = 0;
let low = 0;
let high = matches.length;
while (low < high) {
const middle = low + Math.floor((high - low) / 2);
if ((matches[middle]!.segments[0]?.row ?? 0) < search.anchorRow) low = middle + 1;
else high = middle;
}
selectedIndex = low < matches.length ? low : 0;
} else if (search.selectionMode === "next") {
const baseIndex = exactIndex >= 0 ? exactIndex : Math.min(search.selectedIndex, matches.length - 1);
selectedIndex = baseIndex < 0 ? 0 : (baseIndex + 1) % matches.length;
@ -602,7 +616,6 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
}
}
search.matches = matches;
search.selectedIndex = selectedIndex;
search.selectedKey = selectedIndex >= 0 ? getAltScreenSearchMatchKey(matches[selectedIndex]!) : undefined;
search.selectionMode = "retain";
@ -1480,8 +1493,21 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
box.clip.x + box.clip.width,
scrollbarColumn ?? Number.POSITIVE_INFINITY,
);
for (let matchIndex = 0; matchIndex < search.matches.length; matchIndex++) {
for (const segment of search.matches[matchIndex]!.segments) {
const minContentRow = scrollView.scrollTop + minRow - box.rect.y;
const maxContentRow = scrollView.scrollTop + maxRow - box.rect.y - 1;
let low = 0;
let high = search.matches.length;
while (low < high) {
const middle = low + Math.floor((high - low) / 2);
const match = search.matches[middle]!;
const lastRow = match.segments[match.segments.length - 1]?.row ?? -1;
if (lastRow < minContentRow) low = middle + 1;
else high = middle;
}
for (let matchIndex = low; matchIndex < search.matches.length; matchIndex++) {
const match = search.matches[matchIndex]!;
if ((match.segments[0]?.row ?? 0) > maxContentRow) break;
for (const segment of match.segments) {
const row = box.rect.y + segment.row - scrollView.scrollTop;
if (row < minRow || row >= maxRow) continue;
const startCol = Math.max(minColumn, box.rect.x + segment.startCol);

View file

@ -1,6 +1,10 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { AltScreenSearchComponent, findAltScreenSearchMatches } from "../src/alt-screen-search.ts";
import {
AltScreenSearchComponent,
AltScreenSearchIndex,
findAltScreenSearchMatches,
} from "../src/alt-screen-search.ts";
import { HStack } from "../src/components/h-stack.ts";
import { Image } from "../src/components/image.ts";
import { MouseRegion } from "../src/components/mouse-region.ts";
@ -514,6 +518,38 @@ describe("TuiAltScreen", () => {
]);
});
it("maps normalized ASCII and Unicode search matches back to rendered columns", () => {
assert.deepStrictEqual(findAltScreenSearchMatches(["\x1b[31mfoo bar\x1b[0m", "A界🙂éZ"], "oo bar\nA界🙂é"), [
{
segments: [
{ row: 0, startCol: 1, endCol: 3 },
{ row: 0, startCol: 5, endCol: 8 },
{ row: 1, startCol: 0, endCol: 6 },
],
},
]);
});
it("reuses indexed transcript matches until the query or rendered lines change", () => {
const index = new AltScreenSearchIndex();
const initial = index.search(["alpha needle", "omega"], "needle");
assert.strictEqual(initial.changed, true);
assert.strictEqual(initial.matches.length, 1);
const cached = index.search(["alpha needle", "omega"], "needle");
assert.strictEqual(cached.changed, false);
assert.strictEqual(cached.matches, initial.matches);
const changedQuery = index.search(["alpha needle", "omega"], "omega");
assert.strictEqual(changedQuery.changed, true);
assert.notStrictEqual(changedQuery.matches, initial.matches);
assert.deepStrictEqual(changedQuery.matches[0]?.segments, [{ row: 1, startCol: 0, endCol: 5 }]);
const changedLines = index.search(["alpha needle", "no match"], "omega");
assert.strictEqual(changedLines.changed, true);
assert.deepStrictEqual(changedLines.matches, []);
});
it("renders transcript search with a muted placeholder and right-aligned controls", () => {
const component = new AltScreenSearchComponent(() => {});
const rendered = component.render(48);