opencode-sesh 2.1.2 → 2.1.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-sesh",
3
- "version": "2.1.2",
3
+ "version": "2.1.4",
4
4
  "description": "OpenCode session browser: search, preview, and resume every session across every project from the terminal or TUI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -57,7 +57,8 @@
57
57
  "build": "node scripts/build-tui.mjs",
58
58
  "prepack": "node scripts/build-tui.mjs",
59
59
  "test": "bun run test:unit && bash tests/changelog.sh",
60
- "test:unit": "node scripts/build-tui.mjs && bash tests/sesh.sh && bash tests/install.sh",
60
+ "test:unit": "node scripts/build-tui.mjs && bun run test:tui-home && bash tests/sesh.sh && bash tests/install.sh",
61
+ "test:tui-home": "bun --conditions=browser tests/tui-home.mjs",
61
62
  "test:changelog": "bash tests/changelog.sh",
62
63
  "test:picker": "python3 tests/picker.py",
63
64
  "typecheck": "tsc --noEmit",
@@ -76,12 +77,12 @@
76
77
  "@babel/preset-typescript": "7.29.7",
77
78
  "@commitlint/cli": "^21.2.2",
78
79
  "@commitlint/config-conventional": "^21.2.2",
79
- "@opencode/client": "2.2.0",
80
- "@opencode/plugin": "2.2.0",
81
- "@opencode/theme": "2.2.0",
82
- "@opentui/core": "^0.7.0",
83
- "@opentui/keymap": "^0.7.0",
84
- "@opentui/solid": "^0.7.0",
80
+ "@opencode/client": "2.2.5",
81
+ "@opencode/plugin": "2.2.5",
82
+ "@opencode/theme": "2.2.5",
83
+ "@opentui/core": "^0.8.0",
84
+ "@opentui/keymap": "^0.8.0",
85
+ "@opentui/solid": "^0.8.0",
85
86
  "@types/node": "26.6.5",
86
87
  "babel-preset-solid": "1.9.16",
87
88
  "husky": "^9.1.7",
@@ -540,11 +540,17 @@ async function openTranscriptDb(): Promise<any | undefined> {
540
540
  type SessionSnapshot = { entries: Entry[]; waiting: Map<string, string>; failed: boolean }
541
541
 
542
542
  function createSessionPoll(api: TuiPluginApi, refreshPins: () => void) {
543
- const listeners = new Set<(snapshot: SessionSnapshot) => void>()
543
+ // Listener -> whether it reads `snapshot.waiting`. The needs-input query is
544
+ // synchronous on the TUI thread, so it only runs while such a listener exists.
545
+ const listeners = new Map<(snapshot: SessionSnapshot) => void, boolean>()
544
546
  let snapshot: SessionSnapshot = { entries: [], waiting: new Map(), failed: false }
545
547
  let clock: ReturnType<typeof setInterval> | undefined
546
548
  let inFlight: Promise<void> | undefined
549
+ // Whether `snapshot.waiting` came from a poll that ran the query; a poll
550
+ // without waiting listeners clears it so a later subscriber never sees a stale set.
551
+ let waitingLoaded = false
547
552
  const active = () => listeners.size > 0
553
+ const wantsWaiting = () => [...listeners.values()].some(Boolean)
548
554
  const load = () => {
549
555
  if (inFlight || !active()) return
550
556
  inFlight = (async () => {
@@ -563,25 +569,34 @@ function createSessionPoll(api: TuiPluginApi, refreshPins: () => void) {
563
569
  snapshot = { ...snapshot, failed: true }
564
570
  }
565
571
  if (!active()) return
566
- const db = await openTranscriptDb()
572
+ const db = wantsWaiting() ? await openTranscriptDb() : undefined
567
573
  if (db) {
568
574
  try {
569
- if (active()) snapshot = { ...snapshot, waiting: await queryWaitingIds(db) }
575
+ if (wantsWaiting()) {
576
+ snapshot = { ...snapshot, waiting: await queryWaitingIds(db) }
577
+ waitingLoaded = true
578
+ }
570
579
  } catch {
571
580
  // Keep the previous waiting set when the local store is unavailable.
572
581
  } finally {
573
582
  db.close()
574
583
  }
584
+ } else if (!wantsWaiting()) {
585
+ snapshot = { ...snapshot, waiting: new Map() }
586
+ waitingLoaded = false
575
587
  }
576
- for (const listener of listeners) listener(snapshot)
588
+ for (const listener of [...listeners.keys()]) listener(snapshot)
577
589
  })().finally(() => { inFlight = undefined })
578
590
  }
579
- return (listener: (snapshot: SessionSnapshot) => void) => {
580
- listeners.add(listener)
591
+ return (listener: (snapshot: SessionSnapshot) => void, options: { waiting?: boolean } = {}) => {
592
+ const needsWaiting = options.waiting === true
593
+ listeners.set(listener, needsWaiting)
581
594
  listener(snapshot)
582
595
  if (!clock) {
583
596
  load()
584
597
  clock = setInterval(load, POLL_MS)
598
+ } else if (needsWaiting && !waitingLoaded) {
599
+ load()
585
600
  }
586
601
  return () => {
587
602
  listeners.delete(listener)
@@ -1098,7 +1113,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1098
1113
  setEntries(snapshot.entries)
1099
1114
  setLoadFailed(snapshot.failed)
1100
1115
  setWaiting(snapshot.waiting)
1101
- })))
1116
+ }, { waiting: true })))
1102
1117
 
