opencode-sesh 2.0.0 → 2.0.2

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/dist/tui.js ADDED
@@ -0,0 +1,4359 @@
1
+ import { use as _$use } from "@opentui/solid";
2
+ import { createComponent as _$createComponent } from "@opentui/solid";
3
+ import { memo as _$memo } from "@opentui/solid";
4
+ import { createTextNode as _$createTextNode } from "@opentui/solid";
5
+ import { effect as _$effect } from "@opentui/solid";
6
+ import { insertNode as _$insertNode } from "@opentui/solid";
7
+ import { insert as _$insert } from "@opentui/solid";
8
+ import { setProp as _$setProp } from "@opentui/solid";
9
+ import { createElement as _$createElement } from "@opentui/solid";
10
+ /** @jsxImportSource @opentui/solid */
11
+ import { Plugin } from "@opencode/plugin/tui";
12
+ import { createEffect, createMemo, createRoot, createSignal, For, getOwner, on, onCleanup, onMount, runWithOwner, Show } from "solid-js";
13
+ import { mkdir, readFile, rename, rmdir, unlink, writeFile } from "node:fs/promises";
14
+ import { existsSync } from "node:fs";
15
+ import { dirname, join } from "node:path";
16
+ import { spawn, spawnSync } from "node:child_process";
17
+ import { getTreeSitterClient, RGBA, SyntaxStyle, TextAttributes } from "@opentui/core";
18
+ import { Portal } from "@opentui/solid";
19
+ let markdownStyleCache;
20
+ function buildMarkdownStyle(theme, owner) {
21
+ return SyntaxStyle.fromTheme([{
22
+ scope: ["default"],
23
+ style: {
24
+ foreground: theme.text
25
+ }
26
+ }, {
27
+ scope: ["comment", "comment.documentation"],
28
+ style: {
29
+ foreground: theme.syntaxComment,
30
+ italic: true
31
+ }
32
+ }, {
33
+ scope: ["string", "symbol", "character"],
34
+ style: {
35
+ foreground: theme.syntaxString
36
+ }
37
+ }, {
38
+ scope: ["number", "boolean", "constant", "float"],
39
+ style: {
40
+ foreground: theme.syntaxNumber
41
+ }
42
+ }, {
43
+ scope: ["keyword", "keyword.import", "keyword.directive", "keyword.modifier", "keyword.exception"],
44
+ style: {
45
+ foreground: theme.syntaxKeyword,
46
+ italic: true
47
+ }
48
+ }, {
49
+ scope: ["keyword.return", "keyword.conditional", "keyword.repeat", "keyword.coroutine"],
50
+ style: {
51
+ foreground: theme.syntaxKeyword,
52
+ italic: true
53
+ }
54
+ }, {
55
+ scope: ["keyword.type"],
56
+ style: {
57
+ foreground: theme.syntaxType,
58
+ bold: true,
59
+ italic: true
60
+ }
61
+ }, {
62
+ scope: ["keyword.function", "function.method", "function", "constructor", "variable.member"],
63
+ style: {
64
+ foreground: theme.syntaxFunction
65
+ }
66
+ }, {
67
+ scope: ["operator", "keyword.operator", "punctuation.delimiter", "punctuation.special"],
68
+ style: {
69
+ foreground: theme.syntaxOperator
70
+ }
71
+ }, {
72
+ scope: ["variable", "variable.parameter", "function.method.call", "function.call", "property", "parameter"],
73
+ style: {
74
+ foreground: theme.syntaxVariable
75
+ }
76
+ }, {
77
+ scope: ["type", "module", "class"],
78
+ style: {
79
+ foreground: theme.syntaxType
80
+ }
81
+ }, {
82
+ scope: ["punctuation", "punctuation.bracket"],
83
+ style: {
84
+ foreground: theme.syntaxPunctuation
85
+ }
86
+ }, {
87
+ scope: ["variable.builtin", "type.builtin", "function.builtin", "module.builtin", "constant.builtin", "variable.super"],
88
+ style: {
89
+ foreground: theme.error
90
+ }
91
+ },
92
+ // Some opencode themes give Markdown roles the same foreground as body
93
+ // text. Use their semantic accents here so transcript structure remains
94
+ // legible without recoloring the surrounding TUI or ordinary prose.
95
+ {
96
+ scope: ["markup.heading"],
97
+ style: {
98
+ foreground: theme.accent,
99
+ bold: true
100
+ }
101
+ }, {
102
+ scope: ["markup.heading.1"],
103
+ style: {
104
+ foreground: theme.primary,
105
+ bold: true,
106
+ underline: true
107
+ }
108
+ }, {
109
+ scope: ["markup.heading.2", "markup.heading.3", "markup.heading.4", "markup.heading.5", "markup.heading.6"],
110
+ style: {
111
+ foreground: theme.accent,
112
+ bold: true
113
+ }
114
+ }, {
115
+ scope: ["markup.bold", "markup.strong"],
116
+ style: {
117
+ foreground: theme.accent,
118
+ bold: true
119
+ }
120
+ }, {
121
+ scope: ["markup.italic"],
122
+ style: {
123
+ foreground: theme.syntaxString,
124
+ italic: true
125
+ }
126
+ }, {
127
+ scope: ["markup.list"],
128
+ style: {
129
+ foreground: theme.syntaxKeyword
130
+ }
131
+ }, {
132
+ scope: ["markup.quote"],
133
+ style: {
134
+ foreground: theme.markdownBlockQuote,
135
+ italic: true
136
+ }
137
+ }, {
138
+ scope: ["markup.raw", "markup.raw.block"],
139
+ style: {
140
+ foreground: theme.syntaxString
141
+ }
142
+ }, {
143
+ scope: ["markup.raw.inline"],
144
+ style: {
145
+ foreground: theme.syntaxString,
146
+ background: theme.backgroundElement
147
+ }
148
+ }, {
149
+ scope: ["markup.link", "markup.link.url", "string.special", "string.special.url"],
150
+ style: {
151
+ foreground: theme.markdownLink,
152
+ underline: true
153
+ }
154
+ }, {
155
+ scope: ["markup.link.label", "label"],
156
+ style: {
157
+ foreground: theme.markdownLinkText,
158
+ underline: true
159
+ }
160
+ }, {
161
+ scope: ["conceal"],
162
+ style: {
163
+ foreground: theme.textMuted
164
+ }
165
+ }], owner);
166
+ }
167
+ function getMarkdownStyle(theme, owner) {
168
+ if (markdownStyleCache?.theme === theme) return markdownStyleCache?.style;
169
+ try {
170
+ markdownStyleCache = {
171
+ theme,
172
+ style: buildMarkdownStyle(theme, owner)
173
+ };
174
+ return markdownStyleCache?.style;
175
+ } catch {
176
+ try {
177
+ return SyntaxStyle.create(owner);
178
+ } catch {
179
+ return undefined;
180
+ }
181
+ }
182
+ }
183
+ const emptyPins = () => ({
184
+ sessions: [],
185
+ directories: []
186
+ });
187
+ const pinsPath = () => process.env.SESH_PINS_FILE ?? join(process.env.XDG_DATA_HOME ?? join(process.env.HOME ?? "", ".local/share"), "sesh/pins.json");
188
+ async function readPins() {
189
+ try {
190
+ const data = JSON.parse(await readFile(pinsPath(), "utf8"));
191
+ return {
192
+ sessions: Array.isArray(data.sessions) ? data.sessions.filter(id => typeof id === "string") : [],
193
+ directories: Array.isArray(data.directories) ? data.directories.filter(dir => typeof dir === "string") : []
194
+ };
195
+ } catch {
196
+ return emptyPins();
197
+ }
198
+ }
199
+ async function togglePin(kind, value) {
200
+ const file = pinsPath();
201
+ await mkdir(dirname(file), {
202
+ recursive: true
203
+ });
204
+ const lock = `${file}.lock`;
205
+ let locked = false;
206
+ for (let attempt = 0; attempt < 50; attempt++) {
207
+ try {
208
+ await mkdir(lock);
209
+ locked = true;
210
+ break;
211
+ } catch (error) {
212
+ if (error.code !== "EEXIST") throw error;
213
+ try {
214
+ const owner = Number((await readFile(join(lock, "pid"), "utf8")).trim());
215
+ if (Number.isSafeInteger(owner) && owner > 0) {
216
+ try {
217
+ process.kill(owner, 0);
218
+ } catch (check) {
219
+ if (check.code === "ESRCH") {
220
+ await unlink(join(lock, "pid")).catch(() => {});
221
+ await rmdir(lock).catch(() => {});
222
+ }
223
+ }
224
+ }
225
+ } catch {}
226
+ await new Promise(resolve => setTimeout(resolve, 100));
227
+ }
228
+ }
229
+ if (!locked) throw new Error("pins are busy");
230
+ const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}`;
231
+ try {
232
+ await writeFile(join(lock, "pid"), `${process.pid}\n`);
233
+ const next = await readPins();
234
+ next[kind] = next[kind].includes(value) ? next[kind].filter(item => item !== value) : [...next[kind], value];
235
+ await writeFile(tmp, JSON.stringify(next) + "\n", {
236
+ mode: 0o600
237
+ });
238
+ await rename(tmp, file);
239
+ return next;
240
+ } finally {
241
+ await unlink(tmp).catch(() => {});
242
+ await unlink(join(lock, "pid")).catch(() => {});
243
+ await rmdir(lock);
244
+ }
245
+ }
246
+ function pinnedFirst(entries, pins) {
247
+ const sessions = new Set(pins.sessions);
248
+ const directories = new Set(pins.directories);
249
+ return [...entries].sort((a, b) => {
250
+ const aRank = sessions.has(a.id) ? 2 : directories.has(a.dir) ? 1 : 0;
251
+ const bRank = sessions.has(b.id) ? 2 : directories.has(b.dir) ? 1 : 0;
252
+ return bRank - aRank || b.updated - a.updated;
253
+ });
254
+ }
255
+
256
+ // Sessions panel: an opencode-native session switcher in two parts.
257
+ //
258
+ // 1. Sidebar (always visible): a compact recent-sessions section appended to
259
+ // the native sidebar via the `sidebar_content` slot. Native session_list
260
+ // (<leader>l) is left untouched; this complements it with a cross-project
261
+ // view. Rows are display-only; option+o opens the full picker.
262
+ // 2. Picker (option+o, `/sesh`, command palette): an xlarge grouped picker over
263
+ // every session across all project directories, newest first.
264
+ //
265
+ // `/sesh` is registered by this plugin (a markdown command cannot open the
266
+ // dialog). Native `/sessions` and session_list (<leader>l) stay untouched.
267
+ const BASE_MODE = "base";
268
+ const POLL_MS = 15_000;
269
+ function shortDir(dir, home) {
270
+ if (!dir || dir === "/") return "other";
271
+ const pretty = home && dir.startsWith(home + "/") ? "~" + dir.slice(home.length) : dir;
272
+ return pretty.replace(/\/+$/, "").split("/").pop() || "other";
273
+ }
274
+ function prettyDir(dir, home) {
275
+ if (!dir) return "other";
276
+ if (home && dir === home) return "~";
277
+ const pretty = home && dir.startsWith(home + "/") ? "~" + dir.slice(home.length) : dir.replace(/\/+$/, "");
278
+ const parts = pretty.split("/").filter(Boolean);
279
+ if (parts.length <= 3) return pretty;
280
+ return "…/" + parts.slice(-2).join("/");
281
+ }
282
+ function ago(ms) {
283
+ const s = Math.max(0, Math.floor((Date.now() - ms) / 1000));
284
+ if (s < 60) return "now";
285
+ if (s < 3600) return `${Math.floor(s / 60)}m`;
286
+ if (s < 86400) return `${Math.floor(s / 3600)}h`;
287
+ return `${Math.floor(s / 86400)}d`;
288
+ }
289
+ function truncate(text, width) {
290
+ if (width <= 0) return "";
291
+ if (text.length <= width) return text;
292
+ return text.slice(0, width - 1) + "…";
293
+ }
294
+
295
+ // Sidebar and home rows should read at a similar length. The sidebar title
296
+ // column is ~2 chars narrower than the group column (status dot + timestamp),
297
+ // so cap it a bit lower to guarantee separation.
298
+ const SIDEBAR_TITLE_WIDTH = 27;
299
+ const SIDEBAR_GROUP_WIDTH = 30;
300
+ const HOME_TITLE_WIDTH = 30;
301
+
302
+ // The sidebar lists every session (newest first, pins ahead) and fills
303
+ // whatever vertical space it gets: overflow scrolls inside the stretched
304
+ // scrollbox. The picker (option+o) remains the place for transcript search.
305
+
306
+ // Uppercase is included deliberately: a search box that silently drops shifted
307
+ // letters cannot be used for acronyms or paths like README.
308
+ const SEARCH_KEYS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.@/+:,?!()[]".split("");
309
+ function searchBindings(append, exit) {
310
+ return [...SEARCH_KEYS.map(ch => ({
311
+ key: ch,
312
+ preventDefault: true,
313
+ cmd: () => append(ch)
314
+ })), {
315
+ key: "space",
316
+ preventDefault: true,
317
+ cmd: () => append(" ")
318
+ }, {
319
+ key: "backspace",
320
+ preventDefault: true,
321
+ cmd: () => append("\b")
322
+ }, {
323
+ key: "escape",
324
+ preventDefault: true,
325
+ cmd: exit
326
+ }];
327
+ }
328
+ const rootMouseHandlers = new Set();
329
+ let rootMouseInstalled = false;
330
+ let removeRootMouse;
331
+ const ROOT_MOUSE = Symbol.for("opencode-sesh.root-mouse");
332
+ function ensureRootMouse(renderer) {
333
+ if (rootMouseInstalled || !renderer?.root) return;
334
+ rootMouseInstalled = true;
335
+ const root = renderer.root;
336
+ if (!root[ROOT_MOUSE]) {
337
+ const handlers = new Set();
338
+ root[ROOT_MOUSE] = handlers;
339
+ const previous = root._mouseListeners?.down;
340
+ root.onMouseDown = event => {
341
+ previous?.(event);
342
+ for (const handler of [...handlers]) handler();
343
+ };
344
+ }
345
+ const dispatch = () => {
346
+ for (const handler of [...rootMouseHandlers]) handler();
347
+ };
348
+ root[ROOT_MOUSE].add(dispatch);
349
+ removeRootMouse = () => {
350
+ root[ROOT_MOUSE]?.delete(dispatch);
351
+ rootMouseHandlers.clear();
352
+ rootMouseInstalled = false;
353
+ };
354
+ }
355
+ const LEFT_BUTTON = 0;
356
+ const RIGHT_BUTTON = 2;
357
+ function openInCmuxWorkspace(api, entry) {
358
+ if (!process.env.CMUX_WORKSPACE_ID) {
359
+ api.ui.toast({
360
+ message: "Right-click opens a new workspace only inside cmux",
361
+ variant: "info"
362
+ });
363
+ return;
364
+ }
365
+ const args = ["new-workspace", "--name", entry.title || entry.id, "--command", `opencode --session ${entry.id}`, "--focus", "true"];
366
+ if (entry.dir) args.push("--cwd", entry.dir);
367
+ const child = spawn("cmux", args, {
368
+ stdio: "ignore"
369
+ });
370
+ child.on("error", () => api.ui.toast({
371
+ message: "Could not open a cmux workspace",
372
+ variant: "error"
373
+ }));
374
+ child.on("exit", code => {
375
+ if (code) api.ui.toast({
376
+ message: "Could not open a cmux workspace",
377
+ variant: "error"
378
+ });
379
+ });
380
+ }
381
+ const CONTEXT_MENU_LAYER_PRIORITY = 30;
382
+ function contextMenuItems(pinned, cmux) {
383
+ return [{
384
+ id: "open",
385
+ label: "Open"
386
+ }, {
387
+ id: "preview",
388
+ label: "Preview transcript"
389
+ }, ...(cmux ? [{
390
+ id: "workspace",
391
+ label: "Open in new cmux workspace"
392
+ }] : []), {
393
+ id: "fork",
394
+ label: "Fork"
395
+ }, {
396
+ id: "pin",
397
+ label: pinned ? "Unpin" : "Pin"
398
+ }, {
399
+ id: "delete",
400
+ label: "Delete"
401
+ }];
402
+ }
403
+ function contextMenuSize(items) {
404
+ return {
405
+ width: Math.max(...items.map(item => item.label.length)) + 4,
406
+ height: items.length + 2
407
+ };
408
+ }
409
+ function contextMenuBox(anchor, cols, rows) {
410
+ const width = Math.max(1, Math.min(anchor.width, cols));
411
+ const height = Math.max(1, Math.min(anchor.height, rows));
412
+ const left = Math.max(0, Math.min(anchor.x, cols - width));
413
+ const below = anchor.y + 1;
414
+ const top = below + height <= rows ? below : Math.max(0, anchor.y - height);
415
+ return {
416
+ left,
417
+ top,
418
+ width,
419
+ height
420
+ };
421
+ }
422
+ function createContextMenu(host) {
423
+ let state;
424
+ let dispose;
425
+ const publish = next => {
426
+ state = next;
427
+ host.onChange(next);
428
+ };
429
+ const close = () => {
430
+ dispose?.();
431
+ dispose = undefined;
432
+ if (state) publish(undefined);
433
+ };
434
+ const select = index => {
435
+ if (!state) return;
436
+ const next = Math.max(0, Math.min(state.items.length - 1, index));
437
+ // A still pointer re-fires over events; republishing an identical state
438
+ // would rebuild the keyed menu on every frame, churning renderables until
439
+ // clicks fall through to whatever is beneath.
440
+ if (next === state.index) return;
441
+ publish({
442
+ ...state,
443
+ index: next
444
+ });
445
+ };
446
+ const move = delta => {
447
+ if (!state) return;
448
+ const count = state.items.length;
449
+ const next = (state.index + delta + count) % count;
450
+ if (next === state.index) return;
451
+ publish({
452
+ ...state,
453
+ index: next
454
+ });
455
+ };
456
+ const activate = index => {
457
+ if (!state) return;
458
+ const current = state;
459
+ const item = current.items[index ?? current.index];
460
+ if (!item) return;
461
+ if (item.id === "delete") {
462
+ publish({
463
+ ...current,
464
+ confirming: true,
465
+ items: [{
466
+ id: "confirm",
467
+ label: "Confirm delete"
468
+ }, {
469
+ id: "cancel",
470
+ label: "Cancel"
471
+ }],
472
+ index: 1
473
+ });
474
+ return;
475
+ }
476
+ close();
477
+ if (item.id === "confirm") host.actions.delete(current.entry);else if (item.id !== "cancel") host.actions[item.id](current.entry);
478
+ };
479
+ const click = (index, button) => {
480
+ if ((button ?? LEFT_BUTTON) === LEFT_BUTTON) activate(index);
481
+ };
482
+ const guarded = run => () => {
483
+ if (host.blocked()) close();else run();
484
+ };
485
+ const open = (entry, x, y) => {
486
+ if (host.blocked()) return;
487
+ publish({
488
+ entry,
489
+ x,
490
+ y,
491
+ items: contextMenuItems(host.pinned(entry), host.cmux()),
492
+ index: 0,
493
+ confirming: false
494
+ });
495
+ if (dispose) return;
496
+ dispose = host.registerLayer({
497
+ mode: "base",
498
+ priority: CONTEXT_MENU_LAYER_PRIORITY,
499
+ bindings: [{
500
+ key: "up",
501
+ desc: "Previous menu item",
502
+ preventDefault: true,
503
+ cmd: guarded(() => move(-1))
504
+ }, {
505
+ key: "down",
506
+ desc: "Next menu item",
507
+ preventDefault: true,
508
+ cmd: guarded(() => move(1))
509
+ }, {
510
+ key: "enter",
511
+ desc: "Choose menu item",
512
+ preventDefault: true,
513
+ cmd: guarded(() => activate())
514
+ }, {
515
+ key: "escape",
516
+ desc: "Close menu",
517
+ preventDefault: true,
518
+ cmd: close
519
+ }]
520
+ });
521
+ };
522
+ return {
523
+ open,
524
+ close,
525
+ select,
526
+ activate,
527
+ click,
528
+ current: () => state
529
+ };
530
+ }
531
+ // Loading-spinner frames for the marker of a session whose agent is working.
532
+ const SIDEBAR_SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
533
+ function newestFirst(entries) {
534
+ return [...entries].sort((a, b) => b.updated - a.updated || a.id.localeCompare(b.id));
535
+ }
536
+
537
+ // A working agent is the only state the marker displays: everything that
538
+ // finished goes back to the neutral row, with the open session marked inside.
539
+ function sidebarMarker(current, live) {
540
+ if (live === "running" || live === "busy" || live === "retry") return "running";
541
+ return current ? "current" : "idle";
542
+ }
543
+ const SESSION_PAGE_LIMIT = 200;
544
+ const SESSION_MAX = 5000;
545
+ async function fetchEntries(api) {
546
+ const home = process.env.HOME ?? "";
547
+ const projectResult = await api.client.project.list();
548
+ const projects = projectResult?.data ?? projectResult ?? [];
549
+ const projectName = new Map(projects.map(p => [p.id, p.name?.trim() || shortDir(p.canonical, home)]));
550
+ const sessions = [];
551
+ let cursor;
552
+ let truncated = false;
553
+ for (;;) {
554
+ const result = await api.client.experimental.session.list({
555
+ limit: SESSION_PAGE_LIMIT,
556
+ roots: true,
557
+ ...(cursor === undefined ? {} : {
558
+ cursor
559
+ })
560
+ });
561
+ const page = result?.data ?? result ?? [];
562
+ if (!Array.isArray(page) || page.length === 0) break;
563
+ sessions.push(...page);
564
+ if (!result.cursor?.next) break;
565
+ if (sessions.length >= SESSION_MAX) {
566
+ truncated = true;
567
+ break;
568
+ }
569
+ const next = result.cursor.next;
570
+ if (next === cursor) {
571
+ truncated = true;
572
+ break;
573
+ }
574
+ cursor = next;
575
+ }
576
+ const entries = sessions.filter(s => !s.time?.archived && !s.parentID).sort((a, b) => b.time.updated - a.time.updated).map(s => {
577
+ const projectLabel = s.projectID ? projectName.get(s.projectID) : undefined;
578
+ const sDir = s.directory ?? s.location?.directory ?? "";
579
+ return {
580
+ id: s.id,
581
+ title: s.title?.trim() || "(untitled)",
582
+ dir: sDir,
583
+ group: (projectLabel && projectLabel !== "other" ? projectLabel : shortDir(sDir, home)) || "other",
584
+ updated: s.time.updated
585
+ };
586
+ });
587
+ return {
588
+ entries,
589
+ truncated
590
+ };
591
+ }
592
+ const SESSION_ID_PATTERN = /^ses_[A-Za-z0-9]+$/;
593
+ const TRANSCRIPT_BATCH = 64;
594
+ const REMOTE_CONCURRENCY = 8;
595
+ const nextTick = () => new Promise(resolve => setTimeout(resolve, 0));
596
+ function resolveDbPath() {
597
+ if (process.env.SESH_DB) return process.env.SESH_DB;
598
+ const home = process.env.HOME ?? "";
599
+ const dataHome = process.env.XDG_DATA_HOME ?? `${home}/.local/share`;
600
+ const xdgPath = `${dataHome}/opencode/opencode.db`;
601
+ if (existsSync(xdgPath)) return xdgPath;
602
+ try {
603
+ const result = spawnSync(process.env.SESH_OPENCODE ?? "opencode", ["debug", "paths"], {
604
+ encoding: "utf8"
605
+ });
606
+ const match = result.stdout?.match(/^db\s+(.+)$/m);
607
+ if (match) return match[1].trimEnd();
608
+ } catch {}
609
+ return xdgPath;
610
+ }
611
+ async function openTranscriptDb() {
612
+ try {
613
+ // @ts-ignore - bun:sqlite ships with the Bun runtime (verified); no type package installed
614
+ const sqlite = await import("bun:sqlite");
615
+ if (typeof sqlite?.Database !== "function") return undefined;
616
+ return new sqlite.Database(resolveDbPath(), {
617
+ readonly: true
618
+ });
619
+ } catch {
620
+ return undefined;
621
+ }
622
+ }
623
+ function costRowsQuery(cutoff, includeLegacy) {
624
+ const legacy = includeLegacy ? `UNION ALL
625
+ SELECT s.directory, s.id AS session_id, m.time_created, m.data
626
+ FROM message m JOIN session s ON s.id = m.session_id
627
+ WHERE json_extract(m.data, '$.role') = 'assistant'
628
+ AND NOT EXISTS (SELECT 1 FROM session_v2 v WHERE v.id = s.id)` : "";
629
+ return `WITH costs AS (
630
+ SELECT s.directory, s.id AS session_id, m.time_created, m.data
631
+ FROM session_message m JOIN session_v2 s ON s.id = m.session_id
632
+ WHERE m.type = 'assistant'
633
+ ${legacy}
634
+ )
635
+ SELECT m.directory AS directory,
636
+ ROUND(SUM(CASE WHEN m.time_created >= ${cutoff} THEN COALESCE(json_extract(m.data, '$.cost'), 0) ELSE 0 END), 4) AS window_cost,
637
+ ROUND(SUM(COALESCE(json_extract(m.data, '$.cost'), 0)), 4) AS lifetime_cost,
638
+ COUNT(DISTINCT m.session_id) AS sessions
639
+ FROM costs m
640
+ WHERE json_extract(m.data, '$.cost') IS NOT NULL
641
+ GROUP BY m.directory
642
+ ORDER BY lifetime_cost DESC`;
643
+ }
644
+
645
+ // NEEDS_INPUT_SQL: sessions waiting on the user (shared heuristic — keep in
646
+ // sync with bin/sesh-waiting.sh). A session waits when it is not archived,
647
+ // is not a child (subagent) session, and either has an unanswered `question`
648
+ // tool part or a `tool` part still marked running older than STUCK_AFTER_MS
649
+ // with no message activity in the session or its direct children since then (a
650
+ // long-running subagent/shell tool with recent activity is not stuck; V2
651
+ // resumes interrupted runs server-side, so `running` with no activity means
652
+ // orphaned or awaiting approval). Server-independent on purpose: the TUI
653
+ // sync layer only sees permission/question state for sessions owned by the
654
+ // current server, which is invisible cross-project.
655
+ const STUCK_AFTER_MS = 600000;
656
+ const LIVE_TOOLS_CTE = sessionFilter => `
657
+ WITH live AS (
658
+ SELECT m.session_id AS session_id, m.seq AS seq,
659
+ json_extract(c.value, '$.name') AS name,
660
+ json_extract(c.value, '$.state.status') AS status,
661
+ COALESCE(json_extract(c.value, '$.time.created'), m.time_created) AS created
662
+ FROM session_message m, json_each(m.data, '$.content') c
663
+ WHERE m.type = 'assistant'
664
+ AND (m.data LIKE '%"status":"running"%' OR m.data LIKE '%"status":"pending"%')
665
+ AND json_extract(c.value, '$.type') = 'tool'
666
+ AND json_extract(c.value, '$.state.status') IN ('pending', 'running')${sessionFilter}
667
+ )`;
668
+ const QUESTION_OPEN = `live.name = 'question'
669
+ AND NOT EXISTS (
670
+ SELECT 1 FROM session_message u
671
+ WHERE u.session_id = live.session_id AND u.type = 'user' AND u.seq > live.seq)`;
672
+ const NO_RECENT_ACTIVITY = (sessionIdExpr, stuckBefore) => `NOT EXISTS (
673
+ SELECT 1 FROM session_message r JOIN session_v2 f ON f.id = r.session_id
674
+ WHERE (f.id = ${sessionIdExpr} OR f.parent_id = ${sessionIdExpr}) AND r.time_updated >= ${stuckBefore})`;
675
+ const NEEDS_INPUT_SQL = stuckBefore => `${LIVE_TOOLS_CTE("")}
676
+ SELECT s.id AS id,
677
+ CASE WHEN EXISTS (SELECT 1 FROM live WHERE live.session_id = s.id AND ${QUESTION_OPEN})
678
+ THEN 'question' ELSE 'stuck' END AS reason
679
+ FROM session_v2 s
680
+ WHERE COALESCE(s.time_archived, 0) = 0
681
+ AND s.parent_id IS NULL
682
+ AND (EXISTS (SELECT 1 FROM live WHERE live.session_id = s.id AND ${QUESTION_OPEN})
683
+ OR (EXISTS (
684
+ SELECT 1 FROM live
685
+ WHERE live.session_id = s.id AND live.status = 'running' AND live.created < ${stuckBefore})
686
+ AND ${NO_RECENT_ACTIVITY("s.id", stuckBefore)}))
687
+ ORDER BY s.time_updated DESC`;
688
+ async function queryWaitingIds(db) {
689
+ const rows = db.query(NEEDS_INPUT_SQL(Date.now() - STUCK_AFTER_MS)).all() ?? [];
690
+ const out = new Map();
691
+ for (const row of rows) {
692
+ if (typeof row?.id === "string" && SESSION_ID_PATTERN.test(row.id) && (row.reason === "question" || row.reason === "stuck")) {
693
+ out.set(row.id, row.reason);
694
+ }
695
+ }
696
+ return out;
697
+ }
698
+ // The shared needs-input query decides *which* sessions are waiting. Read the
699
+ // oldest still-unanswered question or stuck tool for each result so triage can
700
+ // show how long it has waited, rather than its last (unrelated) update time.
701
+ async function queryWaitingDetails(db) {
702
+ const reasons = await queryWaitingIds(db);
703
+ const details = new Map([...reasons].map(([id, reason]) => [id, {
704
+ reason: reason
705
+ }]));
706
+ const ids = [...details.keys()];
707
+ const cutoff = Date.now() - STUCK_AFTER_MS;
708
+ for (let i = 0; i < ids.length; i += 200) {
709
+ const batch = ids.slice(i, i + 200);
710
+ const rows = db.query(`${LIVE_TOOLS_CTE(` AND m.session_id IN (${batch.map(() => "?").join(",")})`)}
711
+ SELECT live.session_id AS id,
712
+ MIN(CASE WHEN ${QUESTION_OPEN} THEN live.created END) AS question_since,
713
+ MIN(CASE WHEN live.status = 'running' AND live.created < ${cutoff} THEN live.created END) AS stuck_since
714
+ FROM live
715
+ GROUP BY live.session_id`).all(...batch) ?? [];
716
+ for (const row of rows) {
717
+ const detail = details.get(row.id);
718
+ if (!detail) continue;
719
+ const since = detail.reason === "question" ? row.question_since : row.stuck_since;
720
+ if (typeof since === "number") detail.since = since;
721
+ }
722
+ }
723
+ return details;
724
+ }
725
+ function addTranscript(index, sid, data) {
726
+ try {
727
+ const message = JSON.parse(data);
728
+ const texts = [];
729
+ if (typeof message?.text === "string") texts.push(message.text);
730
+ if (Array.isArray(message?.content)) {
731
+ for (const part of message.content) {
732
+ if (part?.type === "text" && typeof part.text === "string") texts.push(part.text);
733
+ }
734
+ }
735
+ for (const text of texts) {
736
+ const base = index.get(sid) ?? "";
737
+ index.set(sid, `${base} ${text.toLowerCase()}`.trim());
738
+ }
739
+ } catch {
740
+ // ignore malformed messages
741
+ }
742
+ }
743
+
744
+ // Index titles/directories immediately, then add transcript text progressively
745
+ // in batches so the render thread can breathe. Covers every entry, not a fixed
746
+ // newest window; `onProgress` lets the picker show coverage.
747
+ async function buildSearchIndex(api, entries, onProgress) {
748
+ const index = new Map();
749
+ for (const entry of entries) {
750
+ index.set(entry.id, `${entry.title.toLowerCase()} ${entry.dir.toLowerCase()}`);
751
+ }
752
+ const ids = entries.map(entry => entry.id).filter(id => SESSION_ID_PATTERN.test(id));
753
+ const remaining = new Set(ids);
754
+ const total = ids.length;
755
+ let indexed = 0;
756
+ const report = complete => onProgress?.({
757
+ indexed,
758
+ total,
759
+ complete
760
+ }, index);
761
+ report(false);
762
+ const db = await openTranscriptDb();
763
+ if (db) {
764
+ try {
765
+ for (let i = 0; i < ids.length; i += TRANSCRIPT_BATCH) {
766
+ const batch = ids.slice(i, i + TRANSCRIPT_BATCH);
767
+ const placeholders = batch.map(() => "?").join(",");
768
+ const rows = db.query(`SELECT session_id AS sid, data FROM session_message WHERE session_id IN (${placeholders}) AND type IN ('user', 'assistant') ORDER BY seq`).all(...batch) ?? [];
769
+ for (const row of rows) addTranscript(index, row.sid, row.data);
770
+ for (const id of batch) remaining.delete(id);
771
+ indexed += batch.length;
772
+ report(false);
773
+ await nextTick();
774
+ }
775
+ } catch {
776
+ // local store unavailable mid-walk; fall back to the remote path below
777
+ } finally {
778
+ db.close();
779
+ }
780
+ }
781
+ if (remaining.size > 0) {
782
+ await buildSearchIndexRemote(api, [...remaining], index, () => {
783
+ indexed += 1;
784
+ if (indexed % TRANSCRIPT_BATCH === 0) report(false);
785
+ });
786
+ }
787
+ report(true);
788
+ return index;
789
+ }
790
+ async function buildSearchIndexRemote(api, ids, index, onEach) {
791
+ const home = process.env.HOME ?? "";
792
+ const cacheDir = process.env.SESH_CACHE_DIR ?? `${process.env.XDG_CACHE_HOME ?? `${home}/.cache`}/sesh`;
793
+ let cursor = 0;
794
+ // Bounded concurrency: a fixed worker pool instead of one request per session.
795
+ const worker = async () => {
796
+ for (;;) {
797
+ const i = cursor++;
798
+ if (i >= ids.length) return;
799
+ const id = ids[i];
800
+ const base = index.get(id) ?? "";
801
+ try {
802
+ const raw = await readFile(`${cacheDir}/extractions/${id}.json`, "utf8");
803
+ const record = JSON.parse(raw);
804
+ const fulltext = typeof record?.fulltextLower === "string" ? record.fulltextLower : "";
805
+ // Extraction caches prefix the transcript with the cached title. The
806
+ // live entry already indexes its own title; strip that duplicate so a
807
+ // title-only match cannot appear as a transcript excerpt.
808
+ const title = typeof record?.title === "string" ? `${record.title.toLowerCase()} ` : "";
809
+ const transcript = title && fulltext.startsWith(title) ? fulltext.slice(title.length) : fulltext;
810
+ index.set(id, `${base} ${transcript}`.trim());
811
+ } catch {
812
+ try {
813
+ const result = await api.client.session.messages({
814
+ sessionID: id
815
+ });
816
+ const messages = result.data ?? [];
817
+ const text = messages.flatMap(message => message.parts.filter(part => part.type === "text").map(part => part.text)).join(" ").toLowerCase();
818
+ index.set(id, `${base} ${text}`.trim());
819
+ } catch {}
820
+ }
821
+ onEach();
822
+ }
823
+ };
824
+ await Promise.all(Array.from({
825
+ length: Math.min(REMOTE_CONCURRENCY, ids.length)
826
+ }, worker));
827
+ }
828
+
829
+ // The index is title + directory + text parts, all lowercased. Start looking
830
+ // after the metadata so a title match never masquerades as a transcript hit.
831
+ function transcriptMatchExcerpt(indexed, entry, query) {
832
+ const needle = query.trim().toLowerCase();
833
+ if (!indexed || !needle) return undefined;
834
+ const transcriptStart = `${entry.title.toLowerCase()} ${entry.dir.toLowerCase()}`.length + 1;
835
+ const at = indexed.indexOf(needle, transcriptStart);
836
+ if (at < 0) return undefined;
837
+ const start = Math.max(transcriptStart, at - 28);
838
+ const end = Math.min(indexed.length, Math.max(at + needle.length + 28, start + 90));
839
+ const excerpt = indexed.slice(start, end).replace(/\s+/g, " ").trim();
840
+ return `${start > transcriptStart ? "…" : ""}${excerpt}${end < indexed.length ? "…" : ""}`;
841
+ }
842
+ function filterPickerEntries(entries, filters, index, waitingIds, pinnedSessions) {
843
+ const q = filters.query.trim().toLowerCase();
844
+ const pinned = new Set(pinnedSessions);
845
+ return entries.filter(entry => {
846
+ if (filters.scope && entry.group !== filters.scope) return false;
847
+ if (filters.waitingOnly && !waitingIds.has(entry.id)) return false;
848
+ if (filters.pinnedOnly && !pinned.has(entry.id)) return false;
849
+ if (!q) return true;
850
+ return (index.get(entry.id) ?? `${entry.title} ${entry.dir}`.toLowerCase()).includes(q);
851
+ });
852
+ }
853
+ const TRANSCRIPT_PREVIEW_TURNS = 8;
854
+ const TRANSCRIPT_PREVIEW_CHARS = 2000;
855
+
856
+ // Case-insensitive range of `query` inside `text`, so a matched row can show
857
+ // which part of it the search hit instead of only filtering invisibly. Kept
858
+ // below the data-layer markers that tests/tui.mjs evaluates: JSX must not enter
859
+ // that extraction window.
860
+ function matchRange(text, query) {
861
+ const q = query.trim().toLowerCase();
862
+ if (!q) return undefined;
863
+ const at = text.toLowerCase().indexOf(q);
864
+ return at < 0 ? undefined : [at, at + q.length];
865
+ }
866
+ function Highlighted(props) {
867
+ const parts = createMemo(() => {
868
+ const range = matchRange(props.text, props.query);
869
+ if (!range) return [props.text, "", ""];
870
+ return [props.text.slice(0, range[0]), props.text.slice(range[0], range[1]), props.text.slice(range[1])];
871
+ });
872
+ return (() => {
873
+ var _el$ = _$createElement("text"),
874
+ _el$2 = _$createElement("span");
875
+ _$insertNode(_el$, _el$2);
876
+ _$setProp(_el$, "flexGrow", 1);
877
+ _$setProp(_el$, "flexShrink", 1);
878
+ _$setProp(_el$, "overflow", "hidden");
879
+ _$setProp(_el$, "wrapMode", "none");
880
+ _$insert(_el$, () => parts()[0], _el$2);
881
+ _$insert(_el$2, () => parts()[1]);
882
+ _$insert(_el$, () => parts()[2], null);
883
+ _$effect(_p$ => {
884
+ var _v$ = props.bold ? TextAttributes.BOLD : undefined,
885
+ _v$2 = {
886
+ fg: props.color
887
+ },
888
+ _v$3 = {
889
+ fg: props.matchColor
890
+ };
891
+ _v$ !== _p$.e && (_p$.e = _$setProp(_el$, "attributes", _v$, _p$.e));
892
+ _v$2 !== _p$.t && (_p$.t = _$setProp(_el$, "style", _v$2, _p$.t));
893
+ _v$3 !== _p$.a && (_p$.a = _$setProp(_el$2, "style", _v$3, _p$.a));
894
+ return _p$;
895
+ }, {
896
+ e: undefined,
897
+ t: undefined,
898
+ a: undefined
899
+ });
900
+ return _el$;
901
+ })();
902
+ }
903
+ async function fetchTranscriptText(api, sessionID) {
904
+ try {
905
+ const result = await api.client.session.messages({
906
+ sessionID,
907
+ preview: true
908
+ });
909
+ const messages = result.data ?? [];
910
+ const blocks = messages.map(message => {
911
+ const text = message.parts.filter(part => part.type === "text").map(part => part.text).join("\n").trim();
912
+ if (!text) return "";
913
+ const role = message.info.role;
914
+ return `**${role === "user" ? "You" : "Assistant"}**\n\n${text.slice(0, TRANSCRIPT_PREVIEW_CHARS)}`;
915
+ }).filter(Boolean).slice(-TRANSCRIPT_PREVIEW_TURNS).reverse();
916
+ const text = blocks.join("\n\n---\n\n");
917
+ return text || "(no text in this session)";
918
+ } catch {
919
+ return "(preview unavailable)";
920
+ }
921
+ }
922
+
923
+ // Shared transcript preview dialog. Call from a component's setup scope so the
924
+ // preview key layer is torn down with it.
925
+ function createTranscriptPreview(api) {
926
+ const [previewID, setPreviewID] = createSignal();
927
+ const [previewText, setPreviewText] = createSignal("");
928
+ let disposePreviewKeys;
929
+ const close = () => {
930
+ setPreviewID(undefined);
931
+ api.ui.dialog.clear();
932
+ disposePreviewKeys?.();
933
+ disposePreviewKeys = undefined;
934
+ };
935
+ const open = entry => {
936
+ if (previewID() === entry.id) {
937
+ close();
938
+ return;
939
+ }
940
+ if (previewID()) close();
941
+ setPreviewID(entry.id);
942
+ setPreviewText("Loading…");
943
+ void fetchTranscriptText(api, entry.id).then(text => {
944
+ if (previewID() === entry.id) setPreviewText(text);
945
+ });
946
+ api.ui.dialog.replace(() => (() => {
947
+ var _el$3 = _$createElement("box"),
948
+ _el$4 = _$createElement("box"),
949
+ _el$5 = _$createElement("text"),
950
+ _el$6 = _$createElement("text"),
951
+ _el$8 = _$createElement("text"),
952
+ _el$9 = _$createTextNode(` · `),
953
+ _el$0 = _$createElement("scrollbox");
954
+ _$insertNode(_el$3, _el$4);
955
+ _$insertNode(_el$3, _el$8);
956
+ _$insertNode(_el$3, _el$0);
957
+ _$setProp(_el$3, "flexDirection", "column");
958
+ _$setProp(_el$3, "paddingLeft", 4);
959
+ _$setProp(_el$3, "paddingRight", 4);
960
+ _$setProp(_el$3, "paddingBottom", 1);
961
+ _$setProp(_el$3, "gap", 1);
962
+ _$setProp(_el$3, "onMouseDown", event => event.stopPropagation());
963
+ _$insertNode(_el$4, _el$5);
964
+ _$insertNode(_el$4, _el$6);
965
+ _$setProp(_el$4, "flexDirection", "row");
966
+ _$setProp(_el$4, "justifyContent", "space-between");
967
+ _$insert(_el$5, () => entry.title);
968
+ _$insertNode(_el$6, _$createTextNode(`esc`));
969
+ _$setProp(_el$6, "onMouseUp", close);
970
+ _$insertNode(_el$8, _el$9);
971
+ _$insert(_el$8, () => prettyDir(entry.dir, process.env.HOME ?? ""), _el$9);
972
+ _$insert(_el$8, () => ago(entry.updated), null);
973
+ _$setProp(_el$0, "paddingLeft", 1);
974
+ _$setProp(_el$0, "paddingRight", 1);
975
+ _$setProp(_el$0, "scrollbarOptions", {
976
+ visible: false
977
+ });
978
+ _$insert(_el$0, (() => {
979
+ var _c$ = _$memo(() => !!getMarkdownStyle(api.theme.current, api.renderer?.nativeSession));
980
+ return () => _c$() ? (() => {
981
+ var _el$1 = _$createElement("markdown");
982
+ _$effect(_p$ => {
983
+ var _v$9 = previewText(),
984
+ _v$0 = getMarkdownStyle(api.theme.current, api.renderer?.nativeSession),
985
+ _v$1 = getTreeSitterClient();
986
+ _v$9 !== _p$.e && (_p$.e = _$setProp(_el$1, "content", _v$9, _p$.e));
987
+ _v$0 !== _p$.t && (_p$.t = _$setProp(_el$1, "syntaxStyle", _v$0, _p$.t));
988
+ _v$1 !== _p$.a && (_p$.a = _$setProp(_el$1, "treeSitterClient", _v$1, _p$.a));
989
+ return _p$;
990
+ }, {
991
+ e: undefined,
992
+ t: undefined,
993
+ a: undefined
994
+ });
995
+ return _el$1;
996
+ })() : (() => {
997
+ var _el$10 = _$createElement("text");
998
+ _$setProp(_el$10, "wrapMode", "word");
999
+ _$insert(_el$10, previewText);
1000
+ return _el$10;
1001
+ })();
1002
+ })());
1003
+ _$effect(_p$ => {
1004
+ var _v$4 = api.theme.current.backgroundPanel,
1005
+ _v$5 = TextAttributes.BOLD,
1006
+ _v$6 = {
1007
+ fg: api.theme.current.textMuted
1008
+ },
1009
+ _v$7 = {
1010
+ fg: api.theme.current.textMuted
1011
+ },
1012
+ _v$8 = Math.max(6, Math.floor(api.renderer.height / 2));
1013
+ _v$4 !== _p$.e && (_p$.e = _$setProp(_el$3, "backgroundColor", _v$4, _p$.e));
1014
+ _v$5 !== _p$.t && (_p$.t = _$setProp(_el$5, "attributes", _v$5, _p$.t));
1015
+ _v$6 !== _p$.a && (_p$.a = _$setProp(_el$6, "style", _v$6, _p$.a));
1016
+ _v$7 !== _p$.o && (_p$.o = _$setProp(_el$8, "style", _v$7, _p$.o));
1017
+ _v$8 !== _p$.i && (_p$.i = _$setProp(_el$0, "maxHeight", _v$8, _p$.i));
1018
+ return _p$;
1019
+ }, {
1020
+ e: undefined,
1021
+ t: undefined,
1022
+ a: undefined,
1023
+ o: undefined,
1024
+ i: undefined
1025
+ });
1026
+ return _el$3;
1027
+ })(), () => {
1028
+ setPreviewID(undefined);
1029
+ disposePreviewKeys?.();
1030
+ disposePreviewKeys = undefined;
1031
+ });
1032
+ api.ui.dialog.setSize("large");
1033
+ disposePreviewKeys?.();
1034
+ disposePreviewKeys = api.keymap.registerLayer({
1035
+ mode: "modal",
1036
+ bindings: [{
1037
+ key: "escape",
1038
+ desc: "Close preview",
1039
+ preventDefault: true,
1040
+ cmd: close
1041
+ }]
1042
+ });
1043
+ };
1044
+ onCleanup(() => disposePreviewKeys?.());
1045
+
1046
+ // Outside presses reach the root handler; close on the first one instead of
1047
+ // relying on the host backdrop. Gated on our own preview so other dialogs
1048
+ // are never touched.
1049
+ const closeIfOpen = () => {
1050
+ if (previewID()) close();
1051
+ };
1052
+ return {
1053
+ open,
1054
+ closeIfOpen
1055
+ };
1056
+ }
1057
+ // Window by terminal lines, not session count: group spacing and transcript
1058
+ // excerpts each consume an extra line. Carry the directory heading forward
1059
+ // when a long group starts above the viewport.
1060
+ function pickerWindow(rows, start, height, excerpt) {
1061
+ if (!rows.length) return [];
1062
+ const first = Math.max(0, Math.min(start, rows.length - 1));
1063
+ const shown = [];
1064
+ let remaining = height;
1065
+ if (rows[first].kind === "item") {
1066
+ for (let i = first - 1; i >= 0; i--) {
1067
+ const row = rows[i];
1068
+ if (row.kind !== "group") continue;
1069
+ shown.push({
1070
+ ...row,
1071
+ continued: true
1072
+ });
1073
+ remaining--;
1074
+ break;
1075
+ }
1076
+ }
1077
+ for (let i = first; i < rows.length; i++) {
1078
+ const row = rows[i];
1079
+ const lines = row.kind === "group" ? 2 : excerpt(row.entry) ? 2 : 1;
1080
+ if (lines > remaining && shown.length) break;
1081
+ shown.push(row);
1082
+ remaining -= lines;
1083
+ }
1084
+ return shown;
1085
+ }
1086
+ function pickerLastStart(rows, height, excerpt) {
1087
+ if (!rows.length) return 0;
1088
+ let start = rows.length - 1;
1089
+ while (start > 0 && pickerWindow(rows, start - 1, height, excerpt).at(-1) === rows.at(-1)) start--;
1090
+ return start;
1091
+ }
1092
+ function SidebarSessions(props) {
1093
+ const theme = () => props.api.theme.current;
1094
+ const home = process.env.HOME ?? "";
1095
+ const [entries, setEntries] = createSignal([]);
1096
+ const [sectionCollapsed, setSectionCollapsed] = createSignal(false);
1097
+ const [collapsed, setCollapsed] = createSignal({});
1098
+ const [hovered, setHovered] = createSignal();
1099
+ const preview = createTranscriptPreview(props.api);
1100
+ const [menuState, setMenuState] = createSignal();
1101
+ let portalContainer;
1102
+ const [query, setQuery] = createSignal("");
1103
+ const [deleting, setDeleting] = createSignal();
1104
+ const [searching, setSearching] = createSignal(false);
1105
+ const [loadFailed, setLoadFailed] = createSignal(false);
1106
+ const [navActive, setNavActive] = createSignal(false);
1107
+ const [cursor, setCursor] = createSignal(0);
1108
+ const [pendingDelete, setPendingDelete] = createSignal();
1109
+ const [waiting, setWaiting] = createSignal(new Map());
1110
+ let confirmTimer;
1111
+ const currentID = createMemo(() => {
1112
+ const route = props.api.route.current;
1113
+ if (route.name !== "session") return undefined;
1114
+ const params = route.params;
1115
+ return params && typeof params.sessionID === "string" ? params.sessionID : undefined;
1116
+ });
1117
+ onMount(() => {
1118
+ let alive = true;
1119
+ const load = async () => {
1120
+ props.refreshPins();
1121
+ try {
1122
+ const {
1123
+ entries: all
1124
+ } = await fetchEntries(props.api);
1125
+ if (alive) {
1126
+ setEntries(all);
1127
+ setLoadFailed(false);
1128
+ }
1129
+ } catch {
1130
+ // leave the previous list in place rather than flashing to blank, but
1131
+ // say so when there is nothing at all to show
1132
+ if (alive && entries().length === 0) setLoadFailed(true);
1133
+ }
1134
+ try {
1135
+ const db = await openTranscriptDb();
1136
+ if (db) {
1137
+ try {
1138
+ const ids = await queryWaitingIds(db);
1139
+ if (alive) setWaiting(ids);
1140
+ } finally {
1141
+ db.close();
1142
+ }
1143
+ }
1144
+ } catch {
1145
+ // leave the previous waiting set in place rather than flashing
1146
+ }
1147
+ };
1148
+ void load();
1149
+ const clock = setInterval(() => void load(), POLL_MS);
1150
+ onCleanup(() => {
1151
+ alive = false;
1152
+ clearInterval(clock);
1153
+ });
1154
+ });
1155
+ const filteredEntries = createMemo(() => {
1156
+ const q = query().trim().toLowerCase();
1157
+ const ordered = entries();
1158
+ if (!q) return ordered;
1159
+ return ordered.filter(entry => entry.title.toLowerCase().includes(q) || entry.dir.toLowerCase().includes(q));
1160
+ });
1161
+ const shownEntries = createMemo(() => filteredEntries());
1162
+ const remaining = createMemo(() => Math.max(0, filteredEntries().length - shownEntries().length));
1163
+ const NEEDS_INPUT_DIR = "__needs_input__";
1164
+ const waitingEntries = createMemo(() => {
1165
+ const ids = waiting();
1166
+ return newestFirst(shownEntries().filter(entry => ids.has(entry.id)));
1167
+ });
1168
+ const tree = createMemo(() => {
1169
+ const rows = [];
1170
+ const pending = waitingEntries();
1171
+ if (pending.length > 0) {
1172
+ rows.push({
1173
+ kind: "group",
1174
+ dir: NEEDS_INPUT_DIR,
1175
+ label: "Needs input",
1176
+ count: pending.length
1177
+ });
1178
+ if (!collapsed()[NEEDS_INPUT_DIR]) {
1179
+ for (let i = 0; i < pending.length; i++) {
1180
+ rows.push({
1181
+ kind: "item",
1182
+ entry: pending[i],
1183
+ last: i === pending.length - 1
1184
+ });
1185
+ }
1186
+ }
1187
+ }
1188
+ const groups = new Map();
1189
+ for (const entry of shownEntries()) {
1190
+ if (waiting().has(entry.id)) continue;
1191
+ const list = groups.get(entry.dir) ?? [];
1192
+ list.push(entry);
1193
+ groups.set(entry.dir, list);
1194
+ }
1195
+ const ordered = [...groups.entries()].map(([dir, list]) => ({
1196
+ dir,
1197
+ list,
1198
+ updated: Math.max(...list.map(entry => entry.updated))
1199
+ })).sort((a, b) => {
1200
+ const pins = props.pins();
1201
+ const rank = group => pins.directories.includes(group.dir) ? 2 : group.list.some(entry => pins.sessions.includes(entry.id)) ? 1 : 0;
1202
+ return rank(b) - rank(a) || b.updated - a.updated;
1203
+ });
1204
+ for (const group of ordered) {
1205
+ const sessions = newestFirst(group.list);
1206
+ rows.push({
1207
+ kind: "group",
1208
+ dir: group.dir,
1209
+ label: prettyDir(group.dir, home),
1210
+ count: group.list.length
1211
+ });
1212
+ if (collapsed()[group.dir]) continue;
1213
+ for (let i = 0; i < sessions.length; i++) {
1214
+ rows.push({
1215
+ kind: "item",
1216
+ entry: sessions[i],
1217
+ last: i === sessions.length - 1
1218
+ });
1219
+ }
1220
+ }
1221
+ return rows;
1222
+ });
1223
+ const toggleGroup = dir => {
1224
+ setCollapsed(prev => ({
1225
+ ...prev,
1226
+ [dir]: !prev[dir]
1227
+ }));
1228
+ };
1229
+ const toggleSection = () => {
1230
+ if (!sectionCollapsed()) {
1231
+ cancelDelete();
1232
+ setNavActive(false);
1233
+ setSearching(false);
1234
+ setHovered(undefined);
1235
+ menu.close();
1236
+ }
1237
+ setSectionCollapsed(value => !value);
1238
+ };
1239
+ const openSession = entry => {
1240
+ // Opening a session hands input back to the main view: release every
1241
+ // sidebar capture first, so its key layers cannot shadow the composer's
1242
+ // once the new session is open.
1243
+ menu.close();
1244
+ cancelDelete();
1245
+ setNavActive(false);
1246
+ setSearching(false);
1247
+ setHovered(undefined);
1248
+ props.api.route.navigate("session", {
1249
+ sessionID: entry.id
1250
+ });
1251
+ };
1252
+
1253
+ // Flat order of the rows a cursor can land on, so keyboard navigation and
1254
+ // mouse hover resolve to the same row.
1255
+ const itemRows = createMemo(() => tree().flatMap(row => row.kind === "item" ? [row.entry] : []));
1256
+ const isActive = id => navActive() ? itemRows()[cursor()]?.id === id : hovered() === id;
1257
+ const isPendingDelete = id => pendingDelete() === id;
1258
+ const routeKey = createMemo(() => {
1259
+ const route = props.api.route.current;
1260
+ const id = route.name === "session" && typeof route.params?.sessionID === "string" ? route.params.sessionID : "";
1261
+ return `${route.name}:${id}`;
1262
+ });
1263
+ createEffect(on(routeKey, () => menu.close(), {
1264
+ defer: true
1265
+ }));
1266
+ // Navigating away also releases the sidebar's keyboard captures: a nav,
1267
+ // search, hover or armed delete left over from the previous session must
1268
+ // not shadow the composer's keys in the next one.
1269
+ createEffect(on(routeKey, () => {
1270
+ cancelDelete();
1271
+ setNavActive(false);
1272
+ setSearching(false);
1273
+ setHovered(undefined);
1274
+ }, {
1275
+ defer: true
1276
+ }));
1277
+ createEffect(() => {
1278
+ const state = menuState();
1279
+ if (state && !itemRows().some(entry => entry.id === state.entry.id)) menu.close();
1280
+ });
1281
+ createEffect(() => {
1282
+ if (props.api.ui.dialog.open) menu.close();
1283
+ // A modal dialog owns input while open: yield the sidebar's keyboard
1284
+ // captures too, so its layers cannot double-fire with the dialog's own.
1285
+ if (props.api.ui.dialog.open) {
1286
+ cancelDelete();
1287
+ setNavActive(false);
1288
+ setSearching(false);
1289
+ setHovered(undefined);
1290
+ }
1291
+ });
1292
+ const moveCursor = delta => {
1293
+ const count = itemRows().length;
1294
+ if (count === 0) return;
1295
+ setCursor(value => Math.max(0, Math.min(count - 1, value + delta)));
1296
+ };
1297
+ let previousRows = [];
1298
+ createEffect(() => {
1299
+ const rows = itemRows();
1300
+ const selected = previousRows[cursor()]?.id;
1301
+ const position = rows.findIndex(entry => entry.id === selected);
1302
+ if (position >= 0 && position !== cursor()) setCursor(position);else if (cursor() >= rows.length) setCursor(Math.max(0, rows.length - 1));
1303
+ previousRows = rows;
1304
+ });
1305
+
1306
+ // Marker only: a spinner for a working agent, the open-session dot, and the
1307
+ // neutral row for idle sessions. Titles stay plain and no status text is
1308
+ // rendered.
1309
+ const markerOf = id => {
1310
+ let live;
1311
+ try {
1312
+ live = props.api.state.session.status(id);
1313
+ } catch {}
1314
+ return sidebarMarker(id === currentID(), live);
1315
+ };
1316
+
1317
+ // The spinner animates only while a listed session is actually running, so
1318
+ // an idle list costs no timer and only running rows re-render per frame.
1319
+ const [spinFrame, setSpinFrame] = createSignal(0);
1320
+ let spinTimer;
1321
+ createEffect(() => {
1322
+ const spinning = itemRows().some(entry => markerOf(entry.id) === "running");
1323
+ if (spinning && !spinTimer) {
1324
+ spinTimer = setInterval(() => setSpinFrame(value => (value + 1) % SIDEBAR_SPINNER.length), 120);
1325
+ } else if (!spinning && spinTimer) {
1326
+ clearInterval(spinTimer);
1327
+ spinTimer = undefined;
1328
+ setSpinFrame(0);
1329
+ }
1330
+ });
1331
+ onCleanup(() => {
1332
+ if (spinTimer) clearInterval(spinTimer);
1333
+ });
1334
+ const cancelDelete = () => {
1335
+ if (confirmTimer) clearTimeout(confirmTimer);
1336
+ confirmTimer = undefined;
1337
+ setPendingDelete(undefined);
1338
+ };
1339
+
1340
+ // Deleting is irreversible, so ctrl+x arms the row and a second ctrl+x (or y)
1341
+ // commits it. The arm survives pointer movement — edge-hover flicker must
1342
+ // not eat the confirm — and is cleared by the five-second timeout, n, Esc,
1343
+ // collapsing the section, or the delete itself.
1344
+ const requestDelete = entry => {
1345
+ if (deleting()) return;
1346
+ if (pendingDelete() === entry.id) {
1347
+ void deleteEntry(entry);
1348
+ return;
1349
+ }
1350
+ setPendingDelete(entry.id);
1351
+ if (confirmTimer) clearTimeout(confirmTimer);
1352
+ confirmTimer = setTimeout(() => setPendingDelete(undefined), 5000);
1353
+ };
1354
+
1355
+ // The confirm always targets the visibly armed row ("ctrl+x again"),
1356
+ // wherever the pointer or cursor currently is. Returns true when an arm
1357
+ // was pending (and is now committed or discarded as stale).
1358
+ const confirmArmed = () => {
1359
+ const id = pendingDelete();
1360
+ if (!id) return false;
1361
+ const entry = entries().find(e => e.id === id);
1362
+ if (!entry) cancelDelete();else void deleteEntry(entry);
1363
+ return true;
1364
+ };
1365
+ let sidebarScroll;
1366
+ const deleteEntry = async entry => {
1367
+ cancelDelete();
1368
+ if (deleting()) return;
1369
+ setDeleting(entry.id);
1370
+ try {
1371
+ const result = await props.api.client.session.delete({
1372
+ sessionID: entry.id,
1373
+ directory: entry.dir || undefined
1374
+ });
1375
+ if (result && "error" in result && result.error) throw result.error;
1376
+ const {
1377
+ entries: remaining
1378
+ } = await fetchEntries(props.api);
1379
+ if (remaining.some(item => item.id === entry.id)) {
1380
+ setEntries(remaining);
1381
+ props.api.ui.toast({
1382
+ message: "Session was not deleted",
1383
+ variant: "error"
1384
+ });
1385
+ return;
1386
+ }
1387
+ const scrollTop = sidebarScroll?.scrollTop ?? 0;
1388
+ setEntries(remaining);
1389
+ setHovered(undefined);
1390
+ const restore = () => {
1391
+ if (sidebarScroll) sidebarScroll.scrollTop = scrollTop;
1392
+ };
1393
+ queueMicrotask(restore);
1394
+ setTimeout(restore, 0);
1395
+ props.api.ui.toast({
1396
+ message: `Deleted "${truncate(entry.title, 40)}"`,
1397
+ variant: "info"
1398
+ });
1399
+ if (currentID() === entry.id) props.api.route.navigate("home");
1400
+ } catch {
1401
+ props.api.ui.toast({
1402
+ message: "Could not delete session",
1403
+ variant: "error"
1404
+ });
1405
+ } finally {
1406
+ setDeleting(undefined);
1407
+ }
1408
+ };
1409
+ const forkEntry = async entry => {
1410
+ try {
1411
+ const result = await props.api.client.session.fork({
1412
+ sessionID: entry.id,
1413
+ directory: entry.dir || undefined
1414
+ });
1415
+ const created = result?.data ?? result;
1416
+ if (!created?.id) throw new Error("fork returned no session");
1417
+ props.api.route.navigate("session", {
1418
+ sessionID: created.id
1419
+ });
1420
+ props.api.ui.toast({
1421
+ message: `Forked "${truncate(entry.title, 40)}"`,
1422
+ variant: "info"
1423
+ });
1424
+ } catch {
1425
+ props.api.ui.toast({
1426
+ message: "Could not fork session",
1427
+ variant: "error"
1428
+ });
1429
+ }
1430
+ };
1431
+ const menu = createContextMenu({
1432
+ registerLayer: layer => props.api.keymap.registerLayer(layer),
1433
+ actions: {
1434
+ open: entry => void openSession(entry),
1435
+ preview: entry => preview.open(entry),
1436
+ workspace: entry => openInCmuxWorkspace(props.api, entry),
1437
+ fork: entry => void forkEntry(entry),
1438
+ pin: entry => props.onTogglePin("sessions", entry.id),
1439
+ delete: entry => void deleteEntry(entry)
1440
+ },
1441
+ pinned: entry => props.pins().sessions.includes(entry.id),
1442
+ cmux: () => Boolean(process.env.CMUX_WORKSPACE_ID),
1443
+ blocked: () => props.api.ui.dialog.open,
1444
+ onChange: setMenuState
1445
+ });
1446
+ const menuView = createMemo(() => {
1447
+ const state = menuState();
1448
+ if (!state) return undefined;
1449
+ const box = contextMenuBox({
1450
+ x: state.x,
1451
+ y: state.y,
1452
+ ...contextMenuSize(state.items)
1453
+ }, props.api.renderer.width, props.api.renderer.height);
1454
+ return {
1455
+ state,
1456
+ box
1457
+ };
1458
+ });
1459
+ let disposeHoverSpace;
1460
+ let disposeSearch;
1461
+ let disposeNav;
1462
+ let disposeConfirm;
1463
+ let insideSearchBox = false;
1464
+ createEffect(() => {
1465
+ disposeConfirm?.();
1466
+ disposeConfirm = undefined;
1467
+ if (!pendingDelete()) return;
1468
+ disposeConfirm = props.api.keymap.registerLayer({
1469
+ mode: "base",
1470
+ priority: 25,
1471
+ bindings: [{
1472
+ key: "y",
1473
+ desc: "Confirm delete",
1474
+ preventDefault: true,
1475
+ cmd: () => {
1476
+ confirmArmed();
1477
+ }
1478
+ }, {
1479
+ key: "alt+d",
1480
+ desc: "Confirm delete",
1481
+ preventDefault: true,
1482
+ cmd: () => {
1483
+ confirmArmed();
1484
+ }
1485
+ }, {
1486
+ key: "enter",
1487
+ desc: "Confirm delete",
1488
+ preventDefault: true,
1489
+ cmd: () => {
1490
+ confirmArmed();
1491
+ }
1492
+ }, {
1493
+ key: "n",
1494
+ desc: "Cancel delete",
1495
+ preventDefault: true,
1496
+ cmd: cancelDelete
1497
+ }, {
1498
+ key: "escape",
1499
+ desc: "Cancel delete",
1500
+ preventDefault: true,
1501
+ cmd: cancelDelete
1502
+ }]
1503
+ });
1504
+ });
1505
+ const onRootMouseDown = () => {
1506
+ menu.close();
1507
+ preview.closeIfOpen();
1508
+ if (insideSearchBox) {
1509
+ insideSearchBox = false;
1510
+ return;
1511
+ }
1512
+ if (searching()) setSearching(false);
1513
+ // A click anywhere outside the sidebar means input is back in the main
1514
+ // view: release the nav layer, hover shortcuts and any armed delete so
1515
+ // the sidebar's keys and rows stop shadowing the composer.
1516
+ cancelDelete();
1517
+ if (navActive()) setNavActive(false);
1518
+ setHovered(undefined);
1519
+ };
1520
+ onMount(() => {
1521
+ ensureRootMouse(props.api.renderer);
1522
+ rootMouseHandlers.add(onRootMouseDown);
1523
+ const closeMenu = () => menu.close();
1524
+ props.api.renderer.on("resize", closeMenu);
1525
+ onCleanup(() => {
1526
+ rootMouseHandlers.delete(onRootMouseDown);
1527
+ props.api.renderer.off("resize", closeMenu);
1528
+ });
1529
+ });
1530
+ const appendQuery = input => {
1531
+ if (input === "\b") setQuery(value => value.slice(0, -1));else setQuery(value => value + input);
1532
+ };
1533
+ createEffect(() => {
1534
+ disposeSearch?.();
1535
+ disposeSearch = undefined;
1536
+ if (!searching()) return;
1537
+ disposeSearch = props.api.keymap.registerLayer({
1538
+ mode: "base",
1539
+ priority: 20,
1540
+ bindings: searchBindings(appendQuery, () => setSearching(false))
1541
+ });
1542
+ });
1543
+ const openSidebarPreview = entry => preview.open(entry);
1544
+ createEffect(() => {
1545
+ disposeHoverSpace?.();
1546
+ disposeHoverSpace = undefined;
1547
+ const id = hovered();
1548
+ if (!id) return;
1549
+ const entry = entries().find(e => e.id === id);
1550
+ if (!entry) return;
1551
+ disposeHoverSpace = props.api.keymap.registerLayer({
1552
+ mode: "base",
1553
+ priority: 20,
1554
+ bindings: [{
1555
+ key: "alt+p",
1556
+ desc: "Preview session transcript",
1557
+ preventDefault: true,
1558
+ cmd: () => openSidebarPreview(entry)
1559
+ }, {
1560
+ key: "alt+d",
1561
+ desc: "Delete session",
1562
+ preventDefault: true,
1563
+ cmd: () => {
1564
+ if (!confirmArmed()) requestDelete(entry);
1565
+ }
1566
+ }, {
1567
+ key: "alt+s",
1568
+ desc: "Pin session",
1569
+ preventDefault: true,
1570
+ cmd: () => props.onTogglePin("sessions", entry.id)
1571
+ }, {
1572
+ key: "alt+r",
1573
+ desc: "Pin directory",
1574
+ preventDefault: true,
1575
+ cmd: () => props.onTogglePin("directories", entry.dir)
1576
+ }, {
1577
+ key: "/",
1578
+ desc: "Search sessions",
1579
+ preventDefault: true,
1580
+ cmd: () => setSearching(true)
1581
+ }]
1582
+ });
1583
+ });
1584
+
1585
+ // Keyboard navigation is opt-in (click the "Sessions" heading or the search
1586
+ // box) so the sidebar never steals the arrow keys from the main prompt.
1587
+ // Clicking anywhere outside the sidebar, opening a session, or navigating
1588
+ // releases it again, so its layers cannot shadow the composer afterwards.
1589
+ createEffect(() => {
1590
+ disposeNav?.();
1591
+ disposeNav = undefined;
1592
+ if (!navActive()) return;
1593
+ const bindings = [{
1594
+ key: "up",
1595
+ desc: "Previous session",
1596
+ preventDefault: true,
1597
+ cmd: () => moveCursor(-1)
1598
+ }, {
1599
+ key: "down",
1600
+ desc: "Next session",
1601
+ preventDefault: true,
1602
+ cmd: () => moveCursor(1)
1603
+ }, {
1604
+ key: "pageup",
1605
+ desc: "Page up",
1606
+ preventDefault: true,
1607
+ cmd: () => moveCursor(-5)
1608
+ }, {
1609
+ key: "pagedown",
1610
+ desc: "Page down",
1611
+ preventDefault: true,
1612
+ cmd: () => moveCursor(5)
1613
+ }, {
1614
+ key: "home",
1615
+ desc: "First session",
1616
+ preventDefault: true,
1617
+ cmd: () => setCursor(0)
1618
+ }, {
1619
+ key: "end",
1620
+ desc: "Last session",
1621
+ preventDefault: true,
1622
+ cmd: () => setCursor(Math.max(0, itemRows().length - 1))
1623
+ }, {
1624
+ key: "alt+p",
1625
+ desc: "Preview session transcript",
1626
+ preventDefault: true,
1627
+ cmd: () => {
1628
+ const entry = itemRows()[cursor()];
1629
+ if (entry) openSidebarPreview(entry);
1630
+ }
1631
+ }, {
1632
+ key: "alt+d",
1633
+ desc: "Delete session",
1634
+ preventDefault: true,
1635
+ cmd: () => {
1636
+ if (confirmArmed()) return;
1637
+ const entry = itemRows()[cursor()];
1638
+ if (entry) requestDelete(entry);
1639
+ }
1640
+ }, {
1641
+ key: "alt+s",
1642
+ desc: "Pin session",
1643
+ preventDefault: true,
1644
+ cmd: () => {
1645
+ const entry = itemRows()[cursor()];
1646
+ if (entry) props.onTogglePin("sessions", entry.id);
1647
+ }
1648
+ }, {
1649
+ key: "alt+r",
1650
+ desc: "Pin directory",
1651
+ preventDefault: true,
1652
+ cmd: () => {
1653
+ const entry = itemRows()[cursor()];
1654
+ if (entry) props.onTogglePin("directories", entry.dir);
1655
+ }
1656
+ }, {
1657
+ key: "enter",
1658
+ desc: "Open session",
1659
+ preventDefault: true,
1660
+ cmd: () => {
1661
+ if (confirmArmed()) return;
1662
+ const entry = itemRows()[cursor()];
1663
+ if (entry) void openSession(entry);
1664
+ }
1665
+ }, {
1666
+ key: "escape",
1667
+ desc: "Leave the session list",
1668
+ preventDefault: true,
1669
+ cmd: () => {
1670
+ cancelDelete();
1671
+ setNavActive(false);
1672
+ }
1673
+ }, ...(pendingDelete() ? [{
1674
+ key: "y",
1675
+ desc: "Confirm delete",
1676
+ preventDefault: true,
1677
+ cmd: () => {
1678
+ confirmArmed();
1679
+ }
1680
+ }, {
1681
+ key: "n",
1682
+ desc: "Cancel delete",
1683
+ preventDefault: true,
1684
+ cmd: cancelDelete
1685
+ }] : [])];
1686
+ disposeNav = props.api.keymap.registerLayer({
1687
+ mode: "base",
1688
+ priority: 20,
1689
+ bindings
1690
+ });
1691
+ });
1692
+ onCleanup(() => {
1693
+ disposeHoverSpace?.();
1694
+ disposeSearch?.();
1695
+ disposeNav?.();
1696
+ disposeConfirm?.();
1697
+ menu.close();
1698
+ const container = portalContainer;
1699
+ portalContainer = undefined;
1700
+ process.nextTick(() => container?.destroyRecursively?.());
1701
+ if (confirmTimer) clearTimeout(confirmTimer);
1702
+ });
1703
+ return (() => {
1704
+ var _el$11 = _$createElement("box"),
1705
+ _el$12 = _$createElement("box"),
1706
+ _el$13 = _$createElement("text"),
1707
+ _el$14 = _$createElement("b"),
1708
+ _el$16 = _$createElement("span"),
1709
+ _el$17 = _$createElement("span"),
1710
+ _el$21 = _$createElement("text");
1711
+ _$insertNode(_el$11, _el$12);
1712
+ _$setProp(_el$11, "flexDirection", "column");
1713
+ _$setProp(_el$11, "paddingRight", 1);
1714
+ _$setProp(_el$11, "flexGrow", 1);
1715
+ _$setProp(_el$11, "flexShrink", 1);
1716
+ _$insertNode(_el$12, _el$13);
1717
+ _$insertNode(_el$12, _el$21);
1718
+ _$setProp(_el$12, "flexDirection", "row");
1719
+ _$setProp(_el$12, "justifyContent", "space-between");
1720
+ _$setProp(_el$12, "gap", 1);
1721
+ _$insertNode(_el$13, _el$14);
1722
+ _$insertNode(_el$13, _el$16);
1723
+ _$insertNode(_el$13, _el$17);
1724
+ _$setProp(_el$13, "flexShrink", 1);
1725
+ _$setProp(_el$13, "onMouseDown", event => {
1726
+ // Sidebar-internal: keep this click from reaching the root handler,
1727
+ // which would release the nav layer this toggle just activated.
1728
+ event.stopPropagation();
1729
+ if (!sectionCollapsed()) setNavActive(value => !value);
1730
+ });
1731
+ _$insertNode(_el$14, _$createTextNode(`Sessions`));
1732
+ _$insert(_el$16, (() => {
1733
+ var _c$2 = _$memo(() => !!query().trim());
1734
+ return () => _c$2() ? ` (${filteredEntries().length}/${entries().length})` : ` (${entries().length})`;
1735
+ })());
1736
+ _$insert(_el$17, () => navActive() ? " · nav" : "");
1737
+ _$insert(_el$13, _$createComponent(Show, {
1738
+ get when() {
1739
+ return waitingEntries().length > 0;
1740
+ },
1741
+ get children() {
1742
+ var _el$18 = _$createElement("span"),
1743
+ _el$19 = _$createTextNode(` · `),
1744
+ _el$20 = _$createTextNode(` need input`);
1745
+ _$insertNode(_el$18, _el$19);
1746
+ _$insertNode(_el$18, _el$20);
1747
+ _$insert(_el$18, () => waitingEntries().length, _el$20);
1748
+ _$effect(_$p => _$setProp(_el$18, "style", {
1749
+ fg: theme().warning
1750
+ }, _$p));
1751
+ return _el$18;
1752
+ }
1753
+ }), null);
1754
+ _$setProp(_el$21, "flexShrink", 0);
1755
+ _$setProp(_el$21, "onMouseDown", event => {
1756
+ event.stopPropagation();
1757
+ toggleSection();
1758
+ });
1759
+ _$insert(_el$21, () => sectionCollapsed() ? "▸ show" : "▾ hide");
1760
+ _$insert(_el$11, _$createComponent(Show, {
1761
+ get when() {
1762
+ return !sectionCollapsed();
1763
+ },
1764
+ get children() {
1765
+ return _$createComponent(Show, {
1766
+ get when() {
1767
+ return entries().length > 0;
1768
+ },
1769
+ get fallback() {
1770
+ return (() => {
1771
+ var _el$33 = _$createElement("text");
1772
+ _$insert(_el$33, () => loadFailed() ? "Session list unavailable · will retry" : "No sessions yet · option+o to browse");
1773
+ _$effect(_$p => _$setProp(_el$33, "style", {
1774
+ fg: theme().textMuted
1775
+ }, _$p));
1776
+ return _el$33;
1777
+ })();
1778
+ },
1779
+ get children() {
1780
+ return [(() => {
1781
+ var _el$22 = _$createElement("box"),
1782
+ _el$23 = _$createElement("text");
1783
+ _$insertNode(_el$22, _el$23);
1784
+ _$setProp(_el$22, "flexDirection", "row");
1785
+ _$setProp(_el$22, "gap", 1);
1786
+ _$setProp(_el$22, "paddingRight", 1);
1787
+ _$setProp(_el$22, "onMouseDown", event => {
1788
+ // Sidebar-internal: the root handler must not see this click, or
1789
+ // it would clear the search this box just activated.
1790
+ event.stopPropagation();
1791
+ insideSearchBox = true;
1792
+ setSearching(true);
1793
+ });
1794
+ _$setProp(_el$23, "flexGrow", 1);
1795
+ _$setProp(_el$23, "flexShrink", 1);
1796
+ _$setProp(_el$23, "overflow", "hidden");
1797
+ _$setProp(_el$23, "wrapMode", "none");
1798
+ _$insert(_el$23, () => query() || (searching() ? "" : "search…"), null);
1799
+ _$insert(_el$23, () => searching() ? "▏" : "", null);
1800
+ _$insert(_el$22, _$createComponent(Show, {
1801
+ get when() {
1802
+ return query();
1803
+ },
1804
+ get children() {
1805
+ var _el$24 = _$createElement("text");
1806
+ _$insertNode(_el$24, _$createTextNode(`✕`));
1807
+ _$setProp(_el$24, "flexShrink", 0);
1808
+ _$setProp(_el$24, "onMouseDown", event => {
1809
+ event.stopPropagation();
1810
+ setQuery("");
1811
+ });
1812
+ _$effect(_$p => _$setProp(_el$24, "style", {
1813
+ fg: theme().textMuted
1814
+ }, _$p));
1815
+ return _el$24;
1816
+ }
1817
+ }), null);
1818
+ _$effect(_p$ => {
1819
+ var _v$10 = searching() ? theme().backgroundElement : RGBA.fromInts(0, 0, 0, 0),
1820
+ _v$11 = {
1821
+ fg: query() ? theme().text : theme().textMuted
1822
+ };
1823
+ _v$10 !== _p$.e && (_p$.e = _$setProp(_el$22, "backgroundColor", _v$10, _p$.e));
1824
+ _v$11 !== _p$.t && (_p$.t = _$setProp(_el$23, "style", _v$11, _p$.t));
1825
+ return _p$;
1826
+ }, {
1827
+ e: undefined,
1828
+ t: undefined
1829
+ });
1830
+ return _el$22;
1831
+ })(), (() => {
1832
+ var _el$26 = _$createElement("scrollbox");
1833
+ _$use(node => sidebarScroll = node, _el$26);
1834
+ _$setProp(_el$26, "flexGrow", 1);
1835
+ _$setProp(_el$26, "flexShrink", 1);
1836
+ _$setProp(_el$26, "verticalScrollbarOptions", {
1837
+ visible: false
1838
+ });
1839
+ _$setProp(_el$26, "horizontalScrollbarOptions", {
1840
+ visible: false
1841
+ });
1842
+ _$setProp(_el$26, "onMouseScroll", () => menu.close());
1843
+ _$insert(_el$26, _$createComponent(For, {
1844
+ get each() {
1845
+ return tree();
1846
+ },
1847
+ children: row => {
1848
+ if (row.kind === "group") return (() => {
1849
+ var _el$34 = _$createElement("box"),
1850
+ _el$35 = _$createElement("text"),
1851
+ _el$36 = _$createElement("span"),
1852
+ _el$37 = _$createElement("b"),
1853
+ _el$38 = _$createElement("text"),
1854
+ _el$39 = _$createTextNode(` (`),
1855
+ _el$41 = _$createTextNode(`)`);
1856
+ _$insertNode(_el$34, _el$35);
1857
+ _$insertNode(_el$34, _el$38);
1858
+ _$setProp(_el$34, "flexDirection", "row");
1859
+ _$setProp(_el$34, "paddingTop", 1);
1860
+ _$setProp(_el$34, "onMouseDown", event => {
1861
+ event.stopPropagation();
1862
+ if (event.button === RIGHT_BUTTON) return;
1863
+ toggleGroup(row.dir);
1864
+ });
1865
+ _$insertNode(_el$35, _el$36);
1866
+ _$insertNode(_el$35, _el$37);
1867
+ _$setProp(_el$35, "flexGrow", 1);
1868
+ _$setProp(_el$35, "flexShrink", 1);
1869
+ _$setProp(_el$35, "wrapMode", "none");
1870
+ _$insert(_el$36, () => collapsed()[row.dir] ? "▸ " : "▾ ");
1871
+ _$insert(_el$37, () => props.pins().directories.includes(row.dir) ? "★ " : "", null);
1872
+ _$insert(_el$37, () => truncate(row.label, SIDEBAR_GROUP_WIDTH - (props.pins().directories.includes(row.dir) ? 2 : 0)), null);
1873
+ _$insertNode(_el$38, _el$39);
1874
+ _$insertNode(_el$38, _el$41);
1875
+ _$setProp(_el$38, "flexShrink", 0);
1876
+ _$insert(_el$38, () => row.count, _el$41);
1877
+ _$effect(_p$ => {
1878
+ var _v$15 = {
1879
+ fg: theme().textMuted
1880
+ },
1881
+ _v$16 = {
1882
+ fg: theme().textMuted
1883
+ };
1884
+ _v$15 !== _p$.e && (_p$.e = _$setProp(_el$36, "style", _v$15, _p$.e));
1885
+ _v$16 !== _p$.t && (_p$.t = _$setProp(_el$38, "style", _v$16, _p$.t));
1886
+ return _p$;
1887
+ }, {
1888
+ e: undefined,
1889
+ t: undefined
1890
+ });
1891
+ return _el$34;
1892
+ })();
1893
+ const marker = createMemo(() => markerOf(row.entry.id));
1894
+ return (() => {
1895
+ var _el$42 = _$createElement("box"),
1896
+ _el$43 = _$createElement("text"),
1897
+ _el$44 = _$createElement("span"),
1898
+ _el$45 = _$createElement("span"),
1899
+ _el$46 = _$createElement("text");
1900
+ _$insertNode(_el$42, _el$43);
1901
+ _$insertNode(_el$42, _el$46);
1902
+ _$setProp(_el$42, "flexDirection", "row");
1903
+ _$setProp(_el$42, "gap", 1);
1904
+ _$setProp(_el$42, "paddingLeft", 1);
1905
+ _$setProp(_el$42, "paddingRight", 1);
1906
+ _$setProp(_el$42, "onMouseOver", () => {
1907
+ setHovered(row.entry.id);
1908
+ // The keyboard layer wins over the hover layer while nav is
1909
+ // active. Keep its cursor on the hovered row so Ctrl-S/Ctrl-D
1910
+ // cannot silently pin a different (often current) session.
1911
+ if (navActive()) {
1912
+ const index = itemRows().findIndex(entry => entry.id === row.entry.id);
1913
+ if (index >= 0) setCursor(index);
1914
+ }
1915
+ });
1916
+ _$setProp(_el$42, "onMouseOut", () => setHovered(undefined));
1917
+ _$setProp(_el$42, "onMouseDown", event => {
1918
+ if (event.button === RIGHT_BUTTON) {
1919
+ event.stopPropagation();
1920
+ menu.open(row.entry, event.x, event.y);
1921
+ } else {
1922
+ // Sidebar-internal: openSession releases the sidebar's own
1923
+ // captures, so this click must not also reach the root
1924
+ // handler as an outside click.
1925
+ event.stopPropagation();
1926
+ void openSession(row.entry);
1927
+ }
1928
+ });
1929
+ _$insertNode(_el$43, _el$44);
1930
+ _$insertNode(_el$43, _el$45);
1931
+ _$setProp(_el$43, "flexShrink", 0);
1932
+ _$insert(_el$44, () => row.last ? "└" : "├");
1933
+ _$insert(_el$45, (() => {
1934
+ var _c$3 = _$memo(() => marker() === "current");
1935
+ return () => _c$3() ? "●" : _$memo(() => marker() === "running")() ? SIDEBAR_SPINNER[spinFrame()] : isActive(row.entry.id) ? "▸" : "○";
1936
+ })());
1937
+ _$insert(_el$42, _$createComponent(Highlighted, {
1938
+ get text() {
1939
+ return `${props.pins().sessions.includes(row.entry.id) ? "★ " : ""}${truncate(row.entry.title, SIDEBAR_TITLE_WIDTH - (props.pins().sessions.includes(row.entry.id) ? 2 : 0))}`;
1940
+ },
1941
+ get query() {
1942
+ return query();
1943
+ },
1944
+ get color() {
1945
+ return _$memo(() => !!isPendingDelete(row.entry.id))() ? theme().error : theme().text;
1946
+ },
1947
+ get matchColor() {
1948
+ return theme().warning;
1949
+ }
1950
+ }), _el$46);
1951
+ _$setProp(_el$46, "flexShrink", 0);
1952
+ _$insert(_el$46, (() => {
1953
+ var _c$4 = _$memo(() => !!isPendingDelete(row.entry.id));
1954
+ return () => _c$4() ? "option+d again" : ago(row.entry.updated);
1955
+ })());
1956
+ _$effect(_p$ => {
1957
+ var _v$17 = isActive(row.entry.id) && row.entry.id !== currentID() ? theme().backgroundElement : RGBA.fromInts(0, 0, 0, 0),
1958
+ _v$18 = {
1959
+ fg: theme().textMuted
1960
+ },
1961
+ _v$19 = {
1962
+ fg: isPendingDelete(row.entry.id) ? theme().error : marker() === "current" ? theme().accent : marker() === "running" ? theme().success : theme().textMuted
1963
+ },
1964
+ _v$20 = {
1965
+ fg: isPendingDelete(row.entry.id) ? theme().error : theme().textMuted
1966
+ };
1967
+ _v$17 !== _p$.e && (_p$.e = _$setProp(_el$42, "backgroundColor", _v$17, _p$.e));
1968
+ _v$18 !== _p$.t && (_p$.t = _$setProp(_el$44, "style", _v$18, _p$.t));
1969
+ _v$19 !== _p$.a && (_p$.a = _$setProp(_el$45, "style", _v$19, _p$.a));
1970
+ _v$20 !== _p$.o && (_p$.o = _$setProp(_el$46, "style", _v$20, _p$.o));
1971
+ return _p$;
1972
+ }, {
1973
+ e: undefined,
1974
+ t: undefined,
1975
+ a: undefined,
1976
+ o: undefined
1977
+ });
1978
+ return _el$42;
1979
+ })();
1980
+ }
1981
+ }));
1982
+ return _el$26;
1983
+ })(), _$createComponent(Show, {
1984
+ get when() {
1985
+ return remaining() > 0;
1986
+ },
1987
+ get children() {
1988
+ var _el$27 = _$createElement("text");
1989
+ _$insert(_el$27, () => `… ${remaining()} more · option+o for all`);
1990
+ _$effect(_$p => _$setProp(_el$27, "style", {
1991
+ fg: theme().textMuted
1992
+ }, _$p));
1993
+ return _el$27;
1994
+ }
1995
+ }), _$createComponent(Show, {
1996
+ get when() {
1997
+ return _$memo(() => !!query().trim())() && filteredEntries().length === 0;
1998
+ },
1999
+ get children() {
2000
+ var _el$28 = _$createElement("text");
2001
+ _$insertNode(_el$28, _$createTextNode(`No title or directory matches · option+o to search transcripts`));
2002
+ _$effect(_$p => _$setProp(_el$28, "style", {
2003
+ fg: theme().textMuted
2004
+ }, _$p));
2005
+ return _el$28;
2006
+ }
2007
+ }), _$createComponent(Show, {
2008
+ get when() {
2009
+ return navActive();
2010
+ },
2011
+ get children() {
2012
+ var _el$30 = _$createElement("box"),
2013
+ _el$31 = _$createElement("text");
2014
+ _$insertNode(_el$30, _el$31);
2015
+ _$setProp(_el$30, "paddingTop", 1);
2016
+ _$insertNode(_el$31, _$createTextNode(`↑↓ move · enter open · option+p preview · option+s/r pin · option+d delete · esc done`));
2017
+ _$effect(_$p => _$setProp(_el$31, "style", {
2018
+ fg: theme().textMuted
2019
+ }, _$p));
2020
+ return _el$30;
2021
+ }
2022
+ })];
2023
+ }
2024
+ });
2025
+ }
2026
+ }), null);
2027
+ _$insert(_el$11, _$createComponent(Portal, {
2028
+ get mount() {
2029
+ return props.api.renderer.root;
2030
+ },
2031
+ ref: container => {
2032
+ portalContainer = container;
2033
+ container.position = "absolute";
2034
+ container.left = 0;
2035
+ container.top = 0;
2036
+ container.zIndex = 1000;
2037
+ },
2038
+ get children() {
2039
+ return _$createComponent(Show, {
2040
+ get when() {
2041
+ return menuView();
2042
+ },
2043
+ keyed: true,
2044
+ children: view => (() => {
2045
+ var _el$47 = _$createElement("box");
2046
+ _$setProp(_el$47, "position", "absolute");
2047
+ _$setProp(_el$47, "flexDirection", "column");
2048
+ _$setProp(_el$47, "border", true);
2049
+ _$setProp(_el$47, "borderStyle", "rounded");
2050
+ _$setProp(_el$47, "paddingLeft", 1);
2051
+ _$setProp(_el$47, "paddingRight", 1);
2052
+ _$setProp(_el$47, "onMouseDown", event => event.stopPropagation());
2053
+ _$setProp(_el$47, "onMouseUp", event => event.stopPropagation());
2054
+ _$insert(_el$47, _$createComponent(For, {
2055
+ get each() {
2056
+ return view.state.items;
2057
+ },
2058
+ children: (item, index) => (() => {
2059
+ var _el$48 = _$createElement("box"),
2060
+ _el$49 = _$createElement("text");
2061
+ _$insertNode(_el$48, _el$49);
2062
+ _$setProp(_el$48, "height", 1);
2063
+ _$setProp(_el$48, "onMouseOver", () => menu.select(index()));
2064
+ _$setProp(_el$48, "onMouseDown", event => event.stopPropagation());
2065
+ _$setProp(_el$48, "onMouseUp", event => {
2066
+ event.stopPropagation();
2067
+ menu.click(index(), event.button);
2068
+ });
2069
+ _$setProp(_el$49, "wrapMode", "none");
2070
+ _$insert(_el$49, () => item.label);
2071
+ _$effect(_p$ => {
2072
+ var _v$27 = view.state.index === index() ? theme().backgroundElement : RGBA.fromInts(0, 0, 0, 0),
2073
+ _v$28 = {
2074
+ fg: item.id === "delete" || item.id === "confirm" ? theme().error : view.state.index === index() ? theme().text : theme().textMuted
2075
+ };
2076
+ _v$27 !== _p$.e && (_p$.e = _$setProp(_el$48, "backgroundColor", _v$27, _p$.e));
2077
+ _v$28 !== _p$.t && (_p$.t = _$setProp(_el$49, "style", _v$28, _p$.t));
2078
+ return _p$;
2079
+ }, {
2080
+ e: undefined,
2081
+ t: undefined
2082
+ });
2083
+ return _el$48;
2084
+ })()
2085
+ }));
2086
+ _$effect(_p$ => {
2087
+ var _v$21 = view.box.left,
2088
+ _v$22 = view.box.top,
2089
+ _v$23 = view.box.width,
2090
+ _v$24 = view.box.height,
2091
+ _v$25 = theme().borderActive,
2092
+ _v$26 = theme().backgroundPanel;
2093
+ _v$21 !== _p$.e && (_p$.e = _$setProp(_el$47, "left", _v$21, _p$.e));
2094
+ _v$22 !== _p$.t && (_p$.t = _$setProp(_el$47, "top", _v$22, _p$.t));
2095
+ _v$23 !== _p$.a && (_p$.a = _$setProp(_el$47, "width", _v$23, _p$.a));
2096
+ _v$24 !== _p$.o && (_p$.o = _$setProp(_el$47, "height", _v$24, _p$.o));
2097
+ _v$25 !== _p$.i && (_p$.i = _$setProp(_el$47, "borderColor", _v$25, _p$.i));
2098
+ _v$26 !== _p$.n && (_p$.n = _$setProp(_el$47, "backgroundColor", _v$26, _p$.n));
2099
+ return _p$;
2100
+ }, {
2101
+ e: undefined,
2102
+ t: undefined,
2103
+ a: undefined,
2104
+ o: undefined,
2105
+ i: undefined,
2106
+ n: undefined
2107
+ });
2108
+ return _el$47;
2109
+ })()
2110
+ });
2111
+ }
2112
+ }), null);
2113
+ _$effect(_p$ => {
2114
+ var _v$12 = {
2115
+ fg: theme().textMuted
2116
+ },
2117
+ _v$13 = {
2118
+ fg: theme().accent
2119
+ },
2120
+ _v$14 = {
2121
+ fg: theme().textMuted
2122
+ };
2123
+ _v$12 !== _p$.e && (_p$.e = _$setProp(_el$16, "style", _v$12, _p$.e));
2124
+ _v$13 !== _p$.t && (_p$.t = _$setProp(_el$17, "style", _v$13, _p$.t));
2125
+ _v$14 !== _p$.a && (_p$.a = _$setProp(_el$21, "style", _v$14, _p$.a));
2126
+ return _p$;
2127
+ }, {
2128
+ e: undefined,
2129
+ t: undefined,
2130
+ a: undefined
2131
+ });
2132
+ return _el$11;
2133
+ })();
2134
+ }
2135
+ const HOME_SESSION_LIMIT = 6;
2136
+ function HomeSessions(props) {
2137
+ const theme = () => props.api.theme.current;
2138
+ const home = process.env.HOME ?? "";
2139
+ const [entries, setEntries] = createSignal([]);
2140
+ const [query, setQuery] = createSignal("");
2141
+ const [hovered, setHovered] = createSignal();
2142
+ const [searching, setSearching] = createSignal(false);
2143
+ const [loadFailed, setLoadFailed] = createSignal(false);
2144
+ const [pendingDelete, setPendingDelete] = createSignal();
2145
+ const [deleting, setDeleting] = createSignal();
2146
+ let confirmTimer;
2147
+ let disposeConfirm;
2148
+ const preview = createTranscriptPreview(props.api);
2149
+ let disposeHoverSpace;
2150
+ let disposeSearch;
2151
+ let insideSearchBox = false;
2152
+ const cancelDelete = () => {
2153
+ if (confirmTimer) clearTimeout(confirmTimer);
2154
+ confirmTimer = undefined;
2155
+ disposeConfirm?.();
2156
+ disposeConfirm = undefined;
2157
+ setPendingDelete(undefined);
2158
+ };
2159
+ const confirmArmed = () => {
2160
+ const id = pendingDelete();
2161
+ if (!id) return false;
2162
+ const entry = entries().find(e => e.id === id);
2163
+ if (!entry) cancelDelete();else void deleteEntry(entry);
2164
+ return true;
2165
+ };
2166
+ const requestDelete = entry => {
2167
+ if (deleting()) return;
2168
+ if (pendingDelete() === entry.id) {
2169
+ void deleteEntry(entry);
2170
+ return;
2171
+ }
2172
+ setPendingDelete(entry.id);
2173
+ if (confirmTimer) clearTimeout(confirmTimer);
2174
+ confirmTimer = setTimeout(() => setPendingDelete(undefined), 5000);
2175
+ };
2176
+ const deleteEntry = async entry => {
2177
+ cancelDelete();
2178
+ if (deleting()) return;
2179
+ setDeleting(entry.id);
2180
+ try {
2181
+ const result = await props.api.client.session.delete({
2182
+ sessionID: entry.id,
2183
+ directory: entry.dir || undefined
2184
+ });
2185
+ if (result && "error" in result && result.error) throw result.error;
2186
+ const {
2187
+ entries: remaining
2188
+ } = await fetchEntries(props.api);
2189
+ if (remaining.some(item => item.id === entry.id)) {
2190
+ setEntries(remaining);
2191
+ props.api.ui.toast({
2192
+ message: "Session was not deleted",
2193
+ variant: "error"
2194
+ });
2195
+ return;
2196
+ }
2197
+ setEntries(remaining);
2198
+ setHovered(undefined);
2199
+ props.api.ui.toast({
2200
+ message: `Deleted "${truncate(entry.title, 40)}"`,
2201
+ variant: "info"
2202
+ });
2203
+ if ("params" in props.api.route.current && props.api.route.current.params?.sessionID === entry.id) {
2204
+ props.api.route.navigate("home");
2205
+ }
2206
+ } catch {
2207
+ props.api.ui.toast({
2208
+ message: "Could not delete session",
2209
+ variant: "error"
2210
+ });
2211
+ } finally {
2212
+ setDeleting(undefined);
2213
+ }
2214
+ };
2215
+ createEffect(() => {
2216
+ disposeConfirm?.();
2217
+ disposeConfirm = undefined;
2218
+ if (!pendingDelete()) return;
2219
+ disposeConfirm = props.api.keymap.registerLayer({
2220
+ mode: "base",
2221
+ priority: 25,
2222
+ bindings: [{
2223
+ key: "y",
2224
+ desc: "Confirm delete",
2225
+ preventDefault: true,
2226
+ cmd: () => {
2227
+ confirmArmed();
2228
+ }
2229
+ }, {
2230
+ key: "alt+d",
2231
+ desc: "Confirm delete",
2232
+ preventDefault: true,
2233
+ cmd: () => {
2234
+ confirmArmed();
2235
+ }
2236
+ }, {
2237
+ key: "enter",
2238
+ desc: "Confirm delete",
2239
+ preventDefault: true,
2240
+ cmd: () => {
2241
+ confirmArmed();
2242
+ }
2243
+ }, {
2244
+ key: "n",
2245
+ desc: "Cancel delete",
2246
+ preventDefault: true,
2247
+ cmd: cancelDelete
2248
+ }, {
2249
+ key: "escape",
2250
+ desc: "Cancel delete",
2251
+ preventDefault: true,
2252
+ cmd: cancelDelete
2253
+ }]
2254
+ });
2255
+ });
2256
+ const onRootMouseDown = () => {
2257
+ preview.closeIfOpen();
2258
+ if (insideSearchBox) {
2259
+ insideSearchBox = false;
2260
+ return;
2261
+ }
2262
+ if (searching()) setSearching(false);
2263
+ };
2264
+
2265
+ // A modal dialog owns input while open: yield this list's keyboard captures
2266
+ // too, so its layers cannot double-fire with the dialog's own.
2267
+ createEffect(() => {
2268
+ if (props.api.ui.dialog.open) {
2269
+ cancelDelete();
2270
+ setSearching(false);
2271
+ setHovered(undefined);
2272
+ }
2273
+ });
2274
+ onMount(() => {
2275
+ ensureRootMouse(props.api.renderer);
2276
+ rootMouseHandlers.add(onRootMouseDown);
2277
+ onCleanup(() => rootMouseHandlers.delete(onRootMouseDown));
2278
+ });
2279
+ const appendQuery = input => {
2280
+ if (input === "\b") setQuery(value => value.slice(0, -1));else setQuery(value => value + input);
2281
+ };
2282
+ createEffect(() => {
2283
+ disposeSearch?.();
2284
+ disposeSearch = undefined;
2285
+ if (!searching()) return;
2286
+ disposeSearch = props.api.keymap.registerLayer({
2287
+ mode: "base",
2288
+ priority: 20,
2289
+ bindings: searchBindings(appendQuery, () => setSearching(false))
2290
+ });
2291
+ });
2292
+ createEffect(() => {
2293
+ disposeHoverSpace?.();
2294
+ disposeHoverSpace = undefined;
2295
+ const id = hovered();
2296
+ if (!id) return;
2297
+ const entry = entries().find(e => e.id === id);
2298
+ if (!entry) return;
2299
+ disposeHoverSpace = props.api.keymap.registerLayer({
2300
+ mode: "base",
2301
+ priority: 20,
2302
+ bindings: [{
2303
+ key: "alt+p",
2304
+ desc: "Preview session transcript",
2305
+ preventDefault: true,
2306
+ cmd: () => preview.open(entry)
2307
+ }, {
2308
+ key: "alt+d",
2309
+ desc: "Delete session",
2310
+ preventDefault: true,
2311
+ cmd: () => {
2312
+ if (!confirmArmed()) requestDelete(entry);
2313
+ }
2314
+ }, {
2315
+ key: "alt+s",
2316
+ desc: "Pin session",
2317
+ preventDefault: true,
2318
+ cmd: () => props.onTogglePin("sessions", entry.id)
2319
+ }, {
2320
+ key: "alt+r",
2321
+ desc: "Pin directory",
2322
+ preventDefault: true,
2323
+ cmd: () => props.onTogglePin("directories", entry.dir)
2324
+ }]
2325
+ });
2326
+ });
2327
+ onCleanup(() => {
2328
+ disposeSearch?.();
2329
+ disposeHoverSpace?.();
2330
+ disposeConfirm?.();
2331
+ if (confirmTimer) clearTimeout(confirmTimer);
2332
+ });
2333
+ onMount(() => {
2334
+ let alive = true;
2335
+ const load = async () => {
2336
+ props.refreshPins();
2337
+ try {
2338
+ const {
2339
+ entries: all
2340
+ } = await fetchEntries(props.api);
2341
+ if (alive) {
2342
+ setEntries(all);
2343
+ setLoadFailed(false);
2344
+ }
2345
+ } catch {
2346
+ if (alive && entries().length === 0) setLoadFailed(true);
2347
+ }
2348
+ };
2349
+ void load();
2350
+ const clock = setInterval(() => void load(), POLL_MS);
2351
+ onCleanup(() => {
2352
+ alive = false;
2353
+ clearInterval(clock);
2354
+ });
2355
+ });
2356
+ const visible = createMemo(() => {
2357
+ const q = query().trim().toLowerCase();
2358
+ const list = q ? entries().filter(entry => entry.title.toLowerCase().includes(q) || entry.dir.toLowerCase().includes(q)) : entries();
2359
+ return pinnedFirst(list, props.pins()).slice(0, HOME_SESSION_LIMIT);
2360
+ });
2361
+ return (() => {
2362
+ var _el$50 = _$createElement("box"),
2363
+ _el$51 = _$createElement("box");
2364
+ _$insertNode(_el$50, _el$51);
2365
+ _$setProp(_el$50, "width", "100%");
2366
+ _$setProp(_el$50, "alignItems", "center");
2367
+ _$setProp(_el$51, "flexDirection", "column");
2368
+ _$setProp(_el$51, "width", "100%");
2369
+ _$setProp(_el$51, "maxWidth", 72);
2370
+ _$setProp(_el$51, "paddingLeft", 1);
2371
+ _$setProp(_el$51, "paddingRight", 1);
2372
+ _$setProp(_el$51, "paddingTop", 1);
2373
+ _$setProp(_el$51, "gap", 1);
2374
+ _$insert(_el$51, _$createComponent(Show, {
2375
+ get when() {
2376
+ return entries().length > 0;
2377
+ },
2378
+ get fallback() {
2379
+ return (() => {
2380
+ var _el$65 = _$createElement("box"),
2381
+ _el$66 = _$createElement("text"),
2382
+ _el$68 = _$createElement("text");
2383
+ _$insertNode(_el$65, _el$66);
2384
+ _$insertNode(_el$65, _el$68);
2385
+ _$setProp(_el$65, "flexDirection", "row");
2386
+ _$setProp(_el$65, "gap", 1);
2387
+ _$insertNode(_el$66, _$createTextNode(`Recent sessions`));
2388
+ _$insert(_el$68, () => loadFailed() ? "· list unavailable, will retry" : "· none yet, option+o to browse");
2389
+ _$effect(_p$ => {
2390
+ var _v$33 = {
2391
+ fg: theme().textMuted
2392
+ },
2393
+ _v$34 = {
2394
+ fg: theme().textMuted
2395
+ };
2396
+ _v$33 !== _p$.e && (_p$.e = _$setProp(_el$66, "style", _v$33, _p$.e));
2397
+ _v$34 !== _p$.t && (_p$.t = _$setProp(_el$68, "style", _v$34, _p$.t));
2398
+ return _p$;
2399
+ }, {
2400
+ e: undefined,
2401
+ t: undefined
2402
+ });
2403
+ return _el$65;
2404
+ })();
2405
+ },
2406
+ get children() {
2407
+ return [(() => {
2408
+ var _el$52 = _$createElement("box"),
2409
+ _el$53 = _$createElement("box"),
2410
+ _el$54 = _$createElement("text"),
2411
+ _el$56 = _$createElement("text"),
2412
+ _el$58 = _$createElement("box"),
2413
+ _el$59 = _$createElement("box"),
2414
+ _el$60 = _$createElement("text");
2415
+ _$insertNode(_el$52, _el$53);
2416
+ _$insertNode(_el$52, _el$58);
2417
+ _$setProp(_el$52, "flexDirection", "row");
2418
+ _$setProp(_el$52, "justifyContent", "space-between");
2419
+ _$setProp(_el$52, "gap", 2);
2420
+ _$insertNode(_el$53, _el$54);
2421
+ _$insertNode(_el$53, _el$56);
2422
+ _$setProp(_el$53, "flexDirection", "row");
2423
+ _$setProp(_el$53, "gap", 1);
2424
+ _$insertNode(_el$54, _$createTextNode(`Recent sessions`));
2425
+ _$insertNode(_el$56, _$createTextNode(`· option+o for all`));
2426
+ _$insertNode(_el$58, _el$59);
2427
+ _$setProp(_el$58, "flexDirection", "row");
2428
+ _$setProp(_el$58, "gap", 1);
2429
+ _$setProp(_el$58, "paddingLeft", 1);
2430
+ _$setProp(_el$58, "paddingRight", 1);
2431
+ _$setProp(_el$58, "onMouseDown", () => {
2432
+ insideSearchBox = true;
2433
+ setSearching(true);
2434
+ });
2435
+ _$insertNode(_el$59, _el$60);
2436
+ _$setProp(_el$59, "width", 24);
2437
+ _$setProp(_el$60, "flexGrow", 1);
2438
+ _$setProp(_el$60, "flexShrink", 1);
2439
+ _$setProp(_el$60, "overflow", "hidden");
2440
+ _$setProp(_el$60, "wrapMode", "none");
2441
+ _$insert(_el$60, () => query() || (searching() ? "" : "search…"), null);
2442
+ _$insert(_el$60, () => searching() ? "▏" : "", null);
2443
+ _$insert(_el$58, _$createComponent(Show, {
2444
+ get when() {
2445
+ return query();
2446
+ },
2447
+ get children() {
2448
+ var _el$61 = _$createElement("text");
2449
+ _$insertNode(_el$61, _$createTextNode(`✕`));
2450
+ _$setProp(_el$61, "flexShrink", 0);
2451
+ _$setProp(_el$61, "onMouseDown", () => setQuery(""));
2452
+ _$effect(_$p => _$setProp(_el$61, "style", {
2453
+ fg: theme().textMuted
2454
+ }, _$p));
2455
+ return _el$61;
2456
+ }
2457
+ }), null);
2458
+ _$effect(_p$ => {
2459
+ var _v$29 = {
2460
+ fg: theme().textMuted
2461
+ },
2462
+ _v$30 = {
2463
+ fg: theme().textMuted
2464
+ },
2465
+ _v$31 = searching() ? theme().backgroundElement : RGBA.fromInts(0, 0, 0, 0),
2466
+ _v$32 = {
2467
+ fg: query() ? theme().text : theme().textMuted
2468
+ };
2469
+ _v$29 !== _p$.e && (_p$.e = _$setProp(_el$54, "style", _v$29, _p$.e));
2470
+ _v$30 !== _p$.t && (_p$.t = _$setProp(_el$56, "style", _v$30, _p$.t));
2471
+ _v$31 !== _p$.a && (_p$.a = _$setProp(_el$58, "backgroundColor", _v$31, _p$.a));
2472
+ _v$32 !== _p$.o && (_p$.o = _$setProp(_el$60, "style", _v$32, _p$.o));
2473
+ return _p$;
2474
+ }, {
2475
+ e: undefined,
2476
+ t: undefined,
2477
+ a: undefined,
2478
+ o: undefined
2479
+ });
2480
+ return _el$52;
2481
+ })(), _$createComponent(For, {
2482
+ get each() {
2483
+ return visible();
2484
+ },
2485
+ children: entry => (() => {
2486
+ var _el$69 = _$createElement("box"),
2487
+ _el$70 = _$createElement("text"),
2488
+ _el$72 = _$createElement("text");
2489
+ _$insertNode(_el$69, _el$70);
2490
+ _$insertNode(_el$69, _el$72);
2491
+ _$setProp(_el$69, "flexDirection", "row");
2492
+ _$setProp(_el$69, "gap", 1);
2493
+ _$setProp(_el$69, "onMouseOver", () => setHovered(entry.id));
2494
+ _$setProp(_el$69, "onMouseOut", () => setHovered(undefined));
2495
+ _$setProp(_el$69, "onMouseDown", event => event.button === RIGHT_BUTTON ? openInCmuxWorkspace(props.api, entry) : props.api.route.navigate("session", {
2496
+ sessionID: entry.id
2497
+ }));
2498
+ _$insertNode(_el$70, _$createTextNode(`○`));
2499
+ _$setProp(_el$70, "flexShrink", 0);
2500
+ _$insert(_el$69, _$createComponent(Highlighted, {
2501
+ get text() {
2502
+ return `${props.pins().sessions.includes(entry.id) ? "★ " : ""}${truncate(entry.title, HOME_TITLE_WIDTH - (props.pins().sessions.includes(entry.id) ? 2 : 0))}`;
2503
+ },
2504
+ get query() {
2505
+ return query();
2506
+ },
2507
+ get color() {
2508
+ return theme().text;
2509
+ },
2510
+ get matchColor() {
2511
+ return theme().warning;
2512
+ }
2513
+ }), _el$72);
2514
+ _$setProp(_el$72, "flexShrink", 0);
2515
+ _$insert(_el$72, (() => {
2516
+ var _c$5 = _$memo(() => pendingDelete() === entry.id);
2517
+ return () => _c$5() ? "option+d again" : `${props.pins().directories.includes(entry.dir) ? "★ " : ""}${prettyDir(entry.dir, home)} · ${ago(entry.updated)}`;
2518
+ })());
2519
+ _$effect(_p$ => {
2520
+ var _v$35 = entry.id === hovered() ? theme().backgroundElement : RGBA.fromInts(0, 0, 0, 0),
2521
+ _v$36 = {
2522
+ fg: theme().textMuted
2523
+ },
2524
+ _v$37 = {
2525
+ fg: pendingDelete() === entry.id ? theme().error : theme().textMuted
2526
+ };
2527
+ _v$35 !== _p$.e && (_p$.e = _$setProp(_el$69, "backgroundColor", _v$35, _p$.e));
2528
+ _v$36 !== _p$.t && (_p$.t = _$setProp(_el$70, "style", _v$36, _p$.t));
2529
+ _v$37 !== _p$.a && (_p$.a = _$setProp(_el$72, "style", _v$37, _p$.a));
2530
+ return _p$;
2531
+ }, {
2532
+ e: undefined,
2533
+ t: undefined,
2534
+ a: undefined
2535
+ });
2536
+ return _el$69;
2537
+ })()
2538
+ }), _$createComponent(Show, {
2539
+ get when() {
2540
+ return visible().length === 0;
2541
+ },
2542
+ get children() {
2543
+ var _el$63 = _$createElement("text");
2544
+ _$insertNode(_el$63, _$createTextNode(`No title or directory matches · option+o to search transcripts`));
2545
+ _$effect(_$p => _$setProp(_el$63, "style", {
2546
+ fg: theme().textMuted
2547
+ }, _$p));
2548
+ return _el$63;
2549
+ }
2550
+ })];
2551
+ }
2552
+ }));
2553
+ return _el$50;
2554
+ })();
2555
+ }
2556
+ const tui = async api => {
2557
+ const [pins, setPins] = createSignal(emptyPins());
2558
+ // Keep exploratory context while the plugin remains loaded; a full OpenCode
2559
+ // restart starts with a clean picker rather than an old cross-project filter.
2560
+ let pickerState = {
2561
+ query: "",
2562
+ waitingOnly: false,
2563
+ pinnedOnly: false
2564
+ };
2565
+ let pinVersion = 0;
2566
+ const refreshPins = () => {
2567
+ const version = pinVersion;
2568
+ void readPins().then(latest => {
2569
+ if (version === pinVersion) setPins(latest);
2570
+ });
2571
+ };
2572
+ refreshPins();
2573
+ const onTogglePin = (kind, value) => {
2574
+ if (!value) return;
2575
+ pinVersion += 1;
2576
+ void togglePin(kind, value).then(updated => {
2577
+ setPins(updated);
2578
+ api.ui.toast({
2579
+ message: `${updated[kind].includes(value) ? "Pinned" : "Unpinned"} ${kind === "sessions" ? "session" : "directory"}`,
2580
+ variant: "info"
2581
+ });
2582
+ }, () => api.ui.toast({
2583
+ message: "Could not update pins",
2584
+ variant: "error"
2585
+ }));
2586
+ };
2587
+ api.slots.register({
2588
+ order: 100,
2589
+ slots: {
2590
+ sidebar_content() {
2591
+ return _$createComponent(SidebarSessions, {
2592
+ api: api,
2593
+ pins: pins,
2594
+ onTogglePin: onTogglePin,
2595
+ refreshPins: refreshPins
2596
+ });
2597
+ },
2598
+ home_bottom() {
2599
+ return _$createComponent(HomeSessions, {
2600
+ api: api,
2601
+ pins: pins,
2602
+ onTogglePin: onTogglePin,
2603
+ refreshPins: refreshPins
2604
+ });
2605
+ }
2606
+ }
2607
+ });
2608
+ const openPicker = async (command = false) => {
2609
+ refreshPins();
2610
+ if (!command && api.mode.current() !== BASE_MODE) return;
2611
+ if (!command && api.renderer.currentFocusedEditor === null) return;
2612
+ let entriesResult;
2613
+ try {
2614
+ entriesResult = await fetchEntries(api);
2615
+ } catch {
2616
+ api.ui.toast({
2617
+ message: "Could not load sessions",
2618
+ variant: "error"
2619
+ });
2620
+ return;
2621
+ }
2622
+ const entries = entriesResult.entries;
2623
+ if (entries.length === 0) {
2624
+ api.ui.toast({
2625
+ message: "No sessions found",
2626
+ variant: "warning"
2627
+ });
2628
+ return;
2629
+ }
2630
+ const [allEntries, setAllEntries] = createSignal(entries);
2631
+ const [previewText, setPreviewText] = createSignal("");
2632
+ const [showPreview, setShowPreview] = createSignal(false);
2633
+ const [query, setQuery] = createSignal(pickerState.query);
2634
+ const [cursor, setCursor] = createSignal(0);
2635
+ const [viewportStart, setViewportStart] = createSignal(0);
2636
+ const [collapsed, setCollapsed] = createSignal({});
2637
+ const [scope, setScope] = createSignal(entries.some(entry => entry.group === pickerState.scope) ? pickerState.scope : undefined);
2638
+ const [waitingOnly, setWaitingOnly] = createSignal(pickerState.waitingOnly);
2639
+ const [pinnedOnly, setPinnedOnly] = createSignal(pickerState.pinnedOnly);
2640
+ const [waitingIds, setWaitingIds] = createSignal(new Set());
2641
+ const [waitingCoverage, setWaitingCoverage] = createSignal("loading");
2642
+ let alive = true;
2643
+ void (async () => {
2644
+ const db = await openTranscriptDb();
2645
+ if (!db) {
2646
+ if (alive) setWaitingCoverage("unavailable");
2647
+ return;
2648
+ }
2649
+ try {
2650
+ const ids = await queryWaitingIds(db);
2651
+ if (alive) {
2652
+ setWaitingIds(new Set(ids.keys()));
2653
+ setWaitingCoverage("ready");
2654
+ }
2655
+ } catch {
2656
+ if (alive) setWaitingCoverage("unavailable");
2657
+ } finally {
2658
+ db.close();
2659
+ }
2660
+ })();
2661
+ const [pendingDelete, setPendingDelete] = createSignal();
2662
+ const [searchIndex, setSearchIndex] = createSignal(new Map());
2663
+ const [indexProgress, setIndexProgress] = createSignal({
2664
+ indexed: 0,
2665
+ total: 0,
2666
+ complete: false
2667
+ });
2668
+ void buildSearchIndex(api, entries, (progress, index) => {
2669
+ if (!alive) return;
2670
+ setIndexProgress(progress);
2671
+ // Publish a fresh snapshot: the indexing workers mutate their own map,
2672
+ // while Solid needs a new reference to update search results mid-scan.
2673
+ setSearchIndex(new Map(index));
2674
+ });
2675
+ const orderedEntries = createMemo(() => pinnedFirst(allEntries(), pins()));
2676
+ const currentProject = () => {
2677
+ const route = api.route.current;
2678
+ const id = route.name === "session" ? route.params?.sessionID : undefined;
2679
+ return allEntries().find(entry => entry.id === id)?.group ?? allEntries().find(entry => entry.dir === api.state.path.directory)?.group;
2680
+ };
2681
+ const matchedGroups = createMemo(() => {
2682
+ const matched = filterPickerEntries(orderedEntries(), {
2683
+ query: query(),
2684
+ scope: scope(),
2685
+ waitingOnly: waitingOnly(),
2686
+ pinnedOnly: pinnedOnly()
2687
+ }, searchIndex(), waitingIds(), pins().sessions);
2688
+ const byGroup = new Map();
2689
+ for (const entry of matched) {
2690
+ const list = byGroup.get(entry.dir) ?? [];
2691
+ list.push(entry);
2692
+ byGroup.set(entry.dir, list);
2693
+ }
2694
+ const groups = [...byGroup.entries()].map(([dir, list]) => ({
2695
+ dir,
2696
+ list: newestFirst(list)
2697
+ }));
2698
+ const currentPins = pins();
2699
+ const rank = group => currentPins.directories.includes(group.dir) ? 2 : group.list.some(entry => currentPins.sessions.includes(entry.id)) ? 1 : 0;
2700
+ return groups.sort((a, b) => rank(b) - rank(a) || Math.max(...b.list.map(entry => entry.updated)) - Math.max(...a.list.map(entry => entry.updated)));
2701
+ });
2702
+ const selectableEntries = createMemo(() => matchedGroups().flatMap(g => collapsed()[g.dir] && !query().trim() ? [] : g.list));
2703
+
2704
+ // Visible bounding: say how much transcript text search actually covers and
2705
+ // when the metadata walk hit its cap, instead of silently under-reporting.
2706
+ const coverageLabel = createMemo(() => {
2707
+ const progress = indexProgress();
2708
+ const bits = [];
2709
+ if (scope()) bits.push(`scope ${scope()}`);
2710
+ if (waitingOnly()) bits.push(waitingCoverage() === "loading" ? "checking needs input" : waitingCoverage() === "unavailable" ? "needs-input unavailable" : "needs input");
2711
+ if (pinnedOnly()) bits.push("pinned sessions");
2712
+ if (progress.total > 0) {
2713
+ bits.push(progress.complete ? `transcripts ${progress.total}` : `indexing transcripts ${progress.indexed}/${progress.total}`);
2714
+ }
2715
+ if (entriesResult.truncated) bits.push(`showing newest ${allEntries().length}`);
2716
+ return bits.join(" · ");
2717
+ });
2718
+ const pickerRows = createMemo(() => {
2719
+ const flat = [];
2720
+ for (const group of matchedGroups()) {
2721
+ flat.push({
2722
+ kind: "group",
2723
+ dir: group.dir,
2724
+ label: prettyDir(group.dir, process.env.HOME ?? ""),
2725
+ count: group.list.length,
2726
+ pinned: pins().directories.includes(group.dir)
2727
+ });
2728
+ if (collapsed()[group.dir] && !query().trim()) continue;
2729
+ for (let i = 0; i < group.list.length; i++) {
2730
+ flat.push({
2731
+ kind: "item",
2732
+ entry: group.list[i],
2733
+ last: i === group.list.length - 1
2734
+ });
2735
+ }
2736
+ }
2737
+ return flat;
2738
+ });
2739
+ const termHeight = api.renderer.height;
2740
+ // The dialog host anchors content a quarter of the way down the screen and
2741
+ // lets it size to content, so a taller picker drifts downward. Pin the
2742
+ // content height and offset it up by the same amount to keep it centred.
2743
+ const dialogWidth = Math.min(116, api.renderer.width - 2);
2744
+ const CHROME_ROWS = 13;
2745
+ const listHeight = () => Math.max(6, Math.floor(termHeight * 0.75) - CHROME_ROWS);
2746
+ const dialogHeight = () => listHeight() + CHROME_ROWS;
2747
+ const dialogOffset = () => Math.min(0, Math.floor(termHeight / 4 - dialogHeight() / 2 - 1));
2748
+ const hasExcerpt = entry => !!transcriptMatchExcerpt(searchIndex().get(entry.id), entry, query());
2749
+ const maxViewportStart = createMemo(() => pickerLastStart(pickerRows(), listHeight(), hasExcerpt));
2750
+ const visiblePickerRows = createMemo(() => pickerWindow(pickerRows(), Math.min(viewportStart(), maxViewportStart()), listHeight(), hasExcerpt));
2751
+ let previousSelection = [];
2752
+ let deletedAt;
2753
+ createEffect(() => {
2754
+ const rows = selectableEntries();
2755
+ const position = rows.findIndex(entry => entry.id === previousSelection[cursor()]?.id);
2756
+ if (rows !== previousSelection && (rows.length !== previousSelection.length || rows.some((entry, index) => entry.id !== previousSelection[index]?.id))) {
2757
+ // A search/filter/collapse changed the tree: reveal the retained row or
2758
+ // start at the first result rather than leaving a stale window open.
2759
+ if (deletedAt !== undefined) {
2760
+ setCursor(Math.max(0, Math.min(deletedAt, rows.length - 1)));
2761
+ deletedAt = undefined;
2762
+ } else {
2763
+ const next = position >= 0 ? position : 0;
2764
+ setCursor(next);
2765
+ const id = rows[next]?.id;
2766
+ setViewportStart(position < 0 || next === 0 ? 0 : Math.max(0, pickerRows().findIndex(row => row.kind === "item" && row.entry.id === id)));
2767
+ }
2768
+ } else if (cursor() >= rows.length) setCursor(Math.max(0, rows.length - 1));
2769
+ previousSelection = rows;
2770
+ });
2771
+ const previewCache = new Map();
2772
+ let previewTimer;
2773
+ const previewID = () => selectableEntries()[cursor()]?.id;
2774
+ const loadPreview = async id => {
2775
+ if (!id) return;
2776
+ const cached = previewCache.get(id);
2777
+ if (cached !== undefined) {
2778
+ setPreviewText(cached);
2779
+ return;
2780
+ }
2781
+ try {
2782
+ const text = await fetchTranscriptText(api, id);
2783
+ previewCache.set(id, text);
2784
+ if (showPreview() && previewID() === id) setPreviewText(text);
2785
+ } catch {}
2786
+ };
2787
+ const currentSessionID = () => {
2788
+ const route = api.route.current;
2789
+ if (route.name !== "session") return undefined;
2790
+ const params = route.params;
2791
+ return params && typeof params.sessionID === "string" ? params.sessionID : undefined;
2792
+ };
2793
+ const close = () => {
2794
+ cleanup();
2795
+ api.ui.dialog.clear();
2796
+ };
2797
+ createEffect(() => {
2798
+ const showing = showPreview();
2799
+ const id = previewID();
2800
+ if (previewTimer) clearTimeout(previewTimer);
2801
+ if (!showing || !id) return;
2802
+ const cached = previewCache.get(id);
2803
+ if (cached !== undefined) {
2804
+ setPreviewText(cached);
2805
+ return;
2806
+ }
2807
+ // Never show another session's transcript beneath the new selection.
2808
+ setPreviewText("Loading…");
2809
+ previewTimer = setTimeout(() => void loadPreview(id), 120);
2810
+ });
2811
+ const togglePreview = () => setShowPreview(value => !value);
2812
+ const selectCursor = index => {
2813
+ const count = selectableEntries().length;
2814
+ if (count === 0) return;
2815
+ cancelDelete();
2816
+ const next = Math.max(0, Math.min(count - 1, index));
2817
+ const id = selectableEntries()[next].id;
2818
+ const flat = pickerRows();
2819
+ const target = flat.findIndex(row => row.kind === "item" && row.entry.id === id);
2820
+ let start = Math.min(viewportStart(), maxViewportStart());
2821
+ if (target < start) start = target;else while (start < target && !pickerWindow(flat, start, listHeight(), hasExcerpt).some(row => row.kind === "item" && row.entry.id === id)) start++;
2822
+ setViewportStart(Math.max(0, Math.min(start, maxViewportStart())));
2823
+ setCursor(next);
2824
+ };
2825
+ const moveCursor = delta => selectCursor(cursor() + delta);
2826
+
2827
+ // Match OpenTUI's scrollbox: honor every wheel event's delta. The viewport
2828
+ // stays independent of selection, so fast scrolling cannot recenter on hover.
2829
+ const scrollPicker = scroll => {
2830
+ if (showPreview() || !scroll || scroll.direction !== "up" && scroll.direction !== "down") return;
2831
+ const amount = Math.trunc(scroll.delta) * (scroll.direction === "down" ? 1 : -1);
2832
+ setViewportStart(start => Math.max(0, Math.min(maxViewportStart(), start + amount)));
2833
+ };
2834
+
2835
+ // Delete confirmation lives in its own layer, registered only while a row
2836
+ // is armed, so y/n never shadow the search box's typing.
2837
+ let disposeConfirm;
2838
+ let confirmTimer;
2839
+ const cancelDelete = () => {
2840
+ if (confirmTimer) clearTimeout(confirmTimer);
2841
+ confirmTimer = undefined;
2842
+ disposeConfirm?.();
2843
+ disposeConfirm = undefined;
2844
+ setPendingDelete(undefined);
2845
+ };
2846
+ const confirmArmed = () => {
2847
+ const armed = pendingDelete();
2848
+ if (!armed) return false;
2849
+ void deleteEntry(armed);
2850
+ return true;
2851
+ };
2852
+
2853
+ // Escape first closes the transcript overlay, then the picker. One Escape
2854
+ // can reach both the focused search input and the key layer (sometimes in
2855
+ // separate ticks), so ignore the duplicate that trails right behind.
2856
+ let escapeHandledAt = 0;
2857
+ const handleEscape = () => {
2858
+ const now = Date.now();
2859
+ if (escapeHandledAt > now) return;
2860
+ if (showPreview()) {
2861
+ setShowPreview(false);
2862
+ escapeHandledAt = now + 150;
2863
+ return;
2864
+ }
2865
+ if (pendingDelete()) {
2866
+ cancelDelete();
2867
+ escapeHandledAt = now + 150;
2868
+ return;
2869
+ }
2870
+ cancelDelete();
2871
+ close();
2872
+ };
2873
+ const deleteEntry = async entry => {
2874
+ cancelDelete();
2875
+ try {
2876
+ const result = await api.client.session.delete({
2877
+ sessionID: entry.id,
2878
+ directory: entry.dir || undefined
2879
+ });
2880
+ if (result && "error" in result && result.error) throw result.error;
2881
+ const {
2882
+ entries: remaining
2883
+ } = await fetchEntries(api);
2884
+ if (remaining.some(item => item.id === entry.id)) {
2885
+ setAllEntries(remaining);
2886
+ api.ui.toast({
2887
+ message: "Session was not deleted",
2888
+ variant: "error"
2889
+ });
2890
+ return;
2891
+ }
2892
+ deletedAt = selectableEntries().findIndex(item => item.id === entry.id);
2893
+ if (deletedAt < 0) deletedAt = undefined;
2894
+ setAllEntries(remaining);
2895
+ api.ui.toast({
2896
+ message: `Deleted "${truncate(entry.title, 40)}"`,
2897
+ variant: "info"
2898
+ });
2899
+ if (currentSessionID() === entry.id) api.route.navigate("home");
2900
+ } catch {
2901
+ api.ui.toast({
2902
+ message: "Could not delete session",
2903
+ variant: "error"
2904
+ });
2905
+ }
2906
+ };
2907
+ const armDelete = entry => {
2908
+ setPendingDelete(entry);
2909
+ if (confirmTimer) clearTimeout(confirmTimer);
2910
+ confirmTimer = setTimeout(cancelDelete, 5000);
2911
+ disposeConfirm?.();
2912
+ disposeConfirm = api.keymap.registerLayer({
2913
+ mode: "modal",
2914
+ priority: 20,
2915
+ bindings: [{
2916
+ key: "y",
2917
+ desc: "Confirm delete",
2918
+ preventDefault: true,
2919
+ cmd: () => {
2920
+ confirmArmed();
2921
+ }
2922
+ }, {
2923
+ key: "alt+d",
2924
+ desc: "Confirm delete",
2925
+ preventDefault: true,
2926
+ cmd: () => {
2927
+ confirmArmed();
2928
+ }
2929
+ }, {
2930
+ key: "enter",
2931
+ desc: "Confirm delete",
2932
+ preventDefault: true,
2933
+ cmd: () => {
2934
+ confirmArmed();
2935
+ }
2936
+ }, {
2937
+ key: "n",
2938
+ desc: "Cancel delete",
2939
+ preventDefault: true,
2940
+ cmd: cancelDelete
2941
+ }, {
2942
+ key: "escape",
2943
+ desc: "Cancel delete",
2944
+ preventDefault: true,
2945
+ cmd: cancelDelete
2946
+ }]
2947
+ });
2948
+ };
2949
+ const forkEntry = async entry => {
2950
+ try {
2951
+ const result = await api.client.session.fork({
2952
+ sessionID: entry.id,
2953
+ directory: entry.dir || undefined
2954
+ });
2955
+ const created = result?.data ?? result;
2956
+ if (!created?.id) throw new Error("fork returned no session");
2957
+ cleanup();
2958
+ api.ui.dialog.clear();
2959
+ api.route.navigate("session", {
2960
+ sessionID: created.id
2961
+ });
2962
+ api.ui.toast({
2963
+ message: `Forked "${truncate(entry.title, 40)}"`,
2964
+ variant: "info"
2965
+ });
2966
+ } catch {
2967
+ api.ui.toast({
2968
+ message: "Could not fork session",
2969
+ variant: "error"
2970
+ });
2971
+ }
2972
+ };
2973
+ const choose = entry => {
2974
+ const target = entry ?? selectableEntries()[cursor()];
2975
+ if (!target) return;
2976
+ cleanup();
2977
+ api.ui.dialog.clear();
2978
+ api.route.navigate("session", {
2979
+ sessionID: target.id
2980
+ });
2981
+ };
2982
+ const disposeNav = api.keymap.registerLayer({
2983
+ mode: "modal",
2984
+ priority: 1,
2985
+ bindings: [{
2986
+ key: "up",
2987
+ desc: "Previous session",
2988
+ preventDefault: true,
2989
+ cmd: () => moveCursor(-1)
2990
+ }, {
2991
+ key: "down",
2992
+ desc: "Next session",
2993
+ preventDefault: true,
2994
+ cmd: () => moveCursor(1)
2995
+ }, {
2996
+ key: "pageup",
2997
+ desc: "Page up",
2998
+ preventDefault: true,
2999
+ cmd: () => moveCursor(-10)
3000
+ }, {
3001
+ key: "pagedown",
3002
+ desc: "Page down",
3003
+ preventDefault: true,
3004
+ cmd: () => moveCursor(10)
3005
+ }, {
3006
+ key: "home",
3007
+ desc: "First session",
3008
+ preventDefault: true,
3009
+ cmd: () => selectCursor(0)
3010
+ }, {
3011
+ key: "end",
3012
+ desc: "Last session",
3013
+ preventDefault: true,
3014
+ cmd: () => selectCursor(selectableEntries().length - 1)
3015
+ }, {
3016
+ key: "enter",
3017
+ desc: "Open session",
3018
+ preventDefault: true,
3019
+ cmd: () => {
3020
+ if (confirmArmed()) return;
3021
+ choose();
3022
+ }
3023
+ }, {
3024
+ key: "alt+p",
3025
+ desc: "Toggle transcript preview",
3026
+ preventDefault: true,
3027
+ cmd: togglePreview
3028
+ }, {
3029
+ key: "ctrl+s",
3030
+ desc: "Pin session",
3031
+ preventDefault: true,
3032
+ cmd: () => {
3033
+ const entry = selectableEntries()[cursor()];
3034
+ if (entry) onTogglePin("sessions", entry.id);
3035
+ }
3036
+ }, {
3037
+ key: "ctrl+d",
3038
+ desc: "Pin directory",
3039
+ preventDefault: true,
3040
+ cmd: () => {
3041
+ const entry = selectableEntries()[cursor()];
3042
+ if (entry) onTogglePin("directories", entry.dir);
3043
+ }
3044
+ }, {
3045
+ key: "alt+d",
3046
+ desc: "Delete session",
3047
+ preventDefault: true,
3048
+ cmd: () => {
3049
+ if (confirmArmed()) return;
3050
+ const target = selectableEntries()[cursor()];
3051
+ if (target) armDelete(target);
3052
+ }
3053
+ }, {
3054
+ key: "ctrl+f",
3055
+ desc: "Fork session",
3056
+ preventDefault: true,
3057
+ cmd: () => {
3058
+ const target = selectableEntries()[cursor()];
3059
+ if (target) void forkEntry(target);
3060
+ }
3061
+ }, {
3062
+ key: "ctrl+g",
3063
+ desc: "Toggle project scope",
3064
+ preventDefault: true,
3065
+ cmd: () => {
3066
+ const target = selectableEntries()[cursor()];
3067
+ if (scope()) setScope(undefined);else if (target) setScope(target.group);
3068
+ }
3069
+ }, {
3070
+ key: "alt+w",
3071
+ desc: "Filter needs input",
3072
+ preventDefault: true,
3073
+ cmd: () => setWaitingOnly(value => !value)
3074
+ }, {
3075
+ key: "alt+s",
3076
+ desc: "Filter pinned sessions",
3077
+ preventDefault: true,
3078
+ cmd: () => setPinnedOnly(value => !value)
3079
+ }, {
3080
+ key: "escape",
3081
+ desc: "Close",
3082
+ preventDefault: true,
3083
+ cmd: handleEscape
3084
+ }]
3085
+ });
3086
+ const cleanup = () => {
3087
+ if (!alive) return;
3088
+ alive = false;
3089
+ if (confirmTimer) clearTimeout(confirmTimer);
3090
+ pickerState = {
3091
+ query: query(),
3092
+ scope: scope(),
3093
+ waitingOnly: waitingOnly(),
3094
+ pinnedOnly: pinnedOnly()
3095
+ };
3096
+ disposeNav();
3097
+ disposeConfirm?.();
3098
+ disposeConfirm = undefined;
3099
+ if (previewTimer) clearTimeout(previewTimer);
3100
+ };
3101
+ api.ui.dialog.replace(() => (() => {
3102
+ var _el$73 = _$createElement("box"),
3103
+ _el$74 = _$createElement("box"),
3104
+ _el$75 = _$createElement("box"),
3105
+ _el$76 = _$createElement("text"),
3106
+ _el$77 = _$createTextNode(`Sessions `),
3107
+ _el$78 = _$createElement("span"),
3108
+ _el$79 = _$createTextNode(`(`),
3109
+ _el$80 = _$createTextNode(`)`),
3110
+ _el$81 = _$createElement("text"),
3111
+ _el$83 = _$createElement("box"),
3112
+ _el$84 = _$createElement("input"),
3113
+ _el$87 = _$createElement("box"),
3114
+ _el$88 = _$createElement("text"),
3115
+ _el$89 = _$createElement("text"),
3116
+ _el$90 = _$createElement("text"),
3117
+ _el$91 = _$createElement("box"),
3118
+ _el$94 = _$createElement("box"),
3119
+ _el$95 = _$createElement("text");
3120
+ _$insertNode(_el$73, _el$74);
3121
+ _$insertNode(_el$73, _el$91);
3122
+ _$insertNode(_el$73, _el$94);
3123
+ _$setProp(_el$73, "flexDirection", "column");
3124
+ _$setProp(_el$73, "paddingTop", 1);
3125
+ _$setProp(_el$73, "paddingBottom", 1);
3126
+ _$setProp(_el$73, "gap", 1);
3127
+ _$insertNode(_el$74, _el$75);
3128
+ _$insertNode(_el$74, _el$83);
3129
+ _$insertNode(_el$74, _el$87);
3130
+ _$setProp(_el$74, "paddingLeft", 4);
3131
+ _$setProp(_el$74, "paddingRight", 4);
3132
+ _$insertNode(_el$75, _el$76);
3133
+ _$insertNode(_el$75, _el$81);
3134
+ _$setProp(_el$75, "flexDirection", "row");
3135
+ _$setProp(_el$75, "justifyContent", "space-between");
3136
+ _$insertNode(_el$76, _el$77);
3137
+ _$insertNode(_el$76, _el$78);
3138
+ _$insertNode(_el$78, _el$79);
3139
+ _$insertNode(_el$78, _el$80);
3140
+ _$insert(_el$78, () => selectableEntries().length, _el$80);
3141
+ _$insertNode(_el$81, _$createTextNode(`esc`));
3142
+ _$setProp(_el$81, "onMouseUp", close);
3143
+ _$insertNode(_el$83, _el$84);
3144
+ _$setProp(_el$83, "paddingTop", 1);
3145
+ _$setProp(_el$84, "focused", true);
3146
+ _$setProp(_el$84, "placeholder", "Search title, directory, transcript…");
3147
+ _$setProp(_el$84, "cursorStyle", {
3148
+ style: "line",
3149
+ blinking: false
3150
+ });
3151
+ _$setProp(_el$84, "onInput", value => {
3152
+ if (pendingDelete()) return;
3153
+ setQuery(value);
3154
+ });
3155
+ _$setProp(_el$84, "onSubmit", () => {
3156
+ if (confirmArmed()) return;
3157
+ choose();
3158
+ });
3159
+ _$setProp(_el$84, "onKeyDown", event => {
3160
+ if (event.name === "escape") {
3161
+ event.preventDefault();
3162
+ handleEscape();
3163
+ return;
3164
+ }
3165
+ if (event.ctrl && event.name === "x") {
3166
+ event.preventDefault();
3167
+ if (confirmArmed()) return;
3168
+ const target = selectableEntries()[cursor()];
3169
+ if (target) armDelete(target);
3170
+ return;
3171
+ }
3172
+ if (event.ctrl && event.name === "f") {
3173
+ event.preventDefault();
3174
+ const target = selectableEntries()[cursor()];
3175
+ if (target) void forkEntry(target);
3176
+ return;
3177
+ }
3178
+ if (pendingDelete()) {
3179
+ if (event.name === "y" || event.name === "enter" || event.name === "return") {
3180
+ event.preventDefault();
3181
+ confirmArmed();
3182
+ return;
3183
+ }
3184
+ if (event.name === "n") {
3185
+ event.preventDefault();
3186
+ cancelDelete();
3187
+ return;
3188
+ }
3189
+ }
3190
+ });
3191
+ _$insert(_el$74, _$createComponent(Show, {
3192
+ get when() {
3193
+ return coverageLabel();
3194
+ },
3195
+ get children() {
3196
+ var _el$85 = _$createElement("box"),
3197
+ _el$86 = _$createElement("text");
3198
+ _$insertNode(_el$85, _el$86);
3199
+ _$setProp(_el$85, "paddingTop", 1);
3200
+ _$insert(_el$86, coverageLabel);
3201
+ _$effect(_$p => _$setProp(_el$86, "style", {
3202
+ fg: api.theme.current.textMuted
3203
+ }, _$p));
3204
+ return _el$85;
3205
+ }
3206
+ }), _el$87);
3207
+ _$insertNode(_el$87, _el$88);
3208
+ _$insertNode(_el$87, _el$89);
3209
+ _$insertNode(_el$87, _el$90);
3210
+ _$setProp(_el$87, "flexDirection", "row");
3211
+ _$setProp(_el$87, "gap", 2);
3212
+ _$setProp(_el$87, "paddingTop", 1);
3213
+ _$setProp(_el$88, "onMouseDown", () => {
3214
+ const group = currentProject();
3215
+ if (group) setScope(scope() === group ? undefined : group);else api.ui.toast({
3216
+ message: "No current project to filter",
3217
+ variant: "info"
3218
+ });
3219
+ });
3220
+ _$insert(_el$88, (() => {
3221
+ var _c$6 = _$memo(() => !!scope());
3222
+ return () => _c$6() ? `Project: ${truncate(scope(), 22)}` : "Project: all";
3223
+ })());
3224
+ _$setProp(_el$89, "onMouseDown", () => setWaitingOnly(value => !value));
3225
+ _$insert(_el$89, () => waitingOnly() ? "● Needs input" : "○ Needs input");
3226
+ _$setProp(_el$90, "onMouseDown", () => setPinnedOnly(value => !value));
3227
+ _$insert(_el$90, () => pinnedOnly() ? "★ Pinned" : "☆ Pinned");
3228
+ _$setProp(_el$91, "flexDirection", "column");
3229
+ _$setProp(_el$91, "flexGrow", 1);
3230
+ _$setProp(_el$91, "overflow", "hidden");
3231
+ _$setProp(_el$91, "onMouseScroll", event => {
3232
+ scrollPicker(event.scroll);
3233
+ });
3234
+ _$insert(_el$91, _$createComponent(For, {
3235
+ get each() {
3236
+ return visiblePickerRows();
3237
+ },
3238
+ children: row => {
3239
+ if (row.kind === "group") return (() => {
3240
+ var _el$108 = _$createElement("box"),
3241
+ _el$109 = _$createElement("text"),
3242
+ _el$110 = _$createElement("text"),
3243
+ _el$111 = _$createTextNode(` (`),
3244
+ _el$112 = _$createTextNode(`)`);
3245
+ _$insertNode(_el$108, _el$109);
3246
+ _$insertNode(_el$108, _el$110);
3247
+ _$setProp(_el$108, "flexDirection", "row");
3248
+ _$setProp(_el$108, "paddingLeft", 4);
3249
+ _$setProp(_el$108, "paddingRight", 4);
3250
+ _$setProp(_el$108, "onMouseDown", () => setCollapsed(state => ({
3251
+ ...state,
3252
+ [row.dir]: !state[row.dir]
3253
+ })));
3254
+ _$setProp(_el$109, "flexGrow", 1);
3255
+ _$setProp(_el$109, "flexShrink", 1);
3256
+ _$setProp(_el$109, "wrapMode", "none");
3257
+ _$insert(_el$109, () => collapsed()[row.dir] && !query().trim() ? "▸ " : "▾ ", null);
3258
+ _$insert(_el$109, () => row.pinned ? "★ " : "", null);
3259
+ _$insert(_el$109, () => row.label, null);
3260
+ _$insertNode(_el$110, _el$111);
3261
+ _$insertNode(_el$110, _el$112);
3262
+ _$setProp(_el$110, "flexShrink", 0);
3263
+ _$insert(_el$110, () => row.count, _el$112);
3264
+ _$effect(_p$ => {
3265
+ var _v$58 = row.continued ? 0 : 1,
3266
+ _v$59 = {
3267
+ fg: api.theme.current.accent
3268
+ },
3269
+ _v$60 = TextAttributes.BOLD,
3270
+ _v$61 = {
3271
+ fg: api.theme.current.textMuted
3272
+ };
3273
+ _v$58 !== _p$.e && (_p$.e = _$setProp(_el$108, "paddingTop", _v$58, _p$.e));
3274
+ _v$59 !== _p$.t && (_p$.t = _$setProp(_el$109, "style", _v$59, _p$.t));
3275
+ _v$60 !== _p$.a && (_p$.a = _$setProp(_el$109, "attributes", _v$60, _p$.a));
3276
+ _v$61 !== _p$.o && (_p$.o = _$setProp(_el$110, "style", _v$61, _p$.o));
3277
+ return _p$;
3278
+ }, {
3279
+ e: undefined,
3280
+ t: undefined,
3281
+ a: undefined,
3282
+ o: undefined
3283
+ });
3284
+ return _el$108;
3285
+ })();
3286
+ const excerpt = createMemo(() => transcriptMatchExcerpt(searchIndex().get(row.entry.id), row.entry, query()));
3287
+ return (() => {
3288
+ var _el$113 = _$createElement("box"),
3289
+ _el$114 = _$createElement("box"),
3290
+ _el$115 = _$createElement("text"),
3291
+ _el$118 = _$createElement("text");
3292
+ _$insertNode(_el$113, _el$114);
3293
+ _$setProp(_el$113, "flexDirection", "column");
3294
+ _$setProp(_el$113, "paddingLeft", 6);
3295
+ _$setProp(_el$113, "paddingRight", 4);
3296
+ _$setProp(_el$113, "onMouseMove", () => {
3297
+ // Repainting under a stationary pointer must not select
3298
+ // another row while the wheel is moving the viewport.
3299
+ const index = selectableEntries().findIndex(entry => entry.id === row.entry.id);
3300
+ if (index >= 0) setCursor(index);
3301
+ });
3302
+ _$setProp(_el$113, "onMouseDown", event => event.button === RIGHT_BUTTON ? (cleanup(), api.ui.dialog.clear(), openInCmuxWorkspace(api, row.entry)) : choose(row.entry));
3303
+ _$insertNode(_el$114, _el$115);
3304
+ _$insertNode(_el$114, _el$118);
3305
+ _$setProp(_el$114, "flexDirection", "row");
3306
+ _$setProp(_el$114, "gap", 1);
3307
+ _$setProp(_el$115, "flexShrink", 0);
3308
+ _$insert(_el$115, () => row.last ? "└" : "├");
3309
+ _$insert(_el$114, _$createComponent(Show, {
3310
+ get when() {
3311
+ return row.entry.id === currentSessionID();
3312
+ },
3313
+ get children() {
3314
+ var _el$116 = _$createElement("text");
3315
+ _$insertNode(_el$116, _$createTextNode(`●`));
3316
+ _$setProp(_el$116, "flexShrink", 0);
3317
+ _$effect(_$p => _$setProp(_el$116, "style", {
3318
+ fg: row.entry.id === selectableEntries()[cursor()]?.id ? api.theme.current.text : api.theme.current.primary
3319
+ }, _$p));
3320
+ return _el$116;
3321
+ }
3322
+ }), _el$118);
3323
+ _$insert(_el$114, _$createComponent(Highlighted, {
3324
+ get text() {
3325
+ return `${pins().sessions.includes(row.entry.id) ? "★ " : ""}${truncate(row.entry.title, pins().sessions.includes(row.entry.id) ? 59 : 61)}`;
3326
+ },
3327
+ get query() {
3328
+ return query();
3329
+ },
3330
+ get bold() {
3331
+ return row.entry.id === selectableEntries()[cursor()]?.id;
3332
+ },
3333
+ get color() {
3334
+ return _$memo(() => row.entry.id === selectableEntries()[cursor()]?.id)() ? api.theme.current.text : api.theme.current.text;
3335
+ },
3336
+ get matchColor() {
3337
+ return _$memo(() => row.entry.id === selectableEntries()[cursor()]?.id)() ? api.theme.current.text : api.theme.current.warning;
3338
+ }
3339
+ }), _el$118);
3340
+ _$setProp(_el$118, "flexShrink", 0);
3341
+ _$insert(_el$118, (() => {
3342
+ var _c$0 = _$memo(() => pendingDelete()?.id === row.entry.id);
3343
+ return () => _c$0() ? "press y to delete" : `${ago(row.entry.updated)}${query().trim() && !`${row.entry.title} ${row.entry.dir}`.toLowerCase().includes(query().trim().toLowerCase()) ? " · transcript match" : ""}`;
3344
+ })());
3345
+ _$insert(_el$113, _$createComponent(Show, {
3346
+ get when() {
3347
+ return excerpt();
3348
+ },
3349
+ children: match => (() => {
3350
+ var _el$119 = _$createElement("box"),
3351
+ _el$120 = _$createElement("text");
3352
+ _$insertNode(_el$119, _el$120);
3353
+ _$setProp(_el$119, "flexDirection", "row");
3354
+ _$setProp(_el$119, "gap", 1);
3355
+ _$setProp(_el$119, "paddingLeft", 2);
3356
+ _$insertNode(_el$120, _$createTextNode(`↳`));
3357
+ _$setProp(_el$120, "flexShrink", 0);
3358
+ _$insert(_el$119, _$createComponent(Highlighted, {
3359
+ get text() {
3360
+ return match();
3361
+ },
3362
+ get query() {
3363
+ return query();
3364
+ },
3365
+ get color() {
3366
+ return _$memo(() => row.entry.id === selectableEntries()[cursor()]?.id)() ? api.theme.current.text : api.theme.current.textMuted;
3367
+ },
3368
+ get matchColor() {
3369
+ return api.theme.current.warning;
3370
+ }
3371
+ }), null);
3372
+ _$effect(_$p => _$setProp(_el$120, "style", {
3373
+ fg: api.theme.current.textMuted
3374
+ }, _$p));
3375
+ return _el$119;
3376
+ })()
3377
+ }), null);
3378
+ _$effect(_p$ => {
3379
+ var _v$62 = row.entry.id === selectableEntries()[cursor()]?.id ? api.theme.current.backgroundElement : RGBA.fromInts(0, 0, 0, 0),
3380
+ _v$63 = {
3381
+ fg: row.entry.id === selectableEntries()[cursor()]?.id ? api.theme.current.text : api.theme.current.textMuted
3382
+ },
3383
+ _v$64 = {
3384
+ fg: pendingDelete()?.id === row.entry.id ? api.theme.current.error : row.entry.id === selectableEntries()[cursor()]?.id ? api.theme.current.text : api.theme.current.textMuted
3385
+ };
3386
+ _v$62 !== _p$.e && (_p$.e = _$setProp(_el$113, "backgroundColor", _v$62, _p$.e));
3387
+ _v$63 !== _p$.t && (_p$.t = _$setProp(_el$115, "style", _v$63, _p$.t));
3388
+ _v$64 !== _p$.a && (_p$.a = _$setProp(_el$118, "style", _v$64, _p$.a));
3389
+ return _p$;
3390
+ }, {
3391
+ e: undefined,
3392
+ t: undefined,
3393
+ a: undefined
3394
+ });
3395
+ return _el$113;
3396
+ })();
3397
+ }
3398
+ }), null);
3399
+ _$insert(_el$91, _$createComponent(Show, {
3400
+ get when() {
3401
+ return matchedGroups().length === 0;
3402
+ },
3403
+ get children() {
3404
+ var _el$92 = _$createElement("box"),
3405
+ _el$93 = _$createElement("text");
3406
+ _$insertNode(_el$92, _el$93);
3407
+ _$setProp(_el$92, "paddingLeft", 4);
3408
+ _$setProp(_el$92, "paddingRight", 4);
3409
+ _$insert(_el$93, (() => {
3410
+ var _c$7 = _$memo(() => !!(waitingOnly() && waitingCoverage() === "loading"));
3411
+ return () => _c$7() ? "Checking which sessions need input…" : _$memo(() => !!(waitingOnly() && waitingCoverage() === "unavailable"))() ? "Needs-input data unavailable · turn off the filter" : _$memo(() => !!(pinnedOnly() && pins().sessions.length === 0))() ? "No pinned sessions yet · ctrl+s pins a session" : _$memo(() => !!(!indexProgress().complete && query().trim()))() ? "No matches yet · still searching transcripts…" : _$memo(() => !!scope())() ? `No matches in ${scope()} · ctrl+g for all projects` : waitingOnly() ? "No sessions need input with these filters" : "No sessions match · try another search";
3412
+ })());
3413
+ _$effect(_$p => _$setProp(_el$93, "style", {
3414
+ fg: api.theme.current.textMuted
3415
+ }, _$p));
3416
+ return _el$92;
3417
+ }
3418
+ }), null);
3419
+ _$insertNode(_el$94, _el$95);
3420
+ _$setProp(_el$94, "paddingLeft", 4);
3421
+ _$setProp(_el$94, "paddingRight", 4);
3422
+ _$setProp(_el$94, "flexShrink", 0);
3423
+ _$setProp(_el$94, "flexDirection", "column");
3424
+ _$insert(_el$95, (() => {
3425
+ var _c$8 = _$memo(() => !!pendingDelete());
3426
+ return () => _c$8() ? `Delete "${truncate(pendingDelete().title, 40)}"? y confirm · n cancel` : "↑↓ move · enter open · ctrl+g project · option+w input · option+s pinned · option+p preview";
3427
+ })());
3428
+ _$insert(_el$94, _$createComponent(Show, {
3429
+ get when() {
3430
+ return !pendingDelete();
3431
+ },
3432
+ get children() {
3433
+ var _el$96 = _$createElement("text");
3434
+ _$insertNode(_el$96, _$createTextNode(`ctrl+s/d pin · option+d delete · ctrl+f fork · esc close`));
3435
+ _$effect(_$p => _$setProp(_el$96, "style", {
3436
+ fg: api.theme.current.textMuted
3437
+ }, _$p));
3438
+ return _el$96;
3439
+ }
3440
+ }), null);
3441
+ _$insert(_el$73, _$createComponent(Show, {
3442
+ get when() {
3443
+ return showPreview();
3444
+ },
3445
+ get children() {
3446
+ var _el$98 = _$createElement("box"),
3447
+ _el$99 = _$createElement("box"),
3448
+ _el$100 = _$createElement("text"),
3449
+ _el$101 = _$createElement("text"),
3450
+ _el$105 = _$createElement("scrollbox"),
3451
+ _el$106 = _$createElement("text");
3452
+ _$insertNode(_el$98, _el$99);
3453
+ _$insertNode(_el$98, _el$105);
3454
+ _$insertNode(_el$98, _el$106);
3455
+ _$setProp(_el$98, "position", "absolute");
3456
+ _$setProp(_el$98, "left", 0);
3457
+ _$setProp(_el$98, "top", 0);
3458
+ _$setProp(_el$98, "width", dialogWidth);
3459
+ _$setProp(_el$98, "flexDirection", "column");
3460
+ _$setProp(_el$98, "paddingLeft", 4);
3461
+ _$setProp(_el$98, "paddingRight", 4);
3462
+ _$setProp(_el$98, "paddingTop", 1);
3463
+ _$setProp(_el$98, "paddingBottom", 1);
3464
+ _$setProp(_el$98, "gap", 1);
3465
+ _$insertNode(_el$99, _el$100);
3466
+ _$insertNode(_el$99, _el$101);
3467
+ _$setProp(_el$99, "flexDirection", "row");
3468
+ _$setProp(_el$99, "justifyContent", "space-between");
3469
+ _$insert(_el$100, (() => {
3470
+ var _c$9 = _$memo(() => !!previewID());
3471
+ return () => _c$9() ? truncate(selectableEntries()[cursor()].title, dialogWidth - 18) : "Preview";
3472
+ })());
3473
+ _$insertNode(_el$101, _$createTextNode(`esc close`));
3474
+ _$insert(_el$98, _$createComponent(Show, {
3475
+ get when() {
3476
+ return previewID();
3477
+ },
3478
+ get children() {
3479
+ var _el$103 = _$createElement("text"),
3480
+ _el$104 = _$createTextNode(` · `);
3481
+ _$insertNode(_el$103, _el$104);
3482
+ _$insert(_el$103, () => prettyDir(selectableEntries()[cursor()].dir, process.env.HOME ?? ""), _el$104);
3483
+ _$insert(_el$103, () => ago(selectableEntries()[cursor()].updated), null);
3484
+ _$effect(_$p => _$setProp(_el$103, "style", {
3485
+ fg: api.theme.current.textMuted
3486
+ }, _$p));
3487
+ return _el$103;
3488
+ }
3489
+ }), _el$105);
3490
+ _$setProp(_el$105, "flexGrow", 1);
3491
+ _$setProp(_el$105, "scrollbarOptions", {
3492
+ visible: false
3493
+ });
3494
+ _$insert(_el$105, _$createComponent(Show, {
3495
+ get when() {
3496
+ return previewID();
3497
+ },
3498
+ get fallback() {
3499
+ return (() => {
3500
+ var _el$122 = _$createElement("text");
3501
+ _$insertNode(_el$122, _$createTextNode(`Select a session to preview`));
3502
+ _$effect(_$p => _$setProp(_el$122, "style", {
3503
+ fg: api.theme.current.textMuted
3504
+ }, _$p));
3505
+ return _el$122;
3506
+ })();
3507
+ },
3508
+ get children() {
3509
+ return _$createComponent(Show, {
3510
+ get when() {
3511
+ return previewText();
3512
+ },
3513
+ get fallback() {
3514
+ return (() => {
3515
+ var _el$124 = _$createElement("text");
3516
+ _$insertNode(_el$124, _$createTextNode(`Loading…`));
3517
+ _$effect(_$p => _$setProp(_el$124, "style", {
3518
+ fg: api.theme.current.textMuted
3519
+ }, _$p));
3520
+ return _el$124;
3521
+ })();
3522
+ },
3523
+ children: text => getMarkdownStyle(api.theme.current, api.renderer?.nativeSession) ? (() => {
3524
+ var _el$126 = _$createElement("markdown");
3525
+ _$effect(_p$ => {
3526
+ var _v$65 = text(),
3527
+ _v$66 = getMarkdownStyle(api.theme.current, api.renderer?.nativeSession),
3528
+ _v$67 = getTreeSitterClient();
3529
+ _v$65 !== _p$.e && (_p$.e = _$setProp(_el$126, "content", _v$65, _p$.e));
3530
+ _v$66 !== _p$.t && (_p$.t = _$setProp(_el$126, "syntaxStyle", _v$66, _p$.t));
3531
+ _v$67 !== _p$.a && (_p$.a = _$setProp(_el$126, "treeSitterClient", _v$67, _p$.a));
3532
+ return _p$;
3533
+ }, {
3534
+ e: undefined,
3535
+ t: undefined,
3536
+ a: undefined
3537
+ });
3538
+ return _el$126;
3539
+ })() : (() => {
3540
+ var _el$127 = _$createElement("text");
3541
+ _$setProp(_el$127, "wrapMode", "word");
3542
+ _$insert(_el$127, text);
3543
+ return _el$127;
3544
+ })()
3545
+ });
3546
+ }
3547
+ }));
3548
+ _$insertNode(_el$106, _$createTextNode(`↑↓ switch session · esc back to picker`));
3549
+ _$effect(_p$ => {
3550
+ var _v$38 = dialogHeight(),
3551
+ _v$39 = api.theme.current.backgroundPanel,
3552
+ _v$40 = TextAttributes.BOLD,
3553
+ _v$41 = {
3554
+ fg: api.theme.current.textMuted
3555
+ },
3556
+ _v$42 = {
3557
+ fg: api.theme.current.textMuted
3558
+ };
3559
+ _v$38 !== _p$.e && (_p$.e = _$setProp(_el$98, "height", _v$38, _p$.e));
3560
+ _v$39 !== _p$.t && (_p$.t = _$setProp(_el$98, "backgroundColor", _v$39, _p$.t));
3561
+ _v$40 !== _p$.a && (_p$.a = _$setProp(_el$100, "attributes", _v$40, _p$.a));
3562
+ _v$41 !== _p$.o && (_p$.o = _$setProp(_el$101, "style", _v$41, _p$.o));
3563
+ _v$42 !== _p$.i && (_p$.i = _$setProp(_el$106, "style", _v$42, _p$.i));
3564
+ return _p$;
3565
+ }, {
3566
+ e: undefined,
3567
+ t: undefined,
3568
+ a: undefined,
3569
+ o: undefined,
3570
+ i: undefined
3571
+ });
3572
+ return _el$98;
3573
+ }
3574
+ }), null);
3575
+ _$effect(_p$ => {
3576
+ var _v$43 = dialogHeight(),
3577
+ _v$44 = dialogOffset(),
3578
+ _v$45 = api.theme.current.backgroundPanel,
3579
+ _v$46 = TextAttributes.BOLD,
3580
+ _v$47 = {
3581
+ fg: api.theme.current.textMuted
3582
+ },
3583
+ _v$48 = {
3584
+ fg: api.theme.current.textMuted
3585
+ },
3586
+ _v$49 = query(),
3587
+ _v$50 = api.theme.current.textMuted,
3588
+ _v$51 = api.theme.current.backgroundPanel,
3589
+ _v$52 = api.theme.current.text,
3590
+ _v$53 = api.theme.current.primary,
3591
+ _v$54 = {
3592
+ fg: scope() ? api.theme.current.accent : api.theme.current.textMuted
3593
+ },
3594
+ _v$55 = {
3595
+ fg: waitingOnly() ? api.theme.current.warning : api.theme.current.textMuted
3596
+ },
3597
+ _v$56 = {
3598
+ fg: pinnedOnly() ? api.theme.current.accent : api.theme.current.textMuted
3599
+ },
3600
+ _v$57 = {
3601
+ fg: pendingDelete() ? api.theme.current.warning : api.theme.current.textMuted
3602
+ };
3603
+ _v$43 !== _p$.e && (_p$.e = _$setProp(_el$73, "height", _v$43, _p$.e));
3604
+ _v$44 !== _p$.t && (_p$.t = _$setProp(_el$73, "marginTop", _v$44, _p$.t));
3605
+ _v$45 !== _p$.a && (_p$.a = _$setProp(_el$73, "backgroundColor", _v$45, _p$.a));
3606
+ _v$46 !== _p$.o && (_p$.o = _$setProp(_el$76, "attributes", _v$46, _p$.o));
3607
+ _v$47 !== _p$.i && (_p$.i = _$setProp(_el$78, "style", _v$47, _p$.i));
3608
+ _v$48 !== _p$.n && (_p$.n = _$setProp(_el$81, "style", _v$48, _p$.n));
3609
+ _v$49 !== _p$.s && (_p$.s = _$setProp(_el$84, "value", _v$49, _p$.s));
3610
+ _v$50 !== _p$.h && (_p$.h = _$setProp(_el$84, "placeholderColor", _v$50, _p$.h));
3611
+ _v$51 !== _p$.r && (_p$.r = _$setProp(_el$84, "focusedBackgroundColor", _v$51, _p$.r));
3612
+ _v$52 !== _p$.d && (_p$.d = _$setProp(_el$84, "focusedTextColor", _v$52, _p$.d));
3613
+ _v$53 !== _p$.l && (_p$.l = _$setProp(_el$84, "cursorColor", _v$53, _p$.l));
3614
+ _v$54 !== _p$.u && (_p$.u = _$setProp(_el$88, "style", _v$54, _p$.u));
3615
+ _v$55 !== _p$.c && (_p$.c = _$setProp(_el$89, "style", _v$55, _p$.c));
3616
+ _v$56 !== _p$.w && (_p$.w = _$setProp(_el$90, "style", _v$56, _p$.w));
3617
+ _v$57 !== _p$.m && (_p$.m = _$setProp(_el$95, "style", _v$57, _p$.m));
3618
+ return _p$;
3619
+ }, {
3620
+ e: undefined,
3621
+ t: undefined,
3622
+ a: undefined,
3623
+ o: undefined,
3624
+ i: undefined,
3625
+ n: undefined,
3626
+ s: undefined,
3627
+ h: undefined,
3628
+ r: undefined,
3629
+ d: undefined,
3630
+ l: undefined,
3631
+ u: undefined,
3632
+ c: undefined,
3633
+ w: undefined,
3634
+ m: undefined
3635
+ });
3636
+ return _el$73;
3637
+ })(), () => {
3638
+ cleanup();
3639
+ });
3640
+ api.ui.dialog.setSize("xlarge");
3641
+ };
3642
+
3643
+ // Read-only digest dialog: spend per project, last day beside lifetime.
3644
+ // Same message-level sums as `sesh costs`; the dialog just renders them.
3645
+ const openCosts = async (command = false) => {
3646
+ if (!command && api.mode.current() !== BASE_MODE) return;
3647
+ if (!command && api.renderer.currentFocusedEditor === null) return;
3648
+ let rows = [];
3649
+ let failed = false;
3650
+ try {
3651
+ const db = await openTranscriptDb();
3652
+ if (!db) failed = true;else {
3653
+ try {
3654
+ const cutoff = Date.now() - 86400000;
3655
+ const legacyTables = db.query("SELECT count(*) AS count FROM sqlite_master WHERE type = 'table' AND name IN ('session', 'message')").get();
3656
+ const raw = db.query(costRowsQuery(cutoff, legacyTables?.count === 2)).all() ?? [];
3657
+ rows = raw.filter(row => typeof row?.directory === "string" && typeof row?.window_cost === "number" && typeof row?.lifetime_cost === "number" && typeof row?.sessions === "number" && row.lifetime_cost > 0).map(row => ({
3658
+ directory: row.directory,
3659
+ window: row.window_cost,
3660
+ lifetime: row.lifetime_cost,
3661
+ sessions: row.sessions
3662
+ }));
3663
+ } finally {
3664
+ db.close();
3665
+ }
3666
+ }
3667
+ } catch {
3668
+ failed = true;
3669
+ }
3670
+ const money = n => n === 0 ? "—" : `$${n.toFixed(2)}`;
3671
+ const home = process.env.HOME ?? "";
3672
+ const disposeKeys = api.keymap.registerLayer({
3673
+ mode: "modal",
3674
+ bindings: [{
3675
+ key: "escape",
3676
+ desc: "Close",
3677
+ preventDefault: true,
3678
+ cmd: () => api.ui.dialog.clear()
3679
+ }]
3680
+ });
3681
+ api.ui.dialog.replace(() => (() => {
3682
+ var _el$128 = _$createElement("box"),
3683
+ _el$129 = _$createElement("text"),
3684
+ _el$130 = _$createTextNode(`Cost digest `),
3685
+ _el$132 = _$createElement("span"),
3686
+ _el$134 = _$createElement("box"),
3687
+ _el$135 = _$createElement("text");
3688
+ _$insertNode(_el$128, _el$129);
3689
+ _$insertNode(_el$128, _el$134);
3690
+ _$setProp(_el$128, "flexDirection", "column");
3691
+ _$setProp(_el$128, "paddingLeft", 4);
3692
+ _$setProp(_el$128, "paddingRight", 4);
3693
+ _$setProp(_el$128, "paddingTop", 1);
3694
+ _$setProp(_el$128, "gap", 1);
3695
+ _$insertNode(_el$129, _el$130);
3696
+ _$insertNode(_el$129, _el$132);
3697
+ _$insertNode(_el$132, _$createTextNode(`(24h · lifetime)`));
3698
+ _$insert(_el$128, _$createComponent(Show, {
3699
+ get when() {
3700
+ return !failed && rows.length > 0;
3701
+ },
3702
+ get fallback() {
3703
+ return (() => {
3704
+ var _el$137 = _$createElement("text");
3705
+ _$insert(_el$137, failed ? "Cost data unavailable." : "No assistant cost recorded.");
3706
+ _$effect(_$p => _$setProp(_el$137, "style", {
3707
+ fg: api.theme.current.textMuted
3708
+ }, _$p));
3709
+ return _el$137;
3710
+ })();
3711
+ },
3712
+ get children() {
3713
+ return _$createComponent(For, {
3714
+ each: rows,
3715
+ children: row => (() => {
3716
+ var _el$138 = _$createElement("box"),
3717
+ _el$139 = _$createElement("text"),
3718
+ _el$140 = _$createElement("text"),
3719
+ _el$141 = _$createElement("text"),
3720
+ _el$142 = _$createTextNode(`(`),
3721
+ _el$143 = _$createTextNode(`)`),
3722
+ _el$144 = _$createElement("text");
3723
+ _$insertNode(_el$138, _el$139);
3724
+ _$insertNode(_el$138, _el$140);
3725
+ _$insertNode(_el$138, _el$141);
3726
+ _$insertNode(_el$138, _el$144);
3727
+ _$setProp(_el$138, "flexDirection", "row");
3728
+ _$setProp(_el$138, "gap", 2);
3729
+ _$setProp(_el$139, "flexShrink", 0);
3730
+ _$insert(_el$139, () => money(row.window));
3731
+ _$setProp(_el$140, "flexShrink", 0);
3732
+ _$insert(_el$140, () => money(row.lifetime));
3733
+ _$insertNode(_el$141, _el$142);
3734
+ _$insertNode(_el$141, _el$143);
3735
+ _$setProp(_el$141, "flexShrink", 0);
3736
+ _$insert(_el$141, () => row.sessions, _el$143);
3737
+ _$setProp(_el$144, "flexGrow", 1);
3738
+ _$setProp(_el$144, "flexShrink", 1);
3739
+ _$setProp(_el$144, "wrapMode", "none");
3740
+ _$insert(_el$144, () => prettyDir(row.directory, home));
3741
+ _$effect(_p$ => {
3742
+ var _v$72 = {
3743
+ fg: api.theme.current.success
3744
+ },
3745
+ _v$73 = {
3746
+ fg: api.theme.current.textMuted
3747
+ },
3748
+ _v$74 = {
3749
+ fg: api.theme.current.textMuted
3750
+ };
3751
+ _v$72 !== _p$.e && (_p$.e = _$setProp(_el$139, "style", _v$72, _p$.e));
3752
+ _v$73 !== _p$.t && (_p$.t = _$setProp(_el$140, "style", _v$73, _p$.t));
3753
+ _v$74 !== _p$.a && (_p$.a = _$setProp(_el$141, "style", _v$74, _p$.a));
3754
+ return _p$;
3755
+ }, {
3756
+ e: undefined,
3757
+ t: undefined,
3758
+ a: undefined
3759
+ });
3760
+ return _el$138;
3761
+ })()
3762
+ });
3763
+ }
3764
+ }), _el$134);
3765
+ _$insertNode(_el$134, _el$135);
3766
+ _$setProp(_el$134, "flexShrink", 0);
3767
+ _$insertNode(_el$135, _$createTextNode(`esc close`));
3768
+ _$effect(_p$ => {
3769
+ var _v$68 = api.theme.current.backgroundPanel,
3770
+ _v$69 = TextAttributes.BOLD,
3771
+ _v$70 = {
3772
+ fg: api.theme.current.textMuted
3773
+ },
3774
+ _v$71 = {
3775
+ fg: api.theme.current.textMuted
3776
+ };
3777
+ _v$68 !== _p$.e && (_p$.e = _$setProp(_el$128, "backgroundColor", _v$68, _p$.e));
3778
+ _v$69 !== _p$.t && (_p$.t = _$setProp(_el$129, "attributes", _v$69, _p$.t));
3779
+ _v$70 !== _p$.a && (_p$.a = _$setProp(_el$132, "style", _v$70, _p$.a));
3780
+ _v$71 !== _p$.o && (_p$.o = _$setProp(_el$135, "style", _v$71, _p$.o));
3781
+ return _p$;
3782
+ }, {
3783
+ e: undefined,
3784
+ t: undefined,
3785
+ a: undefined,
3786
+ o: undefined
3787
+ });
3788
+ return _el$128;
3789
+ })(), () => {
3790
+ disposeKeys();
3791
+ });
3792
+ api.ui.dialog.setSize("large");
3793
+ };
3794
+
3795
+ // Triage dialog: the waiting set as an openable list. Enter opens the
3796
+ // highlighted session; the sidebar section stays the always-visible view.
3797
+ let lastNeedsID;
3798
+ const openNeeds = async (command = false) => {
3799
+ if (!command && api.mode.current() !== BASE_MODE) return;
3800
+ if (!command && api.renderer.currentFocusedEditor === null) return;
3801
+ let items = [];
3802
+ try {
3803
+ const {
3804
+ entries
3805
+ } = await fetchEntries(api);
3806
+ const db = await openTranscriptDb();
3807
+ if (!db) {
3808
+ api.ui.toast({
3809
+ message: "Needs-input data unavailable",
3810
+ variant: "error"
3811
+ });
3812
+ return;
3813
+ }
3814
+ let waiting;
3815
+ try {
3816
+ waiting = await queryWaitingDetails(db);
3817
+ } finally {
3818
+ db.close();
3819
+ }
3820
+ items = entries.filter(entry => waiting.has(entry.id)).map(entry => ({
3821
+ entry,
3822
+ reason: waiting.get(entry.id)?.reason === "question" ? "awaiting answer" : "run stuck",
3823
+ since: waiting.get(entry.id)?.since
3824
+ })).sort((a, b) => (a.since ?? a.entry.updated) - (b.since ?? b.entry.updated));
3825
+ } catch {
3826
+ api.ui.toast({
3827
+ message: "Could not load sessions",
3828
+ variant: "error"
3829
+ });
3830
+ return;
3831
+ }
3832
+ if (items.length === 0) {
3833
+ api.ui.toast({
3834
+ message: "No sessions are waiting on you.",
3835
+ variant: "info"
3836
+ });
3837
+ return;
3838
+ }
3839
+ const route = api.route.current;
3840
+ const currentID = route.name === "session" ? route.params?.sessionID : undefined;
3841
+ const previous = items.findIndex(item => item.entry.id === (currentID ?? lastNeedsID));
3842
+ const [cursor, setCursor] = createSignal(previous < 0 ? 0 : (previous + 1) % items.length);
3843
+ const moveCursor = delta => setCursor(value => Math.max(0, Math.min(items.length - 1, value + delta)));
3844
+ const choose = (target = items[cursor()]) => {
3845
+ if (!target) return;
3846
+ lastNeedsID = target.entry.id;
3847
+ disposeNav();
3848
+ api.ui.dialog.clear();
3849
+ api.route.navigate("session", {
3850
+ sessionID: target.entry.id
3851
+ });
3852
+ };
3853
+ const disposeNav = api.keymap.registerLayer({
3854
+ mode: "modal",
3855
+ bindings: [{
3856
+ key: "up",
3857
+ desc: "Previous session",
3858
+ preventDefault: true,
3859
+ cmd: () => moveCursor(-1)
3860
+ }, {
3861
+ key: "down",
3862
+ desc: "Next session",
3863
+ preventDefault: true,
3864
+ cmd: () => moveCursor(1)
3865
+ }, {
3866
+ key: "enter",
3867
+ desc: "Open session",
3868
+ preventDefault: true,
3869
+ cmd: () => choose()
3870
+ }, {
3871
+ key: "n",
3872
+ desc: "Open next waiting session",
3873
+ preventDefault: true,
3874
+ cmd: () => {
3875
+ const at = items.findIndex(item => item.entry.id === currentID);
3876
+ choose(items[at < 0 ? cursor() : (at + 1) % items.length]);
3877
+ }
3878
+ }, {
3879
+ key: "escape",
3880
+ desc: "Close",
3881
+ preventDefault: true,
3882
+ cmd: () => api.ui.dialog.clear()
3883
+ }]
3884
+ });
3885
+ const home = process.env.HOME ?? "";
3886
+ api.ui.dialog.replace(() => (() => {
3887
+ var _el$145 = _$createElement("box"),
3888
+ _el$146 = _$createElement("text"),
3889
+ _el$147 = _$createTextNode(`Needs input `),
3890
+ _el$149 = _$createElement("span"),
3891
+ _el$150 = _$createTextNode(`(`),
3892
+ _el$151 = _$createTextNode(` · longest waiting first)`),
3893
+ _el$152 = _$createElement("scrollbox"),
3894
+ _el$153 = _$createElement("box"),
3895
+ _el$154 = _$createElement("text");
3896
+ _$insertNode(_el$145, _el$146);
3897
+ _$insertNode(_el$145, _el$152);
3898
+ _$insertNode(_el$145, _el$153);
3899
+ _$setProp(_el$145, "flexDirection", "column");
3900
+ _$setProp(_el$145, "paddingLeft", 4);
3901
+ _$setProp(_el$145, "paddingRight", 4);
3902
+ _$setProp(_el$145, "paddingTop", 1);
3903
+ _$setProp(_el$145, "gap", 1);
3904
+ _$insertNode(_el$146, _el$147);
3905
+ _$insertNode(_el$146, _el$149);
3906
+ _$insertNode(_el$149, _el$150);
3907
+ _$insertNode(_el$149, _el$151);
3908
+ _$insert(_el$149, () => items.length, _el$151);
3909
+ _$setProp(_el$152, "flexGrow", 1);
3910
+ _$setProp(_el$152, "scrollbarOptions", {
3911
+ visible: false
3912
+ });
3913
+ _$insert(_el$152, _$createComponent(For, {
3914
+ each: items,
3915
+ children: (item, index) => (() => {
3916
+ var _el$156 = _$createElement("box"),
3917
+ _el$157 = _$createElement("text"),
3918
+ _el$158 = _$createElement("text"),
3919
+ _el$159 = _$createElement("text"),
3920
+ _el$160 = _$createTextNode(` · `);
3921
+ _$insertNode(_el$156, _el$157);
3922
+ _$insertNode(_el$156, _el$158);
3923
+ _$insertNode(_el$156, _el$159);
3924
+ _$setProp(_el$156, "flexDirection", "row");
3925
+ _$setProp(_el$156, "gap", 2);
3926
+ _$setProp(_el$156, "paddingLeft", 1);
3927
+ _$setProp(_el$156, "paddingRight", 1);
3928
+ _$setProp(_el$156, "onMouseDown", () => {
3929
+ setCursor(index());
3930
+ choose(item);
3931
+ });
3932
+ _$setProp(_el$157, "flexGrow", 1);
3933
+ _$setProp(_el$157, "flexShrink", 1);
3934
+ _$setProp(_el$157, "wrapMode", "none");
3935
+ _$insert(_el$157, () => truncate(item.entry.title, 48));
3936
+ _$setProp(_el$158, "flexShrink", 0);
3937
+ _$insert(_el$158, () => item.reason);
3938
+ _$insertNode(_el$159, _el$160);
3939
+ _$setProp(_el$159, "flexShrink", 0);
3940
+ _$insert(_el$159, () => prettyDir(item.entry.dir, home), _el$160);
3941
+ _$insert(_el$159, (() => {
3942
+ var _c$1 = _$memo(() => item.since === undefined);
3943
+ return () => _c$1() ? "time unknown" : `waiting ${ago(item.since)}`;
3944
+ })(), null);
3945
+ _$effect(_p$ => {
3946
+ var _v$80 = index() === cursor() ? api.theme.current.backgroundElement : RGBA.fromInts(0, 0, 0, 0),
3947
+ _v$81 = {
3948
+ fg: index() === cursor() ? api.theme.current.text : api.theme.current.text
3949
+ },
3950
+ _v$82 = {
3951
+ fg: index() === cursor() ? api.theme.current.text : api.theme.current.warning
3952
+ },
3953
+ _v$83 = {
3954
+ fg: index() === cursor() ? api.theme.current.text : api.theme.current.textMuted
3955
+ };
3956
+ _v$80 !== _p$.e && (_p$.e = _$setProp(_el$156, "backgroundColor", _v$80, _p$.e));
3957
+ _v$81 !== _p$.t && (_p$.t = _$setProp(_el$157, "style", _v$81, _p$.t));
3958
+ _v$82 !== _p$.a && (_p$.a = _$setProp(_el$158, "style", _v$82, _p$.a));
3959
+ _v$83 !== _p$.o && (_p$.o = _$setProp(_el$159, "style", _v$83, _p$.o));
3960
+ return _p$;
3961
+ }, {
3962
+ e: undefined,
3963
+ t: undefined,
3964
+ a: undefined,
3965
+ o: undefined
3966
+ });
3967
+ return _el$156;
3968
+ })()
3969
+ }));
3970
+ _$insertNode(_el$153, _el$154);
3971
+ _$setProp(_el$153, "flexShrink", 0);
3972
+ _$insertNode(_el$154, _$createTextNode(`↑↓ navigate · enter open · n open next waiting · esc close`));
3973
+ _$effect(_p$ => {
3974
+ var _v$75 = api.theme.current.backgroundPanel,
3975
+ _v$76 = TextAttributes.BOLD,
3976
+ _v$77 = {
3977
+ fg: api.theme.current.textMuted
3978
+ },
3979
+ _v$78 = Math.max(6, Math.floor(api.renderer.height / 2) - 10),
3980
+ _v$79 = {
3981
+ fg: api.theme.current.textMuted
3982
+ };
3983
+ _v$75 !== _p$.e && (_p$.e = _$setProp(_el$145, "backgroundColor", _v$75, _p$.e));
3984
+ _v$76 !== _p$.t && (_p$.t = _$setProp(_el$146, "attributes", _v$76, _p$.t));
3985
+ _v$77 !== _p$.a && (_p$.a = _$setProp(_el$149, "style", _v$77, _p$.a));
3986
+ _v$78 !== _p$.o && (_p$.o = _$setProp(_el$152, "height", _v$78, _p$.o));
3987
+ _v$79 !== _p$.i && (_p$.i = _$setProp(_el$154, "style", _v$79, _p$.i));
3988
+ return _p$;
3989
+ }, {
3990
+ e: undefined,
3991
+ t: undefined,
3992
+ a: undefined,
3993
+ o: undefined,
3994
+ i: undefined
3995
+ });
3996
+ return _el$145;
3997
+ })(), () => {
3998
+ disposeNav();
3999
+ });
4000
+ api.ui.dialog.setSize("large");
4001
+ };
4002
+ api.command?.register(() => [{
4003
+ title: "Pick session (all directories)",
4004
+ value: "sesh.pick",
4005
+ description: "Switch to any session across all project directories",
4006
+ category: "Sessions",
4007
+ slash: {
4008
+ name: "sesh"
4009
+ },
4010
+ onSelect: () => {
4011
+ void openPicker(true);
4012
+ }
4013
+ }, {
4014
+ title: "Cost digest (per project)",
4015
+ value: "sesh.costs",
4016
+ description: "Spend per project, last day and lifetime",
4017
+ category: "Sessions",
4018
+ slash: {
4019
+ name: "sesh-costs"
4020
+ },
4021
+ onSelect: () => {
4022
+ void openCosts(true);
4023
+ }
4024
+ }, {
4025
+ title: "Sessions waiting on you",
4026
+ value: "sesh.needs",
4027
+ description: "Unanswered questions and stuck runs",
4028
+ category: "Sessions",
4029
+ slash: {
4030
+ name: "sesh-needs"
4031
+ },
4032
+ onSelect: () => {
4033
+ void openNeeds(true);
4034
+ }
4035
+ }]);
4036
+ api.keymap.registerLayer({
4037
+ bindings: [{
4038
+ key: "alt+o",
4039
+ desc: "Pick session",
4040
+ preventDefault: true,
4041
+ cmd: () => void openPicker()
4042
+ }]
4043
+ });
4044
+ };
4045
+ let hostOwner = null;
4046
+ function adaptTheme(theme) {
4047
+ const opaque = color => RGBA.fromValues(color.r, color.g, color.b, 1);
4048
+ return {
4049
+ text: theme.text.base,
4050
+ textMuted: theme.text.muted,
4051
+ background: theme.background.base,
4052
+ backgroundPanel: opaque(theme.background.raised.base),
4053
+ backgroundElement: opaque(theme.background.raised.high),
4054
+ border: theme.border.base,
4055
+ borderActive: theme.hue.accent[200],
4056
+ borderSubtle: theme.border.base,
4057
+ accent: theme.hue.accent[200],
4058
+ primary: theme.hue.accent[200],
4059
+ secondary: theme.text.action.secondary.base,
4060
+ error: theme.text.feedback.error.base,
4061
+ warning: theme.text.feedback.warning.base,
4062
+ success: theme.text.feedback.success.base,
4063
+ info: theme.text.feedback.info.base,
4064
+ selectedListItemText: theme.text.base,
4065
+ syntaxComment: theme.syntax.comment,
4066
+ syntaxString: theme.syntax.string,
4067
+ syntaxNumber: theme.syntax.number,
4068
+ syntaxKeyword: theme.syntax.keyword,
4069
+ syntaxType: theme.syntax.type,
4070
+ syntaxFunction: theme.syntax.function,
4071
+ syntaxOperator: theme.syntax.operator,
4072
+ syntaxVariable: theme.syntax.variable,
4073
+ syntaxPunctuation: theme.syntax.punctuation,
4074
+ markdownLink: theme.markdown.link,
4075
+ markdownLinkText: theme.markdown.linkText,
4076
+ markdownBlockQuote: theme.markdown.blockQuote
4077
+ };
4078
+ }
4079
+ function createApi(ctx) {
4080
+ const [dialogOpen, setDialogOpen] = createSignal(false);
4081
+ let sourceTheme;
4082
+ let currentTheme;
4083
+ return {
4084
+ theme: {
4085
+ get current() {
4086
+ if (sourceTheme !== ctx.theme) {
4087
+ sourceTheme = ctx.theme;
4088
+ currentTheme = adaptTheme(sourceTheme);
4089
+ }
4090
+ return currentTheme;
4091
+ }
4092
+ },
4093
+ ui: {
4094
+ toast(opts) {
4095
+ ctx.ui.toast.show({
4096
+ title: opts.title ?? "Sessions",
4097
+ message: opts.message,
4098
+ variant: opts.variant ?? "info"
4099
+ });
4100
+ },
4101
+ dialog: {
4102
+ clear() {
4103
+ ctx.ui.dialog.clear();
4104
+ setDialogOpen(false);
4105
+ },
4106
+ replace(render, onClose) {
4107
+ let closed = false;
4108
+ ctx.ui.dialog.show(render, () => {
4109
+ if (closed) return;
4110
+ closed = true;
4111
+ setDialogOpen(false);
4112
+ onClose?.();
4113
+ });
4114
+ setDialogOpen(true);
4115
+ },
4116
+ setSize(size) {
4117
+ ctx.ui.dialog.set({
4118
+ size
4119
+ });
4120
+ },
4121
+ // V2's Dialog has no reactive open/isOpen accessor; track it locally
4122
+ // from the replace/clear calls above and the host's onClose callback.
4123
+ get open() {
4124
+ return dialogOpen();
4125
+ }
4126
+ }
4127
+ },
4128
+ route: {
4129
+ get current() {
4130
+ const r = ctx.ui.router.current();
4131
+ if (r.type === "session") {
4132
+ return {
4133
+ name: "session",
4134
+ params: {
4135
+ sessionID: r.sessionID
4136
+ }
4137
+ };
4138
+ }
4139
+ return {
4140
+ name: r.type,
4141
+ params: {}
4142
+ };
4143
+ },
4144
+ navigate(name, params) {
4145
+ if (name === "session" && params?.sessionID) {
4146
+ ctx.ui.router.navigate({
4147
+ type: "session",
4148
+ sessionID: params.sessionID
4149
+ });
4150
+ } else if (name === "home") {
4151
+ ctx.ui.router.navigate({
4152
+ type: "home"
4153
+ });
4154
+ }
4155
+ }
4156
+ },
4157
+ state: {
4158
+ path: {
4159
+ get directory() {
4160
+ return ctx.location?.directory ?? process.cwd();
4161
+ }
4162
+ },
4163
+ session: {
4164
+ status(id) {
4165
+ return ctx.data.session.status(id);
4166
+ }
4167
+ }
4168
+ },
4169
+ mode: {
4170
+ current() {
4171
+ return ctx.keymap.mode.current();
4172
+ }
4173
+ },
4174
+ renderer: {
4175
+ get height() {
4176
+ return ctx.renderer?.height ?? 40;
4177
+ },
4178
+ get width() {
4179
+ return ctx.renderer?.width ?? 120;
4180
+ },
4181
+ get root() {
4182
+ return ctx.renderer.root;
4183
+ },
4184
+ get currentFocusedEditor() {
4185
+ return ctx.renderer.currentFocusedEditor;
4186
+ },
4187
+ get nativeSession() {
4188
+ return ctx.renderer?.nativeSession;
4189
+ },
4190
+ on(event, handler) {
4191
+ ctx.renderer.on(event, handler);
4192
+ },
4193
+ off(event, handler) {
4194
+ ctx.renderer.off(event, handler);
4195
+ }
4196
+ },
4197
+ keymap: {
4198
+ registerLayer(layer) {
4199
+ return runWithOwner(hostOwner, () => createRoot(dispose => {
4200
+ ctx.keymap.layer(() => ({
4201
+ mode: layer.mode ?? "base",
4202
+ priority: layer.priority ?? 20,
4203
+ commands: (layer.bindings ?? []).map(b => ({
4204
+ title: b.desc,
4205
+ bind: b.key,
4206
+ run: () => {
4207
+ b.cmd();
4208
+ }
4209
+ }))
4210
+ }));
4211
+ return dispose;
4212
+ }));
4213
+ }
4214
+ },
4215
+ slots: {
4216
+ register(cfg) {
4217
+ if (cfg.slots.sidebar_content) {
4218
+ ctx.ui.slot({
4219
+ prepend: "sidebar.content",
4220
+ render: cfg.slots.sidebar_content
4221
+ });
4222
+ }
4223
+ if (cfg.slots.home_bottom) {
4224
+ ctx.ui.slot({
4225
+ prepend: "home.footer",
4226
+ render: cfg.slots.home_bottom
4227
+ });
4228
+ }
4229
+ }
4230
+ },
4231
+ command: {
4232
+ register(factory) {
4233
+ ctx.keymap.layer(() => ({
4234
+ mode: "global",
4235
+ priority: 20,
4236
+ commands: factory().map(cmd => ({
4237
+ id: cmd.value,
4238
+ title: cmd.title,
4239
+ description: cmd.description,
4240
+ group: cmd.category,
4241
+ palette: true,
4242
+ slash: cmd.slash ? {
4243
+ name: cmd.slash.name
4244
+ } : undefined,
4245
+ run: () => {
4246
+ cmd.onSelect();
4247
+ }
4248
+ }))
4249
+ }));
4250
+ }
4251
+ },
4252
+ client: {
4253
+ project: {
4254
+ async list() {
4255
+ const res = await ctx.client.project.list();
4256
+ return {
4257
+ data: Array.isArray(res) ? res : res?.data ?? []
4258
+ };
4259
+ }
4260
+ },
4261
+ session: {
4262
+ async delete(req) {
4263
+ await ctx.client.session.remove({
4264
+ sessionID: req.sessionID
4265
+ });
4266
+ return {
4267
+ data: undefined
4268
+ };
4269
+ },
4270
+ async fork(req) {
4271
+ const res = await ctx.client.session.fork({
4272
+ sessionID: req.sessionID
4273
+ });
4274
+ return {
4275
+ data: res
4276
+ };
4277
+ },
4278
+ async messages(req) {
4279
+ let cursor;
4280
+ const all = [];
4281
+ do {
4282
+ const res = await ctx.client.message.list({
4283
+ sessionID: req.sessionID,
4284
+ order: req.preview ? "desc" : "asc",
4285
+ limit: 100,
4286
+ cursor
4287
+ });
4288
+ all.push(...res.data);
4289
+ const next = res.cursor.next;
4290
+ if (req.preview || !next || next === cursor) break;
4291
+ cursor = next;
4292
+ } while (true);
4293
+ if (req.preview) all.reverse();
4294
+ const messages = all.map(m => {
4295
+ let text = "";
4296
+ if (m.type === "user") text = m.text ?? "";else if (m.type === "assistant") {
4297
+ text = (m.content ?? []).filter(c => c.type === "text").map(c => c.text).join("\n");
4298
+ }
4299
+ return {
4300
+ id: m.id,
4301
+ info: {
4302
+ role: m.type
4303
+ },
4304
+ parts: [{
4305
+ type: "text",
4306
+ text
4307
+ }]
4308
+ };
4309
+ });
4310
+ return {
4311
+ data: messages
4312
+ };
4313
+ }
4314
+ },
4315
+ experimental: {
4316
+ session: {
4317
+ async list(input) {
4318
+ const res = await ctx.client.session.list({
4319
+ parentID: null,
4320
+ order: "desc",
4321
+ limit: input.limit,
4322
+ cursor: input.cursor
4323
+ });
4324
+ return {
4325
+ cursor: res.cursor,
4326
+ data: res.data.map(s => ({
4327
+ ...s,
4328
+ directory: s.location?.directory ?? ""
4329
+ }))
4330
+ };
4331
+ }
4332
+ }
4333
+ }
4334
+ }
4335
+ };
4336
+ }
4337
+ export default Plugin.define({
4338
+ id: "sesh-panel",
4339
+ async setup(ctx) {
4340
+ const api = createApi(ctx);
4341
+ let initialized = false;
4342
+ const disposeSlot = ctx.ui.slot({
4343
+ append: "app",
4344
+ render: () => {
4345
+ if (!initialized) {
4346
+ initialized = true;
4347
+ hostOwner = getOwner();
4348
+ void tui(api);
4349
+ }
4350
+ return _$createElement("box");
4351
+ }
4352
+ });
4353
+ return () => {
4354
+ if (api.ui.dialog.open) api.ui.dialog.clear();
4355
+ disposeSlot();
4356
+ removeRootMouse?.();
4357
+ };
4358
+ }
4359
+ });