@aiwayds/dsh-tui-pi 2.4.0 → 2.6.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/lib/history.js ADDED
@@ -0,0 +1,746 @@
1
+ /**
2
+ * /history — the read-only two-pane history browser (CONTEXT.md "History
3
+ * browser", ADR 0003, docs/features/history.md).
4
+ *
5
+ * Left pane: a TablePanel listing the browsed session's COMPLETED turns
6
+ * (turn 序号 + user-message preview, seq order — a list, not a tree; the
7
+ * session log has no message-level branching). Right pane: the selected
8
+ * turn's content — the user prompts in the main transcript's bubble style,
9
+ * the assembled LLM replies as Markdown, and a per-tool call-count summary.
10
+ * View and copy only: Enter/`c` refills the editor with the turn's user
11
+ * prompt (a plain setText — never submitted), `s` swaps the browsed session
12
+ * through a /resume-style picker, Esc closes. No resend, no branch, no
13
+ * transcript jump.
14
+ *
15
+ * Snapshot semantics: the event list is read once per open / session switch
16
+ * (live session → `session.snapshotEvents()`, stored session →
17
+ * `sessionPersistence.inspect()` — a cold read, no writer lock, no agent
18
+ * activation). A session that keeps running while the viewer is open does
19
+ * NOT live-update; reopening refreshes.
20
+ *
21
+ * Layout: ≥100 terminal columns renders an HStack (list ≈40% with a 30-column
22
+ * floor, detail takes the remainder); narrower terminals stack the panes
23
+ * vertically (list on top). The container is chosen per render at the current
24
+ * width. The window itself is FIXED geometry: the panel renders exactly
25
+ * `overlayContentBudget()` lines (short content pads blank, long content
26
+ * lives in the detail scroll window), so picking another turn never changes
27
+ * the window size — only a terminal resize re-derives it (overlay mounted at
28
+ * '90%' width / '85%' height; the budget floors the same percentage pi-tui
29
+ * does).
30
+ *
31
+ * Focus model: the keyboard lives on the left list by default; `→` hands it
32
+ * to the detail pane (`↑`/`↓` line-scroll, PgUp/PgDn or `[`/`]` page, `←` or
33
+ * Esc steps back; every other key is inert there). Esc grades detail → list
34
+ * → filter-clear → close — it never skips a level. Focus is visible: the
35
+ * focused pane's cues are the list's ▸ cursor (demoted to `›` while the
36
+ * detail is keyed) versus the detail pane's accent-BOLD title and its
37
+ * `← list · ↑↓ scroll` footer hint.
38
+ *
39
+ * Scroll reality (documented deviation from the original sketch): pi-tui's
40
+ * overlay path composites `component.render(width)` lines directly — the
41
+ * layout engine never descends into overlays, so a ScrollView can never
42
+ * obtain a viewport there (the same limitation AGENTS.md documents for plain
43
+ * Containers). The detail pane therefore manages its own scroll window (the
44
+ * SubagentViewerPanel precedent): `[` / `]` page, selection change resets to
45
+ * the top. The content itself is built from the same pi-tui primitives the
46
+ * main transcript uses (Text bubbles / Markdown), so the look matches.
47
+ *
48
+ * Static rebuild mode (the setTheme/relayout snapshot pattern): switching the
49
+ * selected turn REBUILDS the detail container from the turn's events — the
50
+ * render() path only ever slices an already-built line list and never
51
+ * touches the event data (iron rule 1: render never re-scans). The one
52
+ * deliberate exception is the render-time resize check below: an O(1)
53
+ * budget comparison that may rebuild ALREADY-DERIVED display rows (never
54
+ * events) so the fixed geometry tracks terminal resizes without a resize
55
+ * listener. Unlike the transcript's event-driven relayout(), this runs
56
+ * inside render() by design — do not copy it as a default pattern.
57
+ */
58
+ import { SessionId } from '@deepseek-ai/dsh-session';
59
+ import { Container, getKeybindings, HStack, matchesKey, Markdown, Spacer, Text, VStack, } from '@earendil-works/pi-tui';
60
+ import { groupHistoryTurns, matchesTurnFilter, toolCallSummary, turnPrimaryUserText, } from "./history-turns.js";
61
+ import { stopIcon } from "./icons.js";
62
+ import { isCorruptLogError } from "./log-repair.js";
63
+ import { autoColumns, PanelHost, panelThemeFns, TablePanel } from "./panels.js";
64
+ import { isResumableSessionHeader, inspectPersistedSession, loadSessionLastUpdates, loadSessionPreviews, normalizePreview, PREVIEW_SESSION_CAP, RESUME_DIR_CAP, resumeRowTitle, sortSessionsByLastUpdate, } from "./sessions.js";
65
+ import { ansiFg, BOLD, RESET } from "./theme/index.js";
66
+ import { clipToWidth } from "./text.js";
67
+ /** Terminal width at which the browser switches from stacked to side-by-side. */
68
+ export const DUAL_PANE_MIN_COLUMNS = 100;
69
+ /** Floor of the left list pane in dual-pane mode (spec: min 30 columns). */
70
+ export const LEFT_PANE_MIN_COLUMNS = 30;
71
+ /** Rendered-line cap of one user bubble in the detail pane (spec: truncated). */
72
+ export const MAX_USER_BUBBLE_LINES = 40;
73
+ /** List footer hint (the detail pane carries the scroll hints). */
74
+ const HISTORY_FOOTER = '↑↓ navigate · Enter/c copy · → detail · s session · / filter · Esc close';
75
+ /**
76
+ * Content-row budget inside the framed overlay: showOverlay slices the
77
+ * component's lines at maxHeight ('85%' of the terminal — pi-tui floors the
78
+ * percentage, and so do we, so the budget never exceeds the real slice),
79
+ * and the FramedOverlay adds 4 chrome rows (top/bottom border + a blank
80
+ * spacer each). The browser renders EXACTLY this many lines (pad or cap —
81
+ * fixed window geometry), and every inner budget derives from it so no
82
+ * footer is ever sliced off. Testable; `rows` injected.
83
+ */
84
+ export function overlayContentBudget(rows = process.stdout.rows) {
85
+ return Math.max(6, Math.floor((rows ?? 24) * 0.85) - 4);
86
+ }
87
+ /**
88
+ * Visible rows of the left list: the TablePanel chrome is 7 rows (title,
89
+ * ┬/header/┼/┴ rules, blank spacer, footer) and 2 more rows are reserved
90
+ * for the filter line and the status line — the two optional lines that can
91
+ * co-display with results (an applied filter plus a copy/load status), and
92
+ * under-reserving them slices the panel footers off the fixed budget; the
93
+ * stacked layout additionally owes the detail pane its own chrome + a living
94
+ * body (15 reserved rows), the side-by-side layout only the slice guard (9).
95
+ * Fixed at panel construction — a terminal resize mid-open keeps the stale
96
+ * budget until reopened (the accepted overlay behavior). Testable.
97
+ */
98
+ export function listMaxVisible(rows = process.stdout.rows, columns = process.stdout.columns) {
99
+ const budget = overlayContentBudget(rows);
100
+ const reserve = (columns ?? 120) >= DUAL_PANE_MIN_COLUMNS ? 9 : 15;
101
+ return Math.max(3, Math.min(20, budget - reserve));
102
+ }
103
+ /**
104
+ * The left-list rows for a turn list under `query`: case-insensitive
105
+ * substring match on the preview text and the turn number, in seq order.
106
+ * Pure; the TablePanel clips `preview` to the cell width at render time.
107
+ */
108
+ export function historyRows(turns, query) {
109
+ return turns
110
+ .filter(turn => matchesTurnFilter(turn, query))
111
+ .map(turn => ({
112
+ turn,
113
+ turnLabel: String(turn.turn),
114
+ preview: normalizePreview(turn.previewText) || '(no text)',
115
+ }));
116
+ }
117
+ /** The left-list title for one browsed session. */
118
+ function historyListTitle(sessionId, live) {
119
+ return `● History · ${clipToWidth(sessionId, 8)}${live ? ' (live)' : ''}`;
120
+ }
121
+ /**
122
+ * The user bubble's styled body: `▎ `-prefixed lines (the main transcript's
123
+ * bubble look) with the theme foreground, capped at MAX_USER_BUBBLE_LINES
124
+ * with an explicit continuation marker — the pane scrolls, but a 500-line
125
+ * paste must not bury the reply wholesale.
126
+ */
127
+ function userBubbleText(text, theme) {
128
+ const lines = text.split('\n');
129
+ const body = lines.length <= MAX_USER_BUBBLE_LINES
130
+ ? lines
131
+ : [...lines.slice(0, MAX_USER_BUBBLE_LINES), `… +${lines.length - MAX_USER_BUBBLE_LINES} more lines`];
132
+ return theme.chat.userMessageText(body.map(line => `▎ ${line}`).join('\n'));
133
+ }
134
+ /** The turn-end status line (the main transcript's renderTurnEnd vocabulary). */
135
+ function turnEndLine(turn, theme) {
136
+ if (turn.endReason === 'error') {
137
+ return ansiFg(theme.palette.danger) + `✘ ${turn.endError ?? 'turn failed'}` + RESET;
138
+ }
139
+ if (turn.endReason === 'aborted' || turn.endReason === 'interrupted' || turn.interrupted) {
140
+ return ansiFg(theme.palette.fgSubtle) + `${stopIcon()} interrupted` + RESET;
141
+ }
142
+ if (turn.endReason === 'max-tokens') {
143
+ return ansiFg(theme.palette.attention) + '⚠ output token limit reached' + RESET;
144
+ }
145
+ return undefined;
146
+ }
147
+ /**
148
+ * The detail pane's content for one turn, as a fresh Container of the same
149
+ * primitives the main transcript renders: user prompts as canvasSubtle
150
+ * bubbles (Text + bg), replies as Markdown parsed once per rebuild, then the
151
+ * `⚙` tool-count summary line and any turn-end notice. Exported for tests.
152
+ */
153
+ export function buildTurnDetailContainer(turn, theme) {
154
+ const doc = new Container();
155
+ for (const text of turn.userTexts) {
156
+ doc.addChild(new Text(userBubbleText(text, theme), 1, 0, theme.chat.userMessageBg));
157
+ doc.addChild(new Spacer(1));
158
+ }
159
+ // The turn's replies, one Markdown per assembled message (steps), seq
160
+ // order — never assistant/chunk (iron rule 9).
161
+ for (const text of turn.assistantTexts) {
162
+ doc.addChild(new Markdown(text, 1, 0, theme.markdown, {
163
+ color: line => ansiFg(theme.palette.fgDefault) + line + RESET,
164
+ }));
165
+ doc.addChild(new Spacer(1));
166
+ }
167
+ if (turn.userTexts.length === 0 && turn.assistantTexts.length === 0) {
168
+ doc.addChild(new Text(ansiFg(theme.palette.fgSubtle) + '(this turn rendered no prompt or reply text)' + RESET, 1, 0));
169
+ doc.addChild(new Spacer(1));
170
+ }
171
+ if (turn.toolCallNames.length > 0) {
172
+ doc.addChild(new Text(ansiFg(theme.palette.fgMuted) + `⚙ ${toolCallSummary(turn.toolCallNames)}` + RESET, 1, 0));
173
+ doc.addChild(new Spacer(1));
174
+ }
175
+ const end = turnEndLine(turn, theme);
176
+ if (end !== undefined)
177
+ doc.addChild(new Text(end, 1, 0));
178
+ return doc;
179
+ }
180
+ /**
181
+ * The right pane: one selected turn's content with a self-managed scroll
182
+ * window (see the module comment for why pi-tui's ScrollView cannot live
183
+ * inside an overlay). `basisRows` pins the row budget in stacked layout;
184
+ * undefined uses the full overlay budget (side-by-side layout).
185
+ */
186
+ class TurnDetailPane {
187
+ theme;
188
+ requestRender;
189
+ container;
190
+ headerTitle = '';
191
+ scrollTop = 0;
192
+ bodyRows = 1;
193
+ lineCount = 0;
194
+ /** Whether the focus model has the keyboard on this pane (see the panel). */
195
+ focused = false;
196
+ /** Fixed row budget (stacked layout); undefined = full overlay budget. */
197
+ basisRows = undefined;
198
+ /**
199
+ * Rendered lines per width. HStack measures every child at the full
200
+ * overlay width before allocating the real pane width, so the pane renders
201
+ * at TWO widths per frame — without this cache the Markdown child would
202
+ * re-parse at alternating widths every frame (its cache is single-slot).
203
+ * Cleared on rebuild (the only time content changes); bounded because the
204
+ * realistic width set is two (measure + allocated) per terminal size.
205
+ */
206
+ widthCache = new Map();
207
+ constructor(theme, requestRender) {
208
+ this.theme = theme;
209
+ this.requestRender = requestRender;
210
+ this.container = new Container();
211
+ }
212
+ invalidate() { }
213
+ /** Static rebuild: swap the content to `turn`'s events and reset to the top. */
214
+ setTurn(turn, live) {
215
+ this.scrollTop = 0;
216
+ const source = live ? ' · live snapshot' : '';
217
+ this.headerTitle = turn === undefined
218
+ ? 'History'
219
+ : `Turn ${String(turn.turn)}${turn.endReason === 'completed' ? '' : ` · ${turn.endReason}`}${turn.interrupted ? ' · interrupted' : ''}${source}`;
220
+ this.container = turn === undefined
221
+ ? new Container()
222
+ : buildTurnDetailContainer(turn, this.theme);
223
+ this.widthCache.clear();
224
+ this.requestRender();
225
+ }
226
+ /** Flip the focus visuals (accent vs subtle header, focus footer hint). */
227
+ setFocused(focused) {
228
+ if (this.focused === focused)
229
+ return;
230
+ this.focused = focused;
231
+ this.requestRender();
232
+ }
233
+ /** Page the window by `delta` pages (negative = up), clamped. */
234
+ scrollByPage(delta) {
235
+ const maxScroll = Math.max(0, this.lineCount - this.bodyRows);
236
+ this.scrollTop = Math.max(0, Math.min(this.scrollTop + delta * this.bodyRows, maxScroll));
237
+ this.requestRender();
238
+ }
239
+ /** Scroll the window by `delta` lines (negative = up), clamped. */
240
+ scrollByLines(delta) {
241
+ const maxScroll = Math.max(0, this.lineCount - this.bodyRows);
242
+ this.scrollTop = Math.max(0, Math.min(this.scrollTop + delta, maxScroll));
243
+ this.requestRender();
244
+ }
245
+ render(width) {
246
+ const fns = panelThemeFns(this.theme);
247
+ const budget = Math.max(4, this.basisRows ?? overlayContentBudget());
248
+ // Fixed geometry: header + blank + body + blank + footer = bodyRows + 4
249
+ // = budget EXACTLY, whatever the turn's content — short turns pad with
250
+ // blank body rows, long ones slice (the scroll window).
251
+ this.bodyRows = Math.max(1, budget - 4);
252
+ let lines = this.widthCache.get(width);
253
+ if (lines === undefined) {
254
+ lines = this.container.render(width);
255
+ if (this.widthCache.size >= 4)
256
+ this.widthCache.clear();
257
+ this.widthCache.set(width, lines);
258
+ }
259
+ this.lineCount = lines.length;
260
+ const maxScroll = Math.max(0, lines.length - this.bodyRows);
261
+ this.scrollTop = Math.max(0, Math.min(this.scrollTop, maxScroll));
262
+ const body = this.lineCount === 0
263
+ ? [fns.subtle(clipToWidth('Select a turn on the left.', width))]
264
+ : lines.slice(this.scrollTop, this.scrollTop + this.bodyRows);
265
+ while (body.length < this.bodyRows)
266
+ body.push('');
267
+ // Focus visuals: the focused pane's title reads accent BOLD, the idle
268
+ // one fades to subtle; the focused footer carries the exit/scroll hints.
269
+ const title = this.headerTitle === '' ? 'History' : this.headerTitle;
270
+ const header = this.focused
271
+ ? fns.accent(BOLD + clipToWidth(title, width) + RESET)
272
+ : fns.subtle(clipToWidth(title, width));
273
+ const window = this.lineCount > this.bodyRows
274
+ ? `${this.scrollTop + 1}–${Math.min(this.lineCount, this.scrollTop + this.bodyRows)}/${this.lineCount} lines`
275
+ : `${this.lineCount} line${this.lineCount === 1 ? '' : 's'}`;
276
+ const footer = this.focused
277
+ ? `← list · ↑↓ scroll · [ / ] page · ${window}`
278
+ : `[ / ] page · ${window}`;
279
+ return [header, '', ...body, '', fns.subtle(clipToWidth(footer, width))];
280
+ }
281
+ }
282
+ /**
283
+ * Read one session's events: the live snapshot when the id IS the live
284
+ * session (fresher than any stored copy, and no persistence round-trip),
285
+ otherwise a cold read through `sessionPersistence.inspect` — the host
286
+ * decompresses, no writer lock, no resume, no agent activation.
287
+ */
288
+ async function loadSessionEvents(deps, id) {
289
+ if (deps.getSessionId() === id) {
290
+ const events = deps.getLiveEvents();
291
+ if (events !== undefined)
292
+ return { sessionId: id, live: true, events };
293
+ }
294
+ const { events } = await inspectPersistedSession(deps.ctx, SessionId(id));
295
+ return { sessionId: id, live: false, events };
296
+ }
297
+ /**
298
+ * The user-facing failure line for a failed session load. Corrupt logs get
299
+ * the ⚠ + repair pointer (the /resume vocabulary — repair itself is an
300
+ * agent-side flow and stays out of this read-only browser).
301
+ */
302
+ export function historyLoadErrorMessage(id, error) {
303
+ const message = error instanceof Error ? error.message : String(error);
304
+ const short = clipToWidth(id, 8);
305
+ if (isCorruptLogError(message)) {
306
+ return `⚠ ${short}: corrupt session log — /resume ${short} offers a repair.`;
307
+ }
308
+ return `Cannot read ${short}: ${message}`;
309
+ }
310
+ /**
311
+ * Case-insensitive substring filter over the picker's display vocabulary:
312
+ * the session title (preview/label, the ⚠ and ● markers included), the
313
+ * directory, and the raw session id (paste-an-id narrowing). Empty query
314
+ * matches everything, order preserved. Pure; exported for tests.
315
+ */
316
+ export function filterSessionPickRows(rows, query) {
317
+ const needle = query.trim().toLowerCase();
318
+ if (needle === '')
319
+ return [...rows];
320
+ return rows.filter(row => row.session.toLowerCase().includes(needle)
321
+ || row.dir.toLowerCase().includes(needle)
322
+ || row.id.toLowerCase().includes(needle));
323
+ }
324
+ /**
325
+ * The `s` picker rows: every resumable session (isResumableSessionHeader —
326
+ * subagent children excluded), ordered by last update (log mtime, falling
327
+ * back to createdAt), the most recent ones enriched with first-message
328
+ * previews and the ⚠ corrupt marker (the shared loadSessionPreviews — zero
329
+ * extra IO beyond the preview inspects). The browsed session carries a `●`
330
+ * marker. Deliberately NOT narrowed by the /resume display window
331
+ * (`dsh-tui.resume.*` age/size knobs): that filter shapes what is worth
332
+ * RESUMING, while a look-back browser may read any stored log.
333
+ */
334
+ async function buildSessionPickRows(ctx, currentId) {
335
+ const persistence = ctx.get('sessionPersistence');
336
+ if (persistence === undefined) {
337
+ throw new Error('Session persistence is not configured in this profile.');
338
+ }
339
+ const headers = (await persistence.list()).filter(isResumableSessionHeader);
340
+ const lastUpdates = await loadSessionLastUpdates();
341
+ const ordered = sortSessionsByLastUpdate(headers, lastUpdates);
342
+ const { previews, corruptIds } = await loadSessionPreviews(persistence, ordered.slice(0, PREVIEW_SESSION_CAP).map(header => header.id));
343
+ return ordered.map(header => {
344
+ const id = String(header.id);
345
+ const updated = lastUpdates.get(id)?.mtimeMs ?? header.createdAt;
346
+ const title = resumeRowTitle(header, previews.get(id), corruptIds.has(id));
347
+ return {
348
+ id,
349
+ updated: new Date(updated).toLocaleString(),
350
+ dir: header.cwd ?? 'no cwd',
351
+ session: id === currentId ? `● ${title}` : title,
352
+ };
353
+ });
354
+ }
355
+ /**
356
+ * The browser overlay root: left TablePanel + right detail pane, arranged
357
+ * side-by-side (≥100 columns) or stacked (narrower), the container chosen
358
+ * per render at the current width. The keyboard stays with the left list
359
+ * (navigation, `/` filter, Enter/`c` copy, `s` session switch, Esc close);
360
+ * `[`/`]` page the detail pane; there is no focus management between panes.
361
+ */
362
+ export class HistoryBrowserPanel {
363
+ deps;
364
+ host;
365
+ // Both reassigned by rebuildListPanel (fresh panel per session switch);
366
+ // the constructor assigns them through it (same pattern as settings.ts).
367
+ listOptions;
368
+ list;
369
+ detail;
370
+ // Re-derived on terminal resize (see render).
371
+ listMax;
372
+ sessionId;
373
+ live;
374
+ turns;
375
+ query = '';
376
+ rows;
377
+ status;
378
+ closed = false;
379
+ pickerLoading = false;
380
+ /**
381
+ * Where the keyboard lives: the left list (default) or the right detail
382
+ * pane. `→` hands focus to the detail pane, `←`/Esc step back; only the
383
+ * focused pane's keys act (detail focus makes ↑↓ scroll, list keys inert).
384
+ */
385
+ focus = 'list';
386
+ /**
387
+ * The overlay budget the current list panel was built for (and its derived
388
+ * list height): a terminal resize re-derives both — the one explicit
389
+ * external change the fixed-geometry render is allowed to react to.
390
+ */
391
+ builtBudget;
392
+ /** Set by openHistoryBrowser; delivers the closing echo text. */
393
+ onFinish;
394
+ constructor(deps, host, loaded) {
395
+ this.deps = deps;
396
+ this.host = host;
397
+ this.sessionId = loaded.sessionId;
398
+ this.live = loaded.live;
399
+ this.turns = groupHistoryTurns(loaded.events);
400
+ this.rows = historyRows(this.turns, '');
401
+ this.listMax = listMaxVisible();
402
+ this.builtBudget = overlayContentBudget();
403
+ this.detail = new TurnDetailPane(deps.theme, deps.requestRender);
404
+ this.detail.setTurn(this.rows[0]?.turn, loaded.live);
405
+ this.rebuildListPanel(historyListTitle(loaded.sessionId, loaded.live), this.rows[0]?.turn);
406
+ }
407
+ /**
408
+ * (Re)build the left TablePanel over the current rows — the subagent
409
+ * viewer's swap-the-panel pattern. Columns refit against the live rows
410
+ * (autoColumns scans every row), so a session whose turn numbers gain a
411
+ * digit gets a wider TURN column instead of a clipped one; the cursor
412
+ * lands on `preselect` (row 0 of a freshly loaded session). In-place row
413
+ * swaps (`setQuery`) keep using the retained options object.
414
+ */
415
+ rebuildListPanel(title, preselect) {
416
+ const columns = autoColumns([
417
+ { key: 'turnLabel', title: 'Turn', cap: 6, align: 'right' },
418
+ { key: 'preview', title: 'Prompt' },
419
+ ], this.rows, (row, key) => (key === 'preview' ? row.preview : row.turnLabel));
420
+ this.listOptions = {
421
+ title,
422
+ columns,
423
+ rows: this.rows,
424
+ renderCell: (row, column) => (column.key === 'preview' ? row.preview : row.turnLabel),
425
+ maxVisible: this.listMax,
426
+ footer: HISTORY_FOOTER,
427
+ emptyHint: 'No completed turns',
428
+ // Focus visualization: the focused list shows the ▸ cursor; while the
429
+ // detail pane owns the keyboard the cursor demotes to `›` (the list is
430
+ // still visible, just not keyed).
431
+ marker: selected => selected ? (this.focus === 'detail' ? '› ' : '▸ ') : ' ',
432
+ onSelect: row => this.copyTurn(row.turn),
433
+ onCancel: () => this.finish('History closed.'),
434
+ shortcuts: {
435
+ c: () => this.copySelected(),
436
+ s: () => { void this.openSessionPicker(); },
437
+ // Detail paging rides the list's shortcut map: the TablePanel checks
438
+ // shortcuts only OUTSIDE filter-input mode (the engaged input returns
439
+ // before the shortcut lookup), so `[`/`]` type into the query while
440
+ // the filter is engaged and page the detail pane otherwise.
441
+ '[': () => this.detail.scrollByPage(-1),
442
+ ']': () => this.detail.scrollByPage(1),
443
+ },
444
+ filter: {
445
+ getQuery: () => this.query,
446
+ onQueryChange: next => this.setQuery(next),
447
+ },
448
+ status: () => this.status,
449
+ };
450
+ this.list = new TablePanel(this.deps.theme, this.listOptions);
451
+ const followed = preselect !== undefined && this.list.focusRow(row => row.turn === preselect);
452
+ if (!followed)
453
+ this.list.resyncCursor();
454
+ }
455
+ invalidate() {
456
+ this.list.invalidate();
457
+ this.detail.invalidate();
458
+ }
459
+ render(width) {
460
+ // Fixed geometry: the window is ALWAYS exactly `overlayContentBudget()`
461
+ // lines — short content pads with blank rows, long content is capped by
462
+ // the inner scroll windows. A terminal resize (the one external change
463
+ // this is allowed to react to) re-derives the budget and the list height;
464
+ // the current selection rides across the rebuild.
465
+ const budget = overlayContentBudget();
466
+ const listMax = listMaxVisible();
467
+ if (budget !== this.builtBudget || listMax !== this.listMax) {
468
+ this.builtBudget = budget;
469
+ this.listMax = listMax;
470
+ this.rebuildListPanel(historyListTitle(this.sessionId, this.live), this.list.selectedRow()?.turn);
471
+ }
472
+ let lines;
473
+ if (width >= DUAL_PANE_MIN_COLUMNS) {
474
+ const leftWidth = Math.max(LEFT_PANE_MIN_COLUMNS, Math.floor(width * 0.4));
475
+ this.detail.basisRows = undefined;
476
+ const stack = new HStack([
477
+ // basis (columns) fixed at ~40%, shrinkable to the 30-column floor;
478
+ // the detail pane grows into the remainder. Both panes render inside
479
+ // the same fixed height (detail = budget, list ≤ budget).
480
+ { component: this.list, basis: leftWidth, shrink: 1, minSize: LEFT_PANE_MIN_COLUMNS },
481
+ { component: this.detail, basis: 0, grow: 1, minSize: 16 },
482
+ ]);
483
+ lines = stack.render(width);
484
+ }
485
+ else {
486
+ // Stacked: the list keeps its intrinsic height; the detail pane gets
487
+ // what remains of the budget (the +2 is the filter-line and status-line
488
+ // headroom — both can co-display with results, see listMaxVisible).
489
+ this.detail.basisRows = Math.max(4, budget - (7 + this.listMax + 2));
490
+ const stack = new VStack([
491
+ { component: this.list, basis: 'auto', grow: 0, shrink: 0 },
492
+ { component: this.detail, basis: this.detail.basisRows, grow: 0, shrink: 0 },
493
+ ]);
494
+ lines = stack.render(width);
495
+ }
496
+ if (lines.length > budget)
497
+ return lines.slice(0, budget);
498
+ while (lines.length < budget)
499
+ lines.push('');
500
+ return lines;
501
+ }
502
+ handleInput(data) {
503
+ if (this.closed)
504
+ return;
505
+ const kb = getKeybindings();
506
+ if (this.focus === 'detail') {
507
+ // The right pane owns the keyboard: scroll keys act, the exit keys
508
+ // (`←`/Esc) step back to the list, everything else — `/`, `c`, `s`,
509
+ // Enter — is deliberately inert.
510
+ if (kb.matches(data, 'tui.select.cancel') || matchesKey(data, 'left')) {
511
+ this.setFocus('list');
512
+ return;
513
+ }
514
+ if (kb.matches(data, 'tui.select.up')) {
515
+ this.detail.scrollByLines(-1);
516
+ return;
517
+ }
518
+ if (kb.matches(data, 'tui.select.down')) {
519
+ this.detail.scrollByLines(1);
520
+ return;
521
+ }
522
+ if (kb.matches(data, 'tui.select.pageUp') || data === '[') {
523
+ this.detail.scrollByPage(-1);
524
+ return;
525
+ }
526
+ if (kb.matches(data, 'tui.select.pageDown') || data === ']') {
527
+ this.detail.scrollByPage(1);
528
+ return;
529
+ }
530
+ return;
531
+ }
532
+ // List focus: `→` hands focus to the detail pane — except while the
533
+ // filter input owns the keyboard, where an arrow must not yank focus
534
+ // mid-typing (the TablePanel ignores arrows there either way).
535
+ if (!this.list.isFiltering() && matchesKey(data, 'right')) {
536
+ this.setFocus('detail');
537
+ return;
538
+ }
539
+ const before = this.list.selectedRow()?.turn;
540
+ // The list owns the keyboard: navigation, the `/` filter (which consumes
541
+ // printable keys while engaged — `c`/`s`/`[`/`]` included), shortcuts,
542
+ // Enter (copy) and Esc (filter-clear-then-close grading is the
543
+ // TablePanel's). Selection changes rebuild the detail pane right here —
544
+ // an explicit action, never the render path.
545
+ this.list.handleInput(data);
546
+ const selected = this.list.selectedRow();
547
+ if (selected !== undefined && selected.turn !== before) {
548
+ this.detail.setTurn(selected.turn, this.live);
549
+ }
550
+ }
551
+ /** Move the keyboard between the two panes and refresh the focus visuals. */
552
+ setFocus(focus) {
553
+ if (this.focus === focus)
554
+ return;
555
+ this.focus = focus;
556
+ this.detail.setFocused(focus === 'detail');
557
+ this.deps.requestRender();
558
+ }
559
+ /** Close the overlay and deliver the closing echo text (once). */
560
+ finish(text) {
561
+ if (this.closed)
562
+ return;
563
+ this.closed = true;
564
+ this.host.close();
565
+ this.deps.restoreFocus();
566
+ this.onFinish?.(text);
567
+ }
568
+ /** Refill the editor with the turn's user prompt and close (never submit). */
569
+ copyTurn(turn) {
570
+ // undefined for turns without a human prompt (injected-only turns must
571
+ // not land in the editor — one Enter would submit a notice as a prompt).
572
+ const text = turnPrimaryUserText(turn);
573
+ if (text === undefined || text === '') {
574
+ this.status = 'Nothing to copy — the turn has no user prompt.';
575
+ this.deps.requestRender();
576
+ return;
577
+ }
578
+ this.deps.copyToEditor(text);
579
+ this.finish('Prompt copied to the editor.');
580
+ }
581
+ copySelected() {
582
+ const row = this.list.selectedRow();
583
+ if (row !== undefined)
584
+ this.copyTurn(row.turn);
585
+ }
586
+ /** Swap the browsed session; failures surface on the browser's status line. */
587
+ async loadAndShow(id) {
588
+ let loaded;
589
+ try {
590
+ loaded = await loadSessionEvents(this.deps, id);
591
+ }
592
+ catch (error) {
593
+ // The browser may have been closed while the load was in flight (Esc
594
+ // out of the picker during a slow cold read). Reopening a closed panel
595
+ // would resurrect a dead overlay that finish() can no longer close.
596
+ if (this.closed)
597
+ return;
598
+ // The picker was the mounted overlay and has no status row of its own —
599
+ // return to the browser (the flow's home surface; PanelHost shows the
600
+ // new panel before hiding the old one) so the failure is actually seen.
601
+ this.status = historyLoadErrorMessage(id, error);
602
+ this.host.open(this, '90%', '85%');
603
+ return;
604
+ }
605
+ if (this.closed)
606
+ return;
607
+ this.sessionId = loaded.sessionId;
608
+ this.live = loaded.live;
609
+ this.turns = groupHistoryTurns(loaded.events);
610
+ this.query = '';
611
+ this.status = undefined;
612
+ this.rows = historyRows(this.turns, '');
613
+ // A fresh TablePanel per session: the auto-fitted TURN column re-measures
614
+ // against the new rows (a session with 3-digit turn numbers must not keep
615
+ // a 1-digit-wide column) and the cursor starts at row 0.
616
+ this.rebuildListPanel(historyListTitle(loaded.sessionId, loaded.live), this.rows[0]?.turn);
617
+ this.detail.setTurn(this.list.selectedRow()?.turn, loaded.live);
618
+ // Show the (re)built browser before the host hides the picker — the
619
+ // PanelHost show-new-then-hide-old contract, no focus flash.
620
+ this.host.open(this, '90%', '85%');
621
+ }
622
+ /**
623
+ * Build the `s` session picker over prepared rows. Public so tests can
624
+ * drive the real panel (it mounts as its own overlay through the
625
+ * PanelHost). The filter is the same caller-held-query contract as the
626
+ * main list: `/` engages the input, every keystroke rebuilds the rows
627
+ * (case-insensitive substring over session title, directory and session
628
+ * id — `filterSessionPickRows`) with the cursor following its session
629
+ * across the rebuild, Esc clears the query before popping. The query is
630
+ * picker-local — reset on every `s` (CONTEXT.md "Filter").
631
+ */
632
+ buildSessionPickerPanel(rows) {
633
+ let query = '';
634
+ const columns = autoColumns([
635
+ { key: 'updated', title: 'Updated', cap: 26 },
636
+ // Same cap as the /resume picker's DIR column.
637
+ { key: 'dir', title: 'Dir', cap: RESUME_DIR_CAP },
638
+ { key: 'session', title: 'Session' },
639
+ ], rows, (row, key) => row[key]);
640
+ let picker;
641
+ const options = {
642
+ title: '● Browse session',
643
+ columns,
644
+ rows: [...rows],
645
+ renderCell: (row, column) => row[column.key],
646
+ footer: '↑↓ navigate · Enter browse · / filter · Esc back',
647
+ maxVisible: listMaxVisible(),
648
+ emptyHint: 'No matching sessions',
649
+ onSelect: row => { void this.loadAndShow(row.id); },
650
+ onCancel: () => {
651
+ // Back to the browser: show it, then the host hides the picker.
652
+ if (!this.closed)
653
+ this.host.open(this, '90%', '85%');
654
+ else
655
+ this.finish('History closed.');
656
+ },
657
+ filter: {
658
+ getQuery: () => query,
659
+ onQueryChange: next => {
660
+ query = next;
661
+ const current = picker.selectedRow()?.id;
662
+ options.rows = filterSessionPickRows(rows, query);
663
+ const followed = current !== undefined && picker.focusRow(row => row.id === current);
664
+ if (!followed)
665
+ picker.resyncCursor();
666
+ },
667
+ },
668
+ };
669
+ picker = new TablePanel(this.deps.theme, options);
670
+ return picker;
671
+ }
672
+ /** Open the session picker overlay (the browser stays mounted underneath). */
673
+ async openSessionPicker() {
674
+ if (this.pickerLoading || this.closed)
675
+ return;
676
+ this.pickerLoading = true;
677
+ let rows;
678
+ try {
679
+ rows = await buildSessionPickRows(this.deps.ctx, this.sessionId);
680
+ }
681
+ catch (error) {
682
+ this.pickerLoading = false;
683
+ this.status = historyLoadErrorMessage(this.sessionId, error);
684
+ this.deps.requestRender();
685
+ return;
686
+ }
687
+ this.pickerLoading = false;
688
+ if (this.closed)
689
+ return;
690
+ if (rows.length === 0) {
691
+ this.status = 'No stored sessions to browse.';
692
+ this.deps.requestRender();
693
+ return;
694
+ }
695
+ this.host.open(this.buildSessionPickerPanel(rows), '80%', '95%');
696
+ }
697
+ /** Live query swap: rebuild rows, keep the cursor on its turn when visible. */
698
+ setQuery(query) {
699
+ this.query = query;
700
+ const current = this.list.selectedRow()?.turn;
701
+ this.rows = historyRows(this.turns, query);
702
+ this.listOptions.rows = this.rows;
703
+ const followed = current !== undefined && this.list.focusRow(row => row.turn === current);
704
+ if (!followed)
705
+ this.list.resyncCursor();
706
+ }
707
+ }
708
+ /**
709
+ * Open the history browser. `sessionIdArg` (from `/history <sessionId>`)
710
+ * cold-reads that session; without one the CURRENT live session is browsed
711
+ * and, when none exists, a hint line is returned instead. Resolves with the
712
+ * closing echo text once the overlay closes (Esc, or copy-to-editor).
713
+ */
714
+ export async function openHistoryBrowser(deps, sessionIdArg) {
715
+ const target = sessionIdArg ?? deps.getSessionId();
716
+ if (target === undefined || target === '') {
717
+ return {
718
+ text: 'No active session — use /history <sessionId> to browse a stored one.',
719
+ error: true,
720
+ };
721
+ }
722
+ let loaded;
723
+ try {
724
+ loaded = await loadSessionEvents(deps, target);
725
+ }
726
+ catch (error) {
727
+ return { text: historyLoadErrorMessage(target, error), error: true };
728
+ }
729
+ return new Promise(resolve => {
730
+ const host = new PanelHost(deps.tui, deps.theme, () => {
731
+ // A half-mounted overlay must not strand the keyboard — and the caller
732
+ // gets the truth: this is a mount failure, not a quiet close. (Promise
733
+ // resolution is idempotent, so a later finish/onFinish is a no-op.)
734
+ deps.restoreFocus();
735
+ resolve({ text: 'Failed to open the history viewer.', error: true });
736
+ });
737
+ const panel = new HistoryBrowserPanel(deps, host, loaded);
738
+ panel.onFinish = text => resolve({ text, error: false });
739
+ const handle = host.open(panel, '90%', '85%');
740
+ if (handle === undefined) {
741
+ // host.open already ran the error path above (focus restored + resolve).
742
+ resolve({ text: 'Failed to open the history viewer.', error: true });
743
+ }
744
+ });
745
+ }
746
+ //# sourceMappingURL=history.js.map