opencode-sesh 0.1.7 → 0.2.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
@@ -23,10 +23,12 @@ slot section plus an xlarge modal picker; the terminal UI is fullscreen fzf.
23
23
  ## Start here
24
24
 
25
25
  ```bash
26
- npm install # dev deps (TypeScript, opencode/OpenTUI types)
27
- npm test # fixture-DB regression suite — must stay green
28
- npm run typecheck # tsc over tui/ and opencode/
29
- npm run lint:sh # bash -n on every script
26
+ bun install # dev deps (TypeScript, opencode/OpenTUI types)
27
+ bun run test # fixture-DB regression suite — must stay green
28
+ bun run test:changelog # release-tooling regressions (scripts/changelog.sh)
29
+ bun run test:picker # PTY picker suite (needs fzf >= 0.73)
30
+ bun run typecheck # tsc over tui/ and opencode/
31
+ bun run lint:sh # bash -n on every script
30
32
  HOME=/tmp/fakehome bash install.sh # installer smoke test (never touches ~/.config)
31
33
  ```
32
34
 
@@ -36,6 +38,9 @@ HOME=/tmp/fakehome bash install.sh # installer smoke test (never touches ~/.co
36
38
  Never hardcode install paths.
37
39
  - `tui/sesh-panel.tsx` — SolidJS panel, `/** @jsxImportSource @opentui/solid */`.
38
40
  - `opencode/tools/sesh-list.ts` — the agent-facing `sesh-list` tool.
41
+ - `scripts/changelog.sh` — release tooling: drafts `[Unreleased]` from
42
+ conventional commits, promotes it to a dated version, prints release notes and
43
+ checks the compare links. `tests/changelog.sh` covers it in a throwaway repo.
39
44
  - `themes/`, `tests/`, `install.sh`, `README.md`.
40
45
 
41
46
  The **filename becomes the tool name** in opencode, so `sesh-list.ts` is the
@@ -60,7 +65,18 @@ the schema below. Keystrokes never touch the database:
60
65
  the whole DB and pruning is always safe.
61
66
  - render (cheap, pure `jq`): reads the snapshot only. Filters on `searchText`
62
67
  (title + `fulltextLower`, both lowercased at extraction), groups by `cwd`,
63
- sorts groups/items by recency, renders the TSV.
68
+ sorts groups/items by recency, renders the TSV. Notice rows (headers, a
69
+ vanished selection, an empty store, a query matching nothing) carry an empty
70
+ `sessionId` and a unique `trackingId`. `--header` prints the one-line
71
+ scope/count/status that the picker uses as fzf's header, and a non-actionable
72
+ selection leaves a one-shot `STATE_DIR/action-notice` the next render shows
73
+ and consumes. Snapshot records carry `archived` as 0/1 (the DB column is an
74
+ epoch, not a boolean).
75
+ - Session and directory pins live in `${XDG_DATA_HOME:-$HOME/.local/share}/sesh/pins.json`
76
+ (`SESH_PINS_FILE` overrides it for tests), shared by fzf and the TUI. Writes
77
+ use a sibling lock directory and atomic rename. Option-S toggles a selected
78
+ session; Option-D toggles its directory. Pinned directories sort before groups
79
+ containing pinned sessions, with pinned sessions first inside each group.
64
80
 
65
81
  **Output contract** (TSV, 6 fields):
66
82
  `agentId sessionId liveState display cwd trackingId`. `display` is the only
@@ -69,8 +85,11 @@ empty `sessionId` and are a selection no-op — keep that guard.
69
85
 
70
86
  One worker (`sesh-refresh-worker.sh`, per picker, TERMed on exit) serializes
71
87
  scans; fzf's `start`/`every(3)`/`change` bindings render only. Selection requests
72
- a fresh poll (`request`/`wait`, ≤12 s) before dispatch. opencode has no
73
- live-agent API, so `liveState` is always `unknown` and rows render gray;
88
+ a fresh poll (`request`/`wait`, ≤12 s) before dispatch — skipped for `--print`,
89
+ which is a pure snapshot lookup — and prints a progress line to stderr while it
90
+ waits, because fzf has already exited and the terminal is idle. opencode has no
91
+ live-agent API, so `liveState` is always `unknown` and rows render gray unless
92
+ they are archived (opt-in, tagged `· archived`);
74
93
  dispatch is always `opencode --session <id>` (`--fork` with Ctrl-F or --fork).
