@linxiraos/pi-tui 1.3.1 → 1.4.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,1901 @@
1
+ /**
2
+ * ANSI three-level resume tree: project → workspace → conversation.
3
+ *
4
+ * Renders the rows produced by the coding-agent resume view model; it never
5
+ * groups sessions itself. The model arrives through {@link ResumeTreeSource},
6
+ * which the host builds from `loadResumeDirectory` + `buildResumeView` (pi-tui
7
+ * cannot depend on the coding-agent package). Scope cycling, collapse state,
8
+ * search focus, pin actions and the delete confirmation live here; view
9
+ * construction and persistence live in the host.
10
+ */
11
+
12
+ import * as path from "node:path";
13
+ import { type Component, Input, matchesKey, padding, truncateToWidth, visibleWidth } from "../index";
14
+ import { tuiText, tuiTextFmt } from "../i18n";
15
+ import { matchesAppInterrupt, matchesSelectDown, matchesSelectUp } from "../keybinding-matchers";
16
+ import { routeSgrMouseInput, type SgrMouseEvent } from "../mouse";
17
+ import { theme } from "../theme/theme";
18
+ import type { ThemeColor } from "../theme/theme";
19
+
20
+ export type ResumeTreeScope = "workspace" | "project" | "all";
21
+
22
+ /** Persisted picker state (v2 file): scope plus local expansion. */
23
+ export interface ResumeTreePickerState {
24
+ scope: ResumeTreeScope;
25
+ collapsedProjects?: readonly string[];
26
+ expandedProjects?: readonly string[];
27
+ collapsedWorkspaces?: readonly string[];
28
+ /** Absolute paths in the project/workspace/cwd labels; absent means false. */
29
+ fullPaths?: boolean;
30
+ }
31
+ export type ResumeTreeStatus = "complete" | "interrupted" | "aborted" | "error" | "pending" | "unknown";
32
+ export type ResumeTreeDeleteMode = "trash" | "permanent";
33
+
34
+ /** The session fields the tree reads from a conversation row. */
35
+ export interface ResumeTreeSession {
36
+ path: string;
37
+ id: string;
38
+ cwd: string;
39
+ size?: number;
40
+ title?: string;
41
+ modified: Date;
42
+ firstMessage?: string;
43
+ }
44
+
45
+ export interface ResumeTreeConversation {
46
+ session: ResumeTreeSession;
47
+ key: string;
48
+ id: string;
49
+ path: string;
50
+ title: string;
51
+ cwd: string;
52
+ projectKey: string;
53
+ workspaceKey: string;
54
+ created: Date;
55
+ modified: Date;
56
+ status?: ResumeTreeStatus;
57
+ pinned: boolean;
58
+ current: boolean;
59
+ fork: boolean;
60
+ /** Subagent marker from `session_init.agent`; forks never set it. */
61
+ isSubagent?: boolean;
62
+ agentName?: string;
63
+ parentKey?: string;
64
+ /** Direct subagent children, when the directory attached them. */
65
+ subagents?: readonly ResumeTreeConversation[];
66
+ messageCount: number;
67
+ }
68
+
69
+ export interface ResumeTreeWorkspace {
70
+ key: string;
71
+ path: string;
72
+ name: string;
73
+ /** User-configured alias, or null. The directory name never changes. */
74
+ alias?: string | null;
75
+ /** Alias when set, otherwise `name`. */
76
+ displayName?: string;
77
+ projectKey: string;
78
+ branch: string | null;
79
+ pinned: boolean;
80
+ lastActivity: Date | null;
81
+ counts: { conversations: number; pinnedConversations: number };
82
+ }
83
+
84
+ export interface ResumeTreeProject {
85
+ key: string;
86
+ root: string;
87
+ name: string;
88
+ alias: string | null;
89
+ displayName: string;
90
+ isDefault: boolean;
91
+ pinned: boolean;
92
+ collapsed: boolean;
93
+ lastActivity: Date | null;
94
+ counts: { conversations: number; workspaces: number; pinnedConversations: number };
95
+ }
96
+
97
+ export interface ResumeTreeProjectRow {
98
+ type: "project";
99
+ key: string;
100
+ project: ResumeTreeProject;
101
+ pinned: boolean;
102
+ collapsed: boolean;
103
+ conversationCount: number;
104
+ totalConversationCount: number;
105
+ workspaceCount: number;
106
+ totalWorkspaceCount: number;
107
+ }
108
+
109
+ export interface ResumeTreeWorkspaceRow {
110
+ type: "workspace";
111
+ key: string;
112
+ workspace: ResumeTreeWorkspace;
113
+ projectKey: string;
114
+ pinned: boolean;
115
+ conversationCount: number;
116
+ totalConversationCount: number;
117
+ }
118
+
119
+ export interface ResumeTreeConversationRow {
120
+ type: "conversation";
121
+ key: string;
122
+ conversation: ResumeTreeConversation;
123
+ projectKey: string;
124
+ workspaceKey: string;
125
+ pinned: boolean;
126
+ current: boolean;
127
+ pinnedOnly?: boolean;
128
+ }
129
+
130
+ export type ResumeTreeRow = ResumeTreeProjectRow | ResumeTreeWorkspaceRow | ResumeTreeConversationRow;
131
+
132
+ export interface ResumeTreeView {
133
+ scope: { kind: ResumeTreeScope; projectKey?: string; workspaceKey?: string };
134
+ query: { raw: string; terms?: readonly string[] };
135
+ rows: readonly ResumeTreeRow[];
136
+ conversations?: readonly ResumeTreeConversationRow[];
137
+ counts: {
138
+ conversations: number;
139
+ workspaces?: number;
140
+ projects?: number;
141
+ totalConversations: number;
142
+ totalWorkspaces?: number;
143
+ totalProjects?: number;
144
+ };
145
+ /** Branch of the current session's workspace, when known. */
146
+ referenceBranch?: string | null;
147
+ /** Workspace key of the current session, when known. */
148
+ currentWorkspaceKey?: string;
149
+ }
150
+
151
+ /** Names and per-scope conversation totals for the scope header. */
152
+ export interface ResumeTreeScopeSummary {
153
+ workspaceName: string;
154
+ projectName: string;
155
+ counts: Record<ResumeTreeScope, number>;
156
+ }
157
+
158
+ /**
159
+ * Host bridge. `buildView` must be a pure projection of the loaded directory;
160
+ * `refresh`/`pin*`/`deleteConversation` perform I/O and resolve when the
161
+ * source's internal state is current again.
162
+ */
163
+ export interface ResumeTreeSource {
164
+ readonly initialScope: ResumeTreeScope;
165
+ buildView(input: { scope: ResumeTreeScope; query: string; currentSessionPath?: string }): ResumeTreeView | null;
166
+ scopeSummary(): ResumeTreeScopeSummary;
167
+ currentSessionPath?(): string | undefined;
168
+ /** Remember the chosen scope across opens. */
169
+ persistScope?(scope: ResumeTreeScope): void | Promise<void>;
170
+ /** Restored v2 picker state (scope + expansion); absent means defaults. */
171
+ pickerState?(): ResumeTreePickerState;
172
+ /** Persist scope + expansion together (preferred over `persistScope`). */
173
+ persistPickerState?(state: ResumeTreePickerState): void | Promise<void>;
174
+ /** Re-read the directory; resolves once `buildView` reflects the new state. */
175
+ refresh?(): Promise<void>;
176
+ /** Partial scan failures (unreadable project folders) while rows are still listed. */
177
+ scanWarningNames?(): readonly string[];
178
+ /** A failed load: no rows available at all. */
179
+ loadError?(): string | undefined;
180
+ isLoading?(): boolean;
181
+ pinConversation?(conversation: ResumeTreeConversation, pinned: boolean): Promise<void>;
182
+ pinProject?(project: ResumeTreeProject, pinned: boolean): Promise<void>;
183
+ pinWorkspace?(workspace: ResumeTreeWorkspace, pinned: boolean): Promise<void>;
184
+ /** Set the workspace display alias; resolves the name the picker now shows. */
185
+ renameWorkspace?(workspace: ResumeTreeWorkspace, alias: string | null): Promise<string | null>;
186
+ openWorkspace?(workspace: ResumeTreeWorkspace): void | Promise<void>;
187
+ /** Rename the persisted session title; resolves the stored name or null. */
188
+ renameConversation?(conversation: ResumeTreeConversation, title: string): Promise<string | null>;
189
+ /** False hides the conversation rename action (read-only/foreign session). */
190
+ canRenameConversation?(conversation: ResumeTreeConversation): boolean;
191
+ /** Scopes without a project header (workspace/project) resolve the project here. */
192
+ projectByKey?(key: string): ResumeTreeProject | undefined;
193
+ deleteConversation?(conversation: ResumeTreeConversation): Promise<boolean>;
194
+ deleteMode: ResumeTreeDeleteMode;
195
+ }
196
+
197
+ export interface ResumeTreeOptions {
198
+ source: ResumeTreeSource;
199
+ getTerminalRows?: () => number;
200
+ onSelect: (conversation: ResumeTreeConversation) => void;
201
+ onCancel: () => void;
202
+ onExit: () => void;
203
+ onRequestRender?: () => void;
204
+ /** Start with the search field focused (the default for a resume picker). */
205
+ searchFocused?: boolean;
206
+ }
207
+
208
+ const CURRENT_LABEL_KEY = "resumeCurrentLabel";
209
+ const PINNED_LABEL_KEY = "resumePinnedLabel";
210
+
211
+ function statusWord(status: ResumeTreeStatus | undefined): { text: string; tone: ThemeColor } | undefined {
212
+ switch (status) {
213
+ case "complete":
214
+ return { text: tuiText("ssStatusDone", "done"), tone: "success" };
215
+ case "interrupted":
216
+ return { text: tuiText("ssStatusInterrupted", "interrupted"), tone: "warning" };
217
+ case "aborted":
218
+ return { text: tuiText("ssStatusAborted", "aborted"), tone: "muted" };
219
+ case "error":
220
+ return { text: tuiText("ssStatusError", "error"), tone: "error" };
221
+ case "pending":
222
+ return { text: tuiText("ssStatusPending", "pending"), tone: "accent" };
223
+ default:
224
+ return undefined;
225
+ }
226
+ }
227
+
228
+ /** Short relative age, localised through the shared session-picker keys. */
229
+ export function formatResumeAge(date: Date, now: number = Date.now()): string {
230
+ const elapsed = Math.max(0, now - date.getTime());
231
+ if (elapsed < 60_000) return tuiText("ssJustNow", "just now");
232
+ const minutes = Math.floor(elapsed / 60_000);
233
+ if (minutes < 60) {
234
+ const plural = minutes === 1 ? "" : "s";
235
+ return tuiTextFmt("ssMinuteAgoFmt", "%s minute%s ago", minutes, plural);
236
+ }
237
+ const hours = Math.floor(minutes / 60);
238
+ if (hours < 24) {
239
+ const plural = hours === 1 ? "" : "s";
240
+ return tuiTextFmt("ssHourAgoFmt", "%s hour%s ago", hours, plural);
241
+ }
242
+ const days = Math.floor(hours / 24);
243
+ if (days === 1) return tuiText("ssDayAgo", "1 day ago");
244
+ return tuiTextFmt("ssDaysAgoFmt", "%s days ago", days);
245
+ }
246
+
247
+ /** Middle-ellipsis truncation for path-like text (`a/b/…/x/y`). */
248
+ export function middleEllipsis(text: string, maxWidth: number): string {
249
+ if (maxWidth <= 0) return "";
250
+ if (visibleWidth(text) <= maxWidth) return text;
251
+ if (maxWidth <= 3) return truncateToWidth(text, maxWidth);
252
+ const ellipsis = "…";
253
+ const room = maxWidth - visibleWidth(ellipsis);
254
+ const head = Math.ceil(room / 2);
255
+ const tail = Math.floor(room / 2);
256
+ return `${truncateToWidth(text, head)}${ellipsis}${takeTail(text, tail)}`;
257
+ }
258
+
259
+ /** Tail that starts at a path segment boundary when one is available. */
260
+ function takePathTail(text: string, width: number): string {
261
+ const tail = takeTail(text, width);
262
+ const separator = tail.search(/[\\/]/);
263
+ return separator > 0 ? tail.slice(separator) : tail;
264
+ }
265
+
266
+ /** Last `width` columns of `text` (visible width, no ANSI). */
267
+ function takeTail(text: string, width: number): string {
268
+ if (width <= 0) return "";
269
+ const characters = [...text];
270
+ let out = "";
271
+ let used = 0;
272
+ for (let index = characters.length - 1; index >= 0; index--) {
273
+ const character = characters[index]!;
274
+ const characterWidth = visibleWidth(character);
275
+ if (used + characterWidth > width) break;
276
+ out = character + out;
277
+ used += characterWidth;
278
+ }
279
+ return out;
280
+ }
281
+
282
+ /** Pick the path API from the string's own shape, not the host platform. */
283
+ export function resumePathApi(value: string): typeof path.posix {
284
+ const windows =
285
+ /^[A-Za-z]:[\\/]/.test(value) ||
286
+ value.startsWith("\\\\") ||
287
+ /^\/\/[^/]/.test(value) ||
288
+ (value.includes("\\") && !value.startsWith("/"));
289
+ return windows ? path.win32 : path.posix;
290
+ }
291
+
292
+ /** Full path spelled with its own platform's separators (drive, UNC or POSIX). */
293
+ export function absoluteResumePath(value: string): string {
294
+ const api = resumePathApi(value);
295
+ const normalized = api.normalize(value);
296
+ return normalized === "." ? value : normalized;
297
+ }
298
+
299
+ /** Short parent/name tail or the full absolute path, per the picker toggle. */
300
+ export function formatResumePath(value: string, fullPaths: boolean): string {
301
+ if (!value) return "";
302
+ if (fullPaths) return absoluteResumePath(value);
303
+ const normalized = value.replace(/[\\/]+$/, "") || value;
304
+ const api = resumePathApi(normalized);
305
+ const name = api.basename(normalized);
306
+ const parent = api.basename(api.dirname(normalized));
307
+ if (!parent || parent === name) return name || value;
308
+ return api.join(parent, name);
309
+ }
310
+
311
+ /** Basename-first path tail (`parent/name`) keeping the platform separator. */
312
+ export function resumePathTail(value: string): string {
313
+ return formatResumePath(value, false);
314
+ }
315
+
316
+ /**
317
+ * Truncate from the left so the tail stays readable, keeping a drive/UNC root
318
+ * when there is room for one (`D:\…\repo`, `\\server\share\…\repo`). The
319
+ * returned string never exceeds `maxWidth` visible columns.
320
+ */
321
+ export function truncateResumePath(value: string, maxWidth: number): string {
322
+ const width = Math.max(0, Math.floor(maxWidth));
323
+ if (width === 0) return "";
324
+ if (visibleWidth(value) <= width) return value;
325
+ const ellipsis = "…";
326
+ if (width === 1) return ellipsis;
327
+ const root = resumePathApi(value).parse(value).root;
328
+ if (visibleWidth(root) > 1 && visibleWidth(root) + 2 < width) {
329
+ const rest = value.slice(root.length).replace(/^[\\/]+/, "");
330
+ return `${root}${ellipsis}${takePathTail(rest, width - visibleWidth(root) - 1)}`;
331
+ }
332
+ return `${ellipsis}${takePathTail(value, width - 1)}`;
333
+ }
334
+
335
+ /** Left-pad to a stable column width (never truncates). */
336
+ function padStartVisible(text: string, width: number): string {
337
+ const pad = width - visibleWidth(text);
338
+ return pad > 0 ? `${" ".repeat(pad)}${text}` : text;
339
+ }
340
+
341
+ /**
342
+ * One dense line: `cursor + left` pinned left, `right` pinned to the last
343
+ * column. When the two cannot both fit, `right` is dropped first so the row
344
+ * never wraps.
345
+ */
346
+ function alignRow(cursor: string, left: string, right: string, width: number): string {
347
+ const available = Math.max(1, width - visibleWidth(cursor));
348
+ const rightWidth = visibleWidth(right);
349
+ const gap = 2;
350
+ if (rightWidth > 0 && available - rightWidth - gap >= 8) {
351
+ const leftText = truncateToWidth(left, available - rightWidth - gap);
352
+ const fill = Math.max(gap, available - visibleWidth(leftText) - rightWidth);
353
+ return `${cursor}${leftText}${" ".repeat(fill)}${right}`;
354
+ }
355
+ return `${cursor}${truncateToWidth(left, available)}`;
356
+ }
357
+
358
+ /**
359
+ * Pick the widest right-hand variant this row can carry and return the space
360
+ * left for its left-hand content, following {@link alignRow}'s fit rule.
361
+ */
362
+ function leftBudgetFor(cursor: string, variants: readonly string[], width: number): { right: string; budget: number } {
363
+ const available = Math.max(1, width - visibleWidth(cursor));
364
+ for (const right of variants) {
365
+ if (right && available - visibleWidth(right) - 2 >= 8) {
366
+ return { right, budget: available - visibleWidth(right) - 2 };
367
+ }
368
+ }
369
+ return { right: "", budget: available };
370
+ }
371
+
372
+ /**
373
+ * A workspace branch is noise when it repeats the current workspace's branch;
374
+ * it is kept for the current workspace or when it actually differs.
375
+ */
376
+ export function shouldShowBranch(
377
+ branch: string | null | undefined,
378
+ referenceBranch: string | null | undefined,
379
+ current: boolean,
380
+ workspaceName?: string,
381
+ ): boolean {
382
+ if (!branch) return false;
383
+ if (current) return true;
384
+ if (workspaceName && branch === workspaceName) return false;
385
+ return branch !== referenceBranch;
386
+ }
387
+
388
+ function conversationCountText(row: { conversationCount: number; totalConversationCount: number }): string {
389
+ const visible = row.conversationCount;
390
+ if (visible === row.totalConversationCount) {
391
+ return visible === 1
392
+ ? tuiTextFmt("resumeConversationCountOne", "1 conversation", visible)
393
+ : tuiTextFmt("resumeConversationCountFmt", "%d conversations", visible);
394
+ }
395
+ return tuiTextFmt("resumeConversationCountOfFmt", "%d of %d conversations", visible, row.totalConversationCount);
396
+ }
397
+
398
+ /** Fixed indentation: project 0 → workspace 1 step → conversation 2 steps. */
399
+ const PROJECT_INDENT = 0;
400
+ const WORKSPACE_INDENT = 2;
401
+ const CONVERSATION_INDENT = 4;
402
+ /** Two columns at the right edge: a space plus the menu affordance. */
403
+ const MENU_WIDTH = 2;
404
+
405
+ /** Stable right-hand column widths for the conversation list. */
406
+ export interface ConversationColumns {
407
+ status: number;
408
+ turns: number;
409
+ age: number;
410
+ flags: number;
411
+ }
412
+
413
+ function conversationAge(row: ResumeTreeConversationRow, now?: number): string {
414
+ return formatResumeAge(row.conversation.modified, now);
415
+ }
416
+
417
+ function conversationTurns(row: ResumeTreeConversationRow): string {
418
+ return row.conversation.messageCount === 1
419
+ ? tuiText("resumeTurnCountOne", "1 turn")
420
+ : tuiTextFmt("resumeTurnCountFmt", "%d turns", row.conversation.messageCount);
421
+ }
422
+
423
+ function conversationFlags(row: ResumeTreeConversationRow): { text: string; tone: ThemeColor }[] {
424
+ const flags: { text: string; tone: ThemeColor }[] = [];
425
+ if (row.current) flags.push({ text: tuiText(CURRENT_LABEL_KEY, "current"), tone: "accent" });
426
+ if (row.conversation.fork) flags.push({ text: tuiText("ssForkLabel", "fork"), tone: "dim" });
427
+ if (row.pinned) flags.push({ text: tuiText(PINNED_LABEL_KEY, "pinned"), tone: "accent" });
428
+ return flags;
429
+ }
430
+
431
+ /** Width of the status column, fixed across rows so every title starts together. */
432
+ export function statusColumnWidth(): number {
433
+ let width = 0;
434
+ for (const status of ["complete", "interrupted", "aborted", "error", "pending"] as const) {
435
+ width = Math.max(width, visibleWidth(statusWord(status)?.text ?? ""));
436
+ }
437
+ return width;
438
+ }
439
+
440
+ /** Column widths across every visible conversation row, so columns stay put while scrolling. */
441
+ export function conversationColumns(rows: readonly ResumeTreeConversationRow[], now?: number): ConversationColumns {
442
+ const widths: ConversationColumns = { status: statusColumnWidth(), turns: 0, age: 0, flags: 0 };
443
+ for (const row of rows) {
444
+ widths.turns = Math.max(widths.turns, visibleWidth(conversationTurns(row)));
445
+ widths.age = Math.max(widths.age, visibleWidth(conversationAge(row, now)));
446
+ const flags = conversationFlags(row)
447
+ .map(flag => flag.text)
448
+ .join(` ${theme.sep.dot} `);
449
+ widths.flags = Math.max(widths.flags, visibleWidth(flags));
450
+ }
451
+ return widths;
452
+ }
453
+
454
+ /** `⋯` at the right edge of a menu-bearing row. */
455
+ function menuAffordance(): string {
456
+ return theme.fg("muted", "⋯");
457
+ }
458
+
459
+ /**
460
+ * One dense conversation row: `status | title | turns | age | flags | ⋯`.
461
+ * The status cell and every metadata column have fixed widths computed once
462
+ * per frame; the title is the only flexible cell, and nothing ever wraps.
463
+ */
464
+ export function renderConversationRow(
465
+ row: ResumeTreeConversationRow,
466
+ options: {
467
+ width: number;
468
+ selected: boolean;
469
+ now?: number;
470
+ columns?: ConversationColumns;
471
+ /** Show the conversation cwd as an absolute path instead of omitting it. */
472
+ fullPaths?: boolean;
473
+ },
474
+ ): string {
475
+ const selected = options.selected;
476
+ const cursor = selected ? theme.fg("accent", `${theme.nav.cursor} `) : padding(2);
477
+ const indent = padding(CONVERSATION_INDENT);
478
+ const columns = options.columns ?? conversationColumns([row], options.now);
479
+ const status = statusWord(row.conversation.status);
480
+ const flags = conversationFlags(row);
481
+ const parts: { id: "turns" | "age" | "flags"; text: string }[] = [
482
+ { id: "turns", text: padStartVisible(conversationTurns(row), columns.turns) },
483
+ { id: "age", text: padStartVisible(conversationAge(row, options.now), columns.age) },
484
+ ];
485
+ if (flags.length > 0) {
486
+ parts.push({
487
+ id: "flags",
488
+ text: padStartVisible(
489
+ flags.map(flag => theme.fg(flag.tone, flag.text)).join(` ${theme.fg("dim", theme.sep.dot)} `),
490
+ columns.flags,
491
+ ),
492
+ });
493
+ }
494
+ const available = Math.max(1, options.width - visibleWidth(cursor) - visibleWidth(indent));
495
+ const statusWidth = columns.status + 2; // word + two-column gutter
496
+ const menu = menuAffordance();
497
+ // Drop metadata only when the title would otherwise be unreadable, in
498
+ // priority order: flags, turns, age. The menu glyph never drops.
499
+ const dropOrder: ("flags" | "turns" | "age")[] = ["flags", "turns", "age"];
500
+ const joinParts = () => parts.map(part => part.text).join(` ${theme.fg("dim", theme.sep.dot)} `);
501
+ while (parts.length > 0 && statusWidth + 8 + visibleWidth(joinParts()) + MENU_WIDTH + 1 > available) {
502
+ const id = dropOrder.shift();
503
+ if (!id) break;
504
+ const index = parts.findIndex(part => part.id === id);
505
+ if (index >= 0) parts.splice(index, 1);
506
+ }
507
+ const metadata = joinParts();
508
+ const right = metadata ? `${metadata} ${menu}` : menu;
509
+ const leftBudget = Math.max(1, available - statusWidth - visibleWidth(right) - 1);
510
+ // Full mode adds the cwd as a dim detail after the title; the path takes a
511
+ // bounded share of the flexible area so the title stays readable.
512
+ const detail = options.fullPaths ? absoluteResumePath(row.conversation.cwd) : "";
513
+ let detailText = "";
514
+ let titleWidth = leftBudget;
515
+ if (detail) {
516
+ const detailBudget = Math.min(40, Math.floor(leftBudget * 0.45));
517
+ if (detailBudget >= 8 && leftBudget - detailBudget - 2 >= 8) {
518
+ detailText = ` ${theme.fg("dim", truncateResumePath(detail, detailBudget))}`;
519
+ titleWidth = leftBudget - visibleWidth(detailText);
520
+ }
521
+ }
522
+ const title = truncateToWidth(row.conversation.title || row.conversation.id, Math.max(1, titleWidth));
523
+ const statusText = status ? theme.fg(status.tone, padStartVisible(status.text, columns.status)) : "";
524
+ const left = `${statusText}${" ".repeat(Math.max(0, statusWidth - visibleWidth(statusText)))}${title}${detailText}`;
525
+ const line = alignRow("", left, right, available);
526
+ const rendered = selected ? theme.bold(line) : line;
527
+ return truncateToWidth(`${cursor}${indent}${rendered}`, options.width);
528
+ }
529
+
530
+ /**
531
+ * One workspace sub-header, indented one visible step: a coloured fold+name,
532
+ * a branch chip when it adds information, and right-aligned counts + `⋯`.
533
+ */
534
+ export function renderWorkspaceRow(
535
+ row: ResumeTreeWorkspaceRow,
536
+ options: {
537
+ width: number;
538
+ selected: boolean;
539
+ collapsed: boolean;
540
+ referenceBranch?: string | null;
541
+ current?: boolean;
542
+ /** Show absolute workspace paths instead of parent/name tails. */
543
+ fullPaths?: boolean;
544
+ /** Workspace scope has no use for the path label; callers can omit it. */
545
+ showPath?: boolean;
546
+ },
547
+ ): string {
548
+ const cursor = options.selected ? theme.fg("accent", `${theme.nav.cursor} `) : padding(2);
549
+ const indent = padding(WORKSPACE_INDENT);
550
+ const fold = options.collapsed ? "▸" : "▾";
551
+ const name = row.workspace.displayName || row.workspace.alias || row.workspace.name || row.workspace.path;
552
+ const branch = shouldShowBranch(row.workspace.branch, options.referenceBranch, options.current === true, name)
553
+ ? ` ${theme.fg("warning", `[${row.workspace.branch}]`)}`
554
+ : "";
555
+ const nameStyled = theme.bold(theme.fg("accent", name));
556
+ const right = `${theme.fg("dim", conversationCountText(row))} ${menuAffordance()}`;
557
+ const budget = leftBudgetFor(cursor, [right], options.width);
558
+ const base = `${theme.fg("muted", fold)} ${nameStyled}${branch}`;
559
+ const path = options.showPath === false ? "" : formatResumePath(row.workspace.path, options.fullPaths === true);
560
+ const pathBudget = budget.budget - visibleWidth(indent) - visibleWidth(base) - 2;
561
+ const pathText = path && pathBudget >= 4 ? ` ${theme.fg("dim", truncateResumePath(path, pathBudget))}` : "";
562
+ return alignRow(cursor, indent + base + pathText, budget.right, options.width);
563
+ }
564
+
565
+ /**
566
+ * Project section header: pin + name + path tag on the left, counts/workspaces/
567
+ * age right-aligned. The caller draws a full-width rule under it.
568
+ */
569
+ export function renderProjectRow(
570
+ row: ResumeTreeProjectRow,
571
+ options: {
572
+ width: number;
573
+ selected: boolean;
574
+ collapsed: boolean;
575
+ now?: number;
576
+ /** Show the absolute project root instead of the parent/name tail. */
577
+ fullPaths?: boolean;
578
+ },
579
+ ): string {
580
+ const cursor = options.selected ? theme.fg("accent", `${theme.nav.cursor} `) : padding(2);
581
+ const indent = padding(PROJECT_INDENT);
582
+ const fold = options.collapsed ? "▸" : "▾";
583
+ const pin = row.pinned ? `${theme.icon.pin} ` : "";
584
+ const name = row.project.displayName || row.project.name;
585
+ const workspaces =
586
+ row.totalWorkspaceCount === 1
587
+ ? tuiText("resumeWorkspaceCountOne", "1 workspace")
588
+ : tuiTextFmt("resumeWorkspaceCountFmt", "%d workspaces", row.totalWorkspaceCount);
589
+ const count = theme.fg("dim", conversationCountText(row));
590
+ const activity = row.project.lastActivity
591
+ ? theme.fg("dim", formatResumeAge(row.project.lastActivity, options.now))
592
+ : "";
593
+ const right = [count, theme.fg("dim", workspaces), activity]
594
+ .filter(Boolean)
595
+ .join(` ${theme.fg("dim", theme.sep.dot)} `);
596
+ const compact = [count, theme.fg("dim", workspaces)].filter(Boolean).join(` ${theme.fg("dim", theme.sep.dot)} `);
597
+ const budget = leftBudgetFor(cursor, [right, compact, count], options.width);
598
+ const base = `${indent}${theme.fg("muted", fold)} ${pin}${theme.bold(theme.fg("accent", name))}`;
599
+ const path = formatResumePath(row.project.root, options.fullPaths === true);
600
+ const pathBudget = budget.budget - visibleWidth(base) - 3; // ` (` + `)` + a space
601
+ const pathTag = path && pathBudget >= 3 ? ` ${theme.fg("dim", `(${truncateResumePath(path, pathBudget)})`)}` : "";
602
+ return alignRow(cursor, base + pathTag, budget.right, options.width);
603
+ }
604
+
605
+ /** Full-width rule closing a project section header. */
606
+ export function renderProjectRule(width: number): string {
607
+ return truncateToWidth(theme.fg("dim", theme.boxRound.horizontal.repeat(Math.max(0, width))), width);
608
+ }
609
+
610
+ /** A scope-segment hit range on the header line, in tree-local columns. */
611
+ export interface ResumeScopeHit {
612
+ kind: ResumeTreeScope;
613
+ start: number;
614
+ end: number;
615
+ }
616
+
617
+ /** The three-tab scope control with per-scope counts, plus its clickable ranges. */
618
+ export function renderScopeHeaderHits(
619
+ scope: ResumeTreeScope,
620
+ summary: ResumeTreeScopeSummary,
621
+ width: number,
622
+ ): { text: string; hits: ResumeScopeHit[] } {
623
+ const labels: { kind: ResumeTreeScope; label: string }[] = [
624
+ { kind: "workspace", label: tuiText("resumeScopeWorkspace", "this workspace") },
625
+ { kind: "project", label: tuiText("resumeScopeProject", "this repository") },
626
+ { kind: "all", label: tuiText("resumeScopeAll", "all projects") },
627
+ ];
628
+ const parts: { text: string; priority: number; kind?: ResumeTreeScope; plain?: string }[] = [];
629
+ for (const { kind, label } of labels) {
630
+ const selected = kind === scope;
631
+ const count = summary.counts[kind] ?? 0;
632
+ const plain = `${selected ? "[" : " "}${label}${selected ? "]" : " "}`;
633
+ parts.push({
634
+ text: selected ? theme.bold(theme.fg("accent", plain)) : theme.fg("muted", plain),
635
+ priority: selected ? 1_000 : 100,
636
+ kind,
637
+ plain,
638
+ });
639
+ parts.push({ text: theme.fg("dim", `${count}`), priority: selected ? 200 : 40 });
640
+ }
641
+ let rendered = parts.map(part => part.text).join(" ");
642
+ const dropable = parts.filter(part => part.priority < 1_000).sort((a, b) => a.priority - b.priority);
643
+ while (visibleWidth(rendered) > width && dropable.length > 0) {
644
+ const part = dropable.shift()!;
645
+ const index = parts.indexOf(part);
646
+ if (index >= 0) parts.splice(index, 1);
647
+ rendered = parts.map(entry => entry.text).join(" ");
648
+ }
649
+ if (visibleWidth(rendered) > width) return { text: truncateToWidth(rendered, width), hits: [] };
650
+ const hits: ResumeScopeHit[] = [];
651
+ let column = 0;
652
+ for (let index = 0; index < parts.length; index++) {
653
+ const part = parts[index]!;
654
+ const partWidth = visibleWidth(part.text);
655
+ if (part.kind) {
656
+ // The count immediately follows its label; both switch the scope.
657
+ const count = parts[index + 1];
658
+ const countWidth = count ? 1 + visibleWidth(count.text) : 0;
659
+ hits.push({ kind: part.kind, start: column, end: column + partWidth - 1 + countWidth });
660
+ }
661
+ column += partWidth + (index < parts.length - 1 ? 1 : 0);
662
+ }
663
+ return { text: rendered, hits };
664
+ }
665
+
666
+ /** The three-tab scope control with per-scope counts. */
667
+ export function renderScopeHeader(scope: ResumeTreeScope, summary: ResumeTreeScopeSummary, width: number): string {
668
+ return renderScopeHeaderHits(scope, summary, width).text;
669
+ }
670
+
671
+ interface FlatRow {
672
+ row: ResumeTreeRow;
673
+ /** Visible when every ancestor group is expanded. */
674
+ parentKey?: string;
675
+ }
676
+
677
+ /** Project and workspace keys can be the same path, so identity includes the level. */
678
+ function rowIdentity(row: ResumeTreeRow): string {
679
+ return `${row.type}:${row.key}`;
680
+ }
681
+
682
+ /** Project rows keep their model-side collapsed flag unless the user overrode it. */
683
+ function projectCollapsed(row: ResumeTreeProjectRow, state: ResumeTreeState): boolean {
684
+ return state.expandedProjects.has(row.key) ? false : state.collapsedProjects.has(row.key) || row.collapsed;
685
+ }
686
+
687
+ export interface ResumeTreeState {
688
+ collapsedProjects: Set<string>;
689
+ expandedProjects: Set<string>;
690
+ collapsedWorkspaces: Set<string>;
691
+ }
692
+
693
+ type ResumeTreeMenuKind = "workspace" | "conversation";
694
+ type ResumeTreeMenuAction = "rename" | "toggle" | "open" | "pin" | "delete";
695
+
696
+ interface ResumeTreeMenuItem {
697
+ id: ResumeTreeMenuAction;
698
+ label: string;
699
+ disabled?: boolean;
700
+ }
701
+
702
+ interface ResumeTreeMenu {
703
+ kind: ResumeTreeMenuKind;
704
+ rowKey: string;
705
+ items: ResumeTreeMenuItem[];
706
+ index: number;
707
+ workspace?: ResumeTreeWorkspace;
708
+ conversation?: ResumeTreeConversation;
709
+ }
710
+
711
+ interface ResumeTreeRename {
712
+ rowKey: string;
713
+ kind: ResumeTreeMenuKind;
714
+ value: string;
715
+ workspace?: ResumeTreeWorkspace;
716
+ conversation?: ResumeTreeConversation;
717
+ }
718
+
719
+ /** A clickable button range on one rendered line. */
720
+ interface ResumeButtonHit {
721
+ start: number;
722
+ end: number;
723
+ }
724
+
725
+ /** Flatten model rows under the current local collapse state (headers always stay). */
726
+ export function flattenResumeRows(
727
+ rows: readonly ResumeTreeRow[],
728
+ state: ResumeTreeState,
729
+ searching: boolean,
730
+ ): FlatRow[] {
731
+ const flat: FlatRow[] = [];
732
+ let collapsedProject: string | undefined;
733
+ let collapsedWorkspace: string | undefined;
734
+ for (const row of rows) {
735
+ if (row.type === "project") {
736
+ flat.push({ row });
737
+ collapsedProject = searching || !projectCollapsed(row, state) ? undefined : row.key;
738
+ collapsedWorkspace = undefined;
739
+ continue;
740
+ }
741
+ if (row.type === "workspace") {
742
+ if (collapsedProject) continue;
743
+ flat.push({ row, parentKey: row.projectKey });
744
+ collapsedWorkspace = state.collapsedWorkspaces.has(row.key) && !searching ? row.key : undefined;
745
+ continue;
746
+ }
747
+ if (collapsedProject || collapsedWorkspace) continue;
748
+ flat.push({ row, parentKey: row.workspaceKey });
749
+ }
750
+ return flat;
751
+ }
752
+
753
+ /**
754
+ * The ANSI resume tree component. The host mounts it inside its panel chrome;
755
+ * this component renders the scope control, search field, banners and rows.
756
+ */
757
+ export class ResumeTreeComponent implements Component {
758
+ readonly #source: ResumeTreeSource;
759
+ readonly #search: Input;
760
+ readonly #getTerminalRows: () => number;
761
+ readonly #state: ResumeTreeState = {
762
+ collapsedProjects: new Set(),
763
+ expandedProjects: new Set(),
764
+ collapsedWorkspaces: new Set(),
765
+ };
766
+ #scope: ResumeTreeScope;
767
+ #fullPaths = false;
768
+ #view: ResumeTreeView | null = null;
769
+ #viewQuery = "\0";
770
+ #viewScope: ResumeTreeScope | null = null;
771
+ #searchFocused: boolean;
772
+ #selectedKey: string | undefined;
773
+ #scrollOffset = 0;
774
+ #deleteTarget: ResumeTreeConversationRow | null = null;
775
+ /** Keys pressed on the skeleton while the host's first scan is in flight. */
776
+ #pendingKeys: string[] | undefined;
777
+ #busy = false;
778
+ #notice: string | undefined;
779
+ #expandedOnce = false;
780
+ #menu: ResumeTreeMenu | undefined;
781
+ #rename: ResumeTreeRename | undefined;
782
+ /** Row hit map captured from the last render (`row → action`). */
783
+ #hitRows = new Map<number, { kind: "conversation" | "toggle"; key: string }>();
784
+ /** Lines whose last column is the row's `⋯` menu affordance. */
785
+ #menuHits = new Map<number, { kind: ResumeTreeMenuKind; key: string; start: number }>();
786
+ /** Menu item lines captured from the last render. */
787
+ #menuItemHits = new Map<number, number>();
788
+ /** Rename/delete modal button ranges, keyed by rendered line. */
789
+ #renameButtonHits = new Map<number, { confirm?: ResumeButtonHit; cancel?: ResumeButtonHit }>();
790
+ #deleteButtonHits = new Map<number, { confirm?: ResumeButtonHit; cancel?: ResumeButtonHit }>();
791
+ /** Scope-segment hit ranges on the header line. */
792
+ #scopeHits: ResumeScopeHit[] = [];
793
+ /** Local line/column offset of this component inside the fullscreen frame. */
794
+ #lineOffset = 0;
795
+ #columnOffset = 0;
796
+ #lastClick: { key: string; time: number } | undefined;
797
+ readonly onSelect: (conversation: ResumeTreeConversation) => void;
798
+ readonly onCancel: () => void;
799
+ readonly onExit: () => void;
800
+ onRequestRender?: () => void;
801
+
802
+ constructor(options: ResumeTreeOptions) {
803
+ this.#source = options.source;
804
+ this.#scope = options.source.initialScope;
805
+ this.#getTerminalRows = options.getTerminalRows ?? (() => 24);
806
+ this.#searchFocused = options.searchFocused ?? true;
807
+ this.onSelect = options.onSelect;
808
+ this.onCancel = options.onCancel;
809
+ this.onExit = options.onExit;
810
+ this.onRequestRender = options.onRequestRender;
811
+ const restored = options.source.pickerState?.();
812
+ if (restored) {
813
+ this.#scope = restored.scope;
814
+ this.#fullPaths = restored.fullPaths === true;
815
+ for (const key of restored.collapsedProjects ?? []) this.#state.collapsedProjects.add(key);
816
+ for (const key of restored.expandedProjects ?? []) this.#state.expandedProjects.add(key);
817
+ for (const key of restored.collapsedWorkspaces ?? []) this.#state.collapsedWorkspaces.add(key);
818
+ }
819
+ this.#search = new Input();
820
+ this.#search.prompt = "/ ";
821
+ this.#search.onSubmit = () => this.#activate();
822
+ this.#search.onEscape = () => this.#clearSearch();
823
+ }
824
+
825
+ /** Persist scope + local expansion together (the host owns the file). */
826
+ #persistState(): void {
827
+ const state: ResumeTreePickerState = {
828
+ scope: this.#scope,
829
+ collapsedProjects: [...this.#state.collapsedProjects],
830
+ expandedProjects: [...this.#state.expandedProjects],
831
+ collapsedWorkspaces: [...this.#state.collapsedWorkspaces],
832
+ fullPaths: this.#fullPaths,
833
+ };
834
+ if (this.#source.persistPickerState) void this.#source.persistPickerState(state);
835
+ else void this.#source.persistScope?.(this.#scope);
836
+ }
837
+
838
+ /** Rebuild the cached view (call after the host refreshes its directory). */
839
+ invalidate(): void {
840
+ this.#view = null;
841
+ this.#viewQuery = "\0";
842
+ }
843
+
844
+ get scope(): ResumeTreeScope {
845
+ return this.#scope;
846
+ }
847
+
848
+ get fullPaths(): boolean {
849
+ return this.#fullPaths;
850
+ }
851
+
852
+ /**
853
+ * Toggle absolute paths. In workspace scope the path is redundant, so the
854
+ * key surfaces a one-line hint and leaves the setting untouched; the
855
+ * project/all scopes actually flip and persist it.
856
+ */
857
+ toggleFullPaths(): void {
858
+ if (this.#scope === "workspace") {
859
+ this.#notice = tuiText(
860
+ "resumeFullPathsWorkspaceHint",
861
+ "Full paths matter outside this workspace — press f after Tab",
862
+ );
863
+ this.onRequestRender?.();
864
+ return;
865
+ }
866
+ this.#fullPaths = !this.#fullPaths;
867
+ this.#notice = undefined;
868
+ this.#persistState();
869
+ this.onRequestRender?.();
870
+ }
871
+
872
+ /** Adopt a host-refreshed view while keeping selection and collapse state. */
873
+ setView(view: ResumeTreeView | null): void {
874
+ this.#view = view;
875
+ this.#viewScope = this.#scope;
876
+ this.#viewQuery = this.#search.getValue();
877
+ }
878
+
879
+ /** Cycle workspace → project → all → workspace. */
880
+ cycleScope(direction: 1 | -1 = 1): void {
881
+ const order: ResumeTreeScope[] = ["workspace", "project", "all"];
882
+ const index = order.indexOf(this.#scope);
883
+ this.setScope(order[(index + direction + order.length) % order.length]!);
884
+ }
885
+
886
+ /** Switch to a scope (the scope segments and Tab share this path). */
887
+ setScope(scope: ResumeTreeScope): void {
888
+ if (scope === this.#scope) return;
889
+ this.#scope = scope;
890
+ this.#notice = undefined;
891
+ this.invalidate();
892
+ this.#persistState();
893
+ this.#rebuild();
894
+ this.onRequestRender?.();
895
+ }
896
+
897
+ /** Re-read the directory through the host source. */
898
+ async refresh(): Promise<void> {
899
+ if (this.#busy) return;
900
+ this.#busy = true;
901
+ this.#notice = undefined;
902
+ this.onRequestRender?.();
903
+ try {
904
+ await this.#source.refresh?.();
905
+ this.invalidate();
906
+ this.#rebuild();
907
+ } catch (error) {
908
+ this.#notice = tuiTextFmt(
909
+ "ssErrorPrefixFmt",
910
+ "Error: %s",
911
+ error instanceof Error ? error.message : String(error),
912
+ );
913
+ } finally {
914
+ this.#busy = false;
915
+ this.onRequestRender?.();
916
+ }
917
+ }
918
+
919
+ /** Focus the search field (the `/` key, and the initial state). */
920
+ focusSearch(): void {
921
+ this.#searchFocused = true;
922
+ }
923
+
924
+ #clearSearch(): void {
925
+ if (this.#search.getValue().length === 0) {
926
+ this.onCancel();
927
+ return;
928
+ }
929
+ this.#search.setValue("");
930
+ this.#searchFocused = false;
931
+ this.invalidate();
932
+ this.#rebuild();
933
+ }
934
+
935
+ #visibleRows(): FlatRow[] {
936
+ const view = this.#viewFor();
937
+ if (!view) return [];
938
+ const searching = view.query.raw.trim().length > 0;
939
+ return flattenResumeRows(view.rows, this.#state, searching);
940
+ }
941
+
942
+ #viewFor(): ResumeTreeView | null {
943
+ const query = this.#search.getValue();
944
+ if (this.#view && this.#viewScope === this.#scope && this.#viewQuery === query) return this.#view;
945
+ this.#view = this.#source.buildView({
946
+ scope: this.#scope,
947
+ query,
948
+ currentSessionPath: this.#source.currentSessionPath?.(),
949
+ });
950
+ this.#viewScope = this.#scope;
951
+ this.#viewQuery = query;
952
+ if (!this.#expandedOnce) {
953
+ // Seed local collapse state from the model so the first paint agrees
954
+ // with the persisted project flags.
955
+ const view = this.#view;
956
+ if (view) {
957
+ for (const row of view.rows) {
958
+ if (row.type === "project" && row.collapsed) this.#state.collapsedProjects.add(row.key);
959
+ }
960
+ }
961
+ this.#expandedOnce = true;
962
+ }
963
+ return this.#view;
964
+ }
965
+
966
+ #rebuild(): void {
967
+ const flat = this.#visibleRows();
968
+ if (flat.length === 0) {
969
+ this.#selectedKey = undefined;
970
+ return;
971
+ }
972
+ if (!this.#selectedKey || !flat.some(entry => rowIdentity(entry.row) === this.#selectedKey)) {
973
+ // Open on something actionable: headers only fold, so a picker that
974
+ // starts on a project header makes Enter and Delete look dead.
975
+ const firstConversation = flat.find(entry => entry.row.type === "conversation") ?? flat[0]!;
976
+ this.#selectedKey = rowIdentity(firstConversation.row);
977
+ }
978
+ this.#clampScroll();
979
+ }
980
+
981
+ #selectedIndex(flat: readonly FlatRow[]): number {
982
+ if (this.#selectedKey === undefined) return 0;
983
+ const index = flat.findIndex(entry => rowIdentity(entry.row) === this.#selectedKey);
984
+ return index < 0 ? 0 : index;
985
+ }
986
+
987
+ #move(delta: number): void {
988
+ this.#searchFocused = false;
989
+ const flat = this.#visibleRows();
990
+ if (flat.length === 0) return;
991
+ const next = Math.max(0, Math.min(flat.length - 1, this.#selectedIndex(flat) + delta));
992
+ this.#selectedKey = rowIdentity(flat[next]!.row);
993
+ this.#clampScroll();
994
+ }
995
+
996
+ #pageSize(): number {
997
+ return Math.max(2, Math.floor(this.#listHeight() / 2));
998
+ }
999
+
1000
+ #listHeight(): number {
1001
+ return Math.max(3, this.#getTerminalRows() - 9);
1002
+ }
1003
+
1004
+ /** Rendered line count for one row: a project header carries a rule under it. */
1005
+ #rowLines(row: ResumeTreeRow): number {
1006
+ return row.type === "project" ? 2 : 1;
1007
+ }
1008
+
1009
+ #clampScroll(): void {
1010
+ const flat = this.#visibleRows();
1011
+ const height = this.#listHeight();
1012
+ const index = this.#selectedIndex(flat);
1013
+ let start = Math.min(this.#scrollOffset, index);
1014
+ const linesFrom = (from: number, to: number) => {
1015
+ let total = 0;
1016
+ for (let cursor = from; cursor < to; cursor++) total += this.#rowLines(flat[cursor]!.row);
1017
+ return total;
1018
+ };
1019
+ while (start < index && linesFrom(start, index) + this.#rowLines(flat[index]!.row) > height) start++;
1020
+ if (start > index) start = index;
1021
+ this.#scrollOffset = Math.max(0, Math.min(start, Math.max(0, flat.length - 1)));
1022
+ }
1023
+
1024
+ #selectRow(row: ResumeTreeRow): void {
1025
+ if (row.type === "conversation") {
1026
+ this.onSelect(row.conversation);
1027
+ return;
1028
+ }
1029
+ this.#toggleCollapse(row);
1030
+ }
1031
+
1032
+ #toggleCollapse(row: ResumeTreeProjectRow | ResumeTreeWorkspaceRow): void {
1033
+ if (row.type === "project") {
1034
+ const expanded = !projectCollapsed(row, this.#state);
1035
+ this.#state.collapsedProjects.delete(row.key);
1036
+ this.#state.expandedProjects.delete(row.key);
1037
+ if (expanded) this.#state.collapsedProjects.add(row.key);
1038
+ else this.#state.expandedProjects.add(row.key);
1039
+ } else if (this.#state.collapsedWorkspaces.has(row.key)) {
1040
+ this.#state.collapsedWorkspaces.delete(row.key);
1041
+ } else {
1042
+ this.#state.collapsedWorkspaces.add(row.key);
1043
+ }
1044
+ this.#persistState();
1045
+ this.#clampScroll();
1046
+ this.onRequestRender?.();
1047
+ }
1048
+
1049
+ #collapseOrParent(): void {
1050
+ this.#searchFocused = false;
1051
+ const flat = this.#visibleRows();
1052
+ const index = this.#selectedIndex(flat);
1053
+ const entry = flat[index];
1054
+ if (!entry) return;
1055
+ const row = entry.row;
1056
+ if (row.type === "conversation") {
1057
+ this.#selectedKey = `workspace:${row.workspaceKey}`;
1058
+ return;
1059
+ }
1060
+ if (row.type === "workspace") {
1061
+ if (!this.#state.collapsedWorkspaces.has(row.key)) {
1062
+ this.#state.collapsedWorkspaces.add(row.key);
1063
+ return;
1064
+ }
1065
+ this.#selectedKey = `project:${row.projectKey}`;
1066
+ return;
1067
+ }
1068
+ if (!projectCollapsed(row, this.#state)) {
1069
+ this.#state.collapsedProjects.add(row.key);
1070
+ this.#state.expandedProjects.delete(row.key);
1071
+ }
1072
+ }
1073
+
1074
+ #expandOrChild(): void {
1075
+ this.#searchFocused = false;
1076
+ const flat = this.#visibleRows();
1077
+ const index = this.#selectedIndex(flat);
1078
+ const entry = flat[index];
1079
+ if (!entry) return;
1080
+ const row = entry.row;
1081
+ if (row.type === "project") {
1082
+ if (projectCollapsed(row, this.#state)) {
1083
+ this.#state.collapsedProjects.delete(row.key);
1084
+ this.#state.expandedProjects.add(row.key);
1085
+ return;
1086
+ }
1087
+ const child = flat[index + 1];
1088
+ if (child && child.row.type !== "conversation") this.#selectedKey = rowIdentity(child.row);
1089
+ return;
1090
+ }
1091
+ if (row.type === "workspace") {
1092
+ if (this.#state.collapsedWorkspaces.has(row.key)) {
1093
+ this.#state.collapsedWorkspaces.delete(row.key);
1094
+ return;
1095
+ }
1096
+ const child = flat[index + 1];
1097
+ if (child?.row.type === "conversation") this.#selectedKey = rowIdentity(child.row);
1098
+ }
1099
+ }
1100
+
1101
+ /** Open the small action menu for a workspace or conversation row. */
1102
+ #openMenu(row: ResumeTreeWorkspaceRow | ResumeTreeConversationRow): void {
1103
+ const isWorkspace = row.type === "workspace";
1104
+ const items: ResumeTreeMenuItem[] = [];
1105
+ if (isWorkspace) {
1106
+ items.push({
1107
+ id: "rename",
1108
+ label: tuiText("resumeMenuRename", "rename"),
1109
+ disabled: !this.#source.renameWorkspace,
1110
+ });
1111
+ items.push({
1112
+ id: "toggle",
1113
+ label: this.#state.collapsedWorkspaces.has(row.key)
1114
+ ? tuiText("resumeMenuExpand", "expand")
1115
+ : tuiText("resumeMenuCollapse", "collapse"),
1116
+ });
1117
+ items.push({
1118
+ id: "open",
1119
+ label: tuiText("resumeMenuOpen", "open in file manager"),
1120
+ disabled: !this.#source.openWorkspace,
1121
+ });
1122
+ items.push({
1123
+ id: "pin",
1124
+ label: row.workspace.pinned ? tuiText("resumeUnpinAction", "Unpin") : tuiText("resumePinAction", "Pin"),
1125
+ disabled: !this.#source.pinWorkspace,
1126
+ });
1127
+ } else {
1128
+ items.push({
1129
+ id: "rename",
1130
+ label: tuiText("resumeMenuRename", "rename"),
1131
+ disabled:
1132
+ !this.#source.renameConversation || this.#source.canRenameConversation?.(row.conversation) === false,
1133
+ });
1134
+ items.push({
1135
+ id: "pin",
1136
+ label: row.pinned ? tuiText("resumeUnpinAction", "Unpin") : tuiText("resumePinAction", "Pin"),
1137
+ disabled: !this.#source.pinConversation,
1138
+ });
1139
+ items.push({
1140
+ id: "delete",
1141
+ label:
1142
+ this.#source.deleteMode === "trash"
1143
+ ? tuiText("resumeDeleteTrash", "Move to trash")
1144
+ : tuiText("resumeDeletePermanent", "Delete"),
1145
+ disabled: !this.#source.deleteConversation,
1146
+ });
1147
+ }
1148
+ this.#menu = {
1149
+ kind: isWorkspace ? "workspace" : "conversation",
1150
+ rowKey: rowIdentity(row),
1151
+ items,
1152
+ index: Math.max(
1153
+ 0,
1154
+ items.findIndex(item => !item.disabled),
1155
+ ),
1156
+ ...(isWorkspace ? { workspace: row.workspace } : { conversation: row.conversation }),
1157
+ };
1158
+ this.#searchFocused = false;
1159
+ this.onRequestRender?.();
1160
+ }
1161
+
1162
+ /** Open the menu for the selected row (`m`, or the right-edge affordance). */
1163
+ #openSelectedMenu(): void {
1164
+ const flat = this.#visibleRows();
1165
+ const row = flat[this.#selectedIndex(flat)]?.row;
1166
+ if (row?.type === "workspace" || row?.type === "conversation") this.#openMenu(row);
1167
+ }
1168
+
1169
+ #moveMenuItem(delta: 1 | -1): void {
1170
+ const menu = this.#menu;
1171
+ if (!menu) return;
1172
+ if (menu.items.every(item => item.disabled)) return;
1173
+ let index = menu.index;
1174
+ for (let attempt = 0; attempt < menu.items.length; attempt++) {
1175
+ index = (index + delta + menu.items.length) % menu.items.length;
1176
+ if (!menu.items[index]!.disabled) break;
1177
+ }
1178
+ menu.index = index;
1179
+ this.onRequestRender?.();
1180
+ }
1181
+
1182
+ async #runMenuItem(): Promise<void> {
1183
+ const menu = this.#menu;
1184
+ if (!menu) return;
1185
+ const item = menu.items[menu.index];
1186
+ if (!item || item.disabled) return;
1187
+ this.#menu = undefined;
1188
+ if (item.id === "rename") {
1189
+ this.#beginRename(menu.kind, menu.rowKey, menu.workspace, menu.conversation);
1190
+ return;
1191
+ }
1192
+ const flat = this.#visibleRows();
1193
+ const entry = flat.find(candidate => rowIdentity(candidate.row) === menu.rowKey);
1194
+ const row = entry?.row;
1195
+ if (item.id === "toggle" && row?.type === "workspace") {
1196
+ this.#toggleCollapse(row);
1197
+ return;
1198
+ }
1199
+ if (item.id === "open" && menu.workspace) {
1200
+ await this.#source.openWorkspace?.(menu.workspace);
1201
+ this.onRequestRender?.();
1202
+ return;
1203
+ }
1204
+ if (item.id === "pin") {
1205
+ await this.#pinRow(menu);
1206
+ return;
1207
+ }
1208
+ if (item.id === "delete" && row?.type === "conversation") {
1209
+ this.#deleteTarget = row;
1210
+ this.onRequestRender?.();
1211
+ }
1212
+ }
1213
+
1214
+ async #pinRow(menu: ResumeTreeMenu): Promise<void> {
1215
+ if (menu.kind === "workspace" && menu.workspace) {
1216
+ await this.#source.pinWorkspace?.(menu.workspace, !menu.workspace.pinned);
1217
+ } else if (menu.conversation) {
1218
+ await this.#source.pinConversation?.(menu.conversation, !menu.conversation.pinned);
1219
+ }
1220
+ await this.#refreshAfterMutation();
1221
+ }
1222
+
1223
+ /** Open the rename sub-input for a workspace or conversation row. */
1224
+ #beginRename(
1225
+ kind: ResumeTreeMenuKind,
1226
+ rowKey: string,
1227
+ workspace?: ResumeTreeWorkspace,
1228
+ conversation?: ResumeTreeConversation,
1229
+ ): void {
1230
+ if (kind === "workspace") {
1231
+ if (!workspace || !this.#source.renameWorkspace) return;
1232
+ } else if (
1233
+ !conversation ||
1234
+ !this.#source.renameConversation ||
1235
+ this.#source.canRenameConversation?.(conversation) === false
1236
+ ) {
1237
+ return;
1238
+ }
1239
+ this.#rename = {
1240
+ rowKey,
1241
+ kind,
1242
+ value: kind === "workspace" ? (workspace!.alias ?? workspace!.name) : conversation!.title || conversation!.id,
1243
+ ...(workspace ? { workspace } : {}),
1244
+ ...(conversation ? { conversation } : {}),
1245
+ };
1246
+ this.#menu = undefined;
1247
+ this.#searchFocused = false;
1248
+ this.#notice = undefined;
1249
+ this.onRequestRender?.();
1250
+ }
1251
+
1252
+ /** Ctrl+R: rename the selected workspace/conversation; project rows are inert. */
1253
+ #renameSelected(): void {
1254
+ const flat = this.#visibleRows();
1255
+ const row = flat[this.#selectedIndex(flat)]?.row;
1256
+ if (row?.type === "workspace") this.#beginRename("workspace", rowIdentity(row), row.workspace);
1257
+ else if (row?.type === "conversation") {
1258
+ this.#beginRename("conversation", rowIdentity(row), undefined, row.conversation);
1259
+ }
1260
+ }
1261
+
1262
+ async #commitRename(): Promise<void> {
1263
+ const rename = this.#rename;
1264
+ if (!rename || this.#busy) return;
1265
+ const value = rename.value.trim();
1266
+ if (!value) {
1267
+ this.#notice = tuiText("resumeRenameEmpty", "A name is required.");
1268
+ this.onRequestRender?.();
1269
+ return;
1270
+ }
1271
+ let stored: string | null = null;
1272
+ try {
1273
+ stored =
1274
+ rename.kind === "workspace"
1275
+ ? ((await this.#source.renameWorkspace?.(rename.workspace!, value)) ?? null)
1276
+ : ((await this.#source.renameConversation?.(rename.conversation!, value)) ?? null);
1277
+ } catch (error) {
1278
+ this.#notice = tuiTextFmt(
1279
+ "resumeRenameFailedFmt",
1280
+ "Rename failed: %s",
1281
+ error instanceof Error ? error.message : String(error),
1282
+ );
1283
+ this.onRequestRender?.();
1284
+ return;
1285
+ }
1286
+ if (stored === null) {
1287
+ this.#notice = tuiText("resumeRenameFailed", "The name was not changed.");
1288
+ this.onRequestRender?.();
1289
+ return;
1290
+ }
1291
+ this.#rename = undefined;
1292
+ await this.#refreshAfterMutation();
1293
+ this.#verifyRename(rename, stored);
1294
+ }
1295
+
1296
+ /** Confirm the refreshed row shows what was stored; otherwise say so. */
1297
+ #verifyRename(rename: ResumeTreeRename, stored: string): void {
1298
+ const row = this.#view?.rows.find(candidate => rowIdentity(candidate) === rename.rowKey);
1299
+ if (!row) return;
1300
+ const shown =
1301
+ row.type === "conversation"
1302
+ ? row.conversation.title
1303
+ : row.type === "workspace"
1304
+ ? (row.workspace.displayName ?? row.workspace.name)
1305
+ : row.project.displayName;
1306
+ this.#notice =
1307
+ shown !== stored
1308
+ ? tuiTextFmt("resumeRenameMismatchFmt", "Renamed, but the list still shows “%s”", shown)
1309
+ : undefined;
1310
+ this.onRequestRender?.();
1311
+ }
1312
+
1313
+ async #refreshAfterMutation(): Promise<void> {
1314
+ if (this.#busy) return;
1315
+ this.#busy = true;
1316
+ try {
1317
+ await this.#source.refresh?.();
1318
+ this.invalidate();
1319
+ this.#rebuild();
1320
+ } catch (error) {
1321
+ this.#notice = tuiTextFmt(
1322
+ "ssErrorPrefixFmt",
1323
+ "Error: %s",
1324
+ error instanceof Error ? error.message : String(error),
1325
+ );
1326
+ } finally {
1327
+ this.#busy = false;
1328
+ this.onRequestRender?.();
1329
+ }
1330
+ }
1331
+
1332
+ /** The rename box owns every key; the search text is never touched. */
1333
+ #handleRenameKey(keyData: string): void {
1334
+ const rename = this.#rename;
1335
+ if (!rename) return;
1336
+ if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
1337
+ void this.#commitRename();
1338
+ return;
1339
+ }
1340
+ if (matchesAppInterrupt(keyData) || matchesKey(keyData, "escape")) {
1341
+ this.#rename = undefined;
1342
+ this.onRequestRender?.();
1343
+ return;
1344
+ }
1345
+ if (matchesKey(keyData, "backspace") || keyData === "\x7f") {
1346
+ rename.value = [...rename.value].slice(0, -1).join("");
1347
+ this.onRequestRender?.();
1348
+ return;
1349
+ }
1350
+ // Printable characters (including a pasted run) are literal text: `/`,
1351
+ // `m`, `p`, `f` and `r` keep their letter meaning only outside this box.
1352
+ if (
1353
+ keyData.length > 0 &&
1354
+ !keyData.startsWith("\x1b") &&
1355
+ [...keyData].every(char => char >= " " && char !== "\x7f")
1356
+ ) {
1357
+ rename.value += keyData;
1358
+ this.onRequestRender?.();
1359
+ }
1360
+ }
1361
+
1362
+ /** The delete modal owns every key except Ctrl+C (handled globally). */
1363
+ #handleDeleteKey(keyData: string): void {
1364
+ if (matchesAppInterrupt(keyData) || matchesKey(keyData, "escape") || keyData === "n") {
1365
+ this.#deleteTarget = null;
1366
+ this.onRequestRender?.();
1367
+ return;
1368
+ }
1369
+ if (keyData === "y" || matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
1370
+ void this.#confirmDelete();
1371
+ }
1372
+ }
1373
+
1374
+ #handleMenuKey(keyData: string): void {
1375
+ const menu = this.#menu;
1376
+ if (!menu) return;
1377
+ if (matchesSelectUp(keyData)) {
1378
+ this.#moveMenuItem(-1);
1379
+ return;
1380
+ }
1381
+ if (matchesSelectDown(keyData)) {
1382
+ this.#moveMenuItem(1);
1383
+ return;
1384
+ }
1385
+ if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
1386
+ void this.#runMenuItem();
1387
+ return;
1388
+ }
1389
+ if (matchesAppInterrupt(keyData) || matchesKey(keyData, "escape") || keyData === "m") {
1390
+ this.#menu = undefined;
1391
+ this.onRequestRender?.();
1392
+ }
1393
+ }
1394
+
1395
+ #activate(): void {
1396
+ const flat = this.#visibleRows();
1397
+ const row = flat[this.#selectedIndex(flat)]?.row;
1398
+ if (row) this.#selectRow(row);
1399
+ }
1400
+
1401
+ #requestDelete(): void {
1402
+ const flat = this.#visibleRows();
1403
+ const row = flat[this.#selectedIndex(flat)]?.row;
1404
+ if (row?.type !== "conversation") return;
1405
+ this.#deleteTarget = row;
1406
+ this.onRequestRender?.();
1407
+ }
1408
+
1409
+ async #confirmDelete(): Promise<void> {
1410
+ const target = this.#deleteTarget;
1411
+ if (!target || this.#busy) return;
1412
+ this.#deleteTarget = null;
1413
+ this.#busy = true;
1414
+ try {
1415
+ await this.#source.deleteConversation?.(target.conversation);
1416
+ await this.#source.refresh?.();
1417
+ this.invalidate();
1418
+ this.#rebuild();
1419
+ } catch (error) {
1420
+ this.#notice = tuiTextFmt(
1421
+ "ssErrorPrefixFmt",
1422
+ "Error: %s",
1423
+ error instanceof Error ? error.message : String(error),
1424
+ );
1425
+ } finally {
1426
+ this.#busy = false;
1427
+ this.onRequestRender?.();
1428
+ }
1429
+ }
1430
+
1431
+ /** Pin the selected conversation (`p`) or its project (`P`). */
1432
+ async pinSelected(project: boolean): Promise<void> {
1433
+ const flat = this.#visibleRows();
1434
+ const row = flat[this.#selectedIndex(flat)]?.row;
1435
+ if (!row) return;
1436
+ this.#busy = true;
1437
+ try {
1438
+ if (project) {
1439
+ const projectKey = row.type === "project" ? row.key : row.projectKey;
1440
+ const target = row.type === "project" ? row.project : this.#source.projectByKey?.(projectKey);
1441
+ if (target && !target.isDefault) await this.#source.pinProject?.(target, !target.pinned);
1442
+ } else if (row.type === "conversation") {
1443
+ await this.#source.pinConversation?.(row.conversation, !row.pinned);
1444
+ }
1445
+ await this.#source.refresh?.();
1446
+ this.invalidate();
1447
+ this.#rebuild();
1448
+ } finally {
1449
+ this.#busy = false;
1450
+ this.onRequestRender?.();
1451
+ }
1452
+ }
1453
+
1454
+ /**
1455
+ * Hit-test an SGR mouse report against the rows the last render recorded.
1456
+ * The fullscreen picker paints from screen row 0, so callers pass the
1457
+ * component's frame offset (`row` of the first rendered line, `col` of its
1458
+ * first content column). Keyboard handling never depends on this path.
1459
+ */
1460
+ handleMouse(data: string, offset: { row?: number; col?: number } = {}): boolean {
1461
+ return routeSgrMouseInput(data, event => this.#routeMouse(event, offset));
1462
+ }
1463
+
1464
+ #routeMouse(event: SgrMouseEvent, offset: { row?: number; col?: number }): boolean {
1465
+ const line = event.row - (offset.row ?? this.#lineOffset);
1466
+ const column = event.col - (offset.col ?? this.#columnOffset);
1467
+ if (this.#rename || this.#deleteTarget) {
1468
+ if (!event.leftClick) return true;
1469
+ const hits = this.#rename ? this.#renameButtonHits : this.#deleteButtonHits;
1470
+ const hit = hits.get(line);
1471
+ if (hit?.confirm && column >= hit.confirm.start && column <= hit.confirm.end) {
1472
+ if (this.#rename) void this.#commitRename();
1473
+ else void this.#confirmDelete();
1474
+ } else if (hit?.cancel && column >= hit.cancel.start && column <= hit.cancel.end) {
1475
+ if (this.#rename) this.#rename = undefined;
1476
+ else this.#deleteTarget = null;
1477
+ this.onRequestRender?.();
1478
+ }
1479
+ // Clicks elsewhere keep the modal open.
1480
+ return true;
1481
+ }
1482
+ if (event.wheel !== null) {
1483
+ this.#move(event.wheel * 3);
1484
+ this.onRequestRender?.();
1485
+ return true;
1486
+ }
1487
+ if (!event.leftClick) return true;
1488
+ if (this.#menu) {
1489
+ const itemIndex = this.#menuItemHits.get(line);
1490
+ if (itemIndex !== undefined) {
1491
+ this.#menu.index = itemIndex;
1492
+ void this.#runMenuItem();
1493
+ return true;
1494
+ }
1495
+ this.#menu = undefined;
1496
+ }
1497
+ if (line === 0) {
1498
+ const segment = this.#scopeHits.find(hit => column >= hit.start && column <= hit.end);
1499
+ if (segment) this.setScope(segment.kind);
1500
+ return true;
1501
+ }
1502
+ const menuHit = this.#menuHits.get(line);
1503
+ if (menuHit && column >= menuHit.start) {
1504
+ const flat = this.#visibleRows();
1505
+ const entry = flat.find(candidate => rowIdentity(candidate.row) === menuHit.key);
1506
+ if (entry && (entry.row.type === "workspace" || entry.row.type === "conversation")) {
1507
+ this.#selectedKey = menuHit.key;
1508
+ this.#openMenu(entry.row);
1509
+ }
1510
+ return true;
1511
+ }
1512
+ const hit = this.#hitRows.get(line);
1513
+ if (!hit) return true;
1514
+ const flat = this.#visibleRows();
1515
+ const entry = flat.find(candidate => rowIdentity(candidate.row) === hit.key);
1516
+ if (!entry) return true;
1517
+ this.#searchFocused = false;
1518
+ this.#selectedKey = hit.key;
1519
+ this.#clampScroll();
1520
+ if (hit.kind === "toggle") {
1521
+ const row = entry.row;
1522
+ if (row.type === "project" || row.type === "workspace") this.#toggleCollapse(row);
1523
+ return true;
1524
+ }
1525
+ const now = Date.now();
1526
+ const doubleClick =
1527
+ this.#lastClick !== undefined && this.#lastClick.key === hit.key && now - this.#lastClick.time <= 400;
1528
+ this.#lastClick = doubleClick ? undefined : { key: hit.key, time: now };
1529
+ if (doubleClick && entry.row.type === "conversation") this.onSelect(entry.row.conversation);
1530
+ else this.onRequestRender?.();
1531
+ return true;
1532
+ }
1533
+
1534
+ handleInput(keyData: string): void {
1535
+ if (keyData.startsWith("\x1b[<")) {
1536
+ this.handleMouse(keyData);
1537
+ return;
1538
+ }
1539
+ if (matchesKey(keyData, "ctrl+c")) {
1540
+ this.onExit();
1541
+ return;
1542
+ }
1543
+ // The host's first scan may still be in flight: the skeleton has no rows,
1544
+ // so acting on the key now would silently drop it. Hold keys until the
1545
+ // load lands, then replay them against the loaded rows.
1546
+ if (this.#pendingKeys) {
1547
+ this.#pendingKeys.push(keyData);
1548
+ return;
1549
+ }
1550
+ if (!this.#rename && !this.#deleteTarget && !this.#menu && this.#source.isLoading?.() && this.#source.refresh) {
1551
+ const pending: string[] = [keyData];
1552
+ this.#pendingKeys = pending;
1553
+ void this.#source.refresh().then(() => {
1554
+ // A later refresh may have replaced this queue; its own replay owns it.
1555
+ if (this.#pendingKeys !== pending) return;
1556
+ this.#pendingKeys = undefined;
1557
+ this.invalidate();
1558
+ this.#rebuild();
1559
+ this.onRequestRender?.();
1560
+ for (const key of pending) this.handleInput(key);
1561
+ });
1562
+ return;
1563
+ }
1564
+ if (this.#busy) return;
1565
+ if (this.#rename) {
1566
+ this.#handleRenameKey(keyData);
1567
+ return;
1568
+ }
1569
+ if (this.#deleteTarget) {
1570
+ this.#handleDeleteKey(keyData);
1571
+ return;
1572
+ }
1573
+ if (matchesKey(keyData, "ctrl+r")) {
1574
+ this.#renameSelected();
1575
+ return;
1576
+ }
1577
+ if (this.#menu) {
1578
+ this.#handleMenuKey(keyData);
1579
+ return;
1580
+ }
1581
+ if (!this.#searchFocused && keyData === "m") {
1582
+ this.#openSelectedMenu();
1583
+ return;
1584
+ }
1585
+ if (matchesKey(keyData, "tab")) {
1586
+ this.cycleScope(1);
1587
+ return;
1588
+ }
1589
+ if (keyData === "\x1b[Z") {
1590
+ this.cycleScope(-1);
1591
+ return;
1592
+ }
1593
+ if (matchesSelectUp(keyData)) {
1594
+ this.#move(-1);
1595
+ return;
1596
+ }
1597
+ if (matchesSelectDown(keyData)) {
1598
+ this.#move(1);
1599
+ return;
1600
+ }
1601
+ if (matchesKey(keyData, "pageUp")) {
1602
+ this.#move(-this.#pageSize());
1603
+ return;
1604
+ }
1605
+ if (matchesKey(keyData, "pageDown")) {
1606
+ this.#move(this.#pageSize());
1607
+ return;
1608
+ }
1609
+ if (matchesKey(keyData, "left")) {
1610
+ this.#collapseOrParent();
1611
+ return;
1612
+ }
1613
+ if (matchesKey(keyData, "right")) {
1614
+ this.#expandOrChild();
1615
+ return;
1616
+ }
1617
+ if (matchesKey(keyData, "ctrl+enter") || matchesKey(keyData, "ctrl+return")) {
1618
+ this.#requestDelete();
1619
+ return;
1620
+ }
1621
+ if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
1622
+ this.#activate();
1623
+ return;
1624
+ }
1625
+ if (matchesKey(keyData, "delete")) {
1626
+ this.#requestDelete();
1627
+ return;
1628
+ }
1629
+ if (!this.#searchFocused && keyData === "/") {
1630
+ this.focusSearch();
1631
+ return;
1632
+ }
1633
+ if (!this.#searchFocused && keyData === "r") {
1634
+ void this.refresh();
1635
+ return;
1636
+ }
1637
+ if (matchesKey(keyData, "ctrl+f")) {
1638
+ this.toggleFullPaths();
1639
+ return;
1640
+ }
1641
+ if (!this.#searchFocused && (keyData === "p" || keyData === "P")) {
1642
+ void this.pinSelected(keyData === "P");
1643
+ return;
1644
+ }
1645
+ if (matchesAppInterrupt(keyData)) {
1646
+ this.#clearSearch();
1647
+ return;
1648
+ }
1649
+ if (this.#searchFocused) {
1650
+ const before = this.#search.getValue();
1651
+ this.#search.handleInput(keyData);
1652
+ if (this.#search.getValue() !== before) {
1653
+ this.invalidate();
1654
+ this.#rebuild();
1655
+ }
1656
+ }
1657
+ }
1658
+
1659
+ render(width: number): readonly string[] {
1660
+ const lines: string[] = [];
1661
+ const view = this.#viewFor();
1662
+ const summary = this.#source.scopeSummary();
1663
+ this.#search.placeholder =
1664
+ this.#scope === "all"
1665
+ ? tuiText("resumeSearchAll", "Search all conversations…")
1666
+ : tuiTextFmt(
1667
+ this.#scope === "workspace" ? "resumeSearchInWorkspace" : "resumeSearchInProject",
1668
+ "Search conversations in %s…",
1669
+ this.#scope === "workspace" ? summary.workspaceName : summary.projectName,
1670
+ );
1671
+ this.#hitRows = new Map();
1672
+ this.#menuHits = new Map();
1673
+ this.#menuItemHits = new Map();
1674
+ this.#renameButtonHits = new Map();
1675
+ this.#deleteButtonHits = new Map();
1676
+ const scopeHeader = renderScopeHeaderHits(this.#scope, summary, width);
1677
+ this.#scopeHits = scopeHeader.hits;
1678
+ this.#lineOffset = 0;
1679
+ lines.push(scopeHeader.text);
1680
+ if (this.#search.getValue().length === 0) {
1681
+ lines.push(truncateToWidth(`/ ${theme.fg("dim", this.#search.placeholder)}`, width));
1682
+ } else {
1683
+ lines.push(...this.#search.render(width));
1684
+ }
1685
+ const warningNames = this.#source.scanWarningNames?.() ?? [];
1686
+ const warning =
1687
+ warningNames.length > 0
1688
+ ? tuiTextFmt(
1689
+ "resumeScanWarningFmt",
1690
+ "Some conversation folders could not be read: %s. Showing what was found.",
1691
+ warningNames.join(", "),
1692
+ )
1693
+ : undefined;
1694
+ const error = this.#source.loadError?.();
1695
+ if (this.#notice || error || warning) {
1696
+ const message = this.#notice ?? error ?? `${warning!} · ${tuiText("resumeRetryHint", "r retries")}`;
1697
+ lines.push(truncateToWidth(theme.fg(error ? "error" : "warning", ` ${message}`), width));
1698
+ }
1699
+ if (this.#rename) {
1700
+ const start = lines.length;
1701
+ lines.push(...this.#renderRenameBox(width, start));
1702
+ }
1703
+ if (this.#deleteTarget) {
1704
+ const start = lines.length;
1705
+ lines.push(...this.#renderDeleteBox(width, start));
1706
+ }
1707
+ lines.push("");
1708
+ const loading = !view || this.#source.isLoading?.();
1709
+ if (loading && !view) {
1710
+ lines.push(truncateToWidth(theme.fg("dim", ` ${tuiText("resumeLoading", "Loading conversations…")}`), width));
1711
+ const skeletonWidth = Math.max(4, Math.min(width - 4, 36));
1712
+ for (let index = 0; index < 3; index++) {
1713
+ lines.push(theme.fg("dim", ` ${"░".repeat(skeletonWidth)}`));
1714
+ }
1715
+ return lines;
1716
+ }
1717
+ if (!view) return lines;
1718
+ const searching = view.query.raw.trim().length > 0;
1719
+ if (searching && view.counts.totalConversations > view.counts.conversations) {
1720
+ const hidden = view.counts.totalConversations - view.counts.conversations;
1721
+ lines.push(
1722
+ truncateToWidth(
1723
+ theme.fg(
1724
+ "muted",
1725
+ ` ${tuiTextFmt("resumeHiddenByFilterFmt", "%d conversations hidden by filter", hidden)}`,
1726
+ ),
1727
+ width,
1728
+ ),
1729
+ );
1730
+ }
1731
+ const flat = flattenResumeRows(view.rows, this.#state, searching);
1732
+ if (flat.length === 0) {
1733
+ lines.push(...this.#emptyState(view, searching, width));
1734
+ return lines;
1735
+ }
1736
+ const selectedIndex = this.#selectedIndex(flat);
1737
+ const height = this.#listHeight();
1738
+ this.#clampScroll();
1739
+ const now = Date.now();
1740
+ const conversationRows = flat
1741
+ .map(entry => entry.row)
1742
+ .filter((row): row is ResumeTreeConversationRow => row.type === "conversation");
1743
+ const columns = conversationColumns(conversationRows, now);
1744
+ const bodyStart = lines.length;
1745
+ // Workspace scope shows one workspace, so absolute paths (and even the
1746
+ // path tail) add nothing there; the persisted choice still applies once
1747
+ // the user moves to project/all.
1748
+ const fullPaths = this.#scope !== "workspace" && this.#fullPaths;
1749
+ for (let index = this.#scrollOffset; index < flat.length; index++) {
1750
+ if (lines.length - bodyStart >= height && index !== selectedIndex) break;
1751
+ const row = flat[index]!.row;
1752
+ const selected = index === selectedIndex;
1753
+ const lineIndex = lines.length;
1754
+ if (row.type === "project") {
1755
+ this.#hitRows.set(lineIndex, { kind: "toggle", key: rowIdentity(row) });
1756
+ lines.push(
1757
+ renderProjectRow(row, {
1758
+ width,
1759
+ selected,
1760
+ collapsed: projectCollapsed(row, this.#state) && !searching,
1761
+ now,
1762
+ fullPaths,
1763
+ }),
1764
+ );
1765
+ const ruleIndex = lines.length;
1766
+ this.#hitRows.set(ruleIndex, { kind: "toggle", key: rowIdentity(row) });
1767
+ lines.push(renderProjectRule(width));
1768
+ } else if (row.type === "workspace") {
1769
+ this.#hitRows.set(lineIndex, { kind: "toggle", key: rowIdentity(row) });
1770
+ this.#menuHits.set(lineIndex, { kind: "workspace", key: rowIdentity(row), start: Math.max(0, width - 1) });
1771
+ lines.push(
1772
+ renderWorkspaceRow(row, {
1773
+ width,
1774
+ selected,
1775
+ collapsed: this.#state.collapsedWorkspaces.has(row.key) && !searching,
1776
+ referenceBranch: view.referenceBranch,
1777
+ current: view.currentWorkspaceKey === row.key,
1778
+ fullPaths,
1779
+ showPath: this.#scope !== "workspace",
1780
+ }),
1781
+ );
1782
+ } else {
1783
+ this.#hitRows.set(lineIndex, { kind: "conversation", key: rowIdentity(row) });
1784
+ this.#menuHits.set(lineIndex, {
1785
+ kind: "conversation",
1786
+ key: rowIdentity(row),
1787
+ start: Math.max(0, width - 1),
1788
+ });
1789
+ lines.push(renderConversationRow(row, { width, selected, now, columns, fullPaths }));
1790
+ }
1791
+ }
1792
+ if (this.#menu) {
1793
+ const menu = this.#menu;
1794
+ const kindLabel =
1795
+ menu.kind === "workspace"
1796
+ ? tuiText("resumeMenuWorkspace", "workspace")
1797
+ : tuiText("resumeMenuConversation", "conversation");
1798
+ lines.push(truncateToWidth(` ${theme.fg("accent", `┌ ${kindLabel}`)}`, width));
1799
+ menu.items.forEach((item, itemIndex) => {
1800
+ const itemLine = lines.length;
1801
+ this.#menuItemHits.set(itemLine, itemIndex);
1802
+ const cursor = itemIndex === menu.index ? theme.fg("accent", `${theme.nav.cursor} `) : padding(2);
1803
+ const label = item.disabled ? theme.fg("dim", item.label) : item.label;
1804
+ const marker = itemIndex === menu.index ? theme.fg("accent", "›") : padding(1);
1805
+ lines.push(truncateToWidth(` │ ${cursor}${marker} ${label}`, width));
1806
+ });
1807
+ lines.push(truncateToWidth(` ${theme.fg("dim", "└ Esc")}`, width));
1808
+ }
1809
+ return lines;
1810
+ }
1811
+
1812
+ /**
1813
+ * A small modal box with clickable confirm/cancel buttons. `start` is the
1814
+ * line index in the current frame; button ranges are recorded for mouse
1815
+ * hit-testing. Every line is truncated, never wrapped.
1816
+ */
1817
+ #renderModalBox(
1818
+ title: string,
1819
+ body: readonly string[],
1820
+ width: number,
1821
+ start: number,
1822
+ hits: Map<number, { confirm?: ResumeButtonHit; cancel?: ResumeButtonHit }>,
1823
+ ): string[] {
1824
+ const lines = [truncateToWidth(` ${theme.fg("accent", `┌ ${title}`)}`, width)];
1825
+ for (const line of body) lines.push(truncateToWidth(` ${theme.fg("dim", "│")} ${line}`, width));
1826
+ const prefix = ` ${theme.fg("dim", "└")} `;
1827
+ const confirm = `[${tuiText("resumeConfirmButton", "Confirm")}]`;
1828
+ const cancel = `[${tuiText("resumeCancelButton", "Cancel")}]`;
1829
+ const confirmStart = visibleWidth(prefix);
1830
+ const confirmEnd = confirmStart + visibleWidth(confirm) - 1;
1831
+ const cancelStart = confirmEnd + 3;
1832
+ const cancelEnd = cancelStart + visibleWidth(cancel) - 1;
1833
+ hits.set(start + 1 + body.length, {
1834
+ confirm: { start: confirmStart, end: confirmEnd },
1835
+ cancel: { start: cancelStart, end: cancelEnd },
1836
+ });
1837
+ lines.push(truncateToWidth(`${prefix}${theme.fg("accent", confirm)} ${theme.fg("muted", cancel)}`, width));
1838
+ return lines;
1839
+ }
1840
+
1841
+ #renderRenameBox(width: number, start: number): string[] {
1842
+ const rename = this.#rename!;
1843
+ const title =
1844
+ rename.kind === "workspace"
1845
+ ? tuiText("resumeRenameWorkspaceTitle", "Rename workspace")
1846
+ : tuiText("resumeRenameConversationTitle", "Rename conversation");
1847
+ return this.#renderModalBox(
1848
+ `✎ ${title}`,
1849
+ [`${rename.value}${theme.fg("accent", "▌")}`, tuiText("resumeRenameHint", "Enter saves · Esc cancels")],
1850
+ width,
1851
+ start,
1852
+ this.#renameButtonHits,
1853
+ );
1854
+ }
1855
+
1856
+ #renderDeleteBox(width: number, start: number): string[] {
1857
+ const target = this.#deleteTarget!;
1858
+ const title = target.conversation.title || target.conversation.id;
1859
+ const permanent = this.#source.deleteMode === "permanent";
1860
+ const question = permanent
1861
+ ? tuiTextFmt("resumeDeletePermanentFmt", "Permanently delete “%s”?", title)
1862
+ : tuiTextFmt("resumeDeleteTrashFmt", "Move “%s” to trash?", title);
1863
+ const hint = permanent
1864
+ ? tuiText("resumeDeletePermanentHint", "This cannot be undone.")
1865
+ : tuiText("resumeDeleteTrashHint", "The conversation can be restored from the trash.");
1866
+ return this.#renderModalBox(
1867
+ tuiText("resumeDeleteTitle", "Delete conversation"),
1868
+ [question, theme.fg("dim", hint), theme.fg("dim", tuiText("resumeConfirmKeys", "y confirm · n cancel"))],
1869
+ width,
1870
+ start,
1871
+ this.#deleteButtonHits,
1872
+ );
1873
+ }
1874
+
1875
+ #emptyState(view: ResumeTreeView, searching: boolean, width: number): string[] {
1876
+ const raw = view.query.raw.trim();
1877
+ if (searching) {
1878
+ return [
1879
+ truncateToWidth(
1880
+ theme.fg("muted", ` ${tuiTextFmt("resumeNoMatchFmt", "No conversations match “%s”", raw)}`),
1881
+ width,
1882
+ ),
1883
+ truncateToWidth(theme.fg("dim", ` ${tuiText("resumeClearSearchHint", "Esc clears the search")}`), width),
1884
+ ];
1885
+ }
1886
+ const empty =
1887
+ this.#scope === "workspace"
1888
+ ? tuiText("resumeEmptyWorkspace", "No conversations in this workspace.")
1889
+ : this.#scope === "project"
1890
+ ? tuiText("resumeEmptyProject", "No conversations in this repository.")
1891
+ : tuiText("resumeEmptyAll", "No conversations yet.");
1892
+ return [
1893
+ truncateToWidth(theme.fg("muted", ` ${empty}`), width),
1894
+ truncateToWidth(theme.fg("dim", ` ${tuiText("resumeSwitchScopeHint", "Tab switches scope")}`), width),
1895
+ ];
1896
+ }
1897
+
1898
+ dispose(): void {
1899
+ this.#deleteTarget = null;
1900
+ }
1901
+ }