@vanillagreen/pi-qol 1.1.1 → 1.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.
@@ -0,0 +1,937 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
+ import { ansiGreen, ansiRed, ansiYellow } from "../ansi.js";
4
+ import { SESSION_SEARCH_OVERLAY_HEIGHT_RATIO } from "../constants.js";
5
+ import { settingNumber } from "../settings.js";
6
+ import {
7
+ defaultSessionSearchScope,
8
+ modelLabel,
9
+ sameModel,
10
+ sameSessionSearchProject,
11
+ sessionModelInfo,
12
+ sessionResumeTitle,
13
+ shortPathForUi,
14
+ userMessagesForResult,
15
+ type QolModelInfo,
16
+ } from "./cache.js";
17
+ import {
18
+ buildPromptSnippet,
19
+ formatSessionSearchDate,
20
+ parseSessionSearchQuery,
21
+ promptRecencyTime,
22
+ searchQolSessionHits,
23
+ } from "./search.js";
24
+ import type {
25
+ QolSessionActionState,
26
+ QolSessionConfirmModelState,
27
+ QolSessionContextConfirmState,
28
+ QolSessionForkConfirmState,
29
+ QolSessionMessagesState,
30
+ QolSessionPaletteAction,
31
+ QolSessionSearchHit,
32
+ QolSessionSearchResult,
33
+ QolSessionSearchScope,
34
+ QolSessionSearchSession,
35
+ QolSessionSearchState,
36
+ QolSessionUserMessage,
37
+ } from "./types.js";
38
+
39
+ import { escapeRegex } from "../util.js";
40
+
41
+ export function wrapVisible(text: string, width: number, maxLines: number): string[] {
42
+ const wrapped = wrapTextWithAnsi(text, width);
43
+ if (wrapped.length <= maxLines) return wrapped;
44
+ const head = wrapped.slice(0, Math.max(0, maxLines - 1));
45
+ const tail = wrapped.slice(Math.max(0, maxLines - 1)).join(" ");
46
+ return [...head, truncateToWidth(tail, width, "…")];
47
+ }
48
+
49
+ export function styleSessionSnippet(snippet: string, query: string, _theme: Theme): string {
50
+ let styled = snippet;
51
+ const parsed = parseSessionSearchQuery(query);
52
+ if (parsed.mode === "tokens") {
53
+ for (const token of parsed.tokens) {
54
+ const value = token.value.trim();
55
+ if (!value || value.length > 80) continue;
56
+ try {
57
+ styled = styled.replace(new RegExp(escapeRegex(value), "gi"), (match) => ansiRed(match));
58
+ } catch {
59
+ // Ignore highlighting failures; search result remains readable.
60
+ }
61
+ }
62
+ }
63
+ return styled;
64
+ }
65
+
66
+ export function boxParts(width: number, theme: Theme) {
67
+ const safeWidth = Math.max(24, width);
68
+ const paddingX = 2;
69
+ const frameInner = Math.max(10, safeWidth - 2);
70
+ const inner = Math.max(1, frameInner - paddingX * 2);
71
+ const border = (s: string) => theme.fg("borderAccent", s);
72
+ const fixed = (content = "", rowWidth = inner) => {
73
+ // A single accidental newline in a session name/prompt can tear the box apart.
74
+ // Preserve ANSI styling, but collapse hard line breaks before measuring.
75
+ const safeContent = content.replace(/[\r\n\t]+/g, " ");
76
+ const clipped = truncateToWidth(safeContent, rowWidth, "");
77
+ return clipped + " ".repeat(Math.max(0, rowWidth - visibleWidth(clipped)));
78
+ };
79
+ const row = (content = "", selected = false) => {
80
+ const body = fixed(content);
81
+ return `${border("┃")}${" ".repeat(paddingX)}${selected ? theme.bg("selectedBg", body) : body}${" ".repeat(paddingX)}${border("┃")}`;
82
+ };
83
+ const filledRow = (content = "", bg: "selectedBg" | "toolPendingBg" = "toolPendingBg") => {
84
+ const body = fixed(content);
85
+ return `${border("┃")}${" ".repeat(paddingX)}${theme.bg(bg, body)}${" ".repeat(paddingX)}${border("┃")}`;
86
+ };
87
+ const selectedRow = (content = "") => row(content, true);
88
+ const empty = () => `${border("┃")}${" ".repeat(frameInner)}${border("┃")}`;
89
+ const divider = () => row(theme.fg("borderMuted", "━".repeat(inner)));
90
+ const top = (label = "", right = "") => {
91
+ if (!label) return border(`┏${"━".repeat(frameInner)}┓`);
92
+ const rightPlain = right ? ` ${right} ` : "";
93
+ const titleBudget = Math.max(1, frameInner - visibleWidth(rightPlain) - 1);
94
+ const titlePlain = ` ${truncateToWidth(label, Math.max(1, titleBudget - 2), "…")} `;
95
+ const fill = Math.max(1, frameInner - visibleWidth(titlePlain) - visibleWidth(rightPlain));
96
+ const rightText = right ? theme.fg("dim", rightPlain) : "";
97
+ return `${border("┏")}${ansiGreen(titlePlain)}${border("━".repeat(fill))}${rightText}${border("┓")}`;
98
+ };
99
+ const bottom = () => border(`┗${"━".repeat(frameInner)}┛`);
100
+ return { bottom, divider, empty, filledRow, inner, row, selectedRow, top };
101
+ }
102
+
103
+ function isPrintableInput(data: string): boolean {
104
+ return data.length >= 1 && data.charCodeAt(0) >= 32 && !data.startsWith("\x1b") && data !== "\x7f";
105
+ }
106
+
107
+ export class QolSessionSearchComponent {
108
+ private screen: "search" | "messages" | "actions" | "confirmContext" | "confirmFork" | "confirmModel" = "search";
109
+ private searchState: QolSessionSearchState;
110
+ private messagesState: QolSessionMessagesState | undefined;
111
+ private actionState: QolSessionActionState | undefined;
112
+ private actionReturnScreen: "search" | "messages" = "messages";
113
+ private contextConfirmState: QolSessionContextConfirmState | undefined;
114
+ private forkConfirmState: QolSessionForkConfirmState | undefined;
115
+ private confirmModelState: QolSessionConfirmModelState | undefined;
116
+
117
+
118
+ constructor(
119
+ private readonly done: (action: QolSessionPaletteAction) => void,
120
+ private readonly tui: { requestRender(): void; terminal?: { rows?: number } },
121
+ private readonly theme: Theme,
122
+ private readonly sessions: QolSessionSearchSession[],
123
+ private readonly cwd: string,
124
+ initialQuery = "",
125
+ private readonly currentModel: QolModelInfo | undefined = undefined,
126
+ ) {
127
+ const query = initialQuery.trim();
128
+ const scope = defaultSessionSearchScope(cwd);
129
+ const scopedSessions = this.sessionsForScope(scope);
130
+ this.searchState = {
131
+ cursor: query.length,
132
+ query,
133
+ results: searchQolSessionHits(scopedSessions, query, cwd),
134
+ selected: 0,
135
+ scope,
136
+ total: scopedSessions.length,
137
+ };
138
+ }
139
+
140
+ invalidate(): void {}
141
+
142
+ private maxOverlayRows(): number {
143
+ const terminalRows = Number(this.tui.terminal?.rows ?? process.stdout.rows ?? 30);
144
+ const safeRows = Number.isFinite(terminalRows) && terminalRows > 0 ? terminalRows : 30;
145
+ return Math.max(9, Math.floor(safeRows * SESSION_SEARCH_OVERLAY_HEIGHT_RATIO));
146
+ }
147
+
148
+ private compactSearchLayout(): boolean {
149
+ return this.maxOverlayRows() < 18;
150
+ }
151
+
152
+ private searchMaxVisibleRows(): number {
153
+ const configured = Math.max(1, Math.floor(settingNumber("sessionSearch.maxVisible", 8, this.cwd)));
154
+ // Each hit is two rows plus a separator between hits. Chrome is aggressively
155
+ // reduced on short terminals so the popup can shrink to one visible hit.
156
+ const chromeRows = this.compactSearchLayout() ? 7 : 11;
157
+ const responsive = Math.max(1, Math.floor((this.maxOverlayRows() - chromeRows) / 3));
158
+ return Math.max(1, Math.min(configured, responsive));
159
+ }
160
+
161
+ private messageMaxVisibleRows(): number {
162
+ const configured = Math.max(1, Math.floor(settingNumber("sessionSearch.messageMaxVisible", 12, this.cwd)));
163
+ // Message rows are one line each; reserve space for session metadata,
164
+ // optional scroll status, footer, and frame.
165
+ const responsive = Math.max(1, this.maxOverlayRows() - 14);
166
+ return Math.max(1, Math.min(configured, responsive));
167
+ }
168
+
169
+ private forkConfirmMaxVisibleRows(): number {
170
+ const configured = Math.max(1, Math.floor(settingNumber("sessionSearch.messageMaxVisible", 12, this.cwd)));
171
+ const responsive = Math.max(1, this.maxOverlayRows() - 13);
172
+ return Math.max(1, Math.min(configured, responsive));
173
+ }
174
+
175
+ render(width: number): string[] {
176
+ const configured = Math.max(70, Math.floor(settingNumber("sessionSearch.overlayWidth", 104, this.cwd)));
177
+ const renderWidth = Math.min(Math.max(48, width), configured);
178
+ if (this.screen === "messages" && this.messagesState) return this.renderMessages(renderWidth, this.messagesState);
179
+ if (this.screen === "actions" && this.actionState) return this.renderActions(renderWidth, this.actionState);
180
+ if (this.screen === "confirmContext" && this.contextConfirmState) return this.renderContextConfirm(renderWidth, this.contextConfirmState);
181
+ if (this.screen === "confirmFork" && this.forkConfirmState) return this.renderForkConfirm(renderWidth, this.forkConfirmState);
182
+ if (this.screen === "confirmModel" && this.confirmModelState) return this.renderConfirmModel(renderWidth, this.confirmModelState);
183
+ return this.renderSearch(renderWidth);
184
+ }
185
+
186
+ handleInput(data: string): void {
187
+ if (this.screen === "messages" && this.messagesState) this.handleMessagesInput(data);
188
+ else if (this.screen === "actions" && this.actionState) this.handleActionInput(data);
189
+ else if (this.screen === "confirmContext" && this.contextConfirmState) this.handleContextConfirmInput(data);
190
+ else if (this.screen === "confirmFork" && this.forkConfirmState) this.handleForkConfirmInput(data);
191
+ else if (this.screen === "confirmModel" && this.confirmModelState) this.handleConfirmModelInput(data);
192
+ else {
193
+ this.screen = "search";
194
+ this.handleSearchInput(data);
195
+ }
196
+ this.tui.requestRender();
197
+ }
198
+
199
+ private clampSelection(): void {
200
+ this.searchState.selected = Math.max(0, Math.min(this.searchState.selected, Math.max(0, this.searchState.results.length - 1)));
201
+ }
202
+
203
+ private sessionsForScope(scope = this.searchState.scope): QolSessionSearchSession[] {
204
+ if (scope === "all") return this.sessions;
205
+ return this.sessions.filter((session) => sameSessionSearchProject(session.cwd, this.cwd));
206
+ }
207
+
208
+ private updateResults(resetSelection = true): void {
209
+ const state = this.searchState;
210
+ const scopedSessions = this.sessionsForScope(state.scope);
211
+ state.results = searchQolSessionHits(scopedSessions, state.query, this.cwd);
212
+ state.total = scopedSessions.length;
213
+ if (resetSelection) state.selected = 0;
214
+ this.clampSelection();
215
+ }
216
+
217
+ private toggleScope(): void {
218
+ this.searchState.scope = this.searchState.scope === "current" ? "all" : "current";
219
+ this.updateResults(true);
220
+ }
221
+
222
+ private updateQuery(query: string, cursor: number): void {
223
+ this.searchState.query = query;
224
+ this.searchState.cursor = Math.max(0, Math.min(cursor, query.length));
225
+ this.updateResults(true);
226
+ }
227
+
228
+ private selectedMessageIndex(messages: QolSessionUserMessage[], query: string): number {
229
+ const trimmed = query.trim().toLowerCase();
230
+ if (trimmed) {
231
+ const firstToken = trimmed.replace(/^re:/, "").replace(/["']/g, "").split(/\s+/).find(Boolean);
232
+ if (firstToken) {
233
+ const found = messages.findIndex((message) => message.text.toLowerCase().includes(firstToken));
234
+ if (found >= 0) return found;
235
+ }
236
+ }
237
+ return Math.max(0, messages.length - 1);
238
+ }
239
+
240
+ private openMessages(result: QolSessionSearchResult, preferredMessage?: QolSessionUserMessage): void {
241
+ const messages = userMessagesForResult(result);
242
+ const preferredIndex = preferredMessage
243
+ ? messages.findIndex((message) => message.index === preferredMessage.index || (message.entryId && message.entryId === preferredMessage.entryId))
244
+ : -1;
245
+ this.messagesState = {
246
+ messages,
247
+ result,
248
+ selected: preferredIndex >= 0 ? preferredIndex : this.selectedMessageIndex(messages, this.searchState.query),
249
+ };
250
+ this.screen = "messages";
251
+ }
252
+
253
+ private selectedFocusText(message: QolSessionUserMessage): string {
254
+ return `Focus on prompt #${message.index}: ${message.text}`;
255
+ }
256
+
257
+ private openContextConfirm(type: "summarize" | "newSession", result: QolSessionSearchResult, message: QolSessionUserMessage, returnScreen: "search" | "messages" | "actions"): void {
258
+ this.contextConfirmState = { message, result, returnScreen, type };
259
+ this.screen = "confirmContext";
260
+ }
261
+
262
+ private returnFromContextConfirm(state: QolSessionContextConfirmState): void {
263
+ this.contextConfirmState = undefined;
264
+ if (state.returnScreen === "actions" && this.actionState) this.screen = "actions";
265
+ else if (state.returnScreen === "messages" && this.messagesState) this.screen = "messages";
266
+ else this.screen = "search";
267
+ }
268
+
269
+ private confirmContextAction(state: QolSessionContextConfirmState): void {
270
+ this.done({ customPrompt: this.selectedFocusText(state.message), message: state.message, result: state.result, type: state.type });
271
+ }
272
+
273
+ private openForkConfirm(result: QolSessionSearchResult, message: QolSessionUserMessage, returnScreen: "search" | "messages" | "actions"): void {
274
+ const messages = userMessagesForResult(result);
275
+ const preferredIndex = messages.findIndex((candidate) => candidate.index === message.index || (message.entryId && candidate.entryId === message.entryId));
276
+ this.forkConfirmState = {
277
+ messages,
278
+ result,
279
+ returnScreen,
280
+ selected: preferredIndex >= 0 ? preferredIndex : 0,
281
+ };
282
+ this.screen = "confirmFork";
283
+ }
284
+
285
+ private returnFromForkConfirm(state: QolSessionForkConfirmState): void {
286
+ this.forkConfirmState = undefined;
287
+ if (state.returnScreen === "actions" && this.actionState) this.screen = "actions";
288
+ else if (state.returnScreen === "messages" && this.messagesState) this.screen = "messages";
289
+ else this.screen = "search";
290
+ }
291
+
292
+ private confirmForkAction(state: QolSessionForkConfirmState): void {
293
+ const message = state.messages[state.selected];
294
+ if (!message) return;
295
+ this.done({ message, result: state.result, type: "fork" });
296
+ }
297
+
298
+ private startResume(result: QolSessionSearchResult, returnScreen: "search" | "messages" | "actions"): void {
299
+ const previousModel = sessionModelInfo(result.path);
300
+ const current = this.currentModel;
301
+ if (previousModel && current && !sameModel(previousModel, current)) {
302
+ this.confirmModelState = { result, returnScreen, selected: 0, previousModel, currentModel: current };
303
+ this.screen = "confirmModel";
304
+ return;
305
+ }
306
+ this.done({ result, type: "resume" });
307
+ }
308
+
309
+ private returnFromConfirmModel(state: QolSessionConfirmModelState): void {
310
+ this.confirmModelState = undefined;
311
+ if (state.returnScreen === "actions" && this.actionState) this.screen = "actions";
312
+ else if (state.returnScreen === "messages" && this.messagesState) this.screen = "messages";
313
+ else this.screen = "search";
314
+ }
315
+
316
+ private confirmModelAction(state: QolSessionConfirmModelState): void {
317
+ this.done({ keepCurrentModel: state.selected === 1, result: state.result, type: "resume" });
318
+ }
319
+
320
+ private returnFromActions(): void {
321
+ this.actionState = undefined;
322
+ if (this.actionReturnScreen === "messages" && this.messagesState) this.screen = "messages";
323
+ else this.screen = "search";
324
+ }
325
+
326
+ private handleSearchInput(data: string): void {
327
+ const state = this.searchState;
328
+ if (matchesKey(data, "escape")) {
329
+ this.done({ type: "cancel" });
330
+ return;
331
+ }
332
+ if (matchesKey(data, "tab")) {
333
+ this.toggleScope();
334
+ return;
335
+ }
336
+ if (matchesKey(data, "return") || matchesKey(data, "enter")) {
337
+ const hit = state.results[state.selected];
338
+ if (!hit) return;
339
+ this.openMessages(hit.result, hit.message);
340
+ return;
341
+ }
342
+ if (matchesKey(data, "alt+c")) {
343
+ const hit = state.results[state.selected];
344
+ if (hit) this.done({ message: hit.message, result: hit.result, type: "copy" });
345
+ return;
346
+ }
347
+ if (matchesKey(data, "alt+f")) {
348
+ const hit = state.results[state.selected];
349
+ if (hit) this.openForkConfirm(hit.result, hit.message, "search");
350
+ return;
351
+ }
352
+ if (matchesKey(data, "alt+r")) {
353
+ const hit = state.results[state.selected];
354
+ if (hit) this.startResume(hit.result, "search");
355
+ return;
356
+ }
357
+ if (matchesKey(data, "alt+i")) {
358
+ const hit = state.results[state.selected];
359
+ if (hit) this.openContextConfirm("summarize", hit.result, hit.message, "search");
360
+ return;
361
+ }
362
+ if (matchesKey(data, "alt+n")) {
363
+ const hit = state.results[state.selected];
364
+ if (hit) this.openContextConfirm("newSession", hit.result, hit.message, "search");
365
+ return;
366
+ }
367
+ if (matchesKey(data, "alt+a")) {
368
+ const hit = state.results[state.selected];
369
+ if (hit) {
370
+ this.actionState = { message: hit.message, result: hit.result };
371
+ this.actionReturnScreen = "search";
372
+ this.screen = "actions";
373
+ }
374
+ return;
375
+ }
376
+ if (matchesKey(data, "up")) {
377
+ state.selected = Math.max(0, state.selected - 1);
378
+ return;
379
+ }
380
+ if (matchesKey(data, "down")) {
381
+ state.selected = Math.min(Math.max(0, state.results.length - 1), state.selected + 1);
382
+ return;
383
+ }
384
+ if (matchesKey(data, "-") || matchesKey(data, "pageup")) {
385
+ state.selected = Math.max(0, state.selected - this.searchMaxVisibleRows());
386
+ return;
387
+ }
388
+ if (matchesKey(data, "=") || matchesKey(data, "pagedown")) {
389
+ state.selected = Math.min(Math.max(0, state.results.length - 1), state.selected + this.searchMaxVisibleRows());
390
+ return;
391
+ }
392
+ if (matchesKey(data, "left")) {
393
+ state.cursor = Math.max(0, state.cursor - 1);
394
+ return;
395
+ }
396
+ if (matchesKey(data, "right")) {
397
+ state.cursor = Math.min(state.query.length, state.cursor + 1);
398
+ return;
399
+ }
400
+ if (matchesKey(data, "home") || matchesKey(data, "ctrl+a")) {
401
+ state.cursor = 0;
402
+ return;
403
+ }
404
+ if (matchesKey(data, "end") || matchesKey(data, "ctrl+e")) {
405
+ state.cursor = state.query.length;
406
+ return;
407
+ }
408
+ if (matchesKey(data, "ctrl+u")) {
409
+ this.updateQuery("", 0);
410
+ return;
411
+ }
412
+ if (matchesKey(data, "ctrl+w") || matchesKey(data, "alt+backspace")) {
413
+ const before = state.query.slice(0, state.cursor);
414
+ const after = state.query.slice(state.cursor);
415
+ let i = before.length;
416
+ while (i > 0 && /\s/.test(before[i - 1]!)) i -= 1;
417
+ while (i > 0 && !/\s/.test(before[i - 1]!)) i -= 1;
418
+ this.updateQuery(`${before.slice(0, i)}${after}`, i);
419
+ return;
420
+ }
421
+ if (matchesKey(data, "backspace")) {
422
+ if (state.cursor > 0) this.updateQuery(`${state.query.slice(0, state.cursor - 1)}${state.query.slice(state.cursor)}`, state.cursor - 1);
423
+ return;
424
+ }
425
+ if (matchesKey(data, "delete")) {
426
+ if (state.cursor < state.query.length) this.updateQuery(`${state.query.slice(0, state.cursor)}${state.query.slice(state.cursor + 1)}`, state.cursor);
427
+ return;
428
+ }
429
+ if (isPrintableInput(data)) this.updateQuery(`${state.query.slice(0, state.cursor)}${data}${state.query.slice(state.cursor)}`, state.cursor + data.length);
430
+ }
431
+
432
+ private handleMessagesInput(data: string): void {
433
+ const state = this.messagesState;
434
+ if (!state) return;
435
+ if (matchesKey(data, "escape") || matchesKey(data, "backspace")) {
436
+ this.screen = "search";
437
+ return;
438
+ }
439
+ if (matchesKey(data, "return") || matchesKey(data, "enter")) {
440
+ const message = state.messages[state.selected];
441
+ if (!message) return;
442
+ this.actionState = { message, result: state.result };
443
+ this.actionReturnScreen = "messages";
444
+ this.screen = "actions";
445
+ return;
446
+ }
447
+ if (matchesKey(data, "alt+r")) {
448
+ this.startResume(state.result, "messages");
449
+ return;
450
+ }
451
+ if (matchesKey(data, "alt+c")) {
452
+ const message = state.messages[state.selected];
453
+ if (message) this.done({ message, result: state.result, type: "copy" });
454
+ return;
455
+ }
456
+ if (matchesKey(data, "alt+f")) {
457
+ const message = state.messages[state.selected];
458
+ if (message) this.openForkConfirm(state.result, message, "messages");
459
+ return;
460
+ }
461
+ if (matchesKey(data, "alt+i")) {
462
+ const message = state.messages[state.selected];
463
+ if (message) this.openContextConfirm("summarize", state.result, message, "messages");
464
+ return;
465
+ }
466
+ if (matchesKey(data, "alt+n")) {
467
+ const message = state.messages[state.selected];
468
+ if (message) this.openContextConfirm("newSession", state.result, message, "messages");
469
+ return;
470
+ }
471
+ if (matchesKey(data, "up")) {
472
+ state.selected = Math.max(0, state.selected - 1);
473
+ return;
474
+ }
475
+ if (matchesKey(data, "down")) {
476
+ state.selected = Math.min(state.messages.length - 1, state.selected + 1);
477
+ return;
478
+ }
479
+ if (matchesKey(data, "-") || matchesKey(data, "pageup")) {
480
+ state.selected = Math.max(0, state.selected - this.messageMaxVisibleRows());
481
+ return;
482
+ }
483
+ if (matchesKey(data, "=") || matchesKey(data, "pagedown")) {
484
+ state.selected = Math.min(state.messages.length - 1, state.selected + this.messageMaxVisibleRows());
485
+ return;
486
+ }
487
+ if (matchesKey(data, "home") || matchesKey(data, "ctrl+a")) {
488
+ state.selected = 0;
489
+ return;
490
+ }
491
+ if (matchesKey(data, "end") || matchesKey(data, "ctrl+e")) {
492
+ state.selected = Math.max(0, state.messages.length - 1);
493
+ }
494
+ }
495
+
496
+ private handleActionInput(data: string): void {
497
+ const state = this.actionState;
498
+ if (!state) return;
499
+ if (matchesKey(data, "escape") || matchesKey(data, "backspace")) {
500
+ this.returnFromActions();
501
+ return;
502
+ }
503
+ if (matchesKey(data, "alt+c")) {
504
+ this.done({ message: state.message, result: state.result, type: "copy" });
505
+ return;
506
+ }
507
+ if (matchesKey(data, "alt+f")) {
508
+ this.openForkConfirm(state.result, state.message, "actions");
509
+ return;
510
+ }
511
+ if (matchesKey(data, "alt+r")) {
512
+ this.startResume(state.result, "actions");
513
+ return;
514
+ }
515
+ if (matchesKey(data, "alt+i")) {
516
+ this.openContextConfirm("summarize", state.result, state.message, "actions");
517
+ return;
518
+ }
519
+ if (matchesKey(data, "alt+n")) {
520
+ this.openContextConfirm("newSession", state.result, state.message, "actions");
521
+ return;
522
+ }
523
+ }
524
+
525
+ private handleContextConfirmInput(data: string): void {
526
+ const state = this.contextConfirmState;
527
+ if (!state) return;
528
+ if (matchesKey(data, "escape") || matchesKey(data, "backspace")) {
529
+ this.returnFromContextConfirm(state);
530
+ return;
531
+ }
532
+ if (matchesKey(data, "return") || matchesKey(data, "enter")) {
533
+ this.confirmContextAction(state);
534
+ }
535
+ }
536
+
537
+ private handleConfirmModelInput(data: string): void {
538
+ const state = this.confirmModelState;
539
+ if (!state) return;
540
+ if (matchesKey(data, "escape") || matchesKey(data, "backspace")) {
541
+ this.returnFromConfirmModel(state);
542
+ return;
543
+ }
544
+ if (matchesKey(data, "up") || matchesKey(data, "down")) {
545
+ state.selected = state.selected === 0 ? 1 : 0;
546
+ return;
547
+ }
548
+ if (matchesKey(data, "return") || matchesKey(data, "enter")) {
549
+ this.confirmModelAction(state);
550
+ }
551
+ }
552
+
553
+ private handleForkConfirmInput(data: string): void {
554
+ const state = this.forkConfirmState;
555
+ if (!state) return;
556
+ if (matchesKey(data, "escape") || matchesKey(data, "backspace")) {
557
+ this.returnFromForkConfirm(state);
558
+ return;
559
+ }
560
+ if (matchesKey(data, "return") || matchesKey(data, "enter")) {
561
+ this.confirmForkAction(state);
562
+ return;
563
+ }
564
+ if (matchesKey(data, "up")) {
565
+ state.selected = Math.max(0, state.selected - 1);
566
+ return;
567
+ }
568
+ if (matchesKey(data, "down")) {
569
+ state.selected = Math.min(Math.max(0, state.messages.length - 1), state.selected + 1);
570
+ return;
571
+ }
572
+ if (matchesKey(data, "-") || matchesKey(data, "pageup")) {
573
+ state.selected = Math.max(0, state.selected - this.forkConfirmMaxVisibleRows());
574
+ return;
575
+ }
576
+ if (matchesKey(data, "=") || matchesKey(data, "pagedown")) {
577
+ state.selected = Math.min(Math.max(0, state.messages.length - 1), state.selected + this.forkConfirmMaxVisibleRows());
578
+ return;
579
+ }
580
+ if (matchesKey(data, "home") || matchesKey(data, "ctrl+a")) {
581
+ state.selected = 0;
582
+ return;
583
+ }
584
+ if (matchesKey(data, "end") || matchesKey(data, "ctrl+e")) {
585
+ state.selected = Math.max(0, state.messages.length - 1);
586
+ }
587
+ }
588
+
589
+ private renderSearch(width: number): string[] {
590
+ const { bottom, divider, empty, filledRow, inner, row, top } = boxParts(width, this.theme);
591
+ const state = this.searchState;
592
+ const compact = this.compactSearchLayout();
593
+ const lines: string[] = [top("Session Search", `${state.results.length} hits`), empty()];
594
+ const accent = (s: string) => this.theme.fg("accent", s);
595
+ const dim = (s: string) => this.theme.fg("dim", s);
596
+ const muted = (s: string) => this.theme.fg("muted", s);
597
+ const cursorChar = state.query[state.cursor] ?? " ";
598
+ const queryDisplay = `${state.query.slice(0, state.cursor)}${this.theme.inverse(cursorChar)}${state.query.slice(state.cursor + (state.cursor < state.query.length ? 1 : 0))}`;
599
+ lines.push(row(this.renderScopeTabs(inner)));
600
+ if (!compact) lines.push(empty());
601
+ lines.push(filledRow(` > ${queryDisplay}`));
602
+ if (!compact) lines.push(row(dim(`tokens · re:<pattern> regex · "phrase" exact`)));
603
+ lines.push(divider());
604
+
605
+ if (state.results.length === 0) {
606
+ lines.push(row(muted(state.query.trim() ? "No prompts match your search" : "No prompts found")));
607
+ } else {
608
+ lines.push(...this.renderSearchHitPane(inner, row, dim, muted, accent));
609
+ }
610
+
611
+ lines.push(divider());
612
+ if (compact) {
613
+ lines.push(row(`${ansiYellow("alt+c/f/r/i/n/a")} ${dim("actions")} ${ansiYellow("tab")} ${dim("scope")}`));
614
+ } else {
615
+ lines.push(row(`${ansiYellow("-/=")} ${dim("page")} ${ansiYellow("alt+c")} ${dim("copy")} ${ansiYellow("alt+f")} ${dim("fork")} ${ansiYellow("alt+r")} ${dim("resume")}`));
616
+ lines.push(row(`${ansiYellow("alt+i")} ${dim("inject+ctx")} ${ansiYellow("alt+n")} ${dim("new+ctx")} ${ansiYellow("alt+a")} ${dim("actions")} ${ansiYellow("tab")} ${dim("scope")}`));
617
+ }
618
+ lines.push(bottom());
619
+ return lines;
620
+ }
621
+
622
+ private renderSearchHitPane(
623
+ inner: number,
624
+ row: (content?: string) => string,
625
+ dim: (s: string) => string,
626
+ muted: (s: string) => string,
627
+ accent: (s: string) => string,
628
+ ): string[] {
629
+ const state = this.searchState;
630
+ const compact = this.compactSearchLayout();
631
+ const maxVisible = this.searchMaxVisibleRows();
632
+ const start = Math.max(0, Math.min(state.selected - Math.floor(maxVisible / 2), state.results.length - maxVisible));
633
+ const end = Math.min(start + maxVisible, state.results.length);
634
+ const gap = ` ${dim("│")} `;
635
+ const twoPane = inner >= 72;
636
+ const rightWidth = twoPane ? Math.max(24, Math.floor(inner * 0.43)) : 0;
637
+ const leftWidth = twoPane ? Math.max(24, inner - rightWidth - visibleWidth(gap)) : inner;
638
+ const paneRows = Math.max(1, (end - start) * 3 - 1);
639
+ const selectedHit = state.results[state.selected];
640
+ const rightLines = twoPane ? this.renderPromptPreviewLines(selectedHit, rightWidth, paneRows, dim, muted, accent) : [];
641
+ const fixed = (content: string, colWidth: number, selected = false): string => {
642
+ const safe = content.replace(/[\r\n\t]+/g, " ");
643
+ const clipped = truncateToWidth(safe, colWidth, "");
644
+ const padded = clipped + " ".repeat(Math.max(0, colWidth - visibleWidth(clipped)));
645
+ return selected ? this.theme.bg("selectedBg", padded) : padded;
646
+ };
647
+ const leftLines: string[] = [];
648
+ for (let i = start; i < end; i++) {
649
+ const hit = state.results[i]!;
650
+ const selected = i === state.selected;
651
+ const promptDate = new Date(promptRecencyTime(hit));
652
+ const metadata = dim(`#${hit.message.index} · ${formatSessionSearchDate(promptDate)}`);
653
+ const titleText = truncateToWidth(sessionResumeTitle(hit.result), Math.max(8, leftWidth - visibleWidth(metadata) - 1), "…");
654
+ const title = accent(titleText);
655
+ const snippet = styleSessionSnippet(hit.snippet || hit.message.text, state.query, this.theme);
656
+ leftLines.push(fixed(selected ? this.theme.bold(snippet) : snippet, leftWidth, selected));
657
+ leftLines.push(fixed(`${title} ${metadata}`, leftWidth, selected));
658
+ if (i < end - 1) leftLines.push(fixed(dim("─".repeat(Math.max(8, leftWidth))), leftWidth));
659
+ }
660
+ const output: string[] = [];
661
+ for (let i = 0; i < leftLines.length; i++) {
662
+ if (twoPane) output.push(row(`${leftLines[i] ?? ""}${gap}${rightLines[i] ?? " ".repeat(rightWidth)}`));
663
+ else output.push(row(leftLines[i] ?? ""));
664
+ }
665
+ if (!twoPane && selectedHit) {
666
+ output.push(row(dim("─".repeat(Math.max(8, inner)))));
667
+ for (const line of this.renderPromptPreviewLines(selectedHit, inner, Math.min(5, paneRows), dim, muted, accent)) output.push(row(line));
668
+ }
669
+ if (!compact && state.results.length > maxVisible) output.push(row(dim(`${state.selected + 1}/${state.results.length} ${state.query.trim() ? "matches" : "recent prompts"}`)));
670
+ return output;
671
+ }
672
+
673
+ private renderPromptPreviewLines(
674
+ hit: QolSessionSearchHit | undefined,
675
+ width: number,
676
+ maxLines: number,
677
+ dim: (s: string) => string,
678
+ muted: (s: string) => string,
679
+ accent: (s: string) => string,
680
+ ): string[] {
681
+ const fixed = (content: string): string => {
682
+ const clipped = truncateToWidth(content.replace(/[\r\n\t]+/g, " "), width, "…");
683
+ return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
684
+ };
685
+ if (!hit) return Array.from({ length: maxLines }, () => fixed(""));
686
+ const title = accent("Prompt preview");
687
+ const cwd = muted(truncateToWidth(shortPathForUi(hit.result.cwd || hit.result.path), width, "…"));
688
+ const bodyBudget = Math.max(1, maxLines - 3);
689
+ const body = wrapVisible(styleSessionSnippet(hit.message.text, this.searchState.query, this.theme), width, bodyBudget);
690
+ const lines = [fixed(title), fixed(cwd), fixed(dim("─".repeat(Math.max(8, width)))), ...body.map(fixed)];
691
+ while (lines.length < maxLines) lines.push(fixed(""));
692
+ return lines.slice(0, maxLines);
693
+ }
694
+
695
+ private renderScopeTabs(inner: number): string {
696
+ const tabs: { id: QolSessionSearchScope; label: string }[] = [
697
+ { id: "current", label: "Project" },
698
+ { id: "all", label: "All" },
699
+ ];
700
+ const parts = tabs.map((tab) => {
701
+ const label = ` ${truncateToWidth(tab.label, 18, "…")} `;
702
+ if (tab.id === this.searchState.scope) return this.theme.fg("accent", this.theme.inverse(this.theme.bold(label)));
703
+ return this.theme.bg("selectedBg", this.theme.fg("accent", label));
704
+ });
705
+ return truncateToWidth(parts.join(" "), inner, "");
706
+ }
707
+
708
+ private renderMessages(width: number, state: QolSessionMessagesState): string[] {
709
+ const { bottom, divider, empty, inner, row, selectedRow, top } = boxParts(width, this.theme);
710
+ const accent = (s: string) => this.theme.fg("accent", s);
711
+ const dim = (s: string) => this.theme.fg("dim", s);
712
+ const muted = (s: string) => this.theme.fg("muted", s);
713
+ const result = state.result;
714
+ const lines: string[] = [top("Session Prompts", `${state.selected + 1}/${state.messages.length}`), empty()];
715
+ const pair = (left: string, right: string) => {
716
+ const leftWidth = Math.max(1, inner - visibleWidth(right) - 1);
717
+ const clippedLeft = truncateToWidth(left, leftWidth, "…");
718
+ const gap = Math.max(1, inner - visibleWidth(clippedLeft) - visibleWidth(right));
719
+ return `${clippedLeft}${" ".repeat(gap)}${right}`;
720
+ };
721
+ const right = dim(formatSessionSearchDate(result.modified));
722
+ const title = truncateToWidth(sessionResumeTitle(result), Math.max(12, inner - visibleWidth(right) - 1), "…");
723
+ lines.push(row(pair(this.theme.bold(accent(title)), right)));
724
+ lines.push(row(muted(truncateToWidth(shortPathForUi(result.cwd || result.path), inner, "…"))));
725
+ lines.push(empty(), divider(), empty());
726
+
727
+ const maxVisible = this.messageMaxVisibleRows();
728
+ const start = Math.max(0, Math.min(state.selected - Math.floor(maxVisible / 2), state.messages.length - maxVisible));
729
+ const end = Math.min(start + maxVisible, state.messages.length);
730
+ for (let i = start; i < end; i++) {
731
+ const message = state.messages[i]!;
732
+ const selected = i === state.selected;
733
+ const numberPlain = `#${message.index}`;
734
+ const number = selected ? this.theme.fg("text", numberPlain) : dim(numberPlain);
735
+ const textWidth = Math.max(12, inner - visibleWidth(number) - 1);
736
+ const text = truncateToWidth(message.text, textWidth, "…");
737
+ const messageRow = `${number} ${selected ? this.theme.bold(accent(text)) : text}`;
738
+ lines.push(selected ? selectedRow(messageRow) : row(messageRow));
739
+ }
740
+ lines.push(divider(), empty());
741
+ lines.push(row(`${ansiYellow("-/=")} ${dim("page")} ${ansiYellow("alt+c")} ${dim("copy")} ${ansiYellow("alt+f")} ${dim("fork")} ${ansiYellow("alt+r")} ${dim("resume")}`));
742
+ lines.push(row(`${ansiYellow("alt+i")} ${dim("inject+ctx")} ${ansiYellow("alt+n")} ${dim("new+ctx")}`));
743
+ lines.push(bottom());
744
+ return lines;
745
+ }
746
+
747
+ private renderActions(width: number, state: QolSessionActionState): string[] {
748
+ const { bottom, divider, empty, inner, row, top } = boxParts(width, this.theme);
749
+ const accent = (s: string) => this.theme.fg("accent", s);
750
+ const dim = (s: string) => this.theme.fg("dim", s);
751
+ const muted = (s: string) => this.theme.fg("muted", s);
752
+ const lines: string[] = [top("Prompt Actions"), empty()];
753
+ const title = truncateToWidth(sessionResumeTitle(state.result), Math.max(12, inner - visibleWidth(`#${state.message.index}`) - 2), "…");
754
+ lines.push(row(`${this.theme.bold(accent(title))} ${dim(`#${state.message.index}`)}`));
755
+ lines.push(row(muted(truncateToWidth(shortPathForUi(state.result.cwd || state.result.path), inner, "…"))));
756
+ lines.push(empty(), divider(), empty());
757
+ lines.push(row(dim("Selected prompt")));
758
+ const wrapped = wrapVisible(state.message.text, inner, 4);
759
+ for (const line of wrapped) lines.push(row(line));
760
+ lines.push(empty(), divider(), empty());
761
+ const helpLines = [
762
+ "Fork session from here opens the source session at the point before this prompt and places the prompt in the editor; submit to branch from there.",
763
+ "Copy Prompt copies this prompt into your current editor.",
764
+ "Inject + Context summarizes the source session into the current session, focused on this prompt.",
765
+ "New + Context creates a fresh session and imports that focused summary there.",
766
+ ];
767
+ for (const help of helpLines) {
768
+ for (const line of wrapVisible(dim(help), inner, 2)) lines.push(row(line));
769
+ }
770
+ lines.push(empty(), divider(), empty());
771
+ lines.push(row(`${ansiYellow("alt+c")} ${dim("copy")} ${ansiYellow("alt+f")} ${dim("fork")} ${ansiYellow("alt+r")} ${dim("resume")}`));
772
+ lines.push(row(`${ansiYellow("alt+i")} ${dim("inject+ctx")} ${ansiYellow("alt+n")} ${dim("new+ctx")}`));
773
+ lines.push(bottom());
774
+ return lines;
775
+ }
776
+
777
+ private renderConfirmModel(width: number, state: QolSessionConfirmModelState): string[] {
778
+ const { bottom, divider, empty, inner, row, top } = boxParts(width, this.theme);
779
+ const accent = (s: string) => this.theme.fg("accent", s);
780
+ const dim = (s: string) => this.theme.fg("dim", s);
781
+ const muted = (s: string) => this.theme.fg("muted", s);
782
+ const warning = (s: string) => this.theme.fg("warning", s);
783
+ const previousLabel = modelLabel(state.previousModel);
784
+ const currentLabel = modelLabel(state.currentModel);
785
+ const lines: string[] = [top("Choose model"), empty()];
786
+ const title = truncateToWidth(sessionResumeTitle(state.result), inner, "…");
787
+ lines.push(row(this.theme.bold(warning("Model differs from current session"))));
788
+ lines.push(row(accent(`“${title}”`)));
789
+ lines.push(row(muted(truncateToWidth(shortPathForUi(state.result.cwd || state.result.path), inner, "…"))));
790
+ lines.push(empty());
791
+ lines.push(row(`${dim("Session model: ")}${warning(previousLabel)}`));
792
+ lines.push(row(`${dim("Current model: ")}${warning(currentLabel)}`));
793
+ lines.push(empty());
794
+ for (const help of [
795
+ "Previous model resumes exactly as saved.",
796
+ "Current model writes a model-change event before resume, so full context transfers and future turns use it.",
797
+ ]) {
798
+ for (const line of wrapVisible(dim(help), inner, 2)) lines.push(row(line));
799
+ }
800
+ lines.push(empty(), divider(), empty());
801
+ const optionRow = (index: 0 | 1, label: string, model: string): string => {
802
+ const selected = state.selected === index;
803
+ const prefix = selected ? "› " : " ";
804
+ const content = `${warning(prefix)}${selected ? this.theme.fg("text", label) : dim(label)}${dim(" — ")}${selected ? warning(model) : muted(model)}`;
805
+ const body = truncateToWidth(content, inner, "…") + " ".repeat(Math.max(0, inner - visibleWidth(truncateToWidth(content, inner, "…"))));
806
+ return selected ? this.theme.bg("selectedBg", body) : body;
807
+ };
808
+ lines.push(row(optionRow(0, "Continue with previous model", previousLabel)));
809
+ lines.push(row(optionRow(1, "Continue with current model", currentLabel)));
810
+ lines.push(empty(), divider(), empty());
811
+ lines.push(row(`${ansiYellow("↑/↓")} ${dim("choose")} ${ansiYellow("enter")} ${dim("resume")} ${ansiYellow("backspace")} ${dim("back")}`));
812
+ lines.push(bottom());
813
+ return lines;
814
+ }
815
+
816
+ private renderForkConfirm(width: number, state: QolSessionForkConfirmState): string[] {
817
+ const { bottom, divider, empty, inner, row, selectedRow, top } = boxParts(width, this.theme);
818
+ const accent = (s: string) => this.theme.fg("accent", s);
819
+ const dim = (s: string) => this.theme.fg("dim", s);
820
+ const muted = (s: string) => this.theme.fg("muted", s);
821
+ const lines: string[] = [top("Fork Session", `${state.selected + 1}/${state.messages.length}`), empty()];
822
+ const title = truncateToWidth(sessionResumeTitle(state.result), inner, "…");
823
+ lines.push(row(this.theme.bold(accent(title))));
824
+ lines.push(row(muted(truncateToWidth(shortPathForUi(state.result.cwd || state.result.path), inner, "…"))));
825
+ lines.push(empty(), divider(), empty());
826
+ for (const line of wrapVisible(dim("Choose the prompt to fork from. Pi will open the source session before that prompt and place the prompt in the editor."), inner, 2)) {
827
+ lines.push(row(line));
828
+ }
829
+ lines.push(empty());
830
+
831
+ const maxVisible = this.forkConfirmMaxVisibleRows();
832
+ const start = Math.max(0, Math.min(state.selected - Math.floor(maxVisible / 2), state.messages.length - maxVisible));
833
+ const end = Math.min(start + maxVisible, state.messages.length);
834
+ for (let i = start; i < end; i++) {
835
+ const message = state.messages[i]!;
836
+ const selected = i === state.selected;
837
+ const numberPlain = `#${message.index}`;
838
+ const number = selected ? this.theme.fg("text", numberPlain) : dim(numberPlain);
839
+ const textWidth = Math.max(12, inner - visibleWidth(number) - 1);
840
+ const text = truncateToWidth(message.text, textWidth, "…");
841
+ const messageRow = `${number} ${selected ? this.theme.bold(accent(text)) : text}`;
842
+ lines.push(selected ? selectedRow(messageRow) : row(messageRow));
843
+ }
844
+ if (state.messages.length > maxVisible) lines.push(row(dim(`${state.selected + 1}/${state.messages.length} prompts`)));
845
+ lines.push(divider(), empty());
846
+ lines.push(row(`${ansiYellow("backspace")} ${dim("back")}`));
847
+ lines.push(bottom());
848
+ return lines;
849
+ }
850
+
851
+ private renderContextConfirm(width: number, state: QolSessionContextConfirmState): string[] {
852
+ const { bottom, divider, empty, inner, row, top } = boxParts(width, this.theme);
853
+ const accent = (s: string) => this.theme.fg("accent", s);
854
+ const dim = (s: string) => this.theme.fg("dim", s);
855
+ const muted = (s: string) => this.theme.fg("muted", s);
856
+ const action = state.type === "newSession" ? "New + Context" : "Inject + Context";
857
+ const verb = state.type === "newSession" ? "create new session with context" : "inject context";
858
+ const lines: string[] = [top(action), empty()];
859
+ lines.push(row(`${accent(sessionResumeTitle(state.result))} ${dim(`#${state.message.index}`)}`));
860
+ lines.push(row(muted(truncateToWidth(shortPathForUi(state.result.cwd || state.result.path), inner, "…"))));
861
+ lines.push(empty(), divider(), empty());
862
+ const details = [
863
+ "This summarizes the entire source session, including messages after this prompt.",
864
+ "The selected prompt is used as the focus for the summary, not as a cutoff point.",
865
+ state.type === "newSession"
866
+ ? "New + Context creates a fresh session and imports that focused summary there."
867
+ : "Inject + Context adds that focused summary to your current session.",
868
+ ];
869
+ for (const detail of details) {
870
+ for (const line of wrapVisible(dim(detail), inner, 2)) lines.push(row(line));
871
+ }
872
+ lines.push(empty(), divider(), empty());
873
+ lines.push(row(`${ansiYellow("backspace")} ${dim("back")}`));
874
+ lines.push(bottom());
875
+ return lines;
876
+ }
877
+
878
+ }
879
+
880
+ export class QolSessionSearchLoadingComponent {
881
+ private readonly controller = new AbortController();
882
+ private readonly startedAt = Date.now();
883
+ private frame = 0;
884
+ private timer: ReturnType<typeof setInterval> | undefined;
885
+
886
+ constructor(
887
+ private readonly tui: { requestRender(): void },
888
+ private readonly theme: Theme,
889
+ private readonly title: string,
890
+ private readonly message: string,
891
+ private readonly done: (value: null) => void,
892
+ ) {
893
+ this.timer = setInterval(() => {
894
+ this.frame += 1;
895
+ this.tui.requestRender();
896
+ }, 180);
897
+ this.timer.unref?.();
898
+ }
899
+
900
+ get signal(): AbortSignal {
901
+ return this.controller.signal;
902
+ }
903
+
904
+ dispose(): void {
905
+ if (this.timer) clearInterval(this.timer);
906
+ this.timer = undefined;
907
+ }
908
+
909
+ invalidate(): void {}
910
+
911
+ handleInput(data: string): void {
912
+ if (!matchesKey(data, "escape") && !matchesKey(data, "ctrl+c")) return;
913
+ if (!this.controller.signal.aborted) this.controller.abort();
914
+ this.dispose();
915
+ this.done(null);
916
+ }
917
+
918
+ render(width: number): string[] {
919
+ const { bottom, divider, empty, row, top } = boxParts(width, this.theme);
920
+ const dim = (s: string) => this.theme.fg("dim", s);
921
+ const muted = (s: string) => this.theme.fg("muted", s);
922
+ const accent = (s: string) => this.theme.fg("accent", s);
923
+ const frames = ["|", "/", "-", "\\"];
924
+ const elapsed = `${Math.max(0, Math.floor((Date.now() - this.startedAt) / 1000))}s`;
925
+ const indicator = accent(frames[this.frame % frames.length]!);
926
+ return [
927
+ top(this.title, elapsed),
928
+ empty(),
929
+ row(`${indicator} ${muted(this.message)}`),
930
+ row(dim("Long sessions may take a few seconds to summarize.")),
931
+ empty(),
932
+ divider(),
933
+ empty(),
934
+ bottom(),
935
+ ];
936
+ }
937
+ }