@oh-my-pi/pi-tui 18.7.0 → 18.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.
- package/CHANGELOG.md +21 -52
- package/dist/types/apps/debug/log-viewer.d.ts +4 -0
- package/dist/types/apps/debug/raw-sse-buffer.d.ts +2 -0
- package/dist/types/chat/fence-figure.d.ts +42 -0
- package/dist/types/chat/svg-figure.d.ts +1 -1
- package/dist/types/chat/tool-execution.d.ts +2 -0
- package/dist/types/components/editor.d.ts +8 -3
- package/dist/types/components/markdown.d.ts +11 -6
- package/dist/types/fuzzy.d.ts +17 -3
- package/dist/types/native/backend.d.ts +2 -1
- package/dist/types/native/blobs.d.ts +4 -2
- package/dist/types/native/encode.d.ts +3 -4
- package/dist/types/overlays/agent-transcript-viewer.d.ts +8 -1
- package/dist/types/overlays/history-search.d.ts +10 -0
- package/dist/types/overlays/model-browser.d.ts +6 -0
- package/dist/types/prompt/custom-editor.d.ts +7 -4
- package/dist/types/prompt/interactive-context-helpers.d.ts +2 -1
- package/dist/types/tools/renderer.d.ts +22 -0
- package/dist/types/tools/streaming-output.d.ts +11 -1
- package/dist/types/tools/write.d.ts +8 -1
- package/package.json +9 -9
- package/src/apps/debug/log-viewer.ts +188 -72
- package/src/apps/debug/raw-sse-buffer.ts +8 -0
- package/src/apps/debug/raw-sse.ts +39 -5
- package/src/apps/git/diff-pane.ts +26 -5
- package/src/autocomplete.ts +59 -33
- package/src/chat/assistant-message.ts +30 -18
- package/src/chat/fence-figure.ts +80 -0
- package/src/chat/svg-figure.ts +2 -4
- package/src/chat/tool-execution.ts +53 -4
- package/src/components/editor.ts +16 -3
- package/src/components/markdown.ts +80 -307
- package/src/fuzzy.ts +59 -14
- package/src/native/backend.ts +170 -15
- package/src/native/blobs.ts +4 -2
- package/src/native/encode.ts +58 -31
- package/src/overlays/agent-hub-projection.ts +70 -2
- package/src/overlays/agent-hub.ts +3 -2
- package/src/overlays/agent-transcript-viewer.ts +64 -13
- package/src/overlays/agents-hub.ts +14 -7
- package/src/overlays/copy-selector.ts +30 -8
- package/src/overlays/extensions/state-manager.ts +52 -12
- package/src/overlays/history-search.ts +100 -7
- package/src/overlays/jobs-panel.ts +30 -2
- package/src/overlays/model-browser.ts +107 -21
- package/src/overlays/model-hub.ts +34 -7
- package/src/overlays/oauth-selector.ts +41 -26
- package/src/overlays/plan-review-overlay.ts +142 -64
- package/src/overlays/rewind-selector.ts +86 -17
- package/src/overlays/session-selector.ts +24 -4
- package/src/overlays/settings-selector.ts +21 -6
- package/src/overlays/tree-selector.ts +20 -4
- package/src/overlays/usage-dashboard.ts +12 -15
- package/src/prompt/attachment-chips.ts +2 -1
- package/src/prompt/composer-attachments.ts +6 -4
- package/src/prompt/custom-editor.ts +48 -19
- package/src/prompt/interactive-context-helpers.ts +27 -18
- package/src/prompt/macos-spelling.ts +7 -0
- package/src/prompt/magic-keywords.ts +11 -2
- package/src/prompt/word-completion.ts +38 -5
- package/src/setup/scenes/splash.ts +109 -45
- package/src/status-line/component.ts +220 -64
- package/src/status-line/segments.ts +5 -2
- package/src/terminal.ts +3 -2
- package/src/tools/ast-edit.ts +59 -35
- package/src/tools/ast-grep.ts +35 -16
- package/src/tools/default-renderer.ts +35 -19
- package/src/tools/eval-format/index.ts +28 -2
- package/src/tools/eval-format/javascript.ts +7 -5
- package/src/tools/eval.ts +164 -78
- package/src/tools/grep.ts +81 -48
- package/src/tools/lsp.ts +81 -59
- package/src/tools/mcp.ts +98 -68
- package/src/tools/renderer.ts +27 -0
- package/src/tools/streaming-output.ts +120 -48
- package/src/tools/task.ts +111 -54
- package/src/tools/todo.ts +115 -29
- package/src/tools/wait.ts +34 -24
- package/src/tools/write.ts +77 -39
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
type Component,
|
|
22
22
|
Ellipsis,
|
|
23
23
|
Editor,
|
|
24
|
+
getWidthConfigEpoch,
|
|
24
25
|
Markdown,
|
|
25
26
|
type MarkdownTheme,
|
|
26
27
|
matchesKey,
|
|
@@ -32,7 +33,7 @@ import {
|
|
|
32
33
|
} from "../index";
|
|
33
34
|
import { sanitizeText } from "@oh-my-pi/pi-utils";
|
|
34
35
|
import { sanitizeStatusText } from "../chrome/shared";
|
|
35
|
-
import { getEditorTheme, getMarkdownTheme, theme } from "../theme/theme";
|
|
36
|
+
import { getEditorTheme, getMarkdownTheme, getThemeEpoch, theme } from "../theme/theme";
|
|
36
37
|
import {
|
|
37
38
|
matchesAppExternalEditor,
|
|
38
39
|
matchesSelectCancel,
|
|
@@ -76,6 +77,8 @@ const SIDEBAR_MIN_TOTAL_WIDTH = 64;
|
|
|
76
77
|
const SIDEBAR_MIN_BODY_WIDTH = 40;
|
|
77
78
|
/** Persisted line-context cap; render-time captions clamp again to the viewport. */
|
|
78
79
|
const MAX_ANNOTATION_CONTEXT_WIDTH = 120;
|
|
80
|
+
/** Oldest delete/annotate undo snapshots are dropped past this depth (each holds the full plan text). */
|
|
81
|
+
const MAX_UNDO_ENTRIES = 100;
|
|
79
82
|
|
|
80
83
|
type Focus = "toc" | "body" | "actions";
|
|
81
84
|
|
|
@@ -148,6 +151,78 @@ interface UndoEntry {
|
|
|
148
151
|
deleted: string[];
|
|
149
152
|
}
|
|
150
153
|
|
|
154
|
+
/** Concatenated plan body rows and their anchors, reused while every render input is unchanged. */
|
|
155
|
+
interface BodyLayout {
|
|
156
|
+
width: number;
|
|
157
|
+
annotationRev: number;
|
|
158
|
+
themeEpoch: number;
|
|
159
|
+
widthEpoch: number;
|
|
160
|
+
/** Each section's `Markdown.render` result the layout was built from (compared by identity). */
|
|
161
|
+
rendered: readonly (readonly string[])[];
|
|
162
|
+
lines: readonly string[];
|
|
163
|
+
anchors: BodyRowAnchor[];
|
|
164
|
+
offsets: number[];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Line contexts per rendered-lines array; `Markdown.render` returns a stable array per (text, width). */
|
|
168
|
+
const lineContextCache = new WeakMap<readonly string[], readonly LineAnchorContext[]>();
|
|
169
|
+
/** Match keys per context list, so resolving several annotations normalizes each row once. */
|
|
170
|
+
const normalizedContextCache = new WeakMap<readonly LineAnchorContext[], readonly string[]>();
|
|
171
|
+
|
|
172
|
+
function lineContexts(rendered: readonly string[]): readonly LineAnchorContext[] {
|
|
173
|
+
let contexts = lineContextCache.get(rendered);
|
|
174
|
+
if (!contexts) {
|
|
175
|
+
contexts = rendered.map(line => {
|
|
176
|
+
const sanitized = sanitizeStatusText(line);
|
|
177
|
+
const truncated = visibleWidth(sanitized) > MAX_ANNOTATION_CONTEXT_WIDTH;
|
|
178
|
+
const text = truncateToWidth(sanitized, MAX_ANNOTATION_CONTEXT_WIDTH, Ellipsis.Unicode);
|
|
179
|
+
return { text: text || "(blank line)", truncated };
|
|
180
|
+
});
|
|
181
|
+
lineContextCache.set(rendered, contexts);
|
|
182
|
+
}
|
|
183
|
+
return contexts;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function normalizeLineContext(context: LineAnchorContext): string {
|
|
187
|
+
const normalized = sanitizeStatusText(context.text).replace(/\s+/g, " ").trim();
|
|
188
|
+
return context.truncated && normalized.endsWith("…") ? normalized.slice(0, -1) : normalized;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Row in `contexts` nearest `storedRow` whose text matches the stored context, or -1. */
|
|
192
|
+
function resolveLineRow(
|
|
193
|
+
storedRow: number,
|
|
194
|
+
storedContext: LineAnchorContext,
|
|
195
|
+
contexts: readonly LineAnchorContext[],
|
|
196
|
+
): number {
|
|
197
|
+
if (contexts.length === 0) return -1;
|
|
198
|
+
const targetRow = Math.max(0, Math.floor(storedRow));
|
|
199
|
+
const normalizedStoredContext = normalizeLineContext(storedContext);
|
|
200
|
+
if (!normalizedStoredContext) return -1;
|
|
201
|
+
let normalizedContexts = normalizedContextCache.get(contexts);
|
|
202
|
+
if (!normalizedContexts) {
|
|
203
|
+
normalizedContexts = contexts.map(normalizeLineContext);
|
|
204
|
+
normalizedContextCache.set(contexts, normalizedContexts);
|
|
205
|
+
}
|
|
206
|
+
let best = -1;
|
|
207
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
208
|
+
for (let row = 0; row < normalizedContexts.length; row++) {
|
|
209
|
+
const normalizedContext = normalizedContexts[row]!;
|
|
210
|
+
if (
|
|
211
|
+
normalizedContext !== normalizedStoredContext &&
|
|
212
|
+
!normalizedContext.includes(normalizedStoredContext) &&
|
|
213
|
+
!normalizedStoredContext.includes(normalizedContext)
|
|
214
|
+
) {
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const distance = Math.abs(row - targetRow);
|
|
218
|
+
if (distance < bestDistance) {
|
|
219
|
+
best = row;
|
|
220
|
+
bestDistance = distance;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return best;
|
|
224
|
+
}
|
|
225
|
+
|
|
151
226
|
export interface PlanReviewOverlayCallbacks {
|
|
152
227
|
/** Invoked with the chosen option label (never a disabled one). */
|
|
153
228
|
onPick: (label: string) => void;
|
|
@@ -199,6 +274,10 @@ export class PlanReviewOverlay implements Component {
|
|
|
199
274
|
#sectionOffsets: number[] = [];
|
|
200
275
|
/** Rendered body row to underlying plan row; callouts retain their owner's anchor. */
|
|
201
276
|
#bodyRowAnchors: BodyRowAnchor[] = [];
|
|
277
|
+
/** Memoized {@link #buildBody} result. */
|
|
278
|
+
#bodyLayout: BodyLayout | undefined;
|
|
279
|
+
/** Lines last handed to the scroll view; unchanged layouts skip the copy and keep its render cache. */
|
|
280
|
+
#scrollLines: readonly string[] | undefined;
|
|
202
281
|
#undo: UndoEntry[] = [];
|
|
203
282
|
/** Titles of sections deleted in the overlay, surfaced as Refine feedback. */
|
|
204
283
|
#deleted: string[] = [];
|
|
@@ -444,8 +523,8 @@ export class PlanReviewOverlay implements Component {
|
|
|
444
523
|
) {
|
|
445
524
|
continue;
|
|
446
525
|
}
|
|
447
|
-
const contexts = section.md.render(MAX_ANNOTATION_CONTEXT_WIDTH)
|
|
448
|
-
const row =
|
|
526
|
+
const contexts = lineContexts(section.md.render(MAX_ANNOTATION_CONTEXT_WIDTH));
|
|
527
|
+
const row = resolveLineRow(
|
|
449
528
|
entry.target.row,
|
|
450
529
|
{ text: entry.target.context, truncated: entry.target.contextTruncated === true },
|
|
451
530
|
contexts,
|
|
@@ -866,6 +945,7 @@ export class PlanReviewOverlay implements Component {
|
|
|
866
945
|
}
|
|
867
946
|
|
|
868
947
|
#pushUndo(): void {
|
|
948
|
+
if (this.#undo.length >= MAX_UNDO_ENTRIES) this.#undo.shift();
|
|
869
949
|
this.#undo.push({
|
|
870
950
|
text: joinPlanSections(this.#sections),
|
|
871
951
|
annotations: this.#sections.map(section =>
|
|
@@ -959,7 +1039,7 @@ export class PlanReviewOverlay implements Component {
|
|
|
959
1039
|
seenRows.add(candidate.row);
|
|
960
1040
|
sectionAnchors.push({ text: candidate.context, truncated: candidate.contextTruncated });
|
|
961
1041
|
}
|
|
962
|
-
const resolvedRow =
|
|
1042
|
+
const resolvedRow = resolveLineRow(
|
|
963
1043
|
annotation.target.row,
|
|
964
1044
|
{ text: annotation.target.context, truncated: annotation.target.contextTruncated },
|
|
965
1045
|
sectionAnchors,
|
|
@@ -1187,23 +1267,64 @@ export class PlanReviewOverlay implements Component {
|
|
|
1187
1267
|
|
|
1188
1268
|
#layoutBody(lines: readonly string[], height: number): void {
|
|
1189
1269
|
this.#captureScrollProgress();
|
|
1190
|
-
this.#
|
|
1270
|
+
if (lines !== this.#scrollLines) {
|
|
1271
|
+
this.#scrollView.setLines(lines);
|
|
1272
|
+
this.#scrollLines = lines;
|
|
1273
|
+
}
|
|
1191
1274
|
this.#scrollView.setHeight(height);
|
|
1192
1275
|
const maxOffset = this.#scrollView.getMaxScrollOffset();
|
|
1193
1276
|
if (maxOffset > 0) this.#scrollView.setScrollOffset(Math.round(this.#scrollProgress * maxOffset));
|
|
1194
1277
|
}
|
|
1195
1278
|
|
|
1196
|
-
/**
|
|
1197
|
-
|
|
1279
|
+
/**
|
|
1280
|
+
* Build the concatenated body lines and map each rendered row back to a plan
|
|
1281
|
+
* anchor. Memoized on width, annotation revision, theme/width epochs and each
|
|
1282
|
+
* section's rendered-lines identity, so steady frames and scrolling reuse it.
|
|
1283
|
+
*/
|
|
1284
|
+
#buildBody(bodyContentWidth: number): readonly string[] {
|
|
1285
|
+
const themeEpoch = getThemeEpoch();
|
|
1286
|
+
const widthEpoch = getWidthConfigEpoch();
|
|
1287
|
+
const cached = this.#bodyLayout;
|
|
1288
|
+
if (
|
|
1289
|
+
cached?.width === bodyContentWidth &&
|
|
1290
|
+
cached.annotationRev === this.#annotationRev &&
|
|
1291
|
+
cached.themeEpoch === themeEpoch &&
|
|
1292
|
+
cached.widthEpoch === widthEpoch &&
|
|
1293
|
+
cached.rendered.length === this.#sections.length &&
|
|
1294
|
+
this.#sections.every((section, i) => section.md.render(bodyContentWidth) === cached.rendered[i])
|
|
1295
|
+
) {
|
|
1296
|
+
this.#sectionOffsets = cached.offsets;
|
|
1297
|
+
this.#bodyRowAnchors = cached.anchors;
|
|
1298
|
+
return cached.lines;
|
|
1299
|
+
}
|
|
1198
1300
|
const lines: string[] = [];
|
|
1199
1301
|
const anchors: BodyRowAnchor[] = [];
|
|
1302
|
+
const renderedSections: (readonly string[])[] = [];
|
|
1200
1303
|
// oxlint-disable-next-line unicorn/no-new-array -- length preallocation
|
|
1201
1304
|
const offsets: number[] = new Array(this.#sections.length);
|
|
1202
1305
|
for (let sectionIndex = 0; sectionIndex < this.#sections.length; sectionIndex++) {
|
|
1203
1306
|
const section = this.#sections[sectionIndex]!;
|
|
1204
1307
|
offsets[sectionIndex] = lines.length;
|
|
1205
1308
|
const rendered = section.md.render(bodyContentWidth);
|
|
1206
|
-
|
|
1309
|
+
renderedSections.push(rendered);
|
|
1310
|
+
const contexts = lineContexts(rendered);
|
|
1311
|
+
// Resolve each note's row once; notes on one row keep their array order.
|
|
1312
|
+
let notesByRow: Map<number, string[]> | undefined;
|
|
1313
|
+
for (const annotation of section.annotations) {
|
|
1314
|
+
const annotationRow =
|
|
1315
|
+
annotation.target.kind === "section"
|
|
1316
|
+
? 0
|
|
1317
|
+
: resolveLineRow(
|
|
1318
|
+
annotation.target.row,
|
|
1319
|
+
{ text: annotation.target.context, truncated: annotation.target.contextTruncated },
|
|
1320
|
+
contexts,
|
|
1321
|
+
);
|
|
1322
|
+
if (annotationRow < 0) continue;
|
|
1323
|
+
notesByRow ??= new Map();
|
|
1324
|
+
const notes = notesByRow.get(annotationRow);
|
|
1325
|
+
if (notes) notes.push(annotation.note);
|
|
1326
|
+
else notesByRow.set(annotationRow, [annotation.note]);
|
|
1327
|
+
}
|
|
1207
1328
|
for (let row = 0; row < rendered.length; row++) {
|
|
1208
1329
|
const context = contexts[row]!;
|
|
1209
1330
|
const anchor = {
|
|
@@ -1214,67 +1335,24 @@ export class PlanReviewOverlay implements Component {
|
|
|
1214
1335
|
};
|
|
1215
1336
|
lines.push(rendered[row]!);
|
|
1216
1337
|
anchors.push(anchor);
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
? 0
|
|
1221
|
-
: this.#resolveLineRow(
|
|
1222
|
-
annotation.target.row,
|
|
1223
|
-
{
|
|
1224
|
-
text: annotation.target.context,
|
|
1225
|
-
truncated: annotation.target.contextTruncated,
|
|
1226
|
-
},
|
|
1227
|
-
contexts,
|
|
1228
|
-
);
|
|
1229
|
-
if (annotationRow === row) {
|
|
1230
|
-
this.#appendAnnotationCallout(lines, anchors, annotation.note, anchor, bodyContentWidth);
|
|
1231
|
-
}
|
|
1232
|
-
}
|
|
1338
|
+
const notes = notesByRow?.get(row);
|
|
1339
|
+
if (!notes) continue;
|
|
1340
|
+
for (const note of notes) this.#appendAnnotationCallout(lines, anchors, note, anchor, bodyContentWidth);
|
|
1233
1341
|
}
|
|
1234
1342
|
}
|
|
1235
1343
|
this.#sectionOffsets = offsets;
|
|
1236
1344
|
this.#bodyRowAnchors = anchors;
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
#resolveLineRow(
|
|
1248
|
-
storedRow: number,
|
|
1249
|
-
storedContext: LineAnchorContext,
|
|
1250
|
-
contexts: readonly LineAnchorContext[],
|
|
1251
|
-
): number {
|
|
1252
|
-
if (contexts.length === 0) return -1;
|
|
1253
|
-
const targetRow = Math.max(0, Math.floor(storedRow));
|
|
1254
|
-
const normalize = (context: LineAnchorContext): string => {
|
|
1255
|
-
const normalized = sanitizeStatusText(context.text).replace(/\s+/g, " ").trim();
|
|
1256
|
-
return context.truncated && normalized.endsWith("…") ? normalized.slice(0, -1) : normalized;
|
|
1345
|
+
this.#bodyLayout = {
|
|
1346
|
+
width: bodyContentWidth,
|
|
1347
|
+
annotationRev: this.#annotationRev,
|
|
1348
|
+
themeEpoch,
|
|
1349
|
+
widthEpoch,
|
|
1350
|
+
rendered: renderedSections,
|
|
1351
|
+
lines,
|
|
1352
|
+
anchors,
|
|
1353
|
+
offsets,
|
|
1257
1354
|
};
|
|
1258
|
-
|
|
1259
|
-
if (!normalizedStoredContext) return -1;
|
|
1260
|
-
let best = -1;
|
|
1261
|
-
let bestDistance = Number.POSITIVE_INFINITY;
|
|
1262
|
-
for (let row = 0; row < contexts.length; row++) {
|
|
1263
|
-
const normalizedContext = normalize(contexts[row]!);
|
|
1264
|
-
if (
|
|
1265
|
-
normalizedContext !== normalizedStoredContext &&
|
|
1266
|
-
!normalizedContext.includes(normalizedStoredContext) &&
|
|
1267
|
-
!normalizedStoredContext.includes(normalizedContext)
|
|
1268
|
-
) {
|
|
1269
|
-
continue;
|
|
1270
|
-
}
|
|
1271
|
-
const distance = Math.abs(row - targetRow);
|
|
1272
|
-
if (distance < bestDistance) {
|
|
1273
|
-
best = row;
|
|
1274
|
-
bestDistance = distance;
|
|
1275
|
-
}
|
|
1276
|
-
}
|
|
1277
|
-
return best;
|
|
1355
|
+
return lines;
|
|
1278
1356
|
}
|
|
1279
1357
|
|
|
1280
1358
|
#appendAnnotationCallout(
|
|
@@ -157,6 +157,14 @@ const STRIP_GAP = 2;
|
|
|
157
157
|
/** Duration of the branch-swap camera slide. */
|
|
158
158
|
const SLIDE_MS = 160;
|
|
159
159
|
|
|
160
|
+
/** Same length and identical elements (or both undefined). */
|
|
161
|
+
function sameElements<T>(a: readonly T[] | undefined, b: readonly T[] | undefined): boolean {
|
|
162
|
+
if (a === b) return true;
|
|
163
|
+
if (a === undefined || b === undefined || a.length !== b.length) return false;
|
|
164
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
160
168
|
export class RewindSelectorComponent implements Component {
|
|
161
169
|
#builder: ChatTranscriptBuilder;
|
|
162
170
|
#browser: TranscriptBrowser;
|
|
@@ -195,6 +203,24 @@ export class RewindSelectorComponent implements Component {
|
|
|
195
203
|
#rowText = new WeakMap<readonly string[], string>();
|
|
196
204
|
/** Native filter haystack: lowercased turn text per main target (no rendered rows under TSP). */
|
|
197
205
|
#nativeTexts: { targets: OutlineTarget[]; texts: string[] } | undefined;
|
|
206
|
+
/** Compiled word patterns of the last filter query. */
|
|
207
|
+
#filterPatterns: { query: string; patterns: RegExp[] } | undefined;
|
|
208
|
+
/**
|
|
209
|
+
* Last {@link #filterMatches} result and its inputs. Frames hand back fresh
|
|
210
|
+
* outer `mainRows`/`mainVisible` arrays over cached per-child rows, so those
|
|
211
|
+
* compare element-wise.
|
|
212
|
+
*/
|
|
213
|
+
#filterMemo:
|
|
214
|
+
| {
|
|
215
|
+
query: string;
|
|
216
|
+
targets: OutlineTarget[];
|
|
217
|
+
expanded: boolean;
|
|
218
|
+
native: boolean;
|
|
219
|
+
mainRows: readonly (readonly string[])[];
|
|
220
|
+
mainVisible: readonly boolean[] | undefined;
|
|
221
|
+
matches: readonly number[];
|
|
222
|
+
}
|
|
223
|
+
| undefined;
|
|
198
224
|
/** Last described bar and the state it was built from. */
|
|
199
225
|
#bar: { memo: string; targets: OutlineTarget[]; node: NativeNode } | undefined;
|
|
200
226
|
|
|
@@ -528,14 +554,25 @@ export class RewindSelectorComponent implements Component {
|
|
|
528
554
|
* Matching the rendered rows keeps results honest: collapsed tool output
|
|
529
555
|
* only matches once Ctrl+O expands it.
|
|
530
556
|
*/
|
|
531
|
-
#filterMatches(): number[] {
|
|
532
|
-
const
|
|
533
|
-
const
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
557
|
+
#filterMatches(): readonly number[] {
|
|
558
|
+
const query = this.#filter ?? "";
|
|
559
|
+
const native = isNativeRendering();
|
|
560
|
+
const memo = this.#filterMemo;
|
|
561
|
+
if (
|
|
562
|
+
memo &&
|
|
563
|
+
memo.query === query &&
|
|
564
|
+
memo.targets === this.#targets &&
|
|
565
|
+
memo.expanded === this.#expanded &&
|
|
566
|
+
memo.native === native &&
|
|
567
|
+
sameElements(memo.mainVisible, this.#mainVisible) &&
|
|
568
|
+
(native || sameElements(memo.mainRows, this.#mainRows))
|
|
569
|
+
) {
|
|
570
|
+
memo.mainRows = this.#mainRows;
|
|
571
|
+
memo.mainVisible = this.#mainVisible;
|
|
572
|
+
return memo.matches;
|
|
573
|
+
}
|
|
574
|
+
const patterns = this.#compileFilter(query);
|
|
575
|
+
if (native && this.#nativeTexts?.targets !== this.#targets) {
|
|
539
576
|
this.#nativeTexts = {
|
|
540
577
|
targets: this.#targets,
|
|
541
578
|
// Turn text plus its commands and tool output, like the expanded rendered rows.
|
|
@@ -550,21 +587,53 @@ export class RewindSelectorComponent implements Component {
|
|
|
550
587
|
for (let index = 0; index < this.#targets.length; index++) {
|
|
551
588
|
if (!this.#isMainSelectable(index)) continue;
|
|
552
589
|
const target = this.#targets[index]!;
|
|
553
|
-
const
|
|
554
|
-
?
|
|
555
|
-
:
|
|
556
|
-
let
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
this.#rowText.set(rows, text);
|
|
590
|
+
const matched = native
|
|
591
|
+
? patterns.every(pattern => pattern.test(this.#nativeTexts?.texts[index] ?? ""))
|
|
592
|
+
: patterns.every(pattern => {
|
|
593
|
+
for (let child = target.start; child < target.end; child++) {
|
|
594
|
+
const rows = this.#mainRows[child];
|
|
595
|
+
if (rows !== undefined && pattern.test(this.#plainRowText(rows))) return true;
|
|
560
596
|
}
|
|
561
|
-
return
|
|
597
|
+
return false;
|
|
562
598
|
});
|
|
563
|
-
if (
|
|
599
|
+
if (matched) matches.push(index);
|
|
564
600
|
}
|
|
601
|
+
this.#filterMemo = {
|
|
602
|
+
query,
|
|
603
|
+
targets: this.#targets,
|
|
604
|
+
expanded: this.#expanded,
|
|
605
|
+
native,
|
|
606
|
+
mainRows: this.#mainRows,
|
|
607
|
+
mainVisible: this.#mainVisible,
|
|
608
|
+
matches,
|
|
609
|
+
};
|
|
565
610
|
return matches;
|
|
566
611
|
}
|
|
567
612
|
|
|
613
|
+
/** Word patterns of `query`, compiled once per distinct query. */
|
|
614
|
+
#compileFilter(query: string): RegExp[] {
|
|
615
|
+
const cached = this.#filterPatterns;
|
|
616
|
+
if (cached?.query === query) return cached.patterns;
|
|
617
|
+
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
618
|
+
const patterns = words.map(word =>
|
|
619
|
+
/^[\p{Script=Latin}\p{N}_]+$/u.test(word)
|
|
620
|
+
? new RegExp(`(?<![\\p{L}\\p{N}_])${RegExp.escape(word)}(?![\\p{L}\\p{N}_])`, "u")
|
|
621
|
+
: new RegExp(RegExp.escape(word), "u"),
|
|
622
|
+
);
|
|
623
|
+
this.#filterPatterns = { query, patterns };
|
|
624
|
+
return patterns;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** Lowercased plain text of one rendered child row array, cached by array identity. */
|
|
628
|
+
#plainRowText(rows: readonly string[]): string {
|
|
629
|
+
let text = this.#rowText.get(rows);
|
|
630
|
+
if (text === undefined) {
|
|
631
|
+
text = Bun.stripANSI(rows.join("\n")).toLowerCase();
|
|
632
|
+
this.#rowText.set(rows, text);
|
|
633
|
+
}
|
|
634
|
+
return text;
|
|
635
|
+
}
|
|
636
|
+
|
|
568
637
|
/** Up/Down: step within the active column; leaving a sibling column's top exits the strip. */
|
|
569
638
|
#moveVertical(delta: -1 | 1): void {
|
|
570
639
|
if (this.#activeVariant > 0) {
|
|
@@ -179,9 +179,13 @@ function sessionPreview(session: SessionSelectorEntry, forkedFrom: string | unde
|
|
|
179
179
|
]);
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
/** Absolute dates of week-old sessions; `toLocaleDateString` goes through Intl on every call. */
|
|
183
|
+
const localeDateCache = new WeakMap<Date, { time: number; text: string }>();
|
|
184
|
+
|
|
182
185
|
/** Relative age of a session's last modification (`"3 hours ago"`), falling back to the date after a week. */
|
|
183
186
|
function formatSessionDate(date: Date): string {
|
|
184
|
-
const
|
|
187
|
+
const time = date.getTime();
|
|
188
|
+
const diffMs = Date.now() - time;
|
|
185
189
|
const diffMins = Math.floor(diffMs / 60000);
|
|
186
190
|
const diffHours = Math.floor(diffMs / 3600000);
|
|
187
191
|
const diffDays = Math.floor(diffMs / 86400000);
|
|
@@ -192,7 +196,11 @@ function formatSessionDate(date: Date): string {
|
|
|
192
196
|
if (diffDays === 1) return "1 day ago";
|
|
193
197
|
if (diffDays < 7) return `${diffDays} days ago`;
|
|
194
198
|
|
|
195
|
-
|
|
199
|
+
const cached = localeDateCache.get(date);
|
|
200
|
+
if (cached?.time === time) return cached.text;
|
|
201
|
+
const text = date.toLocaleDateString();
|
|
202
|
+
localeDateCache.set(date, { time, text });
|
|
203
|
+
return text;
|
|
196
204
|
}
|
|
197
205
|
|
|
198
206
|
/** A cached native session item and the inputs it was built from. */
|
|
@@ -545,6 +553,8 @@ class SessionList<T extends SessionSelectorEntry> implements Component {
|
|
|
545
553
|
#lastFilterQuery = "";
|
|
546
554
|
/** Bumped whenever the visible session set may have changed (native memo key). */
|
|
547
555
|
#itemsVersion = 0;
|
|
556
|
+
/** Per-row line heights of the visible list for the ANSI window, reused until the set changes. */
|
|
557
|
+
#rowHeights: { items: readonly T[]; length: number; version: number; heights: readonly number[] } | undefined;
|
|
548
558
|
#itemsNative:
|
|
549
559
|
| { version: number; showCwd: boolean; currentPath: string | undefined; items: NativeNode[] }
|
|
550
560
|
| undefined;
|
|
@@ -1130,14 +1140,13 @@ class SessionList<T extends SessionSelectorEntry> implements Component {
|
|
|
1130
1140
|
// worst-case count-based window would leave (then padded by
|
|
1131
1141
|
// fill-height).
|
|
1132
1142
|
const filtered = this.#menu.visibleItems;
|
|
1133
|
-
const itemHeight = (session: T): number => (session.title ? 4 : 3);
|
|
1134
1143
|
const budget = this.#lineBudget();
|
|
1135
1144
|
const {
|
|
1136
1145
|
startIndex,
|
|
1137
1146
|
endIndex,
|
|
1138
1147
|
rowOffset: offsetRows,
|
|
1139
1148
|
totalRows: rawTotalRows,
|
|
1140
|
-
} = getMenuWindow(filtered
|
|
1149
|
+
} = getMenuWindow(this.#visibleRowHeights(filtered), this.#menu.selectedIndex, budget);
|
|
1141
1150
|
|
|
1142
1151
|
// Each session block is built into sessionLines, then wrapped by ScrollView
|
|
1143
1152
|
// so the right-edge scrollbar is proportional at the physical-line level.
|
|
@@ -1232,6 +1241,17 @@ class SessionList<T extends SessionSelectorEntry> implements Component {
|
|
|
1232
1241
|
return lines;
|
|
1233
1242
|
}
|
|
1234
1243
|
|
|
1244
|
+
/** Line height per visible session (3, or 4 when a title adds a preview line), memoized per visible set. */
|
|
1245
|
+
#visibleRowHeights(items: readonly T[]): readonly number[] {
|
|
1246
|
+
const cached = this.#rowHeights;
|
|
1247
|
+
if (cached?.items === items && cached.length === items.length && cached.version === this.#itemsVersion) {
|
|
1248
|
+
return cached.heights;
|
|
1249
|
+
}
|
|
1250
|
+
const heights = items.map(session => (session.title ? 4 : 3));
|
|
1251
|
+
this.#rowHeights = { items, length: items.length, version: this.#itemsVersion, heights };
|
|
1252
|
+
return heights;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1235
1255
|
handleInput(keyData: string): void {
|
|
1236
1256
|
// Delete key — or Backspace on an empty search query — request delete
|
|
1237
1257
|
// confirmation from the parent. macOS laptops have no dedicated Forward
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
type Component,
|
|
5
5
|
Container,
|
|
6
6
|
extractPrintableText,
|
|
7
|
-
|
|
7
|
+
FuzzyCorpus,
|
|
8
8
|
getKeybindings,
|
|
9
9
|
getSettingItemFilterText,
|
|
10
10
|
type ImageBudget,
|
|
@@ -630,6 +630,12 @@ export class SettingsSelectorComponent implements Component {
|
|
|
630
630
|
#searchMatchCount = 0;
|
|
631
631
|
/** First matching item id per tab id, for Tab-key jumps while searching. */
|
|
632
632
|
#searchFirstMatch = new Map<string, string>();
|
|
633
|
+
/**
|
|
634
|
+
* Per-tab search candidates prepared once per search session. Dropped on
|
|
635
|
+
* search start/end and whenever a value or condition may have changed
|
|
636
|
+
* (`#refreshItems`, `#onSearchSettingChange`); rebuilt lazily by `#setSearchQuery`.
|
|
637
|
+
*/
|
|
638
|
+
#searchCorpora: { tab: SettingTab; corpus: FuzzyCorpus<SettingItem> }[] | null = null;
|
|
633
639
|
#textInputActive = false;
|
|
634
640
|
#hasSectionJump = false;
|
|
635
641
|
// Frame geometry from the last render, for mouse hit-testing (the
|
|
@@ -1111,8 +1117,10 @@ export class SettingsSelectorComponent implements Component {
|
|
|
1111
1117
|
|
|
1112
1118
|
/** Rebuilds the visible items after a change made outside a list's own `onChange` (changed flags, conditions). */
|
|
1113
1119
|
#refreshItems(): void {
|
|
1114
|
-
if (this.#searchList)
|
|
1115
|
-
|
|
1120
|
+
if (this.#searchList) {
|
|
1121
|
+
this.#searchCorpora = null;
|
|
1122
|
+
this.#setSearchQuery(this.#searchQuery);
|
|
1123
|
+
} else if (this.#currentTabId !== "plugins")
|
|
1116
1124
|
this.#refreshCurrentTabItems(getSettingsForTab(this.#context.settings.entries, this.#currentTabId));
|
|
1117
1125
|
}
|
|
1118
1126
|
|
|
@@ -1281,6 +1289,7 @@ export class SettingsSelectorComponent implements Component {
|
|
|
1281
1289
|
/** Swap the tab content for the global search result list. */
|
|
1282
1290
|
#startSearch(initialQuery: string): void {
|
|
1283
1291
|
this.#preSearchTabId = this.#currentTabId;
|
|
1292
|
+
this.#searchCorpora = null;
|
|
1284
1293
|
this.#searchInput = new Input();
|
|
1285
1294
|
this.#searchInput.prompt = "";
|
|
1286
1295
|
this.#searchInput.setValue(initialQuery);
|
|
@@ -1324,13 +1333,17 @@ export class SettingsSelectorComponent implements Component {
|
|
|
1324
1333
|
const tabResults: { tab: SettingTab; matched: SettingItem[]; bestScore: number; order: number }[] = [];
|
|
1325
1334
|
this.#searchFirstMatch.clear();
|
|
1326
1335
|
let total = 0;
|
|
1327
|
-
|
|
1336
|
+
this.#searchCorpora ??= SETTING_TABS.map(tab => {
|
|
1328
1337
|
const candidates: SettingItem[] = [];
|
|
1329
1338
|
for (const def of getSettingsForTab(this.#context.settings.entries, tab)) {
|
|
1330
1339
|
const item = this.#defToItem(def);
|
|
1331
1340
|
if (item) candidates.push(item);
|
|
1332
1341
|
}
|
|
1333
|
-
|
|
1342
|
+
return { tab, corpus: new FuzzyCorpus(candidates, getSettingItemFilterText) };
|
|
1343
|
+
});
|
|
1344
|
+
for (let order = 0; order < this.#searchCorpora.length; order++) {
|
|
1345
|
+
const { tab, corpus } = this.#searchCorpora[order]!;
|
|
1346
|
+
const ranked = corpus.rank(query);
|
|
1334
1347
|
const matched = ranked.map(result => result.item);
|
|
1335
1348
|
counts.set(tab, matched.length);
|
|
1336
1349
|
if (matched.length === 0) continue;
|
|
@@ -1339,7 +1352,7 @@ export class SettingsSelectorComponent implements Component {
|
|
|
1339
1352
|
tab,
|
|
1340
1353
|
matched,
|
|
1341
1354
|
bestScore: ranked[0]?.score ?? 0,
|
|
1342
|
-
order
|
|
1355
|
+
order,
|
|
1343
1356
|
});
|
|
1344
1357
|
}
|
|
1345
1358
|
|
|
@@ -1378,6 +1391,7 @@ export class SettingsSelectorComponent implements Component {
|
|
|
1378
1391
|
const targetTab: SettingTab | "plugins" = selectedDef?.tab ?? this.#preSearchTabId;
|
|
1379
1392
|
|
|
1380
1393
|
this.#searchQuery = "";
|
|
1394
|
+
this.#searchCorpora = null;
|
|
1381
1395
|
this.#searchFirstMatch.clear();
|
|
1382
1396
|
this.#searchMatchCount = 0;
|
|
1383
1397
|
this.#tabs = getSettingsTabs();
|
|
@@ -1441,6 +1455,7 @@ export class SettingsSelectorComponent implements Component {
|
|
|
1441
1455
|
}
|
|
1442
1456
|
// Values feed the searchable text and condition gates may have flipped:
|
|
1443
1457
|
// recompute results in place (selection is preserved by item id).
|
|
1458
|
+
this.#searchCorpora = null;
|
|
1444
1459
|
this.#setSearchQuery(this.#searchQuery);
|
|
1445
1460
|
}
|
|
1446
1461
|
|
|
@@ -2,7 +2,8 @@ import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|
|
2
2
|
import {
|
|
3
3
|
type Component,
|
|
4
4
|
Container,
|
|
5
|
-
|
|
5
|
+
FuzzyQuery,
|
|
6
|
+
FuzzyText,
|
|
6
7
|
Input,
|
|
7
8
|
matchesKey,
|
|
8
9
|
Spacer,
|
|
@@ -210,6 +211,8 @@ class TreeList implements Component {
|
|
|
210
211
|
#multipleRoots = false;
|
|
211
212
|
#activePathIds: Set<string> = new Set();
|
|
212
213
|
#containsActive: Map<TreeSelectorNode, boolean> = new Map();
|
|
214
|
+
/** Prepared search text per node; rebuilt when the node's label changes. */
|
|
215
|
+
#searchTextCache = new WeakMap<TreeSelectorNode, { label: string | undefined; text: FuzzyText }>();
|
|
213
216
|
|
|
214
217
|
onSelect?: (entryId: string, options: { summarize: boolean }) => void;
|
|
215
218
|
onCancel?: () => void;
|
|
@@ -373,7 +376,11 @@ class TreeList implements Component {
|
|
|
373
376
|
#buildFilter(): (node: TreeSelectorNode, row: TreeRow<TreeSelectorNode, string>) => boolean {
|
|
374
377
|
const filterMode = this.#filterMode;
|
|
375
378
|
const leafId = this.currentLeafId;
|
|
376
|
-
const searchTokens = this.getSearchQuery()
|
|
379
|
+
const searchTokens = this.getSearchQuery()
|
|
380
|
+
.toLowerCase()
|
|
381
|
+
.split(/\s+/)
|
|
382
|
+
.filter(Boolean)
|
|
383
|
+
.map(token => new FuzzyQuery(token));
|
|
377
384
|
return node => {
|
|
378
385
|
const entry = node.entry;
|
|
379
386
|
const isCurrentLeaf = entry.id === leafId;
|
|
@@ -435,14 +442,23 @@ class TreeList implements Component {
|
|
|
435
442
|
|
|
436
443
|
// Apply fuzzy search filter
|
|
437
444
|
if (searchTokens.length > 0) {
|
|
438
|
-
const nodeText = this.#
|
|
439
|
-
return searchTokens.every(token =>
|
|
445
|
+
const nodeText = this.#getFuzzyText(node);
|
|
446
|
+
return searchTokens.every(token => token.match(nodeText).matches);
|
|
440
447
|
}
|
|
441
448
|
|
|
442
449
|
return true;
|
|
443
450
|
};
|
|
444
451
|
}
|
|
445
452
|
|
|
453
|
+
/** Prepared fuzzy text for a node's searchable content, built once per label. */
|
|
454
|
+
#getFuzzyText(node: TreeSelectorNode): FuzzyText {
|
|
455
|
+
const cached = this.#searchTextCache.get(node);
|
|
456
|
+
if (cached !== undefined && cached.label === node.label) return cached.text;
|
|
457
|
+
const text = new FuzzyText(this.#getSearchableText(node));
|
|
458
|
+
this.#searchTextCache.set(node, { label: node.label, text });
|
|
459
|
+
return text;
|
|
460
|
+
}
|
|
461
|
+
|
|
446
462
|
/** Get searchable text content from a node */
|
|
447
463
|
#getSearchableText(node: TreeSelectorNode): string {
|
|
448
464
|
const entry = node.entry;
|