1103
1118
  const filteredEntries = createMemo(() => {
1104
1119
  const q = query().trim().toLowerCase()
@@ -1819,10 +1834,25 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1819
1834
  }
1820
1835
 
1821
1836
  const HOME_SESSION_LIMIT = 6
1837
+ // Layout baseline: OpenCode 2.2.4. Re-check these assumptions and the matching
1838
+ // tests/tui-home.mjs fixture when upgrading OpenCode: the fixture mirrors the
1839
+ // host layout, so it cannot detect upstream layout changes on its own.
1840
+ // Sources pinned to that release:
1841
+ // https://github.com/anomalyco/opencode/blob/45898d0f8569225eb1ddb4ee13f8581b34dff311/packages/tui/src/routes/home.tsx
1842
+ // https://github.com/anomalyco/opencode/blob/45898d0f8569225eb1ddb4ee13f8581b34dff311/packages/tui/src/component/prompt/index.tsx#L1676
1843
+ // https://github.com/anomalyco/opencode/blob/45898d0f8569225eb1ddb4ee13f8581b34dff311/packages/tui/src/component/logo.tsx
1844
+ // https://github.com/anomalyco/opencode/blob/45898d0f8569225eb1ddb4ee13f8581b34dff311/packages/tui/src/feature-plugins/home/footer.tsx
1845
+ // The native home needs room for its logo, composer chrome and status footer.
1846
+ // Reserve the editor's *maximum* height (one third of the terminal, at least
1847
+ // six lines), so typing/wrapping a prompt cannot push it into our footer.
1848
+ // Chrome: logo (4) + gap (1) + composer (6) + status (3). Below 44 columns,
1849
+ // the logo stacks to seven lines but the three-line status footer disappears.
1850
+ const HOME_CHROME_ROWS = 14
1822
1851
 
1823
1852
  function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
1824
1853
  const theme = () => props.api.theme.current
1825
1854
  const home = process.env.HOME ?? ""
1855
+ const [dimensions, setDimensions] = createSignal({ width: props.api.renderer.width, height: props.api.renderer.height })
1826
1856
  const [entries, setEntries] = createSignal<Entry[]>([])
1827
1857
  const [query, setQuery] = createSignal("")
1828
1858
  const [hovered, setHovered] = createSignal<string>()
@@ -1930,7 +1960,12 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
1930
1960
  onMount(() => {
1931
1961
  ensureRootMouse(props.api.renderer)
1932
1962
  rootMouseHandlers.add(onRootMouseDown)
1933
- onCleanup(() => rootMouseHandlers.delete(onRootMouseDown))
1963
+ const resize = () => setDimensions({ width: props.api.renderer.width, height: props.api.renderer.height })
1964
+ props.api.renderer.on("resize", resize)
1965
+ onCleanup(() => {
1966
+ rootMouseHandlers.delete(onRootMouseDown)
1967
+ props.api.renderer.off("resize", resize)
1968
+ })
1934
1969
  })
1935
1970
 
1936
1971
  const appendQuery = (input: string) => {
@@ -1991,37 +2026,57 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
1991
2026
  setLoadFailed(snapshot.failed)
1992
2027
  })))
1993
2028
 
