opencode-sesh 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +27 -10
- package/CHANGELOG.md +64 -1
- package/README.md +26 -6
- package/bin/sesh +4 -0
- package/bin/sesh-list.sh +12 -9
- package/bin/sesh-pins.sh +55 -0
- package/bin/sesh-prune.sh +175 -0
- package/bin/sesh-shortcuts.sh +3 -1
- package/bin/sesh-waiting.sh +127 -0
- package/bin/sesh.sh +17 -1
- package/install.sh +2 -0
- package/package.json +1 -1
- package/tui/sesh-panel.tsx +404 -96
package/tui/sesh-panel.tsx
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
|
3
3
|
import type { Project, Session } from "@opencode-ai/sdk/v2"
|
|
4
4
|
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
|
|
5
|
-
import { readFile } from "node:fs/promises"
|
|
6
|
-
import {
|
|
5
|
+
import { mkdir, readFile, rename, rmdir, unlink, writeFile } from "node:fs/promises"
|
|
6
|
+
import { dirname, join } from "node:path"
|
|
7
|
+
import { getTreeSitterClient, RGBA, SyntaxStyle, TextAttributes } from "@opentui/core"
|
|
7
8
|
|
|
8
9
|
type ThemeColors = TuiPluginApi["theme"]["current"]
|
|
9
10
|
|
|
@@ -49,21 +50,24 @@ function buildMarkdownStyle(theme: ThemeColors): SyntaxStyle {
|
|
|
49
50
|
],
|
|
50
51
|
style: { foreground: theme.error },
|
|
51
52
|
},
|
|
52
|
-
|
|
53
|
+
// Some opencode themes give Markdown roles the same foreground as body
|
|
54
|
+
// text. Use their semantic accents here so transcript structure remains
|
|
55
|
+
// legible without recoloring the surrounding TUI or ordinary prose.
|
|
56
|
+
{ scope: ["markup.heading"], style: { foreground: theme.accent, bold: true } },
|
|
53
57
|
{
|
|
54
58
|
scope: ["markup.heading.1"],
|
|
55
|
-
style: { foreground: theme.
|
|
59
|
+
style: { foreground: theme.primary, bold: true, underline: true },
|
|
56
60
|
},
|
|
57
61
|
{
|
|
58
62
|
scope: ["markup.heading.2", "markup.heading.3", "markup.heading.4", "markup.heading.5", "markup.heading.6"],
|
|
59
|
-
style: { foreground: theme.
|
|
63
|
+
style: { foreground: theme.accent, bold: true },
|
|
60
64
|
},
|
|
61
|
-
{ scope: ["markup.bold", "markup.strong"], style: { foreground: theme.
|
|
62
|
-
{ scope: ["markup.italic"], style: { foreground: theme.
|
|
63
|
-
{ scope: ["markup.list"], style: { foreground: theme.
|
|
65
|
+
{ scope: ["markup.bold", "markup.strong"], style: { foreground: theme.accent, bold: true } },
|
|
66
|
+
{ scope: ["markup.italic"], style: { foreground: theme.syntaxString, italic: true } },
|
|
67
|
+
{ scope: ["markup.list"], style: { foreground: theme.syntaxKeyword } },
|
|
64
68
|
{ scope: ["markup.quote"], style: { foreground: theme.markdownBlockQuote, italic: true } },
|
|
65
|
-
{ scope: ["markup.raw", "markup.raw.block"], style: { foreground: theme.
|
|
66
|
-
{ scope: ["markup.raw.inline"], style: { foreground: theme.
|
|
69
|
+
{ scope: ["markup.raw", "markup.raw.block"], style: { foreground: theme.syntaxString } },
|
|
70
|
+
{ scope: ["markup.raw.inline"], style: { foreground: theme.syntaxString, background: theme.background } },
|
|
67
71
|
{
|
|
68
72
|
scope: ["markup.link", "markup.link.url", "string.special", "string.special.url"],
|
|
69
73
|
style: { foreground: theme.markdownLink, underline: true },
|
|
@@ -87,6 +91,77 @@ function getMarkdownStyle(theme: ThemeColors): SyntaxStyle | undefined {
|
|
|
87
91
|
}
|
|
88
92
|
}
|
|
89
93
|
|
|
94
|
+
type Pins = { sessions: string[]; directories: string[] }
|
|
95
|
+
const emptyPins = (): Pins => ({ sessions: [], directories: [] })
|
|
96
|
+
const pinsPath = () =>
|
|
97
|
+
process.env.SESH_PINS_FILE ?? join(process.env.XDG_DATA_HOME ?? join(process.env.HOME ?? "", ".local/share"), "sesh/pins.json")
|
|
98
|
+
|
|
99
|
+
async function readPins(): Promise<Pins> {
|
|
100
|
+
try {
|
|
101
|
+
const data = JSON.parse(await readFile(pinsPath(), "utf8")) as Partial<Pins>
|
|
102
|
+
return {
|
|
103
|
+
sessions: Array.isArray(data.sessions) ? data.sessions.filter((id): id is string => typeof id === "string") : [],
|
|
104
|
+
directories: Array.isArray(data.directories) ? data.directories.filter((dir): dir is string => typeof dir === "string") : [],
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
return emptyPins()
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function togglePin(kind: keyof Pins, value: string): Promise<Pins> {
|
|
112
|
+
const file = pinsPath()
|
|
113
|
+
await mkdir(dirname(file), { recursive: true })
|
|
114
|
+
const lock = `${file}.lock`
|
|
115
|
+
let locked = false
|
|
116
|
+
for (let attempt = 0; attempt < 50; attempt++) {
|
|
117
|
+
try {
|
|
118
|
+
await mkdir(lock)
|
|
119
|
+
locked = true
|
|
120
|
+
break
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error
|
|
123
|
+
try {
|
|
124
|
+
const owner = Number((await readFile(join(lock, "pid"), "utf8")).trim())
|
|
125
|
+
if (Number.isSafeInteger(owner) && owner > 0) {
|
|
126
|
+
try {
|
|
127
|
+
process.kill(owner, 0)
|
|
128
|
+
} catch (check) {
|
|
129
|
+
if ((check as NodeJS.ErrnoException).code === "ESRCH") {
|
|
130
|
+
await unlink(join(lock, "pid")).catch(() => {})
|
|
131
|
+
await rmdir(lock).catch(() => {})
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
} catch {}
|
|
136
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (!locked) throw new Error("pins are busy")
|
|
140
|
+
const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}`
|
|
141
|
+
try {
|
|
142
|
+
await writeFile(join(lock, "pid"), `${process.pid}\n`)
|
|
143
|
+
const next = await readPins()
|
|
144
|
+
next[kind] = next[kind].includes(value) ? next[kind].filter((item) => item !== value) : [...next[kind], value]
|
|
145
|
+
await writeFile(tmp, JSON.stringify(next) + "\n", { mode: 0o600 })
|
|
146
|
+
await rename(tmp, file)
|
|
147
|
+
return next
|
|
148
|
+
} finally {
|
|
149
|
+
await unlink(tmp).catch(() => {})
|
|
150
|
+
await unlink(join(lock, "pid")).catch(() => {})
|
|
151
|
+
await rmdir(lock)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function pinnedFirst(entries: Entry[], pins: Pins): Entry[] {
|
|
156
|
+
const sessions = new Set(pins.sessions)
|
|
157
|
+
const directories = new Set(pins.directories)
|
|
158
|
+
return [...entries].sort((a, b) => {
|
|
159
|
+
const aRank = sessions.has(a.id) ? 2 : directories.has(a.dir) ? 1 : 0
|
|
160
|
+
const bRank = sessions.has(b.id) ? 2 : directories.has(b.dir) ? 1 : 0
|
|
161
|
+
return bRank - aRank || b.updated - a.updated
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
90
165
|
// Sessions panel: an opencode-native session switcher in two parts.
|
|
91
166
|
//
|
|
92
167
|
// 1. Sidebar (always visible): a compact recent-sessions section appended to
|
|
@@ -137,10 +212,9 @@ const SIDEBAR_TITLE_WIDTH = 27
|
|
|
137
212
|
const SIDEBAR_GROUP_WIDTH = 30
|
|
138
213
|
const HOME_TITLE_WIDTH = 30
|
|
139
214
|
|
|
140
|
-
// The sidebar
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
const SIDEBAR_LIMIT = 15
|
|
215
|
+
// The sidebar lists every session (newest first, pins ahead) and fills
|
|
216
|
+
// whatever vertical space it gets: overflow scrolls inside the stretched
|
|
217
|
+
// scrollbox. The picker (ctrl+o) remains the place for transcript search.
|
|
144
218
|
|
|
145
219
|
// Uppercase is included deliberately: a search box that silently drops shifted
|
|
146
220
|
// letters cannot be used for acronyms or paths like README.
|
|
@@ -252,6 +326,68 @@ async function openTranscriptDb(): Promise<any | undefined> {
|
|
|
252
326
|
}
|
|
253
327
|
}
|
|
254
328
|
|
|
329
|
+
// NEEDS_INPUT_SQL: sessions waiting on the user (shared heuristic — keep in
|
|
330
|
+
// sync with bin/sesh-waiting.sh). A session waits when it is not archived,
|
|
331
|
+
// is not a fork child, and either has an unanswered `question` tool part or
|
|
332
|
+
// a `tool` part still marked running older than STUCK_AFTER_MS (awaiting
|
|
333
|
+
// approval, or orphaned by a dead server). Server-independent on purpose:
|
|
334
|
+
// the TUI sync layer only sees permission/question state for sessions owned
|
|
335
|
+
// by the current server, which is invisible cross-project.
|
|
336
|
+
const STUCK_AFTER_MS = 600000
|
|
337
|
+
const NEEDS_INPUT_SQL = (stuckBefore: number) => `
|
|
338
|
+
SELECT s.id AS id,
|
|
339
|
+
CASE WHEN EXISTS (
|
|
340
|
+
SELECT 1 FROM part p
|
|
341
|
+
WHERE p.session_id = s.id
|
|
342
|
+
AND json_extract(p.data, '$.type') = 'tool'
|
|
343
|
+
AND json_extract(p.data, '$.tool') = 'question'
|
|
344
|
+
AND COALESCE(json_extract(p.data, '$.state.status'), 'pending') != 'completed'
|
|
345
|
+
AND NOT EXISTS (
|
|
346
|
+
SELECT 1 FROM message m
|
|
347
|
+
WHERE m.session_id = s.id
|
|
348
|
+
AND json_extract(m.data, '$.role') = 'user'
|
|
349
|
+
AND m.time_created > p.time_created)
|
|
350
|
+
) THEN 'question' ELSE 'stuck' END AS reason
|
|
351
|
+
FROM session s
|
|
352
|
+
WHERE COALESCE(s.time_archived, 0) = 0
|
|
353
|
+
AND s.parent_id IS NULL
|
|
354
|
+
AND (EXISTS (
|
|
355
|
+
SELECT 1 FROM part p
|
|
356
|
+
WHERE p.session_id = s.id
|
|
357
|
+
AND json_extract(p.data, '$.type') = 'tool'
|
|
358
|
+
AND json_extract(p.data, '$.tool') = 'question'
|
|
359
|
+
AND COALESCE(json_extract(p.data, '$.state.status'), 'pending') != 'completed'
|
|
360
|
+
AND NOT EXISTS (
|
|
361
|
+
SELECT 1 FROM message m
|
|
362
|
+
WHERE m.session_id = s.id
|
|
363
|
+
AND json_extract(m.data, '$.role') = 'user'
|
|
364
|
+
AND m.time_created > p.time_created)
|
|
365
|
+
) OR EXISTS (
|
|
366
|
+
SELECT 1 FROM part p2
|
|
367
|
+
WHERE p2.session_id = s.id
|
|
368
|
+
AND json_extract(p2.data, '$.type') = 'tool'
|
|
369
|
+
AND json_extract(p2.data, '$.state.status') = 'running'
|
|
370
|
+
AND p2.time_created < ${stuckBefore}))
|
|
371
|
+
ORDER BY s.time_updated DESC`
|
|
372
|
+
|
|
373
|
+
async function queryWaitingIds(db: any): Promise<Map<string, string>> {
|
|
374
|
+
const rows = (db.query(NEEDS_INPUT_SQL(Date.now() - STUCK_AFTER_MS)).all() ?? []) as {
|
|
375
|
+
id: unknown
|
|
376
|
+
reason: unknown
|
|
377
|
+
}[]
|
|
378
|
+
const out = new Map<string, string>()
|
|
379
|
+
for (const row of rows) {
|
|
380
|
+
if (
|
|
381
|
+
typeof row?.id === "string" &&
|
|
382
|
+
SESSION_ID_PATTERN.test(row.id) &&
|
|
383
|
+
(row.reason === "question" || row.reason === "stuck")
|
|
384
|
+
) {
|
|
385
|
+
out.set(row.id, row.reason)
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return out
|
|
389
|
+
}
|
|
390
|
+
|
|
255
391
|
function addTranscript(index: Map<string, string>, sid: string, data: string): void {
|
|
256
392
|
try {
|
|
257
393
|
const part = JSON.parse(data) as { type?: unknown; text?: unknown }
|
|
@@ -269,7 +405,7 @@ function addTranscript(index: Map<string, string>, sid: string, data: string): v
|
|
|
269
405
|
async function buildSearchIndex(
|
|
270
406
|
api: TuiPluginApi,
|
|
271
407
|
entries: Entry[],
|
|
272
|
-
onProgress?: (progress: IndexProgress) => void,
|
|
408
|
+
onProgress?: (progress: IndexProgress, index: Map<string, string>) => void,
|
|
273
409
|
): Promise<Map<string, string>> {
|
|
274
410
|
const index = new Map<string, string>()
|
|
275
411
|
for (const entry of entries) {
|
|
@@ -279,7 +415,7 @@ async function buildSearchIndex(
|
|
|
279
415
|
const remaining = new Set(ids)
|
|
280
416
|
const total = ids.length
|
|
281
417
|
let indexed = 0
|
|
282
|
-
const report = (complete: boolean) => onProgress?.({ indexed, total, complete })
|
|
418
|
+
const report = (complete: boolean) => onProgress?.({ indexed, total, complete }, index)
|
|
283
419
|
report(false)
|
|
284
420
|
|
|
285
421
|
const db = await openTranscriptDb()
|
|
@@ -309,7 +445,7 @@ async function buildSearchIndex(
|
|
|
309
445
|
if (remaining.size > 0) {
|
|
310
446
|
await buildSearchIndexRemote(api, [...remaining], index, () => {
|
|
311
447
|
indexed += 1
|
|
312
|
-
report(false)
|
|
448
|
+
if (indexed % TRANSCRIPT_BATCH === 0) report(false)
|
|
313
449
|
})
|
|
314
450
|
}
|
|
315
451
|
report(true)
|
|
@@ -446,7 +582,9 @@ function createTranscriptPreview(api: TuiPluginApi) {
|
|
|
446
582
|
}
|
|
447
583
|
setPreviewID(entry.id)
|
|
448
584
|
setPreviewText("Loading…")
|
|
449
|
-
void fetchTranscriptText(api, entry.id).then(
|
|
585
|
+
void fetchTranscriptText(api, entry.id).then((text) => {
|
|
586
|
+
if (previewID() === entry.id) setPreviewText(text)
|
|
587
|
+
})
|
|
450
588
|
api.ui.dialog.replace(
|
|
451
589
|
() => (
|
|
452
590
|
<box flexDirection="column" paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
|
|
@@ -464,7 +602,11 @@ function createTranscriptPreview(api: TuiPluginApi) {
|
|
|
464
602
|
maxHeight={Math.max(6, Math.floor(api.renderer.height / 2))}
|
|
465
603
|
>
|
|
466
604
|
{getMarkdownStyle(api.theme.current) ? (
|
|
467
|
-
<markdown
|
|
605
|
+
<markdown
|
|
606
|
+
content={previewText()}
|
|
607
|
+
syntaxStyle={getMarkdownStyle(api.theme.current)!}
|
|
608
|
+
treeSitterClient={getTreeSitterClient()}
|
|
609
|
+
/>
|
|
468
610
|
) : (
|
|
469
611
|
<text wrapMode="word">{previewText()}</text>
|
|
470
612
|
)}
|
|
@@ -500,10 +642,13 @@ type TreeRow =
|
|
|
500
642
|
| { kind: "group"; dir: string; label: string; count: number }
|
|
501
643
|
| { kind: "item"; entry: Entry; last: boolean }
|
|
502
644
|
|
|
503
|
-
|
|
645
|
+
type PinProps = { pins: () => Pins; onTogglePin: (kind: keyof Pins, value: string) => void; refreshPins: () => void }
|
|
646
|
+
|
|
647
|
+
function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
504
648
|
const theme = () => props.api.theme.current
|
|
505
649
|
const home = process.env.HOME ?? ""
|
|
506
650
|
const [entries, setEntries] = createSignal<Entry[]>([])
|
|
651
|
+
const [sectionCollapsed, setSectionCollapsed] = createSignal(false)
|
|
507
652
|
const [collapsed, setCollapsed] = createSignal<Record<string, boolean>>({})
|
|
508
653
|
const [hovered, setHovered] = createSignal<string>()
|
|
509
654
|
const preview = createTranscriptPreview(props.api)
|
|
@@ -514,6 +659,7 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
514
659
|
const [navActive, setNavActive] = createSignal(false)
|
|
515
660
|
const [cursor, setCursor] = createSignal(0)
|
|
516
661
|
const [pendingDelete, setPendingDelete] = createSignal<string>()
|
|
662
|
+
const [waiting, setWaiting] = createSignal<Map<string, string>>(new Map())
|
|
517
663
|
let confirmTimer: ReturnType<typeof setTimeout> | undefined
|
|
518
664
|
const currentID = createMemo(() => {
|
|
519
665
|
const route = props.api.route.current
|
|
@@ -525,6 +671,7 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
525
671
|
onMount(() => {
|
|
526
672
|
let alive = true
|
|
527
673
|
const load = async () => {
|
|
674
|
+
props.refreshPins()
|
|
528
675
|
try {
|
|
529
676
|
const { entries: all } = await fetchEntries(props.api)
|
|
530
677
|
if (alive) {
|
|
@@ -536,6 +683,19 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
536
683
|
// say so when there is nothing at all to show
|
|
537
684
|
if (alive && entries().length === 0) setLoadFailed(true)
|
|
538
685
|
}
|
|
686
|
+
try {
|
|
687
|
+
const db = await openTranscriptDb()
|
|
688
|
+
if (db) {
|
|
689
|
+
try {
|
|
690
|
+
const ids = await queryWaitingIds(db)
|
|
691
|
+
if (alive) setWaiting(ids)
|
|
692
|
+
} finally {
|
|
693
|
+
db.close()
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
} catch {
|
|
697
|
+
// leave the previous waiting set in place rather than flashing
|
|
698
|
+
}
|
|
539
699
|
}
|
|
540
700
|
void load()
|
|
541
701
|
const clock = setInterval(() => void load(), POLL_MS)
|
|
@@ -547,26 +707,49 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
547
707
|
|
|
548
708
|
const filteredEntries = createMemo(() => {
|
|
549
709
|
const q = query().trim().toLowerCase()
|
|
550
|
-
|
|
551
|
-
|
|
710
|
+
const ordered = pinnedFirst(entries(), props.pins())
|
|
711
|
+
if (!q) return ordered
|
|
712
|
+
return ordered.filter(
|
|
552
713
|
(entry) => entry.title.toLowerCase().includes(q) || entry.dir.toLowerCase().includes(q),
|
|
553
714
|
)
|
|
554
715
|
})
|
|
555
716
|
|
|
556
|
-
const shownEntries = createMemo(() => filteredEntries()
|
|
717
|
+
const shownEntries = createMemo(() => filteredEntries())
|
|
557
718
|
const remaining = createMemo(() => Math.max(0, filteredEntries().length - shownEntries().length))
|
|
558
719
|
|
|
720
|
+
const NEEDS_INPUT_DIR = "__needs_input__"
|
|
721
|
+
const waitingEntries = createMemo(() => {
|
|
722
|
+
const ids = waiting()
|
|
723
|
+
return shownEntries().filter((entry) => ids.has(entry.id))
|
|
724
|
+
})
|
|
725
|
+
const waitingReason = (id: string) => (waiting().get(id) === "question" ? "awaiting answer" : "run stuck")
|
|
726
|
+
|
|
559
727
|
const tree = createMemo<TreeRow[]>(() => {
|
|
728
|
+
const rows: TreeRow[] = []
|
|
729
|
+
const pending = waitingEntries()
|
|
730
|
+
if (pending.length > 0) {
|
|
731
|
+
rows.push({ kind: "group", dir: NEEDS_INPUT_DIR, label: "Needs input", count: pending.length })
|
|
732
|
+
if (!collapsed()[NEEDS_INPUT_DIR]) {
|
|
733
|
+
for (let i = 0; i < pending.length; i++) {
|
|
734
|
+
rows.push({ kind: "item", entry: pending[i], last: i === pending.length - 1 })
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
560
738
|
const groups = new Map<string, Entry[]>()
|
|
561
739
|
for (const entry of shownEntries()) {
|
|
740
|
+
if (waiting().has(entry.id)) continue
|
|
562
741
|
const list = groups.get(entry.dir) ?? []
|
|
563
742
|
list.push(entry)
|
|
564
743
|
groups.set(entry.dir, list)
|
|
565
744
|
}
|
|
566
745
|
const ordered = [...groups.entries()]
|
|
567
746
|
.map(([dir, list]) => ({ dir, list, updated: Math.max(...list.map((entry) => entry.updated)) }))
|
|
568
|
-
.sort((a, b) =>
|
|
569
|
-
|
|
747
|
+
.sort((a, b) => {
|
|
748
|
+
const pins = props.pins()
|
|
749
|
+
const rank = (group: { dir: string; list: Entry[] }) =>
|
|
750
|
+
pins.directories.includes(group.dir) ? 2 : group.list.some((entry) => pins.sessions.includes(entry.id)) ? 1 : 0
|
|
751
|
+
return rank(b) - rank(a) || b.updated - a.updated
|
|
752
|
+
})
|
|
570
753
|
for (const group of ordered) {
|
|
571
754
|
rows.push({ kind: "group", dir: group.dir, label: prettyDir(group.dir, home), count: group.list.length })
|
|
572
755
|
if (collapsed()[group.dir]) continue
|
|
@@ -581,6 +764,16 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
581
764
|
setCollapsed((prev) => ({ ...prev, [dir]: !prev[dir] }))
|
|
582
765
|
}
|
|
583
766
|
|
|
767
|
+
const toggleSection = () => {
|
|
768
|
+
if (!sectionCollapsed()) {
|
|
769
|
+
cancelDelete()
|
|
770
|
+
setNavActive(false)
|
|
771
|
+
setSearching(false)
|
|
772
|
+
setHovered(undefined)
|
|
773
|
+
}
|
|
774
|
+
setSectionCollapsed((value) => !value)
|
|
775
|
+
}
|
|
776
|
+
|
|
584
777
|
// Flat order of the rows a cursor can land on, so keyboard navigation and
|
|
585
778
|
// mouse hover resolve to the same row.
|
|
586
779
|
const itemRows = createMemo(() => tree().flatMap((row) => (row.kind === "item" ? [row.entry] : [])))
|
|
@@ -591,9 +784,14 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
591
784
|
if (count === 0) return
|
|
592
785
|
setCursor((value) => Math.max(0, Math.min(count - 1, value + delta)))
|
|
593
786
|
}
|
|
787
|
+
let previousRows: Entry[] = []
|
|
594
788
|
createEffect(() => {
|
|
595
|
-
const
|
|
596
|
-
|
|
789
|
+
const rows = itemRows()
|
|
790
|
+
const selected = previousRows[cursor()]?.id
|
|
791
|
+
const position = rows.findIndex((entry) => entry.id === selected)
|
|
792
|
+
if (position >= 0 && position !== cursor()) setCursor(position)
|
|
793
|
+
else if (cursor() >= rows.length) setCursor(Math.max(0, rows.length - 1))
|
|
794
|
+
previousRows = rows
|
|
597
795
|
})
|
|
598
796
|
|
|
599
797
|
const sessionStatus = (id: string): "running" | "waiting" | "idle" => {
|
|
@@ -621,8 +819,9 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
621
819
|
}
|
|
622
820
|
|
|
623
821
|
// Deleting is irreversible, so ctrl+x arms the row and a second ctrl+x (or y)
|
|
624
|
-
// commits it.
|
|
625
|
-
//
|
|
822
|
+
// commits it. The arm survives pointer movement — edge-hover flicker must
|
|
823
|
+
// not eat the confirm — and is cleared by the five-second timeout, n, Esc,
|
|
824
|
+
// collapsing the section, or the delete itself.
|
|
626
825
|
const requestDelete = (entry: Entry) => {
|
|
627
826
|
if (deleting()) return
|
|
628
827
|
if (pendingDelete() === entry.id) {
|
|
@@ -634,6 +833,18 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
634
833
|
confirmTimer = setTimeout(() => setPendingDelete(undefined), 5000)
|
|
635
834
|
}
|
|
636
835
|
|
|
836
|
+
// The confirm always targets the visibly armed row ("ctrl+x again"),
|
|
837
|
+
// wherever the pointer or cursor currently is. Returns true when an arm
|
|
838
|
+
// was pending (and is now committed or discarded as stale).
|
|
839
|
+
const confirmArmed = (): boolean => {
|
|
840
|
+
const id = pendingDelete()
|
|
841
|
+
if (!id) return false
|
|
842
|
+
const entry = entries().find((e) => e.id === id)
|
|
843
|
+
if (!entry) cancelDelete()
|
|
844
|
+
else void deleteEntry(entry)
|
|
845
|
+
return true
|
|
846
|
+
}
|
|
847
|
+
|
|
637
848
|
const deleteEntry = async (entry: Entry) => {
|
|
638
849
|
cancelDelete()
|
|
639
850
|
if (deleting()) return
|
|
@@ -710,7 +921,7 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
710
921
|
priority: 20,
|
|
711
922
|
bindings: [
|
|
712
923
|
{
|
|
713
|
-
key: "
|
|
924
|
+
key: "ctrl+p",
|
|
714
925
|
desc: "Preview session transcript",
|
|
715
926
|
preventDefault: true,
|
|
716
927
|
cmd: () => openSidebarPreview(entry),
|
|
@@ -719,8 +930,12 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
719
930
|
key: "ctrl+x",
|
|
720
931
|
desc: "Delete session",
|
|
721
932
|
preventDefault: true,
|
|
722
|
-
cmd: () =>
|
|
933
|
+
cmd: () => {
|
|
934
|
+
if (!confirmArmed()) requestDelete(entry)
|
|
935
|
+
},
|
|
723
936
|
},
|
|
937
|
+
{ key: "option+s", desc: "Pin session", preventDefault: true, cmd: () => props.onTogglePin("sessions", entry.id) },
|
|
938
|
+
{ key: "option+d", desc: "Pin directory", preventDefault: true, cmd: () => props.onTogglePin("directories", entry.dir) },
|
|
724
939
|
{
|
|
725
940
|
key: "/",
|
|
726
941
|
desc: "Search sessions",
|
|
@@ -731,13 +946,6 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
731
946
|
})
|
|
732
947
|
})
|
|
733
948
|
|
|
734
|
-
// Mouse hover must not commit a keyboard-initiated confirm (and vice versa):
|
|
735
|
-
// leaving the armed row abandons the pending delete.
|
|
736
|
-
createEffect(() => {
|
|
737
|
-
const pending = pendingDelete()
|
|
738
|
-
if (pending && !navActive() && pending !== hovered()) cancelDelete()
|
|
739
|
-
})
|
|
740
|
-
|
|
741
949
|
// Keyboard navigation is opt-in (click the "Sessions" heading or the search
|
|
742
950
|
// box) so the sidebar never steals the arrow keys from the main prompt.
|
|
743
951
|
createEffect(() => {
|
|
@@ -757,7 +965,7 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
757
965
|
cmd: () => setCursor(Math.max(0, itemRows().length - 1)),
|
|
758
966
|
},
|
|
759
967
|
{
|
|
760
|
-
key: "
|
|
968
|
+
key: "ctrl+p",
|
|
761
969
|
desc: "Preview session transcript",
|
|
762
970
|
preventDefault: true,
|
|
763
971
|
cmd: () => {
|
|
@@ -770,10 +978,29 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
770
978
|
desc: "Delete session",
|
|
771
979
|
preventDefault: true,
|
|
772
980
|
cmd: () => {
|
|
981
|
+
if (confirmArmed()) return
|
|
773
982
|
const entry = itemRows()[cursor()]
|
|
774
983
|
if (entry) requestDelete(entry)
|
|
775
984
|
},
|
|
776
985
|
},
|
|
986
|
+
{
|
|
987
|
+
key: "option+s",
|
|
988
|
+
desc: "Pin session",
|
|
989
|
+
preventDefault: true,
|
|
990
|
+
cmd: () => {
|
|
991
|
+
const entry = itemRows()[cursor()]
|
|
992
|
+
if (entry) props.onTogglePin("sessions", entry.id)
|
|
993
|
+
},
|
|
994
|
+
},
|
|
995
|
+
{
|
|
996
|
+
key: "option+d",
|
|
997
|
+
desc: "Pin directory",
|
|
998
|
+
preventDefault: true,
|
|
999
|
+
cmd: () => {
|
|
1000
|
+
const entry = itemRows()[cursor()]
|
|
1001
|
+
if (entry) props.onTogglePin("directories", entry.dir)
|
|
1002
|
+
},
|
|
1003
|
+
},
|
|
777
1004
|
{
|
|
778
1005
|
key: "enter",
|
|
779
1006
|
desc: "Open session",
|
|
@@ -799,8 +1026,7 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
799
1026
|
desc: "Confirm delete",
|
|
800
1027
|
preventDefault: true,
|
|
801
1028
|
cmd: () => {
|
|
802
|
-
|
|
803
|
-
if (entry && pendingDelete() === entry.id) void deleteEntry(entry)
|
|
1029
|
+
confirmArmed()
|
|
804
1030
|
},
|
|
805
1031
|
},
|
|
806
1032
|
{ key: "n", desc: "Cancel delete", preventDefault: true, cmd: cancelDelete },
|
|
@@ -818,16 +1044,25 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
818
1044
|
})
|
|
819
1045
|
|
|
820
1046
|
return (
|
|
821
|
-
<box flexDirection="column" paddingRight={1}>
|
|
822
|
-
<box flexDirection="row" gap={1}
|
|
823
|
-
<text
|
|
1047
|
+
<box flexDirection="column" paddingRight={1} flexGrow={1} flexShrink={1}>
|
|
1048
|
+
<box flexDirection="row" justifyContent="space-between" gap={1}>
|
|
1049
|
+
<text flexShrink={1} onMouseDown={() => {
|
|
1050
|
+
if (!sectionCollapsed()) setNavActive((value) => !value)
|
|
1051
|
+
}}>
|
|
824
1052
|
<b>Sessions</b>
|
|
825
1053
|
<span style={{ fg: theme().textMuted }}>
|
|
826
1054
|
{query().trim() ? ` (${filteredEntries().length}/${entries().length})` : ` (${entries().length})`}
|
|
827
1055
|
</span>
|
|
828
|
-
<span style={{ fg:
|
|
1056
|
+
<span style={{ fg: theme().accent }}>{navActive() ? " · nav" : ""}</span>
|
|
1057
|
+
<Show when={waitingEntries().length > 0}>
|
|
1058
|
+
<span style={{ fg: theme().warning }}> · {waitingEntries().length} need input</span>
|
|
1059
|
+
</Show>
|
|
1060
|
+
</text>
|
|
1061
|
+
<text flexShrink={0} style={{ fg: theme().textMuted }} onMouseDown={toggleSection}>
|
|
1062
|
+
{sectionCollapsed() ? "▸ show" : "▾ hide"}
|
|
829
1063
|
</text>
|
|
830
1064
|
</box>
|
|
1065
|
+
<Show when={!sectionCollapsed()}>
|
|
831
1066
|
<Show
|
|
832
1067
|
when={entries().length > 0}
|
|
833
1068
|
fallback={
|
|
@@ -864,6 +1099,8 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
864
1099
|
</Show>
|
|
865
1100
|
</box>
|
|
866
1101
|
<scrollbox
|
|
1102
|
+
flexGrow={1}
|
|
1103
|
+
flexShrink={1}
|
|
867
1104
|
verticalScrollbarOptions={{ visible: false }}
|
|
868
1105
|
horizontalScrollbarOptions={{ visible: false }}
|
|
869
1106
|
>
|
|
@@ -873,7 +1110,7 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
873
1110
|
<box flexDirection="row" paddingTop={1} onMouseDown={() => toggleGroup(row.dir)}>
|
|
874
1111
|
<text flexGrow={1} flexShrink={1} wrapMode="none">
|
|
875
1112
|
<span style={{ fg: theme().textMuted }}>{collapsed()[row.dir] ? "▸ " : "▾ "}</span>
|
|
876
|
-
<b>{truncate(row.label, SIDEBAR_GROUP_WIDTH)}</b>
|
|
1113
|
+
<b>{props.pins().directories.includes(row.dir) ? "★ " : ""}{truncate(row.label, SIDEBAR_GROUP_WIDTH - (props.pins().directories.includes(row.dir) ? 2 : 0))}</b>
|
|
877
1114
|
</text>
|
|
878
1115
|
<text flexShrink={0} style={{ fg: theme().textMuted }}>
|
|
879
1116
|
{" "}({row.count})
|
|
@@ -890,7 +1127,16 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
890
1127
|
? theme().backgroundElement
|
|
891
1128
|
: RGBA.fromInts(0, 0, 0, 0)
|
|
892
1129
|
}
|
|
893
|
-
onMouseOver={() =>
|
|
1130
|
+
onMouseOver={() => {
|
|
1131
|
+
setHovered(row.entry.id)
|
|
1132
|
+
// The keyboard layer wins over the hover layer while nav is
|
|
1133
|
+
// active. Keep its cursor on the hovered row so Option-S/Option-D
|
|
1134
|
+
// cannot silently pin a different (often current) session.
|
|
1135
|
+
if (navActive()) {
|
|
1136
|
+
const index = itemRows().findIndex((entry) => entry.id === row.entry.id)
|
|
1137
|
+
if (index >= 0) setCursor(index)
|
|
1138
|
+
}
|
|
1139
|
+
}}
|
|
894
1140
|
onMouseOut={() => setHovered(undefined)}
|
|
895
1141
|
onMouseDown={() => props.api.route.navigate("session", { sessionID: row.entry.id })}
|
|
896
1142
|
>
|
|
@@ -909,13 +1155,21 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
909
1155
|
</span>
|
|
910
1156
|
</text>
|
|
911
1157
|
<Highlighted
|
|
912
|
-
text={truncate(row.entry.title, SIDEBAR_TITLE_WIDTH)}
|
|
1158
|
+
text={`${props.pins().sessions.includes(row.entry.id) ? "★ " : ""}${truncate(row.entry.title, SIDEBAR_TITLE_WIDTH - (props.pins().sessions.includes(row.entry.id) ? 2 : 0))}`}
|
|
913
1159
|
query={query()}
|
|
914
1160
|
color={isPendingDelete(row.entry.id) ? theme().error : theme().text}
|
|
915
1161
|
matchColor={theme().warning}
|
|
916
1162
|
/>
|
|
917
1163
|
<text flexShrink={0} style={{ fg: isPendingDelete(row.entry.id) ? theme().error : theme().textMuted }}>
|
|
918
|
-
{isPendingDelete(row.entry.id) ?
|
|
1164
|
+
{isPendingDelete(row.entry.id) ? (
|
|
1165
|
+
"ctrl+x again"
|
|
1166
|
+
) : waiting().has(row.entry.id) ? (
|
|
1167
|
+
<span>
|
|
1168
|
+
{ago(row.entry.updated)} · <span style={{ fg: theme().warning }}>{waitingReason(row.entry.id)}</span>
|
|
1169
|
+
</span>
|
|
1170
|
+
) : (
|
|
1171
|
+
ago(row.entry.updated)
|
|
1172
|
+
)}
|
|
919
1173
|
</text>
|
|
920
1174
|
</box>
|
|
921
1175
|
)
|
|
@@ -925,19 +1179,23 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
925
1179
|
<Show when={remaining() > 0}>
|
|
926
1180
|
<text style={{ fg: theme().textMuted }}>{`… ${remaining()} more · ctrl+o for all`}</text>
|
|
927
1181
|
</Show>
|
|
1182
|
+
<Show when={query().trim() && filteredEntries().length === 0}>
|
|
1183
|
+
<text style={{ fg: theme().textMuted }}>No title or directory matches · ctrl+o to search transcripts</text>
|
|
1184
|
+
</Show>
|
|
928
1185
|
<Show when={navActive()}>
|
|
929
1186
|
<box paddingTop={1}>
|
|
930
|
-
<text style={{ fg: theme().textMuted }}>↑↓ move · enter open ·
|
|
1187
|
+
<text style={{ fg: theme().textMuted }}>↑↓ move · enter open · ctrl+p preview · option+s/d pin · ctrl+x delete · esc done</text>
|
|
931
1188
|
</box>
|
|
932
1189
|
</Show>
|
|
933
1190
|
</Show>
|
|
1191
|
+
</Show>
|
|
934
1192
|
</box>
|
|
935
1193
|
)
|
|
936
1194
|
}
|
|
937
1195
|
|
|
938
1196
|
const HOME_SESSION_LIMIT = 6
|
|
939
1197
|
|
|
940
|
-
function HomeSessions(props: { api: TuiPluginApi }) {
|
|
1198
|
+
function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
941
1199
|
const theme = () => props.api.theme.current
|
|
942
1200
|
const home = process.env.HOME ?? ""
|
|
943
1201
|
const [entries, setEntries] = createSignal<Entry[]>([])
|
|
@@ -992,11 +1250,13 @@ function HomeSessions(props: { api: TuiPluginApi }) {
|
|
|
992
1250
|
priority: 20,
|
|
993
1251
|
bindings: [
|
|
994
1252
|
{
|
|
995
|
-
key: "
|
|
1253
|
+
key: "ctrl+p",
|
|
996
1254
|
desc: "Preview session transcript",
|
|
997
1255
|
preventDefault: true,
|
|
998
1256
|
cmd: () => preview.open(entry),
|
|
999
1257
|
},
|
|
1258
|
+
{ key: "option+s", desc: "Pin session", preventDefault: true, cmd: () => props.onTogglePin("sessions", entry.id) },
|
|
1259
|
+
{ key: "option+d", desc: "Pin directory", preventDefault: true, cmd: () => props.onTogglePin("directories", entry.dir) },
|
|
1000
1260
|
],
|
|
1001
1261
|
})
|
|
1002
1262
|
})
|
|
@@ -1007,6 +1267,7 @@ function HomeSessions(props: { api: TuiPluginApi }) {
|
|
|
1007
1267
|
onMount(() => {
|
|
1008
1268
|
let alive = true
|
|
1009
1269
|
const load = async () => {
|
|
1270
|
+
props.refreshPins()
|
|
1010
1271
|
try {
|
|
1011
1272
|
const { entries: all } = await fetchEntries(props.api)
|
|
1012
1273
|
if (alive) {
|
|
@@ -1032,7 +1293,7 @@ function HomeSessions(props: { api: TuiPluginApi }) {
|
|
|
1032
1293
|
(entry) => entry.title.toLowerCase().includes(q) || entry.dir.toLowerCase().includes(q),
|
|
1033
1294
|
)
|
|
1034
1295
|
: entries()
|
|
1035
|
-
return list.slice(0, HOME_SESSION_LIMIT)
|
|
1296
|
+
return pinnedFirst(list, props.pins()).slice(0, HOME_SESSION_LIMIT)
|
|
1036
1297
|
})
|
|
1037
1298
|
|
|
1038
1299
|
return (
|
|
@@ -1097,19 +1358,19 @@ function HomeSessions(props: { api: TuiPluginApi }) {
|
|
|
1097
1358
|
○
|
|
1098
1359
|
</text>
|
|
1099
1360
|
<Highlighted
|
|
1100
|
-
text={truncate(entry.title, HOME_TITLE_WIDTH)}
|
|
1361
|
+
text={`${props.pins().sessions.includes(entry.id) ? "★ " : ""}${truncate(entry.title, HOME_TITLE_WIDTH - (props.pins().sessions.includes(entry.id) ? 2 : 0))}`}
|
|
1101
1362
|
query={query()}
|
|
1102
1363
|
color={theme().text}
|
|
1103
1364
|
matchColor={theme().warning}
|
|
1104
1365
|
/>
|
|
1105
1366
|
<text flexShrink={0} style={{ fg: theme().textMuted }}>
|
|
1106
|
-
{prettyDir(entry.dir, home)} · {ago(entry.updated)}
|
|
1367
|
+
{props.pins().directories.includes(entry.dir) ? "★ " : ""}{prettyDir(entry.dir, home)} · {ago(entry.updated)}
|
|
1107
1368
|
</text>
|
|
1108
1369
|
</box>
|
|
1109
1370
|
)}
|
|
1110
1371
|
</For>
|
|
1111
1372
|
<Show when={visible().length === 0}>
|
|
1112
|
-
<text style={{ fg: theme().textMuted }}>No
|
|
1373
|
+
<text style={{ fg: theme().textMuted }}>No title or directory matches · ctrl+o to search transcripts</text>
|
|
1113
1374
|
</Show>
|
|
1114
1375
|
</Show>
|
|
1115
1376
|
</box>
|
|
@@ -1117,19 +1378,40 @@ function HomeSessions(props: { api: TuiPluginApi }) {
|
|
|
1117
1378
|
}
|
|
1118
1379
|
|
|
1119
1380
|
const tui: TuiPlugin = async (api) => {
|
|
1381
|
+
const [pins, setPins] = createSignal<Pins>(emptyPins())
|
|
1382
|
+
let pinVersion = 0
|
|
1383
|
+
const refreshPins = () => {
|
|
1384
|
+
const version = pinVersion
|
|
1385
|
+
void readPins().then((latest) => {
|
|
1386
|
+
if (version === pinVersion) setPins(latest)
|
|
1387
|
+
})
|
|
1388
|
+
}
|
|
1389
|
+
refreshPins()
|
|
1390
|
+
const onTogglePin = (kind: keyof Pins, value: string) => {
|
|
1391
|
+
if (!value) return
|
|
1392
|
+
pinVersion += 1
|
|
1393
|
+
void togglePin(kind, value).then(
|
|
1394
|
+
(updated) => {
|
|
1395
|
+
setPins(updated)
|
|
1396
|
+
api.ui.toast({ message: `${updated[kind].includes(value) ? "Pinned" : "Unpinned"} ${kind === "sessions" ? "session" : "directory"}`, variant: "info" })
|
|
1397
|
+
},
|
|
1398
|
+
() => api.ui.toast({ message: "Could not update pins", variant: "error" }),
|
|
1399
|
+
)
|
|
1400
|
+
}
|
|
1120
1401
|
api.slots.register({
|
|
1121
1402
|
order: 100,
|
|
1122
1403
|
slots: {
|
|
1123
1404
|
sidebar_content() {
|
|
1124
|
-
return <SidebarSessions api={api} />
|
|
1405
|
+
return <SidebarSessions api={api} pins={pins} onTogglePin={onTogglePin} refreshPins={refreshPins} />
|
|
1125
1406
|
},
|
|
1126
1407
|
home_bottom() {
|
|
1127
|
-
return <HomeSessions api={api} />
|
|
1408
|
+
return <HomeSessions api={api} pins={pins} onTogglePin={onTogglePin} refreshPins={refreshPins} />
|
|
1128
1409
|
},
|
|
1129
1410
|
},
|
|
1130
1411
|
})
|
|
1131
1412
|
|
|
1132
1413
|
const openPicker = async () => {
|
|
1414
|
+
refreshPins()
|
|
1133
1415
|
if (api.mode.current() !== BASE_MODE) return
|
|
1134
1416
|
if (api.renderer.currentFocusedEditor === null) return
|
|
1135
1417
|
|
|
@@ -1159,16 +1441,20 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1159
1441
|
total: 0,
|
|
1160
1442
|
complete: false,
|
|
1161
1443
|
})
|
|
1162
|
-
void buildSearchIndex(api, entries,
|
|
1163
|
-
|
|
1164
|
-
|
|
1444
|
+
void buildSearchIndex(api, entries, (progress, index) => {
|
|
1445
|
+
setIndexProgress(progress)
|
|
1446
|
+
// Publish a fresh snapshot: the indexing workers mutate their own map,
|
|
1447
|
+
// while Solid needs a new reference to update search results mid-scan.
|
|
1448
|
+
setSearchIndex(new Map(index))
|
|
1449
|
+
})
|
|
1165
1450
|
type DialogRow = { kind: "group"; label: string; count: number } | { kind: "item"; entry: Entry }
|
|
1166
1451
|
|
|
1452
|
+
const orderedEntries = createMemo(() => pinnedFirst(allEntries(), pins()))
|
|
1167
1453
|
const matchedGroups = createMemo(() => {
|
|
1168
1454
|
const q = query().trim().toLowerCase()
|
|
1169
1455
|
const index = searchIndex()
|
|
1170
1456
|
const group = scope()
|
|
1171
|
-
const matched =
|
|
1457
|
+
const matched = orderedEntries().filter((entry) => {
|
|
1172
1458
|
if (group && entry.group !== group) return false
|
|
1173
1459
|
if (!q) return true
|
|
1174
1460
|
const haystack = index.get(entry.id) ?? `${entry.title} ${entry.dir}`.toLowerCase()
|
|
@@ -1180,7 +1466,14 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1180
1466
|
list.push(entry)
|
|
1181
1467
|
byGroup.set(entry.group, list)
|
|
1182
1468
|
}
|
|
1183
|
-
|
|
1469
|
+
const groups = [...byGroup.entries()].map(([name, list]) => ({ name, list }))
|
|
1470
|
+
const currentPins = pins()
|
|
1471
|
+
const rank = (group: (typeof groups)[number]) =>
|
|
1472
|
+
group.list.some((entry) => currentPins.directories.includes(entry.dir)) ? 2
|
|
1473
|
+
: group.list.some((entry) => currentPins.sessions.includes(entry.id)) ? 1 : 0
|
|
1474
|
+
return groups.sort((a, b) =>
|
|
1475
|
+
rank(b) - rank(a) || Math.max(...b.list.map((entry) => entry.updated)) - Math.max(...a.list.map((entry) => entry.updated)),
|
|
1476
|
+
)
|
|
1184
1477
|
})
|
|
1185
1478
|
|
|
1186
1479
|
const selectableEntries = createMemo(() => matchedGroups().flatMap((g) => g.list))
|
|
@@ -1202,9 +1495,14 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1202
1495
|
return bits.join(" · ")
|
|
1203
1496
|
})
|
|
1204
1497
|
|
|
1498
|
+
let previousSelection: Entry[] = []
|
|
1205
1499
|
createEffect(() => {
|
|
1206
|
-
const
|
|
1207
|
-
|
|
1500
|
+
const rows = selectableEntries()
|
|
1501
|
+
const selected = previousSelection[cursor()]?.id
|
|
1502
|
+
const position = rows.findIndex((entry) => entry.id === selected)
|
|
1503
|
+
if (position >= 0 && position !== cursor()) setCursor(position)
|
|
1504
|
+
else if (cursor() >= rows.length) setCursor(Math.max(0, rows.length - 1))
|
|
1505
|
+
previousSelection = rows
|
|
1208
1506
|
})
|
|
1209
1507
|
|
|
1210
1508
|
const pickerRows = createMemo<DialogRow[]>(() => {
|
|
@@ -1273,8 +1571,8 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1273
1571
|
setPreviewText(cached)
|
|
1274
1572
|
return
|
|
1275
1573
|
}
|
|
1276
|
-
//
|
|
1277
|
-
|
|
1574
|
+
// Never show another session's transcript beneath the new selection.
|
|
1575
|
+
setPreviewText("Loading…")
|
|
1278
1576
|
previewTimer = setTimeout(() => void loadPreview(id), 120)
|
|
1279
1577
|
})
|
|
1280
1578
|
|
|
@@ -1375,6 +1673,15 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1375
1673
|
cmd: () => setCursor(selectableEntries().length - 1),
|
|
1376
1674
|
},
|
|
1377
1675
|
{ key: "enter", desc: "Open session", preventDefault: true, cmd: () => choose() },
|
|
1676
|
+
{ key: "ctrl+p", desc: "Toggle transcript preview", preventDefault: true, cmd: togglePreview },
|
|
1677
|
+
{ key: "option+s", desc: "Pin session", preventDefault: true, cmd: () => {
|
|
1678
|
+
const entry = selectableEntries()[cursor()]
|
|
1679
|
+
if (entry) onTogglePin("sessions", entry.id)
|
|
1680
|
+
} },
|
|
1681
|
+
{ key: "option+d", desc: "Pin directory", preventDefault: true, cmd: () => {
|
|
1682
|
+
const entry = selectableEntries()[cursor()]
|
|
1683
|
+
if (entry) onTogglePin("directories", entry.dir)
|
|
1684
|
+
} },
|
|
1378
1685
|
{
|
|
1379
1686
|
key: "ctrl+x",
|
|
1380
1687
|
desc: "Delete session",
|
|
@@ -1425,16 +1732,6 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1425
1732
|
if (previewTimer) clearTimeout(previewTimer)
|
|
1426
1733
|
}
|
|
1427
1734
|
|
|
1428
|
-
const onSearchInput = (value: string) => {
|
|
1429
|
-
if (query() === "" && value.trim() === "" && value.length > 0) {
|
|
1430
|
-
if (pickerInput && !pickerInput.isDestroyed) pickerInput.value = ""
|
|
1431
|
-
setQuery("")
|
|
1432
|
-
togglePreview()
|
|
1433
|
-
return
|
|
1434
|
-
}
|
|
1435
|
-
setQuery(value)
|
|
1436
|
-
}
|
|
1437
|
-
|
|
1438
1735
|
api.ui.dialog.replace(
|
|
1439
1736
|
() => (
|
|
1440
1737
|
<box flexDirection="column" flexGrow={1} paddingBottom={1} gap={1}>
|
|
@@ -1449,7 +1746,6 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1449
1746
|
</box>
|
|
1450
1747
|
<box paddingTop={1}>
|
|
1451
1748
|
<input
|
|
1452
|
-
ref={(r) => (pickerInput = r)}
|
|
1453
1749
|
focused
|
|
1454
1750
|
placeholder="Search title, directory, transcript…"
|
|
1455
1751
|
value={query()}
|
|
@@ -1457,7 +1753,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1457
1753
|
focusedBackgroundColor={api.theme.current.backgroundPanel}
|
|
1458
1754
|
focusedTextColor={api.theme.current.text}
|
|
1459
1755
|
cursorColor={api.theme.current.primary}
|
|
1460
|
-
onInput={
|
|
1756
|
+
onInput={setQuery}
|
|
1461
1757
|
onSubmit={() => choose()}
|
|
1462
1758
|
onKeyDown={(event) => {
|
|
1463
1759
|
if (event.name === "escape") {
|
|
@@ -1486,7 +1782,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1486
1782
|
row.kind === "group" ? (
|
|
1487
1783
|
<box paddingTop={1} paddingLeft={4}>
|
|
1488
1784
|
<text style={{ fg: api.theme.current.accent }} attributes={TextAttributes.BOLD}>
|
|
1489
|
-
{row.label} <span style={{ fg: api.theme.current.textMuted }}>({row.count})</span>
|
|
1785
|
+
{matchedGroups().find((group) => group.name === row.label)?.list.some((entry) => pins().directories.includes(entry.dir)) ? "★ " : ""}{row.label} <span style={{ fg: api.theme.current.textMuted }}>({row.count})</span>
|
|
1490
1786
|
</text>
|
|
1491
1787
|
</box>
|
|
1492
1788
|
) : (
|
|
@@ -1516,7 +1812,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1516
1812
|
</text>
|
|
1517
1813
|
</Show>
|
|
1518
1814
|
<Highlighted
|
|
1519
|
-
text={truncate(row.entry.title, 61)}
|
|
1815
|
+
text={`${pins().sessions.includes(row.entry.id) ? "★ " : ""}${truncate(row.entry.title, pins().sessions.includes(row.entry.id) ? 59 : 61)}`}
|
|
1520
1816
|
query={query()}
|
|
1521
1817
|
bold={row.entry.id === selectableEntries()[cursor()]?.id}
|
|
1522
1818
|
color={
|
|
@@ -1543,7 +1839,12 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1543
1839
|
>
|
|
1544
1840
|
{pendingDelete()?.id === row.entry.id
|
|
1545
1841
|
? "press y to delete"
|
|
1546
|
-
: `${prettyDir(row.entry.dir, process.env.HOME ?? "")} · ${ago(row.entry.updated)}
|
|
1842
|
+
: `${prettyDir(row.entry.dir, process.env.HOME ?? "")} · ${ago(row.entry.updated)}${
|
|
1843
|
+
query().trim() &&
|
|
1844
|
+
!`${row.entry.title} ${row.entry.dir}`.toLowerCase().includes(query().trim().toLowerCase())
|
|
1845
|
+
? " · transcript match"
|
|
1846
|
+
: ""
|
|
1847
|
+
}`}
|
|
1547
1848
|
</text>
|
|
1548
1849
|
</box>
|
|
1549
1850
|
)
|
|
@@ -1552,7 +1853,11 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1552
1853
|
<Show when={selectableEntries().length === 0}>
|
|
1553
1854
|
<box paddingLeft={4} paddingRight={4}>
|
|
1554
1855
|
<text style={{ fg: api.theme.current.textMuted }}>
|
|
1555
|
-
{
|
|
1856
|
+
{!indexProgress().complete
|
|
1857
|
+
? "No matches yet · still searching transcripts…"
|
|
1858
|
+
: scope()
|
|
1859
|
+
? `No matches in ${scope()} · ctrl+g for all projects`
|
|
1860
|
+
: "No sessions match · try another search"}
|
|
1556
1861
|
</text>
|
|
1557
1862
|
</box>
|
|
1558
1863
|
</Show>
|
|
@@ -1560,20 +1865,23 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1560
1865
|
<Show when={showPreview()}>
|
|
1561
1866
|
<box flexShrink={0} flexDirection="column" paddingLeft={4} paddingRight={4} height={12}>
|
|
1562
1867
|
<text style={{ fg: api.theme.current.textMuted }} attributes={TextAttributes.BOLD}>
|
|
1563
|
-
Preview
|
|
1868
|
+
{previewID() ? `Preview · ${truncate(selectableEntries()[cursor()]!.title, 48)}` : "Preview"}
|
|
1564
1869
|
</text>
|
|
1565
1870
|
<scrollbox flexGrow={1} scrollbarOptions={{ visible: false }}>
|
|
1566
|
-
<Show
|
|
1567
|
-
when={previewText()}
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1871
|
+
<Show when={previewID()} fallback={<text style={{ fg: api.theme.current.textMuted }}>Select a session to preview</text>}>
|
|
1872
|
+
<Show when={previewText()} fallback={<text style={{ fg: api.theme.current.textMuted }}>Loading…</text>}>
|
|
1873
|
+
{(text) =>
|
|
1874
|
+
getMarkdownStyle(api.theme.current) ? (
|
|
1875
|
+
<markdown
|
|
1876
|
+
content={text()}
|
|
1877
|
+
syntaxStyle={getMarkdownStyle(api.theme.current)!}
|
|
1878
|
+
treeSitterClient={getTreeSitterClient()}
|
|
1879
|
+
/>
|
|
1880
|
+
) : (
|
|
1881
|
+
<text wrapMode="word">{text()}</text>
|
|
1882
|
+
)
|
|
1883
|
+
}
|
|
1884
|
+
</Show>
|
|
1577
1885
|
</Show>
|
|
1578
1886
|
</scrollbox>
|
|
1579
1887
|
</box>
|
|
@@ -1586,7 +1894,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1586
1894
|
>
|
|
1587
1895
|
{pendingDelete()
|
|
1588
1896
|
? `Delete "${truncate(pendingDelete()!.title, 40)}"? y confirm · n cancel`
|
|
1589
|
-
: "↑↓ navigate · enter open · ctrl+x delete · ctrl+f fork · ctrl+g project ·
|
|
1897
|
+
: "↑↓ navigate · enter open · option+s/d pin · ctrl+x delete · ctrl+f fork · ctrl+g project · ctrl+p preview · esc close"}
|
|
1590
1898
|
</text>
|
|
1591
1899
|
</box>
|
|
1592
1900
|
</box>
|