opencode-sesh 0.10.0 → 0.11.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/AGENTS.md CHANGED
@@ -11,7 +11,7 @@ territory: read this first, then the specific file you need.
11
11
  any terminal. Lists **all** sessions across **all** project directories,
12
12
  full-text searchable, with transcript preview and resume/fork/delete.
13
13
  2. **`tui/sesh-panel.tsx`** — a SolidJS/OpenTUI plugin for the opencode TUI: a
14
- recent-sessions section in the sidebar plus a `ctrl+o` / `/sesh` picker.
14
+ recent-sessions section in the sidebar plus an `option+o` / `/sesh` picker.
15
15
  3. **`opencode/tools/sesh-list.ts`** — the `sesh-list` custom tool, so the agent
16
16
  can list sessions across every directory by querying the global `opencode
17
17
  db` store (the native `opencode session list` is project-scoped).
@@ -144,7 +144,7 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
144
144
  by recency; sessions within each group sort by last update regardless of pins.
145
145
  Row markers alone signal session state: a running agent animates a loading
146
146
  spinner marker, the open session shows its own ●, and every other idle row
147
- stays neutral (polled every 15 s); Ctrl-P previews
147
+ stays neutral (polled every 15 s); Option-P previews
148
148
  a row and ctrl+x deletes one (armed, then confirmed). The section lists every
149
149
  session without a row cap and flex-grows to fill the sidebar, with overflow
150
150
  scrolling inside; the picker remains the place for transcript search.
@@ -153,7 +153,7 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
153
153
  same `NEEDS_INPUT_SQL` heuristic as `bin/sesh-waiting.sh`.
154
154
  - **Picker:** a custom dialog (not `DialogSelect`) grouped by
155
155
  project/directory, recency-ordered, resume via
156
- `api.route.navigate("session", …)`. `ctrl+o` and the command palette open it;
156
+ `api.route.navigate("session", …)`. `option+o` and the command palette open it;
157
157
  Ctrl-X arms and then confirms a delete through `api.client.session.delete` and
158
158
  removes the row only after the server confirms; Ctrl-F forks through
159
159
  `api.client.session.fork`; Ctrl-G scopes the list to the selected session's
@@ -164,7 +164,7 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
164
164
  (`TRANSCRIPT_BATCH`) with bounded remote concurrency (`REMOTE_CONCURRENCY`).
165
165
  The header shows indexing progress/coverage. Visible transcript hits get
166
166
  short excerpts from the text-only index (never tool or reasoning payloads).
167
- Project, needs-input (Option-W), and pinned-session (Option-P) filters compose
167
+ Project, needs-input (Option-W), and pinned-session (Option-S) filters compose
168
168
  with the query; the picker remembers query/scope/filters until plugin restart.
169
169
  `/sesh-needs` uses the shared needs-input set plus the oldest unresolved
170
170
  question or running-tool timestamp, lists longest-waiting first, and `n`
@@ -181,6 +181,13 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
181
181
  `dialog.replace(...)`; `replace` resets the size to medium.
182
182
  - Use the `backgroundColor` **prop** on boxes, never `style={{backgroundColor}}`.
183
183
  - No percentage heights/widths in the picker; size the list window explicitly.
184
+ - The picker windows by visible terminal lines: directory headings take two
185
+ lines, transcript excerpts can take a second line, and a sticky directory
186
+ heading keeps a scrolled group identifiable. Wheel scroll follows every
187
+ OpenTUI delta and changes only the viewport; real mouse movement changes
188
+ selection without recentering it.
189
+ Option-P previews in a full-size overlay over the picker; Esc first closes the
190
+ preview, then the picker on the next press.
184
191
  - An inline `<input>` in a slot does **not** receive keyboard focus. Sidebar and
185
192
  home search are keymap-captured queries (priority-20 base layer, letters /
186
193
  space / backspace, Esc exits); click the box to activate, click elsewhere to
package/CHANGELOG.md CHANGED
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.11.0] - 2026-09-25
11
+
12
+ ### Changed
13
+
14
+ - OpenCode TUI shortcuts use Option-O for the picker and Option-P for previews;
15
+ Option-S filters pinned sessions, and the Option-W hint now says "input".
16
+ - The TUI picker groups sessions in collapsible directory trees, with the
17
+ directory path on a separate heading and branch markers on session rows.
18
+ - Mouse-wheel scrolling follows OpenTUI's native per-event speed without moving
19
+ the selection under a stationary pointer. The transcript preview now covers
20
+ the entire picker, with arrow keys to switch sessions while it is open.
10
21
  ## [0.10.0] - 2026-09-25