75
94
  Resume happens in place (`exec` after `cd` to the session's cwd).
76
95
 
@@ -88,7 +107,12 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
88
107
  raw string.
89
108
  - fzf: stock only, `>= 0.73` (`transform:`, `every():`). `{q}` in binds is
90
109
  shell-quoted by fzf — don't add quoting. Ctrl-G is bind-only (stays open);
91
- Ctrl-F is `--expect` (accepts with fork).
110
+ Ctrl-F is `--expect` (accepts with fork). A `transform-header` inside a chained
111
+ action must come **before** `reload`/`reload-sync`: placed after, fzf silently
112
+ publishes an empty list (verified on 0.74.3).
113
+ - Destructive actions confirm. `sesh-delete.sh` prompts on a TTY and refuses
114
+ without `--yes` when stdin is not one; the TUI arms on the first Ctrl-X and
115
+ commits on the second (or `y`).
92
116
  - The installer prefers the local opencode version for `@opencode-ai/*` and
93
117
  merges (never clobbers) the config `package.json`; opencode runs `bun install`
94
118
  at startup.
@@ -96,23 +120,38 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
96
120
  (`feat`, `fix`, `docs`, `ci`, …). See
97
121
  [CONTRIBUTING.md](CONTRIBUTING.md#commit-messages) for the types and the
98
122
  version mapping.
123
+ - `CHANGELOG.md` follows Keep a Changelog: notable changes are written under
124
+ `[Unreleased]` as the work lands, and the Release workflow promotes that
125
+ section (dated, relinked) into the published version, using it verbatim as the
126
+ GitHub release body. `scripts/changelog.sh draft` can seed entries from
127
+ conventional commits, but hand-written prose is the expected form — never let
128
+ a draft overwrite curated entries.
99
129
 
100
130
  ## TUI panel (`tui/sesh-panel.tsx`)
101
131
 
102
132
  - **Sidebar:** `api.slots.register` on `sidebar_content` (append mode — native
103
- sidebar content stays). Renders the most recent unarchived sessions with the
104
- current one highlighted, polled every 15 s. Display-only; the picker does
105
- actions.
133
+ sidebar content stays). Renders pinned sessions before the newest unarchived
134
+ sessions, with the current one highlighted, polled every 15 s; Ctrl-P previews
135
+ a row and ctrl+x deletes one (armed, then confirmed). The section lists every
136
+ session without a row cap and flex-grows to fill the sidebar, with overflow
137
+ scrolling inside; the picker remains the place for transcript search.
106
138
  - **Picker:** a custom dialog (not `DialogSelect`) grouped by
107
139
  project/directory, recency-ordered, resume via
108
140
  `api.route.navigate("session", …)`. `ctrl+o` and the command palette open it;
109
- Ctrl-X deletes via the opencode CLI and removes the row only after the server
110
- confirms the deletion.
141
+ Ctrl-X arms and then confirms a delete through `api.client.session.delete` and
142
+ removes the row only after the server confirms; Ctrl-F forks through
143
+ `api.client.session.fork`; Ctrl-G scopes the list to the selected session's
144
+ project.
111
145
  - **Picker search:** pages the global session list (`SESSION_PAGE_LIMIT`, capped
112
146
  at `SESSION_MAX` and reported when truncated) instead of a fixed 500-row
113
147
  window, then indexes transcript text for **every** session in batches
114
148
  (`TRANSCRIPT_BATCH`) with bounded remote concurrency (`REMOTE_CONCURRENCY`).
115
149
  The header shows indexing progress/coverage. `tests/tui.mjs` locks this.
150
+ - **Durable install:** opencode imports plugins once at startup and never hot
151
+ reloads, so panel changes need a full quit/reopen. `install.sh --sync-panel`
152
+ (run by the `postinstall`) refreshes an already-installed panel and is a
153
+ no-op when absent, and `sesh --check` reports when the installed copy differs
154
+ from the bundled one.
116
155
 
117
156
  ### Hard-won rules — do not regress
118
157
 
@@ -131,12 +170,35 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
131
170
  removes any leftover `commands/sesh.md`.
132
171
  - Do not bind `<leader>l` (native `session_list`) or shadow the native
133
172
  `/sessions` command.
173
+ - **The sidebar never binds arrow keys globally.** Keyboard navigation is
174
+ opt-in: clicking the *Sessions* heading (or its search box) activates a
175
+ priority-20 layer, because a global `↑`/`↓` would hijack the main prompt.
176
+ Cursor state is derived from `itemRows()`, so hover and cursor resolve to the
177
+ same row. The section lists every session with no row cap
178
+ (`tests/tui.mjs` asserts the cap stays out); overflow scrolls inside the
179
+ stretched section.
180
+ - **Delete in the TUI is two-step.** The first `ctrl+x` arms `pendingDelete`,
181
+ the second (or `y`, or Enter in the picker footer) commits the armed row
182
+ wherever the pointer or cursor is, and `n`/Esc (or a five-second timeout)
183
+ cancels. Moving the pointer must never disarm: edge-hover flicker ate the
184
+ confirm before `confirmArmed` landed. The `y`/`n` layer is registered *only
185
+ while armed*, so it can never shadow the search box's typing.
186
+ - **Picker `ctrl+g` is project scope**, not close (parity with the fzf picker's
187
+ `Ctrl-G`); Esc is the only close key. `ctrl+f` forks through
188
+ `api.client.session.fork` and navigates to the returned session.
189
+ - Row titles render through `<Highlighted>`, and JSX must stay out of the
190
+ `function shortDir` … `const TRANSCRIPT_PREVIEW_TURNS` window that
191
+ `tests/tui.mjs` extracts and evaluates as the data layer.
134
192
 
135
193
  ## Verifying
136
194
 
137
- - `npm test` — fixture-DB regression suite, hermetic via `SESH_*` overrides.
138
- - `npm run typecheck` — `tsc` over `tui/` and `opencode/`.
139
- - `npm run lint:sh` — `bash -n` on every script.
195
+ - `bun run test` — fixture-DB regression suite, hermetic via `SESH_*` overrides.
196
+ - `bun run test:changelog` — release-tooling regressions; builds its own git
197
+ history in a temp dir (included in `bun run test`).
198
+ - `bun run test:picker` — PTY suite driving the real fzf picker (needs fzf
199
+ `>= 0.73` and Python 3).
200
+ - `bun run typecheck` — `tsc` over `tui/` and `opencode/`.
201
+ - `bun run lint:sh` — `bash -n` on every script.
140
202
  - `HOME=/tmp/fakehome bash install.sh` — installer smoke test.
141
203
  - Keep `tui/sesh-panel.tsx` and any installed copy byte-identical when testing
142
204
  the panel locally.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,90 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.1] - 2026-09-24
