opencode-sesh 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.10.0] - 2026-09-25
11
+
12
+ ### Changed
13
+
14
+ - The TUI session picker is taller (about three quarters of the screen) and
15
+ stays vertically centred; hovering a row moves the selection.
16
+ - `ctrl+p` opens the transcript preview as a centred overlay on top of the
17
+ picker that follows the hovered session and closes with Esc.
10
18
  ## [0.9.1] - 2026-09-25
11
19
 
12
20
  ### Changed
@@ -352,7 +360,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
352
360
  - Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
353
361
  CI.
354
362
 
355
- [Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.9.1...HEAD
363
+ [Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.10.0...HEAD
364
+ [0.10.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.9.1...v0.10.0
356
365
  [0.9.1]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.9.0...v0.9.1
357
366
  [0.9.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.8.0...v0.9.0
358
367
  [0.8.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.7.3...v0.8.0
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # sesh
2
2
 
3
+ [![CI](https://github.com/ozandogrultan/opencode-sesh/actions/workflows/ci.yml/badge.svg)](https://github.com/ozandogrultan/opencode-sesh/actions/workflows/ci.yml)
4
+
3
5
  **A session browser for [opencode](https://opencode.ai).** Search every session
4
6
  across every project, preview the transcript, and resume in place — from your
5
7
  terminal or from inside the TUI.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "opencode-sesh",
3
- "version": "0.9.1",
4
- "type": "module",
3
+ "version": "0.10.0",
5
4
  "description": "Browse, search, preview and resume opencode sessions from the terminal and the TUI.",
5
+ "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Ozan Dogrultan",
8
8
  "repository": {
@@ -44,12 +44,13 @@
44
44
  },
45
45
  "scripts": {
46
46
  "prepare": "husky",
47
- "postinstall": "bash install.sh --sync-panel || true",
48
47
  "test": "bash tests/sesh.sh && bash tests/changelog.sh",
48
+ "test:unit": "bash tests/sesh.sh",
49
49
  "test:changelog": "bash tests/changelog.sh",
50
50
  "test:picker": "python3 tests/picker.py",
51
- "typecheck": "tsc -p tsconfig.json",
51
+ "typecheck": "tsc --noEmit",
52
52
  "lint:sh": "for script in bin/*.sh bin/sesh install.sh tests/*.sh scripts/*.sh; do bash -n \"$script\" || exit; done",
53
+ "postinstall": "bash install.sh --sync-panel || true",
53
54
  "prepublishOnly": "bun run test && bun run typecheck"
54
55
  },
55
56
  "devDependencies": {
@@ -1646,13 +1646,23 @@ const tui: TuiPlugin = async (api) => {
1646
1646
  })
1647
1647
 
1648
1648
  const termHeight = api.renderer.height
1649
- const listHeight = Math.max(6, Math.floor(termHeight / 2) - 12)
1649
+ // The dialog host anchors content a quarter of the way down the screen and
1650
+ // lets it size to content, so a taller picker drifts downward. Pin the
1651
+ // content height and offset it up by the same amount to keep it centred.
1652
+ const dialogWidth = Math.min(116, api.renderer.width - 2)
1653
+ const CHROME_ROWS = 12
1654
+ const listHeight = () => Math.max(6, Math.floor(termHeight * 0.75) - CHROME_ROWS)
1655
+ const dialogHeight = () => listHeight() + CHROME_ROWS
1656
+ const dialogOffset = () => Math.min(0, Math.floor(termHeight / 4 - dialogHeight() / 2 - 1))
1657
+ const previewWidth = () => Math.max(40, Math.min(94, dialogWidth - 6))
1658
+ const previewHeight = () => Math.max(8, Math.floor(termHeight / 2))
1650
1659
 
1651
1660
  const visiblePickerRows = createMemo<DialogRow[]>(() => {
1652
1661
  const flat = pickerRows()
1653
1662
  // A transcript hit adds a second line to a row; bound by screen height,
1654
1663
  // not just the number of session IDs.
1655
- const windowSize = query().trim() ? Math.max(2, Math.floor(listHeight / 3)) : listHeight
1664
+ const height = listHeight()
1665
+ const windowSize = query().trim() ? Math.max(2, Math.floor(height / 3)) : height
1656
1666
  const currentID = selectableEntries()[cursor()]?.id
1657
1667
  let pos = currentID
1658
1668
  ? flat.findIndex((row) => row.kind === "item" && row.entry.id === currentID)
@@ -1727,6 +1737,22 @@ const tui: TuiPlugin = async (api) => {
1727
1737
  setPendingDelete(undefined)
1728
1738
  }
1729
1739
 
1740
+ // Escape first closes the transcript overlay, then the picker. One Escape
1741
+ // can reach both the focused search input and the key layer (sometimes in
1742
+ // separate ticks), so ignore the duplicate that trails right behind.
1743
+ let escapeHandledAt = 0
1744
+ const handleEscape = () => {
1745
+ const now = Date.now()
1746
+ if (escapeHandledAt > now) return
1747
+ if (showPreview()) {
1748
+ setShowPreview(false)
1749
+ escapeHandledAt = now + 150
1750
+ return
1751
+ }
1752
+ cancelDelete()
1753
+ close()
1754
+ }
1755
+
1730
1756
  const deleteEntry = async (entry: Entry) => {
1731
1757
  cancelDelete()
1732
1758
  try {
@@ -1793,6 +1819,9 @@ const tui: TuiPlugin = async (api) => {
1793
1819
  }
1794
1820
 
1795
1821
  const disposeNav = api.keymap.registerLayer({
1822
+ // Outrank the built-in dialog layer so Escape reaches this picker before
1823
+ // the dialog host closes the whole thing.
1824
+ priority: 1,
1796
1825
  bindings: [
1797
1826
  { key: "up", desc: "Previous session", preventDefault: true, cmd: () => moveCursor(-1) },
1798
1827
  { key: "down", desc: "Next session", preventDefault: true, cmd: () => moveCursor(1) },
@@ -1852,10 +1881,7 @@ const tui: TuiPlugin = async (api) => {
1852
1881
  key: "escape",
1853
1882
  desc: "Close",
1854
1883
  preventDefault: true,
1855
- cmd: () => {
1856
- cancelDelete()
1857
- close()
1858
- },
1884
+ cmd: handleEscape,
1859
1885
  },
1860
1886
  ],
1861
1887
  })
@@ -1871,7 +1897,14 @@ const tui: TuiPlugin = async (api) => {
1871
1897
 
1872
1898
  api.ui.dialog.replace(
1873
1899
  () => (
1874
- <box flexDirection="column" flexGrow={1} paddingBottom={1} gap={1}>
1900
+ <box
1901
+ flexDirection="column"
1902
+ height={dialogHeight()}
1903
+ marginTop={dialogOffset()}
1904
+ paddingBottom={1}
1905
+ gap={1}
1906
+ backgroundColor={api.theme.current.backgroundPanel}
1907
+ >
1875
1908
  <box paddingLeft={4} paddingRight={4}>
1876
1909
  <box flexDirection="row" justifyContent="space-between">
1877
1910
  <text attributes={TextAttributes.BOLD}>
@@ -1895,7 +1928,7 @@ const tui: TuiPlugin = async (api) => {
1895
1928
  onKeyDown={(event) => {
1896
1929
  if (event.name === "escape") {
1897
1930
  event.preventDefault()
1898
- close()
1931
+ handleEscape()
1899
1932
  }
1900
1933
  }}
1901
1934
  />
@@ -1923,6 +1956,8 @@ const tui: TuiPlugin = async (api) => {
1923
1956
  </box>
1924
1957
  <box
1925
1958
  flexDirection="column"
1959
+ flexGrow={1}
1960
+ overflow="hidden"
1926
1961
  onMouseScroll={(event: { scroll?: { direction: string } }) => {
1927
1962
  const direction = event.scroll?.direction
1928
1963
  if (direction === "down") moveCursor(3)
@@ -1932,7 +1967,7 @@ const tui: TuiPlugin = async (api) => {
1932
1967
  <For each={visiblePickerRows()}>
1933
1968
  {(row) => {
1934
1969
  if (row.kind === "group") return (
1935
- <box paddingTop={1} paddingLeft={4}>
1970
+ <box paddingLeft={4}>
1936
1971
  <text style={{ fg: api.theme.current.accent }} attributes={TextAttributes.BOLD}>
1937
1972
  {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>
1938
1973
  </text>
@@ -1949,6 +1984,12 @@ const tui: TuiPlugin = async (api) => {
1949
1984
  ? api.theme.current.primary
1950
1985
  : RGBA.fromInts(0, 0, 0, 0)
1951
1986
  }
1987
+ onMouseOver={() => {
1988
+ // Hovering moves the cursor so the highlight, Ctrl-S/Ctrl-D
1989
+ // and Enter all resolve to the row under the pointer.
1990
+ const index = selectableEntries().findIndex((entry) => entry.id === row.entry.id)
1991
+ if (index >= 0) setCursor(index)
1992
+ }}
1952
1993
  onMouseDown={() => choose(row.entry)}
1953
1994
  >
1954
1995
  <box flexDirection="row" gap={1}>
@@ -2038,11 +2079,46 @@ const tui: TuiPlugin = async (api) => {
2038
2079
  </box>
2039
2080
  </Show>
2040
2081
  </box>
2082
+ <box paddingLeft={4} paddingRight={4} flexShrink={0} flexDirection="column">
2083
+ <text
2084
+ style={{
2085
+ fg: pendingDelete() ? api.theme.current.warning : api.theme.current.textMuted,
2086
+ }}
2087
+ >
2088
+ {pendingDelete()
2089
+ ? `Delete "${truncate(pendingDelete()!.title, 40)}"? y confirm · n cancel`
2090
+ : "↑↓ move · enter open · ctrl+g project · option+w needs · option+p pinned · ctrl+p preview"}
2091
+ </text>
2092
+ <Show when={!pendingDelete()}>
2093
+ <text style={{ fg: api.theme.current.textMuted }}>ctrl+s/d pin · ctrl+x delete · ctrl+f fork · esc close</text>
2094
+ </Show>
2095
+ </box>
2041
2096
  <Show when={showPreview()}>
2042
- <box flexShrink={0} flexDirection="column" paddingLeft={4} paddingRight={4} height={12}>
2043
- <text style={{ fg: api.theme.current.textMuted }} attributes={TextAttributes.BOLD}>
2044
- {previewID() ? `Preview · ${truncate(selectableEntries()[cursor()]!.title, 48)}` : "Preview"}
2045
- </text>
2097
+ <box
2098
+ position="absolute"
2099
+ left={Math.floor((dialogWidth - previewWidth()) / 2)}
2100
+ top={Math.max(0, Math.floor((dialogHeight() - previewHeight()) / 2))}
2101
+ width={previewWidth()}
2102
+ height={previewHeight()}
2103
+ flexDirection="column"
2104
+ backgroundColor={api.theme.current.backgroundElement}
2105
+ paddingLeft={2}
2106
+ paddingRight={2}
2107
+ paddingTop={1}
2108
+ paddingBottom={1}
2109
+ gap={1}
2110
+ >
2111
+ <box flexDirection="row" justifyContent="space-between">
2112
+ <text attributes={TextAttributes.BOLD}>
2113
+ {previewID() ? truncate(selectableEntries()[cursor()]!.title, previewWidth() - 8) : "Preview"}
2114
+ </text>
2115
+ <text style={{ fg: api.theme.current.textMuted }}>esc close</text>
2116
+ </box>
2117
+ <Show when={previewID()}>
2118
+ <text style={{ fg: api.theme.current.textMuted }}>
2119
+ {prettyDir(selectableEntries()[cursor()]!.dir, process.env.HOME ?? "")}
2120
+ </text>
2121
+ </Show>
2046
2122
  <scrollbox flexGrow={1} scrollbarOptions={{ visible: false }}>
2047
2123
  <Show when={previewID()} fallback={<text style={{ fg: api.theme.current.textMuted }}>Select a session to preview</text>}>
2048
2124
  <Show when={previewText()} fallback={<text style={{ fg: api.theme.current.textMuted }}>Loading…</text>}>
@@ -2062,20 +2138,6 @@ const tui: TuiPlugin = async (api) => {
2062
2138
  </scrollbox>
2063
2139
  </box>
2064
2140
  </Show>
2065
- <box paddingLeft={4} paddingRight={4} flexShrink={0} flexDirection="column">
2066
- <text
2067
- style={{
2068
- fg: pendingDelete() ? api.theme.current.warning : api.theme.current.textMuted,
2069
- }}
2070
- >
2071
- {pendingDelete()
2072
- ? `Delete "${truncate(pendingDelete()!.title, 40)}"? y confirm · n cancel`
2073
- : "↑↓ move · enter open · ctrl+g project · option+w needs · option+p pinned · ctrl+p preview"}
2074
- </text>
2075
- <Show when={!pendingDelete()}>
2076
- <text style={{ fg: api.theme.current.textMuted }}>ctrl+s/d pin · ctrl+x delete · ctrl+f fork · esc close</text>
2077
- </Show>
2078
- </box>
2079
2141
  </box>
2080
2142
  ),
2081
2143
  () => {