@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,706 @@
1
+ /**
2
+ * Native (`picker`) mapping of the resume directory model.
3
+ *
4
+ * The coding-agent owns the data model (`ResumeDirectory` / `buildResumeView`:
5
+ * project → workspace → conversation). pi-tui cannot import it (dependency
6
+ * direction), so this module declares structurally compatible `*Like` types
7
+ * and accepts a {@link ResumeSourceLike} the host builds from its real
8
+ * directory and view builder (the ANSI tree passes the same object). Nothing
9
+ * here re-groups conversations: the rows come from the model, and this module
10
+ * only turns them into picker props.
11
+ */
12
+ import type { TspPickerGroup, TspPickerItem, TspPickerScope, TspSpan, TspTone } from "@linxiraos/pi-wire";
13
+ import type { SessionSelectorStatus } from "./session-selector";
14
+ import { tuiText, tuiTextFmt } from "../i18n";
15
+ import { compact, kv, md, node, span } from "../native/describe";
16
+ import { pickerAge, pickerDate, pickerHits } from "../native/picker";
17
+ import type { NativeChild } from "../native/node";
18
+ import { plainText } from "../native/spans";
19
+ import { theme } from "../theme/theme";
20
+ import { shortenPath } from "../render/render-utils";
21
+
22
+ export type ResumeScopeKindLike = "workspace" | "project" | "all";
23
+
24
+ export interface ResumeScopeLike {
25
+ readonly kind: ResumeScopeKindLike;
26
+ readonly key?: string;
27
+ readonly projectKey?: string;
28
+ readonly workspaceKey?: string;
29
+ }
30
+
31
+ export type ResumeScopeInputLike = ResumeScopeKindLike | ResumeScopeLike;
32
+
33
+ /** Parsed query, as far as the native renderer reads it (the ANSI tree passes a subset). */
34
+ export interface ResumeQueryLike {
35
+ readonly raw: string;
36
+ readonly terms?: readonly string[];
37
+ readonly project?: readonly string[];
38
+ readonly workspace?: readonly string[];
39
+ readonly branch?: readonly string[];
40
+ readonly status?: readonly string[];
41
+ readonly current?: boolean | null;
42
+ readonly fork?: boolean | null;
43
+ readonly pinned?: boolean | null;
44
+ }
45
+
46
+ /** The session fields the picker reads; the host's `SessionInfo` supplies all of them. */
47
+ export interface ResumeSessionLike {
48
+ readonly path: string;
49
+ readonly id: string;
50
+ readonly cwd: string;
51
+ readonly title?: string;
52
+ readonly modified: Date;
53
+ readonly size?: number;
54
+ readonly firstMessage?: string;
55
+ readonly allMessagesText?: string;
56
+ readonly status?: SessionSelectorStatus;
57
+ readonly parentSessionPath?: string;
58
+ readonly created?: Date;
59
+ readonly messageCount?: number;
60
+ readonly assistantTurns?: number;
61
+ /** Last model recorded in the session (`provider/modelId`), when one was persisted. */
62
+ readonly model?: string;
63
+ }
64
+
65
+ export interface ResumeConversationLike {
66
+ readonly session: ResumeSessionLike;
67
+ readonly key: string;
68
+ readonly id: string;
69
+ readonly path: string;
70
+ readonly title: string;
71
+ readonly cwd: string;
72
+ readonly projectKey: string;
73
+ readonly workspaceKey: string;
74
+ readonly created: Date;
75
+ readonly modified: Date;
76
+ readonly status?: SessionSelectorStatus;
77
+ readonly pinned: boolean;
78
+ readonly current: boolean;
79
+ readonly fork: boolean;
80
+ /** Subagent marker from `session_init.agent`; forks never set it. */
81
+ readonly isSubagent?: boolean;
82
+ readonly agentName?: string;
83
+ readonly parentKey?: string;
84
+ readonly subagents?: readonly ResumeConversationLike[];
85
+ readonly messageCount: number;
86
+ }
87
+
88
+ export interface ResumeWorkspaceLike {
89
+ readonly key: string;
90
+ readonly path: string;
91
+ readonly name: string;
92
+ readonly alias?: string | null;
93
+ readonly displayName?: string;
94
+ readonly projectKey: string;
95
+ readonly branch: string | null;
96
+ readonly isWorktree?: boolean;
97
+ readonly pinned: boolean;
98
+ readonly lastActivity: Date | null;
99
+ /** Flat conversation list; the core model keeps conversations on the directory only. */
100
+ readonly conversations?: readonly ResumeConversationLike[];
101
+ readonly counts: { readonly conversations: number; readonly pinnedConversations: number };
102
+ }
103
+
104
+ export interface ResumeProjectLike {
105
+ readonly key: string;
106
+ readonly root: string;
107
+ readonly name: string;
108
+ readonly alias: string | null;
109
+ readonly displayName: string;
110
+ readonly isDefault: boolean;
111
+ readonly pinned: boolean;
112
+ readonly removable?: boolean;
113
+ readonly collapsed: boolean;
114
+ readonly lastActivity: Date | null;
115
+ readonly workspaces?: readonly ResumeWorkspaceLike[];
116
+ readonly conversations?: readonly ResumeConversationLike[];
117
+ readonly counts: {
118
+ readonly conversations: number;
119
+ readonly workspaces: number;
120
+ readonly pinnedConversations: number;
121
+ };
122
+ }
123
+
124
+ export interface ResumeDirectoryLike {
125
+ readonly projects: readonly ResumeProjectLike[];
126
+ readonly workspaces: readonly ResumeWorkspaceLike[];
127
+ readonly conversations: readonly ResumeConversationLike[];
128
+ readonly defaultProjectKey: string;
129
+ readonly defaultWorkspaceKey: string;
130
+ readonly counts: {
131
+ readonly projects: number;
132
+ readonly workspaces: number;
133
+ readonly conversations: number;
134
+ readonly pinnedConversations: number;
135
+ };
136
+ }
137
+
138
+ export interface ResumeProjectRowLike {
139
+ readonly type: "project";
140
+ readonly key: string;
141
+ readonly project: ResumeProjectLike;
142
+ readonly depth?: 0;
143
+ readonly pinned: boolean;
144
+ readonly collapsed: boolean;
145
+ readonly conversationCount: number;
146
+ readonly totalConversationCount: number;
147
+ readonly workspaceCount: number;
148
+ readonly totalWorkspaceCount: number;
149
+ }
150
+
151
+ export interface ResumeWorkspaceRowLike {
152
+ readonly type: "workspace";
153
+ readonly key: string;
154
+ readonly workspace: ResumeWorkspaceLike;
155
+ readonly projectKey: string;
156
+ readonly depth?: 1;
157
+ readonly pinned: boolean;
158
+ readonly conversationCount: number;
159
+ readonly totalConversationCount: number;
160
+ }
161
+
162
+ export interface ResumeConversationRowLike {
163
+ readonly type: "conversation";
164
+ readonly key: string;
165
+ readonly conversation: ResumeConversationLike;
166
+ readonly projectKey: string;
167
+ readonly workspaceKey: string;
168
+ readonly depth?: number;
169
+ readonly pinned: boolean;
170
+ readonly current: boolean;
171
+ readonly score?: number;
172
+ readonly pinnedOnly?: boolean;
173
+ }
174
+
175
+ export type ResumeViewRowLike = ResumeProjectRowLike | ResumeWorkspaceRowLike | ResumeConversationRowLike;
176
+
177
+ export interface ResumeViewCountsLike {
178
+ readonly conversations: number;
179
+ readonly workspaces?: number;
180
+ readonly projects?: number;
181
+ readonly pinnedConversations?: number;
182
+ readonly totalConversations: number;
183
+ readonly totalWorkspaces?: number;
184
+ readonly totalProjects?: number;
185
+ }
186
+
187
+ export interface ResumeViewLike {
188
+ readonly scope: ResumeScopeLike;
189
+ readonly query: ResumeQueryLike;
190
+ readonly rows: readonly ResumeViewRowLike[];
191
+ /** Conversation rows; the ANSI tree leaves it out and read `rows` instead. */
192
+ readonly conversations?: readonly ResumeConversationRowLike[];
193
+ readonly counts: ResumeViewCountsLike;
194
+ readonly referenceBranch?: string | null;
195
+ readonly currentWorkspaceKey?: string;
196
+ }
197
+
198
+ export interface ResumeViewOptionsLike {
199
+ scope?: ResumeScopeInputLike;
200
+ query?: string;
201
+ currentSessionPath?: string;
202
+ }
203
+
204
+ /** Names and per-scope conversation totals for the segmented control. */
205
+ export interface ResumeScopeSummaryLike {
206
+ readonly workspaceName: string;
207
+ readonly projectName: string;
208
+ readonly counts: Readonly<Record<ResumeScopeKindLike, number>>;
209
+ }
210
+
211
+ /**
212
+ * The host's resume model: the loaded directory plus its pure view builder.
213
+ * Shared with the ANSI tree, which supplies the same object; `directory` is
214
+ * null until the first scan finishes, and `buildView` returns null then.
215
+ */
216
+ export interface ResumeSourceLike {
217
+ readonly directory: ResumeDirectoryLike | null;
218
+ readonly initialScope?: ResumeScopeKindLike;
219
+ readonly deleteMode?: "trash" | "permanent";
220
+ buildView(options: ResumeViewOptionsLike): ResumeViewLike | null;
221
+ scopeSummary?(): ResumeScopeSummaryLike;
222
+ persistScope?(scope: ResumeScopeKindLike): void | Promise<void>;
223
+ refresh?(): Promise<void>;
224
+ pinConversation?(conversation: ResumeConversationLike, pinned: boolean): void | Promise<void>;
225
+ deleteConversation?(conversation: ResumeConversationLike): boolean | Promise<boolean>;
226
+ isLoading?(): boolean;
227
+ loadError?(): string | undefined;
228
+ }
229
+
230
+ export const RESUME_SCOPE_ORDER: readonly ResumeScopeKindLike[] = ["workspace", "project", "all"];
231
+
232
+ /** The scope after `kind` in the three-way cycle (Tab). */
233
+ export function nextResumeScope(kind: ResumeScopeKindLike): ResumeScopeKindLike {
234
+ const index = RESUME_SCOPE_ORDER.indexOf(kind);
235
+ return RESUME_SCOPE_ORDER[(index + 1) % RESUME_SCOPE_ORDER.length]!;
236
+ }
237
+
238
+ /** The segmented control's label for a scope kind. */
239
+ export function resumeScopeLabel(kind: ResumeScopeKindLike): string {
240
+ switch (kind) {
241
+ case "workspace":
242
+ return tuiText("resumeScopeWorkspace", "this workspace");
243
+ case "project":
244
+ return tuiText("resumeScopeProject", "this repository");
245
+ case "all":
246
+ return tuiText("resumeScopeAll", "all projects");
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Per-scope conversation totals for the segmented control. The host summary
252
+ * wins when present (the ANSI tree already computes it); otherwise the active
253
+ * scope's own pre-query total is reused and the other scopes cost one
254
+ * empty-query view each, so the model's scope resolution (current session →
255
+ * default workspace) decides the group, not this module.
256
+ */
257
+ export function resumeScopeTabs(
258
+ source: ResumeSourceLike,
259
+ active: ResumeViewLike,
260
+ currentSessionPath: string | undefined,
261
+ ): TspPickerScope[] {
262
+ const summary = source.scopeSummary?.();
263
+ if (summary) {
264
+ return RESUME_SCOPE_ORDER.map(kind => ({
265
+ id: kind,
266
+ label: resumeScopeLabel(kind),
267
+ count: summary.counts[kind] ?? 0,
268
+ }));
269
+ }
270
+ const counts = new Map<ResumeScopeKindLike, number>();
271
+ counts.set(active.scope.kind, active.counts.totalConversations);
272
+ for (const kind of RESUME_SCOPE_ORDER) {
273
+ if (counts.has(kind)) continue;
274
+ const view = kind === "all" ? undefined : source.buildView({ scope: kind, query: "", currentSessionPath });
275
+ counts.set(kind, view ? view.counts.totalConversations : (source.directory?.counts.conversations ?? 0));
276
+ }
277
+ return RESUME_SCOPE_ORDER.map(kind => ({
278
+ id: kind,
279
+ label: resumeScopeLabel(kind),
280
+ count: counts.get(kind) ?? 0,
281
+ }));
282
+ }
283
+
284
+ function statusWord(status: SessionSelectorStatus | undefined): string | undefined {
285
+ switch (status) {
286
+ case "complete":
287
+ return tuiText("ssStatusDone", "done");
288
+ case "interrupted":
289
+ return tuiText("ssStatusInterrupted", "interrupted");
290
+ case "aborted":
291
+ return tuiText("ssStatusAborted", "aborted");
292
+ case "error":
293
+ return tuiText("ssStatusError", "error");
294
+ case "pending":
295
+ return tuiText("ssStatusPending", "pending");
296
+ default:
297
+ return undefined;
298
+ }
299
+ }
300
+
301
+ function statusTone(status: SessionSelectorStatus | undefined): TspTone | undefined {
302
+ switch (status) {
303
+ case "complete":
304
+ return "success";
305
+ case "interrupted":
306
+ return "warning";
307
+ case "aborted":
308
+ case "error":
309
+ return "error";
310
+ case "pending":
311
+ return "pending";
312
+ default:
313
+ return undefined;
314
+ }
315
+ }
316
+
317
+ function statusDot(status: SessionSelectorStatus | undefined): string | undefined {
318
+ switch (status) {
319
+ case "complete":
320
+ return theme.status.success;
321
+ case "interrupted":
322
+ return theme.status.warning;
323
+ case "aborted":
324
+ return theme.status.aborted;
325
+ case "error":
326
+ return theme.status.error;
327
+ case "pending":
328
+ return theme.status.pending;
329
+ default:
330
+ return undefined;
331
+ }
332
+ }
333
+
334
+ /** `word + dot` for the preview's status row. */
335
+ function statusSpans(status: SessionSelectorStatus | undefined): TspSpan[] | undefined {
336
+ const word = statusWord(status);
337
+ if (!word) return undefined;
338
+ const tone = statusTone(status) ?? "muted";
339
+ const glyph = statusDot(status);
340
+ return glyph ? [span(`${glyph} `, tone), span(word, tone)] : [span(word, tone)];
341
+ }
342
+
343
+ function cwdSpans(cwd: string): TspSpan[] {
344
+ const short = shortenPath(cwd);
345
+ const cut = short.lastIndexOf("/") + 1;
346
+ return cut > 0 && cut < short.length
347
+ ? [span(short.slice(0, cut), "path dim"), span(short.slice(cut), "path")]
348
+ : [span(short, "path")];
349
+ }
350
+
351
+ function conversationMessage(conversation: ResumeConversationLike): string {
352
+ return plainText(conversation.session.firstMessage ?? "")
353
+ .replace(/\s+/g, " ")
354
+ .trim();
355
+ }
356
+
357
+ function conversationLabel(conversation: ResumeConversationLike): string {
358
+ const title = conversation.title ? plainText(conversation.title).trim() : "";
359
+ return title || conversationMessage(conversation) || conversation.id;
360
+ }
361
+
362
+ /** The view's conversation rows, whether the host projected them separately or left only `rows`. */
363
+ function viewConversations(view: ResumeViewLike): readonly ResumeConversationRowLike[] {
364
+ return (
365
+ view.conversations ?? view.rows.filter((row): row is ResumeConversationRowLike => row.type === "conversation")
366
+ );
367
+ }
368
+
369
+ /** Assistant turns the session persisted; older listings fall back to the message count. */
370
+ export function resumeTurnCount(conversation: ResumeConversationLike): number {
371
+ return conversation.session.assistantTurns ?? conversation.messageCount ?? 0;
372
+ }
373
+
374
+ export interface ResumeItemOptions {
375
+ /** Show each conversation's working directory (multi-worktree scopes). */
376
+ readonly showCwd?: boolean;
377
+ readonly now?: number;
378
+ /** Branch of the current workspace; a repeated branch stays off the cards. */
379
+ readonly referenceBranch?: string | null;
380
+ }
381
+
382
+ /** One conversation card: the existing facts plus turns, branch and model. */
383
+ export function resumePickerItem(
384
+ row: ResumeConversationRowLike,
385
+ workspace: ResumeWorkspaceLike | undefined,
386
+ options: ResumeItemOptions = {},
387
+ ): TspPickerItem {
388
+ const conversation = row.conversation;
389
+ const message = conversationMessage(conversation);
390
+ const title = conversation.title ? plainText(conversation.title).trim() : "";
391
+ const badges: { text: string; tone?: TspTone; title?: string }[] = [];
392
+ if (conversation.fork) badges.push({ text: "fork" });
393
+ if (conversation.current) badges.push({ text: "current", tone: "accent" });
394
+ const when = pickerDate(conversation.modified);
395
+ const detail = options.showCwd && conversation.cwd ? cwdSpans(conversation.cwd) : title ? message : undefined;
396
+ const dot = statusTone(conversation.status);
397
+ // The workspace header already names its branch. Repeat it only when this
398
+ // card differs from the current workspace's branch, or is the current one.
399
+ const branch =
400
+ workspace?.branch &&
401
+ (conversation.current || options.referenceBranch === undefined || workspace.branch !== options.referenceBranch)
402
+ ? workspace.branch
403
+ : undefined;
404
+ const model = conversation.session.model;
405
+ return {
406
+ id: conversation.path,
407
+ label: conversationLabel(conversation),
408
+ ...(detail ? { detail } : {}),
409
+ facts: {
410
+ when: pickerAge(conversation.modified, options.now),
411
+ turns: resumeTurnCount(conversation),
412
+ ...(branch ? { branch } : {}),
413
+ ...(model ? { model } : {}),
414
+ },
415
+ ...(dot ? { dot } : {}),
416
+ ...(conversation.pinned ? { icon: "pin" } : {}),
417
+ ...(badges.length > 0 ? { badges } : {}),
418
+ title: options.showCwd && message ? `${message}\n${when}` : when,
419
+ };
420
+ }
421
+
422
+ /** Project and workspace section heads followed by their conversations, from the model's rows. */
423
+ export function resumePickerOrder(view: ResumeViewLike): (string | TspPickerGroup)[] {
424
+ const order: (string | TspPickerGroup)[] = [];
425
+ for (const row of view.rows) {
426
+ switch (row.type) {
427
+ case "project":
428
+ order.push({
429
+ group: `project:${row.key}`,
430
+ label: row.project.displayName,
431
+ count: row.conversationCount,
432
+ });
433
+ break;
434
+ case "workspace": {
435
+ const branch = row.workspace.branch;
436
+ const showBranch =
437
+ branch !== null &&
438
+ branch !== undefined &&
439
+ (row.key === view.currentWorkspaceKey ||
440
+ view.referenceBranch === undefined ||
441
+ branch !== view.referenceBranch);
442
+ const name = row.workspace.displayName ?? row.workspace.alias ?? row.workspace.name;
443
+ order.push({
444
+ group: `workspace:${row.key}`,
445
+ label: showBranch ? `${name} · ${branch}` : name,
446
+ count: row.conversationCount,
447
+ });
448
+ break;
449
+ }
450
+ case "conversation":
451
+ order.push(row.conversation.path);
452
+ break;
453
+ }
454
+ }
455
+ return order;
456
+ }
457
+
458
+ /** Free-text hit ranges for the displayed rows (structured tokens never highlight). */
459
+ export function resumePickerHits(
460
+ view: ResumeViewLike,
461
+ items: ReadonlyMap<string, TspPickerItem>,
462
+ ): Record<string, readonly (readonly [number, number])[]> | undefined {
463
+ const terms = view.query.terms ?? [];
464
+ if (terms.length === 0) return undefined;
465
+ let hits: Record<string, readonly (readonly [number, number])[]> | undefined;
466
+ for (const row of viewConversations(view)) {
467
+ const label = items.get(row.conversation.path)?.label;
468
+ if (typeof label !== "string") continue;
469
+ const ranges = pickerHits(label, terms);
470
+ if (ranges.length > 0) (hits ??= {})[row.conversation.path] = ranges;
471
+ }
472
+ return hits;
473
+ }
474
+
475
+ /** Catalogue items for every conversation in the target scope (identity stable while typing). */
476
+ export function resumePickerItems(
477
+ catalogue: ResumeViewLike,
478
+ directory: ResumeDirectoryLike,
479
+ options: ResumeItemOptions = {},
480
+ ): { items: TspPickerItem[]; byPath: Map<string, TspPickerItem> } {
481
+ const workspaces = new Map(directory.workspaces.map(workspace => [workspace.key, workspace]));
482
+ const byPath = new Map<string, TspPickerItem>();
483
+ const items = viewConversations(catalogue).map(row => {
484
+ const item = resumePickerItem(row, workspaces.get(row.workspaceKey), options);
485
+ byPath.set(item.id, item);
486
+ return item;
487
+ });
488
+ return { items, byPath };
489
+ }
490
+
491
+ /** The selected conversation path: the caller's when still visible, else the first row. */
492
+ export function resumePickerSelection(view: ResumeViewLike, preferred: string | null | undefined): string | null {
493
+ const conversations = viewConversations(view);
494
+ if (preferred && conversations.some(row => row.conversation.path === preferred)) return preferred;
495
+ return conversations[0]?.conversation.path ?? null;
496
+ }
497
+
498
+ /**
499
+ * The same view with locally deleted conversations (and the section heads
500
+ * left empty by them) removed. Deletes are host-driven; the directory is not
501
+ * rebuilt per keystroke, so the picker hides the removed rows itself.
502
+ */
503
+ export function resumeViewWithout(view: ResumeViewLike, hidden: ReadonlySet<string>): ResumeViewLike {
504
+ if (hidden.size === 0) return view;
505
+ const conversations = viewConversations(view).filter(row => !hidden.has(row.conversation.path));
506
+ const rows = view.rows.filter(row => {
507
+ switch (row.type) {
508
+ case "conversation":
509
+ return !hidden.has(row.conversation.path);
510
+ case "workspace":
511
+ return conversations.some(entry => entry.workspaceKey === row.workspace.key);
512
+ case "project":
513
+ return conversations.some(entry => entry.projectKey === row.project.key);
514
+ }
515
+ });
516
+ return { ...view, rows, conversations, counts: { ...view.counts, conversations: conversations.length } };
517
+ }
518
+
519
+ export interface ResumePickerModel {
520
+ readonly view: ResumeViewLike;
521
+ readonly catalogue: ResumeViewLike;
522
+ readonly items: readonly TspPickerItem[];
523
+ readonly order: readonly (string | TspPickerGroup)[];
524
+ readonly hits?: Readonly<Record<string, readonly (readonly [number, number])[]>>;
525
+ readonly tabs: readonly TspPickerScope[];
526
+ readonly selected: string | null;
527
+ readonly current: readonly string[];
528
+ readonly total: number;
529
+ /** Conversations the filter hides (the ANSI tree stays on screen while filtering). */
530
+ readonly hidden: number;
531
+ readonly empty: string;
532
+ readonly placeholder: string;
533
+ }
534
+
535
+ export interface ResumePickerModelOptions {
536
+ readonly scope: ResumeScopeInputLike;
537
+ readonly query: string;
538
+ readonly currentSessionPath?: string;
539
+ readonly hidden?: ReadonlySet<string>;
540
+ readonly showCwd?: boolean;
541
+ readonly now?: number;
542
+ /** Selection to keep when it survives the query; the first row otherwise. */
543
+ readonly preferredPath?: string | null;
544
+ }
545
+
546
+ /**
547
+ * Everything the native sheet needs for one scope + query: the model's
548
+ * filtered view (rows, counts, tokens), a stable catalogue built without the
549
+ * query, the picker items for it, section order, hits, scope tabs and copy.
550
+ */
551
+ export function buildResumePickerModel(
552
+ source: ResumeSourceLike,
553
+ options: ResumePickerModelOptions,
554
+ ): ResumePickerModel | undefined {
555
+ if (!source.directory) return undefined;
556
+ const filtered = source.buildView({
557
+ scope: options.scope,
558
+ query: options.query,
559
+ currentSessionPath: options.currentSessionPath,
560
+ });
561
+ if (!filtered) return undefined;
562
+ const hidden = options.hidden ?? new Set<string>();
563
+ const view = resumeViewWithout(filtered, hidden);
564
+ const catalogueView =
565
+ options.query.length === 0
566
+ ? filtered
567
+ : source.buildView({ scope: options.scope, query: "", currentSessionPath: options.currentSessionPath });
568
+ if (!catalogueView) return undefined;
569
+ const catalogue = resumeViewWithout(catalogueView, hidden);
570
+ const referenceBranch = referenceBranchOf(source.directory, options.currentSessionPath);
571
+ const { items, byPath } = resumePickerItems(catalogue, source.directory, {
572
+ showCwd: options.showCwd,
573
+ now: options.now,
574
+ referenceBranch,
575
+ });
576
+ const conversations = viewConversations(catalogue);
577
+ return {
578
+ view,
579
+ catalogue,
580
+ items,
581
+ order: resumePickerOrder(view),
582
+ hits: resumePickerHits(view, byPath),
583
+ tabs: resumeScopeTabs(source, view, options.currentSessionPath),
584
+ selected: resumePickerSelection(view, options.preferredPath),
585
+ current: conversations.filter(row => row.conversation.current || row.current).map(row => row.conversation.path),
586
+ total: conversations.length,
587
+ hidden: Math.max(0, conversations.length - viewConversations(view).length),
588
+ empty: resumeEmptyText(view),
589
+ placeholder: resumePlaceholder(source, options.scope),
590
+ };
591
+ }
592
+
593
+ /** Branch of the current session's workspace, when the directory knows it. */
594
+ function referenceBranchOf(
595
+ directory: ResumeDirectoryLike,
596
+ currentSessionPath: string | undefined,
597
+ ): string | null | undefined {
598
+ const current = currentSessionPath
599
+ ? directory.conversations.find(conversation => conversation.path === currentSessionPath)
600
+ : directory.conversations.find(conversation => conversation.current);
601
+ if (!current) return undefined;
602
+ return directory.workspaces.find(workspace => workspace.key === current.workspaceKey)?.branch;
603
+ }
604
+
605
+ /** True when the view carries any filter (free text or structured token). */
606
+ export function resumeQueryActive(view: ResumeViewLike): boolean {
607
+ const query = view.query;
608
+ return (
609
+ (query.terms?.length ?? 0) > 0 ||
610
+ (query.project?.length ?? 0) > 0 ||
611
+ (query.workspace?.length ?? 0) > 0 ||
612
+ (query.branch?.length ?? 0) > 0 ||
613
+ (query.status?.length ?? 0) > 0 ||
614
+ query.current != null ||
615
+ query.fork != null ||
616
+ query.pinned != null
617
+ );
618
+ }
619
+
620
+ /** The empty pane's copy: no matches while filtering, else the scope's own empty wording. */
621
+ export function resumeEmptyText(view: ResumeViewLike): string {
622
+ if (resumeQueryActive(view)) {
623
+ return tuiTextFmt("resumeNoMatchFmt", "No conversations match “%s”", view.query.raw.trim());
624
+ }
625
+ switch (view.scope.kind) {
626
+ case "workspace":
627
+ return tuiText("resumeEmptyWorkspace", "No conversations in this workspace.");
628
+ case "project":
629
+ return tuiText("resumeEmptyProject", "No conversations in this repository.");
630
+ case "all":
631
+ return tuiText("resumeEmptyAll", "No conversations yet.");
632
+ }
633
+ }
634
+
635
+ /** The search field's placeholder for the active scope (names come from the host summary). */
636
+ export function resumePlaceholder(source: ResumeSourceLike, scope: ResumeScopeInputLike): string {
637
+ const kind = typeof scope === "string" ? scope : scope.kind;
638
+ if (kind === "all") return tuiText("resumeSearchAll", "Search all conversations…");
639
+ const summary = source.scopeSummary?.();
640
+ const name = kind === "workspace" ? summary?.workspaceName : summary?.projectName;
641
+ return name
642
+ ? tuiTextFmt(
643
+ kind === "workspace" ? "resumeSearchInWorkspace" : "resumeSearchInProject",
644
+ "Search conversations in %s…",
645
+ name,
646
+ )
647
+ : tuiText("resumeSearchAll", "Search all conversations…");
648
+ }
649
+
650
+ const PREVIEW_EXCERPT_CHARS = 1200;
651
+
652
+ /** The selected conversation's preview: project, workspace, branch, status, model, then the excerpt. */
653
+ export function resumeConversationPreview(
654
+ conversation: ResumeConversationLike,
655
+ project: ResumeProjectLike | undefined,
656
+ workspace: ResumeWorkspaceLike | undefined,
657
+ ): NativeChild[] {
658
+ const first = (conversation.session.firstMessage ?? "").trim();
659
+ const allMessages = conversation.session.allMessagesText ?? "";
660
+ const rest = allMessages.startsWith(first) ? allMessages.slice(first.length).trim() : "";
661
+ const excerpt = compact([
662
+ first.length > 0 &&
663
+ md(first.length > PREVIEW_EXCERPT_CHARS ? `${first.slice(0, PREVIEW_EXCERPT_CHARS)}…` : first, {
664
+ tone: "user",
665
+ }),
666
+ rest.length > 0 && md(rest.length > PREVIEW_EXCERPT_CHARS ? `…${rest.slice(-PREVIEW_EXCERPT_CHARS)}` : rest),
667
+ ]);
668
+ const created =
669
+ conversation.created && !Number.isNaN(conversation.created.getTime()) ? conversation.created : undefined;
670
+ const model = conversation.session.model;
671
+ return compact([
672
+ node("text", { text: conversationLabel(conversation), role: "zeta.picker.title" }),
673
+ kv([
674
+ ["Project", project?.displayName],
675
+ ["Workspace", workspace?.displayName ?? workspace?.name],
676
+ ["Branch", workspace?.branch ?? undefined],
677
+ ["Status", statusSpans(conversation.status)],
678
+ ["Model", model],
679
+ ["Turns", String(resumeTurnCount(conversation))],
680
+ ["Created", created && pickerDate(created)],
681
+ ["Modified", pickerDate(conversation.modified)],
682
+ ["Folder", conversation.cwd ? cwdSpans(conversation.cwd) : undefined],
683
+ ]),
684
+ excerpt.length > 0 && node("section", { head: "Conversation" }, excerpt),
685
+ ]);
686
+ }
687
+
688
+ /** Project/workspace lookups for a conversation path, derived from the directory (no re-grouping). */
689
+ export function resumeConversationContext(
690
+ directory: ResumeDirectoryLike,
691
+ path: string,
692
+ ):
693
+ | {
694
+ conversation: ResumeConversationLike;
695
+ project: ResumeProjectLike | undefined;
696
+ workspace: ResumeWorkspaceLike | undefined;
697
+ }
698
+ | undefined {
699
+ const conversation = directory.conversations.find(candidate => candidate.path === path);
700
+ if (!conversation) return undefined;
701
+ return {
702
+ conversation,
703
+ project: directory.projects.find(project => project.key === conversation.projectKey),
704
+ workspace: directory.workspaces.find(workspace => workspace.key === conversation.workspaceKey),
705
+ };
706
+ }