11
+
12
+ ### Changed
13
+
14
+ - The TUI sidebar's Sessions section now lists every session and fills the
15
+ available sidebar height: the fixed 15-row cap is gone, with overflow
16
+ scrolling inside the section.
17
+
18
+ ### Added
19
+
20
+ - Pin sessions with Option-S or directories with Option-D in both pickers. Pins live
21
+ in a shared XDG data file, appear with a star, and sort ahead of recent
22
+ sessions in the terminal picker, TUI picker, sidebar and home list.
23
+
24
+ ### Fixed
25
+
26
+ - The sidebar delete confirm no longer disarms when the pointer leaves the
27
+ armed row: the second Ctrl+X (or `y`) commits the visibly armed row wherever
28
+ the pointer is, so deleting works while hovering the sidebar edges.
29
+
30
+ - Session previews now use Ctrl-P rather than Space in the terminal picker,
31
+ sidebar and home list, so typing cannot accidentally open a preview.
32
+ - Hovering a sidebar session while keyboard navigation is active now moves the
33
+ selection to that row, so Option-S/Option-D pin the row under the pointer rather
34
+ than the previously selected (often currently open) session.
35
+ - The TUI sidebar's Sessions section now has a working show/hide control,
36
+ separate from clicking the heading to activate keyboard navigation.
37
+ - Transcript previews pass OpenTUI's tree-sitter client to the markdown
38
+ renderer, restoring themed Markdown and fenced-code highlighting.
39
+ - Preview Markdown now uses the active theme's semantic accents for headings,
40
+ emphasis, code and list markers when the default Markdown colors read like
41
+ body text.
42
+
43
+ ### Changed
44
+
45
+ - Pin shortcuts are labeled Option-S/Option-D (the macOS key) in both pickers
46
+ and the docs. The terminal picker still binds fzf's `alt-s`/`alt-d`, which is
47
+ fzf's name for Option and unchanged.
48
+ - The TUI picker now shows transcript search matches as indexing progresses,
49
+ identifies transcript-only hits, and distinguishes an in-progress search from
50
+ a completed search with no results.
51
+ - Session previews identify the selected session and show loading feedback
52
+ while it changes, instead of displaying another session's transcript.
53
+ - The TUI picker uses `Ctrl-P` for transcript preview; Space always enters a
54
+ space in the search field, including at the start of a query.
55
+ - Sidebar and home searches direct users to the full picker for transcript
56
+ search when no titles or directories match.
57
+ ## [0.2.0] - 2026-09-21
58
+
59
+ ### Added
60
+
61
+ - The picker header always names the effective scope, the session count and
62
+ whether the index is fresh or stale (`sesh-list.sh --header`), so a `Ctrl-G`
63
+ scope toggle or a degraded snapshot is no longer invisible.
64
+ - `sesh --print --json` emits `{"sessionId","cwd","fork"}` for scripting.
65
+ - Archived sessions are tagged `· archived` when included with `--archived`,
66
+ replacing the agent-state colours that could never fire (opencode exposes no
67
+ live agent state, so every row rendered grey either way).
68
+ - The TUI picker takes `Ctrl-X` (delete), `Ctrl-F` (fork) and `Ctrl-G` (scope to
69
+ the selected session's project), so both UIs now agree on the same keys.
70
+ - The TUI sidebar is capped at the 15 most recent sessions with
71
+ `… N more · ctrl+o for all`, and clicking the **Sessions** heading drives it
72
+ from the keyboard (`↑`/`↓`, Enter, Space, `Ctrl-X`, Esc).
73
+ - Empty stores, queries that match nothing, vanished selections and an
74
+ unavailable session list now say so instead of rendering a blank screen.
75
+ - Session titles highlight the part of the row the search matched, and the
76
+ sidebar search accepts uppercase and punctuation.
77
+
78
+ ### Changed
79
+
80
+ - Deleting a session asks first. The picker prompts `[y/N]` before dispatching,
81
+ the TUI arms on the first `Ctrl-X` and commits on a second `Ctrl-X` (or `y`;
82
+ `n`, Esc or moving cancels), and `sesh-delete.sh` refuses a non-terminal stdin
83
+ unless given `--yes` — a piped newline can no longer authorize an irreversible
84
+ delete.
85
+ - Resuming no longer stalls silently while the picker waits for a fresh scan: it
86
+ prints a progress line first, and `--print` lookups skip that poll entirely.
87
+ - Selecting a directory header or a notice row explains itself with a one-shot
88
+ notice row instead of repainting the same list as if the key had been ignored.
89
+ - Reconciled README, AGENTS.md and SECURITY.md with current behavior: the
90
+ terminal UI is described as the full set of scripts (including the refresh
91
+ worker and shortcut help), the extraction cache key documents the latest part
92
+ timestamp, the PTY picker suite (`npm run test:picker`) is listed, and the
93
+ npm distribution and supported-version policy are stated.
10
94
  ## [0.1.7] - 2026-09-13
11
95
 
12
96
  ### Fixed
@@ -141,7 +225,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
141
225
  - Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
142
226
  CI.
143
227
 
144
- [Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.7...HEAD
228
+ [Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.2.1...HEAD
229
+ [0.2.1]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.2.0...v0.2.1
230
+ [0.2.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.7...v0.2.0
145
231
  [0.1.7]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.6...v0.1.7
146
232
  [0.1.6]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.5...v0.1.6
147
233
  [0.1.5]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.4...v0.1.5
package/README.md CHANGED
@@ -27,7 +27,10 @@ grouped by directory, full-text searchable, and previewable from anywhere.
27
27
  ## Why sesh
28
28
 
29
29
  - **Every session, everywhere.** Sessions are grouped by project directory and
30
- sorted by recency — across *all* your projects, not just the current one.
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
32
+ either picker. Pins are shared across the terminal and opencode TUI and
33
+ persist across restarts; pinned directories and sessions sort first.
31
34
  - **Full-text search.** Type to match against session titles *and* the text of
32
35
  the conversation itself. Reasoning and tool output are excluded, so the index
33
36
  stays clean.
@@ -58,7 +61,8 @@ cd opencode-sesh
58
61
  bash install.sh
59
62
  ```
60
63
 
61
- `sesh install` (or `bash install.sh`) links `sesh` into `~/.local/bin`, copies
64
+ `sesh install` (or `bash install.sh`) links `sesh` into `$XDG_BIN_HOME`
65
+ (default `~/.local/bin`), copies
62
66
  the `sesh-list` tool and the TUI panel into
63
67
  `${XDG_CONFIG_HOME:-~/.config}/opencode`, registers the panel in `tui.json`, and
64
68
  declares the plugin dependencies (opencode installs them on next start). The
@@ -111,22 +115,31 @@ bash install.sh --uninstall # from source
111
115
  | Enter | Resume the selected session in this terminal |
112
116
  | Ctrl-F | Resume as a fork (the original is untouched) |
113
117
  | Ctrl-G | Toggle current-directory scope / all sessions |
114
- | Space (empty query) | Toggle the transcript preview |
118
+ | Option-S / Option-D | Pin or unpin the selected session / directory |
119
+ | Ctrl-P | Toggle the transcript preview |
115
120
  | `?` (empty query) | Toggle shortcut help |
116
- | Ctrl-X | Delete the selected session |
121
+ | Ctrl-X | Delete the selected session (asks to confirm) |
117
122
  | Escape | Exit |
118
123
 
124
+ The header line always shows the effective scope, the session count and whether
125
+ the index is fresh or stale, so a `Ctrl-G` toggle is never silent. A query that
126
+ matches nothing says so instead of showing an empty screen.
127
+
119
128
  Flags: `--cwd` (current directory only), `--limit N` (default: all),
120
129
  `--archived`, `--print` (print `id<TAB>cwd` instead of resuming), `--fork`,
121
- `--query TEXT`, `--check`.
130
+ `--json` (with `--print`, emit JSON), `--query TEXT`, `--check`.
122
131
 
123
132
  Because `--print` just emits the id and directory, `sesh` doubles as a scriptable
124
133
  session lookup:
125
134
 
126
135
  ```bash
127
136
  read -r id cwd < <(sesh --print --query "auth")
137
+ sesh --print --json --query "auth" | jq -r .cwd
128
138
  ```
129
139
 
140
+ Deleting is irreversible, so the picker asks before dispatching it, and a
141
+ non-interactive `sesh-delete.sh` refuses unless given `--yes`.
142
+
130
143
  ### TUI panel
131
144
 
132
145
  | Key | Action |
@@ -135,12 +148,22 @@ read -r id cwd < <(sesh --print --query "auth")
135
148
  | `ctrl+o` | Open the full picker (also in the command palette) |
136
149
  | Type | Search titles, directories and transcript text |
137
150
  | `↑`/`↓`, `PgUp`/`PgDn`, `Home`/`End` | Move the selection |
138
- | Space | Toggle the transcript preview |
151
+ | Ctrl-P | Toggle the transcript preview |
139
152
  | Enter | Open the selected session |
153
+ | Ctrl-X | Delete the selected session (asks to confirm) |
154
+ | Ctrl-F | Fork the selected session |
155
+ | Ctrl-G | Toggle scope: the selected session's project, or every project |
156
+ | Option-S / Option-D | Pin or unpin the selected session / directory |
140
157
  | Esc | Close |
141
158
 
142
159
  Click the sidebar's `search…` box to filter recent sessions in place; click
143
- elsewhere or press Esc to leave search.
160
+ elsewhere or press Esc to leave search. Click the **Sessions** heading to drive
161
+ the list from the keyboard instead of the mouse (`↑`/`↓` to move, Enter to open,
162
+ Ctrl-P to preview, Option-S/Option-D to pin, Ctrl-X to delete, Esc to leave). Pinned
163
+ sessions appear ahead of the rest; the section lists every session and scrolls
164
+ to fill the sidebar, so click **▾ hide** to collapse it when you need the room.
165
+ Hovering a row while keyboard navigation is active selects it for the next
166
+ shortcut. The full picker is one keystroke away.
144
167
 
145
168
  The full picker pages through your whole global session list (no fixed window)
146
169
  and indexes transcript text for every session in the background, showing
@@ -159,22 +182,25 @@ All variables are optional.
159
182
  | `SESH_GLOW` | first `glow` on `PATH` | optional Markdown preview renderer |
160
183
  | `SESH_OPENCODE` | `opencode` | opencode executable (resume / delete) |
161
184
  | `SESH_CACHE_DIR` | `~/.cache/sesh` | persistent search-index cache |
185
+ | `SESH_PINS_FILE` | `${XDG_DATA_HOME:-~/.local/share}/sesh/pins.json` | shared persistent session/directory pins |
162
186
 
163
187
  ## How it works
164
188
 
165
- `bin/sesh-list.sh` is the only place that reads the opencode session store
166
- (SQLite: `session` / `message` / `part`). Each refresh extracts session metadata
167
- and text-only parts into a persistent per-session cache keyed on `time_updated`
168
- and part count, then atomically publishes a `snapshot.jsonl`. Every keystroke
169
- re-renders that snapshot with `jq` alone, so typing never starts competing
170
- database scans. Reasoning and tool payloads are never indexed or previewed, and
171
- session ids are validated before any SQL interpolation.
172
-
173
- Four flat scripts back the terminal UI — the picker (`sesh.sh`), snapshot
174
- builder (`sesh-list.sh`), preview renderer (`sesh-preview.sh`) and deleter
175
- (`sesh-delete.sh`) — with one worker process per picker serializing refreshes.
176
- The TUI panel (`tui/sesh-panel.tsx`) is a SolidJS OpenTUI plugin that talks to
177
- the opencode SDK over the same store.
189
+ `bin/sesh-list.sh` is the only `bin/` script that reads the opencode session
190
+ store (SQLite: `session` / `message` / `part`). Each refresh extracts session
191
+ metadata and text-only parts into a persistent per-session cache keyed on
192
+ `time_updated`, part count and the latest part timestamp, then atomically
193
+ publishes a `snapshot.jsonl`. Every keystroke re-renders that snapshot with `jq`
194
+ alone, so typing never starts competing database scans. Reasoning and tool
195
+ payloads are never indexed or previewed, and session ids are validated before
196
+ any SQL interpolation.
197
+
198
+ Flat scripts back the terminal UI — the picker (`sesh.sh`), the refresh engine
199
+ (`sesh-list.sh`, driven by a per-picker `sesh-refresh-worker.sh` that serializes
200
+ scans), preview renderer (`sesh-preview.sh`), deleter (`sesh-delete.sh`) and
201
+ shortcut help (`sesh-shortcuts.sh`). The TUI panel (`tui/sesh-panel.tsx`) is a
202
+ SolidJS OpenTUI plugin that talks to the opencode SDK over the same store; the
203
+ `sesh-list` agent tool reads it through `opencode db`.
178
204
 
179
205
  ## FAQ
180
206
 
@@ -202,10 +228,11 @@ guide; this project follows the [Code of Conduct](CODE_OF_CONDUCT.md). Run the
202
228
  checks before opening a PR:
203
229
 
204
230
  ```bash
205
- npm install
206
- npm test # fixture-database regression suite
207
- npm run typecheck # tsc over tui/ and opencode/
208
- npm run lint:sh # bash -n on every script
231
+ bun install
232
+ bun run test # fixture-database regression suite
233
+ bun run test:picker # PTY picker suite (needs fzf >= 0.73)
234
+ bun run typecheck # tsc over tui/ and opencode/
235
+ bun run lint:sh # bash -n on every script
209
236
  ```
210
237
 
211
238
  `HOME=/tmp/fakehome bash install.sh` smoke-tests the installer without touching
@@ -1,12 +1,25 @@
1
1
  #!/usr/bin/env bash
2
- # Usage: sesh-delete.sh STATE_DIR SESSION_ID
2
+ # Usage: sesh-delete.sh [--yes] STATE_DIR SESSION_ID
3
3
  # Ctrl-X deletes through the opencode CLI so its own safeguards apply.
4
- # There is no confirmation prompt, matching the Claude picker.
4
+ # Deletion is irreversible, so this confirms first: an interactive run asks for
5
+ # a y, and a non-interactive run must pass --yes rather than inherit whatever
6
+ # stdin happens to be. Rows with no session id (headers, notices) stay silent
7
+ # no-ops.
5
8
  set -euo pipefail
6
9
 
7
10
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8
- state_dir=${1:-}
9
- session_id=${2:-}
11
+ assume_yes=0
12
+ positional=()
13
+ while [ "$#" -gt 0 ]; do
14
+ case "$1" in
15
+ --yes|-y) assume_yes=1 ;;
16
+ --help) echo "usage: ${0##*/} [--yes] STATE_DIR SESSION_ID"; exit 0 ;;
17
+ *) positional+=("$1") ;;
18
+ esac
19
+ shift
20
+ done
21
+ state_dir=${positional[0]:-}
22
+ session_id=${positional[1]:-}
10
23
  snapshot="$state_dir/snapshot.jsonl"
11
24
  lock="$state_dir/refresh.lock"
12
25
  OPENCODE_BIN=${SESH_OPENCODE:-opencode}
@@ -40,6 +53,27 @@ acquire_lock() {
40
53
  [[ "$session_id" =~ ^ses_[A-Za-z0-9]+$ ]] || { echo "Invalid session identity."; pause; exit 0; }
41
54
  [ -n "$state_dir" ] && [ -f "$snapshot" ] || { echo "No session snapshot available."; pause; exit 0; }
42
55
 
56
+ # Confirm before taking the lock: a human can sit on the prompt for as long as
57
+ # they like without blocking the snapshot publisher. The row is re-checked
58
+ # against the snapshot below, so a session that vanishes meanwhile still loses.
59
+ if [ "$assume_yes" = 0 ]; then
60
+ if [ -t 0 ]; then
61
+ title=$("$JQ_BIN" -r --arg sid "$session_id" 'select(.sessionId == $sid) | .title // ""' "$snapshot" 2>/dev/null || true)
62
+ [ -n "$title" ] || title='untitled'
63
+ printf '%s' "Delete \"$title\" ($session_id)? [y/N] "
64
+ reply=''
65
+ IFS= read -r -n 1 reply || reply=''
66
+ printf '\n'
67
+ case "$reply" in
68
+ y|Y) ;;
69
+ *) echo "Cancelled; nothing was deleted."; pause; exit 0 ;;
70
+ esac
71
+ else
72
+ echo "Refusing to delete $session_id: pass --yes when stdin is not a terminal." >&2
73
+ exit 2
74
+ fi
75
+ fi
76
+
43
77
  # Serialize this action with the sole snapshot publisher. A short collision
44
78
  # is safer than deleting against a moving snapshot.
45
79
  if ! acquire_lock; then
package/bin/sesh-list.sh CHANGED
@@ -12,6 +12,7 @@ LIMIT=0
12
12
  LIMIT_SET=0
13
13
  SCOPE_SET=0
14
14
  REFRESH=0
15
+ HEADER=0
15
16
  WAIT_LOCK=0
16
17
  TOGGLE_SCOPE=0
17
18
  INCLUDE_ARCHIVED=0
@@ -20,10 +21,11 @@ STATE_DIR=''
20
21
  QUERY=''
21
22
  SELECTED_ID=''
22
23
  SCOPE_CWD=''
23
- usage() { echo "usage: ${0##*/} [--refresh] [--wait-lock SECONDS] --state-dir DIR [--cwd] [--limit N] [--archived] [--toggle-scope] [--query TEXT]" >&2; }
24
+ usage() { echo "usage: ${0##*/} [--refresh] [--header] [--wait-lock SECONDS] --state-dir DIR [--cwd] [--limit N] [--archived] [--toggle-scope] [--query TEXT]" >&2; }
24
25
  while [ "$#" -gt 0 ]; do
25
26
  case "$1" in
26
27
  --refresh) REFRESH=1 ;;
28
+ --header) HEADER=1 ;;
27
29
  --wait-lock) WAIT_LOCK=${2:-}; shift ;;
28
30
  --state-dir) STATE_DIR=${2:-}; shift ;;
29
31
  --cwd) SCOPE_CWD=$PWD; SCOPE_SET=1 ;;
@@ -113,38 +115,85 @@ atomic_text() {
113
115
  }
114
116
  set_status() { atomic_text "$STATUS" "$1"; }
115
117
 
118
+ # One-line fzf --header: the effective scope, how many sessions the snapshot
119
+ # holds and whether it is fresh. Reads published state only, so it stays cheap
120
+ # enough to run on fzf's 3s poll without touching the database.
121
+ header() {
122
+ local state='' count=0 scope='all dirs' line=''
123
+ [ -f "$STATUS" ] && state=$(cat "$STATUS" 2>/dev/null || true)
124
+ if [ -f "$SNAPSHOT" ]; then
125
+ count=$(wc -l < "$SNAPSHOT" 2>/dev/null || printf '0')
126
+ count=${count//[!0-9]/}
127
+ [ -n "$count" ] || count=0
128
+ fi
129
+ if [ -n "$SCOPE_CWD" ]; then
130
+ case "$SCOPE_CWD" in
131
+ "$HOME") scope='~' ;;
132
+ "$HOME"/*) scope="~${SCOPE_CWD#"$HOME"}" ;;
133
+ *) scope=$SCOPE_CWD ;;
134
+ esac
135
+ fi
136
+ line="${CYAN}${scope}${RESET} ${GRAY}·${RESET} ${GRAY}${count} sessions${RESET}"
137
+ [ "$INCLUDE_ARCHIVED" = 1 ] && line="${line} ${GRAY}·${RESET} ${YELLOW}archived shown${RESET}"
138
+ [ -n "$state" ] && [ "$state" != fresh ] && line="${line} ${GRAY}·${RESET} ${YELLOW}${state}${RESET}"
139
+ printf '%s' "$line"
140
+ }
141
+
116
142
  render() {
117
- local state=''
143
+ local state='' notice=''
118
144
  [ -f "$STATUS" ] && state=$(cat "$STATUS" 2>/dev/null || true)
145
+ # A non-actionable selection (a directory header, a stale notice) leaves a
146
+ # one-shot message here so the reopened picker explains itself instead of
147
+ # repainting the same list as if the key had been ignored.
148
+ if [ -f "$STATE_DIR/action-notice" ]; then
149
+ notice=$(cat "$STATE_DIR/action-notice" 2>/dev/null || true)
150
+ rm -f "$STATE_DIR/action-notice" 2>/dev/null || true
151
+ fi
119
152
  if [ ! -f "$SNAPSHOT" ]; then
153
+ [ -n "$notice" ] && printf '\t\tunknown\t%s%s%s\t\tnotice:action\n' "$YELLOW" "$notice" "$RESET"
120
154
  [ -n "$state" ] && [ "$state" != fresh ] && printf '\t\tunknown\t%s%s%s\t\tnotice:live-state\n' "$YELLOW" "$state" "$RESET"
121
155
  return 0
122
156
  fi
123
157
  {
158
+ [ -n "$notice" ] && printf '\t\tunknown\t%s%s%s\t\tnotice:action\n' "$YELLOW" "$notice" "$RESET"
124
159
  [ -n "$state" ] && [ "$state" != fresh ] && printf '\t\tunknown\t%s%s%s\t\tnotice:live-state\n' "$YELLOW" "$state" "$RESET"
125
- "$JQ_BIN" -sr --arg green "$GREEN" --arg cyan "$CYAN" --arg gray "$GRAY" --arg magenta "$MAGENTA" --arg red "$RED" --arg bold "$BOLD" --arg yellow "$YELLOW" --arg reset "$RESET" --arg query "$QUERY" --arg home "$HOME" --arg selected "$SELECTED_ID" '
160
+ "$JQ_BIN" -sr --argjson pins "$("${BASH_SOURCE[0]%/*}/sesh-pins.sh" read)" --arg green "$GREEN" --arg cyan "$CYAN" --arg gray "$GRAY" --arg magenta "$MAGENTA" --arg red "$RED" --arg bold "$BOLD" --arg yellow "$YELLOW" --arg reset "$RESET" --arg query "$QUERY" --arg home "$HOME" --arg selected "$SELECTED_ID" '
126
161
  def ago($epoch):
127
162
  ((now - ($epoch | tonumber)) | floor) as $s
128
163
  | if $s < 60 then "now" elif $s < 3600 then "\($s / 60 | floor)m" elif $s < 86400 then "\($s / 3600 | floor)h" else "\($s / 86400 | floor)d" end;
129
164
  def homepath:
130
165
  . as $p | if $p == "" then "?" elif $p == $home then "~" elif ($home != "" and startswith($home + "/")) then "~" + .[($home|length):] else . end;
131
- # Preserve a vanished selection as a non-actionable row with its original
132
- # tracking key. Otherwise fzf falls back to a different session. Keep it
133
- # until the user moves away; empty field 2 makes Enter/Ctrl-F/Ctrl-X no-ops.
166
+ # Non-actionable rows: an empty field 2 makes Enter/Ctrl-F/Ctrl-X no-ops,
167
+ # and the unique id keeps fzf from falling back to a neighbouring session.
168
+ def notice_row($msg; $id): (["", "", "unknown", ($yellow + $msg + $reset), "", $id] | @tsv);
169
+ # Preserve a vanished selection so the picker keeps its identity instead
170
+ # of jumping to a different session; keep it until the user moves away.
134
171
  (if ($selected | test("^ses_[A-Za-z0-9]+$")) and (any(.sessionId == $selected) | not)
135
- then (["", "", "unknown", ($yellow + "Selected session is no longer available; choose another session" + $reset), "", $selected] | @tsv)
172
+ then notice_row("Selected session is no longer available; choose another session"; $selected)
136
173
  else empty end),
174
+ # An empty result set must say so: with the info line hidden and an empty
175
+ # preview, a filtered-to-nothing list is otherwise an indistinguishable
176
+ # blank screen.
137
177
  (($query | ascii_downcase) as $q
138
- | map(select($q == "" or (.searchText | contains($q))))
139
- | group_by(.cwd)
140
- | map({cwd: .[0].cwd, updated: (map(.updatedEpoch) | max), items: (sort_by(.updatedEpoch) | reverse)})
141
- | sort_by(.updated) | reverse
142
- | .[] as $g
143
- | (["", "", "unknown", ($magenta + ($g.cwd | homepath | gsub("[\u0000-\u001f\u007f-\u009f]";" ")) + $reset), $g.cwd, ("hdr:" + $g.cwd)] | @tsv),
178
+ | [.[] | select($q == "" or (.searchText | contains($q)))] as $matched
179
+ | (if ($matched | length) == 0
180
+ then notice_row(if $q == "" then "No sessions in this store" else "No sessions match \"" + $query + "\"" end; "notice:no-match")
181
+ else empty end),
182
+ ($matched
183
+ | group_by(.cwd)
184
+ | map({cwd: .[0].cwd, updated: (map(.updatedEpoch) | max),
185
+ pinDirectory: (.[0].cwd as $dir | $pins.directories | index($dir) != null),
186
+ pinSession: (any(.[]; .sessionId as $id | $pins.sessions | index($id) != null)),
187
+ items: (sort_by([(.sessionId as $id | $pins.sessions | index($id) != null), .updatedEpoch]) | reverse)})
188
+ | sort_by([.pinDirectory, .pinSession, .updated]) | reverse
189
+ | .[] as $g
190
+ | (["", "", "unknown", ($magenta + (if $g.pinDirectory then "★ " else "" end) + ($g.cwd | homepath | gsub("[\u0000-\u001f\u007f-\u009f]";" ")) + $reset), $g.cwd, ("hdr:" + $g.cwd)] | @tsv),
144
191
  ($g.items | to_entries[] | .key as $i | .value as $s
145
- | (if $s.liveState == "running" then ($bold + $green) elif $s.agentId != "" and $s.agentState == "failed" then $red elif $s.agentId != "" then $cyan else $gray end) as $c
192
+ # Archived rows are opt-in and need to look like it; nothing else about
193
+ # a row is knowable here (live agent state is not exposed by opencode).
194
+ | (if $s.archived == 1 then $yellow else $gray end) as $c
146
195
  | ($gray + (if $i == (($g.items|length)-1) then "└" else "├" end) + $reset) as $branch
147
- | [$s.agentId, $s.sessionId, $s.liveState, ($branch + " " + $c + ($s.title | gsub("[\u0000-\u001f\u007f-\u009f]";" ")) + $reset + " " + $gray + ago($s.updatedEpoch) + $reset), $s.cwd, $s.sessionId] | @tsv))
196
+ | [$s.agentId, $s.sessionId, $s.liveState, ($branch + " " + $c + (if ($s.sessionId as $id | $pins.sessions | index($id)) != null then "★ " else "" end) + ($s.title | gsub("[\u0000-\u001f\u007f-\u009f]";" ")) + (if $s.archived == 1 then " · archived" else "" end) + $reset + " " + $gray + ago($s.updatedEpoch) + $reset), $s.cwd, $s.sessionId] | @tsv)))
148
197
  ' "$SNAPSHOT"
149
198
  } || printf '\t\tunknown\t%sSnapshot rendering failed; retry refresh%s\t\tnotice:render-error\n' "$YELLOW" "$RESET"
150
199
  }
@@ -326,7 +375,7 @@ refresh() {
326
375
  done < "$records"
327
376
  fi
328
377
 
329
- if ! "$JQ_BIN" -c --slurpfile hist "$historical" --arg scope "$SCOPE_CWD" --argjson limit "$LIMIT" --argjson archived "$INCLUDE_ARCHIVED" '
378
+ if ! "$JQ_BIN" -c --slurpfile hist "$historical" --argjson pins "$("${BASH_SOURCE[0]%/*}/sesh-pins.sh" read)" --arg scope "$SCOPE_CWD" --argjson limit "$LIMIT" --argjson archived "$INCLUDE_ARCHIVED" '
330
379
  .meta as $meta
331
380
  | ($hist | map({key: .sessionId, value: .}) | from_entries) as $histById
332
381
  | [$meta[]
@@ -336,11 +385,12 @@ refresh() {
336
385
  | . as $m | ($histById[$m.id] // {}) as $h
337
386
  | {sessionId: $m.id, malformedRecords: 0, agentId: "", agentState: "",
338
387
  liveState: "unknown", transcriptPath: "", source: "opencode",
388
+ archived: (if (($m.archived // 0) | tonumber) == 0 then 0 else 1 end),
339
389
  cwd: (if ($h.cwd // "") != "" then $h.cwd else $m.directory end),
340
390
  updatedEpoch: ($h.updatedEpoch // (($m.time_updated / 1000 | floor))),
341
391
  title: (if ($h.title // "") != "" then $h.title else ($m.title // "(untitled)") end)}
342
392
  | .searchText = ((.title | ascii_downcase) + " " + ($histById[.sessionId].fulltextLower // ""))]
343
- | sort_by(.updatedEpoch) | reverse
393
+ | sort_by([(.cwd as $dir | $pins.directories | index($dir) != null), (.sessionId as $id | $pins.sessions | index($id) != null), .updatedEpoch]) | reverse
344
394
  | (if $limit == 0 then . else .[:$limit] end)[]
345
395
  ' "$cache_state" > "$out"; then
346
396
  set_status 'stale: snapshot construction failed; showing last good snapshot'
@@ -356,5 +406,6 @@ refresh() {
356
406
  fi
357
407
  }
358
408
 
409
+ if [ "$HEADER" = 1 ]; then header; exit 0; fi
359
410
  if [ "$REFRESH" = 1 ] || [ "$TOGGLE_SCOPE" = 1 ]; then refresh; fi
360
411
  render