@nklisch/pi-enhanced 0.2.6 → 0.3.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/node_modules/@nklisch/pi-clearance/native/clearance-core.linux-x64-gnu.node +0 -0
  3. package/node_modules/@nklisch/pi-clearance/native/clearance-core.win32-x64-msvc.node +0 -0
  4. package/node_modules/@nklisch/pi-plugins/README.md +4 -2
  5. package/node_modules/@nklisch/pi-plugins/dist/hooks.js +81 -8
  6. package/node_modules/@nklisch/pi-plugins/dist/hooks.js.map +1 -1
  7. package/node_modules/@nklisch/pi-plugins/dist/mcp.js +32 -1
  8. package/node_modules/@nklisch/pi-plugins/dist/mcp.js.map +1 -1
  9. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager.d.ts +10 -4
  10. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager.js +144 -10
  11. package/node_modules/@nklisch/pi-plugins/dist/pi/plugin-manager.js.map +1 -1
  12. package/node_modules/@nklisch/pi-plugins/dist/runtime-discovery.js +40 -14
  13. package/node_modules/@nklisch/pi-plugins/dist/runtime-discovery.js.map +1 -1
  14. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/CHANGELOG.md +25 -0
  15. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/README.md +75 -47
  16. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/dist/public.d.ts +171 -140
  17. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/dist/settings.d.ts +2 -2
  18. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/FORK-MAINTENANCE.md +8 -7
  19. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/VISION.md +12 -10
  20. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/architecture/architecture.md +112 -105
  21. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/comparison-with-upstream.md +3 -3
  22. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/docs/decisions/0004-reconsider-ui-direction.md +5 -0
  23. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/package.json +1 -1
  24. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/config/custom-agents.ts +31 -4
  25. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/config/default-agents.ts +1 -1
  26. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/config/invocation-config.ts +7 -4
  27. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/index.ts +18 -9
  28. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/layered-settings.ts +2 -2
  29. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/child-lifecycle.ts +10 -63
  30. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/concurrency-limiter.ts +122 -76
  31. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/create-subagent-session.ts +3 -5
  32. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/lifecycle-interceptor.ts +4 -3
  33. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/run-listeners.ts +2 -23
  34. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent-manager.ts +177 -252
  35. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent-session.ts +90 -280
  36. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent-state.ts +153 -223
  37. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/subagent.ts +569 -683
  38. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/lifecycle/workspace.ts +1 -1
  39. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/observation/notification.ts +78 -119
  40. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/observation/renderer.ts +15 -42
  41. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/observation/subagent-events-observer.ts +44 -95
  42. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/service/service-adapter.ts +157 -90
  43. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/service/service.ts +60 -68
  44. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/session/conversation.ts +3 -3
  45. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/session/query-source.ts +28 -0
  46. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/session/query.ts +327 -0
  47. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/settings.ts +1 -1
  48. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/agent-tool.ts +171 -249
  49. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/get-result-tool.ts +51 -92
  50. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/helpers.ts +9 -6
  51. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/list-tool.ts +46 -0
  52. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/parent-tool-registry.ts +17 -0
  53. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/query-session-tool.ts +330 -0
  54. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/result-renderer.ts +27 -111
  55. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/resume-tool.ts +105 -0
  56. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/spawn-config.ts +26 -8
  57. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/steer-tool.ts +27 -111
  58. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/stop-tool.ts +53 -0
  59. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/types.ts +11 -5
  60. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/agent-widget.ts +33 -27
  61. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/display.ts +6 -3
  62. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/session-navigation.ts +121 -10
  63. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/session-navigator.ts +430 -112
  64. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/subagents-settings.ts +1 -1
  65. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/ui/widget-renderer.ts +8 -9
  66. package/node_modules/@nklisch/pi-plugins/package.json +2 -2
  67. package/package.json +1 -1
  68. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/background-spawner.ts +0 -81
  69. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/foreground-runner.ts +0 -149
  70. package/node_modules/@nklisch/pi-plugins/node_modules/@nklisch/pi-subagents/src/tools/get-result-report.ts +0 -77
@@ -2,17 +2,9 @@
2
2
  * session-navigator.ts — The `/subagents:sessions` command: pick a subagent and
3
3
  * read its transcript through Pi's own per-entry session components.
4
4
  *
5
- * SDK/TUI consumer half of native session navigation. The unit-testable core
6
- * (selection, sourcing) lives in `session-navigation.ts`; this module wires that
7
- * core to the command picker and a read-only scrollable overlay, and owns the
8
- * renderer — it mounts Pi's interactive components (`AssistantMessageComponent`,
9
- * `ToolExecutionComponent`, …) into a `Container`, mirroring Pi's own
10
- * `renderSessionContext` mapping. Rendering lives here, not in the pure module,
11
- * because the components require a `TUI`, `cwd`, and markdown theme.
12
- *
13
- * The overlay is strictly read-only — steering stays in the `steer_subagent` tool
14
- * and the widget. It consumes a `TranscriptSource`, so the evicted-agent-source
15
- * follow-up swaps the source without touching the renderer or the overlay.
5
+ * The overlay remains a read-only native Pi transcript. Query state is local to
6
+ * this view and uses the same projection as the parent query tool; it never
7
+ * becomes a persisted index or a second message renderer.
16
8
  */