1994
- const visible = createMemo(() => {
2029
+ const filtered = createMemo(() => {
1995
2030
  const q = query().trim().toLowerCase()
1996
2031
  const list = q
1997
2032
  ? entries().filter(
1998
2033
  (entry) => entry.title.toLowerCase().includes(q) || entry.dir.toLowerCase().includes(q),
1999
2034
  )
2000
2035
  : entries()
2001
- return pinnedFirst(list, props.pins()).slice(0, HOME_SESSION_LIMIT)
2036
+ return pinnedFirst(list, props.pins())
2037
+ })
2038
+ const layout = createMemo(() => {
2039
+ const rows = dimensions().height
2040
+ const budget = Math.max(0, rows - Math.max(6, Math.floor(rows / 3)) - HOME_CHROME_ROWS)
2041
+ // Drop decorative padding/spacing before sessions. Keep the full list in
2042
+ // the picker when even a heading and one session cannot fit.
2043
+ const padding = budget >= HOME_SESSION_LIMIT + 2 ? 1 : 0
2044
+ const limit = Math.min(HOME_SESSION_LIMIT, Math.max(0, budget - padding - 1))
2045
+ const count = Math.max(1, Math.min(filtered().length, limit))
2046
+ return { budget, limit, padding, gap: budget >= 1 + padding + count * 2 ? 1 : 0 }
2047
+ })
2048
+ const visible = createMemo(() => filtered().slice(0, layout().limit))
2049
+ createEffect(() => {
2050
+ const ids = new Set(visible().map((entry) => entry.id))
2051
+ if (hovered() && !ids.has(hovered()!)) setHovered(undefined)
2052
+ if (pendingDelete() && !ids.has(pendingDelete()!)) cancelDelete()
2053
+ if (layout().limit === 0) setSearching(false)
2002
2054
  })
2003
2055
 
2004
2056
  return (
2005
- <box width="100%" alignItems="center">
2006
- <box flexDirection="column" width="100%" maxWidth={72} paddingLeft={1} paddingRight={1} paddingTop={1} gap={1}>
2057
+ <box visible={layout().limit > 0} width="100%" flexShrink={0} maxHeight={layout().budget} overflow="hidden" alignItems="center">
2058
+ <box flexDirection="column" width="100%" maxWidth={72} paddingLeft={1} paddingRight={1} paddingTop={layout().padding} gap={layout().gap}>
2007
2059
  <Show
2008
2060
  when={entries().length > 0}
2009
2061
  fallback={
2010
- <box flexDirection="row" gap={1}>
2011
- <text style={{ fg: theme().textMuted }}>Recent sessions</text>
2012
- <text style={{ fg: theme().textMuted }}>
2013
- {loadFailed() ? "· list unavailable, will retry" : "· none yet, option+o to browse"}
2062
+ <box height={1} flexShrink={0}>
2063
+ <text wrapMode="none" overflow="hidden" style={{ fg: theme().textMuted }}>
2064
+ Recent sessions {loadFailed() ? "· list unavailable, will retry" : "· none yet, option+o to browse"}
2014
2065
  </text>
2015
2066
  </box>
2016
2067
  }
2017
2068
  >
2018
- <box flexDirection="row" justifyContent="space-between" gap={2}>
2019
- <box flexDirection="row" gap={1}>
2020
- <text style={{ fg: theme().textMuted }}>Recent sessions</text>
2021
- <text style={{ fg: theme().textMuted }}>· option+o for all</text>
2069
+ <box height={1} flexShrink={0} flexDirection="row" justifyContent="space-between" gap={2} overflow="hidden">
2070
+ <box flexDirection="row" gap={1} flexShrink={0}>
2071
+ <text wrapMode="none" style={{ fg: theme().textMuted }}>Recent sessions</text>
2072
+ <Show when={dimensions().width >= 68}>
2073
+ <text style={{ fg: theme().textMuted }}>· option+o for all</text>
2074
+ </Show>
2022
2075
  </box>
2023
2076
  <box
2024
2077
  flexDirection="row"
2078
+ flexShrink={1}
2079
+ minWidth={0}
2025
2080
  gap={1}
2026
2081
  paddingLeft={1}
2027
2082
  paddingRight={1}
@@ -2031,7 +2086,7 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
2031
2086
  setSearching(true)
2032
2087
  }}
2033
2088
  >
2034
- <box width={24}>
2089
+ <box width={24} flexShrink={1} minWidth={0}>
2035
2090
  <text
2036
2091
  flexGrow={1}
2037
2092
  flexShrink={1}
@@ -2053,6 +2108,9 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
2053
2108
  <For each={visible()}>
2054
2109
  {(entry) => (
2055
2110
  <box
2111
+ height={1}
2112
+ flexShrink={0}
2113
+ overflow="hidden"
2056
2114
  flexDirection="row"
2057
2115
  gap={1}
2058
2116
  backgroundColor={entry.id === hovered() ? theme().backgroundElement : RGBA.fromInts(0, 0, 0, 0)}
@@ -2073,7 +2131,13 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
2073
2131
  color={theme().text}
2074
2132
  matchColor={theme().warning}
2075
2133
  />
2076
- <text flexShrink={0} style={{ fg: pendingDelete() === entry.id ? theme().error : theme().textMuted }}>
2134
+ <text
2135
+ flexShrink={1}
2136
+ maxWidth={Math.max(0, Math.floor((Math.min(72, dimensions().width) - 4) / 2))}
2137
+ wrapMode="none"
2138
+ overflow="hidden"
2139
+ style={{ fg: pendingDelete() === entry.id ? theme().error : theme().textMuted }}
2140
+ >
2077
2141
  {pendingDelete() === entry.id
2078
2142
  ? "option+d again"
2079
2143
  : `${props.pins().directories.includes(entry.dir) ? "★ " : ""}${prettyDir(entry.dir, home)} · ${ago(entry.updated)}`}
@@ -2082,7 +2146,7 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
2082
2146
  )}
2083
2147
  </For>
2084
2148
  <Show when={visible().length === 0}>
2085
- <text style={{ fg: theme().textMuted }}>No title or directory matches · option+o to search transcripts</text>
2149
+ <text height={1} flexShrink={0} wrapMode="none" overflow="hidden" style={{ fg: theme().textMuted }}>No title or directory matches · option+o to search transcripts</text>
2086
2150
  </Show>
2087
2151
  </Show>
2088
2152
  </box>