opencode-sesh 2.0.4 → 2.1.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 +2 -1
- package/CHANGELOG.md +14 -0
- package/README.md +16 -2
- package/bin/sesh-costs.sh +0 -1
- package/bin/sesh-delete.sh +4 -12
- package/bin/sesh-list.sh +37 -25
- package/bin/sesh-pins.sh +12 -9
- package/bin/sesh-prune.sh +8 -5
- package/bin/sesh-refresh-worker.sh +4 -1
- package/bin/sesh-waiting.sh +1 -2
- package/bin/sesh.sh +3 -3
- package/dist/tui.js +700 -683
- package/install.sh +24 -19
- package/package.json +4 -4
- package/plugins/sesh-list.ts +2 -2
- package/tui-plugins/sesh-panel/tui.tsx +142 -137
|
@@ -6,7 +6,7 @@ import type { Accessor } from "solid-js"
|
|
|
6
6
|
type TuiPluginApi = ReturnType<typeof createApi>
|
|
7
7
|
type TuiPlugin = (api: TuiPluginApi) => Promise<void>
|
|
8
8
|
import { createEffect, createMemo, createRoot, createSignal, For, getOwner, on, onCleanup, onMount, runWithOwner, Show } from "solid-js"
|
|
9
|
-
import { mkdir, readFile, rename, rmdir, unlink, writeFile } from "node:fs/promises"
|
|
9
|
+
import { lstat, mkdir, readFile, rename, rmdir, unlink, writeFile } from "node:fs/promises"
|
|
10
10
|
import { existsSync } from "node:fs"
|
|
11
11
|
import { dirname, join } from "node:path"
|
|
12
12
|
import { spawn, spawnSync } from "node:child_process"
|
|
@@ -103,14 +103,26 @@ const emptyPins = (): Pins => ({ sessions: [], directories: [] })
|
|
|
103
103
|
const pinsPath = () =>
|
|
104
104
|
process.env.SESH_PINS_FILE ?? join(process.env.XDG_DATA_HOME ?? join(process.env.HOME ?? "", ".local/share"), "sesh/pins.json")
|
|
105
105
|
|
|
106
|
-
async function readPins(): Promise<Pins> {
|
|
106
|
+
async function readPins(strict = false): Promise<Pins> {
|
|
107
107
|
try {
|
|
108
108
|
const data = JSON.parse(await readFile(pinsPath(), "utf8")) as Partial<Pins>
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
109
|
+
if (!data || !Array.isArray(data.sessions) || !Array.isArray(data.directories) ||
|
|
110
|
+
!data.sessions.every((id) => typeof id === "string") || !data.directories.every((dir) => typeof dir === "string")) {
|
|
111
|
+
throw new Error("invalid pins file")
|
|
112
|
+
}
|
|
113
|
+
return { sessions: data.sessions, directories: data.directories }
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (strict) {
|
|
116
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error
|
|
117
|
+
let absent = false
|
|
118
|
+
try {
|
|
119
|
+
await lstat(pinsPath())
|
|
120
|
+
} catch (statError) {
|
|
121
|
+
if ((statError as NodeJS.ErrnoException).code !== "ENOENT") throw statError
|
|
122
|
+
absent = true
|
|
123
|
+
}
|
|
124
|
+
if (!absent) throw error
|
|
112
125
|
}
|
|
113
|
-
} catch {
|
|
114
126
|
return emptyPins()
|
|
115
127
|
}
|
|
116
128
|
}
|
|
@@ -127,27 +139,15 @@ async function togglePin(kind: keyof Pins, value: string): Promise<Pins> {
|
|
|
127
139
|
break
|
|
128
140
|
} catch (error) {
|
|
129
141
|
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error
|
|
130
|
-
|
|
131
|
-
const owner = Number((await readFile(join(lock, "pid"), "utf8")).trim())
|
|
132
|
-
if (Number.isSafeInteger(owner) && owner > 0) {
|
|
133
|
-
try {
|
|
134
|
-
process.kill(owner, 0)
|
|
135
|
-
} catch (check) {
|
|
136
|
-
if ((check as NodeJS.ErrnoException).code === "ESRCH") {
|
|
137
|
-
await unlink(join(lock, "pid")).catch(() => {})
|
|
138
|
-
await rmdir(lock).catch(() => {})
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
} catch {}
|
|
142
|
+
// Only the owner removes a lock; stale reclamation can race a new owner.
|
|
143
143
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
144
144
|
}
|
|
145
145
|
}
|
|
146
|
-
if (!locked) throw new Error(
|
|
146
|
+
if (!locked) throw new Error(`pins lock is busy: ${lock}; if it persists, stop all sesh pickers and OpenCode instances before removing this lock directory`)
|
|
147
147
|
const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}`
|
|
148
148
|
try {
|
|
149
149
|
await writeFile(join(lock, "pid"), `${process.pid}\n`)
|
|
150
|
-
const next = await readPins()
|
|
150
|
+
const next = await readPins(true)
|
|
151
151
|
next[kind] = next[kind].includes(value) ? next[kind].filter((item) => item !== value) : [...next[kind], value]
|
|
152
152
|
await writeFile(tmp, JSON.stringify(next) + "\n", { mode: 0o600 })
|
|
153
153
|
await rename(tmp, file)
|
|
@@ -155,7 +155,7 @@ async function togglePin(kind: keyof Pins, value: string): Promise<Pins> {
|
|
|
155
155
|
} finally {
|
|
156
156
|
await unlink(tmp).catch(() => {})
|
|
157
157
|
await unlink(join(lock, "pid")).catch(() => {})
|
|
158
|
-
await rmdir(lock)
|
|
158
|
+
await rmdir(lock).catch(() => {})
|
|
159
159
|
}
|
|
160
160
|
}
|
|
161
161
|
|
|
@@ -277,6 +277,10 @@ const LEFT_BUTTON = 0
|
|
|
277
277
|
const RIGHT_BUTTON = 2
|
|
278
278
|
|
|
279
279
|
function openInCmuxWorkspace(api: TuiPluginApi, entry: Entry) {
|
|
280
|
+
if (!SESSION_ID_PATTERN.test(entry.id)) {
|
|
281
|
+
api.ui.toast({ message: "Invalid session ID", variant: "error" })
|
|
282
|
+
return
|
|
283
|
+
}
|
|
280
284
|
if (!process.env.CMUX_WORKSPACE_ID) {
|
|
281
285
|
api.ui.toast({ message: "Right-click opens a new workspace only inside cmux", variant: "info" })
|
|
282
286
|
return
|
|
@@ -445,8 +449,9 @@ function sidebarMarker(current: boolean, live?: string): SidebarMarker {
|
|
|
445
449
|
const SESSION_PAGE_LIMIT = 200
|
|
446
450
|
const SESSION_MAX = 5000
|
|
447
451
|
|
|
448
|
-
async function fetchEntries(api: TuiPluginApi): Promise<EntryResult> {
|
|
452
|
+
async function fetchEntries(api: TuiPluginApi, active = () => true): Promise<EntryResult> {
|
|
449
453
|
const home = process.env.HOME ?? ""
|
|
454
|
+
if (!active()) return { entries: [], truncated: false }
|
|
450
455
|
const projectResult = await api.client.project.list()
|
|
451
456
|
const projects = ((projectResult?.data ?? projectResult) as Project[]) ?? []
|
|
452
457
|
const projectName = new Map<string, string>(
|
|
@@ -456,6 +461,7 @@ async function fetchEntries(api: TuiPluginApi): Promise<EntryResult> {
|
|
|
456
461
|
let cursor: string | undefined
|
|
457
462
|
let truncated = false
|
|
458
463
|
for (;;) {
|
|
464
|
+
if (!active()) break
|
|
459
465
|
const result = await api.client.experimental.session.list({
|
|
460
466
|
limit: SESSION_PAGE_LIMIT,
|
|
461
467
|
roots: true,
|
|
@@ -503,16 +509,19 @@ type IndexProgress = { indexed: number; total: number; complete: boolean }
|
|
|
503
509
|
|
|
504
510
|
const nextTick = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
|
|
505
511
|
|
|
512
|
+
let resolvedDbPath: string | undefined
|
|
513
|
+
|
|
506
514
|
function resolveDbPath(): string {
|
|
507
515
|
if (process.env.SESH_DB) return process.env.SESH_DB
|
|
516
|
+
if (resolvedDbPath) return resolvedDbPath
|
|
508
517
|
const home = process.env.HOME ?? ""
|
|
509
518
|
const dataHome = process.env.XDG_DATA_HOME ?? `${home}/.local/share`
|
|
510
519
|
const xdgPath = `${dataHome}/opencode/opencode.db`
|
|
511
|
-
if (existsSync(xdgPath)) return xdgPath
|
|
520
|
+
if (existsSync(xdgPath)) return resolvedDbPath = xdgPath
|
|
512
521
|
try {
|
|
513
522
|
const result = spawnSync(process.env.SESH_OPENCODE ?? "opencode", ["debug", "paths"], { encoding: "utf8" })
|
|
514
523
|
const match = result.stdout?.match(/^db\s+(.+)$/m)
|
|
515
|
-
if (match) return match[1].trimEnd()
|
|
524
|
+
if (result.status === 0 && match && existsSync(match[1].trimEnd())) return resolvedDbPath = match[1].trimEnd()
|
|
516
525
|
} catch {}
|
|
517
526
|
return xdgPath
|
|
518
527
|
}
|
|
@@ -528,6 +537,62 @@ async function openTranscriptDb(): Promise<any | undefined> {
|
|
|
528
537
|
}
|
|
529
538
|
}
|
|
530
539
|
|
|
540
|
+
type SessionSnapshot = { entries: Entry[]; waiting: Map<string, string>; failed: boolean }
|
|
541
|
+
|
|
542
|
+
function createSessionPoll(api: TuiPluginApi, refreshPins: () => void) {
|
|
543
|
+
const listeners = new Set<(snapshot: SessionSnapshot) => void>()
|
|
544
|
+
let snapshot: SessionSnapshot = { entries: [], waiting: new Map(), failed: false }
|
|
545
|
+
let clock: ReturnType<typeof setInterval> | undefined
|
|
546
|
+
let inFlight: Promise<void> | undefined
|
|
547
|
+
const active = () => listeners.size > 0
|
|
548
|
+
const load = () => {
|
|
549
|
+
if (inFlight || !active()) return
|
|
550
|
+
inFlight = (async () => {
|
|
551
|
+
refreshPins()
|
|
552
|
+
try {
|
|
553
|
+
const result = await fetchEntries(api, active)
|
|
554
|
+
if (!active()) return
|
|
555
|
+
const previous = new Map(snapshot.entries.map((entry) => [entry.id, entry]))
|
|
556
|
+
const entries = result.entries.map((entry) => {
|
|
557
|
+
const old = previous.get(entry.id)
|
|
558
|
+
return old && old.title === entry.title && old.dir === entry.dir && old.group === entry.group && old.updated === entry.updated ? old : entry
|
|
559
|
+
})
|
|
560
|
+
const unchanged = entries.length === snapshot.entries.length && entries.every((entry, i) => entry === snapshot.entries[i])
|
|
561
|
+
snapshot = { ...snapshot, entries: unchanged ? snapshot.entries : entries, failed: false }
|
|
562
|
+
} catch {
|
|
563
|
+
snapshot = { ...snapshot, failed: true }
|
|
564
|
+
}
|
|
565
|
+
if (!active()) return
|
|
566
|
+
const db = await openTranscriptDb()
|
|
567
|
+
if (db) {
|
|
568
|
+
try {
|
|
569
|
+
if (active()) snapshot = { ...snapshot, waiting: await queryWaitingIds(db) }
|
|
570
|
+
} catch {
|
|
571
|
+
// Keep the previous waiting set when the local store is unavailable.
|
|
572
|
+
} finally {
|
|
573
|
+
db.close()
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
for (const listener of listeners) listener(snapshot)
|
|
577
|
+
})().finally(() => { inFlight = undefined })
|
|
578
|
+
}
|
|
579
|
+
return (listener: (snapshot: SessionSnapshot) => void) => {
|
|
580
|
+
listeners.add(listener)
|
|
581
|
+
listener(snapshot)
|
|
582
|
+
if (!clock) {
|
|
583
|
+
load()
|
|
584
|
+
clock = setInterval(load, POLL_MS)
|
|
585
|
+
}
|
|
586
|
+
return () => {
|
|
587
|
+
listeners.delete(listener)
|
|
588
|
+
if (!active() && clock) {
|
|
589
|
+
clearInterval(clock)
|
|
590
|
+
clock = undefined
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
531
596
|
function costRowsQuery(cutoff: number, includeLegacy: boolean): string {
|
|
532
597
|
const legacy = includeLegacy ? `UNION ALL
|
|
533
598
|
SELECT s.directory, s.id AS session_id, m.time_created, m.data
|
|
@@ -654,7 +719,7 @@ function addTranscript(index: Map<string, string>, sid: string, data: string): v
|
|
|
654
719
|
}
|
|
655
720
|
for (const text of texts) {
|
|
656
721
|
const base = index.get(sid) ?? ""
|
|
657
|
-
index.set(sid, `${base} ${text
|
|
722
|
+
index.set(sid, `${base} ${text}`.trim())
|
|
658
723
|
}
|
|
659
724
|
} catch {
|
|
660
725
|
// ignore malformed messages
|
|
@@ -668,22 +733,25 @@ async function buildSearchIndex(
|
|
|
668
733
|
api: TuiPluginApi,
|
|
669
734
|
entries: Entry[],
|
|
670
735
|
onProgress?: (progress: IndexProgress, index: Map<string, string>) => void,
|
|
736
|
+
active = () => true,
|
|
671
737
|
): Promise<Map<string, string>> {
|
|
672
738
|
const index = new Map<string, string>()
|
|
673
739
|
for (const entry of entries) {
|
|
674
|
-
index.set(entry.id, `${entry.title
|
|
740
|
+
index.set(entry.id, `${entry.title} ${entry.dir}`)
|
|
675
741
|
}
|
|
676
742
|
const ids = entries.map((entry) => entry.id).filter((id) => SESSION_ID_PATTERN.test(id))
|
|
677
743
|
const remaining = new Set(ids)
|
|
678
744
|
const total = ids.length
|
|
679
745
|
let indexed = 0
|
|
680
|
-
const report = (complete: boolean) => onProgress?.({ indexed, total, complete }, index)
|
|
746
|
+
const report = (complete: boolean) => { if (active()) onProgress?.({ indexed, total, complete }, index) }
|
|
681
747
|
report(false)
|
|
682
748
|
|
|
749
|
+
if (!active()) return index
|
|
683
750
|
const db = await openTranscriptDb()
|
|
684
751
|
if (db) {
|
|
685
752
|
try {
|
|
686
753
|
for (let i = 0; i < ids.length; i += TRANSCRIPT_BATCH) {
|
|
754
|
+
if (!active()) break
|
|
687
755
|
const batch = ids.slice(i, i + TRANSCRIPT_BATCH)
|
|
688
756
|
const placeholders = batch.map(() => "?").join(",")
|
|
689
757
|
const rows = (db
|
|
@@ -704,11 +772,11 @@ async function buildSearchIndex(
|
|
|
704
772
|
}
|
|
705
773
|
}
|
|
706
774
|
|
|
707
|
-
if (remaining.size > 0) {
|
|
775
|
+
if (active() && remaining.size > 0) {
|
|
708
776
|
await buildSearchIndexRemote(api, [...remaining], index, () => {
|
|
709
777
|
indexed += 1
|
|
710
778
|
if (indexed % TRANSCRIPT_BATCH === 0) report(false)
|
|
711
|
-
})
|
|
779
|
+
}, active)
|
|
712
780
|
}
|
|
713
781
|
report(true)
|
|
714
782
|
return index
|
|
@@ -719,6 +787,7 @@ async function buildSearchIndexRemote(
|
|
|
719
787
|
ids: string[],
|
|
720
788
|
index: Map<string, string>,
|
|
721
789
|
onEach: () => void,
|
|
790
|
+
active = () => true,
|
|
722
791
|
): Promise<void> {
|
|
723
792
|
const home = process.env.HOME ?? ""
|
|
724
793
|
const cacheDir = process.env.SESH_CACHE_DIR ?? `${process.env.XDG_CACHE_HOME ?? `${home}/.cache`}/sesh`
|
|
@@ -726,12 +795,14 @@ async function buildSearchIndexRemote(
|
|
|
726
795
|
// Bounded concurrency: a fixed worker pool instead of one request per session.
|
|
727
796
|
const worker = async () => {
|
|
728
797
|
for (;;) {
|
|
798
|
+
if (!active()) return
|
|
729
799
|
const i = cursor++
|
|
730
800
|
if (i >= ids.length) return
|
|
731
801
|
const id = ids[i]
|
|
732
802
|
const base = index.get(id) ?? ""
|
|
733
803
|
try {
|
|
734
804
|
const raw = await readFile(`${cacheDir}/extractions/${id}.json`, "utf8")
|
|
805
|
+
if (!active()) return
|
|
735
806
|
const record = JSON.parse(raw) as { fulltextLower?: unknown; title?: unknown }
|
|
736
807
|
const fulltext = typeof record?.fulltextLower === "string" ? record.fulltextLower : ""
|
|
737
808
|
// Extraction caches prefix the transcript with the cached title. The
|
|
@@ -741,8 +812,10 @@ async function buildSearchIndexRemote(
|
|
|
741
812
|
const transcript = title && fulltext.startsWith(title) ? fulltext.slice(title.length) : fulltext
|
|
742
813
|
index.set(id, `${base} ${transcript}`.trim())
|
|
743
814
|
} catch {
|
|
815
|
+
if (!active()) return
|
|
744
816
|
try {
|
|
745
|
-
const result = await api.client.session.messages({ sessionID: id })
|
|
817
|
+
const result = await api.client.session.messages({ sessionID: id, active })
|
|
818
|
+
if (!active()) return
|
|
746
819
|
const messages = result.data ?? []
|
|
747
820
|
const text = messages
|
|
748
821
|
.flatMap((message: any) =>
|
|
@@ -751,7 +824,6 @@ async function buildSearchIndexRemote(
|
|
|
751
824
|
.map((part: any) => (part as { text: string }).text),
|
|
752
825
|
)
|
|
753
826
|
.join(" ")
|
|
754
|
-
.toLowerCase()
|
|
755
827
|
index.set(id, `${base} ${text}`.trim())
|
|
756
828
|
} catch {}
|
|
757
829
|
}
|
|
@@ -761,13 +833,13 @@ async function buildSearchIndexRemote(
|
|
|
761
833
|
await Promise.all(Array.from({ length: Math.min(REMOTE_CONCURRENCY, ids.length) }, worker))
|
|
762
834
|
}
|
|
763
835
|
|
|
764
|
-
// The index is title + directory + text parts
|
|
836
|
+
// The index is title + directory + text parts. Start looking
|
|
765
837
|
// after the metadata so a title match never masquerades as a transcript hit.
|
|
766
838
|
function transcriptMatchExcerpt(indexed: string | undefined, entry: Entry, query: string): string | undefined {
|
|
767
839
|
const needle = query.trim().toLowerCase()
|
|
768
840
|
if (!indexed || !needle) return undefined
|
|
769
|
-
const transcriptStart = `${entry.title
|
|
770
|
-
const at = indexed.indexOf(needle, transcriptStart)
|
|
841
|
+
const transcriptStart = `${entry.title} ${entry.dir}`.length + 1
|
|
842
|
+
const at = indexed.toLowerCase().indexOf(needle, transcriptStart)
|
|
771
843
|
if (at < 0) return undefined
|
|
772
844
|
const start = Math.max(transcriptStart, at - 28)
|
|
773
845
|
const end = Math.min(indexed.length, Math.max(at + needle.length + 28, start + 90))
|
|
@@ -787,7 +859,7 @@ function filterPickerEntries(
|
|
|
787
859
|
if (filters.waitingOnly && !waitingIds.has(entry.id)) return false
|
|
788
860
|
if (filters.pinnedOnly && !pinned.has(entry.id)) return false
|
|
789
861
|
if (!q) return true
|
|
790
|
-
return (index.get(entry.id) ?? `${entry.title} ${entry.dir}
|
|
862
|
+
return (index.get(entry.id) ?? `${entry.title} ${entry.dir}`).toLowerCase().includes(q)
|
|
791
863
|
})
|
|
792
864
|
}
|
|
793
865
|
|
|
@@ -994,7 +1066,7 @@ function pickerLastStart(rows: PickerRow[], height: number, excerpt: (entry: Ent
|
|
|
994
1066
|
return start
|
|
995
1067
|
}
|
|
996
1068
|
|
|
997
|
-
type PinProps = { pins: () => Pins; onTogglePin: (kind: keyof Pins, value: string) => void;
|
|
1069
|
+
type PinProps = { pins: () => Pins; onTogglePin: (kind: keyof Pins, value: string) => void; poll: ReturnType<typeof createSessionPoll> }
|
|
998
1070
|
|
|
999
1071
|
function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
1000
1072
|
const theme = () => props.api.theme.current
|
|
@@ -1022,42 +1094,11 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1022
1094
|
return params && typeof params.sessionID === "string" ? params.sessionID : undefined
|
|
1023
1095
|
})
|
|
1024
1096
|
|
|
1025
|
-
onMount(() => {
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
const { entries: all } = await fetchEntries(props.api)
|
|
1031
|
-
if (alive) {
|
|
1032
|
-
setEntries(all)
|
|
1033
|
-
setLoadFailed(false)
|
|
1034
|
-
}
|
|
1035
|
-
} catch {
|
|
1036
|
-
// leave the previous list in place rather than flashing to blank, but
|
|
1037
|
-
// say so when there is nothing at all to show
|
|
1038
|
-
if (alive && entries().length === 0) setLoadFailed(true)
|
|
1039
|
-
}
|
|
1040
|
-
try {
|
|
1041
|
-
const db = await openTranscriptDb()
|
|
1042
|
-
if (db) {
|
|
1043
|
-
try {
|
|
1044
|
-
const ids = await queryWaitingIds(db)
|
|
1045
|
-
if (alive) setWaiting(ids)
|
|
1046
|
-
} finally {
|
|
1047
|
-
db.close()
|
|
1048
|
-
}
|
|
1049
|
-
}
|
|
1050
|
-
} catch {
|
|
1051
|
-
// leave the previous waiting set in place rather than flashing
|
|
1052
|
-
}
|
|
1053
|
-
}
|
|
1054
|
-
void load()
|
|
1055
|
-
const clock = setInterval(() => void load(), POLL_MS)
|
|
1056
|
-
onCleanup(() => {
|
|
1057
|
-
alive = false
|
|
1058
|
-
clearInterval(clock)
|
|
1059
|
-
})
|
|
1060
|
-
})
|
|
1097
|
+
onMount(() => onCleanup(props.poll((snapshot) => {
|
|
1098
|
+
setEntries(snapshot.entries)
|
|
1099
|
+
setLoadFailed(snapshot.failed)
|
|
1100
|
+
setWaiting(snapshot.waiting)
|
|
1101
|
+
})))
|
|
1061
1102
|
|
|
1062
1103
|
const filteredEntries = createMemo(() => {
|
|
1063
1104
|
const q = query().trim().toLowerCase()
|
|
@@ -1069,7 +1110,6 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1069
1110
|
})
|
|
1070
1111
|
|
|
1071
1112
|
const shownEntries = createMemo(() => filteredEntries())
|
|
1072
|
-
const remaining = createMemo(() => Math.max(0, filteredEntries().length - shownEntries().length))
|
|
1073
1113
|
|
|
1074
1114
|
const NEEDS_INPUT_DIR = "__needs_input__"
|
|
1075
1115
|
const waitingEntries = createMemo(() => {
|
|
@@ -1336,7 +1376,6 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1336
1376
|
let disposeSearch: (() => void) | undefined
|
|
1337
1377
|
let disposeNav: (() => void) | undefined
|
|
1338
1378
|
let disposeConfirm: (() => void) | undefined
|
|
1339
|
-
let insideSearchBox = false
|
|
1340
1379
|
|
|
1341
1380
|
createEffect(() => {
|
|
1342
1381
|
disposeConfirm?.()
|
|
@@ -1379,10 +1418,6 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1379
1418
|
const onRootMouseDown = () => {
|
|
1380
1419
|
menu.close()
|
|
1381
1420
|
preview.closeIfOpen()
|
|
1382
|
-
if (insideSearchBox) {
|
|
1383
|
-
insideSearchBox = false
|
|
1384
|
-
return
|
|
1385
|
-
}
|
|
1386
1421
|
if (searching()) setSearching(false)
|
|
1387
1422
|
// A click anywhere outside the sidebar means input is back in the main
|
|
1388
1423
|
// view: release the nav layer, hover shortcuts and any armed delete so
|
|
@@ -1421,12 +1456,12 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1421
1456
|
|
|
1422
1457
|
const openSidebarPreview = (entry: Entry) => preview.open(entry)
|
|
1423
1458
|
|
|
1459
|
+
const hoveredEntry = createMemo(() => entries().find((entry) => entry.id === hovered()))
|
|
1424
1460
|
createEffect(() => {
|
|
1425
1461
|
disposeHoverSpace?.()
|
|
1426
1462
|
disposeHoverSpace = undefined
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
const entry = entries().find((e) => e.id === id)
|
|
1463
|
+
if (navActive()) return
|
|
1464
|
+
const entry = hoveredEntry()
|
|
1430
1465
|
if (!entry) return
|
|
1431
1466
|
disposeHoverSpace = props.api.keymap.registerLayer({
|
|
1432
1467
|
mode: "base",
|
|
@@ -1534,19 +1569,6 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1534
1569
|
setNavActive(false)
|
|
1535
1570
|
},
|
|
1536
1571
|
},
|
|
1537
|
-
...(pendingDelete()
|
|
1538
|
-
? [
|
|
1539
|
-
{
|
|
1540
|
-
key: "y",
|
|
1541
|
-
desc: "Confirm delete",
|
|
1542
|
-
preventDefault: true,
|
|
1543
|
-
cmd: () => {
|
|
1544
|
-
confirmArmed()
|
|
1545
|
-
},
|
|
1546
|
-
},
|
|
1547
|
-
{ key: "n", desc: "Cancel delete", preventDefault: true, cmd: cancelDelete },
|
|
1548
|
-
]
|
|
1549
|
-
: []),
|
|
1550
1572
|
]
|
|
1551
1573
|
disposeNav = props.api.keymap.registerLayer({ mode: "base", priority: 20, bindings })
|
|
1552
1574
|
})
|
|
@@ -1606,7 +1628,6 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1606
1628
|
// Sidebar-internal: the root handler must not see this click, or
|
|
1607
1629
|
// it would clear the search this box just activated.
|
|
1608
1630
|
event.stopPropagation()
|
|
1609
|
-
insideSearchBox = true
|
|
1610
1631
|
setSearching(true)
|
|
1611
1632
|
}}
|
|
1612
1633
|
>
|
|
@@ -1720,9 +1741,6 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1720
1741
|
}}
|
|
1721
1742
|
</For>
|
|
1722
1743
|
</scrollbox>
|
|
1723
|
-
<Show when={remaining() > 0}>
|
|
1724
|
-
<text style={{ fg: theme().textMuted }}>{`… ${remaining()} more · option+o for all`}</text>
|
|
1725
|
-
</Show>
|
|
1726
1744
|
<Show when={query().trim() && filteredEntries().length === 0}>
|
|
1727
1745
|
<text style={{ fg: theme().textMuted }}>No title or directory matches · option+o to search transcripts</text>
|
|
1728
1746
|
</Show>
|
|
@@ -1817,7 +1835,6 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1817
1835
|
const preview = createTranscriptPreview(props.api)
|
|
1818
1836
|
let disposeHoverSpace: (() => void) | undefined
|
|
1819
1837
|
let disposeSearch: (() => void) | undefined
|
|
1820
|
-
let insideSearchBox = false
|
|
1821
1838
|
|
|
1822
1839
|
const cancelDelete = () => {
|
|
1823
1840
|
if (confirmTimer) clearTimeout(confirmTimer)
|
|
@@ -1895,11 +1912,9 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1895
1912
|
|
|
1896
1913
|
const onRootMouseDown = () => {
|
|
1897
1914
|
preview.closeIfOpen()
|
|
1898
|
-
if (insideSearchBox) {
|
|
1899
|
-
insideSearchBox = false
|
|
1900
|
-
return
|
|
1901
|
-
}
|
|
1902
1915
|
if (searching()) setSearching(false)
|
|
1916
|
+
cancelDelete()
|
|
1917
|
+
setHovered(undefined)
|
|
1903
1918
|
}
|
|
1904
1919
|
|
|
1905
1920
|
// A modal dialog owns input while open: yield this list's keyboard captures
|
|
@@ -1934,12 +1949,11 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1934
1949
|
})
|
|
1935
1950
|
})
|
|
1936
1951
|
|
|
1952
|
+
const hoveredEntry = createMemo(() => entries().find((entry) => entry.id === hovered()))
|
|
1937
1953
|
createEffect(() => {
|
|
1938
1954
|
disposeHoverSpace?.()
|
|
1939
1955
|
disposeHoverSpace = undefined
|
|
1940
|
-
const
|
|
1941
|
-
if (!id) return
|
|
1942
|
-
const entry = entries().find((e) => e.id === id)
|
|
1956
|
+
const entry = hoveredEntry()
|
|
1943
1957
|
if (!entry) return
|
|
1944
1958
|
disposeHoverSpace = props.api.keymap.registerLayer({
|
|
1945
1959
|
mode: "base",
|
|
@@ -1972,27 +1986,10 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1972
1986
|
if (confirmTimer) clearTimeout(confirmTimer)
|
|
1973
1987
|
})
|
|
1974
1988
|
|
|
1975
|
-
onMount(() => {
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
try {
|
|
1980
|
-
const { entries: all } = await fetchEntries(props.api)
|
|
1981
|
-
if (alive) {
|
|
1982
|
-
setEntries(all)
|
|
1983
|
-
setLoadFailed(false)
|
|
1984
|
-
}
|
|
1985
|
-
} catch {
|
|
1986
|
-
if (alive && entries().length === 0) setLoadFailed(true)
|
|
1987
|
-
}
|
|
1988
|
-
}
|
|
1989
|
-
void load()
|
|
1990
|
-
const clock = setInterval(() => void load(), POLL_MS)
|
|
1991
|
-
onCleanup(() => {
|
|
1992
|
-
alive = false
|
|
1993
|
-
clearInterval(clock)
|
|
1994
|
-
})
|
|
1995
|
-
})
|
|
1989
|
+
onMount(() => onCleanup(props.poll((snapshot) => {
|
|
1990
|
+
setEntries(snapshot.entries)
|
|
1991
|
+
setLoadFailed(snapshot.failed)
|
|
1992
|
+
})))
|
|
1996
1993
|
|
|
1997
1994
|
const visible = createMemo(() => {
|
|
1998
1995
|
const q = query().trim().toLowerCase()
|
|
@@ -2029,8 +2026,8 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
2029
2026
|
paddingLeft={1}
|
|
2030
2027
|
paddingRight={1}
|
|
2031
2028
|
backgroundColor={searching() ? theme().backgroundElement : RGBA.fromInts(0, 0, 0, 0)}
|
|
2032
|
-
onMouseDown={() => {
|
|
2033
|
-
|
|
2029
|
+
onMouseDown={(event: { stopPropagation: () => void }) => {
|
|
2030
|
+
event.stopPropagation()
|
|
2034
2031
|
setSearching(true)
|
|
2035
2032
|
}}
|
|
2036
2033
|
>
|
|
@@ -2108,6 +2105,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2108
2105
|
})
|
|
2109
2106
|
}
|
|
2110
2107
|
refreshPins()
|
|
2108
|
+
const poll = createSessionPoll(api, refreshPins)
|
|
2111
2109
|
const onTogglePin = (kind: keyof Pins, value: string) => {
|
|
2112
2110
|
if (!value) return
|
|
2113
2111
|
pinVersion += 1
|
|
@@ -2123,10 +2121,10 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2123
2121
|
order: 100,
|
|
2124
2122
|
slots: {
|
|
2125
2123
|
sidebar_content() {
|
|
2126
|
-
return <SidebarSessions api={api} pins={pins} onTogglePin={onTogglePin}
|
|
2124
|
+
return <SidebarSessions api={api} pins={pins} onTogglePin={onTogglePin} poll={poll} />
|
|
2127
2125
|
},
|
|
2128
2126
|
home_bottom() {
|
|
2129
|
-
return <HomeSessions api={api} pins={pins} onTogglePin={onTogglePin}
|
|
2127
|
+
return <HomeSessions api={api} pins={pins} onTogglePin={onTogglePin} poll={poll} />
|
|
2130
2128
|
},
|
|
2131
2129
|
},
|
|
2132
2130
|
})
|
|
@@ -2195,7 +2193,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2195
2193
|
// Publish a fresh snapshot: the indexing workers mutate their own map,
|
|
2196
2194
|
// while Solid needs a new reference to update search results mid-scan.
|
|
2197
2195
|
setSearchIndex(new Map(index))
|
|
2198
|
-
})
|
|
2196
|
+
}, () => alive)
|
|
2199
2197
|
const orderedEntries = createMemo(() => pinnedFirst(allEntries(), pins()))
|
|
2200
2198
|
const currentProject = () => {
|
|
2201
2199
|
const route = api.route.current
|
|
@@ -2368,9 +2366,12 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2368
2366
|
|
|
2369
2367
|
// Match OpenTUI's scrollbox: honor every wheel event's delta. The viewport
|
|
2370
2368
|
// stays independent of selection, so fast scrolling cannot recenter on hover.
|
|
2369
|
+
let scrollRemainder = 0
|
|
2371
2370
|
const scrollPicker = (scroll?: { direction: string; delta: number }) => {
|
|
2372
2371
|
if (showPreview() || !scroll || (scroll.direction !== "up" && scroll.direction !== "down")) return
|
|
2373
|
-
|
|
2372
|
+
scrollRemainder += scroll.delta * (scroll.direction === "down" ? 1 : -1)
|
|
2373
|
+
const amount = Math.trunc(scrollRemainder)
|
|
2374
|
+
scrollRemainder -= amount
|
|
2374
2375
|
setViewportStart((start) => Math.max(0, Math.min(maxViewportStart(), start + amount)))
|
|
2375
2376
|
}
|
|
2376
2377
|
|
|
@@ -3164,9 +3165,10 @@ const tui: TuiPlugin = async (api) => {
|
|
|
3164
3165
|
},
|
|
3165
3166
|
])
|
|
3166
3167
|
|
|
3167
|
-
api.keymap.registerLayer({
|
|
3168
|
+
const disposePickerKey = api.keymap.registerLayer({
|
|
3168
3169
|
bindings: [{ key: "alt+o", desc: "Pick session", preventDefault: true, cmd: () => void openPicker() }],
|
|
3169
3170
|
})
|
|
3171
|
+
onCleanup(disposePickerKey)
|
|
3170
3172
|
}
|
|
3171
3173
|
|
|
3172
3174
|
let hostOwner: ReturnType<typeof getOwner> = null
|
|
@@ -3334,7 +3336,9 @@ function createApi(ctx: Plugin.Context) {
|
|
|
3334
3336
|
register(cfg: { order?: number; slots: Record<string, () => any> }) {
|
|
3335
3337
|
if (cfg.slots.sidebar_content && ctx.options?.showSidebar !== false) {
|
|
3336
3338
|
ctx.ui.slot({
|
|
3337
|
-
|
|
3339
|
+
...(ctx.options?.sidebarPlacement === "prepend"
|
|
3340
|
+
? { prepend: "sidebar.content" as const }
|
|
3341
|
+
: { append: "sidebar.content" as const }),
|
|
3338
3342
|
render: cfg.slots.sidebar_content as any,
|
|
3339
3343
|
})
|
|
3340
3344
|
}
|
|
@@ -3388,10 +3392,11 @@ function createApi(ctx: Plugin.Context) {
|
|
|
3388
3392
|
const res = await ctx.client.session.fork({ sessionID: req.sessionID })
|
|
3389
3393
|
return { data: res }
|
|
3390
3394
|
},
|
|
3391
|
-
async messages(req: { sessionID: string; preview?: boolean }) {
|
|
3395
|
+
async messages(req: { sessionID: string; preview?: boolean; active?: () => boolean }) {
|
|
3392
3396
|
let cursor: string | undefined
|
|
3393
3397
|
const all = []
|
|
3394
3398
|
do {
|
|
3399
|
+
if (req.active && !req.active()) break
|
|
3395
3400
|
const res = await ctx.client.message.list({ sessionID: req.sessionID, order: req.preview ? "desc" : "asc", limit: 100, cursor })
|
|
3396
3401
|
all.push(...res.data)
|
|
3397
3402
|
const next = res.cursor.next
|