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 +16 -10
- package/CHANGELOG.md +14 -0
- package/README.md +11 -10
- package/bin/sesh-costs.sh +3 -3
- package/bin/sesh-list.sh +6 -5
- package/bin/sesh-preview.sh +2 -1
- package/bin/sesh-prune.sh +2 -2
- package/bin/sesh-retitle.sh +6 -8
- package/bin/sesh-waiting.sh +22 -24
- package/install.sh +3 -5
- package/package.json +4 -3
- package/plugins/sesh-list.ts +1 -1
- package/tui-plugins/sesh-panel/tui.tsx +114 -74
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: `
|
|
63
|
-
the same store through `
|
|
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
|
|
69
|
-
|
|
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
|
|
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
|
|
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` (
|
|
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:**
|
|
172
|
-
|
|
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 `
|
|
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
|
|
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 `
|
|
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
|
-
|
|
84
|
-
|
|
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,
|
|
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
|
-
|
|
251
|
-
store (SQLite: `
|
|
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`,
|
|
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 `
|
|
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
|
|
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
|
|
63
|
-
JOIN
|
|
64
|
-
WHERE
|
|
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
|
|
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
|
|
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 ==
|
|
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
|
|
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
|
-
|
|
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 // ""),
|
package/bin/sesh-preview.sh
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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; }
|
package/bin/sesh-retitle.sh
CHANGED
|
@@ -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
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
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)."
|
package/bin/sesh-waiting.sh
CHANGED
|
@@ -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
|
|
60
|
-
WHERE
|
|
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
|
|
66
|
-
WHERE
|
|
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
|
|
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
|
|
75
|
-
WHERE
|
|
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
|
|
81
|
-
WHERE
|
|
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
|
|
86
|
-
WHERE
|
|
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 "
|
|
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"
|
|
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.
|
|
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.
|
|
70
|
-
"@opencode/plugin": "2.0.
|
|
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",
|
package/plugins/sesh-list.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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:
|
|
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 (
|
|
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 =
|
|
452
|
-
if (
|
|
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: "
|
|
1361
|
-
{ key: "
|
|
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: "
|
|
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: "
|
|
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 ·
|
|
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: "
|
|
1858
|
-
{ key: "
|
|
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
|
-
|
|
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)
|
|
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: "
|
|
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:
|
|
3137
|
-
ctx.ui.dialog.set({ size
|
|
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
|
|
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: {
|
|
3277
|
-
const res
|
|
3278
|
-
return { 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: {
|
|
3281
|
-
|
|
3282
|
-
const
|
|
3283
|
-
|
|
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
|
|
3289
|
-
.map((c
|
|
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:
|
|
3304
|
-
|
|
3305
|
-
|
|
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.
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
})
|