17
9
 
18
10
  import {
@@ -30,6 +22,9 @@ import {
30
22
  import {
31
23
  type Component,
32
24
  Container,
25
+ type Focusable,
26
+ Input,
27
+ Key,
33
28
  type MarkdownTheme,
34
29
  matchesKey,
35
30
  Spacer,
@@ -41,7 +36,16 @@ import type { AgentConfigLookup } from "#src/config/agent-types";
41
36
  import { debugLog } from "#src/debug";
42
37
  import type { SessionMessage } from "#src/types";
43
38
  import { describeActivity, formatDuration, formatModelThinking, type Theme } from "#src/ui/display";
44
- import { fileSnapshotSource, listNavigableAgents, liveSource, type NavigableSubagent, type RunDisplayMetadata, type TranscriptSource } from "#src/ui/session-navigation";
39
+ import {
40
+ fileSnapshotSource,
41
+ liveFileSource,
42
+ listNavigableAgents,
43
+ type NavigableSubagent,
44
+ type RunDisplayMetadata,
45
+ type TranscriptSource,
46
+ type TranscriptSourceAvailability,
47
+ } from "#src/ui/session-navigation";
48
+ import { projectSessionMessages, querySession, type SessionQueryEntry } from "#src/session/query";
45
49
 
46
50
  // ─────────────────────────────────────────────────────────────────────────────
47
51
 
@@ -49,6 +53,9 @@ import { fileSnapshotSource, listNavigableAgents, liveSource, type NavigableSuba
49
53
  const CHROME_LINES = 6;
50
54
  const MIN_VIEWPORT = 3;
51
55
  const VIEWPORT_HEIGHT_PCT = 70;
56
+ const OVERLAY_QUERY_LIMIT = 50;
57
+
58
+ type MatchMark = { readonly index: number; readonly total: number; readonly selected: boolean };
52
59
 
53
60
  /** Component factory shape Pi's `ui.custom` invokes to mount an overlay. */
54
61
  export type OverlayComponentFactory<R> = (
@@ -111,7 +118,9 @@ export class SessionNavigatorHandler {
111
118
 
112
119
  let source: TranscriptSource;
113
120
  try {
114
- source = entry.kind === "live" ? liveSource(entry.record) : fileSnapshotSource(entry.outputFile, readFile);
121
+ source = entry.kind === "live"
122
+ ? liveFileSource(entry.record, readFile)
123
+ : fileSnapshotSource(entry.outputFile, readFile);
115
124
  } catch {
116
125
  ui.notify("Could not read the session transcript file.", "error");
117
126
  return;
@@ -129,20 +138,20 @@ export class SessionNavigatorHandler {
129
138
  }
130
139
 
131
140
  /**
132
- * Read-only scrollable transcript overlay.
141
+ * Read-only scrollable transcript overlay with literal query navigation.
133
142
  *
134
- * Caches a `Container` of Pi's per-entry components and rebuilds it only when the
135
- * source changes (live agents) each paint reuses the cached tree, so markdown
136
- * highlighting does not re-run per frame. This class owns scroll state, chrome,
137
- * and the running-agent streaming indicator; the component mapping lives in
138
- * `buildTranscriptComponents`.
143
+ * Pi's message/tool components remain the body renderer. `MatchChrome` only
144
+ * adds block-level rails and a bounded match label around those components,
145
+ * which is the intentional fallback because the native components do not expose
146
+ * substring-highlighting hooks.
139
147
  */
140
- export class TranscriptOverlay implements Component {
148
+ export class TranscriptOverlay implements Component, Focusable {
141
149
  private scrollOffset = 0;
142
150
  private autoScroll = true;
143
151
  private unsubscribe: (() => void) | undefined;
144
152
  private runtimeInterval: ReturnType<typeof setInterval> | undefined;
145
153
  private closed = false;
154
+ private _focused = false;
146
155
 
147
156
  private readonly tui: TUI;
148
157
  private readonly theme: Theme;
@@ -151,7 +160,24 @@ export class TranscriptOverlay implements Component {
151
160
  private readonly done: (result: undefined) => void;
152
161
  private readonly cwd: string;
153
162
  private readonly markdownTheme: MarkdownTheme;
163
+ private readonly searchInput = new Input();
154
164
  private content: Container;
165
+ private sourceMessages: readonly SessionMessage[] = [];
166
+ private matches: readonly SessionQueryEntry[] = [];
167
+ /** Query total, not the capped visible match list; keeps overflow truthful. */
168
+ private totalMatches = 0;
169
+ private matchMarks = new Map<string, MatchMark>();
170
+ private selectedMatchId: string | undefined;
171
+ private searchActive = false;
172
+ private searchCommitted = false;
173
+ private toolsOnly = false;
174
+ private newMatchCount = 0;
175
+
176
+ get focused(): boolean { return this._focused; }
177
+ set focused(value: boolean) {
178
+ this._focused = value;
179
+ this.searchInput.focused = value;
180
+ }
155
181
 
156
182
  constructor({ tui, theme, source, run, done, cwd, markdownTheme }: TranscriptOverlayOptions) {
157
183
  this.tui = tui;
@@ -162,7 +188,7 @@ export class TranscriptOverlay implements Component {
162
188
  this.cwd = cwd;
163
189
  this.markdownTheme = markdownTheme;
164
190
  try {
165
- this.content = this.rebuild();
191
+ this.content = this.rebuildFromSource(false);
166
192
  } catch (error) {
167
193
  debugLog("session navigator initial render", error);
168
194
  this.content = new Container();
@@ -172,43 +198,27 @@ export class TranscriptOverlay implements Component {
172
198
  } catch (error) {
173
199
  debugLog("session navigator subscribe", error);
174
200
  }
175
- if (this.isRunning()) {
176
- this.runtimeInterval = setInterval(() => this.refreshRuntime(), 100);
177
- }
201
+ if (this.isRunning()) this.runtimeInterval = setInterval(() => this.refreshRuntime(), 100);
178
202
  }
179
203
 
180
204
  handleInput(data: string): void {
181
205
  try {
182
- if (matchesKey(data, "escape") || matchesKey(data, "q")) {
183
- this.closed = true;
184
- this.clearRuntimeInterval();
185
- this.done(undefined);
206
+ if (this.searchActive) {
207
+ this.handleSearchInput(data);
186
208
  return;
187
209
  }
188
210
 
189
- const totalLines = this.buildContentLines(this.innerWidth()).length;
190
- const viewportHeight = this.viewportHeight();
191
- const maxScroll = Math.max(0, totalLines - viewportHeight);
192
-
193
- if (matchesKey(data, "up") || matchesKey(data, "k")) {
194
- this.scrollOffset = Math.max(0, this.scrollOffset - 1);
195
- this.autoScroll = this.scrollOffset >= maxScroll;
196
- } else if (matchesKey(data, "down") || matchesKey(data, "j")) {
197
- this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
198
- this.autoScroll = this.scrollOffset >= maxScroll;
199
- } else if (matchesKey(data, "pageUp") || matchesKey(data, "shift+up")) {
200
- this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight);
201
- this.autoScroll = false;
202
- } else if (matchesKey(data, "pageDown") || matchesKey(data, "shift+down")) {
203
- this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewportHeight);
204
- this.autoScroll = this.scrollOffset >= maxScroll;
205
- } else if (matchesKey(data, "home")) {
206
- this.scrollOffset = 0;
207
- this.autoScroll = false;
208
- } else if (matchesKey(data, "end")) {
209
- this.scrollOffset = maxScroll;
210
- this.autoScroll = true;
211
+ if (data === "/") {
212
+ this.beginSearch();
213
+ this.requestRender();
214
+ return;
215
+ }
216
+ if (matchesKey(data, Key.escape) || data === "q") {
217
+ this.close();
218
+ return;
211
219
  }
220
+ this.handleScrollInput(data);
221
+ this.requestRender();
212
222
  } catch (error) {
213
223
  debugLog("session navigator input", error);
214
224
  }
@@ -225,6 +235,7 @@ export class TranscriptOverlay implements Component {
225
235
 
226
236
  private renderSafe(width: number): string[] {
227
237
  if (width < 6) return [];
238
+ this.refreshProjectionIfSourceChanged();
228
239
  const th = this.theme;
229
240
  const innerW = width - 4;
230
241
  const lines: string[] = [];
@@ -250,30 +261,22 @@ export class TranscriptOverlay implements Component {
250
261
  for (let i = 0; i < viewportHeight; i++) lines.push(row(visible[i] ?? ""));
251
262
 
252
263
  lines.push(hrMid);
253
- const scrollPct =
254
- contentLines.length <= viewportHeight
255
- ? "100%"
256
- : `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
257
- const footerLeft = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`);
258
- const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn · Esc close");
259
- const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
260
- lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight));
264
+ lines.push(row(this.footer(innerW, contentLines.length, visibleStart, viewportHeight)));
261
265
  lines.push(hrBot);
262
-
263
266
  return lines;
264
267
  }
265
268
 
266
- // fallow-ignore-next-line unused-class-member
267
269
  invalidate(): void {
268
270
  try {
269
271
  this.content.invalidate();
272
+ this.searchInput.invalidate();
270
273
  } catch (error) {
271
274
  debugLog("session navigator invalidate", error);
272
275
  }
273
276
  }
274
277
 
275
278
  dispose(): void {
276
- this.closed = true;
279
+ this.close(false);
277
280
  const unsubscribe = this.unsubscribe;
278
281
  this.unsubscribe = undefined;
279
282
  if (unsubscribe) {
@@ -283,34 +286,278 @@ export class TranscriptOverlay implements Component {
283
286
  debugLog("session navigator unsubscribe", error);
284
287
  }
285
288
  }
286
- this.clearRuntimeInterval();
287
289
  }
288
290
 
289
- // ---- Private ----
291
+ // ---- Search and source updates -------------------------------------------
292
+
293
+ private beginSearch(): void {
294
+ this.searchActive = true;
295
+ this.searchInput.focused = true;
296
+ this.searchCommitted = false;
297
+ this.toolsOnly = false;
298
+ this.newMatchCount = 0;
299
+ this.selectedMatchId = undefined;
300
+ this.autoScroll = false;
301
+ this.searchInput.setValue("");
302
+ this.matches = [];
303
+ this.totalMatches = 0;
304
+ this.matchMarks.clear();
305
+ }
306
+
307
+ private handleSearchInput(data: string): void {
308
+ if (matchesKey(data, Key.escape)) {
309
+ this.clearSearch();
310
+ this.requestRender();
311
+ return;
312
+ }
313
+ if (matchesKey(data, Key.tab)) {
314
+ this.toolsOnly = !this.toolsOnly;
315
+ this.reproject(false);
316
+ this.requestRender();
317
+ return;
318
+ }
319
+ if (matchesKey(data, Key.ctrl("u"))) {
320
+ this.searchInput.setValue("");
321
+ this.searchCommitted = false;
322
+ this.reproject(false);
323
+ this.requestRender();
324
+ return;
325
+ }
326
+
327
+ const previous = this.searchInput.getValue();
328
+ const shiftEnter = matchesKey(data, "shift+enter");
329
+ const enter = matchesKey(data, Key.enter);
330
+ if (shiftEnter) {
331
+ this.searchCommitted = true;
332
+ this.selectNextMatch(-1);
333
+ this.requestRender();
334
+ return;
335
+ }
336
+ if (enter) {
337
+ this.searchCommitted = true;
338
+ this.selectNextMatch(1);
339
+ this.requestRender();
340
+ return;
341
+ }
342
+ // Printable n/N are literal while editing. Once Enter has committed the
343
+ // query, the mock's n/N navigation shortcuts become active.
344
+ if (this.searchCommitted && (data === "n" || data === "N")) {
345
+ this.selectNextMatch(data === "n" ? 1 : -1);
346
+ this.requestRender();
347
+ return;
348
+ }
349
+
350
+ this.searchInput.handleInput(data);
351
+ if (this.searchInput.getValue() !== previous) {
352
+ this.searchCommitted = false;
353
+ this.selectedMatchId = undefined;
354
+ this.newMatchCount = 0;
355
+ this.reproject(false);
356
+ this.requestRender();
357
+ }
358
+ }
359
+
360
+ private clearSearch(): void {
361
+ this.searchActive = false;
362
+ this.searchInput.focused = false;
363
+ this.searchCommitted = false;
364
+ this.toolsOnly = false;
365
+ this.newMatchCount = 0;
366
+ this.selectedMatchId = undefined;
367
+ this.searchInput.setValue("");
368
+ this.matches = [];
369
+ this.totalMatches = 0;
370
+ this.matchMarks.clear();
371
+ // Keep the current offset. Tail-following resumes only if the operator is
372
+ // already at the tail, so clearing cannot move the inspected block.
373
+ const maxScroll = Math.max(0, this.buildContentLines(this.innerWidth()).length - this.viewportHeight());
374
+ this.autoScroll = this.scrollOffset >= maxScroll;
375
+ this.content = this.rebuildFromSource(false);
376
+ }
377
+
378
+ private selectNextMatch(direction: 1 | -1): void {
379
+ if (this.matches.length === 0) return;
380
+ const current = this.selectedMatchId === undefined
381
+ ? -1
382
+ : this.matches.findIndex((entry) => entry.id === this.selectedMatchId);
383
+ const next = current < 0
384
+ ? direction > 0 ? 0 : this.matches.length - 1
385
+ : (current + direction + this.matches.length) % this.matches.length;
386
+ this.selectedMatchId = this.matches[next]!.id;
387
+ this.newMatchCount = 0;
388
+ this.rebuildFromCurrentMessages();
389
+ this.scrollToSelected();
390
+ }
391
+
392
+ private reproject(preserveSelection: boolean): void {
393
+ const oldMatches = this.matches;
394
+ const oldIds = new Set(oldMatches.map((entry) => entry.id));
395
+ const priorSelection = this.selectedMatchId;
396
+ const queryResult = this.searchActive && this.searchInput.getValue()
397
+ ? querySession(this.sourceMessages, {
398
+ query: this.searchInput.getValue(),
399
+ kind: "all",
400
+ order: "oldest",
401
+ limit: OVERLAY_QUERY_LIMIT,
402
+ entryFamily: this.toolsOnly ? "tools" : "all",
403
+ })
404
+ : { entries: [], totalMatches: 0 };
405
+ const matches = queryResult.entries;
406
+ this.matches = matches;
407
+ this.totalMatches = queryResult.totalMatches;
408
+ if (!preserveSelection) {
409
+ this.selectedMatchId = undefined;
410
+ this.newMatchCount = 0;
411
+ } else if (priorSelection && matches.some((entry) => entry.id === priorSelection)) {
412
+ this.selectedMatchId = priorSelection;
413
+ } else if (priorSelection && matches.length > 0) {
414
+ const priorIndex = oldMatches.findIndex((entry) => entry.id === priorSelection);
415
+ this.selectedMatchId = matches[Math.min(Math.max(priorIndex, 0), matches.length - 1)]!.id;
416
+ } else if (matches.length === 0) {
417
+ this.selectedMatchId = undefined;
418
+ }
419
+ if (preserveSelection && this.searchActive) {
420
+ this.newMatchCount = matches.filter((entry) => !oldIds.has(entry.id)).length;
421
+ }
422
+ this.matchMarks = new Map(matches.map((entry, index) => [entry.id, {
423
+ index: index + 1,
424
+ total: this.totalMatches,
425
+ selected: entry.id === this.selectedMatchId,
426
+ }]));
427
+ }
290
428
 
291
429
  private refreshFromSource(): void {
292
430
  if (this.closed) return;
293
431
  try {
294
- this.content = this.rebuild();
432
+ const oldMessages = this.sourceMessages;
433
+ this.sourceMessages = this.source.getMessages();
434
+ if (this.sourceMessages !== oldMessages) this.reproject(this.searchActive);
435
+ this.content = this.buildComponents(this.sourceMessages);
295
436
  this.tui.requestRender();
296
437
  } catch (error) {
297
438
  debugLog("session navigator live refresh", error);
298
439
  }
299
440
  }
300
441
 
301
- private refreshRuntime(): void {
442
+ private refreshProjectionIfSourceChanged(): void {
302
443
  try {
303
- if (this.closed || !this.isRunning()) {
304
- this.clearRuntimeInterval();
305
- return;
444
+ const messages = this.source.getMessages();
445
+ if (messages !== this.sourceMessages) {
446
+ this.sourceMessages = messages;
447
+ this.reproject(true);
448
+ this.content = this.buildComponents(messages);
306
449
  }
307
- this.tui.requestRender();
308
450
  } catch (error) {
309
- debugLog("session navigator runtime refresh", error);
310
- this.clearRuntimeInterval();
451
+ debugLog("session navigator source projection", error);
311
452
  }
312
453
  }
313
454
 
455
+ private rebuildFromSource(preserveSelection: boolean): Container {
456
+ this.sourceMessages = this.source.getMessages();
457
+ this.reproject(preserveSelection);
458
+ return this.buildComponents(this.sourceMessages);
459
+ }
460
+
461
+ private rebuildFromCurrentMessages(): void {
462
+ this.reproject(true);
463
+ this.content = this.buildComponents(this.sourceMessages);
464
+ }
465
+
466
+ // ---- Ordinary transcript navigation -------------------------------------
467
+
468
+ private handleScrollInput(data: string): void {
469
+ const totalLines = this.buildContentLines(this.innerWidth()).length;
470
+ const viewportHeight = this.viewportHeight();
471
+ const maxScroll = Math.max(0, totalLines - viewportHeight);
472
+
473
+ if (matchesKey(data, Key.up) || data === "k") {
474
+ this.scrollOffset = Math.max(0, this.scrollOffset - 1);
475
+ this.autoScroll = this.scrollOffset >= maxScroll;
476
+ } else if (matchesKey(data, Key.down) || data === "j") {
477
+ this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
478
+ this.autoScroll = this.scrollOffset >= maxScroll;
479
+ } else if (matchesKey(data, Key.pageUp) || matchesKey(data, "shift+up")) {
480
+ this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight);
481
+ this.autoScroll = false;
482
+ } else if (matchesKey(data, Key.pageDown) || matchesKey(data, "shift+down")) {
483
+ this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewportHeight);
484
+ this.autoScroll = this.scrollOffset >= maxScroll;
485
+ } else if (matchesKey(data, Key.home)) {
486
+ this.scrollOffset = 0;
487
+ this.autoScroll = false;
488
+ } else if (matchesKey(data, Key.end)) {
489
+ this.scrollOffset = maxScroll;
490
+ this.autoScroll = true;
491
+ }
492
+ }
493
+
494
+ private scrollToSelected(): void {
495
+ if (!this.selectedMatchId) return;
496
+ const width = this.innerWidth();
497
+ let line = 0;
498
+ let selectedLine: number | undefined;
499
+ for (const child of this.content.children) {
500
+ if (isMatchChrome(child) && child.matchId === this.selectedMatchId) selectedLine = line;
501
+ line += child.render(width).length;
502
+ }
503
+ if (selectedLine === undefined) return;
504
+ const viewport = this.viewportHeight();
505
+ const maxScroll = Math.max(0, this.buildContentLines(width).length - viewport);
506
+ this.scrollOffset = Math.min(maxScroll, Math.max(0, selectedLine - Math.floor(viewport / 3)));
507
+ this.autoScroll = false;
508
+ }
509
+
510
+ // ---- Rendering and lifecycle --------------------------------------------
511
+
512
+ private footer(innerW: number, contentLineCount: number, visibleStart: number, viewportHeight: number): string {
513
+ if (this.searchActive) {
514
+ const input = this.searchInput.render(Math.max(8, Math.min(32, innerW - 48)))[0] ?? "> ";
515
+ const inputLine = "/" + input.slice(1);
516
+ const filter = `[${this.toolsOnly ? "tools" : "all"}]`;
517
+ const query = this.searchInput.getValue();
518
+ const count = !query
519
+ ? "type to search"
520
+ : this.totalMatches === 0
521
+ ? "0 matches"
522
+ : `${this.totalMatches > OVERLAY_QUERY_LIMIT ? `${OVERLAY_QUERY_LIMIT}+` : this.totalMatches} matches${this.selectedMatchId ? ` [${this.selectedIndex()}/${this.totalMatches}]` : ""}${this.newMatchCount > 0 ? ` (+${this.newMatchCount} new)` : ""}`;
523
+ const source = this.source.availability?.();
524
+ const sourceNote = sourceFooterNote(source);
525
+ const hints = "Enter/n next · Shift+Enter/N prev · Tab filter · Esc clear";
526
+ return truncateToWidth(`${inputLine} ${filter} ${count}${sourceNote} · ${hints}`, innerW, "");
527
+ }
528
+ const scrollPct = contentLineCount <= viewportHeight
529
+ ? "100%"
530
+ : `${Math.round(((visibleStart + viewportHeight) / contentLineCount) * 100)}%`;
531
+ const source = this.source.availability?.();
532
+ const sourceNote = sourceFooterNote(source);
533
+ const footerLeft = ` ${contentLineCount} lines · ${scrollPct}${sourceNote}`;
534
+ const footerRight = "↑↓/j/k scroll · PgUp/PgDn · / search · Esc/q close";
535
+ const gap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
536
+ return truncateToWidth(footerLeft + " ".repeat(gap) + footerRight, innerW, "");
537
+ }
538
+
539
+ private selectedIndex(): number {
540
+ const index = this.matches.findIndex((entry) => entry.id === this.selectedMatchId);
541
+ return index < 0 ? 0 : index + 1;
542
+ }
543
+
544
+ private buildContentLines(innerW: number): string[] {
545
+ if (innerW <= 0) return [];
546
+ const lines = this.content.render(innerW);
547
+ const streaming = this.source.streaming();
548
+ if (streaming) lines.push("", `◍ ${describeActivity(streaming.activeTools, streaming.responseText)}`);
549
+ return lines.map((line) => truncateToWidth(line, innerW));
550
+ }
551
+
552
+ private buildComponents(messages: readonly SessionMessage[]): Container {
553
+ return buildTranscriptComponents(messages, {
554
+ tui: this.tui,
555
+ cwd: this.cwd,
556
+ markdownTheme: this.markdownTheme,
557
+ getToolDefinition: (name) => this.source.getToolDefinition(name),
558
+ }, this.matchMarks, this.theme);
559
+ }
560
+
314
561
  private isRunning(): boolean {
315
562
  try {
316
563
  return this.run.completedAt() === undefined;
@@ -320,6 +567,21 @@ export class TranscriptOverlay implements Component {
320
567
  }
321
568
  }
322
569
 
570
+ private refreshRuntime(): void {
571
+ try {
572
+ if (this.closed) {
573
+ this.clearRuntimeInterval();
574
+ return;
575
+ }
576
+ this.refreshProjectionIfSourceChanged();
577
+ if (!this.isRunning()) this.clearRuntimeInterval();
578
+ this.tui.requestRender();
579
+ } catch (error) {
580
+ debugLog("session navigator runtime refresh", error);
581
+ this.clearRuntimeInterval();
582
+ }
583
+ }
584
+
323
585
  private clearRuntimeInterval(): void {
324
586
  if (this.runtimeInterval !== undefined) {
325
587
  clearInterval(this.runtimeInterval);
@@ -327,33 +589,30 @@ export class TranscriptOverlay implements Component {
327
589
  }
328
590
  }
329
591
 
330
- private innerWidth(): number {
331
- return Math.max(0, this.tui.terminal.columns - 4);
332
- }
333
-
592
+ private innerWidth(): number { return Math.max(0, this.tui.terminal.columns - 4); }
334
593
  private viewportHeight(): number {
335
594
  const maxRows = Math.floor((this.tui.terminal.rows * VIEWPORT_HEIGHT_PCT) / 100);
336
595
  return Math.max(MIN_VIEWPORT, maxRows - CHROME_LINES);
337
596
  }
597
+ private requestRender(): void {
598
+ try { this.tui.requestRender(); } catch (error) { debugLog("session navigator request render", error); }
599
+ }
338
600
 
339
- private buildContentLines(innerW: number): string[] {
340
- if (innerW <= 0) return [];
341
- const lines = this.content.render(innerW);
342
- const streaming = this.source.streaming();
343
- if (streaming) {
344
- lines.push("", `◍ ${describeActivity(streaming.activeTools, streaming.responseText)}`);
601
+ private close(notify = true): void {
602
+ if (this.closed) return;
603
+ this.closed = true;
604
+ this.searchInput.focused = false;
605
+ this.clearRuntimeInterval();
606
+ if (notify) {
607
+ try { this.done(undefined); } catch (error) { debugLog("session navigator close", error); }
345
608
  }
346
- return lines.map((l) => truncateToWidth(l, innerW));
347
609
  }
610
+ }
348
611
 
349
- private rebuild(): Container {
350
- return buildTranscriptComponents(this.source.getMessages(), {
351
- tui: this.tui,
352
- cwd: this.cwd,
353
- markdownTheme: this.markdownTheme,
354
- getToolDefinition: (name) => this.source.getToolDefinition(name),
355
- });
356
- }
612
+ function sourceFooterNote(source: TranscriptSourceAvailability | undefined): string {
613
+ if (source?.kind === "file") return " · released (snapshot)";
614
+ if (source?.kind === "unavailable") return " · released / transcript unavailable";
615
+ return "";
357
616
  }
358
617
 
359
618
  /** Dependencies the per-entry component tree needs from the SDK/TUI environment. */
@@ -368,17 +627,31 @@ interface TranscriptRenderOptions {
368
627
  * Build a `Container` of Pi's per-entry components from a message snapshot,
369
628
  * mirroring Pi's own interactive-mode `renderSessionContext` mapping. Tool
370
629
  * results are matched to their tool-call components by id, exactly as Pi does.
371
- * `custom`-role messages are skipped — rendering them needs the child session's
372
- * message-renderer registry, which the navigator does not hold.
373
630
  */
374
631
  function buildTranscriptComponents(
375
632
  messages: readonly SessionMessage[],
376
633
  opts: TranscriptRenderOptions,
634
+ marks: ReadonlyMap<string, MatchMark>,
635
+ theme: Theme,
377
636
  ): Container {
378
637
  const container = new Container();
379
638
  const pendingTools = new Map<string, ToolExecutionComponent>();
380
- for (const message of messages) {
381
- addMessageComponents(container, message, pendingTools, opts);
639
+ const entries = projectSessionMessages(messages);
640
+ const messageEntries = new Map(entries.filter((entry) => entry.kind === "message").map((entry) => [entry.sourceIndex, entry]));
641
+ const toolEntries = new Map(entries.filter((entry) => entry.kind === "tool_call").map((entry) => [entry.id, entry]));
642
+ for (let sourceIndex = 0; sourceIndex < messages.length; sourceIndex++) {
643
+ const message = messages[sourceIndex]!;
644
+ addMessageComponents(
645
+ container,
646
+ message,
647
+ pendingTools,
648
+ opts,
649
+ marks,
650
+ theme,
651
+ messageEntries.get(sourceIndex),
652
+ toolEntries,
653
+ sourceIndex,
654
+ );
382
655
  }
383
656
  return container;
384
657
  }
@@ -388,10 +661,19 @@ function addMessageComponents(
388
661
  message: SessionMessage,
389
662
  pendingTools: Map<string, ToolExecutionComponent>,
390
663
  opts: TranscriptRenderOptions,
664
+ marks: ReadonlyMap<string, MatchMark>,
665
+ theme: Theme,
666
+ messageEntry: SessionQueryEntry | undefined,
667
+ toolEntries: ReadonlyMap<string, SessionQueryEntry>,
668
+ sourceIndex: number,
391
669
  ): void {
670
+ const add = (component: Component, entry: SessionQueryEntry | undefined): void => {
671
+ const mark = entry ? marks.get(entry.id) : undefined;
672
+ container.addChild(mark ? new MatchChrome(component, entry!.id, mark, theme) : component);
673
+ };
392
674
  switch (message.role) {
393
675
  case "assistant": {
394
- container.addChild(new AssistantMessageComponent(message, false, opts.markdownTheme));
676
+ add(new AssistantMessageComponent(message, false, opts.markdownTheme), messageEntry);
395
677
  for (const content of message.content) {
396
678
  if (content.type !== "toolCall") continue;
397
679
  const tool = new ToolExecutionComponent(
@@ -404,7 +686,7 @@ function addMessageComponents(
404
686
  opts.cwd,
405
687
  );
406
688
  tool.setExpanded(true);
407
- container.addChild(tool);
689
+ add(tool, toolEntries.get(content.id));
408
690
  pendingTools.set(content.id, tool);
409
691
  }
410
692
  break;
@@ -414,31 +696,28 @@ function addMessageComponents(
414
696
  pendingTools.delete(message.toolCallId);
415
697
  break;
416
698
  }
417
- case "user": {
418
- addUserComponents(container, message.content, opts.markdownTheme);
699
+ case "user":
700
+ addUserComponents(container, message.content, opts.markdownTheme, messageEntry, marks, theme);
419
701
  break;
420
- }
421
702
  case "bashExecution": {
422
703
  const bash = new BashExecutionComponent(message.command, opts.tui, message.excludeFromContext);
423
704
  if (message.output) bash.appendOutput(message.output);
424
705
  bash.setComplete(message.exitCode, message.cancelled, undefined, message.fullOutputPath);
425
- container.addChild(bash);
706
+ add(bash, toolEntries.get(`bash:${sourceIndex}`));
426
707
  break;
427
708
  }
428
- case "compactionSummary": {
709
+ case "compactionSummary":
429
710
  container.addChild(new Spacer(1));
430
711
  const summary = new CompactionSummaryMessageComponent(message, opts.markdownTheme);
431
712
  summary.setExpanded(true);
432
713
  container.addChild(summary);
433
714
  break;
434
- }
435
- case "branchSummary": {
715
+ case "branchSummary":
436
716
  container.addChild(new Spacer(1));
437
- const summary = new BranchSummaryMessageComponent(message, opts.markdownTheme);
438
- summary.setExpanded(true);
439
- container.addChild(summary);
717
+ const branch = new BranchSummaryMessageComponent(message, opts.markdownTheme);
718
+ branch.setExpanded(true);
719
+ container.addChild(branch);
440
720
  break;
441
- }
442
721
  }
443
722
  }
444
723
 
@@ -447,22 +726,25 @@ function addUserComponents(
447
726
  container: Container,
448
727
  content: string | readonly { type: string; text?: string }[],
449
728
  markdownTheme: MarkdownTheme,
729
+ entry: SessionQueryEntry | undefined,
730
+ marks: ReadonlyMap<string, MatchMark>,
731
+ theme: Theme,
450
732
  ): void {
451
733
  const text = userMessageText(content);
452
734
  if (!text) return;
735
+ const mark = entry ? marks.get(entry.id) : undefined;
736
+ const wrap = (component: Component): void => container.addChild(mark ? new MatchChrome(component, entry!.id, mark, theme) : component);
453
737
  if (container.children.length > 0) container.addChild(new Spacer(1));
454
738
 
455
739
  const skillBlock = parseSkillBlock(text);
456
740
  if (!skillBlock) {
457
- container.addChild(new UserMessageComponent(text, markdownTheme));
741
+ wrap(new UserMessageComponent(text, markdownTheme));
458
742
  return;
459
743
  }
460
- const skill = new SkillInvocationMessageComponent(skillBlock, markdownTheme);
461
- skill.setExpanded(true);
462
- container.addChild(skill);
744
+ wrap(new SkillInvocationMessageComponent(skillBlock, markdownTheme));
463
745
  if (skillBlock.userMessage) {
464
746
  container.addChild(new Spacer(1));
465
- container.addChild(new UserMessageComponent(skillBlock.userMessage, markdownTheme));
747
+ wrap(new UserMessageComponent(skillBlock.userMessage, markdownTheme));
466
748
  }
467
749
  }
468
750
 
@@ -474,3 +756,39 @@ function userMessageText(content: string | readonly { type: string; text?: strin
474
756
  .map((block) => block.text ?? "")
475
757
  .join("");
476
758
  }
759
+
760
+ /**
761
+ * Block-level chrome intentionally wraps native rendering; it does not inspect
762
+ * or reformat the child body, so Pi's tool and markdown renderers stay in use.
763
+ */
764
+ class MatchChrome implements Component {
765
+ readonly matchId: string;
766
+ private readonly component: Component;
767
+ private readonly mark: MatchMark;
768
+ private readonly theme: Theme;
769
+
770
+ constructor(component: Component, matchId: string, mark: MatchMark, theme: Theme) {
771
+ this.component = component;
772
+ this.mark = mark;
773
+ this.matchId = matchId;
774
+ this.theme = theme;
775
+ }
776
+
777
+ render(width: number): string[] {
778
+ if (width <= 2) return this.component.render(Math.max(1, width));
779
+ const bodyWidth = width - 2;
780
+ const lines = this.component.render(bodyWidth);
781
+ const color = this.mark.selected ? "accent" : "warning";
782
+ const label = `${this.mark.selected ? "▶ " : ""}MATCH ${this.mark.index}/${this.mark.total}`;
783
+ const header = truncateToWidth(this.theme.fg(color, `╭─ ${label} ─`), width, "");
784
+ return [header, ...lines.map((line) =>
785
+ truncateToWidth(this.theme.fg(color, "│") + " " + line, width, ""),
786
+ )];
787
+ }
788
+
789
+ invalidate(): void { this.component.invalidate(); }
790
+ }
791
+
792
+ function isMatchChrome(component: Component): component is MatchChrome {
793
+ return component instanceof MatchChrome;
794
+ }