opencode-sesh 0.1.5 → 0.1.7
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 +5 -0
- package/CHANGELOG.md +42 -1
- package/README.md +11 -1
- package/bin/sesh-list.sh +56 -38
- package/bin/sesh.sh +12 -0
- package/install.sh +109 -76
- package/package.json +2 -1
- package/tui/sesh-panel.tsx +180 -74
package/AGENTS.md
CHANGED
|
@@ -108,6 +108,11 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
|
|
|
108
108
|
`api.route.navigate("session", …)`. `ctrl+o` and the command palette open it;
|
|
109
109
|
Ctrl-X deletes via the opencode CLI and removes the row only after the server
|
|
110
110
|
confirms the deletion.
|
|
111
|
+
- **Picker search:** pages the global session list (`SESSION_PAGE_LIMIT`, capped
|
|
112
|
+
at `SESSION_MAX` and reported when truncated) instead of a fixed 500-row
|
|
113
|
+
window, then indexes transcript text for **every** session in batches
|
|
114
|
+
(`TRANSCRIPT_BATCH`) with bounded remote concurrency (`REMOTE_CONCURRENCY`).
|
|
115
|
+
The header shows indexing progress/coverage. `tests/tui.mjs` locks this.
|
|
111
116
|
|
|
112
117
|
### Hard-won rules — do not regress
|
|
113
118
|
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.1.7] - 2026-09-13
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Sidebar and home session titles are capped to a consistent width so long
|
|
15
|
+
names no longer run into the timestamp. Removed OpenTUI's `truncate` prop,
|
|
16
|
+
which middle-truncated the already-shortened titles.
|
|
17
|
+
- The TUI picker no longer re-selects a session when the list scrolls under the
|
|
18
|
+
cursor, and its preview keeps the current transcript visible while the next
|
|
19
|
+
one loads instead of blanking on every cursor move.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Sidebar rows use muted `├`/`└` tree glyphs and directory headers get a blank
|
|
24
|
+
line, so sessions are grouped clearly: a gap between directories, tight rows
|
|
25
|
+
within one.
|
|
26
|
+
- `npm install` and upgrades refresh an already-installed TUI panel
|
|
27
|
+
(`install.sh --sync-panel`), and `sesh --check` reports when the installed
|
|
28
|
+
panel is out of date. Because opencode imports plugins once at startup,
|
|
29
|
+
install now states that a full quit/reopen is required (a window reload reuses
|
|
30
|
+
the running process).
|
|
31
|
+
|
|
32
|
+
## [0.1.6] - 2026-09-13
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- The TUI picker pages through the entire global session list instead of a
|
|
37
|
+
fixed 500-session window, indexes transcript text for every session (not just
|
|
38
|
+
the newest 150) in batches, bounds remote transcript fetching, and shows
|
|
39
|
+
indexing progress/coverage in the search header.
|
|
40
|
+
- Transcript extraction reads parts in batches and assembles all records in a
|
|
41
|
+
single `jq` pass. A 250-session cold index dropped from about 28 s to about
|
|
42
|
+
7 s locally (warm ~0.3 s).
|
|
43
|
+
|
|
44
|
+
### Added
|
|
45
|
+
|
|
46
|
+
- TUI data-layer contract tests (pagination, full index coverage, bounded
|
|
47
|
+
concurrency, progress, and the truncated cap), run on Node 24 in CI.
|
|
48
|
+
|
|
10
49
|
## [0.1.5] - 2026-09-13
|
|
11
50
|
|
|
12
51
|
### Fixed
|
|
@@ -102,7 +141,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
102
141
|
- Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
|
|
103
142
|
CI.
|
|
104
143
|
|
|
105
|
-
[Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.
|
|
144
|
+
[Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.7...HEAD
|
|
145
|
+
[0.1.7]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.6...v0.1.7
|
|
146
|
+
[0.1.6]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.5...v0.1.6
|
|
106
147
|
[0.1.5]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.4...v0.1.5
|
|
107
148
|
[0.1.4]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.3...v0.1.4
|
|
108
149
|
[0.1.3]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.2...v0.1.3
|
package/README.md
CHANGED
|
@@ -68,7 +68,12 @@ file it would overwrite is backed up first.
|
|
|
68
68
|
The npm package ships the same scripts and installer; the runtime tools below
|
|
69
69
|
are still required.
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
Fully quit opencode and reopen it to load the panel — opencode imports plugins
|
|
72
|
+
once at startup, so reloading a window reuses the running process. Upgrades
|
|
73
|
+
refresh an already-installed panel automatically (the npm `postinstall` syncs
|
|
74
|
+
it), and `sesh --check` reports when the installed panel is out of date.
|
|
75
|
+
|
|
76
|
+
Then:
|
|
72
77
|
|
|
73
78
|
```bash
|
|
74
79
|
sesh # terminal picker
|
|
@@ -137,6 +142,11 @@ read -r id cwd < <(sesh --print --query "auth")
|
|
|
137
142
|
Click the sidebar's `search…` box to filter recent sessions in place; click
|
|
138
143
|
elsewhere or press Esc to leave search.
|
|
139
144
|
|
|
145
|
+
The full picker pages through your whole global session list (no fixed window)
|
|
146
|
+
and indexes transcript text for every session in the background, showing
|
|
147
|
+
indexing progress under the search box. It only stops at a safety cap of 5,000
|
|
148
|
+
sessions, and says so when it does.
|
|
149
|
+
|
|
140
150
|
## Configuration
|
|
141
151
|
|
|
142
152
|
All variables are optional.
|
package/bin/sesh-list.sh
CHANGED
|
@@ -260,53 +260,71 @@ refresh() {
|
|
|
260
260
|
"$JQ_BIN" -c '.stale[]?' "$cache_state" > "$needs_extract"
|
|
261
261
|
while IFS= read -r cache; do [ -n "$cache" ] && rm -f "$cache"; done < <("$JQ_BIN" -r '.prune[]?' "$cache_state")
|
|
262
262
|
|
|
263
|
-
local sid
|
|
264
|
-
local extract_failed=0
|
|
263
|
+
local stale_ids=() sid chunk j i
|
|
265
264
|
while IFS= read -r row; do
|
|
266
265
|
[ -n "$row" ] || continue
|
|
267
266
|
sid=$(printf '%s' "$row" | "$JQ_BIN" -r '.id')
|
|
268
267
|
valid_session_id "$sid" || continue
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
268
|
+
stale_ids+=("$sid")
|
|
269
|
+
done < "$needs_extract"
|
|
270
|
+
|
|
271
|
+
# Batch the transcript read: one SQL query per chunk of sessions and a single
|
|
272
|
+
# jq pass to assemble every record, instead of a query plus three jq processes
|
|
273
|
+
# per session. Text still travels through files, never argv. Any failure leaves
|
|
274
|
+
# every stale session uncached, so the next refresh retries them.
|
|
275
|
+
local extract_failed=0 parts_raw="$scratch/parts.jsonl" records="$scratch/records.jsonl"
|
|
276
|
+
: > "$parts_raw"
|
|
277
|
+
j=0
|
|
278
|
+
while [ "$j" -lt "${#stale_ids[@]}" ]; do
|
|
279
|
+
chunk=''
|
|
280
|
+
for ((i = j; i < j + 200 && i < ${#stale_ids[@]}; i++)); do
|
|
281
|
+
chunk="${chunk:+$chunk,}'${stale_ids[$i]}'"
|
|
282
|
+
done
|
|
283
|
+
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 \
|
|
284
|
+
| "$JQ_BIN" -c '.[]?' >> "$parts_raw"; then
|
|
286
285
|
extract_failed=1
|
|
287
|
-
|
|
288
|
-
fi
|
|
289
|
-
if [ -z "$title" ]; then
|
|
290
|
-
title=$("$JQ_BIN" -Rrs 'gsub("[\r\n]+"; " ") | if length > 70 then .[0:67] + "..." else . end | select(length > 0) // "(untitled)"' "$text_file" 2>/dev/null) || title='(untitled)'
|
|
291
|
-
[ -n "$title" ] || title='(untitled)'
|
|
286
|
+
break
|
|
292
287
|
fi
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
288
|
+
j=$((j + 200))
|
|
289
|
+
done
|
|
290
|
+
|
|
291
|
+
if [ "$extract_failed" = 0 ]; then
|
|
292
|
+
"$JQ_BIN" -cnr --arg extractions "$EXTRACTIONS" --slurpfile stale "$needs_extract" --slurpfile parts "$parts_raw" '
|
|
293
|
+
($parts
|
|
294
|
+
| map({sid: .session_id,
|
|
295
|
+
text: ((.data | try fromjson catch null)
|
|
296
|
+
| if type == "object" and .type == "text" and (.text | type) == "string" then .text else empty end)})
|
|
297
|
+
| group_by(.sid)
|
|
298
|
+
| map({key: .[0].sid, value: ([.[].text] | join(" "))})
|
|
299
|
+
| from_entries) as $bySid
|
|
300
|
+
| $stale[]
|
|
301
|
+
| select((.id | type) == "string" and (.id | test("^ses_[A-Za-z0-9]+$")))
|
|
302
|
+
| . as $s
|
|
303
|
+
| ($bySid[$s.id] // "") as $text
|
|
304
|
+
| (if ($s.title // "") == ""
|
|
305
|
+
then (($text | gsub("[\r\n]+"; " ") | if length > 70 then .[0:67] + "..." else . end) | select(length > 0) // "(untitled)")
|
|
306
|
+
else $s.title end) as $title
|
|
307
|
+
| {sessionId: $s.id,
|
|
308
|
+
cachePath: ($extractions + "/" + $s.id + ".json"),
|
|
309
|
+
record: {cacheSchema: 2, cacheSession: $s.id,
|
|
310
|
+
cacheUpdated: ($s.time_updated // 0), cacheParts: ($s.partCount // 0), cacheMax: ($s.partMax // 0),
|
|
311
|
+
sessionId: $s.id, transcriptPath: "", source: "opencode",
|
|
312
|
+
cwd: ($s.directory // ""),
|
|
313
|
+
title: ($title | gsub("[\r\n]+"; " ") | if length > 70 then .[0:67] + "..." else . end),
|
|
314
|
+
updatedEpoch: ((($s.time_updated // 0) / 1000 | floor) | if . > 0 then . else ((($s.time_created // 0) / 1000) | floor) end),
|
|
315
|
+
fulltextLower: (($title + " " + $text) | ascii_downcase)}}
|
|
316
|
+
| "\(.sessionId)\n\(.cachePath)\n\(.record | tojson)"' > "$records" || extract_failed=1
|
|
317
|
+
fi
|
|
318
|
+
|
|
319
|
+
if [ "$extract_failed" = 0 ]; then
|
|
320
|
+
while IFS= read -r sid && IFS= read -r cache_path && IFS= read -r record; do
|
|
321
|
+
[ -n "$sid" ] || continue
|
|
302
322
|
cache_tmp=$(mktemp "$EXTRACTIONS/$sid.json.XXXXXX")
|
|
303
323
|
printf '%s\n' "$record" > "$cache_tmp"
|
|
304
|
-
mv "$cache_tmp" "$
|
|
324
|
+
mv "$cache_tmp" "$cache_path"
|
|
305
325
|
printf '%s\n' "$record" >> "$historical"
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
fi
|
|
309
|
-
done < "$needs_extract"
|
|
326
|
+
done < "$records"
|
|
327
|
+
fi
|
|
310
328
|
|
|
311
329
|
if ! "$JQ_BIN" -c --slurpfile hist "$historical" --arg scope "$SCOPE_CWD" --argjson limit "$LIMIT" --argjson archived "$INCLUDE_ARCHIVED" '
|
|
312
330
|
.meta as $meta
|
package/bin/sesh.sh
CHANGED
|
@@ -38,6 +38,18 @@ fi
|
|
|
38
38
|
if [ "${1:-}" = --check ]; then
|
|
39
39
|
"$SESH_JQ" -en '"2026-01-01T00:00:00Z" | fromdateiso8601 | type == "number"' >/dev/null || { echo 'jq capability check failed' >&2; exit 1; }
|
|
40
40
|
command -v sqlite3 >/dev/null 2>&1 || command -v "$OPENCODE_BIN" >/dev/null 2>&1 || { echo 'neither sqlite3 nor opencode is available' >&2; exit 1; }
|
|
41
|
+
# The TUI panel is a copied file that opencode imports once at startup. Report
|
|
42
|
+
# when the installed copy has fallen behind this package so upgrades are not
|
|
43
|
+
# silently stale.
|
|
44
|
+
panel_bundled="$SCRIPT_DIR/../tui/sesh-panel.tsx"
|
|
45
|
+
panel_installed="${XDG_CONFIG_HOME:-$HOME/.config}/opencode/plugins/sesh-panel.tsx"
|
|
46
|
+
if [ -f "$panel_installed" ] && [ -f "$panel_bundled" ]; then
|
|
47
|
+
if cmp -s "$panel_bundled" "$panel_installed"; then
|
|
48
|
+
echo 'sesh: sidebar panel is current'
|
|
49
|
+
else
|
|
50
|
+
echo 'sesh: sidebar panel is out of date; run `sesh install`, then fully quit and reopen opencode' >&2
|
|
51
|
+
fi
|
|
52
|
+
fi
|
|
41
53
|
echo 'sesh: dependencies ready'
|
|
42
54
|
exit 0
|
|
43
55
|
fi
|
package/install.sh
CHANGED
|
@@ -19,6 +19,7 @@ BIN_DIR="${XDG_BIN_HOME:-$HOME/.local/bin}"
|
|
|
19
19
|
INSTALL_TOOL=1
|
|
20
20
|
INSTALL_TUI=1
|
|
21
21
|
UNINSTALL=0
|
|
22
|
+
SYNC_PANEL=0
|
|
22
23
|
|
|
23
24
|
# Fallback plugin version for when the opencode CLI is unavailable; the
|
|
24
25
|
# installer prefers the local opencode version so the panel matches its SDK.
|
|
@@ -43,6 +44,7 @@ Usage: bash install.sh [--bin-dir DIR] [--no-tool] [--no-tui] [--uninstall]
|
|
|
43
44
|
--no-tool Skip installing the sesh-list custom tool.
|
|
44
45
|
--no-tui Skip installing the in-TUI sessions panel plugin.
|
|
45
46
|
--uninstall Remove everything this script installed.
|
|
47
|
+
--sync-panel Refresh an already-installed TUI panel only (used by postinstall).
|
|
46
48
|
--help Show this help.
|
|
47
49
|
USAGE
|
|
48
50
|
}
|
|
@@ -53,6 +55,7 @@ while [ "$#" -gt 0 ]; do
|
|
|
53
55
|
--no-tool) INSTALL_TOOL=0; shift ;;
|
|
54
56
|
--no-tui) INSTALL_TUI=0; shift ;;
|
|
55
57
|
--uninstall) UNINSTALL=1; shift ;;
|
|
58
|
+
--sync-panel) SYNC_PANEL=1; shift ;;
|
|
56
59
|
--help) usage; exit 0 ;;
|
|
57
60
|
*) fail "unknown option: $1" ;;
|
|
58
61
|
esac
|
|
@@ -110,62 +113,6 @@ launcher_is_ours() {
|
|
|
110
113
|
[ -f "$target" ] && grep -qF 'Pick an opencode session' "$target" 2>/dev/null
|
|
111
114
|
}
|
|
112
115
|
|
|
113
|
-
if [ "$UNINSTALL" = 1 ]; then
|
|
114
|
-
if [ -L "$LAUNCHER" ]; then
|
|
115
|
-
if launcher_is_ours "$LAUNCHER"; then
|
|
116
|
-
rm -f "$LAUNCHER" && note "removed $LAUNCHER"
|
|
117
|
-
else
|
|
118
|
-
note "left $LAUNCHER untouched (not a sesh launcher: $(readlink "$LAUNCHER" 2>/dev/null))"
|
|
119
|
-
fi
|
|
120
|
-
fi
|
|
121
|
-
remove_if_marker "$COMMAND_DST" "rich interactive picker" && note "removed $COMMAND_DST"
|
|
122
|
-
remove_if_marker "$TOOL_DST" "list opencode sessions for the agent" && note "removed $TOOL_DST"
|
|
123
|
-
remove_if_marker "$PANEL_DST" "@jsxImportSource @opentui/solid" && note "removed $PANEL_DST"
|
|
124
|
-
remove_tui_entry && note "unregistered the panel from $TUI_JSON"
|
|
125
|
-
note "uninstalled (extraction cache and plugin dependencies left intact)"
|
|
126
|
-
exit 0
|
|
127
|
-
fi
|
|
128
|
-
|
|
129
|
-
for path in \
|
|
130
|
-
bin/sesh \
|
|
131
|
-
bin/sesh.sh \
|
|
132
|
-
bin/sesh-list.sh \
|
|
133
|
-
bin/sesh-preview.sh \
|
|
134
|
-
bin/sesh-delete.sh \
|
|
135
|
-
bin/sesh-refresh-worker.sh \
|
|
136
|
-
bin/sesh-shortcuts.sh \
|
|
137
|
-
opencode/tools/sesh-list.ts \
|
|
138
|
-
tui/sesh-panel.tsx \
|
|
139
|
-
themes/glow-dark-clean.json; do
|
|
140
|
-
[ -f "$SOURCE_DIR/$path" ] || fail "installer bundle is incomplete: missing $path."
|
|
141
|
-
done
|
|
142
|
-
|
|
143
|
-
for tool in bash jq fzf; do
|
|
144
|
-
command -v "$tool" >/dev/null 2>&1 || fail "required tool '$tool' is unavailable."
|
|
145
|
-
done
|
|
146
|
-
fzf_version=$(fzf --version 2>/dev/null || true)
|
|
147
|
-
if [[ "$fzf_version" =~ ^([0-9]+)\.([0-9]+)(\.([0-9]+))? ]]; then
|
|
148
|
-
if [ "$((10#${BASH_REMATCH[1]}))" -eq 0 ] && [ "$((10#${BASH_REMATCH[2]}))" -lt 73 ]; then
|
|
149
|
-
fail "fzf >= 0.73.0 is required (found $fzf_version)."
|
|
150
|
-
fi
|
|
151
|
-
else
|
|
152
|
-
fail "cannot parse fzf version from: $fzf_version."
|
|
153
|
-
fi
|
|
154
|
-
command -v sqlite3 >/dev/null 2>&1 || command -v opencode >/dev/null 2>&1 \
|
|
155
|
-
|| fail "either sqlite3 or the opencode CLI is required."
|
|
156
|
-
command -v opencode >/dev/null 2>&1 || note "opencode CLI not found: listing and previews work via sqlite3, but resume and delete need opencode."
|
|
157
|
-
|
|
158
|
-
umask 077
|
|
159
|
-
mkdir -p "$BIN_DIR"
|
|
160
|
-
if [ -e "$LAUNCHER" ] && [ ! -L "$LAUNCHER" ]; then
|
|
161
|
-
fail "$LAUNCHER already exists and is not a symlink. Move it aside, then retry."
|
|
162
|
-
fi
|
|
163
|
-
if [ -L "$LAUNCHER" ] && ! launcher_is_ours "$LAUNCHER"; then
|
|
164
|
-
fail "$LAUNCHER is a symlink to $(readlink "$LAUNCHER" 2>/dev/null), which is not a sesh install. Move it aside, then retry."
|
|
165
|
-
fi
|
|
166
|
-
ln -sfn "$SOURCE_DIR/bin/sesh" "$LAUNCHER"
|
|
167
|
-
note "linked $LAUNCHER -> $SOURCE_DIR/bin/sesh"
|
|
168
|
-
|
|
169
116
|
install_file() {
|
|
170
117
|
local src=$1 dst=$2
|
|
171
118
|
mkdir -p "$(dirname "$dst")"
|
|
@@ -181,26 +128,20 @@ install_file() {
|
|
|
181
128
|
note "installed $dst"
|
|
182
129
|
}
|
|
183
130
|
|
|
184
|
-
|
|
185
|
-
# agent; the TUI plugin now registers the slash itself. Drop the old file so the
|
|
186
|
-
# two do not collide.
|
|
187
|
-
remove_if_marker "$COMMAND_DST" "rich interactive picker" && note "removed obsolete markdown command $COMMAND_DST"
|
|
188
|
-
[ "$INSTALL_TOOL" = 1 ] && install_file "$SOURCE_DIR/opencode/tools/sesh-list.ts" "$TOOL_DST"
|
|
189
|
-
|
|
190
|
-
opencode_version=''
|
|
191
|
-
if command -v opencode >/dev/null 2>&1; then
|
|
192
|
-
raw_version=$(opencode --version 2>/dev/null || true)
|
|
193
|
-
if [[ "$raw_version" =~ ([0-9]+\.[0-9]+\.[0-9]+) ]]; then opencode_version="${BASH_REMATCH[1]}"; fi
|
|
194
|
-
fi
|
|
195
|
-
plugin_version="${opencode_version:-$PLUGIN_VERSION_FALLBACK}"
|
|
196
|
-
|
|
197
|
-
if [ "$INSTALL_TUI" = 1 ]; then
|
|
131
|
+
install_tui_panel() {
|
|
198
132
|
install_file "$SOURCE_DIR/tui/sesh-panel.tsx" "$PANEL_DST"
|
|
199
133
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
134
|
+
local opencode_version='' raw_version='' plugin_version=''
|
|
135
|
+
if command -v opencode >/dev/null 2>&1; then
|
|
136
|
+
raw_version=$(opencode --version 2>/dev/null || true)
|
|
137
|
+
if [[ "$raw_version" =~ ([0-9]+\.[0-9]+\.[0-9]+) ]]; then opencode_version="${BASH_REMATCH[1]}"; fi
|
|
138
|
+
fi
|
|
139
|
+
plugin_version="${opencode_version:-$PLUGIN_VERSION_FALLBACK}"
|
|
140
|
+
|
|
141
|
+
# opencode has no persistent-sidebar API: the panel is an xlarge modal dialog
|
|
142
|
+
# (see tui/sesh-panel.tsx). Register it idempotently and drop the previous
|
|
143
|
+
# filename if an older install left it behind.
|
|
144
|
+
local tui_created=0 tui_tmp=''
|
|
204
145
|
if [ ! -f "$TUI_JSON" ]; then
|
|
205
146
|
printf '{\n "$schema": "https://opencode.ai/tui.json",\n "plugin": []\n}\n' > "$TUI_JSON"
|
|
206
147
|
tui_created=1
|
|
@@ -228,7 +169,7 @@ if [ "$INSTALL_TUI" = 1 ]; then
|
|
|
228
169
|
# Local plugins resolve their imports from the config directory, and opencode
|
|
229
170
|
# runs `bun install` there at startup. Keep the required packages declared
|
|
230
171
|
# without clobbering versions the user already pinned.
|
|
231
|
-
pkg_created=0
|
|
172
|
+
local pkg_created=0 pkg_tmp=''
|
|
232
173
|
if [ ! -f "$PACKAGE_JSON" ]; then
|
|
233
174
|
printf '{\n "dependencies": {}\n}\n' > "$PACKAGE_JSON"
|
|
234
175
|
pkg_created=1
|
|
@@ -257,12 +198,104 @@ if [ "$INSTALL_TUI" = 1 ]; then
|
|
|
257
198
|
rm -f "$pkg_tmp"
|
|
258
199
|
note "warning: could not update $PACKAGE_JSON; the TUI panel may fail to load"
|
|
259
200
|
fi
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
# opencode imports plugins once at startup and has no hot reload, so a running
|
|
204
|
+
# instance keeps the old panel until it is fully quit (reloading a window reuses
|
|
205
|
+
# the same process). Say so explicitly and name the processes still holding it.
|
|
206
|
+
note_restart() {
|
|
207
|
+
note "restart opencode to load the updated panel: quit it completely"
|
|
208
|
+
note " (all windows and background processes), then reopen."
|
|
209
|
+
if command -v pgrep >/dev/null 2>&1; then
|
|
210
|
+
local pids
|
|
211
|
+
pids=$(pgrep -x opencode 2>/dev/null | tr '\n' ' ' | sed 's/ $//' || true)
|
|
212
|
+
[ -n "$pids" ] && note " still running: opencode PID(s) $pids"
|
|
213
|
+
fi
|
|
214
|
+
return 0
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if [ "$UNINSTALL" = 1 ]; then
|
|
218
|
+
if [ -L "$LAUNCHER" ]; then
|
|
219
|
+
if launcher_is_ours "$LAUNCHER"; then
|
|
220
|
+
rm -f "$LAUNCHER" && note "removed $LAUNCHER"
|
|
221
|
+
else
|
|
222
|
+
note "left $LAUNCHER untouched (not a sesh launcher: $(readlink "$LAUNCHER" 2>/dev/null))"
|
|
223
|
+
fi
|
|
224
|
+
fi
|
|
225
|
+
remove_if_marker "$COMMAND_DST" "rich interactive picker" && note "removed $COMMAND_DST"
|
|
226
|
+
remove_if_marker "$TOOL_DST" "list opencode sessions for the agent" && note "removed $TOOL_DST"
|
|
227
|
+
remove_if_marker "$PANEL_DST" "@jsxImportSource @opentui/solid" && note "removed $PANEL_DST"
|
|
228
|
+
remove_tui_entry && note "unregistered the panel from $TUI_JSON"
|
|
229
|
+
note "uninstalled (extraction cache and plugin dependencies left intact)"
|
|
230
|
+
exit 0
|
|
231
|
+
fi
|
|
232
|
+
|
|
233
|
+
for path in \
|
|
234
|
+
bin/sesh \
|
|
235
|
+
bin/sesh.sh \
|
|
236
|
+
bin/sesh-list.sh \
|
|
237
|
+
bin/sesh-preview.sh \
|
|
238
|
+
bin/sesh-delete.sh \
|
|
239
|
+
bin/sesh-refresh-worker.sh \
|
|
240
|
+
bin/sesh-shortcuts.sh \
|
|
241
|
+
opencode/tools/sesh-list.ts \
|
|
242
|
+
tui/sesh-panel.tsx \
|
|
243
|
+
themes/glow-dark-clean.json; do
|
|
244
|
+
[ -f "$SOURCE_DIR/$path" ] || fail "installer bundle is incomplete: missing $path."
|
|
245
|
+
done
|
|
246
|
+
|
|
247
|
+
if [ "$SYNC_PANEL" = 1 ]; then
|
|
248
|
+
# Postinstall path: refresh a panel the user already opted into, without
|
|
249
|
+
# touching the launcher or requiring fzf/sqlite. No-op when the panel is
|
|
250
|
+
# absent, so `npm install` never adds a TUI panel on its own.
|
|
251
|
+
[ -f "$PANEL_DST" ] || { note "sesh sidebar panel is not installed; nothing to sync."; exit 0; }
|
|
252
|
+
umask 077
|
|
253
|
+
install_tui_panel
|
|
254
|
+
note_restart
|
|
255
|
+
exit 0
|
|
256
|
+
fi
|
|
257
|
+
|
|
258
|
+
for tool in bash jq fzf; do
|
|
259
|
+
command -v "$tool" >/dev/null 2>&1 || fail "required tool '$tool' is unavailable."
|
|
260
|
+
done
|
|
261
|
+
fzf_version=$(fzf --version 2>/dev/null || true)
|
|
262
|
+
if [[ "$fzf_version" =~ ^([0-9]+)\.([0-9]+)(\.([0-9]+))? ]]; then
|
|
263
|
+
if [ "$((10#${BASH_REMATCH[1]}))" -eq 0 ] && [ "$((10#${BASH_REMATCH[2]}))" -lt 73 ]; then
|
|
264
|
+
fail "fzf >= 0.73.0 is required (found $fzf_version)."
|
|
265
|
+
fi
|
|
266
|
+
else
|
|
267
|
+
fail "cannot parse fzf version from: $fzf_version."
|
|
268
|
+
fi
|
|
269
|
+
command -v sqlite3 >/dev/null 2>&1 || command -v opencode >/dev/null 2>&1 \
|
|
270
|
+
|| fail "either sqlite3 or the opencode CLI is required."
|
|
271
|
+
command -v opencode >/dev/null 2>&1 || note "opencode CLI not found: listing and previews work via sqlite3, but resume and delete need opencode."
|
|
272
|
+
|
|
273
|
+
umask 077
|
|
274
|
+
mkdir -p "$BIN_DIR"
|
|
275
|
+
if [ -e "$LAUNCHER" ] && [ ! -L "$LAUNCHER" ]; then
|
|
276
|
+
fail "$LAUNCHER already exists and is not a symlink. Move it aside, then retry."
|
|
277
|
+
fi
|
|
278
|
+
if [ -L "$LAUNCHER" ] && ! launcher_is_ours "$LAUNCHER"; then
|
|
279
|
+
fail "$LAUNCHER is a symlink to $(readlink "$LAUNCHER" 2>/dev/null), which is not a sesh install. Move it aside, then retry."
|
|
280
|
+
fi
|
|
281
|
+
ln -sfn "$SOURCE_DIR/bin/sesh" "$LAUNCHER"
|
|
282
|
+
note "linked $LAUNCHER -> $SOURCE_DIR/bin/sesh"
|
|
283
|
+
|
|
284
|
+
# Earlier versions shipped a markdown `/sesh` command that could only prompt the
|
|
285
|
+
# agent; the TUI plugin now registers the slash itself. Drop the old file so the
|
|
286
|
+
# two do not collide.
|
|
287
|
+
remove_if_marker "$COMMAND_DST" "rich interactive picker" && note "removed obsolete markdown command $COMMAND_DST"
|
|
288
|
+
[ "$INSTALL_TOOL" = 1 ] && install_file "$SOURCE_DIR/opencode/tools/sesh-list.ts" "$TOOL_DST"
|
|
289
|
+
|
|
290
|
+
if [ "$INSTALL_TUI" = 1 ]; then
|
|
291
|
+
install_tui_panel
|
|
260
292
|
fi
|
|
261
293
|
|
|
262
294
|
"$LAUNCHER" --check || fail "post-install check failed."
|
|
263
295
|
note "done"
|
|
264
296
|
note " picker: run 'sesh' in any terminal, or '/sesh' inside opencode"
|
|
265
|
-
note " sidebar:
|
|
297
|
+
note " sidebar: ctrl+o opens the full picker"
|
|
298
|
+
note_restart
|
|
266
299
|
case ":$PATH:" in
|
|
267
300
|
*":$BIN_DIR:"*) ;;
|
|
268
301
|
*) note " note: $BIN_DIR is not on PATH; add it to use the launcher anywhere." ;;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-sesh",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Browse, search, preview and resume opencode sessions from the terminal and the TUI.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
},
|
|
42
42
|
"scripts": {
|
|
43
43
|
"prepare": "husky",
|
|
44
|
+
"postinstall": "bash install.sh --sync-panel || true",
|
|
44
45
|
"test": "bash tests/sesh.sh",
|
|
45
46
|
"test:picker": "python3 tests/picker.py",
|
|
46
47
|
"typecheck": "tsc -p tsconfig.json",
|
package/tui/sesh-panel.tsx
CHANGED
|
@@ -130,6 +130,13 @@ function truncate(text: string, width: number): string {
|
|
|
130
130
|
return text.slice(0, width - 1) + "…"
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
// Sidebar and home rows should read at a similar length. The sidebar title
|
|
134
|
+
// column is ~2 chars narrower than the group column (status dot + timestamp),
|
|
135
|
+
// so cap it a bit lower to guarantee separation.
|
|
136
|
+
const SIDEBAR_TITLE_WIDTH = 27
|
|
137
|
+
const SIDEBAR_GROUP_WIDTH = 30
|
|
138
|
+
const HOME_TITLE_WIDTH = 30
|
|
139
|
+
|
|
133
140
|
const SEARCH_KEYS = "abcdefghijklmnopqrstuvwxyz0123456789-_.@/+".split("")
|
|
134
141
|
|
|
135
142
|
function searchBindings(append: (input: string) => void, exit: () => void) {
|
|
@@ -159,20 +166,47 @@ function ensureRootMouse(renderer: TuiPluginApi["renderer"]) {
|
|
|
159
166
|
}
|
|
160
167
|
|
|
161
168
|
type Entry = { id: string; title: string; dir: string; group: string; updated: number }
|
|
169
|
+
type EntryResult = { entries: Entry[]; truncated: boolean }
|
|
170
|
+
|
|
171
|
+
const SESSION_PAGE_LIMIT = 200
|
|
172
|
+
const SESSION_MAX = 5000
|
|
162
173
|
|
|
163
|
-
async function fetchEntries(api: TuiPluginApi): Promise<
|
|
174
|
+
async function fetchEntries(api: TuiPluginApi): Promise<EntryResult> {
|
|
164
175
|
const home = process.env.HOME ?? ""
|
|
165
|
-
const
|
|
166
|
-
api.client.experimental.session.list({ limit: 500, roots: true, directory: "" }),
|
|
167
|
-
api.client.project.list({}),
|
|
168
|
-
])
|
|
169
|
-
const sessions = ((sessionResult?.data ?? sessionResult) as Session[]) ?? []
|
|
176
|
+
const projectResult = await api.client.project.list({})
|
|
170
177
|
const projects = ((projectResult?.data ?? projectResult) as Project[]) ?? []
|
|
171
|
-
if (!Array.isArray(sessions)) return []
|
|
172
178
|
const projectName = new Map<string, string>(
|
|
173
179
|
projects.map((p) => [p.id, p.name?.trim() || shortDir(p.worktree ?? "", home)]),
|
|
174
180
|
)
|
|
175
|
-
|
|
181
|
+
const sessions: Session[] = []
|
|
182
|
+
let cursor: number | undefined
|
|
183
|
+
let truncated = false
|
|
184
|
+
// Page through the global list instead of one fixed window, so sessions in
|
|
185
|
+
// older directories stay searchable. `cursor` is the previous page's oldest
|
|
186
|
+
// `time.updated`; a repeated or missing cursor stops the walk.
|
|
187
|
+
for (;;) {
|
|
188
|
+
const result = await api.client.experimental.session.list({
|
|
189
|
+
limit: SESSION_PAGE_LIMIT,
|
|
190
|
+
roots: true,
|
|
191
|
+
directory: "",
|
|
192
|
+
...(cursor === undefined ? {} : { cursor }),
|
|
193
|
+
})
|
|
194
|
+
const page = ((result?.data ?? result) as Session[]) ?? []
|
|
195
|
+
if (!Array.isArray(page) || page.length === 0) break
|
|
196
|
+
sessions.push(...page)
|
|
197
|
+
if (page.length < SESSION_PAGE_LIMIT) break
|
|
198
|
+
if (sessions.length >= SESSION_MAX) {
|
|
199
|
+
truncated = true
|
|
200
|
+
break
|
|
201
|
+
}
|
|
202
|
+
const next = page[page.length - 1]?.time?.updated
|
|
203
|
+
if (typeof next !== "number" || next === cursor) {
|
|
204
|
+
truncated = true
|
|
205
|
+
break
|
|
206
|
+
}
|
|
207
|
+
cursor = next
|
|
208
|
+
}
|
|
209
|
+
const entries = sessions
|
|
176
210
|
.filter((s) => !s.time?.archived && !s.parentID)
|
|
177
211
|
.sort((a, b) => b.time.updated - a.time.updated)
|
|
178
212
|
.map((s) => {
|
|
@@ -187,76 +221,118 @@ async function fetchEntries(api: TuiPluginApi): Promise<Entry[]> {
|
|
|
187
221
|
updated: s.time.updated,
|
|
188
222
|
}
|
|
189
223
|
})
|
|
224
|
+
return { entries, truncated }
|
|
190
225
|
}
|
|
191
226
|
|
|
192
|
-
const TRANSCRIPT_INDEX_LIMIT = 150
|
|
193
227
|
const SESSION_ID_PATTERN = /^ses_[A-Za-z0-9]+$/
|
|
228
|
+
const TRANSCRIPT_BATCH = 64
|
|
229
|
+
const REMOTE_CONCURRENCY = 8
|
|
194
230
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
}
|
|
201
|
-
const ids = targets.map((entry) => entry.id).filter((id) => SESSION_ID_PATTERN.test(id))
|
|
202
|
-
if (ids.length === 0) return index
|
|
231
|
+
type IndexProgress = { indexed: number; total: number; complete: boolean }
|
|
232
|
+
|
|
233
|
+
const nextTick = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
|
|
234
|
+
|
|
235
|
+
async function openTranscriptDb(): Promise<any | undefined> {
|
|
203
236
|
try {
|
|
204
237
|
const home = process.env.HOME ?? ""
|
|
205
238
|
const dataHome = process.env.XDG_DATA_HOME ?? `${home}/.local/share`
|
|
206
239
|
// @ts-ignore - bun:sqlite ships with the Bun runtime (verified); no type package installed
|
|
207
240
|
const sqlite: any = await import("bun:sqlite")
|
|
208
|
-
if (typeof sqlite?.Database !== "function")
|
|
209
|
-
|
|
241
|
+
if (typeof sqlite?.Database !== "function") return undefined
|
|
242
|
+
return new sqlite.Database(`${dataHome}/opencode/opencode.db`, { readonly: true })
|
|
243
|
+
} catch {
|
|
244
|
+
return undefined
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function addTranscript(index: Map<string, string>, sid: string, data: string): void {
|
|
249
|
+
try {
|
|
250
|
+
const part = JSON.parse(data) as { type?: unknown; text?: unknown }
|
|
251
|
+
if (part?.type !== "text" || typeof part.text !== "string") return
|
|
252
|
+
const base = index.get(sid) ?? ""
|
|
253
|
+
index.set(sid, `${base} ${part.text.toLowerCase()}`.trim())
|
|
254
|
+
} catch {
|
|
255
|
+
// ignore malformed parts
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Index titles/directories immediately, then add transcript text progressively
|
|
260
|
+
// in batches so the render thread can breathe. Covers every entry, not a fixed
|
|
261
|
+
// newest window; `onProgress` lets the picker show coverage.
|
|
262
|
+
async function buildSearchIndex(
|
|
263
|
+
api: TuiPluginApi,
|
|
264
|
+
entries: Entry[],
|
|
265
|
+
onProgress?: (progress: IndexProgress) => void,
|
|
266
|
+
): Promise<Map<string, string>> {
|
|
267
|
+
const index = new Map<string, string>()
|
|
268
|
+
for (const entry of entries) {
|
|
269
|
+
index.set(entry.id, `${entry.title.toLowerCase()} ${entry.dir.toLowerCase()}`)
|
|
270
|
+
}
|
|
271
|
+
const ids = entries.map((entry) => entry.id).filter((id) => SESSION_ID_PATTERN.test(id))
|
|
272
|
+
const remaining = new Set(ids)
|
|
273
|
+
const total = ids.length
|
|
274
|
+
let indexed = 0
|
|
275
|
+
const report = (complete: boolean) => onProgress?.({ indexed, total, complete })
|
|
276
|
+
report(false)
|
|
277
|
+
|
|
278
|
+
const db = await openTranscriptDb()
|
|
279
|
+
if (db) {
|
|
210
280
|
try {
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
.
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
byId.set(row.sid, list)
|
|
225
|
-
}
|
|
226
|
-
} catch {}
|
|
227
|
-
}
|
|
228
|
-
for (const entry of targets) {
|
|
229
|
-
const text = (byId.get(entry.id) ?? []).join(" ").toLowerCase()
|
|
230
|
-
if (text) index.set(entry.id, `${index.get(entry.id)} ${text}`)
|
|
281
|
+
for (let i = 0; i < ids.length; i += TRANSCRIPT_BATCH) {
|
|
282
|
+
const batch = ids.slice(i, i + TRANSCRIPT_BATCH)
|
|
283
|
+
const placeholders = batch.map(() => "?").join(",")
|
|
284
|
+
const rows = (db
|
|
285
|
+
.query(
|
|
286
|
+
`SELECT session_id AS sid, data FROM part WHERE session_id IN (${placeholders}) AND json_extract(data, '$.type') = 'text' ORDER BY time_created`,
|
|
287
|
+
)
|
|
288
|
+
.all(...batch) ?? []) as { sid: string; data: string }[]
|
|
289
|
+
for (const row of rows) addTranscript(index, row.sid, row.data)
|
|
290
|
+
for (const id of batch) remaining.delete(id)
|
|
291
|
+
indexed += batch.length
|
|
292
|
+
report(false)
|
|
293
|
+
await nextTick()
|
|
231
294
|
}
|
|
295
|
+
} catch {
|
|
296
|
+
// local store unavailable mid-walk; fall back to the remote path below
|
|
232
297
|
} finally {
|
|
233
298
|
db.close()
|
|
234
299
|
}
|
|
235
|
-
} catch {
|
|
236
|
-
await buildSearchIndexRemote(api, targets, index)
|
|
237
300
|
}
|
|
301
|
+
|
|
302
|
+
if (remaining.size > 0) {
|
|
303
|
+
await buildSearchIndexRemote(api, [...remaining], index, () => {
|
|
304
|
+
indexed += 1
|
|
305
|
+
report(false)
|
|
306
|
+
})
|
|
307
|
+
}
|
|
308
|
+
report(true)
|
|
238
309
|
return index
|
|
239
310
|
}
|
|
240
311
|
|
|
241
312
|
async function buildSearchIndexRemote(
|
|
242
313
|
api: TuiPluginApi,
|
|
243
|
-
|
|
314
|
+
ids: string[],
|
|
244
315
|
index: Map<string, string>,
|
|
316
|
+
onEach: () => void,
|
|
245
317
|
): Promise<void> {
|
|
246
318
|
const home = process.env.HOME ?? ""
|
|
247
319
|
const cacheDir = process.env.SESH_CACHE_DIR ?? `${home}/.cache/sesh`
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
320
|
+
let cursor = 0
|
|
321
|
+
// Bounded concurrency: a fixed worker pool instead of one request per session.
|
|
322
|
+
const worker = async () => {
|
|
323
|
+
for (;;) {
|
|
324
|
+
const i = cursor++
|
|
325
|
+
if (i >= ids.length) return
|
|
326
|
+
const id = ids[i]
|
|
327
|
+
const base = index.get(id) ?? ""
|
|
252
328
|
try {
|
|
253
|
-
const raw = await readFile(`${cacheDir}/extractions/${
|
|
329
|
+
const raw = await readFile(`${cacheDir}/extractions/${id}.json`, "utf8")
|
|
254
330
|
const record = JSON.parse(raw) as { fulltextLower?: unknown }
|
|
255
331
|
const fulltext = typeof record?.fulltextLower === "string" ? record.fulltextLower : ""
|
|
256
|
-
index.set(
|
|
332
|
+
index.set(id, `${base} ${fulltext}`.trim())
|
|
257
333
|
} catch {
|
|
258
334
|
try {
|
|
259
|
-
const result = await api.client.session.messages({ sessionID:
|
|
335
|
+
const result = await api.client.session.messages({ sessionID: id })
|
|
260
336
|
const messages = result.data ?? []
|
|
261
337
|
const text = messages
|
|
262
338
|
.flatMap((message) =>
|
|
@@ -266,11 +342,13 @@ async function buildSearchIndexRemote(
|
|
|
266
342
|
)
|
|
267
343
|
.join(" ")
|
|
268
344
|
.toLowerCase()
|
|
269
|
-
index.set(
|
|
345
|
+
index.set(id, `${base} ${text}`.trim())
|
|
270
346
|
} catch {}
|
|
271
347
|
}
|
|
272
|
-
|
|
273
|
-
|
|
348
|
+
onEach()
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
await Promise.all(Array.from({ length: Math.min(REMOTE_CONCURRENCY, ids.length) }, worker))
|
|
274
352
|
}
|
|
275
353
|
|
|
276
354
|
const TRANSCRIPT_PREVIEW_TURNS = 8
|
|
@@ -374,7 +452,7 @@ function createTranscriptPreview(api: TuiPluginApi) {
|
|
|
374
452
|
|
|
375
453
|
type TreeRow =
|
|
376
454
|
| { kind: "group"; dir: string; label: string; count: number }
|
|
377
|
-
| { kind: "item"; entry: Entry }
|
|
455
|
+
| { kind: "item"; entry: Entry; last: boolean }
|
|
378
456
|
|
|
379
457
|
function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
380
458
|
const theme = () => props.api.theme.current
|
|
@@ -397,7 +475,7 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
397
475
|
let alive = true
|
|
398
476
|
const load = async () => {
|
|
399
477
|
try {
|
|
400
|
-
const all = await fetchEntries(props.api)
|
|
478
|
+
const { entries: all } = await fetchEntries(props.api)
|
|
401
479
|
if (alive) setEntries(all)
|
|
402
480
|
} catch {
|
|
403
481
|
// leave the previous list in place rather than flashing to blank
|
|
@@ -433,7 +511,9 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
433
511
|
for (const group of ordered) {
|
|
434
512
|
rows.push({ kind: "group", dir: group.dir, label: prettyDir(group.dir, home), count: group.list.length })
|
|
435
513
|
if (collapsed()[group.dir]) continue
|
|
436
|
-
for (
|
|
514
|
+
for (let i = 0; i < group.list.length; i++) {
|
|
515
|
+
rows.push({ kind: "item", entry: group.list[i], last: i === group.list.length - 1 })
|
|
516
|
+
}
|
|
437
517
|
}
|
|
438
518
|
return rows
|
|
439
519
|
})
|
|
@@ -469,7 +549,7 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
469
549
|
directory: entry.dir || undefined,
|
|
470
550
|
})
|
|
471
551
|
if (result && "error" in result && result.error) throw result.error
|
|
472
|
-
const remaining = await fetchEntries(props.api)
|
|
552
|
+
const { entries: remaining } = await fetchEntries(props.api)
|
|
473
553
|
if (remaining.some((item) => item.id === entry.id)) {
|
|
474
554
|
setEntries(remaining)
|
|
475
555
|
props.api.ui.toast({ message: "Session was not deleted", variant: "error" })
|
|
@@ -605,10 +685,10 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
605
685
|
<For each={tree()}>
|
|
606
686
|
{(row) =>
|
|
607
687
|
row.kind === "group" ? (
|
|
608
|
-
<box flexDirection="row" onMouseDown={() => toggleGroup(row.dir)}>
|
|
688
|
+
<box flexDirection="row" paddingTop={1} onMouseDown={() => toggleGroup(row.dir)}>
|
|
609
689
|
<text flexGrow={1} flexShrink={1} wrapMode="none">
|
|
610
690
|
<span style={{ fg: theme().textMuted }}>{collapsed()[row.dir] ? "▸ " : "▾ "}</span>
|
|
611
|
-
<b>{row.label}</b>
|
|
691
|
+
<b>{truncate(row.label, SIDEBAR_GROUP_WIDTH)}</b>
|
|
612
692
|
</text>
|
|
613
693
|
<text flexShrink={0} style={{ fg: theme().textMuted }}>
|
|
614
694
|
{" "}({row.count})
|
|
@@ -629,14 +709,16 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
|
|
|
629
709
|
onMouseOut={() => setHovered(undefined)}
|
|
630
710
|
onMouseDown={() => props.api.route.navigate("session", { sessionID: row.entry.id })}
|
|
631
711
|
>
|
|
632
|
-
<text
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
712
|
+
<text flexShrink={0}>
|
|
713
|
+
<span style={{ fg: theme().textMuted }}>{row.last ? "└" : "├"}</span>
|
|
714
|
+
<span
|
|
715
|
+
style={{ fg: row.entry.id === currentID() ? theme().accent : statusColor(row.entry.id) }}
|
|
716
|
+
>
|
|
717
|
+
{row.entry.id === currentID() ? "●" : "○"}
|
|
718
|
+
</span>
|
|
637
719
|
</text>
|
|
638
720
|
<text flexGrow={1} flexShrink={1} overflow="hidden" wrapMode="none" style={{ fg: theme().text }}>
|
|
639
|
-
{row.entry.title}
|
|
721
|
+
{truncate(row.entry.title, SIDEBAR_TITLE_WIDTH)}
|
|
640
722
|
</text>
|
|
641
723
|
<text flexShrink={0} style={{ fg: theme().textMuted }}>
|
|
642
724
|
{ago(row.entry.updated)}
|
|
@@ -723,7 +805,7 @@ function HomeSessions(props: { api: TuiPluginApi }) {
|
|
|
723
805
|
let alive = true
|
|
724
806
|
const load = async () => {
|
|
725
807
|
try {
|
|
726
|
-
const all = await fetchEntries(props.api)
|
|
808
|
+
const { entries: all } = await fetchEntries(props.api)
|
|
727
809
|
if (alive) setEntries(all)
|
|
728
810
|
} catch {}
|
|
729
811
|
}
|
|
@@ -794,7 +876,7 @@ function HomeSessions(props: { api: TuiPluginApi }) {
|
|
|
794
876
|
○
|
|
795
877
|
</text>
|
|
796
878
|
<text flexGrow={1} flexShrink={1} overflow="hidden" wrapMode="none" style={{ fg: theme().text }}>
|
|
797
|
-
{truncate(entry.title,
|
|
879
|
+
{truncate(entry.title, HOME_TITLE_WIDTH)}
|
|
798
880
|
</text>
|
|
799
881
|
<text flexShrink={0} style={{ fg: theme().textMuted }}>
|
|
800
882
|
{prettyDir(entry.dir, home)} · {ago(entry.updated)}
|
|
@@ -827,13 +909,14 @@ const tui: TuiPlugin = async (api) => {
|
|
|
827
909
|
if (api.mode.current() !== BASE_MODE) return
|
|
828
910
|
if (api.renderer.currentFocusedEditor === null) return
|
|
829
911
|
|
|
830
|
-
let
|
|
912
|
+
let entriesResult: EntryResult
|
|
831
913
|
try {
|
|
832
|
-
|
|
914
|
+
entriesResult = await fetchEntries(api)
|
|
833
915
|
} catch {
|
|
834
916
|
api.ui.toast({ message: "Could not load sessions", variant: "error" })
|
|
835
917
|
return
|
|
836
918
|
}
|
|
919
|
+
const entries = entriesResult.entries
|
|
837
920
|
if (entries.length === 0) {
|
|
838
921
|
api.ui.toast({ message: "No sessions found", variant: "warning" })
|
|
839
922
|
return
|
|
@@ -844,7 +927,12 @@ const tui: TuiPlugin = async (api) => {
|
|
|
844
927
|
const [query, setQuery] = createSignal("")
|
|
845
928
|
const [cursor, setCursor] = createSignal(0)
|
|
846
929
|
const [searchIndex, setSearchIndex] = createSignal<Map<string, string>>(new Map())
|
|
847
|
-
|
|
930
|
+
const [indexProgress, setIndexProgress] = createSignal<IndexProgress>({
|
|
931
|
+
indexed: 0,
|
|
932
|
+
total: 0,
|
|
933
|
+
complete: false,
|
|
934
|
+
})
|
|
935
|
+
void buildSearchIndex(api, entries, setIndexProgress).then(setSearchIndex)
|
|
848
936
|
let pickerInput: { value: string; isDestroyed?: boolean } | undefined
|
|
849
937
|
|
|
850
938
|
type DialogRow = { kind: "group"; label: string; count: number } | { kind: "item"; entry: Entry }
|
|
@@ -868,6 +956,22 @@ const tui: TuiPlugin = async (api) => {
|
|
|
868
956
|
|
|
869
957
|
const selectableEntries = createMemo(() => matchedGroups().flatMap((g) => g.list))
|
|
870
958
|
|
|
959
|
+
// Visible bounding: say how much transcript text search actually covers and
|
|
960
|
+
// when the metadata walk hit its cap, instead of silently under-reporting.
|
|
961
|
+
const coverageLabel = createMemo(() => {
|
|
962
|
+
const progress = indexProgress()
|
|
963
|
+
const bits: string[] = []
|
|
964
|
+
if (progress.total > 0) {
|
|
965
|
+
bits.push(
|
|
966
|
+
progress.complete
|
|
967
|
+
? `transcripts ${progress.total}`
|
|
968
|
+
: `indexing transcripts ${progress.indexed}/${progress.total}`,
|
|
969
|
+
)
|
|
970
|
+
}
|
|
971
|
+
if (entriesResult.truncated) bits.push(`showing newest ${entries.length}`)
|
|
972
|
+
return bits.join(" · ")
|
|
973
|
+
})
|
|
974
|
+
|
|
871
975
|
createEffect(() => {
|
|
872
976
|
const count = selectableEntries().length
|
|
873
977
|
if (cursor() >= count) setCursor(Math.max(0, count - 1))
|
|
@@ -939,7 +1043,8 @@ const tui: TuiPlugin = async (api) => {
|
|
|
939
1043
|
setPreviewText(cached)
|
|
940
1044
|
return
|
|
941
1045
|
}
|
|
942
|
-
|
|
1046
|
+
// Keep the previous transcript visible while the next one loads so the
|
|
1047
|
+
// preview does not blink away on every cursor move.
|
|
943
1048
|
previewTimer = setTimeout(() => void loadPreview(id), 120)
|
|
944
1049
|
})
|
|
945
1050
|
|
|
@@ -1041,6 +1146,11 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1041
1146
|
}}
|
|
1042
1147
|
/>
|
|
1043
1148
|
</box>
|
|
1149
|
+
<Show when={coverageLabel()}>
|
|
1150
|
+
<box paddingTop={1}>
|
|
1151
|
+
<text style={{ fg: api.theme.current.textMuted }}>{coverageLabel()}</text>
|
|
1152
|
+
</box>
|
|
1153
|
+
</Show>
|
|
1044
1154
|
</box>
|
|
1045
1155
|
<box
|
|
1046
1156
|
flexDirection="column"
|
|
@@ -1069,10 +1179,6 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1069
1179
|
? api.theme.current.primary
|
|
1070
1180
|
: RGBA.fromInts(0, 0, 0, 0)
|
|
1071
1181
|
}
|
|
1072
|
-
onMouseOver={() => {
|
|
1073
|
-
const i = selectableEntries().findIndex((entry) => entry.id === row.entry.id)
|
|
1074
|
-
if (i >= 0 && i !== cursor()) setCursor(i)
|
|
1075
|
-
}}
|
|
1076
1182
|
onMouseDown={() => choose(row.entry)}
|
|
1077
1183
|
>
|
|
1078
1184
|
<Show when={row.entry.id === currentSessionID()}>
|