@visual-z/xeno 0.1.4 → 0.2.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.
Files changed (146) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +143 -33
  3. package/docs/install.md +18 -6
  4. package/docs/providers.md +147 -0
  5. package/package.json +8 -2
  6. package/src/apps/cli.ts +42 -14
  7. package/src/apps/commands.ts +32 -4
  8. package/src/apps/completions.ts +9 -6
  9. package/src/apps/export-cli.ts +24 -0
  10. package/src/apps/extension-ui.ts +330 -0
  11. package/src/apps/input.ts +16 -1
  12. package/src/apps/model-catalog.ts +42 -0
  13. package/src/apps/project-trust.ts +49 -0
  14. package/src/apps/render.ts +1 -0
  15. package/src/apps/resource-browser.ts +75 -0
  16. package/src/apps/resources.ts +25 -0
  17. package/src/apps/session-browser.ts +113 -0
  18. package/src/apps/session-picker.ts +55 -0
  19. package/src/apps/shell.ts +1177 -378
  20. package/src/apps/startup.ts +400 -268
  21. package/src/core/action-defaults.ts +306 -0
  22. package/src/core/attachment-input.ts +17 -0
  23. package/src/core/catalog-merge.ts +19 -0
  24. package/src/core/event-validation.ts +19 -2
  25. package/src/core/events.ts +45 -2
  26. package/src/core/export.ts +6 -2
  27. package/src/core/extension-flags.ts +14 -0
  28. package/src/core/images.ts +46 -0
  29. package/src/core/json-event.ts +2 -0
  30. package/src/core/keymap.ts +43 -119
  31. package/src/core/model-replay.ts +22 -0
  32. package/src/core/model-scope.ts +53 -0
  33. package/src/core/packages.ts +47 -0
  34. package/src/core/project.ts +21 -3
  35. package/src/core/prompts.ts +33 -5
  36. package/src/core/queue.ts +18 -0
  37. package/src/core/resource-selection.ts +36 -0
  38. package/src/{runtime → core}/resume.ts +15 -22
  39. package/src/core/session-archive.ts +4 -0
  40. package/src/core/session-browser.ts +82 -0
  41. package/src/core/session-entry.ts +56 -0
  42. package/src/core/session-position.ts +26 -0
  43. package/src/core/settings.ts +169 -154
  44. package/src/core/theme.ts +15 -6
  45. package/src/core/thinking.ts +26 -0
  46. package/src/core/tokens.ts +2 -0
  47. package/src/core/transcript.ts +15 -5
  48. package/src/runtime/auth.ts +116 -11
  49. package/src/runtime/catalog.ts +54 -151
  50. package/src/runtime/clipboard.ts +114 -0
  51. package/src/runtime/extension-loader.ts +37 -0
  52. package/src/runtime/extension-ui.ts +55 -0
  53. package/src/runtime/extensions.ts +273 -0
  54. package/src/runtime/images.ts +76 -0
  55. package/src/runtime/loop.ts +213 -83
  56. package/src/runtime/model-config.ts +232 -0
  57. package/src/runtime/model-data.LICENSE +27 -0
  58. package/src/runtime/model-data.ts +21 -0
  59. package/src/runtime/model-replay.ts +10 -0
  60. package/src/runtime/model-scope.ts +20 -0
  61. package/src/runtime/model-sources.ts +151 -0
  62. package/src/runtime/model.ts +74 -11
  63. package/src/runtime/models-fetch.ts +6 -104
  64. package/src/runtime/oauth.ts +4 -3
  65. package/src/runtime/openai-responses.ts +12 -4
  66. package/src/runtime/openai.ts +13 -4
  67. package/src/runtime/package-manager.ts +125 -0
  68. package/src/runtime/pi-provider.ts +235 -0
  69. package/src/runtime/progress.ts +26 -0
  70. package/src/runtime/provider-access.ts +21 -0
  71. package/src/runtime/provider-protocols.ts +29 -0
  72. package/src/runtime/provider-request.ts +73 -0
  73. package/src/runtime/provider.ts +26 -6
  74. package/src/runtime/rich-text.ts +36 -0
  75. package/src/runtime/tools.ts +41 -5
  76. package/src/store/command.ts +7 -1
  77. package/src/store/context-files.ts +1 -1
  78. package/src/store/log.ts +65 -0
  79. package/src/store/models.ts +28 -9
  80. package/src/store/observations.ts +4 -2
  81. package/src/store/packages.ts +98 -0
  82. package/src/store/recovery.ts +8 -0
  83. package/src/store/resource-index.ts +170 -0
  84. package/src/store/resources.ts +75 -142
  85. package/src/store/session-archive.ts +162 -0
  86. package/src/store/session-browser.ts +68 -0
  87. package/src/store/session-position.ts +27 -0
  88. package/src/store/sessions.ts +87 -66
  89. package/src/store/trust.ts +13 -0
  90. package/src/tui/chat-viewport.ts +47 -0
  91. package/src/tui/editor.ts +62 -15
  92. package/src/tui/host.ts +50 -9
  93. package/src/tui/input.ts +8 -2
  94. package/src/tui/select.ts +24 -6
  95. package/src/tui/settings.ts +4 -0
  96. package/src/tui/system-theme.ts +700 -0
  97. package/src/tui/transcript.ts +63 -11
  98. package/src/tui/tree.ts +161 -95
  99. package/src/vendor/pi-rich/LICENSE.upstream +21 -0
  100. package/src/vendor/pi-rich/UPSTREAM.json +17 -0
  101. package/src/vendor/pi-rich/clipboard-command.ts +44 -0
  102. package/src/vendor/pi-rich/exif-orientation.ts +182 -0
  103. package/src/vendor/pi-rich/html.ts +51 -0
  104. package/src/vendor/pi-rich/image-convert.ts +49 -0
  105. package/src/vendor/pi-rich/image-resize-core.ts +164 -0
  106. package/src/vendor/pi-rich/image-resize-worker.ts +42 -0
  107. package/src/vendor/pi-rich/image-resize.ts +123 -0
  108. package/src/vendor/pi-rich/mime.ts +116 -0
  109. package/src/vendor/pi-rich/photon.ts +139 -0
  110. package/src/vendor/pi-rich/syntax-highlight.ts +214 -0
  111. package/src/vendor/pi-tui/UPSTREAM.json +50 -0
  112. package/src/vendor/pi-tui/VENDOR.md +7 -5
  113. package/src/vendor/pi-tui/autocomplete.ts +35 -10
  114. package/src/vendor/pi-tui/colors.ts +367 -0
  115. package/src/vendor/pi-tui/components/box.ts +8 -9
  116. package/src/vendor/pi-tui/components/editor.ts +4 -2
  117. package/src/vendor/pi-tui/components/markdown.ts +8 -2
  118. package/src/vendor/pi-tui/index.ts +31 -1
  119. package/src/vendor/pi-tui/native/UPSTREAM.json +23 -0
  120. package/src/vendor/pi-tui/native/clipboard.h +208 -0
  121. package/src/vendor/pi-tui/native/darwin/README.md +22 -0
  122. package/src/vendor/pi-tui/native/darwin/build.sh +69 -0
  123. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
  124. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
  125. package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +132 -0
  126. package/src/vendor/pi-tui/native/linux/README.md +23 -0
  127. package/src/vendor/pi-tui/native/linux/build.sh +65 -0
  128. package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
  129. package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
  130. package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +70 -0
  131. package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +274 -0
  132. package/src/vendor/pi-tui/native/napi.h +91 -0
  133. package/src/vendor/pi-tui/native/win32/README.md +31 -0
  134. package/src/vendor/pi-tui/native/win32/build.mjs +232 -0
  135. package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
  136. package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
  137. package/src/vendor/pi-tui/native/win32/src/win32-platform.c +241 -0
  138. package/src/vendor/pi-tui/native-platform.ts +2 -0
  139. package/src/vendor/pi-tui/oklab.ts +233 -0
  140. package/src/vendor/pi-tui/terminal-colors.ts +26 -8
  141. package/src/vendor/pi-tui/terminal-image.ts +43 -8
  142. package/src/vendor/pi-tui/terminal.ts +16 -9
  143. package/src/vendor/pi-tui/tui-alt-screen.ts +26 -18
  144. package/src/vendor/pi-tui/tui.ts +112 -75
  145. package/src/vendor/pi-tui/utils.ts +89 -49
  146. package/src/vendor/pi-tui/wheel-scroll.ts +82 -0
