opencode-sesh 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1177 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
3
+ import type { Project, Session } from "@opencode-ai/sdk/v2"
4
+ import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
5
+ import { readFile } from "node:fs/promises"
6
+ import { RGBA, SyntaxStyle, TextAttributes } from "@opentui/core"
7
+
8
+ type ThemeColors = TuiPluginApi["theme"]["current"]
9
+
10
+ let markdownStyleCache: { theme: ThemeColors; style: SyntaxStyle } | undefined
11
+
12
+ function buildMarkdownStyle(theme: ThemeColors): SyntaxStyle {
13
+ return SyntaxStyle.fromTheme([
14
+ { scope: ["default"], style: { foreground: theme.text } },
15
+ { scope: ["comment", "comment.documentation"], style: { foreground: theme.syntaxComment, italic: true } },
16
+ { scope: ["string", "symbol", "character"], style: { foreground: theme.syntaxString } },
17
+ { scope: ["number", "boolean", "constant", "float"], style: { foreground: theme.syntaxNumber } },
18
+ {
19
+ scope: ["keyword", "keyword.import", "keyword.directive", "keyword.modifier", "keyword.exception"],
20
+ style: { foreground: theme.syntaxKeyword, italic: true },
21
+ },
22
+ {
23
+ scope: ["keyword.return", "keyword.conditional", "keyword.repeat", "keyword.coroutine"],
24
+ style: { foreground: theme.syntaxKeyword, italic: true },
25
+ },
26
+ { scope: ["keyword.type"], style: { foreground: theme.syntaxType, bold: true, italic: true } },
27
+ {
28
+ scope: ["keyword.function", "function.method", "function", "constructor", "variable.member"],
29
+ style: { foreground: theme.syntaxFunction },
30
+ },
31
+ {
32
+ scope: ["operator", "keyword.operator", "punctuation.delimiter", "punctuation.special"],
33
+ style: { foreground: theme.syntaxOperator },
34
+ },
35
+ {
36
+ scope: ["variable", "variable.parameter", "function.method.call", "function.call", "property", "parameter"],
37
+ style: { foreground: theme.syntaxVariable },
38
+ },
39
+ { scope: ["type", "module", "class"], style: { foreground: theme.syntaxType } },
40
+ { scope: ["punctuation", "punctuation.bracket"], style: { foreground: theme.syntaxPunctuation } },
41
+ {
42
+ scope: [
43
+ "variable.builtin",
44
+ "type.builtin",
45
+ "function.builtin",
46
+ "module.builtin",
47
+ "constant.builtin",
48
+ "variable.super",
49
+ ],
50
+ style: { foreground: theme.error },
51
+ },
52
+ { scope: ["markup.heading"], style: { foreground: theme.markdownHeading, bold: true } },
53
+ {
54
+ scope: ["markup.heading.1"],
55
+ style: { foreground: theme.markdownHeading, bold: true, underline: true },
56
+ },
57
+ {
58
+ scope: ["markup.heading.2", "markup.heading.3", "markup.heading.4", "markup.heading.5", "markup.heading.6"],
59
+ style: { foreground: theme.markdownHeading, bold: true },
60
+ },
61
+ { scope: ["markup.bold", "markup.strong"], style: { foreground: theme.markdownStrong, bold: true } },
62
+ { scope: ["markup.italic"], style: { foreground: theme.markdownEmph, italic: true } },
63
+ { scope: ["markup.list"], style: { foreground: theme.markdownListItem } },
64
+ { scope: ["markup.quote"], style: { foreground: theme.markdownBlockQuote, italic: true } },
65
+ { scope: ["markup.raw", "markup.raw.block"], style: { foreground: theme.markdownCode } },
66
+ { scope: ["markup.raw.inline"], style: { foreground: theme.markdownCode, background: theme.background } },
67
+ {
68
+ scope: ["markup.link", "markup.link.url", "string.special", "string.special.url"],
69
+ style: { foreground: theme.markdownLink, underline: true },
70
+ },
71
+ { scope: ["markup.link.label", "label"], style: { foreground: theme.markdownLinkText, underline: true } },
72
+ { scope: ["conceal"], style: { foreground: theme.textMuted } },
73
+ ])
74
+ }
75
+
76
+ function getMarkdownStyle(theme: ThemeColors): SyntaxStyle | undefined {
77
+ if (markdownStyleCache?.theme === theme) return markdownStyleCache.style
78
+ try {
79
+ markdownStyleCache = { theme, style: buildMarkdownStyle(theme) }
80
+ return markdownStyleCache.style
81
+ } catch {
82
+ try {
83
+ return SyntaxStyle.create()
84
+ } catch {
85
+ return undefined
86
+ }
87
+ }
88
+ }
89
+
90
+ // Sessions panel: an opencode-native session switcher in two parts.
91
+ //
92
+ // 1. Sidebar (always visible): a compact recent-sessions section appended to
93
+ // the native sidebar via the `sidebar_content` slot. Native session_list
94
+ // (<leader>l) is left untouched; this complements it with a cross-project
95
+ // view. Rows are display-only; ctrl+o opens the full picker.
96
+ // 2. Picker (ctrl+o, `/sesh`, command palette): an xlarge grouped picker over
97
+ // every session across all project directories, newest first.
98
+ //
99
+ // `/sesh` is registered by this plugin (a markdown command cannot open the
100
+ // dialog). Native `/sessions` and session_list (<leader>l) stay untouched.
101
+ const BASE_MODE = "base"
102
+ const POLL_MS = 15_000
103
+
104
+ function shortDir(dir: string, home: string): string {
105
+ if (!dir || dir === "/") return "other"
106
+ const pretty = home && dir.startsWith(home + "/") ? "~" + dir.slice(home.length) : dir
107
+ return pretty.replace(/\/+$/, "").split("/").pop() || "other"
108
+ }
109
+
110
+ function prettyDir(dir: string, home: string): string {
111
+ if (!dir) return "other"
112
+ if (home && dir === home) return "~"
113
+ const pretty = home && dir.startsWith(home + "/") ? "~" + dir.slice(home.length) : dir.replace(/\/+$/, "")
114
+ const parts = pretty.split("/").filter(Boolean)
115
+ if (parts.length <= 3) return pretty
116
+ return "…/" + parts.slice(-2).join("/")
117
+ }
118
+
119
+ function ago(ms: number): string {
120
+ const s = Math.max(0, Math.floor((Date.now() - ms) / 1000))
121
+ if (s < 60) return "now"
122
+ if (s < 3600) return `${Math.floor(s / 60)}m`
123
+ if (s < 86400) return `${Math.floor(s / 3600)}h`
124
+ return `${Math.floor(s / 86400)}d`
125
+ }
126
+
127
+ function truncate(text: string, width: number): string {
128
+ if (width <= 0) return ""
129
+ if (text.length <= width) return text
130
+ return text.slice(0, width - 1) + "…"
131
+ }
132
+
133
+ const SEARCH_KEYS = "abcdefghijklmnopqrstuvwxyz0123456789-_.@/+".split("")
134
+
135
+ function searchBindings(append: (input: string) => void, exit: () => void) {
136
+ return [
137
+ ...SEARCH_KEYS.map((ch) => ({ key: ch, preventDefault: true, cmd: () => append(ch) })),
138
+ { key: "space", preventDefault: true, cmd: () => append(" ") },
139
+ { key: "backspace", preventDefault: true, cmd: () => append("\b") },
140
+ { key: "escape", preventDefault: true, cmd: exit },
141
+ ]
142
+ }
143
+
144
+ const rootMouseHandlers = new Set<() => void>()
145
+ let rootMouseInstalled = false
146
+
147
+ function ensureRootMouse(renderer: TuiPluginApi["renderer"]) {
148
+ if (rootMouseInstalled || !renderer?.root) return
149
+ rootMouseInstalled = true
150
+ const root = renderer.root as unknown as {
151
+ onMouseDown?: (event: unknown) => void
152
+ _mouseListeners?: { down?: (event: unknown) => void }
153
+ }
154
+ const previous = root._mouseListeners?.down
155
+ root.onMouseDown = (event) => {
156
+ previous?.(event)
157
+ for (const handler of [...rootMouseHandlers]) handler()
158
+ }
159
+ }
160
+
161
+ type Entry = { id: string; title: string; dir: string; group: string; updated: number }
162
+
163
+ async function fetchEntries(api: TuiPluginApi): Promise<Entry[]> {
164
+ const home = process.env.HOME ?? ""
165
+ const [sessionResult, projectResult] = await Promise.all([
166
+ api.client.experimental.session.list({ limit: 500, roots: true, directory: "" }),
167
+ api.client.project.list({}),
168
+ ])
169
+ const sessions = ((sessionResult?.data ?? sessionResult) as Session[]) ?? []
170
+ const projects = ((projectResult?.data ?? projectResult) as Project[]) ?? []
171
+ if (!Array.isArray(sessions)) return []
172
+ const projectName = new Map<string, string>(
173
+ projects.map((p) => [p.id, p.name?.trim() || shortDir(p.worktree ?? "", home)]),
174
+ )
175
+ return sessions
176
+ .filter((s) => !s.time?.archived && !s.parentID)
177
+ .sort((a, b) => b.time.updated - a.time.updated)
178
+ .map((s) => {
179
+ const projectLabel = s.projectID ? projectName.get(s.projectID) : undefined
180
+ return {
181
+ id: s.id,
182
+ title: s.title?.trim() || "(untitled)",
183
+ dir: s.directory ?? "",
184
+ group:
185
+ (projectLabel && projectLabel !== "other" ? projectLabel : shortDir(s.directory ?? "", home)) ||
186
+ "other",
187
+ updated: s.time.updated,
188
+ }
189
+ })
190
+ }
191
+
192
+ const TRANSCRIPT_INDEX_LIMIT = 150
193
+ const SESSION_ID_PATTERN = /^ses_[A-Za-z0-9]+$/
194
+
195
+ async function buildSearchIndex(api: TuiPluginApi, entries: Entry[]): Promise<Map<string, string>> {
196
+ const index = new Map<string, string>()
197
+ const targets = entries.slice(0, TRANSCRIPT_INDEX_LIMIT)
198
+ for (const entry of targets) {
199
+ index.set(entry.id, `${entry.title.toLowerCase()} ${entry.dir.toLowerCase()}`)
200
+ }
201
+ const ids = targets.map((entry) => entry.id).filter((id) => SESSION_ID_PATTERN.test(id))
202
+ if (ids.length === 0) return index
203
+ try {
204
+ const home = process.env.HOME ?? ""
205
+ const dataHome = process.env.XDG_DATA_HOME ?? `${home}/.local/share`
206
+ // @ts-ignore - bun:sqlite ships with the Bun runtime (verified); no type package installed
207
+ const sqlite: any = await import("bun:sqlite")
208
+ if (typeof sqlite?.Database !== "function") throw new Error("bun:sqlite unavailable")
209
+ const db = new sqlite.Database(`${dataHome}/opencode/opencode.db`, { readonly: true })
210
+ try {
211
+ const placeholders = ids.map(() => "?").join(",")
212
+ const rows = (db
213
+ .query(
214
+ `SELECT session_id AS sid, data FROM part WHERE session_id IN (${placeholders}) AND json_extract(data, '$.type') = 'text' ORDER BY time_created`,
215
+ )
216
+ .all(...ids) ?? []) as { sid: string; data: string }[]
217
+ const byId = new Map<string, string[]>()
218
+ for (const row of rows) {
219
+ try {
220
+ const part = JSON.parse(row.data) as { type?: unknown; text?: unknown }
221
+ if (part?.type === "text" && typeof part.text === "string") {
222
+ const list = byId.get(row.sid) ?? []
223
+ list.push(part.text)
224
+ byId.set(row.sid, list)
225
+ }
226
+ } catch {}
227
+ }
228
+ for (const entry of targets) {
229
+ const text = (byId.get(entry.id) ?? []).join(" ").toLowerCase()
230
+ if (text) index.set(entry.id, `${index.get(entry.id)} ${text}`)
231
+ }
232
+ } finally {
233
+ db.close()
234
+ }
235
+ } catch {
236
+ await buildSearchIndexRemote(api, targets, index)
237
+ }
238
+ return index
239
+ }
240
+
241
+ async function buildSearchIndexRemote(
242
+ api: TuiPluginApi,
243
+ targets: Entry[],
244
+ index: Map<string, string>,
245
+ ): Promise<void> {
246
+ const home = process.env.HOME ?? ""
247
+ const cacheDir = process.env.SESH_CACHE_DIR ?? `${home}/.cache/sesh`
248
+ await Promise.all(
249
+ targets.map(async (entry) => {
250
+ const base = index.get(entry.id) ?? ""
251
+ if (!SESSION_ID_PATTERN.test(entry.id)) return
252
+ try {
253
+ const raw = await readFile(`${cacheDir}/extractions/${entry.id}.json`, "utf8")
254
+ const record = JSON.parse(raw) as { fulltextLower?: unknown }
255
+ const fulltext = typeof record?.fulltextLower === "string" ? record.fulltextLower : ""
256
+ index.set(entry.id, `${base} ${fulltext}`)
257
+ } catch {
258
+ try {
259
+ const result = await api.client.session.messages({ sessionID: entry.id })
260
+ const messages = result.data ?? []
261
+ const text = messages
262
+ .flatMap((message) =>
263
+ message.parts
264
+ .filter((part) => part.type === "text")
265
+ .map((part) => (part as { text: string }).text),
266
+ )
267
+ .join(" ")
268
+ .toLowerCase()
269
+ index.set(entry.id, `${base} ${text}`)
270
+ } catch {}
271
+ }
272
+ }),
273
+ )
274
+ }
275
+
276
+ const TRANSCRIPT_PREVIEW_TURNS = 8
277
+ const TRANSCRIPT_PREVIEW_CHARS = 2000
278
+
279
+ async function fetchTranscriptText(api: TuiPluginApi, sessionID: string): Promise<string> {
280
+ try {
281
+ const result = await api.client.session.messages({ sessionID })
282
+ const messages = result.data ?? []
283
+ const blocks = messages
284
+ .map((message) => {
285
+ const text = message.parts
286
+ .filter((part) => part.type === "text")
287
+ .map((part) => (part as { text: string }).text)
288
+ .join("\n")
289
+ .trim()
290
+ if (!text) return ""
291
+ const role = (message.info as { role?: string }).role
292
+ return `**${role === "user" ? "You" : "Assistant"}**\n\n${text.slice(0, TRANSCRIPT_PREVIEW_CHARS)}`
293
+ })
294
+ .filter(Boolean)
295
+ .slice(-TRANSCRIPT_PREVIEW_TURNS)
296
+ .reverse()
297
+ const text = blocks.join("\n\n---\n\n")
298
+ return text || "(no text in this session)"
299
+ } catch {
300
+ return "(preview unavailable)"
301
+ }
302
+ }
303
+
304
+ // Shared transcript preview dialog. Call from a component's setup scope so the
305
+ // preview key layer is torn down with it.
306
+ function createTranscriptPreview(api: TuiPluginApi) {
307
+ const [previewID, setPreviewID] = createSignal<string>()
308
+ const [previewText, setPreviewText] = createSignal("")
309
+ let disposePreviewKeys: (() => void) | undefined
310
+
311
+ const close = () => {
312
+ setPreviewID(undefined)
313
+ api.ui.dialog.clear()
314
+ disposePreviewKeys?.()
315
+ disposePreviewKeys = undefined
316
+ }
317
+
318
+ const open = (entry: Entry) => {
319
+ if (previewID() === entry.id) {
320
+ close()
321
+ return
322
+ }
323
+ setPreviewID(entry.id)
324
+ setPreviewText("Loading…")
325
+ void fetchTranscriptText(api, entry.id).then(setPreviewText)
326
+ api.ui.dialog.replace(
327
+ () => (
328
+ <box flexDirection="column" paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
329
+ <box flexDirection="row" justifyContent="space-between">
330
+ <text attributes={TextAttributes.BOLD}>{entry.title}</text>
331
+ <text style={{ fg: api.theme.current.textMuted }}>esc</text>
332
+ </box>
333
+ <text style={{ fg: api.theme.current.textMuted }}>
334
+ {prettyDir(entry.dir, process.env.HOME ?? "")} · {ago(entry.updated)}
335
+ </text>
336
+ <scrollbox
337
+ paddingLeft={1}
338
+ paddingRight={1}
339
+ scrollbarOptions={{ visible: false }}
340
+ maxHeight={Math.max(6, Math.floor(api.renderer.height / 2))}
341
+ >
342
+ {getMarkdownStyle(api.theme.current) ? (
343
+ <markdown content={previewText()} syntaxStyle={getMarkdownStyle(api.theme.current)!} />
344
+ ) : (
345
+ <text wrapMode="word">{previewText()}</text>
346
+ )}
347
+ </scrollbox>
348
+ </box>
349
+ ),
350
+ () => {
351
+ setPreviewID(undefined)
352
+ disposePreviewKeys?.()
353
+ disposePreviewKeys = undefined
354
+ },
355
+ )
356
+ api.ui.dialog.setSize("large")
357
+ disposePreviewKeys?.()
358
+ disposePreviewKeys = api.keymap.registerLayer({
359
+ bindings: [
360
+ {
361
+ key: "escape",
362
+ desc: "Close preview",
363
+ preventDefault: true,
364
+ cmd: close,
365
+ },
366
+ ],
367
+ })
368
+ }
369
+
370
+ onCleanup(() => disposePreviewKeys?.())
371
+
372
+ return { open }
373
+ }
374
+
375
+ type TreeRow =
376
+ | { kind: "group"; dir: string; label: string; count: number }
377
+ | { kind: "item"; entry: Entry }
378
+
379
+ function SidebarSessions(props: { api: TuiPluginApi }) {
380
+ const theme = () => props.api.theme.current
381
+ const home = process.env.HOME ?? ""
382
+ const [entries, setEntries] = createSignal<Entry[]>([])
383
+ const [collapsed, setCollapsed] = createSignal<Record<string, boolean>>({})
384
+ const [hovered, setHovered] = createSignal<string>()
385
+ const preview = createTranscriptPreview(props.api)
386
+ const [query, setQuery] = createSignal("")
387
+ const [deleting, setDeleting] = createSignal<string>()
388
+ const [searching, setSearching] = createSignal(false)
389
+ const currentID = createMemo(() => {
390
+ const route = props.api.route.current
391
+ if (route.name !== "session") return undefined
392
+ const params = route.params
393
+ return params && typeof params.sessionID === "string" ? params.sessionID : undefined
394
+ })
395
+
396
+ onMount(() => {
397
+ let alive = true
398
+ const load = async () => {
399
+ try {
400
+ const all = await fetchEntries(props.api)
401
+ if (alive) setEntries(all)
402
+ } catch {
403
+ // leave the previous list in place rather than flashing to blank
404
+ }
405
+ }
406
+ void load()
407
+ const clock = setInterval(() => void load(), POLL_MS)
408
+ onCleanup(() => {
409
+ alive = false
410
+ clearInterval(clock)
411
+ })
412
+ })
413
+
414
+ const filteredEntries = createMemo(() => {
415
+ const q = query().trim().toLowerCase()
416
+ if (!q) return entries()
417
+ return entries().filter(
418
+ (entry) => entry.title.toLowerCase().includes(q) || entry.dir.toLowerCase().includes(q),
419
+ )
420
+ })
421
+
422
+ const tree = createMemo<TreeRow[]>(() => {
423
+ const groups = new Map<string, Entry[]>()
424
+ for (const entry of filteredEntries()) {
425
+ const list = groups.get(entry.dir) ?? []
426
+ list.push(entry)
427
+ groups.set(entry.dir, list)
428
+ }
429
+ const ordered = [...groups.entries()]
430
+ .map(([dir, list]) => ({ dir, list, updated: Math.max(...list.map((entry) => entry.updated)) }))
431
+ .sort((a, b) => b.updated - a.updated)
432
+ const rows: TreeRow[] = []
433
+ for (const group of ordered) {
434
+ rows.push({ kind: "group", dir: group.dir, label: prettyDir(group.dir, home), count: group.list.length })
435
+ if (collapsed()[group.dir]) continue
436
+ for (const entry of group.list) rows.push({ kind: "item", entry })
437
+ }
438
+ return rows
439
+ })
440
+
441
+ const toggleGroup = (dir: string) => {
442
+ setCollapsed((prev) => ({ ...prev, [dir]: !prev[dir] }))
443
+ }
444
+
445
+ const sessionStatus = (id: string): "running" | "waiting" | "idle" => {
446
+ try {
447
+ const permissions = props.api.state.session.permission(id)
448
+ const questions = props.api.state.session.question(id)
449
+ if ((permissions?.length ?? 0) > 0 || (questions?.length ?? 0) > 0) return "waiting"
450
+ const status = props.api.state.session.status(id)
451
+ if (status?.type === "busy" || status?.type === "retry") return "running"
452
+ } catch {}
453
+ return "idle"
454
+ }
455
+
456
+ const statusColor = (id: string) => {
457
+ const status = sessionStatus(id)
458
+ if (status === "running") return theme().success
459
+ if (status === "waiting") return theme().warning
460
+ return theme().textMuted
461
+ }
462
+
463
+ const deleteEntry = async (entry: Entry) => {
464
+ if (deleting()) return
465
+ setDeleting(entry.id)
466
+ try {
467
+ const result = await props.api.client.session.delete({
468
+ sessionID: entry.id,
469
+ directory: entry.dir || undefined,
470
+ })
471
+ if (result && "error" in result && result.error) throw result.error
472
+ const remaining = await fetchEntries(props.api)
473
+ if (remaining.some((item) => item.id === entry.id)) {
474
+ setEntries(remaining)
475
+ props.api.ui.toast({ message: "Session was not deleted", variant: "error" })
476
+ return
477
+ }
478
+ setEntries(remaining)
479
+ setHovered(undefined)
480
+ props.api.ui.toast({ message: `Deleted "${truncate(entry.title, 40)}"`, variant: "info" })
481
+ if (currentID() === entry.id) props.api.route.navigate("home")
482
+ } catch {
483
+ props.api.ui.toast({ message: "Could not delete session", variant: "error" })
484
+ } finally {
485
+ setDeleting(undefined)
486
+ }
487
+ }
488
+
489
+ let disposeHoverSpace: (() => void) | undefined
490
+ let disposeSearch: (() => void) | undefined
491
+ let insideSearchBox = false
492
+
493
+ const onRootMouseDown = () => {
494
+ if (insideSearchBox) {
495
+ insideSearchBox = false
496
+ return
497
+ }
498
+ if (searching()) setSearching(false)
499
+ }
500
+
501
+ onMount(() => {
502
+ ensureRootMouse(props.api.renderer)
503
+ rootMouseHandlers.add(onRootMouseDown)
504
+ onCleanup(() => rootMouseHandlers.delete(onRootMouseDown))
505
+ })
506
+
507
+ const appendQuery = (input: string) => {
508
+ if (input === "\b") setQuery((value) => value.slice(0, -1))
509
+ else setQuery((value) => value + input)
510
+ }
511
+
512
+ createEffect(() => {
513
+ disposeSearch?.()
514
+ disposeSearch = undefined
515
+ if (!searching()) return
516
+ disposeSearch = props.api.keymap.registerLayer({
517
+ mode: "base",
518
+ priority: 20,
519
+ bindings: searchBindings(appendQuery, () => setSearching(false)),
520
+ })
521
+ })
522
+
523
+ const openSidebarPreview = (entry: Entry) => preview.open(entry)
524
+
525
+ createEffect(() => {
526
+ disposeHoverSpace?.()
527
+ disposeHoverSpace = undefined
528
+ const id = hovered()
529
+ if (!id) return
530
+ const entry = entries().find((e) => e.id === id)
531
+ if (!entry) return
532
+ disposeHoverSpace = props.api.keymap.registerLayer({
533
+ mode: "base",
534
+ priority: 20,
535
+ bindings: [
536
+ {
537
+ key: "space",
538
+ desc: "Preview session transcript",
539
+ preventDefault: true,
540
+ cmd: () => openSidebarPreview(entry),
541
+ },
542
+ {
543
+ key: "ctrl+x",
544
+ desc: "Delete session",
545
+ preventDefault: true,
546
+ cmd: () => void deleteEntry(entry),
547
+ },
548
+ {
549
+ key: "/",
550
+ desc: "Search sessions",
551
+ preventDefault: true,
552
+ cmd: () => setSearching(true),
553
+ },
554
+ ],
555
+ })
556
+ })
557
+
558
+ onCleanup(() => {
559
+ disposeHoverSpace?.()
560
+ disposeSearch?.()
561
+ })
562
+
563
+ return (
564
+ <box flexDirection="column" paddingRight={1}>
565
+ <Show when={entries().length > 0}>
566
+ <box flexDirection="row" gap={1}>
567
+ <text>
568
+ <b>Sessions</b>
569
+ <span style={{ fg: theme().textMuted }}>
570
+ {query().trim() ? ` (${filteredEntries().length}/${entries().length})` : ` (${entries().length})`}
571
+ </span>
572
+ </text>
573
+ </box>
574
+ <box
575
+ flexDirection="row"
576
+ gap={1}
577
+ paddingLeft={1}
578
+ paddingRight={1}
579
+ backgroundColor={searching() ? theme().backgroundElement : RGBA.fromInts(0, 0, 0, 0)}
580
+ onMouseDown={() => {
581
+ insideSearchBox = true
582
+ setSearching(true)
583
+ }}
584
+ >
585
+ <text
586
+ flexGrow={1}
587
+ flexShrink={1}
588
+ overflow="hidden"
589
+ wrapMode="none"
590
+ style={{ fg: query() ? theme().text : theme().textMuted }}
591
+ >
592
+ {query() || "search…"}
593
+ {searching() ? "▏" : ""}
594
+ </text>
595
+ <Show when={query()}>
596
+ <text flexShrink={0} style={{ fg: theme().textMuted }} onMouseDown={() => setQuery("")}>
597
+ ✕
598
+ </text>
599
+ </Show>
600
+ </box>
601
+ <scrollbox
602
+ verticalScrollbarOptions={{ visible: false }}
603
+ horizontalScrollbarOptions={{ visible: false }}
604
+ >
605
+ <For each={tree()}>
606
+ {(row) =>
607
+ row.kind === "group" ? (
608
+ <box flexDirection="row" onMouseDown={() => toggleGroup(row.dir)}>
609
+ <text flexGrow={1} flexShrink={1} wrapMode="none">
610
+ <span style={{ fg: theme().textMuted }}>{collapsed()[row.dir] ? "▸ " : "▾ "}</span>
611
+ <b>{row.label}</b>
612
+ </text>
613
+ <text flexShrink={0} style={{ fg: theme().textMuted }}>
614
+ {" "}({row.count})
615
+ </text>
616
+ </box>
617
+ ) : (
618
+ <box
619
+ flexDirection="row"
620
+ gap={1}
621
+ paddingLeft={1}
622
+ paddingRight={1}
623
+ backgroundColor={
624
+ row.entry.id === hovered() && row.entry.id !== currentID()
625
+ ? theme().backgroundElement
626
+ : RGBA.fromInts(0, 0, 0, 0)
627
+ }
628
+ onMouseOver={() => setHovered(row.entry.id)}
629
+ onMouseOut={() => setHovered(undefined)}
630
+ onMouseDown={() => props.api.route.navigate("session", { sessionID: row.entry.id })}
631
+ >
632
+ <text
633
+ flexShrink={0}
634
+ style={{ fg: row.entry.id === currentID() ? theme().accent : statusColor(row.entry.id) }}
635
+ >
636
+ {row.entry.id === currentID() ? "●" : "○"}
637
+ </text>
638
+ <text flexGrow={1} flexShrink={1} overflow="hidden" wrapMode="none" style={{ fg: theme().text }}>
639
+ {row.entry.title}
640
+ </text>
641
+ <text flexShrink={0} style={{ fg: theme().textMuted }}>
642
+ {ago(row.entry.updated)}
643
+ </text>
644
+ </box>
645
+ )
646
+ }
647
+ </For>
648
+ </scrollbox>
649
+ </Show>
650
+ </box>
651
+ )
652
+ }
653
+
654
+ const HOME_SESSION_LIMIT = 6
655
+
656
+ function HomeSessions(props: { api: TuiPluginApi }) {
657
+ const theme = () => props.api.theme.current
658
+ const home = process.env.HOME ?? ""
659
+ const [entries, setEntries] = createSignal<Entry[]>([])
660
+ const [query, setQuery] = createSignal("")
661
+ const [hovered, setHovered] = createSignal<string>()
662
+ const [searching, setSearching] = createSignal(false)
663
+ const preview = createTranscriptPreview(props.api)
664
+ let disposeHoverSpace: (() => void) | undefined
665
+ let disposeSearch: (() => void) | undefined
666
+ let insideSearchBox = false
667
+
668
+ const onRootMouseDown = () => {
669
+ if (insideSearchBox) {
670
+ insideSearchBox = false
671
+ return
672
+ }
673
+ if (searching()) setSearching(false)
674
+ }
675
+
676
+ onMount(() => {
677
+ ensureRootMouse(props.api.renderer)
678
+ rootMouseHandlers.add(onRootMouseDown)
679
+ onCleanup(() => rootMouseHandlers.delete(onRootMouseDown))
680
+ })
681
+
682
+ const appendQuery = (input: string) => {
683
+ if (input === "\b") setQuery((value) => value.slice(0, -1))
684
+ else setQuery((value) => value + input)
685
+ }
686
+
687
+ createEffect(() => {
688
+ disposeSearch?.()
689
+ disposeSearch = undefined
690
+ if (!searching()) return
691
+ disposeSearch = props.api.keymap.registerLayer({
692
+ mode: "base",
693
+ priority: 20,
694
+ bindings: searchBindings(appendQuery, () => setSearching(false)),
695
+ })
696
+ })
697
+
698
+ createEffect(() => {
699
+ disposeHoverSpace?.()
700
+ disposeHoverSpace = undefined
701
+ const id = hovered()
702
+ if (!id) return
703
+ const entry = entries().find((e) => e.id === id)
704
+ if (!entry) return
705
+ disposeHoverSpace = props.api.keymap.registerLayer({
706
+ mode: "base",
707
+ priority: 20,
708
+ bindings: [
709
+ {
710
+ key: "space",
711
+ desc: "Preview session transcript",
712
+ preventDefault: true,
713
+ cmd: () => preview.open(entry),
714
+ },
715
+ ],
716
+ })
717
+ })
718
+
719
+ onCleanup(() => disposeSearch?.())
720
+ onCleanup(() => disposeHoverSpace?.())
721
+
722
+ onMount(() => {
723
+ let alive = true
724
+ const load = async () => {
725
+ try {
726
+ const all = await fetchEntries(props.api)
727
+ if (alive) setEntries(all)
728
+ } catch {}
729
+ }
730
+ void load()
731
+ const clock = setInterval(() => void load(), POLL_MS)
732
+ onCleanup(() => {
733
+ alive = false
734
+ clearInterval(clock)
735
+ })
736
+ })
737
+
738
+ const visible = createMemo(() => {
739
+ const q = query().trim().toLowerCase()
740
+ const list = q
741
+ ? entries().filter(
742
+ (entry) => entry.title.toLowerCase().includes(q) || entry.dir.toLowerCase().includes(q),
743
+ )
744
+ : entries()
745
+ return list.slice(0, HOME_SESSION_LIMIT)
746
+ })
747
+
748
+ return (
749
+ <box flexDirection="column" width="100%" maxWidth={72} paddingLeft={1} paddingRight={1} paddingTop={1} gap={1}>
750
+ <Show when={entries().length > 0}>
751
+ <box flexDirection="row" justifyContent="space-between" gap={2}>
752
+ <text style={{ fg: theme().textMuted }}>Recent sessions</text>
753
+ <box
754
+ flexDirection="row"
755
+ gap={1}
756
+ paddingLeft={1}
757
+ paddingRight={1}
758
+ backgroundColor={searching() ? theme().backgroundElement : RGBA.fromInts(0, 0, 0, 0)}
759
+ onMouseDown={() => {
760
+ insideSearchBox = true
761
+ setSearching(true)
762
+ }}
763
+ >
764
+ <box width={24}>
765
+ <text
766
+ flexGrow={1}
767
+ flexShrink={1}
768
+ overflow="hidden"
769
+ wrapMode="none"
770
+ style={{ fg: query() ? theme().text : theme().textMuted }}
771
+ >
772
+ {query() || "search…"}
773
+ {searching() ? "▏" : ""}
774
+ </text>
775
+ </box>
776
+ <Show when={query()}>
777
+ <text flexShrink={0} style={{ fg: theme().textMuted }} onMouseDown={() => setQuery("")}>
778
+ ✕
779
+ </text>
780
+ </Show>
781
+ </box>
782
+ </box>
783
+ <For each={visible()}>
784
+ {(entry) => (
785
+ <box
786
+ flexDirection="row"
787
+ gap={1}
788
+ backgroundColor={entry.id === hovered() ? theme().backgroundElement : RGBA.fromInts(0, 0, 0, 0)}
789
+ onMouseOver={() => setHovered(entry.id)}
790
+ onMouseOut={() => setHovered(undefined)}
791
+ onMouseDown={() => props.api.route.navigate("session", { sessionID: entry.id })}
792
+ >
793
+ <text flexShrink={0} style={{ fg: theme().textMuted }}>
794
+ ○
795
+ </text>
796
+ <text flexGrow={1} flexShrink={1} overflow="hidden" wrapMode="none" style={{ fg: theme().text }}>
797
+ {truncate(entry.title, 52)}
798
+ </text>
799
+ <text flexShrink={0} style={{ fg: theme().textMuted }}>
800
+ {prettyDir(entry.dir, home)} · {ago(entry.updated)}
801
+ </text>
802
+ </box>
803
+ )}
804
+ </For>
805
+ <Show when={visible().length === 0}>
806
+ <text style={{ fg: theme().textMuted }}>No matching sessions</text>
807
+ </Show>
808
+ </Show>
809
+ </box>
810
+ )
811
+ }
812
+
813
+ const tui: TuiPlugin = async (api) => {
814
+ api.slots.register({
815
+ order: 100,
816
+ slots: {
817
+ sidebar_content() {
818
+ return <SidebarSessions api={api} />
819
+ },
820
+ home_bottom() {
821
+ return <HomeSessions api={api} />
822
+ },
823
+ },
824
+ })
825
+
826
+ const openPicker = async () => {
827
+ if (api.mode.current() !== BASE_MODE) return
828
+ if (api.renderer.currentFocusedEditor === null) return
829
+
830
+ let entries: Entry[]
831
+ try {
832
+ entries = await fetchEntries(api)
833
+ } catch {
834
+ api.ui.toast({ message: "Could not load sessions", variant: "error" })
835
+ return
836
+ }
837
+ if (entries.length === 0) {
838
+ api.ui.toast({ message: "No sessions found", variant: "warning" })
839
+ return
840
+ }
841
+
842
+ const [previewText, setPreviewText] = createSignal("")
843
+ const [showPreview, setShowPreview] = createSignal(false)
844
+ const [query, setQuery] = createSignal("")
845
+ const [cursor, setCursor] = createSignal(0)
846
+ const [searchIndex, setSearchIndex] = createSignal<Map<string, string>>(new Map())
847
+ void buildSearchIndex(api, entries).then(setSearchIndex)
848
+ let pickerInput: { value: string; isDestroyed?: boolean } | undefined
849
+
850
+ type DialogRow = { kind: "group"; label: string; count: number } | { kind: "item"; entry: Entry }
851
+
852
+ const matchedGroups = createMemo(() => {
853
+ const q = query().trim().toLowerCase()
854
+ const index = searchIndex()
855
+ const matched = entries.filter((entry) => {
856
+ if (!q) return true
857
+ const haystack = index.get(entry.id) ?? `${entry.title} ${entry.dir}`.toLowerCase()
858
+ return haystack.includes(q)
859
+ })
860
+ const byGroup = new Map<string, Entry[]>()
861
+ for (const entry of matched) {
862
+ const list = byGroup.get(entry.group) ?? []
863
+ list.push(entry)
864
+ byGroup.set(entry.group, list)
865
+ }
866
+ return [...byGroup.entries()].map(([name, list]) => ({ name, list }))
867
+ })
868
+
869
+ const selectableEntries = createMemo(() => matchedGroups().flatMap((g) => g.list))
870
+
871
+ createEffect(() => {
872
+ const count = selectableEntries().length
873
+ if (cursor() >= count) setCursor(Math.max(0, count - 1))
874
+ })
875
+
876
+ const pickerRows = createMemo<DialogRow[]>(() => {
877
+ const flat: DialogRow[] = []
878
+ for (const group of matchedGroups()) {
879
+ flat.push({ kind: "group", label: group.name, count: group.list.length })
880
+ for (const entry of group.list) flat.push({ kind: "item", entry })
881
+ }
882
+ return flat
883
+ })
884
+
885
+ const termHeight = api.renderer.height
886
+ const listHeight = Math.max(6, Math.floor(termHeight / 2) - 12)
887
+
888
+ const visiblePickerRows = createMemo<DialogRow[]>(() => {
889
+ const flat = pickerRows()
890
+ const windowSize = listHeight
891
+ const currentID = selectableEntries()[cursor()]?.id
892
+ let pos = currentID
893
+ ? flat.findIndex((row) => row.kind === "item" && row.entry.id === currentID)
894
+ : 0
895
+ if (pos < 0) pos = 0
896
+ let start = Math.max(0, pos - Math.floor(windowSize / 2))
897
+ start = Math.min(start, Math.max(0, flat.length - windowSize))
898
+ return flat.slice(start, start + windowSize)
899
+ })
900
+
901
+ const previewCache = new Map<string, string>()
902
+ let previewTimer: ReturnType<typeof setTimeout> | undefined
903
+
904
+ const previewID = () => selectableEntries()[cursor()]?.id
905
+
906
+ const loadPreview = async (id: string | undefined) => {
907
+ if (!id) return
908
+ const cached = previewCache.get(id)
909
+ if (cached !== undefined) {
910
+ setPreviewText(cached)
911
+ return
912
+ }
913
+ try {
914
+ const text = await fetchTranscriptText(api, id)
915
+ previewCache.set(id, text)
916
+ if (showPreview() && previewID() === id) setPreviewText(text)
917
+ } catch {}
918
+ }
919
+
920
+ const currentSessionID = () => {
921
+ const route = api.route.current
922
+ if (route.name !== "session") return undefined
923
+ const params = route.params
924
+ return params && typeof params.sessionID === "string" ? params.sessionID : undefined
925
+ }
926
+
927
+ const close = () => {
928
+ cleanup()
929
+ api.ui.dialog.clear()
930
+ }
931
+
932
+ createEffect(() => {
933
+ const showing = showPreview()
934
+ const id = previewID()
935
+ if (previewTimer) clearTimeout(previewTimer)
936
+ if (!showing || !id) return
937
+ const cached = previewCache.get(id)
938
+ if (cached !== undefined) {
939
+ setPreviewText(cached)
940
+ return
941
+ }
942
+ setPreviewText("")
943
+ previewTimer = setTimeout(() => void loadPreview(id), 120)
944
+ })
945
+
946
+ const togglePreview = () => setShowPreview((value) => !value)
947
+
948
+ const moveCursor = (delta: number) => {
949
+ const count = selectableEntries().length
950
+ if (count === 0) return
951
+ setCursor((value) => Math.max(0, Math.min(count - 1, value + delta)))
952
+ }
953
+
954
+ const choose = (entry?: Entry) => {
955
+ const target = entry ?? selectableEntries()[cursor()]
956
+ if (!target) return
957
+ cleanup()
958
+ api.ui.dialog.clear()
959
+ api.route.navigate("session", { sessionID: target.id })
960
+ }
961
+
962
+ const disposeNav = api.keymap.registerLayer({
963
+ bindings: [
964
+ { key: "up", desc: "Previous session", preventDefault: true, cmd: () => moveCursor(-1) },
965
+ { key: "down", desc: "Next session", preventDefault: true, cmd: () => moveCursor(1) },
966
+ { key: "pageup", desc: "Page up", preventDefault: true, cmd: () => moveCursor(-10) },
967
+ { key: "pagedown", desc: "Page down", preventDefault: true, cmd: () => moveCursor(10) },
968
+ { key: "home", desc: "First session", preventDefault: true, cmd: () => setCursor(0) },
969
+ {
970
+ key: "end",
971
+ desc: "Last session",
972
+ preventDefault: true,
973
+ cmd: () => setCursor(selectableEntries().length - 1),
974
+ },
975
+ { key: "enter", desc: "Open session", preventDefault: true, cmd: () => choose() },
976
+ {
977
+ key: "escape",
978
+ desc: "Close",
979
+ preventDefault: true,
980
+ cmd: () => {
981
+ cleanup()
982
+ api.ui.dialog.clear()
983
+ },
984
+ },
985
+ {
986
+ key: "ctrl+g",
987
+ desc: "Close",
988
+ preventDefault: true,
989
+ cmd: () => {
990
+ cleanup()
991
+ api.ui.dialog.clear()
992
+ },
993
+ },
994
+ ],
995
+ })
996
+
997
+ const cleanup = () => {
998
+ disposeNav()
999
+ if (previewTimer) clearTimeout(previewTimer)
1000
+ }
1001
+
1002
+ const onSearchInput = (value: string) => {
1003
+ if (query() === "" && value.trim() === "" && value.length > 0) {
1004
+ if (pickerInput && !pickerInput.isDestroyed) pickerInput.value = ""
1005
+ setQuery("")
1006
+ togglePreview()
1007
+ return
1008
+ }
1009
+ setQuery(value)
1010
+ }
1011
+
1012
+ api.ui.dialog.replace(
1013
+ () => (
1014
+ <box flexDirection="column" flexGrow={1} paddingBottom={1} gap={1}>
1015
+ <box paddingLeft={4} paddingRight={4}>
1016
+ <box flexDirection="row" justifyContent="space-between">
1017
+ <text attributes={TextAttributes.BOLD}>
1018
+ Sessions <span style={{ fg: api.theme.current.textMuted }}>({selectableEntries().length})</span>
1019
+ </text>
1020
+ <text style={{ fg: api.theme.current.textMuted }} onMouseUp={close}>
1021
+ esc
1022
+ </text>
1023
+ </box>
1024
+ <box paddingTop={1}>
1025
+ <input
1026
+ ref={(r) => (pickerInput = r)}
1027
+ focused
1028
+ placeholder="Search title, directory, transcript…"
1029
+ value={query()}
1030
+ placeholderColor={api.theme.current.textMuted}
1031
+ focusedBackgroundColor={api.theme.current.backgroundPanel}
1032
+ focusedTextColor={api.theme.current.text}
1033
+ cursorColor={api.theme.current.primary}
1034
+ onInput={onSearchInput}
1035
+ onSubmit={() => choose()}
1036
+ onKeyDown={(event) => {
1037
+ if (event.name === "escape") {
1038
+ event.preventDefault()
1039
+ close()
1040
+ }
1041
+ }}
1042
+ />
1043
+ </box>
1044
+ </box>
1045
+ <box
1046
+ flexDirection="column"
1047
+ onMouseScroll={(event: { scroll?: { direction: string } }) => {
1048
+ const direction = event.scroll?.direction
1049
+ if (direction === "down") moveCursor(3)
1050
+ else if (direction === "up") moveCursor(-3)
1051
+ }}
1052
+ >
1053
+ <For each={visiblePickerRows()}>
1054
+ {(row) =>
1055
+ row.kind === "group" ? (
1056
+ <box paddingTop={1} paddingLeft={4}>
1057
+ <text style={{ fg: api.theme.current.accent }} attributes={TextAttributes.BOLD}>
1058
+ {row.label} <span style={{ fg: api.theme.current.textMuted }}>({row.count})</span>
1059
+ </text>
1060
+ </box>
1061
+ ) : (
1062
+ <box
1063
+ flexDirection="row"
1064
+ gap={1}
1065
+ paddingLeft={4}
1066
+ paddingRight={4}
1067
+ backgroundColor={
1068
+ row.entry.id === selectableEntries()[cursor()]?.id
1069
+ ? api.theme.current.primary
1070
+ : RGBA.fromInts(0, 0, 0, 0)
1071
+ }
1072
+ onMouseOver={() => {
1073
+ const i = selectableEntries().findIndex((entry) => entry.id === row.entry.id)
1074
+ if (i >= 0 && i !== cursor()) setCursor(i)
1075
+ }}
1076
+ onMouseDown={() => choose(row.entry)}
1077
+ >
1078
+ <Show when={row.entry.id === currentSessionID()}>
1079
+ <text
1080
+ flexShrink={0}
1081
+ style={{
1082
+ fg:
1083
+ row.entry.id === selectableEntries()[cursor()]?.id
1084
+ ? api.theme.current.selectedListItemText
1085
+ : api.theme.current.primary,
1086
+ }}
1087
+ >
1088
+ ●
1089
+ </text>
1090
+ </Show>
1091
+ <text
1092
+ flexGrow={1}
1093
+ flexShrink={1}
1094
+ overflow="hidden"
1095
+ wrapMode="none"
1096
+ attributes={
1097
+ row.entry.id === selectableEntries()[cursor()]?.id ? TextAttributes.BOLD : undefined
1098
+ }
1099
+ style={{
1100
+ fg:
1101
+ row.entry.id === selectableEntries()[cursor()]?.id
1102
+ ? api.theme.current.selectedListItemText
1103
+ : api.theme.current.text,
1104
+ }}
1105
+ >
1106
+ {truncate(row.entry.title, 61)}
1107
+ </text>
1108
+ <text
1109
+ flexShrink={0}
1110
+ style={{
1111
+ fg:
1112
+ row.entry.id === selectableEntries()[cursor()]?.id
1113
+ ? api.theme.current.selectedListItemText
1114
+ : api.theme.current.textMuted,
1115
+ }}
1116
+ >
1117
+ {prettyDir(row.entry.dir, process.env.HOME ?? "")} · {ago(row.entry.updated)}
1118
+ </text>
1119
+ </box>
1120
+ )
1121
+ }
1122
+ </For>
1123
+ </box>
1124
+ <Show when={showPreview()}>
1125
+ <box flexShrink={0} flexDirection="column" paddingLeft={4} paddingRight={4} height={12}>
1126
+ <text style={{ fg: api.theme.current.textMuted }} attributes={TextAttributes.BOLD}>
1127
+ Preview
1128
+ </text>
1129
+ <scrollbox flexGrow={1} scrollbarOptions={{ visible: false }}>
1130
+ <Show
1131
+ when={previewText()}
1132
+ fallback={<text style={{ fg: api.theme.current.textMuted }}>Loading…</text>}
1133
+ >
1134
+ {(text) =>
1135
+ getMarkdownStyle(api.theme.current) ? (
1136
+ <markdown content={text()} syntaxStyle={getMarkdownStyle(api.theme.current)!} />
1137
+ ) : (
1138
+ <text wrapMode="word">{text()}</text>
1139
+ )
1140
+ }
1141
+ </Show>
1142
+ </scrollbox>
1143
+ </box>
1144
+ </Show>
1145
+ <box paddingLeft={4} paddingRight={4} flexShrink={0}>
1146
+ <text style={{ fg: api.theme.current.textMuted }}>
1147
+ ↑↓ navigate · enter open · space preview · esc close
1148
+ </text>
1149
+ </box>
1150
+ </box>
1151
+ ),
1152
+ () => {
1153
+ cleanup()
1154
+ },
1155
+ )
1156
+ api.ui.dialog.setSize("xlarge")
1157
+ }
1158
+
1159
+ api.command?.register(() => [
1160
+ {
1161
+ title: "Pick session (all directories)",
1162
+ value: "sesh.pick",
1163
+ description: "Switch to any session across all project directories",
1164
+ category: "Sessions",
1165
+ slash: { name: "sesh" },
1166
+ onSelect: () => {
1167
+ void openPicker()
1168
+ },
1169
+ },
1170
+ ])
1171
+
1172
+ api.keymap.registerLayer({
1173
+ bindings: [{ key: "ctrl+o", desc: "Pick session", preventDefault: true, cmd: () => void openPicker() }],
1174
+ })
1175
+ }
1176
+
1177
+ export default { id: "sesh-panel", tui }