@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
|
@@ -49,7 +49,14 @@ export type AgentTranscriptEntry = SessionMessageEntryLike | { type: "model_chan
|
|
|
49
49
|
|
|
50
50
|
/** Local filesystem and session parsing capabilities supplied by the host. */
|
|
51
51
|
export interface AgentTranscriptSource {
|
|
52
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Sync primitives drive the first paint and the incremental tail. When the
|
|
54
|
+
* host also supplies `promises.readFile` (e.g. passes `node:fs` itself), full
|
|
55
|
+
* reloads after rotation/rewrite read the file off the UI thread.
|
|
56
|
+
*/
|
|
57
|
+
fs: Pick<typeof fs, "openSync" | "closeSync" | "readSync" | "readFileSync" | "statSync"> & {
|
|
58
|
+
promises?: Pick<typeof fs.promises, "readFile">;
|
|
59
|
+
};
|
|
53
60
|
parseEntries(text: string): AgentTranscriptEntry[];
|
|
54
61
|
}
|
|
55
62
|
|
|
@@ -86,6 +93,8 @@ export interface AgentTranscriptViewerDeps {
|
|
|
86
93
|
|
|
87
94
|
/** How often to re-stat a file-backed transcript for growth (advisor/live tail). */
|
|
88
95
|
const POLL_MS = 250;
|
|
96
|
+
/** Every Nth idle poll re-verifies sentinels, catching same-size/mtime rewrites. */
|
|
97
|
+
const IDLE_SENTINEL_CHECK_EVERY = 5;
|
|
89
98
|
|
|
90
99
|
const SENTINEL_BYTES = 4096;
|
|
91
100
|
|
|
@@ -183,6 +192,10 @@ export class AgentTranscriptViewer implements Component {
|
|
|
183
192
|
#model: string | undefined;
|
|
184
193
|
#pollTimer: NodeJS.Timeout | undefined;
|
|
185
194
|
#disposed = false;
|
|
195
|
+
/** Async full reload in flight; polls are skipped until it lands. */
|
|
196
|
+
#localLoadInFlight = false;
|
|
197
|
+
/** Idle polls since the sentinels were last verified. */
|
|
198
|
+
#idlePolls = 0;
|
|
186
199
|
#initialEntryId: string | undefined;
|
|
187
200
|
/** Last described node and the visible inputs it was built from. */
|
|
188
201
|
#nativeCache: { signature: string; node: NativeNode } | undefined;
|
|
@@ -215,8 +228,10 @@ export class AgentTranscriptViewer implements Component {
|
|
|
215
228
|
this.#editor.setMaxHeight(4);
|
|
216
229
|
this.#editor.onSubmit = text => this.#submit(text);
|
|
217
230
|
}
|
|
218
|
-
|
|
219
|
-
|
|
231
|
+
// First paint loads synchronously so the initial entry can be revealed
|
|
232
|
+
// immediately; later full reloads from the poll may go async.
|
|
233
|
+
this.#refresh(false);
|
|
234
|
+
this.#pollTimer = setInterval(() => this.#refresh(true), POLL_MS);
|
|
220
235
|
this.#pollTimer.unref?.();
|
|
221
236
|
}
|
|
222
237
|
|
|
@@ -245,12 +260,13 @@ export class AgentTranscriptViewer implements Component {
|
|
|
245
260
|
// ========================================================================
|
|
246
261
|
|
|
247
262
|
/** Refresh the transcript from a local file or remote host. */
|
|
248
|
-
#refresh(): void {
|
|
263
|
+
#refresh(allowAsync: boolean): void {
|
|
249
264
|
if (this.#disposed) return;
|
|
250
265
|
if (this.#deps.remote) {
|
|
251
266
|
this.#fetchRemote();
|
|
252
267
|
return;
|
|
253
268
|
}
|
|
269
|
+
if (this.#localLoadInFlight) return;
|
|
254
270
|
const sessionFile = this.#deps.registry.get(this.#deps.agentId)?.sessionFile;
|
|
255
271
|
if (!sessionFile) {
|
|
256
272
|
this.#clearLocal("none");
|
|
@@ -264,14 +280,29 @@ export class AgentTranscriptViewer implements Component {
|
|
|
264
280
|
return;
|
|
265
281
|
}
|
|
266
282
|
const state = this.#localState;
|
|
267
|
-
if (state
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
283
|
+
if (state) {
|
|
284
|
+
// Idle fast path: an unchanged identity/size/mtime costs one stat;
|
|
285
|
+
// sentinels are re-read only every Nth idle poll so a rewrite that
|
|
286
|
+
// keeps size and mtime is still noticed.
|
|
287
|
+
if (
|
|
288
|
+
state.path === sessionFile &&
|
|
289
|
+
state.dev === stat.dev &&
|
|
290
|
+
state.ino === stat.ino &&
|
|
291
|
+
stat.size === state.size &&
|
|
292
|
+
stat.mtimeMs === state.mtimeMs
|
|
293
|
+
) {
|
|
294
|
+
if (++this.#idlePolls < IDLE_SENTINEL_CHECK_EVERY) return;
|
|
295
|
+
this.#idlePolls = 0;
|
|
296
|
+
if (this.#canAppendLocal(sessionFile, stat, state)) return;
|
|
297
|
+
this.#loadLocalFull(sessionFile, stat, allowAsync);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (stat.size > state.size && this.#canAppendLocal(sessionFile, stat, state)) {
|
|
301
|
+
this.#appendLocal(sessionFile, stat, state, allowAsync);
|
|
271
302
|
return;
|
|
272
303
|
}
|
|
273
304
|
}
|
|
274
|
-
this.#loadLocalFull(sessionFile, stat);
|
|
305
|
+
this.#loadLocalFull(sessionFile, stat, allowAsync);
|
|
275
306
|
}
|
|
276
307
|
|
|
277
308
|
#clearLocal(reason: string): void {
|
|
@@ -305,7 +336,23 @@ export class AgentTranscriptViewer implements Component {
|
|
|
305
336
|
return true;
|
|
306
337
|
}
|
|
307
338
|
|
|
308
|
-
#loadLocalFull(sessionFile: string, stat: fs.Stats): void {
|
|
339
|
+
#loadLocalFull(sessionFile: string, stat: fs.Stats, allowAsync: boolean): void {
|
|
340
|
+
const promises = allowAsync ? this.#deps.transcript.fs.promises : undefined;
|
|
341
|
+
if (promises) {
|
|
342
|
+
this.#localLoadInFlight = true;
|
|
343
|
+
void promises
|
|
344
|
+
.readFile(sessionFile)
|
|
345
|
+
.then(data => {
|
|
346
|
+
this.#localLoadInFlight = false;
|
|
347
|
+
if (!this.#disposed) this.#applyLocalFull(sessionFile, stat, data);
|
|
348
|
+
})
|
|
349
|
+
.catch((err: unknown) => {
|
|
350
|
+
// Leave #localState unchanged so a transient read error retries next poll.
|
|
351
|
+
this.#localLoadInFlight = false;
|
|
352
|
+
logger.debug("transcript viewer: read failed", { err: String(err) });
|
|
353
|
+
});
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
309
356
|
let data: Buffer;
|
|
310
357
|
try {
|
|
311
358
|
data = this.#deps.transcript.fs.readFileSync(sessionFile);
|
|
@@ -314,6 +361,10 @@ export class AgentTranscriptViewer implements Component {
|
|
|
314
361
|
logger.debug("transcript viewer: read failed", { err: String(err) });
|
|
315
362
|
return;
|
|
316
363
|
}
|
|
364
|
+
this.#applyLocalFull(sessionFile, stat, data);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
#applyLocalFull(sessionFile: string, stat: fs.Stats, data: Buffer): void {
|
|
317
368
|
// The file may have grown between the earlier `statSync` and this read.
|
|
318
369
|
// Anchor the tail cursor to what we actually consumed so the next poll's
|
|
319
370
|
// `#appendLocal` never re-renders bytes already in the rebuilt transcript;
|
|
@@ -347,7 +398,7 @@ export class AgentTranscriptViewer implements Component {
|
|
|
347
398
|
this.#rebuild(this.#extractMessages(this.#deps.transcript.parseEntries(complete)));
|
|
348
399
|
}
|
|
349
400
|
|
|
350
|
-
#appendLocal(sessionFile: string, stat: fs.Stats, state: LocalTranscriptState): void {
|
|
401
|
+
#appendLocal(sessionFile: string, stat: fs.Stats, state: LocalTranscriptState, allowAsync: boolean): void {
|
|
351
402
|
let chunk: string;
|
|
352
403
|
try {
|
|
353
404
|
chunk = readFileRangeSync(
|
|
@@ -358,7 +409,7 @@ export class AgentTranscriptViewer implements Component {
|
|
|
358
409
|
).toString("utf-8");
|
|
359
410
|
} catch (err) {
|
|
360
411
|
logger.debug("transcript viewer: tail read failed", { err: String(err) });
|
|
361
|
-
this.#loadLocalFull(sessionFile, stat);
|
|
412
|
+
this.#loadLocalFull(sessionFile, stat, allowAsync);
|
|
362
413
|
return;
|
|
363
414
|
}
|
|
364
415
|
const combined = state.pending + chunk;
|
|
@@ -373,7 +424,7 @@ export class AgentTranscriptViewer implements Component {
|
|
|
373
424
|
// File unlinked/rotated mid-poll: fall back to a guarded full reload
|
|
374
425
|
// instead of letting the open escape the poll timer.
|
|
375
426
|
logger.debug("transcript viewer: sentinel recompute failed", { err: String(err) });
|
|
376
|
-
this.#loadLocalFull(sessionFile, stat);
|
|
427
|
+
this.#loadLocalFull(sessionFile, stat, allowAsync);
|
|
377
428
|
return;
|
|
378
429
|
}
|
|
379
430
|
this.#localState = {
|
|
@@ -15,7 +15,7 @@ import type { TspPickerAction, TspPickerColumn, TspPickerItem, TspPickerScope, T
|
|
|
15
15
|
import {
|
|
16
16
|
type Component,
|
|
17
17
|
Editor,
|
|
18
|
-
|
|
18
|
+
FuzzyQuery,
|
|
19
19
|
Input,
|
|
20
20
|
matchesKey,
|
|
21
21
|
replaceTabs,
|
|
@@ -194,12 +194,12 @@ function listRowId(rowDef: ListRow): string {
|
|
|
194
194
|
return rowDef.kind === "new" ? "new" : `agent:${rowDef.agent.source}:${rowDef.agent.name}`;
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
-
|
|
197
|
+
/** All `tokens` (prepared once per query) fuzzy-match the agent's searchable text. */
|
|
198
|
+
function matchAgent(agent: HubAgent, tokens: readonly FuzzyQuery[]): boolean {
|
|
199
|
+
// Not memoized: `overrideModel` is edited in place, and the module index
|
|
200
|
+
// cache already reuses the per-text index for unchanged agents.
|
|
198
201
|
const text = `${agent.name} ${agent.description} ${SOURCE_LABEL[agent.source]} ${agent.overrideModel ?? ""}`;
|
|
199
|
-
return
|
|
200
|
-
.trim()
|
|
201
|
-
.split(/\s+/)
|
|
202
|
-
.every(token => fuzzyMatch(token, text).matches);
|
|
202
|
+
return tokens.every(token => token.match(text).matches);
|
|
203
203
|
}
|
|
204
204
|
|
|
205
205
|
/**
|
|
@@ -373,7 +373,14 @@ export class AgentsHubComponent implements Component {
|
|
|
373
373
|
const scoped =
|
|
374
374
|
entry.kind === "source" ? this.#allAgents.filter(agent => agent.source === entry.source) : this.#allAgents;
|
|
375
375
|
const query = this.#search.getValue();
|
|
376
|
-
|
|
376
|
+
let filtered = scoped;
|
|
377
|
+
if (query) {
|
|
378
|
+
const tokens = query
|
|
379
|
+
.trim()
|
|
380
|
+
.split(/\s+/)
|
|
381
|
+
.map(token => new FuzzyQuery(token));
|
|
382
|
+
filtered = scoped.filter(agent => matchAgent(agent, tokens));
|
|
383
|
+
}
|
|
377
384
|
this.#rows = [...filtered.map(agent => ({ kind: "agent", agent }) as ListRow), { kind: "new" }];
|
|
378
385
|
}
|
|
379
386
|
|
|
@@ -327,6 +327,12 @@ interface ControlRegion {
|
|
|
327
327
|
end: number;
|
|
328
328
|
}
|
|
329
329
|
|
|
330
|
+
/** A block's preview rows (highlighted, width-cut, plus the "more lines" row) and its full line count. */
|
|
331
|
+
interface BlockPreview {
|
|
332
|
+
rows: string[];
|
|
333
|
+
lineCount: number;
|
|
334
|
+
}
|
|
335
|
+
|
|
330
336
|
export class CopySelectorComponent implements Component {
|
|
331
337
|
#builder: ChatTranscriptBuilder;
|
|
332
338
|
#browser: TranscriptBrowser;
|
|
@@ -348,6 +354,8 @@ export class CopySelectorComponent implements Component {
|
|
|
348
354
|
|
|
349
355
|
/** Last described root and the state it was built from. */
|
|
350
356
|
#native: { memo: string; targets: OutlineTarget[]; blocks: CopyBlock[] | undefined; node: NativeNode } | undefined;
|
|
357
|
+
/** ANSI block previews (syntax highlight + width cut) of the exploded turn. */
|
|
358
|
+
#blockPreviews: { blocks: CopyBlock[]; inner: number; previews: readonly BlockPreview[] } | undefined;
|
|
351
359
|
/** Transcript list items, rebuilt only when the replayed targets change. */
|
|
352
360
|
#nativeItems: { targets: OutlineTarget[]; truncated: boolean; items: NativeNode[] } | undefined;
|
|
353
361
|
/** Timeline picker items of the replayed targets. */
|
|
@@ -414,6 +422,7 @@ export class CopySelectorComponent implements Component {
|
|
|
414
422
|
}
|
|
415
423
|
|
|
416
424
|
invalidate(): void {
|
|
425
|
+
this.#blockPreviews = undefined;
|
|
417
426
|
this.#builder.container.invalidate();
|
|
418
427
|
this.#browser.invalidate();
|
|
419
428
|
}
|
|
@@ -945,18 +954,13 @@ export class CopySelectorComponent implements Component {
|
|
|
945
954
|
*/
|
|
946
955
|
#composeBlocks(blocks: CopyBlock[], columnWidth: number, lineOffset: number): ComposedColumn {
|
|
947
956
|
const inner = Math.max(10, columnWidth - 4);
|
|
957
|
+
const previews = this.#previewBlocks(blocks, inner);
|
|
948
958
|
const lines: string[] = [];
|
|
949
959
|
let selStart = -1;
|
|
950
960
|
let selEnd = -1;
|
|
951
961
|
for (let index = 0; index < blocks.length; index++) {
|
|
952
962
|
const block = blocks[index]!;
|
|
953
|
-
const
|
|
954
|
-
const shown = raw.slice(0, BLOCK_PREVIEW_LINES);
|
|
955
|
-
const styled = block.language ? highlightCode(shown.join("\n"), block.language) : shown;
|
|
956
|
-
const rows = styled.map(row => truncateToWidth(replaceTabs(row), inner));
|
|
957
|
-
if (raw.length > shown.length) {
|
|
958
|
-
rows.push(theme.fg("dim", `… +${raw.length - shown.length} more lines`));
|
|
959
|
-
}
|
|
963
|
+
const { rows, lineCount } = previews[index]!;
|
|
960
964
|
const selected = index === this.#blockSelected;
|
|
961
965
|
const captionColor: ThemeColor = selected ? OUTLINE_COLOR : "dim";
|
|
962
966
|
const controls: Array<{ action: ControlRegion["action"]; text: string }> = [
|
|
@@ -965,7 +969,7 @@ export class CopySelectorComponent implements Component {
|
|
|
965
969
|
if (block.href && this.deps.onOpen) controls.push({ action: "open", text: `${theme.cmd.share} open` });
|
|
966
970
|
const controlsWidth = controls.reduce((sum, control) => sum + visibleWidth(control.text) + 2, 0);
|
|
967
971
|
const summary = truncateToWidth(
|
|
968
|
-
`${index + 1}/${blocks.length}${theme.sep.dot}${block.label}${theme.sep.dot}${
|
|
972
|
+
`${index + 1}/${blocks.length}${theme.sep.dot}${block.label}${theme.sep.dot}${lineCount} line${lineCount === 1 ? "" : "s"}`,
|
|
969
973
|
Math.max(4, inner - controlsWidth),
|
|
970
974
|
);
|
|
971
975
|
// Caption: two-space gutter, summary, then the controls, each preceded by two spaces.
|
|
@@ -998,6 +1002,24 @@ export class CopySelectorComponent implements Component {
|
|
|
998
1002
|
lines.push("");
|
|
999
1003
|
return { lines, selStart, selEnd };
|
|
1000
1004
|
}
|
|
1005
|
+
|
|
1006
|
+
/** Highlighted, width-cut previews of each block; rebuilt when the block set or width changes. */
|
|
1007
|
+
#previewBlocks(blocks: CopyBlock[], inner: number): readonly BlockPreview[] {
|
|
1008
|
+
const cached = this.#blockPreviews;
|
|
1009
|
+
if (cached?.blocks === blocks && cached.inner === inner) return cached.previews;
|
|
1010
|
+
const previews = blocks.map(block => {
|
|
1011
|
+
const raw = block.content.split("\n");
|
|
1012
|
+
const shown = raw.slice(0, BLOCK_PREVIEW_LINES);
|
|
1013
|
+
const styled = block.language ? highlightCode(shown.join("\n"), block.language) : shown;
|
|
1014
|
+
const rows = styled.map(row => truncateToWidth(replaceTabs(row), inner));
|
|
1015
|
+
if (raw.length > shown.length) {
|
|
1016
|
+
rows.push(theme.fg("dim", `… +${raw.length - shown.length} more lines`));
|
|
1017
|
+
}
|
|
1018
|
+
return { rows, lineCount: raw.length };
|
|
1019
|
+
});
|
|
1020
|
+
this.#blockPreviews = { blocks, inner, previews };
|
|
1021
|
+
return previews;
|
|
1022
|
+
}
|
|
1001
1023
|
}
|
|
1002
1024
|
|
|
1003
1025
|
/** Raw multi-line text of a user message (string or text blocks). */
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Pure dashboard tree, filtering, and selection state. */
|
|
2
|
-
import {
|
|
2
|
+
import { FuzzyQuery, FuzzyText } from "../../fuzzy";
|
|
3
3
|
import {
|
|
4
4
|
type DashboardState,
|
|
5
5
|
type DisabledReason,
|
|
@@ -118,6 +118,50 @@ export function flattenTree(tree: TreeNode[]): FlatTreeItem[] {
|
|
|
118
118
|
return flat;
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/** Searchable fields an extension's cached {@link FuzzyText} was built from. */
|
|
122
|
+
interface ExtensionSearchMemo {
|
|
123
|
+
name: string;
|
|
124
|
+
displayName: string;
|
|
125
|
+
description: string | undefined;
|
|
126
|
+
trigger: string | undefined;
|
|
127
|
+
providerName: string;
|
|
128
|
+
kind: string;
|
|
129
|
+
text: FuzzyText;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Prepared search text per extension; rebuilt if any searchable field changes. */
|
|
133
|
+
const extensionSearchCache = new WeakMap<Extension, ExtensionSearchMemo>();
|
|
134
|
+
|
|
135
|
+
function extensionSearchText(ext: Extension): FuzzyText {
|
|
136
|
+
const memo = extensionSearchCache.get(ext);
|
|
137
|
+
if (
|
|
138
|
+
memo !== undefined &&
|
|
139
|
+
memo.name === ext.name &&
|
|
140
|
+
memo.displayName === ext.displayName &&
|
|
141
|
+
memo.description === ext.description &&
|
|
142
|
+
memo.trigger === ext.trigger &&
|
|
143
|
+
memo.providerName === ext.source.providerName &&
|
|
144
|
+
memo.kind === ext.kind
|
|
145
|
+
) {
|
|
146
|
+
return memo.text;
|
|
147
|
+
}
|
|
148
|
+
const text = new FuzzyText(
|
|
149
|
+
[ext.name, ext.displayName, ext.description || "", ext.trigger || "", ext.source.providerName, ext.kind].join(
|
|
150
|
+
" ",
|
|
151
|
+
),
|
|
152
|
+
);
|
|
153
|
+
extensionSearchCache.set(ext, {
|
|
154
|
+
name: ext.name,
|
|
155
|
+
displayName: ext.displayName,
|
|
156
|
+
description: ext.description,
|
|
157
|
+
trigger: ext.trigger,
|
|
158
|
+
providerName: ext.source.providerName,
|
|
159
|
+
kind: ext.kind,
|
|
160
|
+
text,
|
|
161
|
+
});
|
|
162
|
+
return text;
|
|
163
|
+
}
|
|
164
|
+
|
|
121
165
|
/**
|
|
122
166
|
* Apply fuzzy filter to extensions.
|
|
123
167
|
*/
|
|
@@ -126,22 +170,18 @@ export function applyFilter(extensions: Extension[], query: string): Extension[]
|
|
|
126
170
|
return extensions;
|
|
127
171
|
}
|
|
128
172
|
|
|
129
|
-
const tokens = query
|
|
173
|
+
const tokens = query
|
|
174
|
+
.toLowerCase()
|
|
175
|
+
.split(/\s+/)
|
|
176
|
+
.filter(Boolean)
|
|
177
|
+
.map(token => new FuzzyQuery(token));
|
|
130
178
|
if (tokens.length === 0) {
|
|
131
179
|
return extensions;
|
|
132
180
|
}
|
|
133
181
|
|
|
134
182
|
return extensions.filter(ext => {
|
|
135
|
-
const searchable =
|
|
136
|
-
|
|
137
|
-
ext.displayName,
|
|
138
|
-
ext.description || "",
|
|
139
|
-
ext.trigger || "",
|
|
140
|
-
ext.source.providerName,
|
|
141
|
-
ext.kind,
|
|
142
|
-
].join(" ");
|
|
143
|
-
|
|
144
|
-
return tokens.every(token => fuzzyMatch(token, searchable).matches);
|
|
183
|
+
const searchable = extensionSearchText(ext);
|
|
184
|
+
return tokens.every(token => token.match(searchable).matches);
|
|
145
185
|
});
|
|
146
186
|
}
|
|
147
187
|
|
|
@@ -65,6 +65,13 @@ const PICKER_KEY = "picker";
|
|
|
65
65
|
/** Visible result rows; also the jump distance for PageUp/PageDown. */
|
|
66
66
|
const MAX_VISIBLE = 10;
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Quiet period after the last query edit before the synchronous storage
|
|
70
|
+
* search runs. Only applied when the host wires {@link HistorySearchComponent.setOnRequestRender},
|
|
71
|
+
* since the deferred results need a repaint to show up.
|
|
72
|
+
*/
|
|
73
|
+
const SEARCH_DEBOUNCE_MS = 100;
|
|
74
|
+
|
|
68
75
|
/** Split a query the same way `HistorySource` tokenizes it, so highlights align with matches. */
|
|
69
76
|
function queryTokens(query: string): string[] {
|
|
70
77
|
return query
|
|
@@ -106,6 +113,8 @@ class HistoryResultsList implements Component {
|
|
|
106
113
|
#menu: MenuSelection<HistorySearchEntry>;
|
|
107
114
|
#tokens: string[] = [];
|
|
108
115
|
#maxVisible = MAX_VISIBLE;
|
|
116
|
+
/** Highlighted prompt text per entry, valid for one `(tokens, budget)` pair; reset on invalidate (theme). */
|
|
117
|
+
#highlightCache = new WeakMap<HistorySearchEntry, { tokens: string[]; budget: number; text: string }>();
|
|
109
118
|
|
|
110
119
|
constructor(menu: MenuSelection<HistorySearchEntry>) {
|
|
111
120
|
this.#menu = menu;
|
|
@@ -116,7 +125,17 @@ class HistoryResultsList implements Component {
|
|
|
116
125
|
}
|
|
117
126
|
|
|
118
127
|
invalidate(): void {
|
|
119
|
-
|
|
128
|
+
this.#highlightCache = new WeakMap();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Truncated, token-highlighted prompt of `entry` for `budget` cells. */
|
|
132
|
+
#highlightedPrompt(entry: HistorySearchEntry, budget: number): string {
|
|
133
|
+
const cached = this.#highlightCache.get(entry);
|
|
134
|
+
if (cached !== undefined && cached.tokens === this.#tokens && cached.budget === budget) return cached.text;
|
|
135
|
+
const normalized = entry.prompt.replace(/\s+/g, " ").trim();
|
|
136
|
+
const text = highlightTokens(truncateToWidth(normalized, budget), this.#tokens);
|
|
137
|
+
this.#highlightCache.set(entry, { tokens: this.#tokens, budget, text });
|
|
138
|
+
return text;
|
|
120
139
|
}
|
|
121
140
|
|
|
122
141
|
render(width: number): readonly string[] {
|
|
@@ -151,9 +170,7 @@ class HistoryResultsList implements Component {
|
|
|
151
170
|
const showTime = rowWidth >= gutterWidth + 12 + timeWidth;
|
|
152
171
|
|
|
153
172
|
const promptBudget = Math.max(4, rowWidth - gutterWidth - (showTime ? timeWidth + 1 : 0));
|
|
154
|
-
const
|
|
155
|
-
const plain = truncateToWidth(normalized, promptBudget);
|
|
156
|
-
const highlighted = highlightTokens(plain, this.#tokens);
|
|
173
|
+
const highlighted = this.#highlightedPrompt(entry, promptBudget);
|
|
157
174
|
|
|
158
175
|
const cursor = isSelected ? theme.fg("accent", cursorSymbol) : padding(gutterWidth);
|
|
159
176
|
let line = cursor + (isSelected ? theme.bold(highlighted) : highlighted);
|
|
@@ -186,12 +203,22 @@ export class HistorySearchComponent extends OverlayPanel {
|
|
|
186
203
|
#nativeHints: NativeNode | undefined;
|
|
187
204
|
#nativeMemo: HistoryNativeMemo | undefined;
|
|
188
205
|
#pickerItems: { items: readonly HistorySearchEntry[]; rows: readonly TspPickerItem[] } | undefined;
|
|
206
|
+
/** Trimmed query the current results were fetched for; `undefined` before the first fetch. */
|
|
207
|
+
#resultsQuery: string | undefined;
|
|
208
|
+
#searchTimer: NodeJS.Timeout | undefined;
|
|
209
|
+
#onRequestRender?: () => void;
|
|
189
210
|
|
|
190
211
|
constructor(historyStorage: HistorySource, onSelect: (prompt: string) => void, onCancel: () => void) {
|
|
191
212
|
super("History", "omp.overlay.history");
|
|
192
213
|
this.#historyStorage = historyStorage;
|
|
193
|
-
this.#onSelect =
|
|
194
|
-
|
|
214
|
+
this.#onSelect = prompt => {
|
|
215
|
+
this.#cancelPendingSearch();
|
|
216
|
+
onSelect(prompt);
|
|
217
|
+
};
|
|
218
|
+
this.#onCancel = () => {
|
|
219
|
+
this.#cancelPendingSearch();
|
|
220
|
+
onCancel();
|
|
221
|
+
};
|
|
195
222
|
|
|
196
223
|
this.#menu = new MenuSelection<HistorySearchEntry>([], {
|
|
197
224
|
getKey: entry => `${entry.created_at}:${entry.prompt}`,
|
|
@@ -199,6 +226,7 @@ export class HistorySearchComponent extends OverlayPanel {
|
|
|
199
226
|
});
|
|
200
227
|
this.#searchInput = new Input();
|
|
201
228
|
this.#searchInput.onSubmit = () => {
|
|
229
|
+
this.#flushPendingSearch();
|
|
202
230
|
const selected = this.#menu.selectedItem;
|
|
203
231
|
if (selected) {
|
|
204
232
|
this.#onSelect(selected.prompt);
|
|
@@ -225,38 +253,59 @@ export class HistorySearchComponent extends OverlayPanel {
|
|
|
225
253
|
this.#updateResults();
|
|
226
254
|
}
|
|
227
255
|
|
|
256
|
+
/**
|
|
257
|
+
* Repaint hook for debounced result updates. Once set, query edits search
|
|
258
|
+
* storage after a {@link SEARCH_DEBOUNCE_MS} quiet period instead of on
|
|
259
|
+
* every key; without it every edit searches synchronously.
|
|
260
|
+
*/
|
|
261
|
+
setOnRequestRender(callback: () => void): void {
|
|
262
|
+
this.#onRequestRender = callback;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
override dispose(): void {
|
|
266
|
+
this.#cancelPendingSearch();
|
|
267
|
+
super.dispose();
|
|
268
|
+
}
|
|
269
|
+
|
|
228
270
|
handleInput(keyData: string): void {
|
|
229
271
|
if (matchesSelectUp(keyData)) {
|
|
272
|
+
this.#flushPendingSearch();
|
|
230
273
|
this.#menu.move(-1, false);
|
|
231
274
|
return;
|
|
232
275
|
}
|
|
233
276
|
|
|
234
277
|
if (matchesSelectDown(keyData)) {
|
|
278
|
+
this.#flushPendingSearch();
|
|
235
279
|
this.#menu.move(1, false);
|
|
236
280
|
return;
|
|
237
281
|
}
|
|
238
282
|
|
|
239
283
|
if (matchesSelectPageUp(keyData)) {
|
|
284
|
+
this.#flushPendingSearch();
|
|
240
285
|
this.#menu.move(-MAX_VISIBLE, false);
|
|
241
286
|
return;
|
|
242
287
|
}
|
|
243
288
|
|
|
244
289
|
if (matchesSelectPageDown(keyData)) {
|
|
290
|
+
this.#flushPendingSearch();
|
|
245
291
|
this.#menu.move(MAX_VISIBLE, false);
|
|
246
292
|
return;
|
|
247
293
|
}
|
|
248
294
|
|
|
249
295
|
if (matchesKey(keyData, "home")) {
|
|
296
|
+
this.#flushPendingSearch();
|
|
250
297
|
this.#menu.moveToBoundary("first");
|
|
251
298
|
return;
|
|
252
299
|
}
|
|
253
300
|
|
|
254
301
|
if (matchesKey(keyData, "end")) {
|
|
302
|
+
this.#flushPendingSearch();
|
|
255
303
|
this.#menu.moveToBoundary("last");
|
|
256
304
|
return;
|
|
257
305
|
}
|
|
258
306
|
|
|
259
307
|
if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
|
|
308
|
+
this.#flushPendingSearch();
|
|
260
309
|
const selected = this.#menu.selectedItem;
|
|
261
310
|
if (selected) {
|
|
262
311
|
this.#onSelect(selected.prompt);
|
|
@@ -270,7 +319,7 @@ export class HistorySearchComponent extends OverlayPanel {
|
|
|
270
319
|
}
|
|
271
320
|
|
|
272
321
|
this.#searchInput.handleInput(keyData);
|
|
273
|
-
this.#
|
|
322
|
+
this.#scheduleResults();
|
|
274
323
|
}
|
|
275
324
|
|
|
276
325
|
/**
|
|
@@ -372,6 +421,9 @@ export class HistorySearchComponent extends OverlayPanel {
|
|
|
372
421
|
* Picker: a row click highlights, a second click or `Insert` inserts it
|
|
373
422
|
* (Enter), `Close` cancels (Esc), `Clear search` empties the query.
|
|
374
423
|
* List: picking a result does what highlighting it and pressing Enter does.
|
|
424
|
+
* A row picked while a debounced search is pending belongs to the previous
|
|
425
|
+
* query: results are refreshed first and the pick only lands if that entry
|
|
426
|
+
* is still among them.
|
|
375
427
|
*/
|
|
376
428
|
handleNativeEvent(event: NativeUiEvent): void {
|
|
377
429
|
const ev = pickerEvent(event, PICKER_KEY);
|
|
@@ -385,6 +437,7 @@ export class HistorySearchComponent extends OverlayPanel {
|
|
|
385
437
|
}
|
|
386
438
|
return;
|
|
387
439
|
}
|
|
440
|
+
this.#flushPendingSearch();
|
|
388
441
|
const index = this.#menu.visibleItems.findIndex(entry => nativeEntryKey(entry) === ev.item);
|
|
389
442
|
if (index < 0) return;
|
|
390
443
|
this.#menu.setSelectedIndex(index);
|
|
@@ -392,6 +445,7 @@ export class HistorySearchComponent extends OverlayPanel {
|
|
|
392
445
|
return;
|
|
393
446
|
}
|
|
394
447
|
if ((event.type !== "select" && event.type !== "activate") || event.key !== "list") return;
|
|
448
|
+
this.#flushPendingSearch();
|
|
395
449
|
const index = this.#menu.visibleItems.findIndex(entry => nativeEntryKey(entry) === event.item);
|
|
396
450
|
const target = this.#menu.visibleItems[index];
|
|
397
451
|
if (!target) return;
|
|
@@ -399,8 +453,47 @@ export class HistorySearchComponent extends OverlayPanel {
|
|
|
399
453
|
this.#onSelect(target.prompt);
|
|
400
454
|
}
|
|
401
455
|
|
|
456
|
+
/**
|
|
457
|
+
* Refresh results after a query edit: no-op when the trimmed query is
|
|
458
|
+
* unchanged (cursor moves, trailing spaces), immediate for the empty query
|
|
459
|
+
* or when no repaint hook is wired, debounced otherwise.
|
|
460
|
+
*/
|
|
461
|
+
#scheduleResults(): void {
|
|
462
|
+
const query = this.#searchInput.getValue().trim();
|
|
463
|
+
if (query === this.#resultsQuery) {
|
|
464
|
+
this.#cancelPendingSearch();
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
const requestRender = this.#onRequestRender;
|
|
468
|
+
if (!query || !requestRender) {
|
|
469
|
+
this.#updateResults();
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
this.#cancelPendingSearch();
|
|
473
|
+
this.#searchTimer = setTimeout(() => {
|
|
474
|
+
this.#searchTimer = undefined;
|
|
475
|
+
this.#updateResults();
|
|
476
|
+
requestRender();
|
|
477
|
+
}, SEARCH_DEBOUNCE_MS);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Run a pending debounced search now, so selection acts on the current query's results. */
|
|
481
|
+
#flushPendingSearch(): void {
|
|
482
|
+
if (this.#searchTimer === undefined) return;
|
|
483
|
+
this.#cancelPendingSearch();
|
|
484
|
+
this.#updateResults();
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
#cancelPendingSearch(): void {
|
|
488
|
+
if (this.#searchTimer === undefined) return;
|
|
489
|
+
clearTimeout(this.#searchTimer);
|
|
490
|
+
this.#searchTimer = undefined;
|
|
491
|
+
}
|
|
492
|
+
|
|
402
493
|
#updateResults(): void {
|
|
494
|
+
this.#cancelPendingSearch();
|
|
403
495
|
const query = this.#searchInput.getValue().trim();
|
|
496
|
+
this.#resultsQuery = query;
|
|
404
497
|
const results = query
|
|
405
498
|
? this.#historyStorage.search(query, this.#resultLimit)
|
|
406
499
|
: this.#historyStorage.getRecent(this.#resultLimit);
|
|
@@ -110,6 +110,8 @@ export class JobsSheet implements Component {
|
|
|
110
110
|
readonly #source: JobsSheetSource;
|
|
111
111
|
#selectedId: string | undefined;
|
|
112
112
|
#native: { key: string; output: string | undefined; node: NativeNode } | undefined;
|
|
113
|
+
/** Fallback output tail, reused while the selected job's output is unchanged. */
|
|
114
|
+
#tail: { id: string; output: string; lines: readonly string[] } | undefined;
|
|
113
115
|
|
|
114
116
|
constructor(source: JobsSheetSource) {
|
|
115
117
|
this.#source = source;
|
|
@@ -177,8 +179,9 @@ export class JobsSheet implements Component {
|
|
|
177
179
|
detail.artifactId && `artifact://${detail.artifactId}`,
|
|
178
180
|
]);
|
|
179
181
|
if (facts.length > 0) lines.push(truncateToWidth(` ${facts.join(" · ")}`, width));
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
+
for (const line of this.#tailLines(selected.id, detail.output ?? "")) {
|
|
183
|
+
if (line) lines.push(truncateToWidth(` ${line}`, width));
|
|
184
|
+
}
|
|
182
185
|
}
|
|
183
186
|
return lines;
|
|
184
187
|
}
|
|
@@ -210,6 +213,14 @@ export class JobsSheet implements Component {
|
|
|
210
213
|
return { jobs, selected };
|
|
211
214
|
}
|
|
212
215
|
|
|
216
|
+
#tailLines(id: string, output: string): readonly string[] {
|
|
217
|
+
const cached = this.#tail;
|
|
218
|
+
if (cached?.id === id && cached.output === output) return cached.lines;
|
|
219
|
+
const lines = tailLines(output, FALLBACK_TAIL_LINES);
|
|
220
|
+
this.#tail = { id, output, lines };
|
|
221
|
+
return lines;
|
|
222
|
+
}
|
|
223
|
+
|
|
213
224
|
#move(delta: number): void {
|
|
214
225
|
const { jobs, selected } = this.#current();
|
|
215
226
|
if (!selected) return;
|
|
@@ -228,6 +239,23 @@ function jobAge(job: JobsPanelJob, nowMs: number): number {
|
|
|
228
239
|
return Math.max(0, (job.endTime ?? nowMs) - job.startTime);
|
|
229
240
|
}
|
|
230
241
|
|
|
242
|
+
/**
|
|
243
|
+
* The last `count` pieces of `text.trimEnd().split("\n")`, found by scanning
|
|
244
|
+
* back from the end so a long running output is never split as a whole.
|
|
245
|
+
*/
|
|
246
|
+
function tailLines(text: string, count: number): string[] {
|
|
247
|
+
const trimmed = text.trimEnd();
|
|
248
|
+
const lines: string[] = [];
|
|
249
|
+
let end = trimmed.length;
|
|
250
|
+
while (lines.length < count) {
|
|
251
|
+
const newline = end > 0 ? trimmed.lastIndexOf("\n", end - 1) : -1;
|
|
252
|
+
lines.push(trimmed.slice(newline + 1, end));
|
|
253
|
+
if (newline < 0) break;
|
|
254
|
+
end = newline;
|
|
255
|
+
}
|
|
256
|
+
return lines.reverse();
|
|
257
|
+
}
|
|
258
|
+
|
|
231
259
|
/** A task job as an `agent` node when the terminal draws them, else a one-line dot row. */
|
|
232
260
|
function describeJob(job: JobsPanelJob, nowMs: number, agent: boolean): NativeNode {
|
|
233
261
|
const settled = job.status !== "running";
|