opencode-sesh 0.1.5 → 0.1.6

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,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.6] - 2026-09-13
11
+
12
+ ### Changed
13
+
14
+ - The TUI picker pages through the entire global session list instead of a
15
+ fixed 500-session window, indexes transcript text for every session (not just
16
+ the newest 150) in batches, bounds remote transcript fetching, and shows
17
+ indexing progress/coverage in the search header.
18
+ - Transcript extraction reads parts in batches and assembles all records in a
19
+ single `jq` pass. A 250-session cold index dropped from about 28 s to about
20
+ 7 s locally (warm ~0.3 s).
21
+
22
+ ### Added
23
+
24
+ - TUI data-layer contract tests (pagination, full index coverage, bounded
25
+ concurrency, progress, and the truncated cap), run on Node 24 in CI.
26
+
10
27
  ## [0.1.5] - 2026-09-13
11
28
 
12
29
  ### Fixed
@@ -102,7 +119,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
102
119
  - Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
103
120
  CI.
104
121
 
105
- [Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.5...HEAD
122
+ [Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.6...HEAD
123
+ [0.1.6]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.5...v0.1.6
106
124
  [0.1.5]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.4...v0.1.5
107
125
  [0.1.4]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.3...v0.1.4
108
126
  [0.1.3]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.2...v0.1.3
package/README.md CHANGED
@@ -137,6 +137,11 @@ read -r id cwd < <(sesh --print --query "auth")
137
137
  Click the sidebar's `search…` box to filter recent sessions in place; click
138
138
  elsewhere or press Esc to leave search.
139
139
 
140
+ The full picker pages through your whole global session list (no fixed window)
141
+ and indexes transcript text for every session in the background, showing
142
+ indexing progress under the search box. It only stops at a safety cap of 5,000
143
+ sessions, and says so when it does.
144
+
140
145
  ## Configuration
141
146
 
142
147
  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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-sesh",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "description": "Browse, search, preview and resume opencode sessions from the terminal and the TUI.",
6
6
  "license": "MIT",
@@ -159,20 +159,47 @@ function ensureRootMouse(renderer: TuiPluginApi["renderer"]) {
159
159
  }
160
160
 
161
161
  type Entry = { id: string; title: string; dir: string; group: string; updated: number }
162
+ type EntryResult = { entries: Entry[]; truncated: boolean }
162
163
 
163
- async function fetchEntries(api: TuiPluginApi): Promise<Entry[]> {
164
+ const SESSION_PAGE_LIMIT = 200
165
+ const SESSION_MAX = 5000
166
+
167
+ async function fetchEntries(api: TuiPluginApi): Promise<EntryResult> {
164
168
  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[]) ?? []
169
+ const projectResult = await api.client.project.list({})
170
170
  const projects = ((projectResult?.data ?? projectResult) as Project[]) ?? []
171
- if (!Array.isArray(sessions)) return []
172
171
  const projectName = new Map<string, string>(
173
172
  projects.map((p) => [p.id, p.name?.trim() || shortDir(p.worktree ?? "", home)]),
174
173
  )
175
- return sessions
174
+ const sessions: Session[] = []
175
+ let cursor: number | undefined
176
+ let truncated = false
177
+ // Page through the global list instead of one fixed window, so sessions in
178
+ // older directories stay searchable. `cursor` is the previous page's oldest
179
+ // `time.updated`; a repeated or missing cursor stops the walk.
180
+ for (;;) {
181
+ const result = await api.client.experimental.session.list({
182
+ limit: SESSION_PAGE_LIMIT,
183
+ roots: true,
184
+ directory: "",
185
+ ...(cursor === undefined ? {} : { cursor }),
186
+ })
187
+ const page = ((result?.data ?? result) as Session[]) ?? []
188
+ if (!Array.isArray(page) || page.length === 0) break
189
+ sessions.push(...page)
190
+ if (page.length < SESSION_PAGE_LIMIT) break
191
+ if (sessions.length >= SESSION_MAX) {
192
+ truncated = true
193
+ break
194
+ }
195
+ const next = page[page.length - 1]?.time?.updated
196
+ if (typeof next !== "number" || next === cursor) {
197
+ truncated = true
198
+ break
199
+ }
200
+ cursor = next
201
+ }
202
+ const entries = sessions
176
203
  .filter((s) => !s.time?.archived && !s.parentID)
177
204
  .sort((a, b) => b.time.updated - a.time.updated)
178
205
  .map((s) => {
@@ -187,76 +214,118 @@ async function fetchEntries(api: TuiPluginApi): Promise<Entry[]> {
187
214
  updated: s.time.updated,
188
215
  }
189
216
  })
217
+ return { entries, truncated }
190
218
  }
191
219
 
192
- const TRANSCRIPT_INDEX_LIMIT = 150
193
220
  const SESSION_ID_PATTERN = /^ses_[A-Za-z0-9]+$/
221
+ const TRANSCRIPT_BATCH = 64
222
+ const REMOTE_CONCURRENCY = 8
194
223
 
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
224
+ type IndexProgress = { indexed: number; total: number; complete: boolean }
225
+
226
+ const nextTick = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
227
+
228
+ async function openTranscriptDb(): Promise<any | undefined> {
203
229
  try {
204
230
  const home = process.env.HOME ?? ""
205
231
  const dataHome = process.env.XDG_DATA_HOME ?? `${home}/.local/share`
206
232
  // @ts-ignore - bun:sqlite ships with the Bun runtime (verified); no type package installed
207
233
  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 })
234
+ if (typeof sqlite?.Database !== "function") return undefined
235
+ return new sqlite.Database(`${dataHome}/opencode/opencode.db`, { readonly: true })
236
+ } catch {
237
+ return undefined
238
+ }
239
+ }
240
+
241
+ function addTranscript(index: Map<string, string>, sid: string, data: string): void {
242
+ try {
243
+ const part = JSON.parse(data) as { type?: unknown; text?: unknown }
244
+ if (part?.type !== "text" || typeof part.text !== "string") return
245
+ const base = index.get(sid) ?? ""
246
+ index.set(sid, `${base} ${part.text.toLowerCase()}`.trim())
247
+ } catch {
248
+ // ignore malformed parts
249
+ }
250
+ }
251
+
252
+ // Index titles/directories immediately, then add transcript text progressively
253
+ // in batches so the render thread can breathe. Covers every entry, not a fixed
254
+ // newest window; `onProgress` lets the picker show coverage.
255
+ async function buildSearchIndex(
256
+ api: TuiPluginApi,
257
+ entries: Entry[],
258
+ onProgress?: (progress: IndexProgress) => void,
259
+ ): Promise<Map<string, string>> {
260
+ const index = new Map<string, string>()
261
+ for (const entry of entries) {
262
+ index.set(entry.id, `${entry.title.toLowerCase()} ${entry.dir.toLowerCase()}`)
263
+ }
264
+ const ids = entries.map((entry) => entry.id).filter((id) => SESSION_ID_PATTERN.test(id))
265
+ const remaining = new Set(ids)
266
+ const total = ids.length
267
+ let indexed = 0
268
+ const report = (complete: boolean) => onProgress?.({ indexed, total, complete })
269
+ report(false)
270
+
271
+ const db = await openTranscriptDb()
272
+ if (db) {
210
273
  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}`)
274
+ for (let i = 0; i < ids.length; i += TRANSCRIPT_BATCH) {
275
+ const batch = ids.slice(i, i + TRANSCRIPT_BATCH)
276
+ const placeholders = batch.map(() => "?").join(",")
277
+ const rows = (db
278
+ .query(
279
+ `SELECT session_id AS sid, data FROM part WHERE session_id IN (${placeholders}) AND json_extract(data, '$.type') = 'text' ORDER BY time_created`,
280
+ )
281
+ .all(...batch) ?? []) as { sid: string; data: string }[]
282
+ for (const row of rows) addTranscript(index, row.sid, row.data)
283
+ for (const id of batch) remaining.delete(id)
284
+ indexed += batch.length
285
+ report(false)
286
+ await nextTick()
231
287
  }
288
+ } catch {
289
+ // local store unavailable mid-walk; fall back to the remote path below
232
290
  } finally {
233
291
  db.close()
234
292
  }
235
- } catch {
236
- await buildSearchIndexRemote(api, targets, index)
237
293
  }
294
+
295
+ if (remaining.size > 0) {
296
+ await buildSearchIndexRemote(api, [...remaining], index, () => {
297
+ indexed += 1
298
+ report(false)
299
+ })
300
+ }
301
+ report(true)
238
302
  return index
239
303
  }
240
304
 
241
305
  async function buildSearchIndexRemote(
242
306
  api: TuiPluginApi,
243
- targets: Entry[],
307
+ ids: string[],
244
308
  index: Map<string, string>,
309
+ onEach: () => void,
245
310
  ): Promise<void> {
246
311
  const home = process.env.HOME ?? ""
247
312
  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
313
+ let cursor = 0
314
+ // Bounded concurrency: a fixed worker pool instead of one request per session.
315
+ const worker = async () => {
316
+ for (;;) {
317
+ const i = cursor++
318
+ if (i >= ids.length) return
319
+ const id = ids[i]
320
+ const base = index.get(id) ?? ""
252
321
  try {
253
- const raw = await readFile(`${cacheDir}/extractions/${entry.id}.json`, "utf8")
322
+ const raw = await readFile(`${cacheDir}/extractions/${id}.json`, "utf8")
254
323
  const record = JSON.parse(raw) as { fulltextLower?: unknown }
255
324
  const fulltext = typeof record?.fulltextLower === "string" ? record.fulltextLower : ""
256
- index.set(entry.id, `${base} ${fulltext}`)
325
+ index.set(id, `${base} ${fulltext}`.trim())
257
326
  } catch {
258
327
  try {
259
- const result = await api.client.session.messages({ sessionID: entry.id })
328
+ const result = await api.client.session.messages({ sessionID: id })
260
329
  const messages = result.data ?? []
261
330
  const text = messages
262
331
  .flatMap((message) =>
@@ -266,11 +335,13 @@ async function buildSearchIndexRemote(
266
335
  )
267
336
  .join(" ")
268
337
  .toLowerCase()
269
- index.set(entry.id, `${base} ${text}`)
338
+ index.set(id, `${base} ${text}`.trim())
270
339
  } catch {}
271
340
  }
272
- }),
273
- )
341
+ onEach()
342
+ }
343
+ }
344
+ await Promise.all(Array.from({ length: Math.min(REMOTE_CONCURRENCY, ids.length) }, worker))
274
345
  }
275
346
 
276
347
  const TRANSCRIPT_PREVIEW_TURNS = 8
@@ -397,7 +468,7 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
397
468
  let alive = true
398
469
  const load = async () => {
399
470
  try {
400
- const all = await fetchEntries(props.api)
471
+ const { entries: all } = await fetchEntries(props.api)
401
472
  if (alive) setEntries(all)
402
473
  } catch {
403
474
  // leave the previous list in place rather than flashing to blank
@@ -469,7 +540,7 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
469
540
  directory: entry.dir || undefined,
470
541
  })
471
542
  if (result && "error" in result && result.error) throw result.error
472
- const remaining = await fetchEntries(props.api)
543
+ const { entries: remaining } = await fetchEntries(props.api)
473
544
  if (remaining.some((item) => item.id === entry.id)) {
474
545
  setEntries(remaining)
475
546
  props.api.ui.toast({ message: "Session was not deleted", variant: "error" })
@@ -723,7 +794,7 @@ function HomeSessions(props: { api: TuiPluginApi }) {
723
794
  let alive = true
724
795
  const load = async () => {
725
796
  try {
726
- const all = await fetchEntries(props.api)
797
+ const { entries: all } = await fetchEntries(props.api)
727
798
  if (alive) setEntries(all)
728
799
  } catch {}
729
800
  }
@@ -827,13 +898,14 @@ const tui: TuiPlugin = async (api) => {
827
898
  if (api.mode.current() !== BASE_MODE) return
828
899
  if (api.renderer.currentFocusedEditor === null) return
829
900
 
830
- let entries: Entry[]
901
+ let entriesResult: EntryResult
831
902
  try {
832
- entries = await fetchEntries(api)
903
+ entriesResult = await fetchEntries(api)
833
904
  } catch {
834
905
  api.ui.toast({ message: "Could not load sessions", variant: "error" })
835
906
  return
836
907
  }
908
+ const entries = entriesResult.entries
837
909
  if (entries.length === 0) {
838
910
  api.ui.toast({ message: "No sessions found", variant: "warning" })
839
911
  return
@@ -844,7 +916,12 @@ const tui: TuiPlugin = async (api) => {
844
916
  const [query, setQuery] = createSignal("")
845
917
  const [cursor, setCursor] = createSignal(0)
846
918
  const [searchIndex, setSearchIndex] = createSignal<Map<string, string>>(new Map())
847
- void buildSearchIndex(api, entries).then(setSearchIndex)
919
+ const [indexProgress, setIndexProgress] = createSignal<IndexProgress>({
920
+ indexed: 0,
921
+ total: 0,
922
+ complete: false,
923
+ })
924
+ void buildSearchIndex(api, entries, setIndexProgress).then(setSearchIndex)
848
925
  let pickerInput: { value: string; isDestroyed?: boolean } | undefined
849
926
 
850
927
  type DialogRow = { kind: "group"; label: string; count: number } | { kind: "item"; entry: Entry }
@@ -868,6 +945,22 @@ const tui: TuiPlugin = async (api) => {
868
945
 
869
946
  const selectableEntries = createMemo(() => matchedGroups().flatMap((g) => g.list))
870
947
 
948
+ // Visible bounding: say how much transcript text search actually covers and
949
+ // when the metadata walk hit its cap, instead of silently under-reporting.
950
+ const coverageLabel = createMemo(() => {
951
+ const progress = indexProgress()
952
+ const bits: string[] = []
953
+ if (progress.total > 0) {
954
+ bits.push(
955
+ progress.complete
956
+ ? `transcripts ${progress.total}`
957
+ : `indexing transcripts ${progress.indexed}/${progress.total}`,
958
+ )
959
+ }
960
+ if (entriesResult.truncated) bits.push(`showing newest ${entries.length}`)
961
+ return bits.join(" · ")
962
+ })
963
+
871
964
  createEffect(() => {
872
965
  const count = selectableEntries().length
873
966
  if (cursor() >= count) setCursor(Math.max(0, count - 1))
@@ -1041,6 +1134,11 @@ const tui: TuiPlugin = async (api) => {
1041
1134
  }}
1042
1135
  />
1043
1136
  </box>
1137
+ <Show when={coverageLabel()}>
1138
+ <box paddingTop={1}>
1139
+ <text style={{ fg: api.theme.current.textMuted }}>{coverageLabel()}</text>
1140
+ </box>
1141
+ </Show>
1044
1142
  </box>
1045
1143
  <box
1046
1144
  flexDirection="column"