@aiwayds/dsh-tui-pi 2.5.0 → 2.7.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/README.md +2 -2
- package/README.zh-CN.md +1 -2
- package/lib/history-fork.d.ts +83 -0
- package/lib/history-fork.js +182 -0
- package/lib/history-fork.js.map +1 -0
- package/lib/history-turns.d.ts +95 -0
- package/lib/history-turns.js +186 -0
- package/lib/history-turns.js.map +1 -0
- package/lib/history.d.ts +268 -0
- package/lib/history.js +794 -0
- package/lib/history.js.map +1 -0
- package/lib/hotkeys.d.ts +1 -1
- package/lib/hotkeys.js +2 -3
- package/lib/hotkeys.js.map +1 -1
- package/lib/index.js +218 -51
- package/lib/index.js.map +1 -1
- package/lib/keymap.d.ts +0 -5
- package/lib/keymap.js +3 -8
- package/lib/keymap.js.map +1 -1
- package/lib/panels.d.ts +15 -0
- package/lib/panels.js +13 -3
- package/lib/panels.js.map +1 -1
- package/lib/preset-dialog.d.ts +152 -0
- package/lib/preset-dialog.js +254 -0
- package/lib/preset-dialog.js.map +1 -0
- package/lib/preset.d.ts +16 -9
- package/lib/preset.js +18 -11
- package/lib/preset.js.map +1 -1
- package/lib/session.d.ts +41 -0
- package/lib/session.js +82 -25
- package/lib/session.js.map +1 -1
- package/lib/sessions.d.ts +43 -0
- package/lib/sessions.js +9 -5
- package/lib/sessions.js.map +1 -1
- package/lib/tui.js +1 -1
- package/lib/tui.js.map +1 -1
- package/package.json +1 -1
package/lib/history.js
ADDED
|
@@ -0,0 +1,794 @@
|
|
|
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, turnSeedSlice, } 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 · f fork · → 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
|
+
/** The browsed session's raw events — the fork-at-turn slice source. */
|
|
376
|
+
events;
|
|
377
|
+
query = '';
|
|
378
|
+
rows;
|
|
379
|
+
status;
|
|
380
|
+
closed = false;
|
|
381
|
+
pickerLoading = false;
|
|
382
|
+
forkInProgress = false;
|
|
383
|
+
/**
|
|
384
|
+
* Where the keyboard lives: the left list (default) or the right detail
|
|
385
|
+
* pane. `→` hands focus to the detail pane, `←`/Esc step back; only the
|
|
386
|
+
* focused pane's keys act (detail focus makes ↑↓ scroll, list keys inert).
|
|
387
|
+
*/
|
|
388
|
+
focus = 'list';
|
|
389
|
+
/**
|
|
390
|
+
* The overlay budget the current list panel was built for (and its derived
|
|
391
|
+
* list height): a terminal resize re-derives both — the one explicit
|
|
392
|
+
* external change the fixed-geometry render is allowed to react to.
|
|
393
|
+
*/
|
|
394
|
+
builtBudget;
|
|
395
|
+
/** Set by openHistoryBrowser; delivers the closing echo text. */
|
|
396
|
+
onFinish;
|
|
397
|
+
constructor(deps, host, loaded) {
|
|
398
|
+
this.deps = deps;
|
|
399
|
+
this.host = host;
|
|
400
|
+
this.sessionId = loaded.sessionId;
|
|
401
|
+
this.live = loaded.live;
|
|
402
|
+
this.turns = groupHistoryTurns(loaded.events);
|
|
403
|
+
this.events = loaded.events;
|
|
404
|
+
this.rows = historyRows(this.turns, '');
|
|
405
|
+
this.listMax = listMaxVisible();
|
|
406
|
+
this.builtBudget = overlayContentBudget();
|
|
407
|
+
this.detail = new TurnDetailPane(deps.theme, deps.requestRender);
|
|
408
|
+
this.detail.setTurn(this.rows[0]?.turn, loaded.live);
|
|
409
|
+
this.rebuildListPanel(historyListTitle(loaded.sessionId, loaded.live), this.rows[0]?.turn);
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* (Re)build the left TablePanel over the current rows — the subagent
|
|
413
|
+
* viewer's swap-the-panel pattern. Columns refit against the live rows
|
|
414
|
+
* (autoColumns scans every row), so a session whose turn numbers gain a
|
|
415
|
+
* digit gets a wider TURN column instead of a clipped one; the cursor
|
|
416
|
+
* lands on `preselect` (row 0 of a freshly loaded session). In-place row
|
|
417
|
+
* swaps (`setQuery`) keep using the retained options object.
|
|
418
|
+
*/
|
|
419
|
+
rebuildListPanel(title, preselect) {
|
|
420
|
+
const columns = autoColumns([
|
|
421
|
+
{ key: 'turnLabel', title: 'Turn', cap: 6, align: 'right' },
|
|
422
|
+
{ key: 'preview', title: 'Prompt' },
|
|
423
|
+
], this.rows, (row, key) => (key === 'preview' ? row.preview : row.turnLabel));
|
|
424
|
+
this.listOptions = {
|
|
425
|
+
title,
|
|
426
|
+
columns,
|
|
427
|
+
rows: this.rows,
|
|
428
|
+
renderCell: (row, column) => (column.key === 'preview' ? row.preview : row.turnLabel),
|
|
429
|
+
maxVisible: this.listMax,
|
|
430
|
+
footer: HISTORY_FOOTER,
|
|
431
|
+
emptyHint: 'No completed turns',
|
|
432
|
+
// Focus visualization: the focused list shows the ▸ cursor; while the
|
|
433
|
+
// detail pane owns the keyboard the cursor demotes to `›` (the list is
|
|
434
|
+
// still visible, just not keyed).
|
|
435
|
+
marker: selected => selected ? (this.focus === 'detail' ? '› ' : '▸ ') : ' ',
|
|
436
|
+
onSelect: row => this.copyTurn(row.turn),
|
|
437
|
+
onCancel: () => this.finish('History closed.'),
|
|
438
|
+
shortcuts: {
|
|
439
|
+
c: () => this.copySelected(),
|
|
440
|
+
s: () => { void this.openSessionPicker(); },
|
|
441
|
+
// Fork at the selected turn (confirmed in a dialog over the browser).
|
|
442
|
+
f: () => { void this.forkAtSelectedTurn(); },
|
|
443
|
+
// Detail paging rides the list's shortcut map: the TablePanel checks
|
|
444
|
+
// shortcuts only OUTSIDE filter-input mode (the engaged input returns
|
|
445
|
+
// before the shortcut lookup), so `[`/`]` type into the query while
|
|
446
|
+
// the filter is engaged and page the detail pane otherwise.
|
|
447
|
+
'[': () => this.detail.scrollByPage(-1),
|
|
448
|
+
']': () => this.detail.scrollByPage(1),
|
|
449
|
+
},
|
|
450
|
+
filter: {
|
|
451
|
+
getQuery: () => this.query,
|
|
452
|
+
onQueryChange: next => this.setQuery(next),
|
|
453
|
+
},
|
|
454
|
+
status: () => this.status,
|
|
455
|
+
};
|
|
456
|
+
this.list = new TablePanel(this.deps.theme, this.listOptions);
|
|
457
|
+
const followed = preselect !== undefined && this.list.focusRow(row => row.turn === preselect);
|
|
458
|
+
if (!followed)
|
|
459
|
+
this.list.resyncCursor();
|
|
460
|
+
}
|
|
461
|
+
invalidate() {
|
|
462
|
+
this.list.invalidate();
|
|
463
|
+
this.detail.invalidate();
|
|
464
|
+
}
|
|
465
|
+
render(width) {
|
|
466
|
+
// Fixed geometry: the window is ALWAYS exactly `overlayContentBudget()`
|
|
467
|
+
// lines — short content pads with blank rows, long content is capped by
|
|
468
|
+
// the inner scroll windows. A terminal resize (the one external change
|
|
469
|
+
// this is allowed to react to) re-derives the budget and the list height;
|
|
470
|
+
// the current selection rides across the rebuild.
|
|
471
|
+
const budget = overlayContentBudget();
|
|
472
|
+
const listMax = listMaxVisible();
|
|
473
|
+
if (budget !== this.builtBudget || listMax !== this.listMax) {
|
|
474
|
+
this.builtBudget = budget;
|
|
475
|
+
this.listMax = listMax;
|
|
476
|
+
this.rebuildListPanel(historyListTitle(this.sessionId, this.live), this.list.selectedRow()?.turn);
|
|
477
|
+
}
|
|
478
|
+
let lines;
|
|
479
|
+
if (width >= DUAL_PANE_MIN_COLUMNS) {
|
|
480
|
+
const leftWidth = Math.max(LEFT_PANE_MIN_COLUMNS, Math.floor(width * 0.4));
|
|
481
|
+
this.detail.basisRows = undefined;
|
|
482
|
+
const stack = new HStack([
|
|
483
|
+
// basis (columns) fixed at ~40%, shrinkable to the 30-column floor;
|
|
484
|
+
// the detail pane grows into the remainder. Both panes render inside
|
|
485
|
+
// the same fixed height (detail = budget, list ≤ budget).
|
|
486
|
+
{ component: this.list, basis: leftWidth, shrink: 1, minSize: LEFT_PANE_MIN_COLUMNS },
|
|
487
|
+
{ component: this.detail, basis: 0, grow: 1, minSize: 16 },
|
|
488
|
+
]);
|
|
489
|
+
lines = stack.render(width);
|
|
490
|
+
}
|
|
491
|
+
else {
|
|
492
|
+
// Stacked: the list keeps its intrinsic height; the detail pane gets
|
|
493
|
+
// what remains of the budget (the +2 is the filter-line and status-line
|
|
494
|
+
// headroom — both can co-display with results, see listMaxVisible).
|
|
495
|
+
this.detail.basisRows = Math.max(4, budget - (7 + this.listMax + 2));
|
|
496
|
+
const stack = new VStack([
|
|
497
|
+
{ component: this.list, basis: 'auto', grow: 0, shrink: 0 },
|
|
498
|
+
{ component: this.detail, basis: this.detail.basisRows, grow: 0, shrink: 0 },
|
|
499
|
+
]);
|
|
500
|
+
lines = stack.render(width);
|
|
501
|
+
}
|
|
502
|
+
if (lines.length > budget)
|
|
503
|
+
return lines.slice(0, budget);
|
|
504
|
+
while (lines.length < budget)
|
|
505
|
+
lines.push('');
|
|
506
|
+
return lines;
|
|
507
|
+
}
|
|
508
|
+
handleInput(data) {
|
|
509
|
+
if (this.closed)
|
|
510
|
+
return;
|
|
511
|
+
const kb = getKeybindings();
|
|
512
|
+
if (this.focus === 'detail') {
|
|
513
|
+
// The right pane owns the keyboard: scroll keys act, the exit keys
|
|
514
|
+
// (`←`/Esc) step back to the list, everything else — `/`, `c`, `s`,
|
|
515
|
+
// Enter — is deliberately inert.
|
|
516
|
+
if (kb.matches(data, 'tui.select.cancel') || matchesKey(data, 'left')) {
|
|
517
|
+
this.setFocus('list');
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
if (kb.matches(data, 'tui.select.up')) {
|
|
521
|
+
this.detail.scrollByLines(-1);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
if (kb.matches(data, 'tui.select.down')) {
|
|
525
|
+
this.detail.scrollByLines(1);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
if (kb.matches(data, 'tui.select.pageUp') || data === '[') {
|
|
529
|
+
this.detail.scrollByPage(-1);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
if (kb.matches(data, 'tui.select.pageDown') || data === ']') {
|
|
533
|
+
this.detail.scrollByPage(1);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
// The selection is the same whichever pane is keyed: `f` forks at it
|
|
537
|
+
// from the detail focus too.
|
|
538
|
+
if (data === 'f') {
|
|
539
|
+
void this.forkAtSelectedTurn();
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
// List focus: `→` hands focus to the detail pane — except while the
|
|
545
|
+
// filter input owns the keyboard, where an arrow must not yank focus
|
|
546
|
+
// mid-typing (the TablePanel ignores arrows there either way).
|
|
547
|
+
if (!this.list.isFiltering() && matchesKey(data, 'right')) {
|
|
548
|
+
this.setFocus('detail');
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
const before = this.list.selectedRow()?.turn;
|
|
552
|
+
// The list owns the keyboard: navigation, the `/` filter (which consumes
|
|
553
|
+
// printable keys while engaged — `c`/`s`/`[`/`]` included), shortcuts,
|
|
554
|
+
// Enter (copy) and Esc (filter-clear-then-close grading is the
|
|
555
|
+
// TablePanel's). Selection changes rebuild the detail pane right here —
|
|
556
|
+
// an explicit action, never the render path.
|
|
557
|
+
this.list.handleInput(data);
|
|
558
|
+
const selected = this.list.selectedRow();
|
|
559
|
+
if (selected !== undefined && selected.turn !== before) {
|
|
560
|
+
this.detail.setTurn(selected.turn, this.live);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
/** Move the keyboard between the two panes and refresh the focus visuals. */
|
|
564
|
+
setFocus(focus) {
|
|
565
|
+
if (this.focus === focus)
|
|
566
|
+
return;
|
|
567
|
+
this.focus = focus;
|
|
568
|
+
this.detail.setFocused(focus === 'detail');
|
|
569
|
+
this.deps.requestRender();
|
|
570
|
+
}
|
|
571
|
+
/** Close the overlay and deliver the closing echo text (once). */
|
|
572
|
+
finish(text) {
|
|
573
|
+
if (this.closed)
|
|
574
|
+
return;
|
|
575
|
+
this.closed = true;
|
|
576
|
+
this.host.close();
|
|
577
|
+
this.deps.restoreFocus();
|
|
578
|
+
this.onFinish?.(text);
|
|
579
|
+
}
|
|
580
|
+
/** Refill the editor with the turn's user prompt and close (never submit). */
|
|
581
|
+
copyTurn(turn) {
|
|
582
|
+
// undefined for turns without a human prompt (injected-only turns must
|
|
583
|
+
// not land in the editor — one Enter would submit a notice as a prompt).
|
|
584
|
+
const text = turnPrimaryUserText(turn);
|
|
585
|
+
if (text === undefined || text === '') {
|
|
586
|
+
this.status = 'Nothing to copy — the turn has no user prompt.';
|
|
587
|
+
this.deps.requestRender();
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
this.deps.copyToEditor(text);
|
|
591
|
+
this.finish('Prompt copied to the editor.');
|
|
592
|
+
}
|
|
593
|
+
copySelected() {
|
|
594
|
+
const row = this.list.selectedRow();
|
|
595
|
+
if (row !== undefined)
|
|
596
|
+
this.copyTurn(row.turn);
|
|
597
|
+
}
|
|
598
|
+
/** Swap the browsed session; failures surface on the browser's status line. */
|
|
599
|
+
async loadAndShow(id) {
|
|
600
|
+
let loaded;
|
|
601
|
+
try {
|
|
602
|
+
loaded = await loadSessionEvents(this.deps, id);
|
|
603
|
+
}
|
|
604
|
+
catch (error) {
|
|
605
|
+
// The browser may have been closed while the load was in flight (Esc
|
|
606
|
+
// out of the picker during a slow cold read). Reopening a closed panel
|
|
607
|
+
// would resurrect a dead overlay that finish() can no longer close.
|
|
608
|
+
if (this.closed)
|
|
609
|
+
return;
|
|
610
|
+
// The picker was the mounted overlay and has no status row of its own —
|
|
611
|
+
// return to the browser (the flow's home surface; PanelHost shows the
|
|
612
|
+
// new panel before hiding the old one) so the failure is actually seen.
|
|
613
|
+
this.status = historyLoadErrorMessage(id, error);
|
|
614
|
+
this.host.open(this, '90%', '85%');
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
if (this.closed)
|
|
618
|
+
return;
|
|
619
|
+
this.sessionId = loaded.sessionId;
|
|
620
|
+
this.live = loaded.live;
|
|
621
|
+
this.turns = groupHistoryTurns(loaded.events);
|
|
622
|
+
this.events = loaded.events;
|
|
623
|
+
this.query = '';
|
|
624
|
+
this.status = undefined;
|
|
625
|
+
this.rows = historyRows(this.turns, '');
|
|
626
|
+
// A fresh TablePanel per session: the auto-fitted TURN column re-measures
|
|
627
|
+
// against the new rows (a session with 3-digit turn numbers must not keep
|
|
628
|
+
// a 1-digit-wide column) and the cursor starts at row 0.
|
|
629
|
+
this.rebuildListPanel(historyListTitle(loaded.sessionId, loaded.live), this.rows[0]?.turn);
|
|
630
|
+
this.detail.setTurn(this.list.selectedRow()?.turn, loaded.live);
|
|
631
|
+
// Show the (re)built browser before the host hides the picker — the
|
|
632
|
+
// PanelHost show-new-then-hide-old contract, no focus flash.
|
|
633
|
+
this.host.open(this, '90%', '85%');
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Build the `s` session picker over prepared rows. Public so tests can
|
|
637
|
+
* drive the real panel (it mounts as its own overlay through the
|
|
638
|
+
* PanelHost). The filter is the same caller-held-query contract as the
|
|
639
|
+
* main list: `/` engages the input, every keystroke rebuilds the rows
|
|
640
|
+
* (case-insensitive substring over session title, directory and session
|
|
641
|
+
* id — `filterSessionPickRows`) with the cursor following its session
|
|
642
|
+
* across the rebuild, Esc clears the query before popping. The query is
|
|
643
|
+
* picker-local — reset on every `s` (CONTEXT.md "Filter").
|
|
644
|
+
*/
|
|
645
|
+
buildSessionPickerPanel(rows) {
|
|
646
|
+
let query = '';
|
|
647
|
+
const columns = autoColumns([
|
|
648
|
+
{ key: 'updated', title: 'Updated', cap: 26 },
|
|
649
|
+
// Same cap as the /resume picker's DIR column.
|
|
650
|
+
{ key: 'dir', title: 'Dir', cap: RESUME_DIR_CAP },
|
|
651
|
+
{ key: 'session', title: 'Session' },
|
|
652
|
+
], rows, (row, key) => row[key]);
|
|
653
|
+
let picker;
|
|
654
|
+
const options = {
|
|
655
|
+
title: '● Browse session',
|
|
656
|
+
columns,
|
|
657
|
+
rows: [...rows],
|
|
658
|
+
renderCell: (row, column) => row[column.key],
|
|
659
|
+
footer: '↑↓ navigate · Enter browse · / filter · Esc back',
|
|
660
|
+
maxVisible: listMaxVisible(),
|
|
661
|
+
emptyHint: 'No matching sessions',
|
|
662
|
+
onSelect: row => { void this.loadAndShow(row.id); },
|
|
663
|
+
onCancel: () => {
|
|
664
|
+
// Back to the browser: show it, then the host hides the picker.
|
|
665
|
+
if (!this.closed)
|
|
666
|
+
this.host.open(this, '90%', '85%');
|
|
667
|
+
else
|
|
668
|
+
this.finish('History closed.');
|
|
669
|
+
},
|
|
670
|
+
filter: {
|
|
671
|
+
getQuery: () => query,
|
|
672
|
+
onQueryChange: next => {
|
|
673
|
+
query = next;
|
|
674
|
+
const current = picker.selectedRow()?.id;
|
|
675
|
+
options.rows = filterSessionPickRows(rows, query);
|
|
676
|
+
const followed = current !== undefined && picker.focusRow(row => row.id === current);
|
|
677
|
+
if (!followed)
|
|
678
|
+
picker.resyncCursor();
|
|
679
|
+
},
|
|
680
|
+
},
|
|
681
|
+
};
|
|
682
|
+
picker = new TablePanel(this.deps.theme, options);
|
|
683
|
+
return picker;
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Fork at the selected turn (`f`): confirm over the open browser, then
|
|
687
|
+
* hand the turn-bounded seed to the fork-and-switch seam. The browsed
|
|
688
|
+
* session's events are the slice source — live snapshots and cold-read
|
|
689
|
+
* (`inspect`) events fork alike. Cancel or an empty slice (nothing
|
|
690
|
+
* completed to carry) changes nothing; a failed fork keeps the browser
|
|
691
|
+
* open with the failure on the status line plus a buffered notice.
|
|
692
|
+
*/
|
|
693
|
+
async forkAtSelectedTurn() {
|
|
694
|
+
if (this.closed || this.forkInProgress)
|
|
695
|
+
return;
|
|
696
|
+
const row = this.list.selectedRow();
|
|
697
|
+
if (row === undefined)
|
|
698
|
+
return;
|
|
699
|
+
const seed = turnSeedSlice(this.events, row.turn.turn);
|
|
700
|
+
if (seed.length === 0)
|
|
701
|
+
return;
|
|
702
|
+
this.forkInProgress = true;
|
|
703
|
+
try {
|
|
704
|
+
const outcome = await this.deps.confirmForkAtTurn(row.turnLabel, this.turns.length, !this.live);
|
|
705
|
+
if (outcome !== true)
|
|
706
|
+
return;
|
|
707
|
+
await this.deps.forkAtTurn(seed, this.sessionId);
|
|
708
|
+
this.finish(`Forked at turn ${row.turnLabel} — new session opened.`);
|
|
709
|
+
}
|
|
710
|
+
catch (error) {
|
|
711
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
712
|
+
this.status = `Fork failed: ${message}`;
|
|
713
|
+
this.deps.reportError(`Fork at turn ${row.turnLabel} failed: ${message}`);
|
|
714
|
+
this.deps.requestRender();
|
|
715
|
+
}
|
|
716
|
+
finally {
|
|
717
|
+
this.forkInProgress = false;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
/** Open the session picker overlay (the browser stays mounted underneath). */
|
|
721
|
+
async openSessionPicker() {
|
|
722
|
+
if (this.pickerLoading || this.closed)
|
|
723
|
+
return;
|
|
724
|
+
this.pickerLoading = true;
|
|
725
|
+
let rows;
|
|
726
|
+
try {
|
|
727
|
+
rows = await buildSessionPickRows(this.deps.ctx, this.sessionId);
|
|
728
|
+
}
|
|
729
|
+
catch (error) {
|
|
730
|
+
this.pickerLoading = false;
|
|
731
|
+
this.status = historyLoadErrorMessage(this.sessionId, error);
|
|
732
|
+
this.deps.requestRender();
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
this.pickerLoading = false;
|
|
736
|
+
if (this.closed)
|
|
737
|
+
return;
|
|
738
|
+
if (rows.length === 0) {
|
|
739
|
+
this.status = 'No stored sessions to browse.';
|
|
740
|
+
this.deps.requestRender();
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
this.host.open(this.buildSessionPickerPanel(rows), '80%', '95%');
|
|
744
|
+
}
|
|
745
|
+
/** Live query swap: rebuild rows, keep the cursor on its turn when visible. */
|
|
746
|
+
setQuery(query) {
|
|
747
|
+
this.query = query;
|
|
748
|
+
const current = this.list.selectedRow()?.turn;
|
|
749
|
+
this.rows = historyRows(this.turns, query);
|
|
750
|
+
this.listOptions.rows = this.rows;
|
|
751
|
+
const followed = current !== undefined && this.list.focusRow(row => row.turn === current);
|
|
752
|
+
if (!followed)
|
|
753
|
+
this.list.resyncCursor();
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Open the history browser. `sessionIdArg` (from `/history <sessionId>`)
|
|
758
|
+
* cold-reads that session; without one the CURRENT live session is browsed
|
|
759
|
+
* and, when none exists, a hint line is returned instead. Resolves with the
|
|
760
|
+
* closing echo text once the overlay closes (Esc, or copy-to-editor).
|
|
761
|
+
*/
|
|
762
|
+
export async function openHistoryBrowser(deps, sessionIdArg) {
|
|
763
|
+
const target = sessionIdArg ?? deps.getSessionId();
|
|
764
|
+
if (target === undefined || target === '') {
|
|
765
|
+
return {
|
|
766
|
+
text: 'No active session — use /history <sessionId> to browse a stored one.',
|
|
767
|
+
error: true,
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
let loaded;
|
|
771
|
+
try {
|
|
772
|
+
loaded = await loadSessionEvents(deps, target);
|
|
773
|
+
}
|
|
774
|
+
catch (error) {
|
|
775
|
+
return { text: historyLoadErrorMessage(target, error), error: true };
|
|
776
|
+
}
|
|
777
|
+
return new Promise(resolve => {
|
|
778
|
+
const host = new PanelHost(deps.tui, deps.theme, () => {
|
|
779
|
+
// A half-mounted overlay must not strand the keyboard — and the caller
|
|
780
|
+
// gets the truth: this is a mount failure, not a quiet close. (Promise
|
|
781
|
+
// resolution is idempotent, so a later finish/onFinish is a no-op.)
|
|
782
|
+
deps.restoreFocus();
|
|
783
|
+
resolve({ text: 'Failed to open the history viewer.', error: true });
|
|
784
|
+
});
|
|
785
|
+
const panel = new HistoryBrowserPanel(deps, host, loaded);
|
|
786
|
+
panel.onFinish = text => resolve({ text, error: false });
|
|
787
|
+
const handle = host.open(panel, '90%', '85%');
|
|
788
|
+
if (handle === undefined) {
|
|
789
|
+
// host.open already ran the error path above (focus restored + resolve).
|
|
790
|
+
resolve({ text: 'Failed to open the history viewer.', error: true });
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
//# sourceMappingURL=history.js.map
|