@saifymatteo/pi-session-retention 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,41 @@
1
+ # Changelog
2
+
3
+ Release notes are recorded from 0.1.3 onward.
4
+
5
+ ## [Unreleased]
6
+
7
+ ## [0.1.6]
8
+
9
+ ### Added
10
+
11
+ - Preview trashed sessions in place with Enter, scroll read-only conversation excerpts, and switch between family members. Messages use Pi's themed Markdown rendering with highlighted code blocks and isolated formatting per excerpt. Ctrl+R requests a confirmed whole-family restore from either search or preview; Esc returns from preview without losing the search.
12
+ - Support Page Up/Page Down and Home/End in Session Trash: page through search results and previews, and jump straight to the first or last family or to the preview's top and bottom.
13
+ - Group Session Trash families under their originating project directories, matching session search: folder headers use the cached working directory when known, the selected family's folder stays fixed above the scrolling list, and filtering keeps only folders with matches.
14
+ - Open Session Trash before transcript indexing finishes, enrich search in the background, and reuse guarded metadata caches for unchanged files. Closing the picker cancels indexing; stale or unsafe metadata falls back to filenames without weakening restore or purge validation.
15
+ - Clean unrelated inactive session families while other Pi instances stay open, protecting registered open sessions and coordinating session changes across windows. Unregistered or unverifiable instances still defer maintenance.
16
+
17
+ ### Fixed
18
+
19
+ - Wait for in-flight automatic cleanup when opening Session Trash or requesting explicit cleanup, instead of requiring a retry after reload; retain lock and shutdown safeguards.
20
+ - Separate Trash preview UI from transcript text with fixed muted project metadata, ruled sections, message-body rails, and a fixed excerpt-status strip. Preserve blue/yellow bold role headers without a user-message background.
21
+ - Accept OS pseudo-processes with PID 0, such as Windows System Idle Process, instead of incorrectly deferring session registration and cleanup previews.
22
+
23
+ ## [0.1.5]
24
+
25
+ ### Added
26
+
27
+ - Include package release notes in Pi 1.5.0's Extension Updates.
28
+
29
+ ## [0.1.4]
30
+
31
+ ### Fixed
32
+
33
+ - Schedule automatic maintenance outside the awaited session-start handler; defer launch while the agent is busy.
34
+ - Offer `/session-cleanup` for first approval without interrupting startup, retaining store locking, protected sessions, and shutdown draining.
35
+
36
+ ## [0.1.3]
37
+
38
+ ### Fixed
39
+
40
+ - Classify the package as an extension in the Pi package gallery.
41
+ - Show the package screenshot as its gallery preview.
package/README.md CHANGED
@@ -31,7 +31,7 @@ Moves inactive session families out of Pi's searchable session store. No search
31
31
  - `/session-cleanup preview` — show counts, scope and example candidate families without moving or deleting sessions.
32
32
  - `/session-cleanup` — review the first cleanup and approve daily automatic maintenance. Later calls run maintenance immediately.
33
33
  - `/session-cleanup pause` — pause automatic maintenance; run `/session-cleanup` to review and enable it again.
34
- - `/session-trash [query]` — open a dialog styled like `session-finder.ts`, with a heavy border, padded rows, a `Search:` line and yellow selection, listing trashed families immediately. Type to filter by names, first user prompts, IDs or original paths; use ↑↓ and Enter to select, then confirm a whole-family restore. Esc cancels. An optional query prefills the filter; clearing it shows all families again.
34
+ - `/session-trash [query]` — search trashed families by name, first user prompt, ID or original path. ↑↓ selects; Enter previews; Ctrl+R requests a confirmed whole-family restore from either view. In preview, `[ ]` switches members and ↑↓ / PgUp/PgDn scrolls. Esc returns to search or closes it. An optional query prefills the filter.
35
35
 
36
36
  `/session-cleanup` advertises `[preview|pause]` and offers both arguments through command completion.
37
37
 
@@ -51,13 +51,13 @@ Startup reads bounded session headers and file metadata, not every transcript. A
51
51
 
52
52
  ## Safety and recovery
53
53
 
54
- - Maintenance is serialized by an exclusive per-store lock. Process discovery must succeed, and maintenance defers when its inventory finds another recognized Pi CLI process, including idle windows. Successful inventories are reused for at most one second within a maintenance transaction to avoid starting PowerShell for every file. Session switches and forks in the current window are blocked until maintenance ends. It does not assume that excluding this window protects other windows. Close other Pi windows and run `/session-cleanup` to retry. Unrecognized custom SDK hosts and programs with renamed executables are outside this process detector's guarantee; do not run maintenance alongside them.
54
+ - Maintenance protects every verified open session's whole family, including idle windows, while cleaning unrelated inactive families. All open instances must load the updated extension and share an agent home; reload/update or close any instance reported as unregistered. Shared registration and per-store locks serialize maintenance and block session switches/forks until it ends. Unrecognized SDK hosts and renamed executables remain outside process detection; do not run maintenance alongside them.
55
55
  - On `/reload`, session replacement or graceful shutdown, maintenance cancels pending approval/Trash dialogs, stops at the next safety boundary and waits for in-flight file work to finish before releasing its lock. The lock is never removed while its operation can still mutate files. Forced termination or a crash can still leave a stale lock; these are not automatically removed.
