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.
- package/AGENTS.md +129 -0
- package/CHANGELOG.md +55 -0
- package/LICENSE +21 -0
- package/README.md +207 -0
- package/bin/sesh +26 -0
- package/bin/sesh-delete.sh +66 -0
- package/bin/sesh-list.sh +294 -0
- package/bin/sesh-preview.sh +68 -0
- package/bin/sesh-refresh-worker.sh +162 -0
- package/bin/sesh-shortcuts.sh +17 -0
- package/bin/sesh.sh +150 -0
- package/install.sh +241 -0
- package/opencode/tools/sesh-list.ts +42 -0
- package/package.json +57 -0
- package/themes/glow-dark-clean.json +191 -0
- package/tui/sesh-panel.tsx +1177 -0
|
@@ -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 }
|