11
22
 
12
23
  ### Changed
@@ -360,7 +371,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
360
371
  - Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
361
372
  CI.
362
373
 
363
- [Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.10.0...HEAD
374
+ [Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.11.0...HEAD
375
+ [0.11.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.10.0...v0.11.0
364
376
  [0.10.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.9.1...v0.10.0
365
377
  [0.9.1]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.9.0...v0.9.1
366
378
  [0.9.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.8.0...v0.9.0
package/README.md CHANGED
@@ -41,8 +41,8 @@ grouped by directory, full-text searchable, and previewable from anywhere.
41
41
  - **Resume in place.** Enter `exec`s `opencode --session <id>` in the session's
42
42
  own directory. Ctrl-F forks instead. Your terminal becomes the session — no
43
43
  tabs, no panes, no window management.
44
- - **Native TUI, too.** A recent-sessions section in the opencode sidebar plus a
45
- `ctrl+o` picker, both themed by your opencode theme.
44
+ - **Native TUI, too.** A recent-sessions section in the opencode sidebar plus an
45
+ `option+o` picker, both themed by your opencode theme.
46
46
  - **Fast.** The database is scanned once per refresh and cached; keystrokes only
47
47
  re-render the last snapshot, so typing never triggers a query storm.
48
48
 
@@ -87,7 +87,7 @@ sesh # terminal picker
87
87
 
88
88
  ```text
89
89
  /sesh # inside opencode — picker (TUI plugin)
90
- ctrl+o # inside opencode — picker
90
+ option+o # inside opencode — picker
91
91
  ```
92
92
 
93
93
  ### Requirements
@@ -169,22 +169,27 @@ non-interactive `sesh-delete.sh` refuses unless given `--yes`.
169
169
  | `/sesh` | Open the full picker |
170
170
  | `/sesh-costs` | Per-project cost digest (24h beside lifetime) |
171
171
  | `/sesh-needs` | Sessions waiting on you (Enter opens) |
172
- | `ctrl+o` | Open the full picker (also in the command palette) |
172
+ | `option+o` | Open the full picker (also in the command palette) |
173
173
  | Type | Search titles, directories and transcript text |
174
174
  | `↑`/`↓`, `PgUp`/`PgDn`, `Home`/`End` | Move the selection |
175
- | Ctrl-P | Toggle the transcript preview |
175
+ | Option-P | Toggle the transcript preview |
176
176
  | Enter | Open the selected session |
177
177
  | Ctrl-X | Delete the selected session (asks to confirm) |
178
178
  | Ctrl-F | Fork the selected session |
179
179
  | Ctrl-G | Toggle scope: the selected session's project, or every project |
180
- | Option-W / Option-P | Show only sessions needing input / pinned sessions |
180
+ | Option-W / Option-S | Show only sessions needing input / pinned sessions |
181
181
  | Ctrl-S / Ctrl-D | Pin or unpin the selected session / directory |
182
- | Esc | Close |
182
+ | Esc | Close the preview first, then the picker |
183
+
184
+ The picker groups sessions under collapsible directory headings (click ▾/▸).
185
+ Mouse-wheel scrolling moves through the tree without changing the selection
186
+ under a stationary pointer. Option-P opens a full-size transcript preview over
187
+ the picker; use ↑/↓ to preview other sessions, then Esc to return to the list.
183
188
 
184
189
  Click the sidebar's `search…` box to filter recent sessions in place; click
185
190
  elsewhere or press Esc to leave search. Click the **Sessions** heading to drive
186
191
  the list from the keyboard instead of the mouse (`↑`/`↓` to move, Enter to open,
187
- Ctrl-P to preview, Ctrl-S/Ctrl-D to pin, Ctrl-X to delete, Esc to leave). Pinned
192
+ Option-P to preview, Ctrl-S/Ctrl-D to pin, Ctrl-X to delete, Esc to leave). Pinned
188
193
  directories rise first, while sessions within each directory are ordered by
189
194
  last update, newest first. The section lists every session and scrolls to fill
190
195
  the sidebar, so click **▾ hide** to collapse it when you need the room.
package/install.sh CHANGED
@@ -298,7 +298,7 @@ fi
298
298
  "$LAUNCHER" --check || fail "post-install check failed."
299
299
  note "done"
300
300
  note " picker: run 'sesh' in any terminal, or '/sesh' inside opencode"
301
- note " sidebar: ctrl+o opens the full picker"
301
+ note " sidebar: option+o opens the full picker"
302
302
  note_restart
303
303
  case ":$PATH:" in
304
304
  *":$BIN_DIR:"*) ;;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-sesh",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Browse, search, preview and resume opencode sessions from the terminal and the TUI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -167,8 +167,8 @@ function pinnedFirst(entries: Entry[], pins: Pins): Entry[] {
167
167
  // 1. Sidebar (always visible): a compact recent-sessions section appended to
168
168
  // the native sidebar via the `sidebar_content` slot. Native session_list
169
169
  // (<leader>l) is left untouched; this complements it with a cross-project
170
- // view. Rows are display-only; ctrl+o opens the full picker.
171
- // 2. Picker (ctrl+o, `/sesh`, command palette): an xlarge grouped picker over
170
+ // view. Rows are display-only; option+o opens the full picker.
171
+ // 2. Picker (option+o, `/sesh`, command palette): an xlarge grouped picker over
172
172
  // every session across all project directories, newest first.
173
173
  //
174
174
  // `/sesh` is registered by this plugin (a markdown command cannot open the
@@ -214,7 +214,7 @@ const HOME_TITLE_WIDTH = 30
214
214
 
215
215
  // The sidebar lists every session (newest first, pins ahead) and fills
216
216
  // whatever vertical space it gets: overflow scrolls inside the stretched
217
- // scrollbox. The picker (ctrl+o) remains the place for transcript search.
217
+ // scrollbox. The picker (option+o) remains the place for transcript search.
218
218
 
219
219
  // Uppercase is included deliberately: a search box that silently drops shifted
220
220
  // letters cannot be used for acronyms or paths like README.
@@ -729,6 +729,44 @@ type TreeRow =
729
729
  | { kind: "group"; dir: string; label: string; count: number }
730
730
  | { kind: "item"; entry: Entry; last: boolean }
731
731
 
732
+ type PickerRow =
733
+ | { kind: "group"; dir: string; label: string; count: number; pinned: boolean; continued?: boolean }
734
+ | { kind: "item"; entry: Entry; last: boolean }
735
+
736
+ // Window by terminal lines, not session count: group spacing and transcript
737
+ // excerpts each consume an extra line. Carry the directory heading forward
738
+ // when a long group starts above the viewport.
739
+ function pickerWindow(rows: PickerRow[], start: number, height: number, excerpt: (entry: Entry) => boolean): PickerRow[] {
740
+ if (!rows.length) return []
741
+ const first = Math.max(0, Math.min(start, rows.length - 1))
742
+ const shown: PickerRow[] = []
743
+ let remaining = height
744
+ if (rows[first].kind === "item") {
745
+ for (let i = first - 1; i >= 0; i--) {
746
+ const row = rows[i]
747
+ if (row.kind !== "group") continue
748
+ shown.push({ ...row, continued: true })
749
+ remaining--
750
+ break
751
+ }
752
+ }
753
+ for (let i = first; i < rows.length; i++) {
754
+ const row = rows[i]
755
+ const lines = row.kind === "group" ? 2 : excerpt(row.entry) ? 2 : 1
756
+ if (lines > remaining && shown.length) break
757
+ shown.push(row)
758
+ remaining -= lines
759
+ }
760
+ return shown
761
+ }
762
+
763
+ function pickerLastStart(rows: PickerRow[], height: number, excerpt: (entry: Entry) => boolean): number {
764
+ if (!rows.length) return 0
765
+ let start = rows.length - 1
766
+ while (start > 0 && pickerWindow(rows, start - 1, height, excerpt).at(-1) === rows.at(-1)) start--
767
+ return start
768
+ }
769
+
732
770
  type PinProps = { pins: () => Pins; onTogglePin: (kind: keyof Pins, value: string) => void; refreshPins: () => void }
733
771
 
734
772
  function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
@@ -1021,7 +1059,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1021
1059
  priority: 20,
1022
1060
  bindings: [
1023
1061
  {
1024
- key: "ctrl+p",
1062
+ key: "option+p",
1025
1063
  desc: "Preview session transcript",
1026
1064
  preventDefault: true,
1027
1065
  cmd: () => openSidebarPreview(entry),
@@ -1065,7 +1103,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1065
1103
  cmd: () => setCursor(Math.max(0, itemRows().length - 1)),
1066
1104
  },
1067
1105
  {
1068
- key: "ctrl+p",
1106
+ key: "option+p",
1069
1107
  desc: "Preview session transcript",
1070
1108
  preventDefault: true,
1071
1109
  cmd: () => {
@@ -1167,7 +1205,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1167
1205
  when={entries().length > 0}
1168
1206
  fallback={
1169
1207
  <text style={{ fg: theme().textMuted }}>
1170
- {loadFailed() ? "Session list unavailable · will retry" : "No sessions yet · ctrl+o to browse"}
1208
+ {loadFailed() ? "Session list unavailable · will retry" : "No sessions yet · option+o to browse"}
1171
1209
  </text>
1172
1210
  }
1173
1211
  >
@@ -1273,14 +1311,14 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1273
1311
  </For>
1274
1312
  </scrollbox>
1275
1313
  <Show when={remaining() > 0}>
1276
- <text style={{ fg: theme().textMuted }}>{`… ${remaining()} more · ctrl+o for all`}</text>
1314
+ <text style={{ fg: theme().textMuted }}>{`… ${remaining()} more · option+o for all`}</text>
1277
1315
  </Show>
1278
1316
  <Show when={query().trim() && filteredEntries().length === 0}>
1279
- <text style={{ fg: theme().textMuted }}>No title or directory matches · ctrl+o to search transcripts</text>
1317
+ <text style={{ fg: theme().textMuted }}>No title or directory matches · option+o to search transcripts</text>
1280
1318
  </Show>
1281
1319
  <Show when={navActive()}>
1282
1320
  <box paddingTop={1}>
1283
- <text style={{ fg: theme().textMuted }}>↑↓ move · enter open · ctrl+p preview · ctrl+s/d pin · ctrl+x delete · esc done</text>
1321
+ <text style={{ fg: theme().textMuted }}>↑↓ move · enter open · option+p preview · ctrl+s/d pin · ctrl+x delete · esc done</text>
1284
1322
  </box>
1285
1323
  </Show>
1286
1324
  </Show>
@@ -1346,7 +1384,7 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
1346
1384
  priority: 20,
1347
1385
  bindings: [
1348
1386
  {
1349
- key: "ctrl+p",
1387
+ key: "option+p",
1350
1388
  desc: "Preview session transcript",
1351
1389
  preventDefault: true,
1352
1390
  cmd: () => preview.open(entry),
@@ -1400,7 +1438,7 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
1400
1438
  <box flexDirection="row" gap={1}>
1401
1439
  <text style={{ fg: theme().textMuted }}>Recent sessions</text>
1402
1440
  <text style={{ fg: theme().textMuted }}>
1403
- {loadFailed() ? "· list unavailable, will retry" : "· none yet, ctrl+o to browse"}
1441
+ {loadFailed() ? "· list unavailable, will retry" : "· none yet, option+o to browse"}
1404
1442
  </text>
1405
1443
  </box>
1406
1444
  }
@@ -1408,7 +1446,7 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
1408
1446
  <box flexDirection="row" justifyContent="space-between" gap={2}>
1409
1447
  <box flexDirection="row" gap={1}>
1410
1448
  <text style={{ fg: theme().textMuted }}>Recent sessions</text>
1411
- <text style={{ fg: theme().textMuted }}>· ctrl+o for all</text>
1449
+ <text style={{ fg: theme().textMuted }}>· option+o for all</text>
1412
1450
  </box>
1413
1451
  <box
1414
1452
  flexDirection="row"
@@ -1466,7 +1504,7 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
1466
1504
  )}
1467
1505
  </For>
1468
1506
  <Show when={visible().length === 0}>
1469
- <text style={{ fg: theme().textMuted }}>No title or directory matches · ctrl+o to search transcripts</text>
1507
+ <text style={{ fg: theme().textMuted }}>No title or directory matches · option+o to search transcripts</text>
1470
1508
  </Show>
1471
1509
  </Show>
1472
1510
  </box>
@@ -1534,6 +1572,8 @@ const tui: TuiPlugin = async (api) => {
1534
1572
  const [showPreview, setShowPreview] = createSignal(false)
1535
1573
  const [query, setQuery] = createSignal(pickerState.query)
1536
1574
  const [cursor, setCursor] = createSignal(0)
1575
+ const [viewportStart, setViewportStart] = createSignal(0)
1576
+ const [collapsed, setCollapsed] = createSignal<Record<string, boolean>>({})
1537
1577
  const [scope, setScope] = createSignal<string | undefined>(
1538
1578
  entries.some((entry) => entry.group === pickerState.scope) ? pickerState.scope : undefined,
1539
1579
  )
@@ -1574,8 +1614,6 @@ const tui: TuiPlugin = async (api) => {
1574
1614
  // while Solid needs a new reference to update search results mid-scan.
1575
1615
  setSearchIndex(new Map(index))
1576
1616
  })
1577
- type DialogRow = { kind: "group"; label: string; count: number } | { kind: "item"; entry: Entry }
1578
-
1579
1617
  const orderedEntries = createMemo(() => pinnedFirst(allEntries(), pins()))
1580
1618
  const currentProject = () => {
1581
1619
  const route = api.route.current
@@ -1591,21 +1629,23 @@ const tui: TuiPlugin = async (api) => {
1591
1629
  )
1592
1630
  const byGroup = new Map<string, Entry[]>()
1593
1631
  for (const entry of matched) {
1594
- const list = byGroup.get(entry.group) ?? []
1632
+ const list = byGroup.get(entry.dir) ?? []
1595
1633
  list.push(entry)
1596
- byGroup.set(entry.group, list)
1634
+ byGroup.set(entry.dir, list)
1597
1635
  }
1598
- const groups = [...byGroup.entries()].map(([name, list]) => ({ name, list: newestFirst(list) }))
1636
+ const groups = [...byGroup.entries()].map(([dir, list]) => ({ dir, list: newestFirst(list) }))
1599
1637
  const currentPins = pins()
1600
1638
  const rank = (group: (typeof groups)[number]) =>
1601
- group.list.some((entry) => currentPins.directories.includes(entry.dir)) ? 2
1639
+ currentPins.directories.includes(group.dir) ? 2
1602
1640
  : group.list.some((entry) => currentPins.sessions.includes(entry.id)) ? 1 : 0
1603
1641
  return groups.sort((a, b) =>
1604
1642
  rank(b) - rank(a) || Math.max(...b.list.map((entry) => entry.updated)) - Math.max(...a.list.map((entry) => entry.updated)),
1605
1643
  )
1606
1644
  })
1607
1645
 
1608
- const selectableEntries = createMemo(() => matchedGroups().flatMap((g) => g.list))
1646
+ const selectableEntries = createMemo(() => matchedGroups().flatMap((g) =>
1647
+ collapsed()[g.dir] && !query().trim() ? [] : g.list,
1648
+ ))
1609
1649
 
1610
1650
  // Visible bounding: say how much transcript text search actually covers and
1611
1651
  // when the metadata walk hit its cap, instead of silently under-reporting.
@@ -1626,21 +1666,15 @@ const tui: TuiPlugin = async (api) => {
1626
1666
  return bits.join(" · ")
1627
1667
  })
1628
1668
 
1629
- let previousSelection: Entry[] = []
1630
- createEffect(() => {
1631
- const rows = selectableEntries()
1632
- const selected = previousSelection[cursor()]?.id
1633
- const position = rows.findIndex((entry) => entry.id === selected)
1634
- if (position >= 0 && position !== cursor()) setCursor(position)
1635
- else if (cursor() >= rows.length) setCursor(Math.max(0, rows.length - 1))
1636
- previousSelection = rows
1637
- })
1638
-
1639
- const pickerRows = createMemo<DialogRow[]>(() => {
1640
- const flat: DialogRow[] = []
1669
+ const pickerRows = createMemo<PickerRow[]>(() => {
1670
+ const flat: PickerRow[] = []
1641
1671
  for (const group of matchedGroups()) {
1642
- flat.push({ kind: "group", label: group.name, count: group.list.length })
1643
- for (const entry of group.list) flat.push({ kind: "item", entry })
1672
+ flat.push({ kind: "group", dir: group.dir, label: prettyDir(group.dir, process.env.HOME ?? ""),
1673
+ count: group.list.length, pinned: pins().directories.includes(group.dir) })
1674
+ if (collapsed()[group.dir] && !query().trim()) continue
1675
+ for (let i = 0; i < group.list.length; i++) {
1676
+ flat.push({ kind: "item", entry: group.list[i], last: i === group.list.length - 1 })
1677
+ }
1644
1678
  }
1645
1679
  return flat
1646
1680
  })
@@ -1654,23 +1688,28 @@ const tui: TuiPlugin = async (api) => {
1654
1688
  const listHeight = () => Math.max(6, Math.floor(termHeight * 0.75) - CHROME_ROWS)
1655
1689
  const dialogHeight = () => listHeight() + CHROME_ROWS
1656
1690
  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))
1691
+ const hasExcerpt = (entry: Entry) => !!transcriptMatchExcerpt(searchIndex().get(entry.id), entry, query())
1692
+ const maxViewportStart = createMemo(() => pickerLastStart(pickerRows(), listHeight(), hasExcerpt))
1693
+ const visiblePickerRows = createMemo(() => pickerWindow(
1694
+ pickerRows(), Math.min(viewportStart(), maxViewportStart()), listHeight(), hasExcerpt,
1695
+ ))
1659
1696
 
1660
- const visiblePickerRows = createMemo<DialogRow[]>(() => {
1661
- const flat = pickerRows()
1662
- // A transcript hit adds a second line to a row; bound by screen height,
1663
- // not just the number of session IDs.
1664
- const height = listHeight()
1665
- const windowSize = query().trim() ? Math.max(2, Math.floor(height / 3)) : height
1666
- const currentID = selectableEntries()[cursor()]?.id
1667
- let pos = currentID
1668
- ? flat.findIndex((row) => row.kind === "item" && row.entry.id === currentID)
1669
- : 0
1670
- if (pos < 0) pos = 0
1671
- let start = Math.max(0, pos - Math.floor(windowSize / 2))
1672
- start = Math.min(start, Math.max(0, flat.length - windowSize))
1673
- return flat.slice(start, start + windowSize)
1697
+ let previousSelection: Entry[] = []
1698
+ createEffect(() => {
1699
+ const rows = selectableEntries()
1700
+ const position = rows.findIndex((entry) => entry.id === previousSelection[cursor()]?.id)
1701
+ if (rows !== previousSelection && (rows.length !== previousSelection.length ||
1702
+ rows.some((entry, index) => entry.id !== previousSelection[index]?.id))) {
1703
+ // A search/filter/collapse changed the tree: reveal the retained row or
1704
+ // start at the first result rather than leaving a stale window open.
1705
+ const next = position >= 0 ? position : 0
1706
+ setCursor(next)
1707
+ const id = rows[next]?.id
1708
+ setViewportStart(position < 0 || next === 0 ? 0 : Math.max(0, pickerRows().findIndex(
1709
+ (row) => row.kind === "item" && row.entry.id === id,
1710
+ )))
1711
+ } else if (cursor() >= rows.length) setCursor(Math.max(0, rows.length - 1))
1712
+ previousSelection = rows
1674
1713
  })
1675
1714
 
1676
1715
  const previewCache = new Map<string, string>()
@@ -1721,11 +1760,30 @@ const tui: TuiPlugin = async (api) => {
1721
1760
 
1722
1761
  const togglePreview = () => setShowPreview((value) => !value)
1723
1762
 
1724
- const moveCursor = (delta: number) => {
1763
+ const selectCursor = (index: number) => {
1725
1764
  const count = selectableEntries().length
1726
1765
  if (count === 0) return
1727
1766
  cancelDelete()
1728
- setCursor((value) => Math.max(0, Math.min(count - 1, value + delta)))
1767
+ const next = Math.max(0, Math.min(count - 1, index))
1768
+ const id = selectableEntries()[next].id
1769
+ const flat = pickerRows()
1770
+ const target = flat.findIndex((row) => row.kind === "item" && row.entry.id === id)
1771
+ let start = Math.min(viewportStart(), maxViewportStart())
1772
+ if (target < start) start = target
1773
+ else while (start < target && !pickerWindow(flat, start, listHeight(), hasExcerpt).some(
1774
+ (row) => row.kind === "item" && row.entry.id === id,
1775
+ )) start++
1776
+ setViewportStart(Math.max(0, Math.min(start, maxViewportStart())))
1777
+ setCursor(next)
1778
+ }
1779
+ const moveCursor = (delta: number) => selectCursor(cursor() + delta)
1780
+
1781
+ // Match OpenTUI's scrollbox: honor every wheel event's delta. The viewport
1782
+ // stays independent of selection, so fast scrolling cannot recenter on hover.
1783
+ const scrollPicker = (scroll?: { direction: string; delta: number }) => {
1784
+ if (showPreview() || !scroll || (scroll.direction !== "up" && scroll.direction !== "down")) return
1785
+ const amount = Math.trunc(scroll.delta) * (scroll.direction === "down" ? 1 : -1)
1786
+ setViewportStart((start) => Math.max(0, Math.min(maxViewportStart(), start + amount)))
1729
1787
  }
1730
1788
 
1731
1789
  // Delete confirmation lives in its own layer, registered only while a row
@@ -1827,15 +1885,15 @@ const tui: TuiPlugin = async (api) => {
1827
1885
  { key: "down", desc: "Next session", preventDefault: true, cmd: () => moveCursor(1) },
1828
1886
  { key: "pageup", desc: "Page up", preventDefault: true, cmd: () => moveCursor(-10) },
1829
1887
  { key: "pagedown", desc: "Page down", preventDefault: true, cmd: () => moveCursor(10) },
1830
- { key: "home", desc: "First session", preventDefault: true, cmd: () => setCursor(0) },
1888
+ { key: "home", desc: "First session", preventDefault: true, cmd: () => selectCursor(0) },
1831
1889
  {
1832
1890
  key: "end",
1833
1891
  desc: "Last session",
1834
1892
  preventDefault: true,
1835
- cmd: () => setCursor(selectableEntries().length - 1),
1893
+ cmd: () => selectCursor(selectableEntries().length - 1),
1836
1894
  },
1837
1895
  { key: "enter", desc: "Open session", preventDefault: true, cmd: () => choose() },
1838
- { key: "ctrl+p", desc: "Toggle transcript preview", preventDefault: true, cmd: togglePreview },
1896
+ { key: "option+p", desc: "Toggle transcript preview", preventDefault: true, cmd: togglePreview },
1839
1897
  { key: "ctrl+s", desc: "Pin session", preventDefault: true, cmd: () => {
1840
1898
  const entry = selectableEntries()[cursor()]
1841
1899
  if (entry) onTogglePin("sessions", entry.id)
@@ -1876,7 +1934,7 @@ const tui: TuiPlugin = async (api) => {
1876
1934
  },
1877
1935
  },
1878
1936
  { key: "option+w", desc: "Filter needs input", preventDefault: true, cmd: () => setWaitingOnly((value) => !value) },
1879
- { key: "option+p", desc: "Filter pinned sessions", preventDefault: true, cmd: () => setPinnedOnly((value) => !value) },
1937
+ { key: "option+s", desc: "Filter pinned sessions", preventDefault: true, cmd: () => setPinnedOnly((value) => !value) },
1880
1938
  {
1881
1939
  key: "escape",
1882
1940
  desc: "Close",
@@ -1958,41 +2016,45 @@ const tui: TuiPlugin = async (api) => {
1958
2016
  flexDirection="column"
1959
2017
  flexGrow={1}
1960
2018
  overflow="hidden"
1961
- onMouseScroll={(event: { scroll?: { direction: string } }) => {
1962
- const direction = event.scroll?.direction
1963
- if (direction === "down") moveCursor(3)
1964
- else if (direction === "up") moveCursor(-3)
2019
+ onMouseScroll={(event: { scroll?: { direction: string; delta: number } }) => {
2020
+ scrollPicker(event.scroll)
1965
2021
  }}
1966
2022
  >
1967
2023
  <For each={visiblePickerRows()}>
1968
2024
  {(row) => {
1969
2025
  if (row.kind === "group") return (
1970
- <box paddingLeft={4}>
1971
- <text style={{ fg: api.theme.current.accent }} attributes={TextAttributes.BOLD}>
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>
2026
+ <box flexDirection="row" paddingTop={row.continued ? 0 : 1} paddingLeft={4} paddingRight={4}
2027
+ onMouseDown={() => setCollapsed((state) => ({ ...state, [row.dir]: !state[row.dir] }))}>
2028
+ <text flexGrow={1} flexShrink={1} wrapMode="none" style={{ fg: api.theme.current.accent }} attributes={TextAttributes.BOLD}>
2029
+ {collapsed()[row.dir] && !query().trim() ? "▸ " : "▾ "}{row.pinned ? "★ " : ""}{row.label}
1973
2030
  </text>
2031
+ <text flexShrink={0} style={{ fg: api.theme.current.textMuted }}> ({row.count})</text>
1974
2032
  </box>
1975
2033
  )
1976
2034
  const excerpt = createMemo(() => transcriptMatchExcerpt(searchIndex().get(row.entry.id), row.entry, query()))
1977
2035
  return (
1978
2036
  <box
1979
2037
  flexDirection="column"
1980
- paddingLeft={4}
2038
+ paddingLeft={6}
1981
2039
  paddingRight={4}
1982
2040
  backgroundColor={
1983
2041
  row.entry.id === selectableEntries()[cursor()]?.id
1984
2042
  ? api.theme.current.primary
1985
2043
  : RGBA.fromInts(0, 0, 0, 0)
1986
2044
  }
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.
2045
+ onMouseMove={() => {
2046
+ // Repainting under a stationary pointer must not select
2047
+ // another row while the wheel is moving the viewport.
1990
2048
  const index = selectableEntries().findIndex((entry) => entry.id === row.entry.id)
1991
2049
  if (index >= 0) setCursor(index)
1992
2050
  }}
1993
2051
  onMouseDown={() => choose(row.entry)}
1994
2052
  >
1995
2053
  <box flexDirection="row" gap={1}>
2054
+ <text flexShrink={0} style={{ fg: row.entry.id === selectableEntries()[cursor()]?.id
2055
+ ? api.theme.current.selectedListItemText : api.theme.current.textMuted }}>
2056
+ {row.last ? "└" : "├"}
2057
+ </text>
1996
2058
  <Show when={row.entry.id === currentSessionID()}>
1997
2059
  <text
1998
2060
  flexShrink={0}
@@ -2034,7 +2096,7 @@ const tui: TuiPlugin = async (api) => {
2034
2096
  >
2035
2097
  {pendingDelete()?.id === row.entry.id
2036
2098
  ? "press y to delete"
2037
- : `${prettyDir(row.entry.dir, process.env.HOME ?? "")} · ${ago(row.entry.updated)}${
2099
+ : `${ago(row.entry.updated)}${
2038
2100
  query().trim() &&
2039
2101
  !`${row.entry.title} ${row.entry.dir}`.toLowerCase().includes(query().trim().toLowerCase())
2040
2102
  ? " · transcript match"
@@ -2059,7 +2121,7 @@ const tui: TuiPlugin = async (api) => {
2059
2121
  )
2060
2122
  }}
2061
2123
  </For>
2062
- <Show when={selectableEntries().length === 0}>
2124
+ <Show when={matchedGroups().length === 0}>
2063
2125
  <box paddingLeft={4} paddingRight={4}>
2064
2126
  <text style={{ fg: api.theme.current.textMuted }}>
2065
2127
  {waitingOnly() && waitingCoverage() === "loading"
@@ -2087,7 +2149,7 @@ const tui: TuiPlugin = async (api) => {
2087
2149
  >
2088
2150
  {pendingDelete()
2089
2151
  ? `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"}
2152
+ : "↑↓ move · enter open · ctrl+g project · option+w input · option+s pinned · option+p preview"}
2091
2153
  </text>
2092
2154
  <Show when={!pendingDelete()}>
2093
2155
  <text style={{ fg: api.theme.current.textMuted }}>ctrl+s/d pin · ctrl+x delete · ctrl+f fork · esc close</text>
@@ -2096,27 +2158,27 @@ const tui: TuiPlugin = async (api) => {
2096
2158
  <Show when={showPreview()}>
2097
2159
  <box
2098
2160
  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()}
2161
+ left={0}
2162
+ top={0}
2163
+ width={dialogWidth}
2164
+ height={dialogHeight()}
2103
2165
  flexDirection="column"
2104
2166
  backgroundColor={api.theme.current.backgroundElement}
2105
- paddingLeft={2}
2106
- paddingRight={2}
2167
+ paddingLeft={4}
2168
+ paddingRight={4}
2107
2169
  paddingTop={1}
2108
2170
  paddingBottom={1}
2109
2171
  gap={1}
2110
2172
  >
2111
2173
  <box flexDirection="row" justifyContent="space-between">
2112
2174
  <text attributes={TextAttributes.BOLD}>
2113
- {previewID() ? truncate(selectableEntries()[cursor()]!.title, previewWidth() - 8) : "Preview"}
2175
+ {previewID() ? truncate(selectableEntries()[cursor()]!.title, dialogWidth - 18) : "Preview"}
2114
2176
  </text>
2115
2177
  <text style={{ fg: api.theme.current.textMuted }}>esc close</text>
2116
2178
  </box>
2117
2179
  <Show when={previewID()}>
2118
2180
  <text style={{ fg: api.theme.current.textMuted }}>
2119
- {prettyDir(selectableEntries()[cursor()]!.dir, process.env.HOME ?? "")}
2181
+ {prettyDir(selectableEntries()[cursor()]!.dir, process.env.HOME ?? "")} · {ago(selectableEntries()[cursor()]!.updated)}
2120
2182
  </text>
2121
2183
  </Show>
2122
2184
  <scrollbox flexGrow={1} scrollbarOptions={{ visible: false }}>
@@ -2136,6 +2198,7 @@ const tui: TuiPlugin = async (api) => {
2136
2198
  </Show>
2137
2199
  </Show>
2138
2200
  </scrollbox>
2201
+ <text style={{ fg: api.theme.current.textMuted }}>↑↓ switch session · esc back to picker</text>
2139
2202
  </box>
2140
2203
  </Show>
2141
2204
  </box>
@@ -2436,7 +2499,7 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
2436
2499
  ])
2437
2500
 
2438
2501
  api.keymap.registerLayer({
2439
- bindings: [{ key: "ctrl+o", desc: "Pick session", preventDefault: true, cmd: () => void openPicker() }],
2502
+ bindings: [{ key: "option+o", desc: "Pick session", preventDefault: true, cmd: () => void openPicker() }],
2440
2503
  })
2441
2504
  }
2442
2505