56
56
  - Each family has a recovery manifest written before any file moves. Incomplete operations remain recoverable in `/session-trash` and are never automatically purged. Keep the manifest with its files. If the process crashes leaving `.maintenance.lock`, close all Pi processes before removing that stale lock and retrying. A corrupt lock fails closed rather than being guessed away.
57
57
  - Symlink paths, invalid headers, unresolved parents, family cycles, changed files and corrupt/path-escaping manifests are rejected or protected. An unreadable session can defer cleanup because the complete family graph cannot be established safely. Restore collisions do not overwrite either copy.
58
58
  - Moving session files does not retroactively ingest them into external memory integrations. Such integrations retain their live in-memory state and flush outstanding writes at shutdown; they do not depend on reopening historical JSONL files for those writes. Earlier failed ingestion is not repaired by this extension.
59
59
 
60
- Keep other Pi windows closed during maintenance. Sampled OS process discovery and an extension-owned lock are conservative safeguards, not a native session lock honored by every external program. A new external process can race a cleanup; do not launch another Pi process while maintenance is running. Back up irreplaceable conversations before approving retention.
60
+ Updated, registered Pi windows may stay open during maintenance. Sampled OS process discovery and extension-owned locks are not native session locks honored by every program: do not launch another Pi process while maintenance runs. Back up irreplaceable conversations before approving retention.
61
61
 
62
62
  ## Development checks
63
63
 
package/package.json CHANGED
@@ -1,38 +1,39 @@
1
1
  {
2
- "name": "@saifymatteo/pi-session-retention",
3
- "version": "0.1.4",
4
- "license": "AGPL-3.0-only",
5
- "type": "module",
6
- "keywords": [
7
- "pi-package",
8
- "pi-extension"
9
- ],
10
- "files": [
11
- "src",
12
- "README.md",
13
- "NOTICE.md",
14
- "LICENSE"
15
- ],
16
- "pi": {
17
- "image": "https://raw.githubusercontent.com/saifymatteo/pi-extensions/main/packages/pi-session-retention/screenshot.png",
18
- "extensions": [
19
- "./src/index.ts"
20
- ]
21
- },
22
- "peerDependencies": {
23
- "@earendil-works/pi-coding-agent": "*",
24
- "@earendil-works/pi-tui": "*"
25
- },
26
- "engines": {
27
- "node": ">=24"
28
- },
29
- "description": "Inspect and manage stored sessions; review deletion behavior before use.",
30
- "publishConfig": {
31
- "access": "public"
32
- },
33
- "repository": {
34
- "type": "git",
35
- "url": "git+https://github.com/saifymatteo/pi-extensions.git",
36
- "directory": "packages/pi-session-retention"
37
- }
2
+ "name": "@saifymatteo/pi-session-retention",
3
+ "version": "0.1.6",
4
+ "license": "AGPL-3.0-only",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi-extension"
9
+ ],
10
+ "files": [
11
+ "src",
12
+ "README.md",
13
+ "CHANGELOG.md",
14
+ "NOTICE.md",
15
+ "LICENSE"
16
+ ],
17
+ "pi": {
18
+ "image": "https://raw.githubusercontent.com/saifymatteo/pi-extensions/main/packages/pi-session-retention/screenshot.png",
19
+ "extensions": [
20
+ "./src/index.ts"
21
+ ]
22
+ },
23
+ "peerDependencies": {
24
+ "@earendil-works/pi-coding-agent": "*",
25
+ "@earendil-works/pi-tui": "*"
26
+ },
27
+ "engines": {
28
+ "node": ">=24"
29
+ },
30
+ "description": "Inspect and manage stored sessions; review deletion behavior before use.",
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/saifymatteo/pi-extensions.git",
37
+ "directory": "packages/pi-session-retention"
38
+ }
38
39
  }
package/src/README.md CHANGED
@@ -7,7 +7,7 @@ Moves inactive session families out of Pi's searchable session store. No search
7
7
  - `/session-cleanup preview` — show counts, scope and example candidate families without moving or deleting sessions.
8
8
  - `/session-cleanup` — review the first cleanup and approve daily automatic maintenance. Later calls run maintenance immediately.
9
9
  - `/session-cleanup pause` — pause automatic maintenance; run `/session-cleanup` to review and enable it again.
10
- - `/session-trash [query]` — open a dialog styled like `session-finder.ts`, with a heavy border, padded rows, a `Search:` line and yellow selection, listing trashed families immediately. Type to filter by names, first user prompts, IDs or original paths; use ↑↓ and Enter to select, then confirm a whole-family restore. Esc cancels. An optional query prefills the filter; clearing it shows all families again.
10
+ - `/session-trash [query]` — search trashed families by name, first user prompt, ID or original path. ↑↓ selects; Enter previews; Ctrl+R requests a confirmed whole-family restore from either view. In preview, `[ ]` switches members and ↑↓ / PgUp/PgDn scrolls. Esc returns to search or closes it. An optional query prefills the filter.
11
11
 
