opencode-sesh 0.9.0 → 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/AGENTS.md CHANGED
@@ -83,8 +83,8 @@ the schema below. Keystrokes never touch the database:
83
83
  epoch, not a boolean).
84
84
  - Session and directory pins live in `${XDG_DATA_HOME:-$HOME/.local/share}/sesh/pins.json`
85
85
  (`SESH_PINS_FILE` overrides it for tests), shared by fzf and the TUI. Writes
86
- use a sibling lock directory and atomic rename. Option-S toggles a selected
87
- session; Option-D toggles its directory. Pinned directories sort before groups
86
+ use a sibling lock directory and atomic rename. Ctrl-S toggles a selected
87
+ session; Ctrl-D toggles its directory. Pinned directories sort before groups
88
88
  containing pinned sessions, with pinned sessions first inside each group.
89
89
 
90
90
  **Output contract** (TSV, 6 fields):
@@ -105,7 +105,8 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
105
105
  ## Conventions
106
106
 
107
107
  - Default scope is **all** sessions, every directory. `--cwd`/Ctrl-G narrows.
108
- Archived sessions and fork children are hidden unless `--archived`.
108
+ Archived sessions and child sessions (fork children, spawned subagents) are
109
+ hidden unless `--archived`; metadata and cache pruning still see them.
109
110
  - **Text parts only** in search/preview; reasoning and tool payloads are
110
111
  excluded (asserted in tests).
111
112
  - Validate session ids (`^ses_[A-Za-z0-9]+$`) before SQL interpolation — never
@@ -141,8 +142,9 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
141
142
  - **Sidebar:** `api.slots.register` on `sidebar_content` (append mode — native
142
143
  sidebar content stays). Pinned directories sort first, then directory groups
143
144
  by recency; sessions within each group sort by last update regardless of pins.
144
- Waiting, running, and current-idle sessions have distinct title colors and
145
- text labels (in addition to row markers), polled every 15 s; Ctrl-P previews
145
+ Row markers alone signal session state: a running agent animates a loading
146
+ spinner marker, the open session shows its own ●, and every other idle row
147
+ stays neutral (polled every 15 s); Ctrl-P previews
146
148
  a row and ctrl+x deletes one (armed, then confirmed). The section lists every
147
149
  session without a row cap and flex-grows to fill the sidebar, with overflow
148
150
  scrolling inside; the picker remains the place for transcript search.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ 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.
18
+ ## [0.9.1] - 2026-09-25
19
+
20
+ ### Changed
21
+
22
+ - Sidebar rows show state only in the marker column: a running agent animates
23
+ a loading spinner; idle rows and their titles go back to neutral.
24
+ - Pin keybinds move from Option-S/Option-D to Ctrl-S/Ctrl-D in the terminal
25
+ picker, the sidebar, and the picker dialog.
26
+ - The terminal picker hides child sessions (fork children, spawned subagents)
27
+ like the panel does; `--archived` still opts them back in.
10
28
  ## [0.9.0] - 2026-09-24
11
29
 
12
30
  ### Added
@@ -342,7 +360,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
342
360
  - Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
343
361
  CI.
344
362
 
