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.
@@ -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
- return {
110
- sessions: Array.isArray(data.sessions) ? data.sessions.filter((id): id is string => typeof id === "string") : [],
111
- directories: Array.isArray(data.directories) ? data.directories.filter((dir): dir is string => typeof dir === "string") : [],
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
- try {
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("pins are busy")
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.toLowerCase()}`.trim())
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.toLowerCase()} ${entry.dir.toLowerCase()}`)
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, all lowercased. Start looking
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.toLowerCase()} ${entry.dir.toLowerCase()}`.length + 1
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}`.toLowerCase()).includes(q)
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; refreshPins: () => 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
- let alive = true
1027
- const load = async () => {
1028
- props.refreshPins()
1029
- try {
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
- const id = hovered()
1428
- if (!id) return
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 id = hovered()
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
- let alive = true
1977
- const load = async () => {
1978
- props.refreshPins()
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
- insideSearchBox = true
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} refreshPins={refreshPins} />
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} refreshPins={refreshPins} />
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
- const amount = Math.trunc(scroll.delta) * (scroll.direction === "down" ? 1 : -1)
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
- prepend: "sidebar.content",
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