12
12
  `/session-cleanup` advertises `[preview|pause]` and offers both arguments through command completion.
13
13
 
@@ -27,13 +27,13 @@ Startup reads bounded session headers and file metadata, not every transcript. A
27
27
 
28
28
  ## Safety and recovery
29
29
 
30
- - Maintenance is serialized by an exclusive per-store lock. Process discovery must succeed, and maintenance defers when its inventory finds another recognized Pi CLI process, including idle windows. Successful inventories are reused for at most one second within a maintenance transaction to avoid starting PowerShell for every file. Session switches and forks in the current window are blocked until maintenance ends. It does not assume that excluding this window protects other windows. Close other Pi windows and run `/session-cleanup` to retry. Unrecognized custom SDK hosts and programs with renamed executables are outside this process detector's guarantee; do not run maintenance alongside them.
30
+ - Maintenance protects every verified open session's whole family, including idle windows, while cleaning unrelated inactive families. All open instances must load the updated extension and share an agent home; reload/update or close any instance reported as unregistered. Shared registration and per-store locks serialize maintenance and block session switches/forks until it ends. Unrecognized SDK hosts and renamed executables remain outside process detection; do not run maintenance alongside them.
31
31
  - On `/reload`, session replacement or graceful shutdown, maintenance cancels pending approval/Trash dialogs, stops at the next safety boundary and waits for in-flight file work to finish before releasing its lock. The lock is never removed while its operation can still mutate files. Forced termination or a crash can still leave a stale lock; these are not automatically removed.
32
32
  - Each family has a recovery manifest written before any file moves. Incomplete operations remain recoverable in `/session-trash` and are never automatically purged. Keep the manifest with its files. If the process crashes leaving `.maintenance.lock`, close all Pi processes before removing that stale lock and retrying. A corrupt lock fails closed rather than being guessed away.
33
33
  - Symlink paths, invalid headers, unresolved parents, family cycles, changed files and corrupt/path-escaping manifests are rejected or protected. An unreadable session can defer cleanup because the complete family graph cannot be established safely. Restore collisions do not overwrite either copy.
34
34
  - Moving session files does not retroactively ingest them into external memory integrations. Such integrations retain their live in-memory state and flush outstanding writes at shutdown; they do not depend on reopening historical JSONL files for those writes. Earlier failed ingestion is not repaired by this extension.
35
35
 
36
- Keep other Pi windows closed during maintenance. Sampled OS process discovery and an extension-owned lock are conservative safeguards, not a native session lock honored by every external program. A new external process can race a cleanup; do not launch another Pi process while maintenance is running. Back up irreplaceable conversations before approving retention.
36
+ Updated, registered Pi windows may stay open during maintenance. Sampled OS process discovery and extension-owned locks are not native session locks honored by every program: do not launch another Pi process while maintenance runs. Back up irreplaceable conversations before approving retention.
37
37
 
38
38
  ## Development checks
39
39
 
package/src/core.ts CHANGED
@@ -23,7 +23,7 @@ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
23
23
 
24
24
  // Check every component, not just the leaf. Missing suffixes are allowed only
25
25
  // for creation; existing symlinks and non-directory ancestors are always errors.
26
- async function safePath(path: string, missing = false) {
26
+ export async function safePath(path: string, missing = false) {
27
27
  const absolute = resolve(path)
28
28
  const root = parse(absolute).root
29
29
  let cursor = root
@@ -37,7 +37,7 @@ async function safePath(path: string, missing = false) {
37
37
  }
38
38
  }
39
39
  async function exists(path: string) { try { await lstat(path); return true } catch (error) { if (absent(error)) return false; throw error } }
40
- async function regular(path: string) {
40
+ export async function regular(path: string) {
41
41
  await safePath(path)
42
42
  const s = await lstat(path)
43
43
  if (!s.isFile()) throw new Error(`Not a regular file: ${path}`)
@@ -144,7 +144,7 @@ export class RetentionStore {
144
144
  if (!inside(this.root, absolute)) throw new Error('Original path escapes root')
145
145
  return absolute
146
146
  }
147
- private async load(id: string): Promise<TrashBatch> {
147
+ async getTrashBatch(id: string): Promise<TrashBatch> {
148
148
  await this.roots()
149
149
  const dir = this.batchPath(id)
150
150
  const path = join(dir, 'manifest.json')
@@ -181,10 +181,10 @@ export class RetentionStore {
181
181
  if (!await exists(this.trashRoot)) return { batches, issues }
182
182
  for (const item of await readdir(this.trashRoot, { withFileTypes: true })) {
183
183
  // Shared store metadata belongs to the adapter, not to a session family.
184
- if (['settings.json', '.maintenance.lock'].includes(item.name) || /^settings-[0-9a-f-]+\.tmp$/.test(item.name)) continue
184
+ if (['settings.json', '.maintenance.lock', '.search-cache'].includes(item.name) || /^(?:settings-|\.lock-)[0-9a-f-]+\.tmp$/.test(item.name)) continue
185
185
  try {
186
186
  if (!item.isDirectory() || item.isSymbolicLink()) throw new Error('Not a batch directory')
187
- batches.push(await this.load(item.name))
187
+ batches.push(await this.getTrashBatch(item.name))
188
188
  } catch (error) { issues.push(`${item.name}: ${message(error)}`) }
189
189
  }
190
190
  return { batches, issues }
@@ -266,7 +266,7 @@ export class RetentionStore {
266
266
  }
267
267
  async restoreFamily(id: string, options: MutationOptions): Promise<{ restored: string[] }> {
268
268
  const now = time(options?.now)
269
- const { manifest } = await this.load(id)
269
+ const { manifest } = await this.getTrashBatch(id)
270
270
  const paths = manifest.files.flatMap(file => [this.original(file.original), join(this.batchPath(id), file.stored)])
271
271
  await this.allow(options, 'restore', paths)
272
272
  // Preflight the whole batch before moving anything, including incomplete ones.
@@ -312,7 +312,7 @@ export class RetentionStore {
312
312
  if (!batch.manifest.ready || batch.missing.length || now - batch.manifest.trashedAt <= RETENTION_MS) continue
313
313
  const id = batch.manifest.id
314
314
  try {
315
- const { manifest, missing } = await this.load(id)
315
+ const { manifest, missing } = await this.getTrashBatch(id)
316
316
  if (!manifest.ready || missing.length || now - manifest.trashedAt <= RETENTION_MS) continue
317
317
  const paths = manifest.files.flatMap(file => [this.original(file.original), join(this.batchPath(id), file.stored)])
318
318
  await this.allow(options, 'purge', paths)
package/src/dialog.ts CHANGED
@@ -1,14 +1,30 @@
1
- import type { Theme } from "@earendil-works/pi-coding-agent";
2
- import { Input, Key, matchesKey, SelectList, truncateToWidth, visibleWidth, type Component, type Focusable, type KeybindingsManager, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
1
+ import { posix, win32 } from "node:path";
2
+ import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
3
+ import { Container, Input, Key, Markdown, matchesKey, ScrollView, sliceByColumn, Spacer, stripTerminalSequences, Text, truncateToWidth, visibleWidth, type Component, type Focusable, type KeybindingsManager, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
4
+ import type { TrashPreview } from "./preview.ts";
3
5
  import { dialogRows, wheelAsArrows } from "./lib/dialog.ts";
4
6
 
5
- export type TrashChoice = { value: string; label: string; description: string; search: string };
7
+ export type TrashChoice = { value: string; label: string; description: string; search: string; members?: number; group?: string; cwd?: string };
8
+ export type TrashPreviewLoader = (id: string, memberIndex: number, signal: AbortSignal) => Promise<TrashPreview>;
9
+ export type TrashIndexProgress = { completed: number; total: number; failed: number };
10
+ type TrashRow = { kind: "folder"; label: string } | { kind: "family"; choice: TrashChoice };
6
11
 
7
12
  export class SessionTrashDialog implements Component, Focusable {
8
13
  private input = new Input({ prompt: "" });
9
- private list!: SelectList;
14
+ private rows: TrashRow[] = [];
15
+ private selectedValue?: string;
16
+ private selectedRow = 0;
17
+ private offset = 0;
10
18
  private filtered: TrashChoice[] = [];
11
19
  private viewport = 5;
20
+ private progress?: TrashIndexProgress;
21
+ private closed = false;
22
+ private hasFocus = false;
23
+ private preview?: { id: string; member: number; count: number; controller: AbortController };
24
+ private previewContent = new Container();
25
+ private previewValue: TrashPreview | string = "";
26
+ private previewScroll = new ScrollView(this.previewContent, { scrollbar: "hidden", overscroll: "contain" });
27
+ private previewTasks = new Set<Promise<void>>();
12
28
 
13
29
  constructor(
14
30
  private tui: TUI,
@@ -17,102 +33,324 @@ export class SessionTrashDialog implements Component, Focusable {
17
33
  private choices: TrashChoice[],
18
34
  query: string,
19
35
  private done: (id: string | undefined) => void,
36
+ private loadPreview?: TrashPreviewLoader,
20
37
  ) {
21
38
  this.input.handleInput(query); // Native text insertion leaves the cursor after the prefilled query.
22
39
  this.filter();
23
40
  }
24
41
 
25
- get focused(): boolean { return this.input.focused; }
26
- set focused(value: boolean) { this.input.focused = value; }
42
+ get focused(): boolean { return this.hasFocus; }
43
+ set focused(value: boolean) { this.hasFocus = value; this.input.focused = value && !this.preview; }
27
44
 
28
- private filter(): void {
45
+ update(choices: TrashChoice[], progress?: TrashIndexProgress): void {
46
+ if (this.closed) return;
47
+ const selected = this.getSelectedItem()?.value;
48
+ this.choices = choices;
49
+ this.progress = progress;
50
+ this.filter(selected);
51
+ this.refresh();
52
+ }
53
+
54
+ private indexStatus(): string {
55
+ if (!this.progress) return "";
56
+ const { completed, total, failed } = this.progress;
57
+ if (completed < total) return `indexing ${completed}/${total}${failed ? ` · ${failed} unavailable` : ""}`;
58
+ return failed ? `search incomplete · ${failed} unavailable` : "";
59
+ }
60
+
61
+ private filter(selected?: string): void {
29
62
  const query = this.input.getValue().toLowerCase();
30
63
  this.filtered = this.choices.filter((choice) => choice.search.toLowerCase().includes(query));
31
- this.buildList();
64
+ this.buildList(selected);
32
65
  }
33
66
 
34
67
  private buildList(selected?: string): void {
35
- this.list = new SelectList(this.filtered, this.viewport, {
36
- selectedPrefix: (text) => text,
37
- selectedText: (text) => text.replace(/^→ /, "▶ "),
38
- description: (text) => text,
39
- scrollInfo: (text) => this.theme.fg("dim", text),
40
- noMatch: () => this.theme.fg("muted", this.choices.length ? "No matching families" : "Trash empty"),
68
+ // Families grouped by their originating project directory, newest trash first
69
+ // (the caller pre-sorts). Folder labels prefer a cached real cwd over the
70
+ // encoded session-directory segment.
71
+ if (selected !== undefined) this.selectedValue = selected;
72
+ if (this.selectedValue && !this.filtered.some(choice => choice.value === this.selectedValue)) this.selectedValue = undefined;
73
+ this.selectedValue ??= this.filtered[0]?.value;
74
+ const labels = new Map<string, string>();
75
+ for (const choice of this.filtered) {
76
+ const key = choice.group || "";
77
+ if (!labels.has(key)) labels.set(key, choice.cwd || "");
78
+ else if (choice.cwd && !labels.get(key)) labels.set(key, choice.cwd);
79
+ }
80
+ const rows: TrashRow[] = [];
81
+ const seen = new Set<string>();
82
+ for (const choice of this.filtered) {
83
+ const key = choice.group || "";
84
+ if (!seen.has(key)) { seen.add(key); rows.push({ kind: "folder", label: labels.get(key) || key || "(no project)" }); }
85
+ rows.push({ kind: "family", choice });
86
+ }
87
+ this.rows = rows;
88
+ this.selectedRow = Math.max(0, rows.findIndex(row => row.kind === "family" && row.choice.value === this.selectedValue));
89
+ this.clampOffset();
90
+ }
91
+
92
+ getSelectedItem(): TrashChoice | undefined { return this.filtered.find(choice => choice.value === this.selectedValue); }
93
+
94
+ private activeFolderIndex(): number {
95
+ for (let i = Math.min(this.selectedRow, this.rows.length - 1); i >= 0; i--) {
96
+ if (this.rows[i]?.kind === "folder") return i;
97
+ }
98
+ return -1;
99
+ }
100
+
101
+ private clampOffset(): void {
102
+ this.offset = Math.max(0, Math.min(this.selectedRow - this.viewport + 1, Math.max(0, this.rows.length - this.viewport)));
103
+ }
104
+
105
+ private refresh(): void { this.input.invalidate(); this.tui.requestRender(); }
106
+
107
+ private setPreviewContent(value: TrashPreview | string = this.previewValue): void {
108
+ this.previewValue = value;
109
+ this.previewContent.clear();
110
+ if (typeof value === "string") {
111
+ this.previewContent.addChild(new Text(value, 0, 0));
112
+ return;
113
+ }
114
+ const markdownTheme = getMarkdownTheme();
115
+ const userOptions = { preserveOrderedListMarkers: true, preserveBackslashEscapes: true };
116
+ const section = (label: string) => this.previewContent.addChild({
117
+ render: (width: number) => {
118
+ const heading = truncateToWidth(`── ${label} `, width, "");
119
+ return [this.theme.fg("muted", markdownTheme.bold(heading) + "─".repeat(Math.max(0, width - visibleWidth(heading))))];
120
+ },
121
+ invalidate() {},
41
122
  });
42
- if (selected !== undefined) this.list.setSelectedIndex(Math.max(0, this.filtered.findIndex((choice) => choice.value === selected)));
123
+ const message = (role: "user" | "assistant", text: string) => {
124
+ const isUser = role === "user";
125
+ this.previewContent.addChild(new Text(this.theme.fg(isUser ? "mdLink" : "warning", markdownTheme.bold(isUser ? "You" : "Assistant")), 0, 0));
126
+ // Each excerpt retains its native Markdown cache and isolated code fences.
127
+ const body = new Markdown(text, 0, 0, markdownTheme, undefined, isUser ? userOptions : undefined);
128
+ const rail = {
129
+ children: [body],
130
+ render: (width: number) => {
131
+ const inset = width >= 4 ? 3 : 0;
132
+ const prefix = inset ? this.theme.fg("dim", "│") + " " : "";
133
+ return body.render(Math.max(1, width - inset)).map(line => truncateToWidth(prefix + line, width, ""));
134
+ },
135
+ invalidate: () => body.invalidate(),
136
+ };
137
+ this.previewContent.addChild(rail);
138
+ this.previewContent.addChild(new Spacer(1));
139
+ };
140
+ section("FIRST REQUEST");
141
+ message("user", value.firstRequest || "No user text recorded");
142
+ section("RECENT MESSAGES");
143
+ for (const entry of value.recent) message(entry.role, entry.text);
144
+ }
145
+
146
+ private finish(id?: string): void {
147
+ this.dispose();
148
+ this.done(id);
149
+ }
150
+
151
+ private openPreview(id: string, member = 0): void {
152
+ const choice = this.choices.find(choice => choice.value === id);
153
+ if (!choice || this.closed) return;
154
+ this.preview?.controller.abort();
155
+ const count = choice.members ?? 1;
156
+ const state = { id, member: (member + count) % count, count, controller: new AbortController() };
157
+ this.preview = state;
158
+ this.input.focused = false;
159
+ this.setPreviewContent("Loading preview…");
160
+ this.previewScroll.scrollToStart();
161
+ this.refresh();
162
+ const current = () => !this.closed && this.preview === state && !state.controller.signal.aborted;
163
+ const task = Promise.resolve().then(async () => {
164
+ state.controller.signal.throwIfAborted();
165
+ if (!this.loadPreview) throw new Error("Preview unavailable");
166
+ return this.loadPreview(id, state.member, state.controller.signal);
167
+ }).then(value => {
168
+ if (!current()) return;
169
+ this.setPreviewContent(value);
170
+ this.refresh();
171
+ }, error => {
172
+ if (!current()) return;
173
+ const reason = stripTerminalSequences(String(error instanceof Error ? error.message : error)).replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").slice(0, 1000);
174
+ this.setPreviewContent(`Preview unavailable: ${reason}\n\nEsc returns to search. Ctrl+R can still request a freshly validated restore.`);
175
+ this.refresh();
176
+ }).catch(() => { /* The host may dispose its renderer during completion. */ });
177
+ this.previewTasks.add(task);
178
+ void task.then(() => this.previewTasks.delete(task));
43
179
  }
44
180
 
45
181
  handleMouse(event: TuiMouseEvent) { return wheelAsArrows(event, (key) => this.handleInput(key)); }
46
182
 
47
183
  handleInput(data: string): void {
184
+ if (this.closed) return;
48
185
  const kb = this.keybindings;
49
- if (kb.matches(data, "tui.select.cancel") || matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
50
- this.done(undefined);
186
+ if (matchesKey(data, Key.ctrl("c"))) { this.finish(); return; }
187
+ if (kb.matches(data, "tui.select.cancel") || matchesKey(data, Key.escape)) {
188
+ if (this.preview) {
189
+ this.preview.controller.abort();
190
+ this.preview = undefined;
191
+ this.input.focused = this.hasFocus;
192
+ this.refresh();
193
+ } else this.finish();
194
+ return;
195
+ }
196
+ if (matchesKey(data, Key.ctrl("r"))) {
197
+ const id = this.preview?.id ?? this.getSelectedItem()?.value;
198
+ if (id) this.finish(id);
199
+ return;
200
+ }
201
+ if (this.preview) {
202
+ if ((data === "[" || data === "]") && this.preview.count > 1) this.openPreview(this.preview.id, this.preview.member + (data === "[" ? -1 : 1));
203
+ else {
204
+ if (kb.matches(data, "tui.select.up")) this.previewScroll.scrollBy(-1);
205
+ if (kb.matches(data, "tui.select.down")) this.previewScroll.scrollBy(1);
206
+ if (kb.matches(data, "tui.select.pageUp")) this.previewScroll.scrollBy(-Math.max(1, this.previewScroll.viewportHeight));
207
+ if (kb.matches(data, "tui.select.pageDown")) this.previewScroll.scrollBy(Math.max(1, this.previewScroll.viewportHeight));
208
+ if (matchesKey(data, Key.home)) this.previewScroll.scrollToStart();
209
+ if (matchesKey(data, Key.end)) this.previewScroll.scrollToEnd();
210
+ this.refresh();
211
+ }
51
212
  return;
52
213
  }
53
214
  if (kb.matches(data, "tui.select.confirm")) {
54
- const selected = this.list.getSelectedItem();
55
- if (selected) this.done(selected.value);
215
+ const selected = this.getSelectedItem();
216
+ if (selected) this.openPreview(selected.value);
56
217
  return;
57
218
  }
58
219
  const up = kb.matches(data, "tui.select.up");
59
220
  const down = kb.matches(data, "tui.select.down");
60
221
  const pageUp = kb.matches(data, "tui.select.pageUp");
61
222
  const pageDown = kb.matches(data, "tui.select.pageDown");
62
- if (up || down || pageUp || pageDown) {
63
- const index = this.filtered.findIndex((choice) => choice.value === this.list.getSelectedItem()?.value);
223
+ const home = matchesKey(data, Key.home);
224
+ const end = matchesKey(data, Key.end);
225
+ if (up || down || pageUp || pageDown || home || end) {
226
+ const index = this.filtered.findIndex((choice) => choice.value === this.selectedValue);
64
227
  const count = this.filtered.length;
65
228
  if (count) {
66
- const next = pageUp || pageDown
67
- ? Math.max(0, Math.min(count - 1, index + (pageUp ? -this.viewport : this.viewport)))
68
- : (index + (up ? -1 : 1) + count) % count;
69
- this.list.setSelectedIndex(next);
229
+ const next = home ? 0 : end ? count - 1
230
+ : pageUp || pageDown
231
+ ? Math.max(0, Math.min(count - 1, index + (pageUp ? -this.viewport : this.viewport)))
232
+ : (index + (up ? -1 : 1) + count) % count;
233
+ this.selectedValue = this.filtered[next]?.value;
234
+ this.buildList(this.selectedValue);
70
235
  }
71
236
  } else {
72
237
  const previous = this.input.getValue();
73
238
  this.input.handleInput(data);
74
239
  if (previous !== this.input.getValue()) this.filter();
75
240
  }
76
- this.invalidate();
77
- this.tui.requestRender();
241
+ this.refresh();
242
+ }
243
+
244
+ private renderPreview(width: number, height: number): string[] {
245
+ const state = this.preview!;
246
+ const rows = Math.max(1, Math.min(height, dialogRows(this.tui, 0)));
247
+ // Eight fixed chrome rows still need room for a readable first message.
248
+ const compact = width < 16 || rows < 12;
249
+ const bodyWidth = Math.max(1, compact ? width : width - 6);
250
+ const value = typeof this.previewValue === "string" ? undefined : this.previewValue;
251
+ const member = `Session ${state.member + 1}/${state.count}${state.count > 1 ? " · [ ] switch" : ""} · read-only`;
252
+ const notice = value ? `Preview only · ${value.truncated ? "Some content shortened" : "Current branch · Tools/thinking omitted"}` : "Read-only preview";
253
+ const footer = `↑↓ PgUp/PgDn Home/End scroll · Ctrl+R restore all ${state.count} · Esc back`;
254
+ const head = rows >= 3 ? [this.theme.fg("muted", member)] : [];
255
+ const tail = [...(rows >= 4 ? [this.theme.fg("muted", notice)] : []), ...(rows >= 2 ? [this.theme.fg("dim", footer)] : [])];
256
+ const capacity = Math.max(1, rows - (compact ? head.length + tail.length : 8));
257
+ const content = this.previewScroll.render(bodyWidth);
258
+ this.previewScroll.updateLayout(content.length, capacity, () => this.tui.requestRender());
259
+ const visible = content.slice(this.previewScroll.scrollTop, this.previewScroll.scrollTop + capacity);
260
+ if (compact) return [...head, ...visible, ...tail].map(line => truncateToWidth(line, width, ""));
261
+ while (visible.length < capacity) visible.push("");
262
+ const border = (text: string) => this.theme.fg("border", text);
263
+ const row = (text: string, color: "text" | "muted" | "dim" = "text") => {
264
+ const fitted = truncateToWidth(text, bodyWidth, "…");
265
+ return border("┃ ") + this.theme.fg(color, fitted + " ".repeat(Math.max(0, bodyWidth - visibleWidth(fitted)))) + border(" ┃");
266
+ };
267
+ const cwd = (value?.cwd || "").replace(/[\r\n\t]/g, " ");
268
+ const project = (/^[a-z]:[\\/]|^\\\\/i.test(cwd) ? win32 : posix).basename(cwd) || cwd || (value ? "Project not recorded" : "");
269
+ const projectWidth = bodyWidth - visibleWidth(` · ${member}`);
270
+ const metadata = project && projectWidth >= 6 ? `${truncateToWidth(project, projectWidth, "…")} · ${member}` : member;
271
+ const pathWidth = visibleWidth(cwd);
272
+ const path = pathWidth > bodyWidth ? `…${sliceByColumn(cwd, pathWidth - bodyWidth + 1, bodyWidth - 1, true)}` : cwd;
273
+ const label = (value?.name || this.choices.find(choice => choice.value === state.id)?.label || state.id).replace(/[\r\n\t]/g, " ");
274
+ const title = truncateToWidth(` Session Trash › ${label} `, width - 3, "…");
275
+ return [border(`┏${title}${"━".repeat(Math.max(0, width - 2 - visibleWidth(title)))}┓`), row(metadata, "muted"), row(path, "muted"), row(""), ...visible.map(line => row(line)), border(`┣${"━".repeat(width - 2)}┫`), row(notice, "muted"), row(footer, "dim"), border(`┗${"━".repeat(width - 2)}┛`)];
78
276
  }
79
277
 
80
278
  render(width: number): string[] {
81
279
  const height = Math.max(1, this.tui.terminal.rows);
82
- // Fixed lines: 2 borders, search, footer, list scroll info.
83
- const viewport = dialogRows(this.tui, 5);
280
+ if (this.preview) return this.renderPreview(width, height);
281
+ // Fixed lines: 2 borders, search, active-folder header, footer, list scroll info.
282
+ const viewport = dialogRows(this.tui, 6);
84
283
  if (viewport !== this.viewport) {
85
284
  this.viewport = viewport;
86
- this.buildList(this.list.getSelectedItem()?.value);
285
+ this.buildList(this.selectedValue);
87
286
  }
88
287
  // Below the frame's minimum size, keep the native input/cursor usable.
89
288
  if (width < 16 || height < 8) return [
90
289
  ...this.input.render(Math.max(1, width)),
91
- ...this.list.render(Math.max(4, width)).map((line) => this.theme.fg(line.startsWith("▶ ") ? "warning" : "text", line)),
290
+ ...(this.indexStatus() ? [this.indexStatus()] : []),
291
+ ...this.windowLines(this.viewport).map((line) => this.theme.fg(line.startsWith("▶ ") ? "warning" : "text", line)),
92
292
  ].slice(0, height).map((line) => truncateToWidth(line, Math.max(0, width), ""));
93
293
 
94
294
  // Match session-finder.ts: heavy border, two-cell padding and warning selection.
95
295
  const border = (value: string) => this.theme.fg("border", value);
96
296
  const inner = width - 2;
97
297
  const bodyWidth = inner - 4;
98
- const title = truncateToWidth(` Session Trash · ${this.filtered.length} results `, inner - 1, "…");
99
- const row = (value: string, color: "text" | "warning" | "dim" = "text") => {
298
+ const progress = this.indexStatus();
299
+ const title = truncateToWidth(` Session Trash${progress ? ` · ${progress}` : ""} · ${this.filtered.length} results `, inner - 1, "…");
300
+ const row = (value: string, color: "text" | "warning" | "dim" | "muted" = "text") => {
100
301
  const fitted = truncateToWidth(value, bodyWidth, "…");
101
302
  const padded = ` ${fitted}${" ".repeat(Math.max(0, bodyWidth - visibleWidth(fitted)))} `;
102
303
  return border("┃") + this.theme.fg(color, padded) + border("┃");
103
304
  };
104
305
  const lines = [border(`┏${title}${"━".repeat(Math.max(0, inner - visibleWidth(title)))}┓`)];
105
306
  for (const line of this.input.render(bodyWidth - 8)) lines.push(row(`Search: ${line}`, "warning"));
106
- const items = this.list.render(bodyWidth);
107
- while (items.length < viewport + 1) items.push("");
108
- for (const item of items) lines.push(row(item, item.startsWith("▶ ") ? "warning" : "text"));
109
- lines.push(row("↑↓ / PgUp/PgDn navigate · type to search · Enter restore · Esc close", "dim"));
307
+ // Match session-finder.ts: the selected family's folder stays fixed above the
308
+ // scrolling list, and inline folder headers skip the fixed copy. With no rows,
309
+ // render the empty-state text instead of folder chrome.
310
+ const folderIndex = this.activeFolderIndex();
311
+ if (!this.rows.length) {
312
+ const empty = this.indexStatus() ? `No matches yet — ${this.indexStatus()}` : this.choices.length ? "No matching families" : "Trash empty";
313
+ lines.push(row(this.theme.fg("muted", empty), "muted"));
314
+ for (let i = 1; i < this.viewport + 1; i++) lines.push(row(""));
315
+ } else {
316
+ const folder = folderIndex >= 0 && this.rows[folderIndex]?.kind === "folder" ? this.rows[folderIndex].label : "(no project)";
317
+ lines.push(row(this.theme.fg("dim", `▸ ${folder}`), "dim"));
318
+ for (const line of this.windowLines(this.viewport, folderIndex)) lines.push(row(line, line.startsWith("▶ ") ? "warning" : line.startsWith("▸ ") ? "dim" : "text"));
319
+ }
320
+ const info = this.rows.length > this.viewport ? ` (${Math.max(0, this.filtered.findIndex(choice => choice.value === this.selectedValue) + 1) || 1}/${this.filtered.length})` : "";
321
+ lines.push(row(this.theme.fg("dim", info), "dim"));
322
+ lines.push(row("↑↓ navigate · Home/End jump · type to search · Enter preview · Ctrl+R restore · Esc close", "dim"));
110
323
  lines.push(border(`┗${"━".repeat(inner)}┛`));
111
324
  return lines;
112
325
  }
113
326
 
327
+ /** Windowed list rows: folder headers plus the selected family marker. */
328
+ private windowLines(count: number, skipFolderIndex = -1): string[] {
329
+ // Like session-finder: slice rows, drop the fixed folder copy, cap at `count`.
330
+ const window: string[] = [];
331
+ for (let i = this.offset; i < this.rows.length && window.length < count; i++) {
332
+ if (i === skipFolderIndex) continue;
333
+ const entry = this.rows[i];
334
+ window.push(entry.kind === "folder" ? `▸ ${entry.label}` : `${entry.choice.value === this.selectedValue ? "▶ " : " "}${entry.choice.label}${entry.choice.description ? ` · ${entry.choice.description}` : ""}`);
335
+ }
336
+ while (window.length < count) window.push("");
337
+ return window;
338
+ }
339
+
340
+ dispose(): void {
341
+ this.closed = true;
342
+ this.preview?.controller.abort();
343
+ this.input.focused = false;
344
+ }
345
+
346
+ async drain(): Promise<void> {
347
+ this.dispose();
348
+ await Promise.all([...this.previewTasks]);
349
+ }
350
+
114
351
  invalidate(): void {
115
352
  this.input.invalidate();
116
- this.list.invalidate();
353
+ this.setPreviewContent();
354
+ this.previewScroll.invalidate();
117
355
  }
118
356
  }