@xprnvd/pi-context-view 0.7.2 → 0.8.0

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.
@@ -0,0 +1,427 @@
1
+ /**
2
+ * Shared find-as-you-type pieces for both views: the one-line query prompt,
3
+ * case-insensitive matching, and match highlighting inside rendered lines.
4
+ * Matching follows pi's fullscreen transcript search: every whitespace run,
5
+ * line breaks included, matches a single space, so a phrase that wraps across
6
+ * two preview lines is still found. No pi access beyond the theme.
7
+ */
8
+ import type { Theme } from "@earendil-works/pi-coding-agent";
9
+ import {
10
+ Input,
11
+ sliceByColumn,
12
+ stripTerminalSequences,
13
+ truncateToWidth,
14
+ visibleWidth,
15
+ } from "@earendil-works/pi-tui";
16
+
17
+ import { normalizeInlineText, normalizePreviewText } from "../text.ts";
18
+ import { BODY_INDENT, fitLine, spreadLine } from "./layout.ts";
19
+
20
+ /** Hint-row key label that opens the search prompt in either view. */
21
+ export const SEARCH_KEY = "/";
22
+ /** Hint-row key label for stepping through preview matches. */
23
+ export const MATCH_STEP_KEY_HINT = "n/N";
24
+
25
+ const ANSI_SEQUENCE = /\u001B\[[\u0030-\u003F]*[\u0020-\u002F]*[\u0040-\u007E]|\u001B[\]_P^X][^\u0007\u001B]*(?:\u0007|\u001B\\)/g;
26
+ const TRAILING_ANSI = new RegExp(`(?:${ANSI_SEQUENCE.source})+$`);
27
+ const WORD = /\S+/gu;
28
+ const PRINTABLE_ASCII = /^[\u0020-\u007E]*$/;
29
+ const segmenter = new Intl.Segmenter();
30
+
31
+ /** Visible-column span of one match on one rendered line, end exclusive. */
32
+ export interface MatchSegment {
33
+ readonly row: number;
34
+ readonly start: number;
35
+ readonly end: number;
36
+ }
37
+
38
+ /** One query occurrence; several segments when it wraps across rows. */
39
+ export interface SearchMatch {
40
+ readonly segments: readonly MatchSegment[];
41
+ }
42
+
43
+ /** One highlight range on a rendered line. */
44
+ export interface HighlightRange {
45
+ readonly start: number;
46
+ readonly end: number;
47
+ /** The match the user is on; emphasized over the others. */
48
+ readonly current: boolean;
49
+ }
50
+
51
+ /** Corpus character range mapped back to a run of visible columns on one row. */
52
+ interface CorpusSpan {
53
+ readonly textStart: number;
54
+ readonly textEnd: number;
55
+ readonly row: number;
56
+ readonly start: number;
57
+ readonly end: number;
58
+ /** One column per character, so inner offsets map linearly. */
59
+ readonly linear: boolean;
60
+ }
61
+
62
+ /** Whitespace-collapsed text of every line plus its mapping to rows and columns. */
63
+ interface SearchCorpus {
64
+ readonly text: string;
65
+ readonly spans: readonly CorpusSpan[];
66
+ }
67
+
68
+ /** Case-insensitive pattern for a query, or undefined when it is blank. */
69
+ export function searchPattern(query: string): RegExp | undefined {
70
+ const normalized = query.replace(/\s+/gu, " ").trim();
71
+ if (normalized === "") return undefined;
72
+ return new RegExp(normalized.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "iu");
73
+ }
74
+
75
+ /** Whether a row label contains the query, compared as it renders on one line. */
76
+ export function labelMatches(label: string, pattern: RegExp): boolean {
77
+ return pattern.test(normalizeInlineText(label));
78
+ }
79
+
80
+ /**
81
+ * Searchable form of raw content: sanitized like a preview, with whitespace
82
+ * collapsed so a phrase matches however it wraps. Callers cache the result.
83
+ */
84
+ export function searchableContent(text: string): string {
85
+ return normalizePreviewText(text).replace(/\s+/gu, " ");
86
+ }
87
+
88
+ /** Every occurrence of the query across rendered lines, in reading order. */
89
+ export function findMatches(lines: readonly string[], pattern: RegExp): SearchMatch[] {
90
+ const corpus = buildCorpus(lines);
91
+ const global = new RegExp(pattern.source, "giu");
92
+ const matches: SearchMatch[] = [];
93
+ let spanIndex = 0;
94
+ for (const found of corpus.text.matchAll(global)) {
95
+ const start = found.index;
96
+ const end = start + found[0].length;
97
+ if (end === start) continue;
98
+ while (spanIndex < corpus.spans.length && (corpus.spans[spanIndex]?.textEnd ?? 0) <= start) spanIndex++;
99
+ const segments: MatchSegment[] = [];
100
+ for (let index = spanIndex; index < corpus.spans.length; index++) {
101
+ const span = corpus.spans[index];
102
+ if (span === undefined || span.textStart >= end) break;
103
+ const segmentStart = span.linear ? span.start + Math.max(start, span.textStart) - span.textStart : span.start;
104
+ const segmentEnd = span.linear ? span.start + Math.min(end, span.textEnd) - span.textStart : span.end;
105
+ const previous = segments.at(-1);
106
+ // Consecutive spans on one row, including the space between words, join into one segment.
107
+ if (previous !== undefined && previous.row === span.row) {
108
+ segments[segments.length - 1] = { ...previous, end: Math.max(previous.end, segmentEnd) };
109
+ } else {
110
+ segments.push({ row: span.row, start: segmentStart, end: segmentEnd });
111
+ }
112
+ }
113
+ if (segments.length > 0) matches.push({ segments });
114
+ }
115
+ return matches;
116
+ }
117
+
118
+ /**
119
+ * Highlight ranges on one rendered line with pi's own transcript-search styles.
120
+ * Styling is reapplied to every plain run, so the line's own color resets
121
+ * cannot cut a highlight short.
122
+ */
123
+ export function highlightLine(theme: Theme, line: string, ranges: readonly HighlightRange[]): string {
124
+ const width = visibleWidth(line);
125
+ let result = "";
126
+ let column = 0;
127
+ // Every slice comes from the original line, which carries the styles active at its start.
128
+ for (const range of [...ranges].sort((a, b) => a.start - b.start)) {
129
+ const start = Math.max(column, Math.min(range.start, width));
130
+ const end = Math.min(range.end, width);
131
+ if (end <= start) continue;
132
+ result += sliceByColumn(line, column, start - column, true);
133
+ result += styleRuns(sliceByColumn(line, start, end - start, true), (text) => matchStyle(theme, text, range.current));
134
+ column = end;
135
+ }
136
+ if (column === 0) return line;
137
+ // Slicing stops at the last visible column, so restore the resets that close the line.
138
+ return `${result}${sliceByColumn(line, column, width - column, true)}${line.match(TRAILING_ANSI)?.[0] ?? ""}`;
139
+ }
140
+
141
+ /** One match segment on a row, tagged with the match it belongs to. */
142
+ interface RowSegment {
143
+ readonly match: number;
144
+ readonly start: number;
145
+ readonly end: number;
146
+ }
147
+
148
+ /** Match segments grouped by row, so rendering a row never scans every match. */
149
+ function segmentsByRow(matches: readonly SearchMatch[]): Map<number, RowSegment[]> {
150
+ const rows = new Map<number, RowSegment[]>();
151
+ for (const [match, { segments }] of matches.entries()) {
152
+ for (const segment of segments) {
153
+ const row = rows.get(segment.row);
154
+ const entry = { match, start: segment.start, end: segment.end };
155
+ if (row === undefined) rows.set(segment.row, [entry]);
156
+ else row.push(entry);
157
+ }
158
+ }
159
+ return rows;
160
+ }
161
+
162
+ /** Status text for a query: blank, `No matches`, a `current/total` ordinal, or a counted noun. */
163
+ export function matchStatus(
164
+ active: boolean,
165
+ count: number,
166
+ current: number | undefined,
167
+ noun: readonly [singular: string, plural: string] = ["match", "matches"],
168
+ ): string {
169
+ if (!active) return "";
170
+ if (count === 0) return "No matches";
171
+ if (current !== undefined) return `${current + 1}/${count}`;
172
+ return `${count} ${count === 1 ? noun[0] : noun[1]}`;
173
+ }
174
+
175
+ /** Pi's transcript-search look: underlined match colors, or bold inverse for the current match. */
176
+ function matchStyle(theme: Theme, text: string, current: boolean): string {
177
+ const colored = theme.bg("searchMatchBg", theme.fg("searchMatchText", text));
178
+ return current ? theme.bold(theme.inverse(colored)) : theme.underline(colored);
179
+ }
180
+
181
+ /** Apply a style to each plain-text run while passing escape sequences through unchanged. */
182
+ function styleRuns(text: string, style: (text: string) => string): string {
183
+ let result = "";
184
+ let offset = 0;
185
+ for (const sequence of text.matchAll(ANSI_SEQUENCE)) {
186
+ if (sequence.index > offset) result += style(text.slice(offset, sequence.index));
187
+ result += sequence[0];
188
+ offset = sequence.index + sequence[0].length;
189
+ }
190
+ if (offset < text.length) result += style(text.slice(offset));
191
+ return result;
192
+ }
193
+
194
+ /** Join every word on every line with single spaces, remembering where each word renders. */
195
+ function buildCorpus(lines: readonly string[]): SearchCorpus {
196
+ let text = "";
197
+ const spans: CorpusSpan[] = [];
198
+ for (const [row, line] of lines.entries()) {
199
+ const plain = stripTerminalSequences(line);
200
+ for (const word of plain.matchAll(WORD)) {
201
+ if (text.length > 0) text += " ";
202
+ const column = visibleWidth(plain.slice(0, word.index));
203
+ if (PRINTABLE_ASCII.test(word[0])) {
204
+ spans.push({
205
+ textStart: text.length,
206
+ textEnd: text.length + word[0].length,
207
+ row,
208
+ start: column,
209
+ end: column + word[0].length,
210
+ linear: true,
211
+ });
212
+ text += word[0];
213
+ continue;
214
+ }
215
+ let graphemeColumn = column;
216
+ for (const { segment } of segmenter.segment(word[0])) {
217
+ const width = visibleWidth(segment);
218
+ spans.push({
219
+ textStart: text.length,
220
+ textEnd: text.length + segment.length,
221
+ row,
222
+ start: graphemeColumn,
223
+ end: graphemeColumn + width,
224
+ linear: false,
225
+ });
226
+ text += segment;
227
+ graphemeColumn += width;
228
+ }
229
+ }
230
+ }
231
+ return { text, spans };
232
+ }
233
+
234
+ /** Selection change waiting for the next render, which knows the wrapped lines. */
235
+ type PendingMove = "first" | -1 | 1;
236
+
237
+ /**
238
+ * Find-in-preview state over rendered lines: the query prompt, its matches,
239
+ * and the current match. Wrapping depends on width, so the view hands over
240
+ * freshly rendered lines every frame and moves are resolved against them.
241
+ */
242
+ export class PreviewSearch {
243
+ public readonly prompt = new SearchPrompt();
244
+ private matchList: readonly SearchMatch[] = [];
245
+ private matchRows = new Map<number, RowSegment[]>();
246
+ private source: readonly string[] | undefined;
247
+ private sourceQuery = "";
248
+ private currentIndex: number | undefined;
249
+ private pending: PendingMove | undefined;
250
+ private anchor = 0;
251
+
252
+ /** Matches in the lines last passed to `sync`. */
253
+ public get matches(): readonly SearchMatch[] {
254
+ return this.matchList;
255
+ }
256
+
257
+ /** Index of the current match, if any. */
258
+ public get current(): number | undefined {
259
+ return this.currentIndex;
260
+ }
261
+
262
+ /** Status for the prompt row. */
263
+ public get status(): string {
264
+ return matchStatus(this.prompt.active, this.matchList.length, this.currentIndex);
265
+ }
266
+
267
+ /** Open the prompt; typing selects the first match at or after `anchorRow`. */
268
+ public edit(anchorRow: number): void {
269
+ this.anchor = anchorRow;
270
+ this.prompt.edit();
271
+ }
272
+
273
+ /** Feed one key to the open prompt; returns whether the query changed. */
274
+ public type(data: string): boolean {
275
+ if (!this.prompt.type(data)) return false;
276
+ this.pending = "first";
277
+ return true;
278
+ }
279
+
280
+ /** Select the first match at or after `anchorRow` once the next frame's lines are known. */
281
+ public restart(anchorRow = 0): void {
282
+ this.anchor = anchorRow;
283
+ this.currentIndex = undefined;
284
+ this.pending = this.prompt.active ? "first" : undefined;
285
+ }
286
+
287
+ /** Move to the next or previous match, wrapping around; false without a query. */
288
+ public step(direction: -1 | 1): boolean {
289
+ if (!this.prompt.active) return false;
290
+ this.pending = direction;
291
+ return true;
292
+ }
293
+
294
+ /** Drop the query, its matches, and any pending move. */
295
+ public clear(): void {
296
+ this.prompt.clear();
297
+ this.matchList = [];
298
+ this.matchRows.clear();
299
+ this.source = undefined;
300
+ this.sourceQuery = "";
301
+ this.currentIndex = undefined;
302
+ this.pending = undefined;
303
+ }
304
+
305
+ /**
306
+ * Match the query against this frame's lines and resolve any pending move.
307
+ * Returns the row the view should reveal, or undefined when nothing moved.
308
+ */
309
+ public sync(lines: readonly string[]): number | undefined {
310
+ if (lines !== this.source || this.prompt.query !== this.sourceQuery) {
311
+ this.source = lines;
312
+ this.sourceQuery = this.prompt.query;
313
+ const pattern = this.prompt.pattern;
314
+ this.matchList = pattern === undefined ? [] : findMatches(lines, pattern);
315
+ this.matchRows = segmentsByRow(this.matchList);
316
+ if (this.currentIndex !== undefined && this.currentIndex >= this.matchList.length) {
317
+ this.currentIndex = this.matchList.length === 0 ? undefined : this.matchList.length - 1;
318
+ }
319
+ }
320
+ const pending = this.pending;
321
+ this.pending = undefined;
322
+ if (pending === undefined || this.matchList.length === 0) {
323
+ if (pending !== undefined) this.currentIndex = undefined;
324
+ return undefined;
325
+ }
326
+ this.currentIndex = this.resolve(pending);
327
+ return this.matchList[this.currentIndex]?.segments[0]?.row;
328
+ }
329
+
330
+ /** Highlight every match segment on one rendered row. */
331
+ public highlight(theme: Theme, line: string, row: number): string {
332
+ const segments = this.matchRows.get(row);
333
+ if (segments === undefined) return line;
334
+ const ranges = segments.map(({ match, start, end }) => ({ start, end, current: match === this.currentIndex }));
335
+ return highlightLine(theme, line, ranges);
336
+ }
337
+
338
+ /** Index a pending move lands on; the match list is non-empty. */
339
+ private resolve(pending: PendingMove): number {
340
+ const count = this.matchList.length;
341
+ if (pending === "first") {
342
+ const index = this.matchList.findIndex((match) => (match.segments[0]?.row ?? 0) >= this.anchor);
343
+ return index === -1 ? 0 : index;
344
+ }
345
+ if (this.currentIndex === undefined) return pending === 1 ? 0 : count - 1;
346
+ return (this.currentIndex + pending + count) % count;
347
+ }
348
+ }
349
+
350
+ /**
351
+ * One-line query prompt. While open it edits through pi's own `Input`, so
352
+ * word deletion, kill/yank, and undo behave as everywhere else in pi; once
353
+ * submitted it keeps showing the query as read-only text.
354
+ */
355
+ export class SearchPrompt {
356
+ private input = new Input({ prompt: "" });
357
+ private open = false;
358
+
359
+ /** Whether keys currently edit the query. */
360
+ public get editing(): boolean {
361
+ return this.open;
362
+ }
363
+
364
+ /** The query as typed. */
365
+ public get query(): string {
366
+ return this.input.getValue();
367
+ }
368
+
369
+ /** Whether a non-blank query is set, typed or submitted. */
370
+ public get active(): boolean {
371
+ return searchPattern(this.query) !== undefined;
372
+ }
373
+
374
+ /** Pattern for the current query, or undefined when it is blank. */
375
+ public get pattern(): RegExp | undefined {
376
+ return searchPattern(this.query);
377
+ }
378
+
379
+ /** Start editing, keeping the current query so it can be refined. */
380
+ public edit(): void {
381
+ this.open = true;
382
+ }
383
+
384
+ /** Stop editing and keep the query. */
385
+ public submit(): void {
386
+ this.open = false;
387
+ }
388
+
389
+ /** Drop the query and stop editing. */
390
+ public clear(): void {
391
+ this.input = new Input({ prompt: "" });
392
+ this.open = false;
393
+ }
394
+
395
+ /** Replace the query with the cursor at its end, without editing. */
396
+ public set(query: string): void {
397
+ this.input = new Input({ prompt: "" });
398
+ this.input.handleInput(query);
399
+ this.open = false;
400
+ }
401
+
402
+ /** Feed one key to the open prompt; returns whether the query changed. */
403
+ public type(data: string): boolean {
404
+ const previous = this.query;
405
+ this.input.handleInput(data);
406
+ return this.query !== previous;
407
+ }
408
+
409
+ /**
410
+ * Prompt row: dim label, the query (with pi's fake cursor while editing),
411
+ * and a right-aligned status such as the match count.
412
+ */
413
+ public render(theme: Theme, width: number, label: string, status: string): string {
414
+ return this.renderAfter(theme, width, theme.fg("dim", `${label}: `), status);
415
+ }
416
+
417
+ /** Prompt row after an already styled `lead`, such as a mode switcher, instead of a plain label. */
418
+ public renderAfter(theme: Theme, width: number, lead: string, status: string): string {
419
+ const prefix = `${BODY_INDENT}${lead}`;
420
+ const right = status === "" ? "" : `${theme.fg("dim", status)} `;
421
+ const queryWidth = Math.max(1, width - visibleWidth(prefix) - visibleWidth(right) - 1);
422
+ const query = this.open
423
+ ? truncateToWidth(this.input.render(queryWidth)[0] ?? "", queryWidth, "").trimEnd()
424
+ : theme.fg("text", truncateToWidth(normalizeInlineText(this.query), queryWidth, "…"));
425
+ return right === "" ? fitLine(`${prefix}${query}`, width) : spreadLine(`${prefix}${query}`, right, width);
426
+ }
427
+ }
@@ -106,6 +106,14 @@ export class BlockNavigator {
106
106
  return this.moveTo(this.firstVisibleBlock(offset), offset);
107
107
  }
108
108
 
109
+ /** Select one block, e.g. the one holding a search match, scrolling minimally to show it. */
110
+ public select(index: number): boolean {
111
+ if (index < 0 || index >= this.extents.length || index === this.selectedIndex) return false;
112
+ this.selectedIndex = index;
113
+ this.revealSelected();
114
+ return true;
115
+ }
116
+
109
117
  /** Select the first block at the top of the stream. */
110
118
  public moveToFirst(): boolean {
111
119
  return this.moveTo(0, 0);