opencode-sesh 0.9.0 → 0.9.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.
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,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.1] - 2026-09-25
11
+
12
+ ### Changed
13
+
14
+ - Sidebar rows show state only in the marker column: a running agent animates
15
+ a loading spinner; idle rows and their titles go back to neutral.
16
+ - Pin keybinds move from Option-S/Option-D to Ctrl-S/Ctrl-D in the terminal
17
+ picker, the sidebar, and the picker dialog.
18
+ - The terminal picker hides child sessions (fork children, spawned subagents)
19
+ like the panel does; `--archived` still opts them back in.
10
20
  ## [0.9.0] - 2026-09-24
11
21
 
12
22
  ### Added
@@ -342,7 +352,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
342
352
  - Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
343
353
  CI.
344
354
 
345
- [Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.9.0...HEAD
355
+ [Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.9.1...HEAD
356
+ [0.9.1]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.9.0...v0.9.1
346
357
  [0.9.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.8.0...v0.9.0
347
358
  [0.8.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.7.3...v0.8.0
348
359
  [0.7.3]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.7.2...v0.7.3
package/README.md CHANGED
@@ -28,7 +28,7 @@ grouped by directory, full-text searchable, and previewable from anywhere.
28
28
 
29
29
  - **Every session, everywhere.** Sessions are grouped by project directory and
30
30
  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
31
+ - **Pin what matters.** Ctrl-S pins a session and Ctrl-D pins its directory in
32
32
  either picker. Pins are shared across the terminal and opencode TUI and
33
33
  persist across restarts; pinned directories and sessions sort first.
34
34
  - **Full-text search.** Type to match against session titles *and* the text of
@@ -115,7 +115,7 @@ bash install.sh --uninstall # from source
115
115
  | Enter | Resume the selected session in this terminal |
116
116
  | Ctrl-F | Resume as a fork (the original is untouched) |
117
117
  | Ctrl-G | Toggle current-directory scope / all sessions |
118
- | Option-S / Option-D | Pin or unpin the selected session / directory |
118
+ | Ctrl-S / Ctrl-D | Pin or unpin the selected session / directory |
119
119
  | Ctrl-P | Toggle the transcript preview |
120
120
  | `?` (empty query) | Toggle shortcut help |
121
121
  | Ctrl-X | Delete the selected session (asks to confirm) |
@@ -176,21 +176,21 @@ non-interactive `sesh-delete.sh` refuses unless given `--yes`.
176
176
  | Ctrl-F | Fork the selected session |
177
177
  | Ctrl-G | Toggle scope: the selected session's project, or every project |
178
178
  | Option-W / Option-P | Show only sessions needing input / pinned sessions |
179
- | Option-S / Option-D | Pin or unpin the selected session / directory |
179
+ | Ctrl-S / Ctrl-D | Pin or unpin the selected session / directory |
180
180
  | Esc | Close |
181
181
 
182
182
  Click the sidebar's `search…` box to filter recent sessions in place; click
183
183
  elsewhere or press Esc to leave search. Click the **Sessions** heading to drive
184
184
  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
185
+ Ctrl-P to preview, Ctrl-S/Ctrl-D to pin, Ctrl-X to delete, Esc to leave). Pinned
186
186
  directories rise first, while sessions within each directory are ordered by
187
187
  last update, newest first. The section lists every session and scrolls to fill
188
188
  the sidebar, so click **▾ hide** to collapse it when you need the room.
189
189
  Sessions waiting on you (unanswered agent questions, runs stuck mid-tool) pin
190
190
  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.
191
+ the heading; `sesh --needs-input` lists the same set in the terminal. Row
192
+ markers are the only status signal: a loading spinner marks the session whose
193
+ agent is working; the open session shows ● and every other row stays neutral.
194
194
  Hovering a row while keyboard navigation is active selects it for the next
195
195
  shortcut. Clicking (or Enter on) a session opens it here. The full picker is one
196
196
  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,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-sesh",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "type": "module",
5
5
  "description": "Browse, search, preview and resume opencode sessions from the terminal and the TUI.",
6
6
  "license": "MIT",
@@ -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
  })
@@ -1806,11 +1807,11 @@ const tui: TuiPlugin = async (api) => {
1806
1807
  },
1807
1808
  { key: "enter", desc: "Open session", preventDefault: true, cmd: () => choose() },
1808
1809
  { key: "ctrl+p", desc: "Toggle transcript preview", preventDefault: true, cmd: togglePreview },
1809
- { key: "option+s", desc: "Pin session", preventDefault: true, cmd: () => {
1810
+ { key: "ctrl+s", desc: "Pin session", preventDefault: true, cmd: () => {
1810
1811
  const entry = selectableEntries()[cursor()]
1811
1812
  if (entry) onTogglePin("sessions", entry.id)
1812
1813
  } },
1813
- { key: "option+d", desc: "Pin directory", preventDefault: true, cmd: () => {
1814
+ { key: "ctrl+d", desc: "Pin directory", preventDefault: true, cmd: () => {
1814
1815
  const entry = selectableEntries()[cursor()]
1815
1816
  if (entry) onTogglePin("directories", entry.dir)
1816
1817
  } },
@@ -2025,7 +2026,7 @@ const tui: TuiPlugin = async (api) => {
2025
2026
  : waitingOnly() && waitingCoverage() === "unavailable"
2026
2027
  ? "Needs-input data unavailable · turn off the filter"
2027
2028
  : pinnedOnly() && pins().sessions.length === 0
2028
- ? "No pinned sessions yet · option+s pins a session"
2029
+ ? "No pinned sessions yet · ctrl+s pins a session"
2029
2030
  : !indexProgress().complete && query().trim()
2030
2031
  ? "No matches yet · still searching transcripts…"
2031
2032
  : scope()
@@ -2072,7 +2073,7 @@ const tui: TuiPlugin = async (api) => {
2072
2073
  : "↑↓ move · enter open · ctrl+g project · option+w needs · option+p pinned · ctrl+p preview"}
2073
2074
  </text>
2074
2075
  <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
+ <text style={{ fg: api.theme.current.textMuted }}>ctrl+s/d pin · ctrl+x delete · ctrl+f fork · esc close</text>
2076
2077
  </Show>
2077
2078
  </box>
2078
2079
  </box>