opencode-sesh 0.1.7 → 0.2.1
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 +78 -16
- package/CHANGELOG.md +87 -1
- package/README.md +51 -24
- package/bin/sesh-delete.sh +38 -4
- package/bin/sesh-list.sh +68 -17
- package/bin/sesh-pins.sh +55 -0
- package/bin/sesh-shortcuts.sh +6 -3
- package/bin/sesh.sh +52 -16
- package/package.json +6 -4
- package/tui/sesh-panel.tsx +661 -121
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,7 +212,13 @@ const SIDEBAR_TITLE_WIDTH = 27
|
|
|
137
212
|
const SIDEBAR_GROUP_WIDTH = 30
|
|
138
213
|
const HOME_TITLE_WIDTH = 30
|
|
139
214
|
|
|
140
|
-
|
|
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.
|
|
218
|
+
|
|
219
|
+
// Uppercase is included deliberately: a search box that silently drops shifted
|
|
220
|
+
// letters cannot be used for acronyms or paths like README.
|
|
221
|
+
const SEARCH_KEYS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.@/+:,?!()[]".split("")
|
|
141
222
|
|
|
142
223
|
function searchBindings(append: (input: string) => void, exit: () => void) {
|
|
143
224
|
return [
|
|
@@ -262,7 +343,7 @@ function addTranscript(index: Map<string, string>, sid: string, data: string): v
|
|
|
262
343
|
async function buildSearchIndex(
|
|
263
344
|
api: TuiPluginApi,
|
|
264
345
|
entries: Entry[],
|
|
265
|
-
onProgress?: (progress: IndexProgress) => void,
|
|
346
|
+
onProgress?: (progress: IndexProgress, index: Map<string, string>) => void,
|
|
266
347
|
): Promise<Map<string, string>> {
|
|
267
348
|
const index = new Map<string, string>()
|
|
268
349
|
for (const entry of entries) {
|
|
@@ -272,7 +353,7 @@ async function buildSearchIndex(
|
|
|
272
353
|
const remaining = new Set(ids)
|
|
273
354
|
const total = ids.length
|
|
274
355
|
let indexed = 0
|
|
275
|
-
const report = (complete: boolean) => onProgress?.({ indexed, total, complete })
|
|
356
|
+
const report = (complete: boolean) => onProgress?.({ indexed, total, complete }, index)
|
|
276
357
|
report(false)
|
|
277
358
|
|
|
278
359
|
const db = await openTranscriptDb()
|
|
@@ -302,7 +383,7 @@ async function buildSearchIndex(
|
|
|
302
383
|
if (remaining.size > 0) {
|
|
303
384
|
await buildSearchIndexRemote(api, [...remaining], index, () => {
|
|
304
385
|
indexed += 1
|
|
305
|
-
report(false)
|
|
386
|
+
if (indexed % TRANSCRIPT_BATCH === 0) report(false)
|
|
306
387
|
})
|
|
307
388
|
}
|
|
308
389
|
report(true)
|
|
@@ -354,6 +435,45 @@ async function buildSearchIndexRemote(
|
|
|
354
435
|
const TRANSCRIPT_PREVIEW_TURNS = 8
|
|
355
436
|
const TRANSCRIPT_PREVIEW_CHARS = 2000
|
|
356
437
|
|
|
438
|
+
// Case-insensitive range of `query` inside `text`, so a matched row can show
|
|
439
|
+
// which part of it the search hit instead of only filtering invisibly. Kept
|
|
440
|
+
// below the data-layer markers that tests/tui.mjs evaluates: JSX must not enter
|
|
441
|
+
// that extraction window.
|
|
442
|
+
function matchRange(text: string, query: string): [number, number] | undefined {
|
|
443
|
+
const q = query.trim().toLowerCase()
|
|
444
|
+
if (!q) return undefined
|
|
445
|
+
const at = text.toLowerCase().indexOf(q)
|
|
446
|
+
return at < 0 ? undefined : [at, at + q.length]
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function Highlighted(props: {
|
|
450
|
+
text: string
|
|
451
|
+
query: string
|
|
452
|
+
color: RGBA
|
|
453
|
+
matchColor: RGBA
|
|
454
|
+
bold?: boolean
|
|
455
|
+
}) {
|
|
456
|
+
const parts = createMemo(() => {
|
|
457
|
+
const range = matchRange(props.text, props.query)
|
|
458
|
+
if (!range) return [props.text, "", ""] as const
|
|
459
|
+
return [props.text.slice(0, range[0]), props.text.slice(range[0], range[1]), props.text.slice(range[1])] as const
|
|
460
|
+
})
|
|
461
|
+
return (
|
|
462
|
+
<text
|
|
463
|
+
flexGrow={1}
|
|
464
|
+
flexShrink={1}
|
|
465
|
+
overflow="hidden"
|
|
466
|
+
wrapMode="none"
|
|
467
|
+
attributes={props.bold ? TextAttributes.BOLD : undefined}
|
|
468
|
+
style={{ fg: props.color }}
|
|
469
|
+
>
|
|
470
|
+
{parts()[0]}
|
|
471
|
+
<span style={{ fg: props.matchColor }}>{parts()[1]}</span>
|
|
472
|
+
{parts()[2]}
|
|
473
|
+
</text>
|
|
474
|
+
)
|
|
475
|
+
}
|
|
476
|
+
|
|
357
477
|
async function fetchTranscriptText(api: TuiPluginApi, sessionID: string): Promise<string> {
|
|
358
478
|
try {
|
|
359
479
|
const result = await api.client.session.messages({ sessionID })
|
|
@@ -400,7 +520,9 @@ function createTranscriptPreview(api: TuiPluginApi) {
|
|
|
400
520
|
}
|
|
401
521
|
setPreviewID(entry.id)
|
|
402
522
|
setPreviewText("Loading…")
|
|
403
|
-
void fetchTranscriptText(api, entry.id).then(
|
|
523
|
+
void fetchTranscriptText(api, entry.id).then((text) => {
|
|
524
|
+
if (previewID() === entry.id) setPreviewText(text)
|
|
525
|
+
})
|
|
404
526
|
api.ui.dialog.replace(
|
|
405
527
|
() => (
|
|
406
528
|
<box flexDirection="column" paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
|
|
@@ -418,7 +540,11 @@ function createTranscriptPreview(api: TuiPluginApi) {
|
|
|
418
540
|
maxHeight={Math.max(6, Math.floor(api.renderer.height / 2))}
|
|
419
541
|
>
|
|
420
542
|
{getMarkdownStyle(api.theme.current) ? (
|
|
421
|
-
<markdown
|
|
543
|
+
<markdown
|
|
544
|
+
content={previewText()}
|
|
545
|
+
syntaxStyle={getMarkdownStyle(api.theme.current)!}
|
|
546
|
+
treeSitterClient={getTreeSitterClient()}
|
|
547
|
+
/>
|
|
422
548
|
) : (
|
|
423
549
|
<text wrapMode="word">{previewText()}</text>
|
|
424
550
|
)}
|
|
@@ -454,16 +580,24 @@ type TreeRow =
|
|
|
454
580
|
| { kind: "group"; dir: string; label: string; count: number }
|
|
455
581
|
| { kind: "item"; entry: Entry; last: boolean }
|
|
456
582
|
|
|
457
|
-
|
|
583
|
+
type PinProps = { pins: () => Pins; onTogglePin: (kind: keyof Pins, value: string) => void; refreshPins: () => void }
|
|
584
|
+
|
|
585
|
+
function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
458
586
|
const theme = () => props.api.theme.current
|
|
459
587
|
const home = process.env.HOME ?? ""
|
|
460
588
|
const [entries, setEntries] = createSignal<Entry[]>([])
|
|
589
|
+
const [sectionCollapsed, setSectionCollapsed] = createSignal(false)
|
|
461
590
|
const [collapsed, setCollapsed] = createSignal<Record<string, boolean>>({})
|
|
462
591
|
const [hovered, setHovered] = createSignal<string>()
|
|
463
592
|
const preview = createTranscriptPreview(props.api)
|
|
464
593
|
const [query, setQuery] = createSignal("")
|
|
465
594
|
const [deleting, setDeleting] = createSignal<string>()
|
|
466
595
|
const [searching, setSearching] = createSignal(false)
|
|
596
|
+
const [loadFailed, setLoadFailed] = createSignal(false)
|
|
597
|
+
const [navActive, setNavActive] = createSignal(false)
|
|
598
|
+
const [cursor, setCursor] = createSignal(0)
|
|
599
|
+
const [pendingDelete, setPendingDelete] = createSignal<string>()
|
|
600
|
+
let confirmTimer: ReturnType<typeof setTimeout> | undefined
|
|
467
601
|
const currentID = createMemo(() => {
|
|
468
602
|
const route = props.api.route.current
|
|
469
603
|
if (route.name !== "session") return undefined
|
|
@@ -474,11 +608,17 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
474
608
|
onMount(() => {
|
|
475
609
|
let alive = true
|
|
476
610
|
const load = async () => {
|
|
611
|
+
props.refreshPins()
|
|
477
612
|
try {
|
|
478
613
|
const { entries: all } = await fetchEntries(props.api)
|
|
479
|
-
if (alive)
|
|
614
|
+
if (alive) {
|
|
615
|
+
setEntries(all)
|
|
616
|
+
setLoadFailed(false)
|
|
617
|
+
}
|
|
480
618
|
} catch {
|
|
481
|
-
// leave the previous list in place rather than flashing to blank
|
|
619
|
+
// leave the previous list in place rather than flashing to blank, but
|
|
620
|
+
// say so when there is nothing at all to show
|
|
621
|
+
if (alive && entries().length === 0) setLoadFailed(true)
|
|
482
622
|
}
|
|
483
623
|
}
|
|
484
624
|
void load()
|
|
@@ -491,22 +631,31 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
491
631
|
|
|
492
632
|
const filteredEntries = createMemo(() => {
|
|
493
633
|
const q = query().trim().toLowerCase()
|
|
494
|
-
|
|
495
|
-
|
|
634
|
+
const ordered = pinnedFirst(entries(), props.pins())
|
|
635
|
+
if (!q) return ordered
|
|
636
|
+
return ordered.filter(
|
|
496
637
|
(entry) => entry.title.toLowerCase().includes(q) || entry.dir.toLowerCase().includes(q),
|
|
497
638
|
)
|
|
498
639
|
})
|
|
499
640
|
|
|
641
|
+
const shownEntries = createMemo(() => filteredEntries())
|
|
642
|
+
const remaining = createMemo(() => Math.max(0, filteredEntries().length - shownEntries().length))
|
|
643
|
+
|
|
500
644
|
const tree = createMemo<TreeRow[]>(() => {
|
|
501
645
|
const groups = new Map<string, Entry[]>()
|
|
502
|
-
for (const entry of
|
|
646
|
+
for (const entry of shownEntries()) {
|
|
503
647
|
const list = groups.get(entry.dir) ?? []
|
|
504
648
|
list.push(entry)
|
|
505
649
|
groups.set(entry.dir, list)
|
|
506
650
|
}
|
|
507
651
|
const ordered = [...groups.entries()]
|
|
508
652
|
.map(([dir, list]) => ({ dir, list, updated: Math.max(...list.map((entry) => entry.updated)) }))
|
|
509
|
-
.sort((a, b) =>
|
|
653
|
+
.sort((a, b) => {
|
|
654
|
+
const pins = props.pins()
|
|
655
|
+
const rank = (group: { dir: string; list: Entry[] }) =>
|
|
656
|
+
pins.directories.includes(group.dir) ? 2 : group.list.some((entry) => pins.sessions.includes(entry.id)) ? 1 : 0
|
|
657
|
+
return rank(b) - rank(a) || b.updated - a.updated
|
|
658
|
+
})
|
|
510
659
|
const rows: TreeRow[] = []
|
|
511
660
|
for (const group of ordered) {
|
|
512
661
|
rows.push({ kind: "group", dir: group.dir, label: prettyDir(group.dir, home), count: group.list.length })
|
|
@@ -522,6 +671,36 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
522
671
|
setCollapsed((prev) => ({ ...prev, [dir]: !prev[dir] }))
|
|
523
672
|
}
|
|
524
673
|
|
|
674
|
+
const toggleSection = () => {
|
|
675
|
+
if (!sectionCollapsed()) {
|
|
676
|
+
cancelDelete()
|
|
677
|
+
setNavActive(false)
|
|
678
|
+
setSearching(false)
|
|
679
|
+
setHovered(undefined)
|
|
680
|
+
}
|
|
681
|
+
setSectionCollapsed((value) => !value)
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Flat order of the rows a cursor can land on, so keyboard navigation and
|
|
685
|
+
// mouse hover resolve to the same row.
|
|
686
|
+
const itemRows = createMemo(() => tree().flatMap((row) => (row.kind === "item" ? [row.entry] : [])))
|
|
687
|
+
const isActive = (id: string) => (navActive() ? itemRows()[cursor()]?.id === id : hovered() === id)
|
|
688
|
+
const isPendingDelete = (id: string) => pendingDelete() === id
|
|
689
|
+
const moveCursor = (delta: number) => {
|
|
690
|
+
const count = itemRows().length
|
|
691
|
+
if (count === 0) return
|
|
692
|
+
setCursor((value) => Math.max(0, Math.min(count - 1, value + delta)))
|
|
693
|
+
}
|
|
694
|
+
let previousRows: Entry[] = []
|
|
695
|
+
createEffect(() => {
|
|
696
|
+
const rows = itemRows()
|
|
697
|
+
const selected = previousRows[cursor()]?.id
|
|
698
|
+
const position = rows.findIndex((entry) => entry.id === selected)
|
|
699
|
+
if (position >= 0 && position !== cursor()) setCursor(position)
|
|
700
|
+
else if (cursor() >= rows.length) setCursor(Math.max(0, rows.length - 1))
|
|
701
|
+
previousRows = rows
|
|
702
|
+
})
|
|
703
|
+
|
|
525
704
|
const sessionStatus = (id: string): "running" | "waiting" | "idle" => {
|
|
526
705
|
try {
|
|
527
706
|
const permissions = props.api.state.session.permission(id)
|
|
@@ -540,7 +719,41 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
540
719
|
return theme().textMuted
|
|
541
720
|
}
|
|
542
721
|
|
|
722
|
+
const cancelDelete = () => {
|
|
723
|
+
if (confirmTimer) clearTimeout(confirmTimer)
|
|
724
|
+
confirmTimer = undefined
|
|
725
|
+
setPendingDelete(undefined)
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// Deleting is irreversible, so ctrl+x arms the row and a second ctrl+x (or y)
|
|
729
|
+
// commits it. The arm survives pointer movement — edge-hover flicker must
|
|
730
|
+
// not eat the confirm — and is cleared by the five-second timeout, n, Esc,
|
|
731
|
+
// collapsing the section, or the delete itself.
|
|
732
|
+
const requestDelete = (entry: Entry) => {
|
|
733
|
+
if (deleting()) return
|
|
734
|
+
if (pendingDelete() === entry.id) {
|
|
735
|
+
void deleteEntry(entry)
|
|
736
|
+
return
|
|
737
|
+
}
|
|
738
|
+
setPendingDelete(entry.id)
|
|
739
|
+
if (confirmTimer) clearTimeout(confirmTimer)
|
|
740
|
+
confirmTimer = setTimeout(() => setPendingDelete(undefined), 5000)
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// The confirm always targets the visibly armed row ("ctrl+x again"),
|
|
744
|
+
// wherever the pointer or cursor currently is. Returns true when an arm
|
|
745
|
+
// was pending (and is now committed or discarded as stale).
|
|
746
|
+
const confirmArmed = (): boolean => {
|
|
747
|
+
const id = pendingDelete()
|
|
748
|
+
if (!id) return false
|
|
749
|
+
const entry = entries().find((e) => e.id === id)
|
|
750
|
+
if (!entry) cancelDelete()
|
|
751
|
+
else void deleteEntry(entry)
|
|
752
|
+
return true
|
|
753
|
+
}
|
|
754
|
+
|
|
543
755
|
const deleteEntry = async (entry: Entry) => {
|
|
756
|
+
cancelDelete()
|
|
544
757
|
if (deleting()) return
|
|
545
758
|
setDeleting(entry.id)
|
|
546
759
|
try {
|
|
@@ -568,6 +781,7 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
568
781
|
|
|
569
782
|
let disposeHoverSpace: (() => void) | undefined
|
|
570
783
|
let disposeSearch: (() => void) | undefined
|
|
784
|
+
let disposeNav: (() => void) | undefined
|
|
571
785
|
let insideSearchBox = false
|
|
572
786
|
|
|
573
787
|
const onRootMouseDown = () => {
|
|
@@ -614,7 +828,7 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
614
828
|
priority: 20,
|
|
615
829
|
bindings: [
|
|
616
830
|
{
|
|
617
|
-
key: "
|
|
831
|
+
key: "ctrl+p",
|
|
618
832
|
desc: "Preview session transcript",
|
|
619
833
|
preventDefault: true,
|
|
620
834
|
cmd: () => openSidebarPreview(entry),
|
|
@@ -623,8 +837,12 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
623
837
|
key: "ctrl+x",
|
|
624
838
|
desc: "Delete session",
|
|
625
839
|
preventDefault: true,
|
|
626
|
-
cmd: () =>
|
|
840
|
+
cmd: () => {
|
|
841
|
+
if (!confirmArmed()) requestDelete(entry)
|
|
842
|
+
},
|
|
627
843
|
},
|
|
844
|
+
{ key: "option+s", desc: "Pin session", preventDefault: true, cmd: () => props.onTogglePin("sessions", entry.id) },
|
|
845
|
+
{ key: "option+d", desc: "Pin directory", preventDefault: true, cmd: () => props.onTogglePin("directories", entry.dir) },
|
|
628
846
|
{
|
|
629
847
|
key: "/",
|
|
630
848
|
desc: "Search sessions",
|
|
@@ -635,22 +853,128 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
635
853
|
})
|
|
636
854
|
})
|
|
637
855
|
|
|
856
|
+
// Keyboard navigation is opt-in (click the "Sessions" heading or the search
|
|
857
|
+
// box) so the sidebar never steals the arrow keys from the main prompt.
|
|
858
|
+
createEffect(() => {
|
|
859
|
+
disposeNav?.()
|
|
860
|
+
disposeNav = undefined
|
|
861
|
+
if (!navActive()) return
|
|
862
|
+
const bindings = [
|
|
863
|
+
{ key: "up", desc: "Previous session", preventDefault: true, cmd: () => moveCursor(-1) },
|
|
864
|
+
{ key: "down", desc: "Next session", preventDefault: true, cmd: () => moveCursor(1) },
|
|
865
|
+
{ key: "pageup", desc: "Page up", preventDefault: true, cmd: () => moveCursor(-5) },
|
|
866
|
+
{ key: "pagedown", desc: "Page down", preventDefault: true, cmd: () => moveCursor(5) },
|
|
867
|
+
{ key: "home", desc: "First session", preventDefault: true, cmd: () => setCursor(0) },
|
|
868
|
+
{
|
|
869
|
+
key: "end",
|
|
870
|
+
desc: "Last session",
|
|
871
|
+
preventDefault: true,
|
|
872
|
+
cmd: () => setCursor(Math.max(0, itemRows().length - 1)),
|
|
873
|
+
},
|
|
874
|
+
{
|
|
875
|
+
key: "ctrl+p",
|
|
876
|
+
desc: "Preview session transcript",
|
|
877
|
+
preventDefault: true,
|
|
878
|
+
cmd: () => {
|
|
879
|
+
const entry = itemRows()[cursor()]
|
|
880
|
+
if (entry) openSidebarPreview(entry)
|
|
881
|
+
},
|
|
882
|
+
},
|
|
883
|
+
{
|
|
884
|
+
key: "ctrl+x",
|
|
885
|
+
desc: "Delete session",
|
|
886
|
+
preventDefault: true,
|
|
887
|
+
cmd: () => {
|
|
888
|
+
if (confirmArmed()) return
|
|
889
|
+
const entry = itemRows()[cursor()]
|
|
890
|
+
if (entry) requestDelete(entry)
|
|
891
|
+
},
|
|
892
|
+
},
|
|
893
|
+
{
|
|
894
|
+
key: "option+s",
|
|
895
|
+
desc: "Pin session",
|
|
896
|
+
preventDefault: true,
|
|
897
|
+
cmd: () => {
|
|
898
|
+
const entry = itemRows()[cursor()]
|
|
899
|
+
if (entry) props.onTogglePin("sessions", entry.id)
|
|
900
|
+
},
|
|
901
|
+
},
|
|
902
|
+
{
|
|
903
|
+
key: "option+d",
|
|
904
|
+
desc: "Pin directory",
|
|
905
|
+
preventDefault: true,
|
|
906
|
+
cmd: () => {
|
|
907
|
+
const entry = itemRows()[cursor()]
|
|
908
|
+
if (entry) props.onTogglePin("directories", entry.dir)
|
|
909
|
+
},
|
|
910
|
+
},
|
|
911
|
+
{
|
|
912
|
+
key: "enter",
|
|
913
|
+
desc: "Open session",
|
|
914
|
+
preventDefault: true,
|
|
915
|
+
cmd: () => {
|
|
916
|
+
const entry = itemRows()[cursor()]
|
|
917
|
+
if (entry) props.api.route.navigate("session", { sessionID: entry.id })
|
|
918
|
+
},
|
|
919
|
+
},
|
|
920
|
+
{
|
|
921
|
+
key: "escape",
|
|
922
|
+
desc: "Leave the session list",
|
|
923
|
+
preventDefault: true,
|
|
924
|
+
cmd: () => {
|
|
925
|
+
cancelDelete()
|
|
926
|
+
setNavActive(false)
|
|
927
|
+
},
|
|
928
|
+
},
|
|
929
|
+
...(pendingDelete()
|
|
930
|
+
? [
|
|
931
|
+
{
|
|
932
|
+
key: "y",
|
|
933
|
+
desc: "Confirm delete",
|
|
934
|
+
preventDefault: true,
|
|
935
|
+
cmd: () => {
|
|
936
|
+
confirmArmed()
|
|
937
|
+
},
|
|
938
|
+
},
|
|
939
|
+
{ key: "n", desc: "Cancel delete", preventDefault: true, cmd: cancelDelete },
|
|
940
|
+
]
|
|
941
|
+
: []),
|
|
942
|
+
]
|
|
943
|
+
disposeNav = props.api.keymap.registerLayer({ mode: "base", priority: 20, bindings })
|
|
944
|
+
})
|
|
945
|
+
|
|
638
946
|
onCleanup(() => {
|
|
639
947
|
disposeHoverSpace?.()
|
|
640
948
|
disposeSearch?.()
|
|
949
|
+
disposeNav?.()
|
|
950
|
+
if (confirmTimer) clearTimeout(confirmTimer)
|
|
641
951
|
})
|
|
642
952
|
|
|
643
953
|
return (
|
|
644
|
-
<box flexDirection="column" paddingRight={1}>
|
|
645
|
-
<
|
|
646
|
-
<
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
954
|
+
<box flexDirection="column" paddingRight={1} flexGrow={1} flexShrink={1}>
|
|
955
|
+
<box flexDirection="row" justifyContent="space-between" gap={1}>
|
|
956
|
+
<text flexShrink={1} onMouseDown={() => {
|
|
957
|
+
if (!sectionCollapsed()) setNavActive((value) => !value)
|
|
958
|
+
}}>
|
|
959
|
+
<b>Sessions</b>
|
|
960
|
+
<span style={{ fg: theme().textMuted }}>
|
|
961
|
+
{query().trim() ? ` (${filteredEntries().length}/${entries().length})` : ` (${entries().length})`}
|
|
962
|
+
</span>
|
|
963
|
+
<span style={{ fg: theme().accent }}>{navActive() ? " · nav" : ""}</span>
|
|
964
|
+
</text>
|
|
965
|
+
<text flexShrink={0} style={{ fg: theme().textMuted }} onMouseDown={toggleSection}>
|
|
966
|
+
{sectionCollapsed() ? "▸ show" : "▾ hide"}
|
|
967
|
+
</text>
|
|
968
|
+
</box>
|
|
969
|
+
<Show when={!sectionCollapsed()}>
|
|
970
|
+
<Show
|
|
971
|
+
when={entries().length > 0}
|
|
972
|
+
fallback={
|
|
973
|
+
<text style={{ fg: theme().textMuted }}>
|
|
974
|
+
{loadFailed() ? "Session list unavailable · will retry" : "No sessions yet · ctrl+o to browse"}
|
|
652
975
|
</text>
|
|
653
|
-
|
|
976
|
+
}
|
|
977
|
+
>
|
|
654
978
|
<box
|
|
655
979
|
flexDirection="row"
|
|
656
980
|
gap={1}
|
|
@@ -679,6 +1003,8 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
679
1003
|
</Show>
|
|
680
1004
|
</box>
|
|
681
1005
|
<scrollbox
|
|
1006
|
+
flexGrow={1}
|
|
1007
|
+
flexShrink={1}
|
|
682
1008
|
verticalScrollbarOptions={{ visible: false }}
|
|
683
1009
|
horizontalScrollbarOptions={{ visible: false }}
|
|
684
1010
|
>
|
|
@@ -688,7 +1014,7 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
688
1014
|
<box flexDirection="row" paddingTop={1} onMouseDown={() => toggleGroup(row.dir)}>
|
|
689
1015
|
<text flexGrow={1} flexShrink={1} wrapMode="none">
|
|
690
1016
|
<span style={{ fg: theme().textMuted }}>{collapsed()[row.dir] ? "▸ " : "▾ "}</span>
|
|
691
|
-
<b>{truncate(row.label, SIDEBAR_GROUP_WIDTH)}</b>
|
|
1017
|
+
<b>{props.pins().directories.includes(row.dir) ? "★ " : ""}{truncate(row.label, SIDEBAR_GROUP_WIDTH - (props.pins().directories.includes(row.dir) ? 2 : 0))}</b>
|
|
692
1018
|
</text>
|
|
693
1019
|
<text flexShrink={0} style={{ fg: theme().textMuted }}>
|
|
694
1020
|
{" "}({row.count})
|
|
@@ -701,33 +1027,63 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
701
1027
|
paddingLeft={1}
|
|
702
1028
|
paddingRight={1}
|
|
703
1029
|
backgroundColor={
|
|
704
|
-
row.entry.id
|
|
1030
|
+
isActive(row.entry.id) && row.entry.id !== currentID()
|
|
705
1031
|
? theme().backgroundElement
|
|
706
1032
|
: RGBA.fromInts(0, 0, 0, 0)
|
|
707
1033
|
}
|
|
708
|
-
onMouseOver={() =>
|
|
1034
|
+
onMouseOver={() => {
|
|
1035
|
+
setHovered(row.entry.id)
|
|
1036
|
+
// The keyboard layer wins over the hover layer while nav is
|
|
1037
|
+
// active. Keep its cursor on the hovered row so Option-S/Option-D
|
|
1038
|
+
// cannot silently pin a different (often current) session.
|
|
1039
|
+
if (navActive()) {
|
|
1040
|
+
const index = itemRows().findIndex((entry) => entry.id === row.entry.id)
|
|
1041
|
+
if (index >= 0) setCursor(index)
|
|
1042
|
+
}
|
|
1043
|
+
}}
|
|
709
1044
|
onMouseOut={() => setHovered(undefined)}
|
|
710
1045
|
onMouseDown={() => props.api.route.navigate("session", { sessionID: row.entry.id })}
|
|
711
1046
|
>
|
|
712
1047
|
<text flexShrink={0}>
|
|
713
1048
|
<span style={{ fg: theme().textMuted }}>{row.last ? "└" : "├"}</span>
|
|
714
1049
|
<span
|
|
715
|
-
style={{
|
|
1050
|
+
style={{
|
|
1051
|
+
fg: isPendingDelete(row.entry.id)
|
|
1052
|
+
? theme().error
|
|
1053
|
+
: row.entry.id === currentID()
|
|
1054
|
+
? theme().accent
|
|
1055
|
+
: statusColor(row.entry.id),
|
|
1056
|
+
}}
|
|
716
1057
|
>
|
|
717
|
-
{row.entry.id === currentID() ? "●" : "○"}
|
|
1058
|
+
{row.entry.id === currentID() ? "●" : isActive(row.entry.id) ? "▸" : "○"}
|
|
718
1059
|
</span>
|
|
719
1060
|
</text>
|
|
720
|
-
<
|
|
721
|
-
{truncate(row.entry.title, SIDEBAR_TITLE_WIDTH)}
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
{
|
|
1061
|
+
<Highlighted
|
|
1062
|
+
text={`${props.pins().sessions.includes(row.entry.id) ? "★ " : ""}${truncate(row.entry.title, SIDEBAR_TITLE_WIDTH - (props.pins().sessions.includes(row.entry.id) ? 2 : 0))}`}
|
|
1063
|
+
query={query()}
|
|
1064
|
+
color={isPendingDelete(row.entry.id) ? theme().error : theme().text}
|
|
1065
|
+
matchColor={theme().warning}
|
|
1066
|
+
/>
|
|
1067
|
+
<text flexShrink={0} style={{ fg: isPendingDelete(row.entry.id) ? theme().error : theme().textMuted }}>
|
|
1068
|
+
{isPendingDelete(row.entry.id) ? "ctrl+x again" : ago(row.entry.updated)}
|
|
725
1069
|
</text>
|
|
726
1070
|
</box>
|
|
727
1071
|
)
|
|
728
1072
|
}
|
|
729
1073
|
</For>
|
|
730
1074
|
</scrollbox>
|
|
1075
|
+
<Show when={remaining() > 0}>
|
|
1076
|
+
<text style={{ fg: theme().textMuted }}>{`… ${remaining()} more · ctrl+o for all`}</text>
|
|
1077
|
+
</Show>
|
|
1078
|
+
<Show when={query().trim() && filteredEntries().length === 0}>
|
|
1079
|
+
<text style={{ fg: theme().textMuted }}>No title or directory matches · ctrl+o to search transcripts</text>
|
|
1080
|
+
</Show>
|
|
1081
|
+
<Show when={navActive()}>
|
|
1082
|
+
<box paddingTop={1}>
|
|
1083
|
+
<text style={{ fg: theme().textMuted }}>↑↓ move · enter open · ctrl+p preview · option+s/d pin · ctrl+x delete · esc done</text>
|
|
1084
|
+
</box>
|
|
1085
|
+
</Show>
|
|
1086
|
+
</Show>
|
|
731
1087
|
</Show>
|
|
732
1088
|
</box>
|
|
733
1089
|
)
|
|
@@ -735,13 +1091,14 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
735
1091
|
|
|
736
1092
|
const HOME_SESSION_LIMIT = 6
|
|
737
1093
|
|
|
738
|
-
function HomeSessions(props: { api: TuiPluginApi }) {
|
|
1094
|
+
function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
739
1095
|
const theme = () => props.api.theme.current
|
|
740
1096
|
const home = process.env.HOME ?? ""
|
|
741
1097
|
const [entries, setEntries] = createSignal<Entry[]>([])
|
|
742
1098
|
const [query, setQuery] = createSignal("")
|
|
743
1099
|
const [hovered, setHovered] = createSignal<string>()
|
|
744
1100
|
const [searching, setSearching] = createSignal(false)
|
|
1101
|
+
const [loadFailed, setLoadFailed] = createSignal(false)
|
|
745
1102
|
const preview = createTranscriptPreview(props.api)
|
|
746
1103
|
let disposeHoverSpace: (() => void) | undefined
|
|
747
1104
|
let disposeSearch: (() => void) | undefined
|
|
@@ -789,11 +1146,13 @@ function HomeSessions(props: { api: TuiPluginApi }) {
|
|
|
789
1146
|
priority: 20,
|
|
790
1147
|
bindings: [
|
|
791
1148
|
{
|
|
792
|
-
key: "
|
|
1149
|
+
key: "ctrl+p",
|
|
793
1150
|
desc: "Preview session transcript",
|
|
794
1151
|
preventDefault: true,
|
|
795
1152
|
cmd: () => preview.open(entry),
|
|
796
1153
|
},
|
|
1154
|
+
{ key: "option+s", desc: "Pin session", preventDefault: true, cmd: () => props.onTogglePin("sessions", entry.id) },
|
|
1155
|
+
{ key: "option+d", desc: "Pin directory", preventDefault: true, cmd: () => props.onTogglePin("directories", entry.dir) },
|
|
797
1156
|
],
|
|
798
1157
|
})
|
|
799
1158
|
})
|
|
@@ -804,10 +1163,16 @@ function HomeSessions(props: { api: TuiPluginApi }) {
|
|
|
804
1163
|
onMount(() => {
|
|
805
1164
|
let alive = true
|
|
806
1165
|
const load = async () => {
|
|
1166
|
+
props.refreshPins()
|
|
807
1167
|
try {
|
|
808
1168
|
const { entries: all } = await fetchEntries(props.api)
|
|
809
|
-
if (alive)
|
|
810
|
-
|
|
1169
|
+
if (alive) {
|
|
1170
|
+
setEntries(all)
|
|
1171
|
+
setLoadFailed(false)
|
|
1172
|
+
}
|
|
1173
|
+
} catch {
|
|
1174
|
+
if (alive && entries().length === 0) setLoadFailed(true)
|
|
1175
|
+
}
|
|
811
1176
|
}
|
|
812
1177
|
void load()
|
|
813
1178
|
const clock = setInterval(() => void load(), POLL_MS)
|
|
@@ -824,14 +1189,27 @@ function HomeSessions(props: { api: TuiPluginApi }) {
|
|
|
824
1189
|
(entry) => entry.title.toLowerCase().includes(q) || entry.dir.toLowerCase().includes(q),
|
|
825
1190
|
)
|
|
826
1191
|
: entries()
|
|
827
|
-
return list.slice(0, HOME_SESSION_LIMIT)
|
|
1192
|
+
return pinnedFirst(list, props.pins()).slice(0, HOME_SESSION_LIMIT)
|
|
828
1193
|
})
|
|
829
1194
|
|
|
830
1195
|
return (
|
|
831
1196
|
<box flexDirection="column" width="100%" maxWidth={72} paddingLeft={1} paddingRight={1} paddingTop={1} gap={1}>
|
|
832
|
-
<Show
|
|
1197
|
+
<Show
|
|
1198
|
+
when={entries().length > 0}
|
|
1199
|
+
fallback={
|
|
1200
|
+
<box flexDirection="row" gap={1}>
|
|
1201
|
+
<text style={{ fg: theme().textMuted }}>Recent sessions</text>
|
|
1202
|
+
<text style={{ fg: theme().textMuted }}>
|
|
1203
|
+
{loadFailed() ? "· list unavailable, will retry" : "· none yet, ctrl+o to browse"}
|
|
1204
|
+
</text>
|
|
1205
|
+
</box>
|
|
1206
|
+
}
|
|
1207
|
+
>
|
|
833
1208
|
<box flexDirection="row" justifyContent="space-between" gap={2}>
|
|
834
|
-
<
|
|
1209
|
+
<box flexDirection="row" gap={1}>
|
|
1210
|
+
<text style={{ fg: theme().textMuted }}>Recent sessions</text>
|
|
1211
|
+
<text style={{ fg: theme().textMuted }}>· ctrl+o for all</text>
|
|
1212
|
+
</box>
|
|
835
1213
|
<box
|
|
836
1214
|
flexDirection="row"
|
|
837
1215
|
gap={1}
|
|
@@ -875,17 +1253,20 @@ function HomeSessions(props: { api: TuiPluginApi }) {
|
|
|
875
1253
|
<text flexShrink={0} style={{ fg: theme().textMuted }}>
|
|
876
1254
|
○
|
|
877
1255
|
</text>
|
|
878
|
-
<
|
|
879
|
-
{truncate(entry.title, HOME_TITLE_WIDTH)}
|
|
880
|
-
|
|
1256
|
+
<Highlighted
|
|
1257
|
+
text={`${props.pins().sessions.includes(entry.id) ? "★ " : ""}${truncate(entry.title, HOME_TITLE_WIDTH - (props.pins().sessions.includes(entry.id) ? 2 : 0))}`}
|
|
1258
|
+
query={query()}
|
|
1259
|
+
color={theme().text}
|
|
1260
|
+
matchColor={theme().warning}
|
|
1261
|
+
/>
|
|
881
1262
|
<text flexShrink={0} style={{ fg: theme().textMuted }}>
|
|
882
|
-
{prettyDir(entry.dir, home)} · {ago(entry.updated)}
|
|
1263
|
+
{props.pins().directories.includes(entry.dir) ? "★ " : ""}{prettyDir(entry.dir, home)} · {ago(entry.updated)}
|
|
883
1264
|
</text>
|
|
884
1265
|
</box>
|
|
885
1266
|
)}
|
|
886
1267
|
</For>
|
|
887
1268
|
<Show when={visible().length === 0}>
|
|
888
|
-
<text style={{ fg: theme().textMuted }}>No
|
|
1269
|
+
<text style={{ fg: theme().textMuted }}>No title or directory matches · ctrl+o to search transcripts</text>
|
|
889
1270
|
</Show>
|
|
890
1271
|
</Show>
|
|
891
1272
|
</box>
|
|
@@ -893,19 +1274,40 @@ function HomeSessions(props: { api: TuiPluginApi }) {
|
|
|
893
1274
|
}
|
|
894
1275
|
|
|
895
1276
|
const tui: TuiPlugin = async (api) => {
|
|
1277
|
+
const [pins, setPins] = createSignal<Pins>(emptyPins())
|
|
1278
|
+
let pinVersion = 0
|
|
1279
|
+
const refreshPins = () => {
|
|
1280
|
+
const version = pinVersion
|
|
1281
|
+
void readPins().then((latest) => {
|
|
1282
|
+
if (version === pinVersion) setPins(latest)
|
|
1283
|
+
})
|
|
1284
|
+
}
|
|
1285
|
+
refreshPins()
|
|
1286
|
+
const onTogglePin = (kind: keyof Pins, value: string) => {
|
|
1287
|
+
if (!value) return
|
|
1288
|
+
pinVersion += 1
|
|
1289
|
+
void togglePin(kind, value).then(
|
|
1290
|
+
(updated) => {
|
|
1291
|
+
setPins(updated)
|
|
1292
|
+
api.ui.toast({ message: `${updated[kind].includes(value) ? "Pinned" : "Unpinned"} ${kind === "sessions" ? "session" : "directory"}`, variant: "info" })
|
|
1293
|
+
},
|
|
1294
|
+
() => api.ui.toast({ message: "Could not update pins", variant: "error" }),
|
|
1295
|
+
)
|
|
1296
|
+
}
|
|
896
1297
|
api.slots.register({
|
|
897
1298
|
order: 100,
|
|
898
1299
|
slots: {
|
|
899
1300
|
sidebar_content() {
|
|
900
|
-
return <SidebarSessions api={api} />
|
|
1301
|
+
return <SidebarSessions api={api} pins={pins} onTogglePin={onTogglePin} refreshPins={refreshPins} />
|
|
901
1302
|
},
|
|
902
1303
|
home_bottom() {
|
|
903
|
-
return <HomeSessions api={api} />
|
|
1304
|
+
return <HomeSessions api={api} pins={pins} onTogglePin={onTogglePin} refreshPins={refreshPins} />
|
|
904
1305
|
},
|
|
905
1306
|
},
|
|
906
1307
|
})
|
|
907
1308
|
|
|
908
1309
|
const openPicker = async () => {
|
|
1310
|
+
refreshPins()
|
|
909
1311
|
if (api.mode.current() !== BASE_MODE) return
|
|
910
1312
|
if (api.renderer.currentFocusedEditor === null) return
|
|
911
1313
|
|
|
@@ -922,25 +1324,34 @@ const tui: TuiPlugin = async (api) => {
|
|
|
922
1324
|
return
|
|
923
1325
|
}
|
|
924
1326
|
|
|
1327
|
+
const [allEntries, setAllEntries] = createSignal<Entry[]>(entries)
|
|
925
1328
|
const [previewText, setPreviewText] = createSignal("")
|
|
926
1329
|
const [showPreview, setShowPreview] = createSignal(false)
|
|
927
1330
|
const [query, setQuery] = createSignal("")
|
|
928
1331
|
const [cursor, setCursor] = createSignal(0)
|
|
1332
|
+
const [scope, setScope] = createSignal<string>()
|
|
1333
|
+
const [pendingDelete, setPendingDelete] = createSignal<Entry>()
|
|
929
1334
|
const [searchIndex, setSearchIndex] = createSignal<Map<string, string>>(new Map())
|
|
930
1335
|
const [indexProgress, setIndexProgress] = createSignal<IndexProgress>({
|
|
931
1336
|
indexed: 0,
|
|
932
1337
|
total: 0,
|
|
933
1338
|
complete: false,
|
|
934
1339
|
})
|
|
935
|
-
void buildSearchIndex(api, entries,
|
|
936
|
-
|
|
937
|
-
|
|
1340
|
+
void buildSearchIndex(api, entries, (progress, index) => {
|
|
1341
|
+
setIndexProgress(progress)
|
|
1342
|
+
// Publish a fresh snapshot: the indexing workers mutate their own map,
|
|
1343
|
+
// while Solid needs a new reference to update search results mid-scan.
|
|
1344
|
+
setSearchIndex(new Map(index))
|
|
1345
|
+
})
|
|
938
1346
|
type DialogRow = { kind: "group"; label: string; count: number } | { kind: "item"; entry: Entry }
|
|
939
1347
|
|
|
1348
|
+
const orderedEntries = createMemo(() => pinnedFirst(allEntries(), pins()))
|
|
940
1349
|
const matchedGroups = createMemo(() => {
|
|
941
1350
|
const q = query().trim().toLowerCase()
|
|
942
1351
|
const index = searchIndex()
|
|
943
|
-
const
|
|
1352
|
+
const group = scope()
|
|
1353
|
+
const matched = orderedEntries().filter((entry) => {
|
|
1354
|
+
if (group && entry.group !== group) return false
|
|
944
1355
|
if (!q) return true
|
|
945
1356
|
const haystack = index.get(entry.id) ?? `${entry.title} ${entry.dir}`.toLowerCase()
|
|
946
1357
|
return haystack.includes(q)
|
|
@@ -951,7 +1362,14 @@ const tui: TuiPlugin = async (api) => {
|
|
|
951
1362
|
list.push(entry)
|
|
952
1363
|
byGroup.set(entry.group, list)
|
|
953
1364
|
}
|
|
954
|
-
|
|
1365
|
+
const groups = [...byGroup.entries()].map(([name, list]) => ({ name, list }))
|
|
1366
|
+
const currentPins = pins()
|
|
1367
|
+
const rank = (group: (typeof groups)[number]) =>
|
|
1368
|
+
group.list.some((entry) => currentPins.directories.includes(entry.dir)) ? 2
|
|
1369
|
+
: group.list.some((entry) => currentPins.sessions.includes(entry.id)) ? 1 : 0
|
|
1370
|
+
return groups.sort((a, b) =>
|
|
1371
|
+
rank(b) - rank(a) || Math.max(...b.list.map((entry) => entry.updated)) - Math.max(...a.list.map((entry) => entry.updated)),
|
|
1372
|
+
)
|
|
955
1373
|
})
|
|
956
1374
|
|
|
957
1375
|
const selectableEntries = createMemo(() => matchedGroups().flatMap((g) => g.list))
|
|
@@ -961,6 +1379,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
961
1379
|
const coverageLabel = createMemo(() => {
|
|
962
1380
|
const progress = indexProgress()
|
|
963
1381
|
const bits: string[] = []
|
|
1382
|
+
if (scope()) bits.push(`scope ${scope()}`)
|
|
964
1383
|
if (progress.total > 0) {
|
|
965
1384
|
bits.push(
|
|
966
1385
|
progress.complete
|
|
@@ -968,13 +1387,18 @@ const tui: TuiPlugin = async (api) => {
|
|
|
968
1387
|
: `indexing transcripts ${progress.indexed}/${progress.total}`,
|
|
969
1388
|
)
|
|
970
1389
|
}
|
|
971
|
-
if (entriesResult.truncated) bits.push(`showing newest ${
|
|
1390
|
+
if (entriesResult.truncated) bits.push(`showing newest ${allEntries().length}`)
|
|
972
1391
|
return bits.join(" · ")
|
|
973
1392
|
})
|
|
974
1393
|
|
|
1394
|
+
let previousSelection: Entry[] = []
|
|
975
1395
|
createEffect(() => {
|
|
976
|
-
const
|
|
977
|
-
|
|
1396
|
+
const rows = selectableEntries()
|
|
1397
|
+
const selected = previousSelection[cursor()]?.id
|
|
1398
|
+
const position = rows.findIndex((entry) => entry.id === selected)
|
|
1399
|
+
if (position >= 0 && position !== cursor()) setCursor(position)
|
|
1400
|
+
else if (cursor() >= rows.length) setCursor(Math.max(0, rows.length - 1))
|
|
1401
|
+
previousSelection = rows
|
|
978
1402
|
})
|
|
979
1403
|
|
|
980
1404
|
const pickerRows = createMemo<DialogRow[]>(() => {
|
|
@@ -1043,8 +1467,8 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1043
1467
|
setPreviewText(cached)
|
|
1044
1468
|
return
|
|
1045
1469
|
}
|
|
1046
|
-
//
|
|
1047
|
-
|
|
1470
|
+
// Never show another session's transcript beneath the new selection.
|
|
1471
|
+
setPreviewText("Loading…")
|
|
1048
1472
|
previewTimer = setTimeout(() => void loadPreview(id), 120)
|
|
1049
1473
|
})
|
|
1050
1474
|
|
|
@@ -1053,9 +1477,76 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1053
1477
|
const moveCursor = (delta: number) => {
|
|
1054
1478
|
const count = selectableEntries().length
|
|
1055
1479
|
if (count === 0) return
|
|
1480
|
+
cancelDelete()
|
|
1056
1481
|
setCursor((value) => Math.max(0, Math.min(count - 1, value + delta)))
|
|
1057
1482
|
}
|
|
1058
1483
|
|
|
1484
|
+
// Delete confirmation lives in its own layer, registered only while a row
|
|
1485
|
+
// is armed, so y/n never shadow the search box's typing.
|
|
1486
|
+
let disposeConfirm: (() => void) | undefined
|
|
1487
|
+
const cancelDelete = () => {
|
|
1488
|
+
disposeConfirm?.()
|
|
1489
|
+
disposeConfirm = undefined
|
|
1490
|
+
setPendingDelete(undefined)
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
const deleteEntry = async (entry: Entry) => {
|
|
1494
|
+
cancelDelete()
|
|
1495
|
+
try {
|
|
1496
|
+
const result = await api.client.session.delete({
|
|
1497
|
+
sessionID: entry.id,
|
|
1498
|
+
directory: entry.dir || undefined,
|
|
1499
|
+
})
|
|
1500
|
+
if (result && "error" in result && result.error) throw result.error
|
|
1501
|
+
const { entries: remaining } = await fetchEntries(api)
|
|
1502
|
+
if (remaining.some((item) => item.id === entry.id)) {
|
|
1503
|
+
setAllEntries(remaining)
|
|
1504
|
+
api.ui.toast({ message: "Session was not deleted", variant: "error" })
|
|
1505
|
+
return
|
|
1506
|
+
}
|
|
1507
|
+
setAllEntries(remaining)
|
|
1508
|
+
api.ui.toast({ message: `Deleted "${truncate(entry.title, 40)}"`, variant: "info" })
|
|
1509
|
+
} catch {
|
|
1510
|
+
api.ui.toast({ message: "Could not delete session", variant: "error" })
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
const armDelete = (entry: Entry) => {
|
|
1515
|
+
setPendingDelete(entry)
|
|
1516
|
+
disposeConfirm?.()
|
|
1517
|
+
disposeConfirm = api.keymap.registerLayer({
|
|
1518
|
+
bindings: [
|
|
1519
|
+
{
|
|
1520
|
+
key: "y",
|
|
1521
|
+
desc: "Confirm delete",
|
|
1522
|
+
preventDefault: true,
|
|
1523
|
+
cmd: () => {
|
|
1524
|
+
const armed = pendingDelete()
|
|
1525
|
+
if (armed) void deleteEntry(armed)
|
|
1526
|
+
},
|
|
1527
|
+
},
|
|
1528
|
+
{ key: "n", desc: "Cancel delete", preventDefault: true, cmd: cancelDelete },
|
|
1529
|
+
],
|
|
1530
|
+
})
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
const forkEntry = async (entry: Entry) => {
|
|
1534
|
+
try {
|
|
1535
|
+
const result = await api.client.session.fork({
|
|
1536
|
+
sessionID: entry.id,
|
|
1537
|
+
directory: entry.dir || undefined,
|
|
1538
|
+
})
|
|
1539
|
+
const created = (result?.data ?? result) as Session | undefined
|
|
1540
|
+
if (!created?.id) throw new Error("fork returned no session")
|
|
1541
|
+
cleanup()
|
|
1542
|
+
api.ui.dialog.clear()
|
|
1543
|
+
api.route.navigate("session", { sessionID: created.id })
|
|
1544
|
+
api.ui.toast({ message: `Forked "${truncate(entry.title, 40)}"`, variant: "info" })
|
|
1545
|
+
} catch {
|
|
1546
|
+
api.ui.toast({ message: "Could not fork session", variant: "error" })
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1059
1550
|
const choose = (entry?: Entry) => {
|
|
1060
1551
|
const target = entry ?? selectableEntries()[cursor()]
|
|
1061
1552
|
if (!target) return
|
|
@@ -1078,22 +1569,53 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1078
1569
|
cmd: () => setCursor(selectableEntries().length - 1),
|
|
1079
1570
|
},
|
|
1080
1571
|
{ key: "enter", desc: "Open session", preventDefault: true, cmd: () => choose() },
|
|
1572
|
+
{ key: "ctrl+p", desc: "Toggle transcript preview", preventDefault: true, cmd: togglePreview },
|
|
1573
|
+
{ key: "option+s", desc: "Pin session", preventDefault: true, cmd: () => {
|
|
1574
|
+
const entry = selectableEntries()[cursor()]
|
|
1575
|
+
if (entry) onTogglePin("sessions", entry.id)
|
|
1576
|
+
} },
|
|
1577
|
+
{ key: "option+d", desc: "Pin directory", preventDefault: true, cmd: () => {
|
|
1578
|
+
const entry = selectableEntries()[cursor()]
|
|
1579
|
+
if (entry) onTogglePin("directories", entry.dir)
|
|
1580
|
+
} },
|
|
1081
1581
|
{
|
|
1082
|
-
key: "
|
|
1083
|
-
desc: "
|
|
1582
|
+
key: "ctrl+x",
|
|
1583
|
+
desc: "Delete session",
|
|
1084
1584
|
preventDefault: true,
|
|
1085
1585
|
cmd: () => {
|
|
1086
|
-
|
|
1087
|
-
|
|
1586
|
+
const target = selectableEntries()[cursor()]
|
|
1587
|
+
if (!target) return
|
|
1588
|
+
const armed = pendingDelete()
|
|
1589
|
+
if (armed && armed.id === target.id) void deleteEntry(target)
|
|
1590
|
+
else armDelete(target)
|
|
1591
|
+
},
|
|
1592
|
+
},
|
|
1593
|
+
{
|
|
1594
|
+
key: "ctrl+f",
|
|
1595
|
+
desc: "Fork session",
|
|
1596
|
+
preventDefault: true,
|
|
1597
|
+
cmd: () => {
|
|
1598
|
+
const target = selectableEntries()[cursor()]
|
|
1599
|
+
if (target) void forkEntry(target)
|
|
1088
1600
|
},
|
|
1089
1601
|
},
|
|
1090
1602
|
{
|
|
1091
1603
|
key: "ctrl+g",
|
|
1604
|
+
desc: "Toggle project scope",
|
|
1605
|
+
preventDefault: true,
|
|
1606
|
+
cmd: () => {
|
|
1607
|
+
const target = selectableEntries()[cursor()]
|
|
1608
|
+
if (scope()) setScope(undefined)
|
|
1609
|
+
else if (target) setScope(target.group)
|
|
1610
|
+
},
|
|
1611
|
+
},
|
|
1612
|
+
{
|
|
1613
|
+
key: "escape",
|
|
1092
1614
|
desc: "Close",
|
|
1093
1615
|
preventDefault: true,
|
|
1094
1616
|
cmd: () => {
|
|
1095
|
-
|
|
1096
|
-
|
|
1617
|
+
cancelDelete()
|
|
1618
|
+
close()
|
|
1097
1619
|
},
|
|
1098
1620
|
},
|
|
1099
1621
|
],
|
|
@@ -1101,19 +1623,11 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1101
1623
|
|
|
1102
1624
|
const cleanup = () => {
|
|
1103
1625
|
disposeNav()
|
|
1626
|
+
disposeConfirm?.()
|
|
1627
|
+
disposeConfirm = undefined
|
|
1104
1628
|
if (previewTimer) clearTimeout(previewTimer)
|
|
1105
1629
|
}
|
|
1106
1630
|
|
|
1107
|
-
const onSearchInput = (value: string) => {
|
|
1108
|
-
if (query() === "" && value.trim() === "" && value.length > 0) {
|
|
1109
|
-
if (pickerInput && !pickerInput.isDestroyed) pickerInput.value = ""
|
|
1110
|
-
setQuery("")
|
|
1111
|
-
togglePreview()
|
|
1112
|
-
return
|
|
1113
|
-
}
|
|
1114
|
-
setQuery(value)
|
|
1115
|
-
}
|
|
1116
|
-
|
|
1117
1631
|
api.ui.dialog.replace(
|
|
1118
1632
|
() => (
|
|
1119
1633
|
<box flexDirection="column" flexGrow={1} paddingBottom={1} gap={1}>
|
|
@@ -1128,7 +1642,6 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1128
1642
|
</box>
|
|
1129
1643
|
<box paddingTop={1}>
|
|
1130
1644
|
<input
|
|
1131
|
-
ref={(r) => (pickerInput = r)}
|
|
1132
1645
|
focused
|
|
1133
1646
|
placeholder="Search title, directory, transcript…"
|
|
1134
1647
|
value={query()}
|
|
@@ -1136,7 +1649,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1136
1649
|
focusedBackgroundColor={api.theme.current.backgroundPanel}
|
|
1137
1650
|
focusedTextColor={api.theme.current.text}
|
|
1138
1651
|
cursorColor={api.theme.current.primary}
|
|
1139
|
-
onInput={
|
|
1652
|
+
onInput={setQuery}
|
|
1140
1653
|
onSubmit={() => choose()}
|
|
1141
1654
|
onKeyDown={(event) => {
|
|
1142
1655
|
if (event.name === "escape") {
|
|
@@ -1165,7 +1678,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1165
1678
|
row.kind === "group" ? (
|
|
1166
1679
|
<box paddingTop={1} paddingLeft={4}>
|
|
1167
1680
|
<text style={{ fg: api.theme.current.accent }} attributes={TextAttributes.BOLD}>
|
|
1168
|
-
{row.label} <span style={{ fg: api.theme.current.textMuted }}>({row.count})</span>
|
|
1681
|
+
{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>
|
|
1169
1682
|
</text>
|
|
1170
1683
|
</box>
|
|
1171
1684
|
) : (
|
|
@@ -1194,63 +1707,90 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1194
1707
|
●
|
|
1195
1708
|
</text>
|
|
1196
1709
|
</Show>
|
|
1197
|
-
<
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1710
|
+
<Highlighted
|
|
1711
|
+
text={`${pins().sessions.includes(row.entry.id) ? "★ " : ""}${truncate(row.entry.title, pins().sessions.includes(row.entry.id) ? 59 : 61)}`}
|
|
1712
|
+
query={query()}
|
|
1713
|
+
bold={row.entry.id === selectableEntries()[cursor()]?.id}
|
|
1714
|
+
color={
|
|
1715
|
+
row.entry.id === selectableEntries()[cursor()]?.id
|
|
1716
|
+
? api.theme.current.selectedListItemText
|
|
1717
|
+
: api.theme.current.text
|
|
1204
1718
|
}
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
>
|
|
1212
|
-
{truncate(row.entry.title, 61)}
|
|
1213
|
-
</text>
|
|
1719
|
+
matchColor={
|
|
1720
|
+
row.entry.id === selectableEntries()[cursor()]?.id
|
|
1721
|
+
? api.theme.current.selectedListItemText
|
|
1722
|
+
: api.theme.current.warning
|
|
1723
|
+
}
|
|
1724
|
+
/>
|
|
1214
1725
|
<text
|
|
1215
1726
|
flexShrink={0}
|
|
1216
1727
|
style={{
|
|
1217
1728
|
fg:
|
|
1218
|
-
row.entry.id
|
|
1219
|
-
? api.theme.current.
|
|
1220
|
-
:
|
|
1729
|
+
pendingDelete()?.id === row.entry.id
|
|
1730
|
+
? api.theme.current.error
|
|
1731
|
+
: row.entry.id === selectableEntries()[cursor()]?.id
|
|
1732
|
+
? api.theme.current.selectedListItemText
|
|
1733
|
+
: api.theme.current.textMuted,
|
|
1221
1734
|
}}
|
|
1222
1735
|
>
|
|
1223
|
-
{
|
|
1736
|
+
{pendingDelete()?.id === row.entry.id
|
|
1737
|
+
? "press y to delete"
|
|
1738
|
+
: `${prettyDir(row.entry.dir, process.env.HOME ?? "")} · ${ago(row.entry.updated)}${
|
|
1739
|
+
query().trim() &&
|
|
1740
|
+
!`${row.entry.title} ${row.entry.dir}`.toLowerCase().includes(query().trim().toLowerCase())
|
|
1741
|
+
? " · transcript match"
|
|
1742
|
+
: ""
|
|
1743
|
+
}`}
|
|
1224
1744
|
</text>
|
|
1225
1745
|
</box>
|
|
1226
1746
|
)
|
|
1227
1747
|
}
|
|
1228
1748
|
</For>
|
|
1749
|
+
<Show when={selectableEntries().length === 0}>
|
|
1750
|
+
<box paddingLeft={4} paddingRight={4}>
|
|
1751
|
+
<text style={{ fg: api.theme.current.textMuted }}>
|
|
1752
|
+
{!indexProgress().complete
|
|
1753
|
+
? "No matches yet · still searching transcripts…"
|
|
1754
|
+
: scope()
|
|
1755
|
+
? `No matches in ${scope()} · ctrl+g for all projects`
|
|
1756
|
+
: "No sessions match · try another search"}
|
|
1757
|
+
</text>
|
|
1758
|
+
</box>
|
|
1759
|
+
</Show>
|
|
1229
1760
|
</box>
|
|
1230
1761
|
<Show when={showPreview()}>
|
|
1231
1762
|
<box flexShrink={0} flexDirection="column" paddingLeft={4} paddingRight={4} height={12}>
|
|
1232
1763
|
<text style={{ fg: api.theme.current.textMuted }} attributes={TextAttributes.BOLD}>
|
|
1233
|
-
Preview
|
|
1764
|
+
{previewID() ? `Preview · ${truncate(selectableEntries()[cursor()]!.title, 48)}` : "Preview"}
|
|
1234
1765
|
</text>
|
|
1235
1766
|
<scrollbox flexGrow={1} scrollbarOptions={{ visible: false }}>
|
|
1236
|
-
<Show
|
|
1237
|
-
when={previewText()}
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1767
|
+
<Show when={previewID()} fallback={<text style={{ fg: api.theme.current.textMuted }}>Select a session to preview</text>}>
|
|
1768
|
+
<Show when={previewText()} fallback={<text style={{ fg: api.theme.current.textMuted }}>Loading…</text>}>
|
|
1769
|
+
{(text) =>
|
|
1770
|
+
getMarkdownStyle(api.theme.current) ? (
|
|
1771
|
+
<markdown
|
|
1772
|
+
content={text()}
|
|
1773
|
+
syntaxStyle={getMarkdownStyle(api.theme.current)!}
|
|
1774
|
+
treeSitterClient={getTreeSitterClient()}
|
|
1775
|
+
/>
|
|
1776
|
+
) : (
|
|
1777
|
+
<text wrapMode="word">{text()}</text>
|
|
1778
|
+
)
|
|
1779
|
+
}
|
|
1780
|
+
</Show>
|
|
1247
1781
|
</Show>
|
|
1248
1782
|
</scrollbox>
|
|
1249
1783
|
</box>
|
|
1250
1784
|
</Show>
|
|
1251
1785
|
<box paddingLeft={4} paddingRight={4} flexShrink={0}>
|
|
1252
|
-
<text
|
|
1253
|
-
|
|
1786
|
+
<text
|
|
1787
|
+
style={{
|
|
1788
|
+
fg: pendingDelete() ? api.theme.current.warning : api.theme.current.textMuted,
|
|
1789
|
+
}}
|
|
1790
|
+
>
|
|
1791
|
+
{pendingDelete()
|
|
1792
|
+
? `Delete "${truncate(pendingDelete()!.title, 40)}"? y confirm · n cancel`
|
|
1793
|
+
: "↑↓ navigate · enter open · option+s/d pin · ctrl+x delete · ctrl+f fork · ctrl+g project · ctrl+p preview · esc close"}
|
|
1254
1794
|
</text>
|
|
1255
1795
|
</box>
|
|
1256
1796
|
</box>
|