345
- [Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.9.0...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
365
+ [0.9.1]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.9.0...v0.9.1
346
366
  [0.9.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.8.0...v0.9.0
347
367
  [0.8.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.7.3...v0.8.0
348
368
  [0.7.3]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.7.2...v0.7.3
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.
@@ -28,7 +30,7 @@ grouped by directory, full-text searchable, and previewable from anywhere.
28
30
 
29
31
  - **Every session, everywhere.** Sessions are grouped by project directory and
30
32
  sorted by pins, then recency — across *all* your projects, not just the current one.
31
- - **Pin what matters.** Option-S pins a session and Option-D pins its directory in
33
+ - **Pin what matters.** Ctrl-S pins a session and Ctrl-D pins its directory in
32
34
  either picker. Pins are shared across the terminal and opencode TUI and
33
35
  persist across restarts; pinned directories and sessions sort first.
34
36
  - **Full-text search.** Type to match against session titles *and* the text of
@@ -115,7 +117,7 @@ bash install.sh --uninstall # from source
115
117
  | Enter | Resume the selected session in this terminal |
116
118
  | Ctrl-F | Resume as a fork (the original is untouched) |
117
119
  | Ctrl-G | Toggle current-directory scope / all sessions |
118
- | Option-S / Option-D | Pin or unpin the selected session / directory |
120
+ | Ctrl-S / Ctrl-D | Pin or unpin the selected session / directory |
119
121
  | Ctrl-P | Toggle the transcript preview |
120
122
  | `?` (empty query) | Toggle shortcut help |
121
123
  | Ctrl-X | Delete the selected session (asks to confirm) |
@@ -176,21 +178,21 @@ non-interactive `sesh-delete.sh` refuses unless given `--yes`.
176
178
  | Ctrl-F | Fork the selected session |
177
179
  | Ctrl-G | Toggle scope: the selected session's project, or every project |
178
180
  | Option-W / Option-P | Show only sessions needing input / pinned sessions |
179
- | Option-S / Option-D | Pin or unpin the selected session / directory |
181
+ | Ctrl-S / Ctrl-D | Pin or unpin the selected session / directory |
180
182
  | Esc | Close |
181
183
 
182
184
  Click the sidebar's `search…` box to filter recent sessions in place; click
183
185
  elsewhere or press Esc to leave search. Click the **Sessions** heading to drive
184
186
  the list from the keyboard instead of the mouse (`↑`/`↓` to move, Enter to open,
185
- Ctrl-P to preview, Option-S/Option-D to pin, Ctrl-X to delete, Esc to leave). Pinned
187
+ Ctrl-P to preview, Ctrl-S/Ctrl-D to pin, Ctrl-X to delete, Esc to leave). Pinned
186
188
  directories rise first, while sessions within each directory are ordered by
187
189
  last update, newest first. The section lists every session and scrolls to fill
188
190
  the sidebar, so click **▾ hide** to collapse it when you need the room.
189
191
  Sessions waiting on you (unanswered agent questions, runs stuck mid-tool) pin
190
192
  themselves to a **Needs input** group above the directories, with the count in
191
- the heading; `sesh --needs-input` lists the same set in the terminal. Row titles
192
- and labels mark waiting (warning), running (success), and the current idle
193
- session (accent); other sessions stay neutral.
193
+ the heading; `sesh --needs-input` lists the same set in the terminal. Row
194
+ markers are the only status signal: a loading spinner marks the session whose
195
+ agent is working; the open session shows ● and every other row stays neutral.
194
196
  Hovering a row while keyboard navigation is active selects it for the next
195
197
  shortcut. Clicking (or Enter on) a session opens it here. The full picker is one
196
198
  keystroke away.
package/bin/sesh-list.sh CHANGED
@@ -276,7 +276,7 @@ refresh() {
276
276
  historical="$scratch/historical.jsonl"; needs_extract="$scratch/needs-extract.json"
277
277
  out="$scratch/snapshot.jsonl"
278
278
 
279
- db_json "SELECT id, directory, title, time_created, time_updated, COALESCE(time_archived, 0) AS archived FROM session ORDER BY time_updated DESC;" > "$sessions" 2>/dev/null || {
279
+ db_json "SELECT id, directory, title, time_created, time_updated, COALESCE(time_archived, 0) AS archived, COALESCE(parent_id, '') AS parent FROM session ORDER BY time_updated DESC;" > "$sessions" 2>/dev/null || {
280
280
  if [ -f "$SNAPSHOT" ]; then set_status 'stale: opencode database query failed; showing last good snapshot'; else set_status 'unavailable: opencode database query failed'; fi
281
281
  return 0
282
282
  }
@@ -388,7 +388,9 @@ refresh() {
388
388
  | ($hist | map({key: .sessionId, value: .}) | from_entries) as $histById
389
389
  | [$meta[]
390
390
  | select((.id | type) == "string" and (.id | test("^ses_[A-Za-z0-9]+$")))
391
- | select($archived == 1 or .archived == 0)
391
+ # Child sessions (fork children, spawned subagents) surface only with
392
+ # --archived; metadata still sees them, so cache pruning stays safe.
393
+ | select($archived == 1 or (.archived == 0 and .parent == ""))
392
394
  | select($scope == "" or .directory == $scope)
393
395
  | . as $m | ($histById[$m.id] // {}) as $h
394
396
  | {sessionId: $m.id, malformedRecords: 0, agentId: "", agentState: "",
@@ -8,8 +8,8 @@ sesh shortcuts
8
8
  Ctrl-F Resume the selected session as a fork
9
9
  Ctrl-G Toggle current-directory scope / all sessions
10
10
  Ctrl-P Toggle transcript preview
11
- Option-S Pin / unpin selected session
12
- Option-D Pin / unpin selected directory
11
+ Ctrl-S Pin / unpin selected session
12
+ Ctrl-D Pin / unpin selected directory
13
13
  ? Toggle this help
14
14
  Ctrl-X Delete the selected session (asks to confirm)
15
15
  Escape Exit the picker
package/bin/sesh.sh CHANGED
@@ -146,8 +146,8 @@ while :; do
146
146
  --bind="$help_bind" \
147
147
  --bind="ctrl-x:execute($delete_cmd)+transform-header($header_cmd)+reload($cache_cmd)" \
148
148
  --bind="ctrl-g:execute($toggle_cmd)+transform-header($header_cmd)+reload($cache_cmd)" \
149
- --bind="alt-s:execute-silent($pin_session_cmd)+reload($cache_cmd)" \
150
- --bind="alt-d:execute-silent($pin_directory_cmd)+reload($cache_cmd)" \
149
+ --bind="ctrl-s:execute-silent($pin_session_cmd)+reload($cache_cmd)" \
150
+ --bind="ctrl-d:execute-silent($pin_directory_cmd)+reload($cache_cmd)" \
151
151
  --bind="change:reload:$cache_cmd" \
152
152
  --bind="start,every(3):transform-header($header_cmd)+reload-sync:$cache_cmd")
153
153
  status=$?
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "opencode-sesh",
3
- "version": "0.9.0",
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": {
@@ -248,19 +248,20 @@ function ensureRootMouse(renderer: TuiPluginApi["renderer"]) {
248
248
 
249
249
  type Entry = { id: string; title: string; dir: string; group: string; updated: number }
250
250
  type EntryResult = { entries: Entry[]; truncated: boolean }
251
- type SidebarActivity = "waiting" | "running" | "active" | "idle"
251
+ type SidebarMarker = "current" | "running" | "idle"
252
+
253
+ // Loading-spinner frames for the marker of a session whose agent is working.
254
+ const SIDEBAR_SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
252
255
 
253
256
  function newestFirst(entries: Entry[]): Entry[] {
254
257
  return [...entries].sort((a, b) => b.updated - a.updated || a.id.localeCompare(b.id))
255
258
  }
256
259
 
257
- // A question needs an answer even if the agent is busy. A stale running-tool
258
- // record, on the other hand, must not hide a currently busy agent.
259
- function sidebarActivity(current: boolean, live?: string, pending = false, reason?: string): SidebarActivity {
260
- if (pending || reason === "question") return "waiting"
260
+ // A working agent is the only state the marker displays: everything that
261
+ // finished goes back to the neutral row, with the open session marked inside.
262
+ function sidebarMarker(current: boolean, live?: string): SidebarMarker {
261
263
  if (live === "busy" || live === "retry") return "running"
262
- if (reason === "stuck") return "waiting"
263
- return current ? "active" : "idle"
264
+ return current ? "current" : "idle"
264
265
  }
265
266
 
266
267
  const SESSION_PAGE_LIMIT = 200
@@ -808,8 +809,6 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
808
809
  const ids = waiting()
809
810
  return newestFirst(shownEntries().filter((entry) => ids.has(entry.id)))
810
811
  })
811
- const waitingReason = (id: string) =>
812
- waiting().get(id) === "question" ? "awaiting answer" : waiting().get(id) === "stuck" ? "run stuck" : "needs input"
813
812
 
814
813
  const tree = createMemo<TreeRow[]>(() => {
815
814
  const rows: TreeRow[] = []
@@ -884,26 +883,34 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
884
883
  previousRows = rows
885
884
  })
886
885
 
887
- const sessionStatus = (id: string): SidebarActivity => {
886
+ // Marker only: a spinner for a working agent, the open-session dot, and the
887
+ // neutral row for idle sessions. Titles stay plain and no status text is
888
+ // rendered.
889
+ const markerOf = (id: string): SidebarMarker => {
890
+ let live: string | undefined
888
891
  try {
889
- const permissions = props.api.state.session.permission(id)
890
- const questions = props.api.state.session.question(id)
891
- return sidebarActivity(
892
- id === currentID(),
893
- props.api.state.session.status(id)?.type,
894
- (permissions?.length ?? 0) > 0 || (questions?.length ?? 0) > 0,
895
- waiting().get(id),
896
- )
892
+ live = props.api.state.session.status(id)?.type
897
893
  } catch {}
898
- return sidebarActivity(id === currentID(), undefined, false, waiting().get(id))
894
+ return sidebarMarker(id === currentID(), live)
899
895
  }
900
896
 
901
- const statusColor = (status: SidebarActivity) => {
902
- if (status === "running") return theme().success
903
- if (status === "waiting") return theme().warning
904
- if (status === "active") return theme().accent
905
- return theme().textMuted
906
- }
897
+ // The spinner animates only while a listed session is actually running, so
898
+ // an idle list costs no timer and only running rows re-render per frame.
899
+ const [spinFrame, setSpinFrame] = createSignal(0)
900
+ let spinTimer: ReturnType<typeof setInterval> | undefined
901
+ createEffect(() => {
902
+ const spinning = itemRows().some((entry) => markerOf(entry.id) === "running")
903
+ if (spinning && !spinTimer) {
904
+ spinTimer = setInterval(() => setSpinFrame((value) => (value + 1) % SIDEBAR_SPINNER.length), 120)
905
+ } else if (!spinning && spinTimer) {
906
+ clearInterval(spinTimer)
907
+ spinTimer = undefined
908
+ setSpinFrame(0)
909
+ }
910
+ })
911
+ onCleanup(() => {
912
+ if (spinTimer) clearInterval(spinTimer)
913
+ })
907
914
 
908
915
  const cancelDelete = () => {
909
916
  if (confirmTimer) clearTimeout(confirmTimer)
@@ -1027,8 +1034,8 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1027
1034
  if (!confirmArmed()) requestDelete(entry)
1028
1035
  },
1029
1036
  },
1030
- { key: "option+s", desc: "Pin session", preventDefault: true, cmd: () => props.onTogglePin("sessions", entry.id) },
1031
- { key: "option+d", desc: "Pin directory", preventDefault: true, cmd: () => props.onTogglePin("directories", entry.dir) },
1037
+ { key: "ctrl+s", desc: "Pin session", preventDefault: true, cmd: () => props.onTogglePin("sessions", entry.id) },
1038
+ { key: "ctrl+d", desc: "Pin directory", preventDefault: true, cmd: () => props.onTogglePin("directories", entry.dir) },
1032
1039
  {
1033
1040
  key: "/",
1034
1041
  desc: "Search sessions",
@@ -1077,7 +1084,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1077
1084
  },
1078
1085
  },
1079
1086
  {
1080
- key: "option+s",
1087
+ key: "ctrl+s",
1081
1088
  desc: "Pin session",
1082
1089
  preventDefault: true,
1083
1090
  cmd: () => {
@@ -1086,7 +1093,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1086
1093
  },
1087
1094
  },
1088
1095
  {
1089
- key: "option+d",
1096
+ key: "ctrl+d",
1090
1097
  desc: "Pin directory",
1091
1098
  preventDefault: true,
1092
1099
  cmd: () => {
@@ -1210,7 +1217,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1210
1217
  </text>
1211
1218
  </box>
1212
1219
  )
1213
- const activity = createMemo(() => sessionStatus(row.entry.id))
1220
+ const marker = createMemo(() => markerOf(row.entry.id))
1214
1221
  return (
1215
1222
  <box
1216
1223
  flexDirection="row"
@@ -1225,7 +1232,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1225
1232
  onMouseOver={() => {
1226
1233
  setHovered(row.entry.id)
1227
1234
  // The keyboard layer wins over the hover layer while nav is
1228
- // active. Keep its cursor on the hovered row so Option-S/Option-D
1235
+ // active. Keep its cursor on the hovered row so Ctrl-S/Ctrl-D
1229
1236
  // cannot silently pin a different (often current) session.
1230
1237
  if (navActive()) {
1231
1238
  const index = itemRows().findIndex((entry) => entry.id === row.entry.id)
@@ -1241,30 +1248,24 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1241
1248
  style={{
1242
1249
  fg: isPendingDelete(row.entry.id)
1243
1250
  ? theme().error
1244
- : row.entry.id === currentID()
1251
+ : marker() === "current"
1245
1252
  ? theme().accent
1246
- : statusColor(activity()),
1253
+ : marker() === "running"
1254
+ ? theme().success
1255
+ : theme().textMuted,
1247
1256
  }}
1248
1257
  >
1249
- {row.entry.id === currentID() ? "●" : activity() === "waiting" ? "!" : activity() === "running" ? "▶" : isActive(row.entry.id) ? "▸" : "○"}
1258
+ {marker() === "current" ? "●" : marker() === "running" ? SIDEBAR_SPINNER[spinFrame()] : isActive(row.entry.id) ? "▸" : "○"}
1250
1259
  </span>
1251
1260
  </text>
1252
1261
  <Highlighted
1253
1262
  text={`${props.pins().sessions.includes(row.entry.id) ? "★ " : ""}${truncate(row.entry.title, SIDEBAR_TITLE_WIDTH - (props.pins().sessions.includes(row.entry.id) ? 2 : 0))}`}
1254
1263
  query={query()}
1255
- color={isPendingDelete(row.entry.id) ? theme().error : activity() === "idle" ? theme().text : statusColor(activity())}
1264
+ color={isPendingDelete(row.entry.id) ? theme().error : theme().text}
1256
1265
  matchColor={theme().warning}
1257
1266
  />
1258
1267
  <text flexShrink={0} style={{ fg: isPendingDelete(row.entry.id) ? theme().error : theme().textMuted }}>
1259
- {isPendingDelete(row.entry.id) ? (
1260
- "ctrl+x again"
1261
- ) : activity() !== "idle" ? (
1262
- <span>
1263
- {ago(row.entry.updated)} · <span style={{ fg: statusColor(activity()) }}>{activity() === "waiting" ? waitingReason(row.entry.id) : activity()}</span>
1264
- </span>
1265
- ) : (
1266
- ago(row.entry.updated)
1267
- )}
1268
+ {isPendingDelete(row.entry.id) ? "ctrl+x again" : ago(row.entry.updated)}
1268
1269
  </text>
1269
1270
  </box>
1270
1271
  )
@@ -1279,7 +1280,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1279
1280
  </Show>
1280
1281
  <Show when={navActive()}>
1281
1282
  <box paddingTop={1}>
1282
- <text style={{ fg: theme().textMuted }}>↑↓ move · enter open · ctrl+p preview · option+s/d pin · ctrl+x delete · esc done</text>
1283
+ <text style={{ fg: theme().textMuted }}>↑↓ move · enter open · ctrl+p preview · ctrl+s/d pin · ctrl+x delete · esc done</text>
1283
1284
  </box>
1284
1285
  </Show>
1285
1286
  </Show>
@@ -1350,8 +1351,8 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
1350
1351
  preventDefault: true,
1351
1352
  cmd: () => preview.open(entry),
1352
1353
  },
1353
- { key: "option+s", desc: "Pin session", preventDefault: true, cmd: () => props.onTogglePin("sessions", entry.id) },
1354
- { key: "option+d", desc: "Pin directory", preventDefault: true, cmd: () => props.onTogglePin("directories", entry.dir) },
1354
+ { key: "ctrl+s", desc: "Pin session", preventDefault: true, cmd: () => props.onTogglePin("sessions", entry.id) },
1355
+ { key: "ctrl+d", desc: "Pin directory", preventDefault: true, cmd: () => props.onTogglePin("directories", entry.dir) },
1355
1356
  ],
1356
1357
  })
1357
1358
  })
@@ -1645,13 +1646,23 @@ const tui: TuiPlugin = async (api) => {
1645
1646
  })
1646
1647
 
1647
1648
  const termHeight = api.renderer.height
1648
- 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))
1649
1659
 
1650
1660
  const visiblePickerRows = createMemo<DialogRow[]>(() => {
1651
1661
  const flat = pickerRows()
1652
1662
  // A transcript hit adds a second line to a row; bound by screen height,
1653
1663
  // not just the number of session IDs.
1654
- 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
1655
1666
  const currentID = selectableEntries()[cursor()]?.id
1656
1667
  let pos = currentID
1657
1668
  ? flat.findIndex((row) => row.kind === "item" && row.entry.id === currentID)
@@ -1726,6 +1737,22 @@ const tui: TuiPlugin = async (api) => {
1726
1737
  setPendingDelete(undefined)
1727
1738
  }
1728
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
+
1729
1756
  const deleteEntry = async (entry: Entry) => {
1730
1757
  cancelDelete()
1731
1758
  try {
@@ -1792,6 +1819,9 @@ const tui: TuiPlugin = async (api) => {
1792
1819
  }
1793
1820
 
1794
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,
1795
1825
  bindings: [
1796
1826
  { key: "up", desc: "Previous session", preventDefault: true, cmd: () => moveCursor(-1) },
1797
1827
  { key: "down", desc: "Next session", preventDefault: true, cmd: () => moveCursor(1) },
@@ -1806,11 +1836,11 @@ const tui: TuiPlugin = async (api) => {
1806
1836
  },
1807
1837
  { key: "enter", desc: "Open session", preventDefault: true, cmd: () => choose() },
1808
1838
  { key: "ctrl+p", desc: "Toggle transcript preview", preventDefault: true, cmd: togglePreview },
1809
- { key: "option+s", desc: "Pin session", preventDefault: true, cmd: () => {
1839
+ { key: "ctrl+s", desc: "Pin session", preventDefault: true, cmd: () => {
1810
1840
  const entry = selectableEntries()[cursor()]
1811
1841
  if (entry) onTogglePin("sessions", entry.id)
1812
1842
  } },
1813
- { key: "option+d", desc: "Pin directory", preventDefault: true, cmd: () => {
1843
+ { key: "ctrl+d", desc: "Pin directory", preventDefault: true, cmd: () => {
1814
1844
  const entry = selectableEntries()[cursor()]
1815
1845
  if (entry) onTogglePin("directories", entry.dir)
1816
1846
  } },
@@ -1851,10 +1881,7 @@ const tui: TuiPlugin = async (api) => {
1851
1881
  key: "escape",
1852
1882
  desc: "Close",
1853
1883
  preventDefault: true,
1854
- cmd: () => {
1855
- cancelDelete()
1856
- close()
1857
- },
1884
+ cmd: handleEscape,
1858
1885
  },
1859
1886
  ],
1860
1887
  })
@@ -1870,7 +1897,14 @@ const tui: TuiPlugin = async (api) => {
1870
1897
 
1871
1898
  api.ui.dialog.replace(
1872
1899
  () => (
1873
- <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
+ >
1874
1908
  <box paddingLeft={4} paddingRight={4}>
1875
1909
  <box flexDirection="row" justifyContent="space-between">
1876
1910
  <text attributes={TextAttributes.BOLD}>
@@ -1894,7 +1928,7 @@ const tui: TuiPlugin = async (api) => {
1894
1928
  onKeyDown={(event) => {
1895
1929
  if (event.name === "escape") {
1896
1930
  event.preventDefault()
1897
- close()
1931
+ handleEscape()
1898
1932
  }
1899
1933
  }}
1900
1934
  />
@@ -1922,6 +1956,8 @@ const tui: TuiPlugin = async (api) => {
1922
1956
  </box>
1923
1957
  <box
1924
1958
  flexDirection="column"
1959
+ flexGrow={1}
1960
+ overflow="hidden"
1925
1961
  onMouseScroll={(event: { scroll?: { direction: string } }) => {
1926
1962
  const direction = event.scroll?.direction
1927
1963
  if (direction === "down") moveCursor(3)
@@ -1931,7 +1967,7 @@ const tui: TuiPlugin = async (api) => {
1931
1967
  <For each={visiblePickerRows()}>
1932
1968
  {(row) => {
1933
1969
  if (row.kind === "group") return (
1934
- <box paddingTop={1} paddingLeft={4}>
1970
+ <box paddingLeft={4}>
1935
1971
  <text style={{ fg: api.theme.current.accent }} attributes={TextAttributes.BOLD}>
1936
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>
1937
1973
  </text>
@@ -1948,6 +1984,12 @@ const tui: TuiPlugin = async (api) => {
1948
1984
  ? api.theme.current.primary
1949
1985
  : RGBA.fromInts(0, 0, 0, 0)
1950
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
+ }}
1951
1993
  onMouseDown={() => choose(row.entry)}
1952
1994
  >
1953
1995
  <box flexDirection="row" gap={1}>
@@ -2025,7 +2067,7 @@ const tui: TuiPlugin = async (api) => {
2025
2067
  : waitingOnly() && waitingCoverage() === "unavailable"
2026
2068
  ? "Needs-input data unavailable · turn off the filter"
2027
2069
  : pinnedOnly() && pins().sessions.length === 0
2028
- ? "No pinned sessions yet · option+s pins a session"
2070
+ ? "No pinned sessions yet · ctrl+s pins a session"
2029
2071
  : !indexProgress().complete && query().trim()
2030
2072
  ? "No matches yet · still searching transcripts…"
2031
2073
  : scope()
@@ -2037,11 +2079,46 @@ const tui: TuiPlugin = async (api) => {
2037
2079
  </box>
2038
2080
  </Show>
2039
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>
2040
2096
  <Show when={showPreview()}>
2041
- <box flexShrink={0} flexDirection="column" paddingLeft={4} paddingRight={4} height={12}>
2042
- <text style={{ fg: api.theme.current.textMuted }} attributes={TextAttributes.BOLD}>
2043
- {previewID() ? `Preview · ${truncate(selectableEntries()[cursor()]!.title, 48)}` : "Preview"}
2044
- </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>
2045
2122
  <scrollbox flexGrow={1} scrollbarOptions={{ visible: false }}>
2046
2123
  <Show when={previewID()} fallback={<text style={{ fg: api.theme.current.textMuted }}>Select a session to preview</text>}>
2047
2124
  <Show when={previewText()} fallback={<text style={{ fg: api.theme.current.textMuted }}>Loading…</text>}>
@@ -2061,20 +2138,6 @@ const tui: TuiPlugin = async (api) => {
2061
2138
  </scrollbox>
2062
2139
  </box>
2063
2140
  </Show>
2064
- <box paddingLeft={4} paddingRight={4} flexShrink={0} flexDirection="column">
2065
- <text
2066
- style={{
2067
- fg: pendingDelete() ? api.theme.current.warning : api.theme.current.textMuted,
2068
- }}
2069
- >
2070
- {pendingDelete()
2071
- ? `Delete "${truncate(pendingDelete()!.title, 40)}"? y confirm · n cancel`
2072
- : "↑↓ move · enter open · ctrl+g project · option+w needs · option+p pinned · ctrl+p preview"}
2073
- </text>
2074
- <Show when={!pendingDelete()}>
2075
- <text style={{ fg: api.theme.current.textMuted }}>option+s/d pin · ctrl+x delete · ctrl+f fork · esc close</text>
2076
- </Show>
2077
- </box>
2078
2141
  </box>
2079
2142
  ),
2080
2143
  () => {