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 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.5...HEAD
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
- Restart opencode to load the panel, then:
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 dir title created updated partCount partMax raw_file text_file record cache
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
- dir=$(printf '%s' "$row" | "$JQ_BIN" -r '.directory // ""')
270
- title=$(printf '%s' "$row" | "$JQ_BIN" -r '.title // ""')
271
- created=$(printf '%s' "$row" | "$JQ_BIN" -r '.time_created // 0')
272
- updated=$(printf '%s' "$row" | "$JQ_BIN" -r '.time_updated // 0')
273
- partCount=$(printf '%s' "$row" | "$JQ_BIN" -r '.partCount // 0')
274
- partMax=$(printf '%s' "$row" | "$JQ_BIN" -r '.partMax // 0')
275
- cache="$EXTRACTIONS/$sid.json"
276
- raw_file="$scratch/$sid.parts.json"
277
- text_file="$scratch/$sid.text"
278
- # Text parts only: reasoning blobs and tool payloads would bloat the search
279
- # index. The transcript travels through files, never argv, so a large
280
- # conversation cannot hit ARG_MAX; a failure leaves the session uncached so
281
- # the next refresh retries instead of certifying it fresh.
282
- if db_json "SELECT data FROM part WHERE session_id = '$sid' AND json_extract(data, '\$.type') = 'text' ORDER BY time_created;" > "$raw_file" 2>/dev/null \
283
- && "$JQ_BIN" -r '[.[]? | (.data? // empty) | (try fromjson catch empty) | select(type == "object" and .type == "text") | .text // empty] | join(" ")' "$raw_file" > "$text_file" 2>/dev/null; then
284
- :
285
- else
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
- continue
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
- record=$("$JQ_BIN" -cn --arg sid "$sid" --arg cwd "$dir" --arg title "$title" \
294
- --argjson created "$created" --argjson updated "$updated" --argjson parts "$partCount" --argjson maxpart "$partMax" \
295
- --rawfile text "$text_file" '
296
- {cacheSchema: 2, cacheSession: $sid, cacheUpdated: $updated, cacheParts: $parts, cacheMax: $maxpart,
297
- sessionId: $sid, transcriptPath: "", source: "opencode",
298
- cwd: $cwd, title: ($title | gsub("[\r\n]+"; " ") | if length > 70 then .[0:67] + "..." else . end),
299
- updatedEpoch: (($updated / 1000 | floor) | if . > 0 then . else ($created / 1000 | floor) end),
300
- fulltextLower: (($title + " " + $text) | sub("\n$"; "") | ascii_downcase)}') || record=''
301
- if [ -n "$record" ]; then
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" "$cache"
324
+ mv "$cache_tmp" "$cache_path"
305
325
  printf '%s\n' "$record" >> "$historical"
306
- else
307
- extract_failed=1
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
- # Earlier versions shipped a markdown `/sesh` command that could only prompt the
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
- # opencode has no persistent-sidebar API: the panel is an xlarge modal
201
- # dialog (see tui/sesh-panel.tsx). Register it idempotently and drop the
202
- # previous filename if an older install left it behind.
203
- tui_created=0
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: restart opencode to load the panel (ctrl+o opens the full picker)"
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.5",
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",
@@ -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<Entry[]> {
174
+ async function fetchEntries(api: TuiPluginApi): Promise<EntryResult> {
164
175
  const home = process.env.HOME ?? ""
165
- const [sessionResult, projectResult] = await Promise.all([
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
- return sessions
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
- async function buildSearchIndex(api: TuiPluginApi, entries: Entry[]): Promise<Map<string, string>> {
196
- const index = new Map<string, string>()
197
- const targets = entries.slice(0, TRANSCRIPT_INDEX_LIMIT)
198
- for (const entry of targets) {
199
- index.set(entry.id, `${entry.title.toLowerCase()} ${entry.dir.toLowerCase()}`)
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") throw new Error("bun:sqlite unavailable")
209
- const db = new sqlite.Database(`${dataHome}/opencode/opencode.db`, { readonly: true })
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
- const placeholders = ids.map(() => "?").join(",")
212
- const rows = (db
213
- .query(
214
- `SELECT session_id AS sid, data FROM part WHERE session_id IN (${placeholders}) AND json_extract(data, '$.type') = 'text' ORDER BY time_created`,
215
- )
216
- .all(...ids) ?? []) as { sid: string; data: string }[]
217
- const byId = new Map<string, string[]>()
218
- for (const row of rows) {
219
- try {
220
- const part = JSON.parse(row.data) as { type?: unknown; text?: unknown }
221
- if (part?.type === "text" && typeof part.text === "string") {
222
- const list = byId.get(row.sid) ?? []
223
- list.push(part.text)
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
- targets: Entry[],
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
- await Promise.all(
249
- targets.map(async (entry) => {
250
- const base = index.get(entry.id) ?? ""
251
- if (!SESSION_ID_PATTERN.test(entry.id)) return
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/${entry.id}.json`, "utf8")
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(entry.id, `${base} ${fulltext}`)
332
+ index.set(id, `${base} ${fulltext}`.trim())
257
333
  } catch {
258
334
  try {
259
- const result = await api.client.session.messages({ sessionID: entry.id })
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(entry.id, `${base} ${text}`)
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 (const entry of group.list) rows.push({ kind: "item", entry })
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
- flexShrink={0}
634
- style={{ fg: row.entry.id === currentID() ? theme().accent : statusColor(row.entry.id) }}
635
- >
636
- {row.entry.id === currentID() ? "●" : "○"}
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, 52)}
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 entries: Entry[]
912
+ let entriesResult: EntryResult
831
913
  try {
832
- entries = await fetchEntries(api)
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
- void buildSearchIndex(api, entries).then(setSearchIndex)
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
- setPreviewText("")
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()}>