@@ -0,0 +1,49 @@
1
+ import { dirname } from "node:path";
2
+ import { SelectList } from "../tui/select.ts";
3
+ import { createInteractiveTui } from "../tui/host.ts";
4
+ import { IDENTITY } from "../tui/style.ts";
5
+ import { installBindings } from "../tui/input.ts";
6
+ import { loadUiResources } from "../store/resources.ts";
7
+ import type { Style } from "../tui/style.ts";
8
+ import { projectTrust, saveTrust } from "../store/trust.ts";
9
+ import { truncateToWidth } from "../vendor/pi-tui/utils.ts";
10
+
11
+ export type TrustChoice = "once" | "here" | "parent" | "never";
12
+ export async function applyTrustChoice(workspace: string, choice: TrustChoice): Promise<boolean> {
13
+ if (choice !== "once") await saveTrust(choice === "parent" ? dirname(workspace) : workspace, choice !== "never");
14
+ return choice !== "never";
15
+ }
16
+ export class TrustSelector extends SelectList {
17
+ private readonly workspace: string;
18
+ private readonly decision: Awaited<ReturnType<typeof projectTrust>>;
19
+ constructor(workspace: string, decision: Awaited<ReturnType<typeof projectTrust>>, style: Style) {
20
+ super([
21
+ { value: "once", label: "Trust for this session", description: "Load project resources without saving a decision" },
22
+ { value: "here", label: "Trust this directory", description: workspace },
23
+ { value: "parent", label: "Trust parent directory", description: dirname(workspace) },
24
+ { value: "never", label: "Do not trust this directory", description: "Skip project configuration and executable extensions" },
25
+ ], { title: "Project trust", search: false, style,
26
+ ...(decision.saved ? { selectedValue: decision.saved.trusted ? decision.saved.path === workspace ? "here" : "parent" : "never" } : {}) });
27
+ this.workspace = workspace; this.decision = decision;
28
+ }
29
+ override render(width: number): string[] {
30
+ const saved = this.decision.saved;
31
+ return ["Project trust", this.workspace, `Saved: ${saved ? `${saved.trusted ? "trusted" : "untrusted"} (${saved.path})` : "none"}`,
32
+ `Current session: ${this.decision.trusted ? "trusted" : "untrusted"} · Tool permissions are separate`, ...super.render(width).slice(1)]
33
+ .map(line => truncateToWidth(line, width, ""));
34
+ }
35
+ }
36
+ export async function pickProjectTrust(workspace: string): Promise<boolean> {
37
+ const decision = await projectTrust(workspace);
38
+ installBindings((await loadUiResources({ workspace, trusted: false })).keymap.byAction);
39
+ const screen = createInteractiveTui();
40
+ const select = new TrustSelector(workspace, decision, IDENTITY);
41
+ screen.addChild(select); screen.setFocus(select);
42
+ try {
43
+ const choice = await new Promise<string | null>(resolve => {
44
+ select.onInput = data => { const state = select.acceptInput(data); if (state.cancelled) resolve(null); else if (state.confirmed) resolve(state.confirmed.value); screen.requestRender(); };
45
+ screen.start();
46
+ });
47
+ return choice === null ? false : applyTrustChoice(workspace, choice as TrustChoice);
48
+ } finally { await screen.terminal.drainInput(); screen.stop(); }
49
+ }
@@ -200,6 +200,7 @@ export function formatEvent(
200
200
  case "text_delta":
201
201
  case "thinking_delta":
202
202
  case "discard":
203
+ case "progress":
203
204
  return [];
204
205
  default:
205
206
  return assertNever(e.kind, "formatEvent");
@@ -0,0 +1,75 @@
1
+ /** Pi config selector behavior, using the same inventory as runtime loading. */
2
+ import { SelectList } from "../tui/select.ts";
3
+ import { createInteractiveTui } from "../tui/host.ts";
4
+ import { installBindings, keyMatches } from "../tui/input.ts";
5
+ import { getKeybindings } from "../vendor/pi-tui/keybindings.ts";
6
+ import { truncateToWidth } from "../vendor/pi-tui/utils.ts";
7
+ import { nextResourceOverride } from "../core/resource-selection.ts";
8
+ import { discoverResources, setResourceOverride, type ResourceIndex, type ResourceOptions } from "../store/resource-index.ts";
9
+ import { loadUiResources } from "../store/resources.ts";
10
+ import { createStyle, IDENTITY, type Style } from "../tui/style.ts";
11
+ import { colorEnabled } from "./render.ts";
12
+
13
+ export class ResourceBrowser extends SelectList {
14
+ scope: "user" | "project" = "user";
15
+ index: ResourceIndex;
16
+ notice = "";
17
+ private readonly resourceOptions: ResourceOptions;
18
+ private readonly redraw: () => void;
19
+ private readonly changed: (() => Promise<void>) | undefined;
20
+ constructor(options: { resources: ResourceOptions; index: ResourceIndex; style: Style; redraw: () => void; changed?: () => Promise<void> }) {
21
+ super([], { title: "Resources", style: options.style, emptyMessage: "No resources found" });
22
+ this.resourceOptions = options.resources; this.index = options.index; this.redraw = options.redraw; this.changed = options.changed;
23
+ if(options.resources.local)this.scope="project";
24
+ this.refresh();
25
+ }
26
+ private refresh(): void {
27
+ this.setItems(this.index.entries.map(entry => ({ value: entry.kind + ":" + entry.path,
28
+ label: `[${entry.enabled ? "x" : " "}] ${entry.kind} · ${entry.name}${this.scope === "project" ? ` · ${entry.override}` : ""}`,
29
+ description: `${entry.scope}${entry.trusted ? "" : " · untrusted"}${entry.package?" · "+entry.package.source:""} · ${entry.path}` })));
30
+ this.redraw();
31
+ }
32
+ async handleAction(data: string): Promise<boolean> {
33
+ const kb = getKeybindings();
34
+ if (kb.matches(data, "tui.input.tab")) { this.scope = this.scope === "user" ? "project" : "user"; this.refresh(); return true; }
35
+ if (kb.matches(data, "tui.select.confirm")) return true;
36
+ if (!keyMatches(data, "space")) return false;
37
+ const selected = this.state.items[this.state.selected]?.value;
38
+ const entry = this.index.entries.find(row => row.kind + ":" + row.path === selected);
39
+ if (!entry) return true;
40
+ const state = this.scope === "project" ? nextResourceOverride(entry.override) : entry.inherited ? "skip" : "load";
41
+ try {
42
+ await setResourceOverride(this.index, entry, this.scope, state);
43
+ this.index = await discoverResources(this.resourceOptions);
44
+ await this.changed?.();
45
+ this.notice = entry.trusted ? "Saved and reloaded" : "Saved; project resources remain blocked until this project is trusted";
46
+ } catch (error) { this.notice = String(error); }
47
+ this.refresh(); return true;
48
+ }
49
+ override render(width: number): string[] {
50
+ return [`${this.scope === "user" ? "Global" : "Project"} Resources`, this.scope === "user" ? this.index.userDir : this.index.projectDir,
51
+ ...super.render(width).slice(1, -1), `${getKeybindings().getKeys("tui.input.tab").join("/")} scope · Space ${this.scope === "project" ? "inherit/+/-" : "toggle"} · ${getKeybindings().getKeys("tui.select.cancel").join("/")} close`, this.notice]
52
+ .filter(Boolean).map(line => truncateToWidth(line, width, ""));
53
+ }
54
+ }
55
+
56
+ /** Standalone configuration does not allocate a conversation or require a model credential. */
57
+ export async function runResourceConfig(options: ResourceOptions): Promise<void> {
58
+ const index = await discoverResources(options);
59
+ const ui = await loadUiResources(options, index);
60
+ const screen = createInteractiveTui();
61
+ const browser = new ResourceBrowser({ resources: options, index, style: colorEnabled() ? createStyle(ui.theme.colors) : IDENTITY, redraw: () => screen.requestRender() });
62
+ installBindings(ui.keymap.byAction);
63
+ screen.addChild(browser); screen.setFocus(browser);
64
+ try {
65
+ await new Promise<void>(resolve => {
66
+ let pending = Promise.resolve();
67
+ browser.onInput = data => { pending = pending.then(async () => {
68
+ if (await browser.handleAction(data)) return;
69
+ if (browser.acceptInput(data).cancelled) resolve();
70
+ screen.requestRender();
71
+ }).catch(error => { browser.notice = String(error); screen.requestRender(); }); };
72
+ screen.start();
73
+ });
74
+ } finally { await screen.terminal.drainInput(); screen.stop(); }
75
+ }
@@ -0,0 +1,25 @@
1
+ import { loadSessionResources, visibleSchemas, type App } from "./startup.ts";
2
+ import { loadCatalog } from "../runtime/catalog.ts";
3
+ import { readModelSources } from "../store/models.ts";
4
+ import { publishModelCatalog } from "./model-catalog.ts";
5
+
6
+ /** Stage local resources, persist their checkpoint, then publish their UI/runtime views together. */
7
+ export async function reloadAppResources(app: App): Promise<boolean> {
8
+ const resources = await loadSessionResources(app.args, app.workspace, app.io, app.projectTrusted);
9
+ const catalog = loadCatalog(await readModelSources());
10
+ for (const warning of catalog.warnings) app.io.say(app.io.palette.yellow(warning));
11
+ const cut = await app.session.reloadResources({ system: resources.system, tools: visibleSchemas(app.args, resources.extensions) });
12
+ const modelCut = await publishModelCatalog(app, catalog.models, cut);
13
+ await app.extensions?.dispose();
14
+ app.extensions = resources.extensions;
15
+ app.session.config.tools.extensions = resources.extensions.definitions();
16
+ app.session.config.tools.extensionHooks = resources.extensions.toolHooks();
17
+ app.loadedContext = resources.loadedContext;
18
+ app.slash = resources.slash;
19
+ app.ui = resources.ui;
20
+ app.ui.settings = { ...app.ui.settings, thinking: app.providerConfig.effort };
21
+ app.resources = resources.index;
22
+ app.projectTrusted = resources.trusted;
23
+ app.system = app.session.config.frozen.system;
24
+ return cut || modelCut;
25
+ }
@@ -0,0 +1,113 @@
1
+ /** Pi selector interaction, backed exclusively by xeno's session store. Shared by startup and /resume. */
2
+ import { SelectList, type SelectItem, type SelectState } from "../tui/select.ts";
3
+ import { Input } from "../vendor/pi-tui/components/input.ts";
4
+ import { fuzzyMatch } from "../vendor/pi-tui/fuzzy.ts";
5
+ import { truncateToWidth } from "../vendor/pi-tui/utils.ts";
6
+ import { keyMatches } from "../tui/input.ts";
7
+ import { defaultKeymap, type Keymap } from "../core/keymap.ts";
8
+ import { ACTION_ALIASES } from "../core/action-defaults.ts";
9
+ import { parseSessionQuery, selectSessions, type SessionSort } from "../core/session-browser.ts";
10
+ import { archiveSession, renameSession, scanSessionRoots, sessionRoots, type SessionRoot } from "../store/session-browser.ts";
11
+ import type { ListedSession } from "../store/sessions.ts";
12
+ import type { Style } from "../tui/style.ts";
13
+
14
+ type Session = Extract<ListedSession, { ok: true }>;
15
+ export class SessionBrowser extends SelectList {
16
+ private sessions: Session[] = [];
17
+ private readonly initial: SessionRoot;
18
+ private readonly redraw: () => void;
19
+ private readonly active: string | undefined;
20
+ private readonly bindings: () => Keymap;
21
+ private readonly renamed: ((path: string, name: string) => void) | undefined;
22
+ private scan = new AbortController();
23
+ private disposed = false;
24
+ private editing: { kind: "rename" | "archive"; session: Session; input: Input } | undefined;
25
+ scope: "current" | "all" = "current";
26
+ sort: SessionSort = "threaded";
27
+ named = false;
28
+ showPath = false;
29
+ progress = "";
30
+ notice = "";
31
+ constructor(options: { root: SessionRoot; style: Style; redraw: () => void; active?: string; bindings?: () => Keymap; renamed?: (path: string, name: string) => void }) {
32
+ super([], { style: options.style, title: "Resume session", emptyMessage: "No matching sessions", filter: (_items, query) => this.visible(query) });
33
+ this.initial = options.root; this.redraw = options.redraw; this.active = options.active; this.bindings = options.bindings ?? defaultKeymap;
34
+ this.renamed = options.renamed;
35
+ }
36
+ override get focused(): boolean { return this.editing?.input.focused ?? super.focused; }
37
+ override set focused(value: boolean) { super.focused = value; if (this.editing) this.editing.input.focused = value; }
38
+ get sessionCount(): number { return this.sessions.length; }
39
+ private visible(query: string): SelectItem[] {
40
+ return selectSessions(this.sessions, query, this.sort, this.named, fuzzyMatch).map(s => ({ value: s.path,
41
+ label: " ".repeat(Math.min(12, s.depth)) + (s.depth ? "↳ " : "") + (s.name?.trim() || s.text?.split("\n").find(Boolean)?.slice(0, 100) || s.id),
42
+ description: this.showPath ? s.path : [s.updated_at ?? "", s.model ?? "", this.scope === "all" ? s.workspace ?? "" : ""].filter(Boolean).join(" · "),
43
+ }));
44
+ }
45
+ private refresh(): void { if (!this.disposed) { this.setItems([]); this.redraw(); } }
46
+ async load(): Promise<void> {
47
+ this.scan.abort(); const scan = this.scan = new AbortController();
48
+ this.sessions = []; this.progress = "Loading sessions…"; this.refresh();
49
+ try {
50
+ const roots = this.scope === "current" ? [this.initial] : await sessionRoots(this.initial);
51
+ for await (const batch of scanSessionRoots(roots, scan.signal)) {
52
+ if (this.disposed || scan !== this.scan) return;
53
+ this.sessions.push(...batch.sessions.filter((s): s is Session => s.ok));
54
+ for (const row of batch.sessions) if (!row.ok) this.notice = row.error;
55
+ this.progress = `Loading ${batch.loaded}/${batch.total}`; this.refresh();
56
+ }
57
+ } catch (error) { if (!scan.signal.aborted) this.notice = String(error); }
58
+ if (scan !== this.scan || this.disposed) return;
59
+ this.progress = ""; this.refresh();
60
+ }
61
+ dispose(): void { this.disposed = true; this.scan.abort(); }
62
+ private matches(data: string, action: string): boolean {
63
+ return (this.bindings().byAction[(ACTION_ALIASES[action] ?? action) as keyof Keymap["byAction"]] ?? []).some(chord => keyMatches(data, chord));
64
+ }
65
+ async handleAction(data: string): Promise<boolean> {
66
+ if (this.editing) {
67
+ if (this.matches(data, "tui.select.cancel") || keyMatches(data, "ctrl+c")) { this.editing = undefined; this.redraw(); return true; }
68
+ if (this.matches(data, "tui.select.confirm")) {
69
+ const edit = this.editing;
70
+ try {
71
+ if (edit.kind === "rename") {
72
+ await renameSession(edit.session.path, edit.input.getValue());
73
+ edit.session.name = edit.input.getValue().trim();
74
+ this.renamed?.(edit.session.path, edit.session.name);
75
+ } else {
76
+ const destination = await archiveSession(edit.session.path, this.active);
77
+ this.sessions = this.sessions.filter(s => s.path !== edit.session.path);
78
+ this.notice = `Kept at ${JSON.stringify(destination)}; restore the directory to ${JSON.stringify(edit.session.path)}`;
79
+ }
80
+ this.editing = undefined; this.refresh();
81
+ } catch (error) { this.notice = String(error); this.redraw(); }
82
+ } else if (this.editing.kind === "rename") this.editing.input.handleInput(data);
83
+ this.redraw(); return true;
84
+ }
85
+ if (this.matches(data, "tui.input.tab")) { this.scope = this.scope === "current" ? "all" : "current"; void this.load(); return true; }
86
+ if (this.matches(data, "app.session.toggleNamedFilter")) this.named = !this.named;
87
+ else if (this.matches(data, "app.session.toggleSort")) this.sort = this.sort === "threaded" ? "recent" : this.sort === "recent" ? "relevance" : "threaded";
88
+ else if (this.matches(data, "app.session.togglePath")) this.showPath = !this.showPath;
89
+ else if (["app.session.rename", "app.session.delete", "app.session.deleteNoninvasive"].some(action => this.matches(data, action))) {
90
+ const path = this.state.items[this.state.selected]?.value;
91
+ const session = this.sessions.find(s => s.path === path);
92
+ if (!session) return true;
93
+ const kind = this.matches(data, "app.session.rename") ? "rename" : "archive";
94
+ if (kind === "archive" && session.path === this.active) { this.notice = "Cannot remove the currently active session"; this.redraw(); return true; }
95
+ const input = new Input(); input.setValue(session.name ?? ""); input.focused = this.focused;
96
+ this.editing = { kind, session, input };
97
+ } else return false;
98
+ this.refresh(); return true;
99
+ }
100
+ override acceptInput(data: string): SelectState { const result = super.acceptInput(data); this.redraw(); return result; }
101
+ override render(width: number): string[] {
102
+ const header = this.progress === "Loading sessions…" ? this.progress : `Resume session · ${this.scope === "current" ? "Current folder" : "All workspaces"} · ${this.named ? "Named" : "All names"} · ${this.sort}`;
103
+ const hint = (action: string, label: string) => {
104
+ const keys = this.bindings().byAction[(ACTION_ALIASES[action] ?? action) as keyof Keymap["byAction"]];
105
+ return keys?.length ? `${keys.join("/")} ${label}` : "";
106
+ };
107
+ const body = this.editing ? [this.editing.kind === "rename" ? "Rename session" : "Remove session from this list? Original log will be kept in .trash.",
108
+ this.editing.session.name ?? this.editing.session.id, ...(this.editing.kind === "rename" ? this.editing.input.render(width) : []), "Enter confirm · Esc cancel"] :
109
+ [...super.render(width).slice(1, -1), [["tui.input.tab", "scope"], ["app.session.toggleNamedFilter", "named"], ["app.session.toggleSort", "sort"],
110
+ ["app.session.togglePath", "path"], ["app.session.rename", "rename"], ["app.session.delete", "remove"]].map(([action, label]) => hint(action!, label!)).filter(Boolean).join(" · "), 're:<pattern> regex · "phrase" exact'];
111
+ return [header, ...body, this.progress, parseSessionQuery(this.query).error ?? this.notice].filter(Boolean).map(line => truncateToWidth(line, width, ""));
112
+ }
113
+ }
@@ -0,0 +1,55 @@
1
+ /** A startup picker owns only a terminal, never a provisional durable session. */
2
+ import { sessionsDir, type ListedSession } from "../store/sessions.ts";
3
+ import { registerSessionRoot, sessionRoots } from "../store/session-browser.ts";
4
+ import { createInteractiveTui } from "../tui/host.ts";
5
+ import type { SelectItem } from "../tui/select.ts";
6
+ import { SessionBrowser } from "./session-browser.ts";
7
+ import { installBindings } from "../tui/input.ts";
8
+ import { defaultKeymap, type Keymap } from "../core/keymap.ts";
9
+ import type { Style } from "../tui/style.ts";
10
+
11
+ export function sessionItems(sessions: readonly ListedSession[]): SelectItem[] {
12
+ return sessions.flatMap((s) => s.ok ? [{
13
+ value: s.path,
14
+ label: s.name ?? s.id,
15
+ description: [s.model ?? "", s.updated_at ?? "", `${s.events} events`].filter(Boolean).join(" "),
16
+ }] : []);
17
+ }
18
+
19
+ export async function pickStartupSession(opts: {
20
+ workspace: string;
21
+ sessionDir?: string;
22
+ style: Style;
23
+ say: (line: string) => void;
24
+ keymap?: Keymap;
25
+ }): Promise<string | null> {
26
+ const root = { path: sessionsDir(opts.workspace, opts.sessionDir), workspace: opts.workspace };
27
+ await registerSessionRoot(root).catch(error => opts.say(String(error)));
28
+ const screen = createInteractiveTui();
29
+ const select = new SessionBrowser({ root, style: opts.style, redraw: () => screen.requestRender(), bindings: () => opts.keymap ?? defaultKeymap() });
30
+ installBindings((opts.keymap ?? defaultKeymap()).byAction);
31
+ screen.addChild(select);
32
+ screen.setFocus(select);
33
+ try {
34
+ return await new Promise<string | null>((resolve) => {
35
+ let pending = Promise.resolve();
36
+ select.onInput = (data) => { pending = pending.then(async () => {
37
+ if (await select.handleAction(data)) return;
38
+ const state = select.acceptInput(data);
39
+ if (state.cancelled) resolve(null);
40
+ else if (state.confirmed) resolve(state.confirmed.value);
41
+ else screen.requestRender();
42
+ }).catch(error => { opts.say(String(error)); }); };
43
+ screen.start();
44
+ void select.load().then(async () => {
45
+ if (select.scope === "current" && !select.sessionCount && !select.notice && (await sessionRoots(root)).length === 1) {
46
+ opts.say("No sessions to resume"); resolve(null);
47
+ }
48
+ }).catch(error => { opts.say(String(error)); });
49
+ });
50
+ } finally {
51
+ select.dispose();
52
+ await screen.terminal.drainInput();
53
+ screen.stop();
54
+ }
55
+ }