mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
fix(tui): make fullscreen transcript search scale linearly
This commit is contained in:
parent
0dbf2b76fe
commit
2d41163332
5 changed files with 206 additions and 48 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue