@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 +41 -0
- package/README.md +3 -3
- package/package.json +37 -36
- package/src/README.md +3 -3
- package/src/core.ts +7 -7
- package/src/dialog.ts +276 -38
- package/src/index.ts +158 -107
- package/src/preview.ts +117 -0
- package/src/safety.ts +69 -12
- package/src/search-cache.ts +188 -0
- package/src/sessions.ts +98 -0
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]` —
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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]` —
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
2
|
-
import {
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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 (
|
|
50
|
-
|
|
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.
|
|
55
|
-
if (selected) this.
|
|
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
|
-
|
|
63
|
-
|
|
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 =
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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.
|
|
77
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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.
|
|
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.
|
|
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
|
|
99
|
-
const
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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.
|
|
353
|
+
this.setPreviewContent();
|
|
354
|
+
this.previewScroll.invalidate();
|
|
117
355
|
}
|
|
118
356
|
}
|