opencode-sesh 1.0.2 → 1.0.4

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
@@ -59,16 +59,18 @@ The tool name in opencode is `sesh_list`. Do not rename it without updating the
59
59
  ## Data flow (fzf picker)
60
60
 
61
61
  `bin/sesh-list.sh` owns the fzf picker's session store access (opencode SQLite
62
- DB: `session` / `message` / `part` tables); the `sesh-list` agent tool queries
63
- the same store through `opencode db`. Keep its SQL column names in sync with
62
+ DB: `session_v2` / `session_message` tables); the `sesh-list` agent tool queries
63
+ the same store read-only through `bun:sqlite`. V2 discovers the tool from the
64
+ global config directory's `plugins/`. Keep its SQL column names in sync with
64
65
  the schema below. Keystrokes never touch the database:
65
66
 
66
67
  - `--refresh` (expensive): bulk-reads session metadata and per-session text
67
68
  parts, validates the persistent extraction cache
68
- (`$SESH_CACHE_DIR/extractions/<ses_*.json>`, schema 2) against `time_updated`,
69
- part count and the latest part timestamp, re-extracts only changed sessions,
69
+ (`$SESH_CACHE_DIR/extractions/<ses_*.json>`, schema 3) against `time_updated`,
70
+ message count and the latest message timestamp, re-extracts only changed sessions,
70
71
  prunes vanished ones, and atomically publishes `STATE_DIR/snapshot.jsonl`.
71
- Extraction moves transcripts through files, never argv (no ARG_MAX limit); if
72
+ Extraction reads user `data.text` and assistant `data.content` text entries,
73
+ ordered by message `seq`, through files, never argv (no ARG_MAX limit); if
72
74
  any session fails, the published status is `stale` and that session stays
73
75
  uncached so the next refresh retries, otherwise `status = fresh`. Scope
74
76
  (`--cwd`/toggle) and `--limit` apply at assembly, so metadata always covers
@@ -85,7 +87,10 @@ the schema below. Keystrokes never touch the database:
85
87
  - Session and directory pins live in `${XDG_DATA_HOME:-$HOME/.local/share}/sesh/pins.json`
86
88
  (`SESH_PINS_FILE` overrides it for tests), shared by fzf and the TUI. Writes
87
89
  use a sibling lock directory and atomic rename. Ctrl-S toggles a selected
88
- session; Ctrl-D toggles its directory. Pinned directories sort before groups
90
+ session; Ctrl-D toggles its directory (terminal picker and option+o picker).
91
+ The sidebar and home lists use Option-S / Option-R instead: opencode binds
92
+ `ctrl+d` itself (composer delete-char, session delete), so ctrl pins would
93
+ shadow the composer. Pinned directories sort before groups
89
94
  containing pinned sessions, with pinned sessions first inside each group.
90
95
 
91
96
  **Output contract** (TSV, 6 fields):
@@ -138,7 +143,7 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
138
143
 
139
144
  ## TUI panel (`tui-plugins/sesh-panel/tui.tsx`)
140
145
 
141
- - **Sidebar:** `api.slots.register` on `sidebar_content` (append mode — native
146
+ - **Sidebar:** `api.slots.register` on `sidebar_content` (prepend mode — native
142
147
  sidebar content stays). Pinned directories sort first, then directory groups
143
148
  by recency; sessions within each group sort by last update regardless of pins.
144
149
  Row markers alone signal session state: a running agent animates a loading
@@ -168,8 +173,9 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
168
173
  `/sesh-needs` uses the shared needs-input set plus the oldest unresolved
169
174
  question or running-tool timestamp, lists longest-waiting first, and `n`
170
175
  opens the next waiting session. `tests/tui.mjs` locks the data layer.
171
- - **Durable install:** opencode imports plugins once at startup and never hot
172
- reloads, so panel changes need a full quit/reopen. `install.sh --sync-panel`
176
+ - **Durable install:** V2 reloads plugins on watched configuration changes;
177
+ unwatched dependencies may need a restart. If a panel update does not appear,
178
+ fully quit/reopen opencode. `install.sh --sync-panel`
173
179
  (run by the `postinstall`) refreshes an already-installed panel and is a
174
180
  no-op when absent, and `sesh --check` reports when the installed copy differs
175
181
  from the bundled one.
@@ -231,7 +237,7 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
231
237
  history in a temp dir (included in `bun run test`).
232
238
  - `bun run test:picker` — PTY suite driving the real fzf picker (needs fzf
233
239
  `>= 0.73` and Python 3).
234
- - `bun run typecheck` — `tsc` over `tui/` and `opencode/`.
240
+ - `bun run typecheck` — `tsc` over `tui-plugins/` and `plugins/`.
235
241
  - `bun run lint:sh` — `bash -n` on every script.
236
242
  - `HOME=/tmp/fakehome bash install.sh` — installer smoke test.
237
243
  - Keep `tui-plugins/sesh-panel/tui.tsx` and any installed copy byte-identical when testing
package/CHANGELOG.md CHANGED
@@ -5,6 +5,18 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.0.4] - 2026-10-08
9
+
10
+ ### Fixed
11
+
12
+ - Restore V2 session store, dialog and fork compatibility
13
+
14
+ ## [1.0.3] - 2026-10-08
15
+
16
+ ### Fixed
17
+
18
+ - **tui:** Rebind sidebar pins to option keys to stop shadowing composer
19
+
8
20
  ## [1.0.2] - 2026-10-08
9
21
 
10
22
  ### Fixed
@@ -430,6 +442,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
430
442
  - Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
431
443
  CI.
432
444
 
445
+ [1.0.4]: https://github.com/ozandogrultan/opencode-sesh/compare/v1.0.3...v1.0.4
446
+ [1.0.3]: https://github.com/ozandogrultan/opencode-sesh/compare/v1.0.2...v1.0.3
433
447
  [1.0.2]: https://github.com/ozandogrultan/opencode-sesh/compare/v1.0.1...v1.0.2
434
448
  [1.0.1]: https://github.com/ozandogrultan/opencode-sesh/compare/v1.0.0...v1.0.1
435
449
  [1.0.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.14.1...v1.0.0
package/README.md CHANGED
@@ -24,7 +24,7 @@ Sessions (8) ▾ hide
24
24
  ├○ Retry the OAuth token refr… 2h
25
25
  ├○ Login rate limiting notes 3d
26
26
  ├○ Draft the release notes 7d
27
- ├○ Auth middleware cleanup 11d
27
+ ├○ Auth middleware cleanup 11d
28
28
  └○ Rotate the signing keys 12d
29
29
  ▾ ~/Projects/mobile (3)
30
30
  ├○ Author avatar upload retry 1d
@@ -72,7 +72,7 @@ bash install.sh
72
72
  `sesh install` (or `bash install.sh`) links `sesh` into `$XDG_BIN_HOME`
73
73
  (default `~/.local/bin`), copies
74
74
  the `sesh-list` tool and the TUI panel into
75
- `${XDG_CONFIG_HOME:-~/.config}/opencode`, registers the panel in `tui.json`, and
75
+ `${XDG_CONFIG_HOME:-~/.config}/opencode`, registers the panel in `opencode.json`, and
76
76
  declares the plugin dependencies (opencode installs them on next start). The
77
77
  panel registers the `/sesh` slash command. It never edits your shell rc, and any
78
78
  file it would overwrite is backed up first.
@@ -80,8 +80,8 @@ file it would overwrite is backed up first.
80
80
  The npm package ships the same scripts and installer; the runtime tools below
81
81
  are still required.
82
82
 
83
- Fully quit opencode and reopen it to load the panel — opencode imports plugins
84
- once at startup, so reloading a window reuses the running process. Upgrades
83
+ V2 reloads plugins when watched configuration files change. If a panel update
84
+ does not appear, fully quit opencode and reopen it. Upgrades
85
85
  refresh an already-installed panel automatically (the npm `postinstall` syncs
86
86
  it), and `sesh --check` reports when the installed panel is out of date.
87
87
 
@@ -201,7 +201,7 @@ workspace at its directory; outside cmux it shows a toast instead.
201
201
  Click the sidebar's `search…` box to filter recent sessions in place; click
202
202
  elsewhere or press Esc to leave search. Click the **Sessions** heading to drive
203
203
  the list from the keyboard instead of the mouse (`↑`/`↓` to move, Enter to open,
204
- Option-P to preview, Ctrl-S/Ctrl-D to pin, Option-D to delete, Esc to leave). Pinned
204
+ Option-P to preview, Option-S/Option-R to pin, Option-D to delete, Esc to leave). Pinned
205
205
  directories rise first, while sessions within each directory are ordered by
206
206
  last update, newest first. The section lists every session and scrolls to fill
207
207
  the sidebar, so click **▾ hide** to collapse it when you need the room.
@@ -247,10 +247,10 @@ workspace, and a global "resume from anywhere" hotkey — all caller-side.
247
247
 
248
248
  ## How it works
249
249
 
250
- `bin/sesh-list.sh` is the only `bin/` script that reads the opencode session
251
- store (SQLite: `session` / `message` / `part`). Each refresh extracts session
250
+ The `bin/` readers use the V2 opencode session
251
+ store (SQLite: `session_v2` / `session_message`). Each refresh extracts session
252
252
  metadata and text-only parts into a persistent per-session cache keyed on
253
- `time_updated`, part count and the latest part timestamp, then atomically
253
+ `time_updated`, message count and the latest message timestamp, then atomically
254
254
  publishes a `snapshot.jsonl`. Every keystroke re-renders that snapshot with `jq`
255
255
  alone, so typing never starts competing database scans. Reasoning and tool
256
256
  payloads are never indexed or previewed, and session ids are validated before
@@ -261,7 +261,8 @@ Flat scripts back the terminal UI — the picker (`sesh.sh`), the refresh engine
261
261
  scans), preview renderer (`sesh-preview.sh`), deleter (`sesh-delete.sh`) and
262
262
  shortcut help (`sesh-shortcuts.sh`). The TUI panel (`tui-plugins/sesh-panel/tui.tsx`) is a
263
263
  SolidJS OpenTUI plugin that talks to the opencode SDK over the same store; the
264
- `sesh-list` agent tool reads it through `opencode db`.
264
+ `sesh-list` agent tool reads it read-only through `bun:sqlite`. V2 discovers
265
+ the installed tool automatically in the global config directory's `plugins/`.
265
266
 
266
267
  ## FAQ
267
268
 
@@ -292,7 +293,7 @@ checks before opening a PR:
292
293
  bun install
293
294
  bun run test # fixture-database regression suite
294
295
  bun run test:picker # PTY picker suite (needs fzf >= 0.73)
295
- bun run typecheck # tsc over tui/ and opencode/
296
+ bun run typecheck # tsc over tui-plugins/ and plugins/
296
297
  bun run lint:sh # bash -n on every script
297
298
  ```
298
299
 
package/bin/sesh-costs.sh CHANGED
@@ -59,9 +59,9 @@ SELECT s.directory AS directory,
59
59
  ROUND(SUM(COALESCE(json_extract(m.data, '\$.cost'), 0)), 4) AS lifetime_cost,
60
60
  COUNT(DISTINCT s.id) AS sessions,
61
61
  SUM(CASE WHEN m.time_created >= $CUTOFF THEN COALESCE(json_extract(m.data, '\$.tokens.input'), 0) + COALESCE(json_extract(m.data, '\$.tokens.output'), 0) ELSE 0 END) AS window_tokens
62
- FROM message m
63
- JOIN session s ON s.id = m.session_id
64
- WHERE json_extract(m.data, '\$.role') = 'assistant'
62
+ FROM session_message m
63
+ JOIN session_v2 s ON s.id = m.session_id
64
+ WHERE m.type = 'assistant'
65
65
  AND json_extract(m.data, '\$.cost') IS NOT NULL
66
66
  GROUP BY s.directory
67
67
  ORDER BY lifetime_cost DESC;"
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, COALESCE(parent_id, '') AS parent 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_v2 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
  }
@@ -287,7 +287,7 @@ refresh() {
287
287
  if [ -f "$SNAPSHOT" ]; then set_status 'stale: opencode database query failed; showing last good snapshot'; else set_status 'unavailable: opencode database query failed'; fi
288
288
  return 0
289
289
  }
290
- db_json "SELECT session_id, COUNT(*) AS n, MAX(time_updated) AS m FROM part GROUP BY session_id;" > "$parts" 2>/dev/null || printf '[]\n' > "$parts"
290
+ db_json "SELECT session_id, COUNT(*) AS n, MAX(time_updated) AS m FROM session_message GROUP BY session_id;" > "$parts" 2>/dev/null || printf '[]\n' > "$parts"
291
291
  "$JQ_BIN" -e 'type == "array"' "$parts" >/dev/null 2>&1 || printf '[]\n' > "$parts"
292
292
 
293
293
  # Same bulk cache validation as the Claude list: every cache arrives as its
@@ -302,7 +302,7 @@ refresh() {
302
302
  | ($parts[0] | map({key:.session_id, value:.}) | from_entries) as $stats
303
303
  | [$sessions[0][] | . + {partCount: ($stats[.id].n // 0), partMax: ($stats[.id].m // 0)}] as $meta
304
304
  | ($meta | map({key:.id, value:.}) | from_entries) as $byId
305
- | [$caches[0][]? | select(.cacheSchema == 2 and (.sessionId | type) == "string")
305
+ | [$caches[0][]? | select(.cacheSchema == 3 and (.sessionId | type) == "string")
306
306
  | select($byId[.sessionId] as $m | $m != null and .cacheUpdated == $m.time_updated and .cacheParts == $m.partCount and .cacheMax == $m.partMax)] as $cached
307
307
  | ($cached | map({key: .sessionId, value:true}) | from_entries) as $cachedIds
308
308
  | {cached: $cached,
@@ -337,7 +337,8 @@ refresh() {
337
337
  for ((i = j; i < j + 200 && i < ${#stale_ids[@]}; i++)); do
338
338
  chunk="${chunk:+$chunk,}'${stale_ids[$i]}'"
339
339
  done
340
- if ! db_json "SELECT session_id, data FROM part WHERE session_id IN ($chunk) AND json_extract(data, '\$.type') = 'text' ORDER BY session_id, time_created;" 2>/dev/null \
340
+ if ! db_json "SELECT session_id, json_object('type', 'text', 'text', json_extract(data, '\$.text')) AS data, seq, -1 AS ordinal FROM session_message WHERE session_id IN ($chunk) AND type = 'user' AND json_type(data, '\$.text') = 'text'
341
+ UNION ALL SELECT m.session_id, c.value AS data, m.seq, CAST(c.key AS INTEGER) AS ordinal FROM session_message m, json_each(m.data, '\$.content') c WHERE m.session_id IN ($chunk) AND m.type = 'assistant' AND json_extract(c.value, '\$.type') = 'text' ORDER BY session_id, seq, ordinal;" 2>/dev/null \
341
342
  | "$JQ_BIN" -c '.[]?' >> "$parts_raw"; then
342
343
  extract_failed=1
343
344
  break
@@ -363,7 +364,7 @@ refresh() {
363
364
  else $s.title end) as $title
364
365
  | {sessionId: $s.id,
365
366
  cachePath: ($extractions + "/" + $s.id + ".json"),
366
- record: {cacheSchema: 2, cacheSession: $s.id,
367
+ record: {cacheSchema: 3, cacheSession: $s.id,
367
368
  cacheUpdated: ($s.time_updated // 0), cacheParts: ($s.partCount // 0), cacheMax: ($s.partMax // 0),
368
369
  sessionId: $s.id, transcriptPath: "", source: "opencode",
369
370
  cwd: ($s.directory // ""),
@@ -26,7 +26,8 @@ fi
26
26
 
27
27
  SQLITE_BIN=${SESH_SQLITE:-}
28
28
  if [ -z "$SQLITE_BIN" ] && command -v sqlite3 >/dev/null 2>&1; then SQLITE_BIN=sqlite3; fi
29
- SQL="SELECT m.data AS msg, p.data AS part FROM part p JOIN message m ON m.id = p.message_id WHERE p.session_id = '$session_id' AND json_extract(p.data, '\$.type') = 'text' ORDER BY p.time_created DESC LIMIT 200;"
29
+ SQL="SELECT json_object('role', type) AS msg, json_object('type', 'text', 'text', json_extract(data, '\$.text')) AS part, seq, -1 AS ordinal FROM session_message WHERE session_id = '$session_id' AND type = 'user' AND json_type(data, '\$.text') = 'text'
30
+ UNION ALL SELECT json_object('role', m.type) AS msg, c.value AS part, m.seq, CAST(c.key AS INTEGER) AS ordinal FROM session_message m, json_each(m.data, '\$.content') c WHERE m.session_id = '$session_id' AND m.type = 'assistant' AND json_extract(c.value, '\$.type') = 'text' ORDER BY seq DESC, ordinal DESC LIMIT 200;"
30
31
  if [ -n "$SQLITE_BIN" ]; then
31
32
  rows=$("$SQLITE_BIN" -json -readonly "$DB_PATH" "$SQL" 2>/dev/null) || rows=''
32
33
  else
package/bin/sesh-prune.sh CHANGED
@@ -84,7 +84,7 @@ fi
84
84
  JQ_BIN=${SESH_JQ:-$(command -v jq || true)}
85
85
  [ -n "$JQ_BIN" ] || { echo 'sesh: jq is required' >&2; exit 1; }
86
86
 
87
- CANDIDATES=$(query "SELECT id, directory, title, time_updated AS updated FROM session WHERE time_updated < $CUTOFF AND COALESCE(time_archived, 0) = 0 AND parent_id IS NULL ORDER BY time_updated ASC;")
87
+ CANDIDATES=$(query "SELECT id, directory, title, time_updated AS updated FROM session_v2 WHERE time_updated < $CUTOFF AND COALESCE(time_archived, 0) = 0 AND parent_id IS NULL ORDER BY time_updated ASC;")
88
88
  # sqlite3 -json prints nothing (not []) when no rows match.
89
89
  [ -n "$CANDIDATES" ] || CANDIDATES='[]'
90
90
  PINS=$("$SCRIPT_DIR/sesh-pins.sh" read 2>/dev/null || printf '{"sessions":[],"directories":[]}')
@@ -152,7 +152,7 @@ if [ "$hard_delete" = 0 ]; then
152
152
  esac
153
153
  done
154
154
  IN_LIST=${IN_LIST%,}
155
- write_db "UPDATE session SET time_archived = $NOW_MS WHERE id IN ($IN_LIST);"
155
+ write_db "UPDATE session_v2 SET time_archived = $NOW_MS WHERE id IN ($IN_LIST);"
156
156
  echo "Archived $COUNT session(s) older than $older_than."
157
157
  else
158
158
  command -v "$OPENCODE_BIN" >/dev/null 2>&1 || { echo 'sesh: opencode executable is unavailable for deletes.' >&2; exit 1; }
@@ -72,13 +72,11 @@ JQ_BIN=${SESH_JQ:-$(command -v jq || true)}
72
72
  # first `max_words` words kept.
73
73
  CANDIDATES=$(query "
74
74
  SELECT s.id AS id, s.directory AS directory, s.title AS old_title,
75
- (SELECT p.data FROM part p
76
- JOIN message m ON m.id = p.message_id
77
- WHERE p.session_id = s.id
78
- AND json_extract(p.data, '\$.type') = 'text'
79
- AND json_extract(m.data, '\$.role') = 'user'
80
- ORDER BY p.time_created ASC LIMIT 1) AS first_part
81
- FROM session s
75
+ (SELECT m.data FROM session_message m
76
+ WHERE m.session_id = s.id AND m.type = 'user'
77
+ AND json_type(m.data, '\$.text') = 'text'
78
+ ORDER BY m.seq ASC LIMIT 1) AS first_part
79
+ FROM session_v2 s
82
80
  WHERE s.title LIKE 'New session - %'
83
81
  OR TRIM(COALESCE(s.title, '')) = ''
84
82
  ORDER BY s.time_updated DESC;")
@@ -136,6 +134,6 @@ printf '%s' "$PLAN" | "$JQ_BIN" -r '.[] | [.id, .new_title] | @tsv' | while IFS=
136
134
  ses_[A-Za-z0-9]*) ;;
137
135
  *) echo "Refusing: unexpected session id shape." >&2; exit 1 ;;
138
136
  esac
139
- write_db "UPDATE session SET title = '$(sql_escape "$new")' WHERE id = '$id' AND (title LIKE 'New session - %' OR TRIM(COALESCE(title, '')) = '');"
137
+ write_db "UPDATE session_v2 SET title = '$(sql_escape "$new")' WHERE id = '$id' AND (title LIKE 'New session - %' OR TRIM(COALESCE(title, '')) = '');"
140
138
  done
141
139
  echo "Retitled $COUNT session(s)."
@@ -54,39 +54,37 @@ STUCK_BEFORE=$((NOW_MS - 600000))
54
54
 
55
55
  # NEEDS_INPUT_SQL (shared heuristic — keep in sync with tui-plugins/sesh-panel/tui.tsx).
56
56
  read -r -d '' QUERY <<SQL || true
57
+ WITH live AS (
58
+ SELECT m.session_id AS session_id, m.seq AS seq,
59
+ json_extract(c.value, '\$.name') AS name,
60
+ json_extract(c.value, '\$.state.status') AS status,
61
+ COALESCE(json_extract(c.value, '\$.time.created'), m.time_created) AS created
62
+ FROM session_message m, json_each(m.data, '\$.content') c
63
+ WHERE m.type = 'assistant'
64
+ AND (m.data LIKE '%"status":"running"%' OR m.data LIKE '%"status":"pending"%')
65
+ AND json_extract(c.value, '\$.type') = 'tool'
66
+ AND json_extract(c.value, '\$.state.status') IN ('pending', 'running')
67
+ )
57
68
  SELECT s.id AS id, s.directory AS directory, s.title AS title, s.time_updated AS updated,
58
69
  CASE WHEN EXISTS (
59
- SELECT 1 FROM part p
60
- WHERE p.session_id = s.id
61
- AND json_extract(p.data, '\$.type') = 'tool'
62
- AND json_extract(p.data, '\$.tool') = 'question'
63
- AND COALESCE(json_extract(p.data, '\$.state.status'), 'pending') != 'completed'
70
+ SELECT 1 FROM live
71
+ WHERE live.session_id = s.id AND live.name = 'question'
64
72
  AND NOT EXISTS (
65
- SELECT 1 FROM message m
66
- WHERE m.session_id = s.id
67
- AND json_extract(m.data, '\$.role') = 'user'
68
- AND m.time_created > p.time_created)
73
+ SELECT 1 FROM session_message u
74
+ WHERE u.session_id = live.session_id AND u.type = 'user' AND u.seq > live.seq)
69
75
  ) THEN 'question' ELSE 'stuck' END AS reason
70
- FROM session s
76
+ FROM session_v2 s
71
77
  WHERE COALESCE(s.time_archived, 0) = 0
72
78
  AND s.parent_id IS NULL
73
79
  AND (EXISTS (
74
- SELECT 1 FROM part p
75
- WHERE p.session_id = s.id
76
- AND json_extract(p.data, '\$.type') = 'tool'
77
- AND json_extract(p.data, '\$.tool') = 'question'
78
- AND COALESCE(json_extract(p.data, '\$.state.status'), 'pending') != 'completed'
80
+ SELECT 1 FROM live
81
+ WHERE live.session_id = s.id AND live.name = 'question'
79
82
  AND NOT EXISTS (
80
- SELECT 1 FROM message m
81
- WHERE m.session_id = s.id
82
- AND json_extract(m.data, '\$.role') = 'user'
83
- AND m.time_created > p.time_created)
83
+ SELECT 1 FROM session_message u
84
+ WHERE u.session_id = live.session_id AND u.type = 'user' AND u.seq > live.seq)
84
85
  ) OR EXISTS (
85
- SELECT 1 FROM part p2
86
- WHERE p2.session_id = s.id
87
- AND json_extract(p2.data, '\$.type') = 'tool'
88
- AND json_extract(p2.data, '\$.state.status') = 'running'
89
- AND p2.time_created < $STUCK_BEFORE))
86
+ SELECT 1 FROM live
87
+ WHERE live.session_id = s.id AND live.status = 'running' AND live.created < $STUCK_BEFORE))
90
88
  ORDER BY s.time_updated DESC;
91
89
  SQL
92
90
 
package/install.sh CHANGED
@@ -210,11 +210,8 @@ install_tui_panel() {
210
210
  fi
211
211
  }
212
212
 
213
- # opencode imports plugins once at startup and has no hot reload, so a running
214
- # instance keeps the old panel until it is fully quit (reloading a window reuses
215
- # the same process). Say so explicitly and name the processes still holding it.
216
213
  note_restart() {
217
- note "restart opencode to load the updated panel: quit it completely"
214
+ note "V2 reloads watched plugins automatically; if the panel stays unchanged, restart opencode: quit it completely"
218
215
  note " (all windows and background processes), then reopen."
219
216
  if command -v pgrep >/dev/null 2>&1; then
220
217
  local pids
@@ -238,7 +235,7 @@ if [ "$UNINSTALL" = 1 ]; then
238
235
  if [ -d "$PANEL_DIR" ]; then
239
236
  rm -rf "$PANEL_DIR" && note "removed $PANEL_DIR"
240
237
  fi
241
- remove_if_marker "$CONFIG_DIR/plugins/sesh-panel.tsx" "@jsxImportSource @opentui/solid" && note "removed $CONFIG_DIR/plugins/sesh-panel.tsx"
238
+ remove_if_marker "$CONFIG_DIR/plugins/sesh-panel.tsx" 'id: "sesh-panel"' && note "removed $CONFIG_DIR/plugins/sesh-panel.tsx"
242
239
  remove_opencode_entry && note "unregistered the panel from $OPENCODE_JSON"
243
240
  remove_tui_entry && note "unregistered the panel from $TUI_JSON"
244
241
  note "uninstalled (extraction cache and plugin dependencies left intact)"
@@ -309,6 +306,7 @@ remove_if_marker "$COMMAND_DST" "rich interactive picker" && note "removed obsol
309
306
  [ "$INSTALL_TOOL" = 1 ] && install_file "$SOURCE_DIR/plugins/sesh-list.ts" "$TOOL_DST"
310
307
  # Clean up obsolete V1 tool if present
311
308
  remove_if_marker "$CONFIG_DIR/tools/sesh-list.ts" "list opencode sessions for the agent" && note "removed obsolete V1 tool"
309
+ remove_if_marker "$CONFIG_DIR/plugins/sesh-panel.tsx" 'id: "sesh-panel"' && note "removed obsolete V1 panel"
312
310
 
313
311
  if [ "$INSTALL_TUI" = 1 ]; then
314
312
  install_tui_panel
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-sesh",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Browse, search, preview and resume opencode sessions from the terminal and the TUI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -66,8 +66,9 @@
66
66
  "devDependencies": {
67
67
  "@commitlint/cli": "^21.2.2",
68
68
  "@commitlint/config-conventional": "^21.2.2",
69
- "@opencode/client": "2.0.18",
70
- "@opencode/plugin": "2.0.18",
69
+ "@opencode/client": "2.0.25",
70
+ "@opencode/plugin": "2.0.25",
71
+ "@opencode/theme": "2.0.25",
71
72
  "@opentui/core": "^0.5.11",
72
73
  "@opentui/keymap": "^0.5.11",
73
74
  "@opentui/solid": "^0.5.11",
@@ -26,7 +26,7 @@ const input = {
26
26
  export default Plugin.define({
27
27
  id: "sesh-list",
28
28
  async setup(ctx) {
29
- ctx.tool.transform((editor) => {
29
+ await ctx.tool.transform((editor) => {
30
30
  editor.add({
31
31
  name: "sesh_list",
32
32
  description: "List opencode sessions across all project directories, newest first",
@@ -3,7 +3,7 @@ import { Plugin } from "@opencode/plugin/tui"
3
3
  import type { Project, SessionInfo as Session } from "@opencode/client"
4
4
  import type { Accessor } from "solid-js"
5
5
 
6
- type TuiPluginApi = any
6
+ type TuiPluginApi = ReturnType<typeof createApi>
7
7
  type TuiPlugin = (api: TuiPluginApi) => Promise<void>
8
8
  import { createEffect, createMemo, createRoot, createSignal, For, getOwner, on, onCleanup, onMount, runWithOwner, Show } from "solid-js"
9
9
  import { mkdir, readFile, rename, rmdir, unlink, writeFile } from "node:fs/promises"
@@ -237,6 +237,8 @@ function searchBindings(append: (input: string) => void, exit: () => void) {
237
237
 
238
238
  const rootMouseHandlers = new Set<() => void>()
239
239
  let rootMouseInstalled = false
240
+ let removeRootMouse: (() => void) | undefined
241
+ const ROOT_MOUSE = Symbol.for("opencode-sesh.root-mouse")
240
242
 
241
243
  function ensureRootMouse(renderer: TuiPluginApi["renderer"]) {
242
244
  if (rootMouseInstalled || !renderer?.root) return
@@ -244,12 +246,26 @@ function ensureRootMouse(renderer: TuiPluginApi["renderer"]) {
244
246
  const root = renderer.root as unknown as {
245
247
  onMouseDown?: (event: unknown) => void
246
248
  _mouseListeners?: { down?: (event: unknown) => void }
249
+ [ROOT_MOUSE]?: Set<() => void>
247
250
  }
248
- const previous = root._mouseListeners?.down
249
- root.onMouseDown = (event) => {
250
- previous?.(event)
251
+ if (!root[ROOT_MOUSE]) {
252
+ const handlers = new Set<() => void>()
253
+ root[ROOT_MOUSE] = handlers
254
+ const previous = root._mouseListeners?.down
255
+ root.onMouseDown = (event) => {
256
+ previous?.(event)
257
+ for (const handler of [...handlers]) handler()
258
+ }
259
+ }
260
+ const dispatch = () => {
251
261
  for (const handler of [...rootMouseHandlers]) handler()
252
262
  }
263
+ root[ROOT_MOUSE].add(dispatch)
264
+ removeRootMouse = () => {
265
+ root[ROOT_MOUSE]?.delete(dispatch)
266
+ rootMouseHandlers.clear()
267
+ rootMouseInstalled = false
268
+ }
253
269
  }
254
270
 
255
271
  type Entry = { id: string; title: string; dir: string; group: string; updated: number }
@@ -422,17 +438,14 @@ const SESSION_MAX = 5000
422
438
 
423
439
  async function fetchEntries(api: TuiPluginApi): Promise<EntryResult> {
424
440
  const home = process.env.HOME ?? ""
425
- const projectResult = await api.client.project.list({})
441
+ const projectResult = await api.client.project.list()
426
442
  const projects = ((projectResult?.data ?? projectResult) as Project[]) ?? []
427
443
  const projectName = new Map<string, string>(
428
444
  projects.map((p) => [p.id, p.name?.trim() || shortDir(p.canonical, home)]),
429
445
  )
430
446
  const sessions: Session[] = []
431
- let cursor: number | undefined
447
+ let cursor: string | undefined
432
448
  let truncated = false
433
- // Page through the global list instead of one fixed window, so sessions in
434
- // older directories stay searchable. `cursor` is the previous page's oldest
435
- // `time.updated`; a repeated or missing cursor stops the walk.
436
449
  for (;;) {
437
450
  const result = await api.client.experimental.session.list({
438
451
  limit: SESSION_PAGE_LIMIT,
@@ -443,13 +456,13 @@ async function fetchEntries(api: TuiPluginApi): Promise<EntryResult> {
443
456
  const page = ((result?.data ?? result) as Session[]) ?? []
444
457
  if (!Array.isArray(page) || page.length === 0) break
445
458
  sessions.push(...page)
446
- if (page.length < SESSION_PAGE_LIMIT) break
459
+ if (!result.cursor?.next) break
447
460
  if (sessions.length >= SESSION_MAX) {
448
461
  truncated = true
449
462
  break
450
463
  }
451
- const next = page[page.length - 1]?.time?.updated
452
- if (typeof next !== "number" || next === cursor) {
464
+ const next = result.cursor.next
465
+ if (next === cursor) {
453
466
  truncated = true
454
467
  break
455
468
  }
@@ -773,7 +786,7 @@ function Highlighted(props: {
773
786
 
774
787
  async function fetchTranscriptText(api: TuiPluginApi, sessionID: string): Promise<string> {
775
788
  try {
776
- const result = await api.client.session.messages({ sessionID })
789
+ const result = await api.client.session.messages({ sessionID, preview: true })
777
790
  const messages = result.data ?? []
778
791
  const blocks = messages
779
792
  .map((message: any) => {
@@ -815,6 +828,7 @@ function createTranscriptPreview(api: TuiPluginApi) {
815
828
  close()
816
829
  return
817
830
  }
831
+ if (previewID()) close()
818
832
  setPreviewID(entry.id)
819
833
  setPreviewText("Loading…")
820
834
  void fetchTranscriptText(api, entry.id).then((text) => {
@@ -857,6 +871,7 @@ function createTranscriptPreview(api: TuiPluginApi) {
857
871
  api.ui.dialog.setSize("large")
858
872
  disposePreviewKeys?.()
859
873
  disposePreviewKeys = api.keymap.registerLayer({
874
+ mode: "modal",
860
875
  bindings: [
861
876
  {
862
877
  key: "escape",
@@ -1089,6 +1104,14 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1089
1104
  })
1090
1105
  createEffect(() => {
1091
1106
  if (props.api.ui.dialog.open) menu.close()
1107
+ // A modal dialog owns input while open: yield the sidebar's keyboard
1108
+ // captures too, so its layers cannot double-fire with the dialog's own.
1109
+ if (props.api.ui.dialog.open) {
1110
+ cancelDelete()
1111
+ setNavActive(false)
1112
+ setSearching(false)
1113
+ setHovered(undefined)
1114
+ }
1092
1115
  })
1093
1116
 
1094
1117
  const moveCursor = (delta: number) => {
@@ -1357,8 +1380,8 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1357
1380
  if (!confirmArmed()) requestDelete(entry)
1358
1381
  },
1359
1382
  },
1360
- { key: "ctrl+s", desc: "Pin session", preventDefault: true, cmd: () => props.onTogglePin("sessions", entry.id) },
1361
- { key: "ctrl+d", desc: "Pin directory", preventDefault: true, cmd: () => props.onTogglePin("directories", entry.dir) },
1383
+ { key: "alt+s", desc: "Pin session", preventDefault: true, cmd: () => props.onTogglePin("sessions", entry.id) },
1384
+ { key: "alt+r", desc: "Pin directory", preventDefault: true, cmd: () => props.onTogglePin("directories", entry.dir) },
1362
1385
  {
1363
1386
  key: "/",
1364
1387
  desc: "Search sessions",
@@ -1409,7 +1432,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1409
1432
  },
1410
1433
  },
1411
1434
  {
1412
- key: "ctrl+s",
1435
+ key: "alt+s",
1413
1436
  desc: "Pin session",
1414
1437
  preventDefault: true,
1415
1438
  cmd: () => {
@@ -1418,7 +1441,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1418
1441
  },
1419
1442
  },
1420
1443
  {
1421
- key: "ctrl+d",
1444
+ key: "alt+r",
1422
1445
  desc: "Pin directory",
1423
1446
  preventDefault: true,
1424
1447
  cmd: () => {
@@ -1639,7 +1662,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1639
1662
  </Show>
1640
1663
  <Show when={navActive()}>
1641
1664
  <box paddingTop={1}>
1642
- <text style={{ fg: theme().textMuted }}>↑↓ move · enter open · option+p preview · ctrl+s/d pin · option+d delete · esc done</text>
1665
+ <text style={{ fg: theme().textMuted }}>↑↓ move · enter open · option+p preview · option+s/r pin · option+d delete · esc done</text>
1643
1666
  </box>
1644
1667
  </Show>
1645
1668
  </Show>
@@ -1807,6 +1830,16 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
1807
1830
  if (searching()) setSearching(false)
1808
1831
  }
1809
1832
 
1833
+ // A modal dialog owns input while open: yield this list's keyboard captures
1834
+ // too, so its layers cannot double-fire with the dialog's own.
1835
+ createEffect(() => {
1836
+ if (props.api.ui.dialog.open) {
1837
+ cancelDelete()
1838
+ setSearching(false)
1839
+ setHovered(undefined)
1840
+ }
1841
+ })
1842
+
1810
1843
  onMount(() => {
1811
1844
  ensureRootMouse(props.api.renderer)
1812
1845
  rootMouseHandlers.add(onRootMouseDown)
@@ -1854,8 +1887,8 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
1854
1887
  if (!confirmArmed()) requestDelete(entry)
1855
1888
  },
1856
1889
  },
1857
- { key: "ctrl+s", desc: "Pin session", preventDefault: true, cmd: () => props.onTogglePin("sessions", entry.id) },
1858
- { key: "ctrl+d", desc: "Pin directory", preventDefault: true, cmd: () => props.onTogglePin("directories", entry.dir) },
1890
+ { key: "alt+s", desc: "Pin session", preventDefault: true, cmd: () => props.onTogglePin("sessions", entry.id) },
1891
+ { key: "alt+r", desc: "Pin directory", preventDefault: true, cmd: () => props.onTogglePin("directories", entry.dir) },
1859
1892
  ],
1860
1893
  })
1861
1894
  })
@@ -2026,10 +2059,10 @@ const tui: TuiPlugin = async (api) => {
2026
2059
  },
2027
2060
  })
2028
2061
 
2029
- const openPicker = async () => {
2062
+ const openPicker = async (command = false) => {
2030
2063
  refreshPins()
2031
- if (api.mode.current() !== BASE_MODE) return
2032
- if (api.renderer.currentFocusedEditor === null) return
2064
+ if (!command && api.mode.current() !== BASE_MODE) return
2065
+ if (!command && api.renderer.currentFocusedEditor === null) return
2033
2066
 
2034
2067
  let entriesResult: EntryResult
2035
2068
  try {
@@ -2339,6 +2372,7 @@ const tui: TuiPlugin = async (api) => {
2339
2372
  confirmTimer = setTimeout(cancelDelete, 5000)
2340
2373
  disposeConfirm?.()
2341
2374
  disposeConfirm = api.keymap.registerLayer({
2375
+ mode: "modal",
2342
2376
  priority: 20,
2343
2377
  bindings: [
2344
2378
  {
@@ -2397,9 +2431,7 @@ const tui: TuiPlugin = async (api) => {
2397
2431
  }
2398
2432
 
2399
2433
  const disposeNav = api.keymap.registerLayer({
2400
- // Outrank the built-in dialog layer so Escape reaches this picker before
2401
- // the dialog host closes the whole thing.
2402
- mode: "global",
2434
+ mode: "modal",
2403
2435
  priority: 1,
2404
2436
  bindings: [
2405
2437
  { key: "up", desc: "Previous session", preventDefault: true, cmd: () => moveCursor(-1) },
@@ -2472,6 +2504,7 @@ const tui: TuiPlugin = async (api) => {
2472
2504
  })
2473
2505
 
2474
2506
  const cleanup = () => {
2507
+ if (!alive) return
2475
2508
  alive = false
2476
2509
  if (confirmTimer) clearTimeout(confirmTimer)
2477
2510
  pickerState = { query: query(), scope: scope(), waitingOnly: waitingOnly(), pinnedOnly: pinnedOnly() }
@@ -2775,9 +2808,9 @@ const tui: TuiPlugin = async (api) => {
2775
2808
 
2776
2809
  // Read-only digest dialog: spend per project, last day beside lifetime.
2777
2810
  // Same message-level sums as `sesh costs`; the dialog just renders them.
2778
- const openCosts = async () => {
2779
- if (api.mode.current() !== BASE_MODE) return
2780
- if (api.renderer.currentFocusedEditor === null) return
2811
+ const openCosts = async (command = false) => {
2812
+ if (!command && api.mode.current() !== BASE_MODE) return
2813
+ if (!command && api.renderer.currentFocusedEditor === null) return
2781
2814
  type CostRow = { directory: string; window: number; lifetime: number; sessions: number }
2782
2815
  let rows: CostRow[] = []
2783
2816
  let failed = false
@@ -2835,6 +2868,7 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
2835
2868
  const money = (n: number) => (n === 0 ? "—" : `$${n.toFixed(2)}`)
2836
2869
  const home = process.env.HOME ?? ""
2837
2870
  const disposeKeys = api.keymap.registerLayer({
2871
+ mode: "modal",
2838
2872
  bindings: [
2839
2873
  { key: "escape", desc: "Close", preventDefault: true, cmd: () => api.ui.dialog.clear() },
2840
2874
  ],
@@ -2888,9 +2922,9 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
2888
2922
  // Triage dialog: the waiting set as an openable list. Enter opens the
2889
2923
  // highlighted session; the sidebar section stays the always-visible view.
2890
2924
  let lastNeedsID: string | undefined
2891
- const openNeeds = async () => {
2892
- if (api.mode.current() !== BASE_MODE) return
2893
- if (api.renderer.currentFocusedEditor === null) return
2925
+ const openNeeds = async (command = false) => {
2926
+ if (!command && api.mode.current() !== BASE_MODE) return
2927
+ if (!command && api.renderer.currentFocusedEditor === null) return
2894
2928
  let items: { entry: Entry; reason: string; since?: number }[] = []
2895
2929
  try {
2896
2930
  const { entries } = await fetchEntries(api)
@@ -2935,6 +2969,7 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
2935
2969
  api.route.navigate("session", { sessionID: target.entry.id })
2936
2970
  }
2937
2971
  const disposeNav = api.keymap.registerLayer({
2972
+ mode: "modal",
2938
2973
  bindings: [
2939
2974
  { key: "up", desc: "Previous session", preventDefault: true, cmd: () => moveCursor(-1) },
2940
2975
  { key: "down", desc: "Next session", preventDefault: true, cmd: () => moveCursor(1) },
@@ -3036,7 +3071,7 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
3036
3071
  category: "Sessions",
3037
3072
  slash: { name: "sesh" },
3038
3073
  onSelect: () => {
3039
- void openPicker()
3074
+ void openPicker(true)
3040
3075
  },
3041
3076
  },
3042
3077
  {
@@ -3046,7 +3081,7 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
3046
3081
  category: "Sessions",
3047
3082
  slash: { name: "sesh-costs" },
3048
3083
  onSelect: () => {
3049
- void openCosts()
3084
+ void openCosts(true)
3050
3085
  },
3051
3086
  },
3052
3087
  {
@@ -3056,7 +3091,7 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
3056
3091
  category: "Sessions",
3057
3092
  slash: { name: "sesh-needs" },
3058
3093
  onSelect: () => {
3059
- void openNeeds()
3094
+ void openNeeds(true)
3060
3095
  },
3061
3096
  },
3062
3097
  ])
@@ -3102,7 +3137,7 @@ function adaptTheme(theme: Plugin.Context["theme"]) {
3102
3137
  }
3103
3138
  }
3104
3139
 
3105
- function createApi(ctx: Plugin.Context): TuiPluginApi {
3140
+ function createApi(ctx: Plugin.Context) {
3106
3141
  const [dialogOpen, setDialogOpen] = createSignal(false)
3107
3142
  let sourceTheme: Plugin.Context["theme"] | undefined
3108
3143
  let currentTheme: ReturnType<typeof adaptTheme>
@@ -3117,9 +3152,9 @@ function createApi(ctx: Plugin.Context): TuiPluginApi {
3117
3152
  },
3118
3153
  },
3119
3154
  ui: {
3120
- toast(opts: { message: string; variant?: "info" | "warning" | "error" | "success" }) {
3155
+ toast(opts: { title?: string; message: string; variant?: "info" | "warning" | "error" | "success" }) {
3121
3156
  ctx.ui.toast.show({
3122
- title: "Session",
3157
+ title: opts.title ?? "Sessions",
3123
3158
  message: opts.message,
3124
3159
  variant: opts.variant ?? "info",
3125
3160
  })
@@ -3129,12 +3164,18 @@ function createApi(ctx: Plugin.Context): TuiPluginApi {
3129
3164
  ctx.ui.dialog.clear()
3130
3165
  setDialogOpen(false)
3131
3166
  },
3132
- replace(render: () => any) {
3167
+ replace(render: () => any, onClose?: () => void) {
3168
+ let closed = false
3169
+ ctx.ui.dialog.show(render, () => {
3170
+ if (closed) return
3171
+ closed = true
3172
+ setDialogOpen(false)
3173
+ onClose?.()
3174
+ })
3133
3175
  setDialogOpen(true)
3134
- ctx.ui.dialog.show(render, () => setDialogOpen(false))
3135
3176
  },
3136
- setSize(size: string) {
3137
- ctx.ui.dialog.set({ size: size as any })
3177
+ setSize(size: Parameters<Plugin.Context["ui"]["dialog"]["set"]>[0]["size"]) {
3178
+ ctx.ui.dialog.set({ size })
3138
3179
  },
3139
3180
  // V2's Dialog has no reactive open/isOpen accessor; track it locally
3140
3181
  // from the replace/clear calls above and the host's onClose callback.
@@ -3206,8 +3247,7 @@ function createApi(ctx: Plugin.Context): TuiPluginApi {
3206
3247
  ctx.keymap.layer(() => ({
3207
3248
  mode: layer.mode ?? "base",
3208
3249
  priority: layer.priority ?? 20,
3209
- commands: (layer.bindings ?? []).map((b, i) => ({
3210
- id: `sesh-cmd-${Math.random().toString(36).slice(2)}-${i}`,
3250
+ commands: (layer.bindings ?? []).map((b) => ({
3211
3251
  title: b.desc,
3212
3252
  bind: b.key,
3213
3253
  run: () => {
@@ -3216,7 +3256,7 @@ function createApi(ctx: Plugin.Context): TuiPluginApi {
3216
3256
  })),
3217
3257
  }))
3218
3258
  return dispose
3219
- }))
3259
+ }))!
3220
3260
  },
3221
3261
  },
3222
3262
  slots: {
@@ -3273,20 +3313,28 @@ function createApi(ctx: Plugin.Context): TuiPluginApi {
3273
3313
  await ctx.client.session.remove({ sessionID: req.sessionID })
3274
3314
  return { data: undefined }
3275
3315
  },
3276
- async fork(req: { path: { id: string } }) {
3277
- const res: any = await ctx.client.session.fork({ sessionID: req.path.id })
3278
- return { data: res?.data ?? res }
3316
+ async fork(req: { sessionID: string; directory?: string }) {
3317
+ const res = await ctx.client.session.fork({ sessionID: req.sessionID })
3318
+ return { data: res }
3279
3319
  },
3280
- async messages(req: { path?: { id?: string }; sessionID?: string }) {
3281
- const sessionID = req.sessionID ?? req.path?.id ?? ""
3282
- const res = await ctx.client.message.list({ sessionID, order: "asc", limit: 100 })
3283
- const messages = (res.data ?? []).map((m: any) => {
3320
+ async messages(req: { sessionID: string; preview?: boolean }) {
3321
+ let cursor: string | undefined
3322
+ const all = []
3323
+ do {
3324
+ const res = await ctx.client.message.list({ sessionID: req.sessionID, order: req.preview ? "desc" : "asc", limit: 100, cursor })
3325
+ all.push(...res.data)
3326
+ const next = res.cursor.next
3327
+ if (req.preview || !next || next === cursor) break
3328
+ cursor = next
3329
+ } while (true)
3330
+ if (req.preview) all.reverse()
3331
+ const messages = all.map((m) => {
3284
3332
  let text = ""
3285
3333
  if (m.type === "user") text = m.text ?? ""
3286
3334
  else if (m.type === "assistant") {
3287
3335
  text = (m.content ?? [])
3288
- .filter((c: any) => c.type === "text")
3289
- .map((c: any) => c.text)
3336
+ .filter((c) => c.type === "text")
3337
+ .map((c) => c.text)
3290
3338
  .join("\n")
3291
3339
  }
3292
3340
  return {
@@ -3300,30 +3348,17 @@ function createApi(ctx: Plugin.Context): TuiPluginApi {
3300
3348
  },
3301
3349
  experimental: {
3302
3350
  session: {
3303
- async list(input: any) {
3304
- // V1 paged the global list with a numeric `time.updated` cursor and a
3305
- // descending order. V2 returns an opaque cursor per location, so
3306
- // fetch once and page in memory to keep the caller's loop intact.
3307
- const limit = Number(input?.limit) > 0 ? Number(input.limit) : 200
3308
- const cursor = typeof input?.cursor === "number" ? input.cursor : undefined
3309
- const res: any = await ctx.client.session.list({ limit: 5000, order: "desc" })
3310
- const all: any[] = Array.isArray(res) ? res : (res?.data ?? [])
3311
- const normalized = all
3312
- .map((s: any) => ({
3351
+ async list(input: { limit: number; cursor?: string; roots?: boolean; directory?: string }) {
3352
+ const res = await ctx.client.session.list({ parentID: null, order: "desc", limit: input.limit, cursor: input.cursor })
3353
+ return { cursor: res.cursor, data: res.data.map((s) => ({
3313
3354
  ...s,
3314
- directory: s.directory ?? s.location?.directory ?? "",
3315
- }))
3316
- .sort((a: any, b: any) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0))
3317
- const page =
3318
- cursor === undefined
3319
- ? normalized.slice(0, limit)
3320
- : normalized.filter((s: any) => (s.time?.updated ?? 0) < cursor).slice(0, limit)
3321
- return { data: page }
3355
+ directory: s.location?.directory ?? "",
3356
+ })) }
3322
3357
  },
3323
3358
  },
3324
3359
  },
3325
3360
  },
3326
- } as any
3361
+ }
3327
3362
  }
3328
3363
 
3329
3364
  export default Plugin.define({
@@ -3331,7 +3366,7 @@ export default Plugin.define({
3331
3366
  async setup(ctx) {
3332
3367
  const api = createApi(ctx)
3333
3368
  let initialized = false
3334
- return ctx.ui.slot({
3369
+ const disposeSlot = ctx.ui.slot({
3335
3370
  append: "app",
3336
3371
  render: () => {
3337
3372
  if (!initialized) {
@@ -3342,5 +3377,10 @@ export default Plugin.define({
3342
3377
  return <box />
3343
3378
  },
3344
3379
  })
3380
+ return () => {
3381
+ if (api.ui.dialog.open) api.ui.dialog.clear()
3382
+ disposeSlot()
3383
+ removeRootMouse?.()
3384
+ }
3345
3385
  },
3346
3386
  })