opencode-sesh 0.7.3 → 0.9.0

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
@@ -139,8 +139,10 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
139
139
  ## TUI panel (`tui/sesh-panel.tsx`)
140
140
 
141
141
  - **Sidebar:** `api.slots.register` on `sidebar_content` (append mode — native
142
- sidebar content stays). Renders pinned sessions before the newest unarchived
143
- sessions, with the current one highlighted, polled every 15 s; Ctrl-P previews
142
+ sidebar content stays). Pinned directories sort first, then directory groups
143
+ by recency; sessions within each group sort by last update regardless of pins.
144
+ Waiting, running, and current-idle sessions have distinct title colors and
145
+ text labels (in addition to row markers), polled every 15 s; Ctrl-P previews
144
146
  a row and ctrl+x deletes one (armed, then confirmed). The section lists every
145
147
  session without a row cap and flex-grows to fill the sidebar, with overflow
146
148
  scrolling inside; the picker remains the place for transcript search.
@@ -158,7 +160,13 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
158
160
  at `SESSION_MAX` and reported when truncated) instead of a fixed 500-row
159
161
  window, then indexes transcript text for **every** session in batches
160
162
  (`TRANSCRIPT_BATCH`) with bounded remote concurrency (`REMOTE_CONCURRENCY`).
161
- The header shows indexing progress/coverage. `tests/tui.mjs` locks this.
163
+ The header shows indexing progress/coverage. Visible transcript hits get
164
+ short excerpts from the text-only index (never tool or reasoning payloads).
165
+ Project, needs-input (Option-W), and pinned-session (Option-P) filters compose
166
+ with the query; the picker remembers query/scope/filters until plugin restart.
167
+ `/sesh-needs` uses the shared needs-input set plus the oldest unresolved
168
+ question or running-tool timestamp, lists longest-waiting first, and `n`
169
+ opens the next waiting session. `tests/tui.mjs` locks the data layer.
162
170
  - **Durable install:** opencode imports plugins once at startup and never hot
163
171
  reloads, so panel changes need a full quit/reopen. `install.sh --sync-panel`
164
172
  (run by the `postinstall`) refreshes an already-installed panel and is a
package/CHANGELOG.md CHANGED
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.0] - 2026-09-24
11
+
12
+ ### Added
13
+
14
+ - The TUI picker shows a highlighted excerpt for matching transcript text,
15
+ offers composable project, needs-input and pinned-session filters, and keeps
16
+ the query and filters when reopened during the same opencode run.
17
+ - `/sesh-needs` shows how long each session has waited, puts the longest-waiting
18
+ first, and can open the next waiting session with `n`.
19
+ ## [0.8.0] - 2026-09-24
20
+
21
+ ### Changed
22
+
23
+ - Sidebar and picker sessions within each directory now sort by last update,
24
+ newest first, even when a session is pinned. Sidebar titles and labels make
25
+ waiting, running, and currently open idle sessions distinguishable at a glance.
10
26
  ## [0.7.3] - 2026-09-24
11
27
 
12
28
  ### Removed
@@ -326,7 +342,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
326
342
  - Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
327
343
  CI.
328
344
 
329
- [Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.7.3...HEAD
345
+ [Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.9.0...HEAD
346
+ [0.9.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.8.0...v0.9.0
347
+ [0.8.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.7.3...v0.8.0
330
348
  [0.7.3]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.7.2...v0.7.3
331
349
  [0.7.2]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.7.1...v0.7.2
332
350
  [0.7.1]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.7.0...v0.7.1
package/README.md CHANGED
@@ -175,6 +175,7 @@ non-interactive `sesh-delete.sh` refuses unless given `--yes`.
175
175
  | Ctrl-X | Delete the selected session (asks to confirm) |
176
176
  | Ctrl-F | Fork the selected session |
177
177
  | Ctrl-G | Toggle scope: the selected session's project, or every project |
178
+ | Option-W / Option-P | Show only sessions needing input / pinned sessions |
178
179
  | Option-S / Option-D | Pin or unpin the selected session / directory |
179
180
  | Esc | Close |
180
181
 
@@ -182,11 +183,14 @@ Click the sidebar's `search…` box to filter recent sessions in place; click
182
183
  elsewhere or press Esc to leave search. Click the **Sessions** heading to drive
183
184
  the list from the keyboard instead of the mouse (`↑`/`↓` to move, Enter to open,
184
185
  Ctrl-P to preview, Option-S/Option-D to pin, Ctrl-X to delete, Esc to leave). Pinned
185
- sessions appear ahead of the rest; the section lists every session and scrolls
186
- to fill the sidebar, so click **▾ hide** to collapse it when you need the room.
186
+ directories rise first, while sessions within each directory are ordered by
187
+ last update, newest first. The section lists every session and scrolls to fill
188
+ the sidebar, so click **▾ hide** to collapse it when you need the room.
187
189
  Sessions waiting on you (unanswered agent questions, runs stuck mid-tool) pin
188
190
  themselves to a **Needs input** group above the directories, with the count in
189
- the heading; `sesh --needs-input` lists the same set in the terminal.
191
+ the heading; `sesh --needs-input` lists the same set in the terminal. Row titles
192
+ and labels mark waiting (warning), running (success), and the current idle
193
+ session (accent); other sessions stay neutral.
190
194
  Hovering a row while keyboard navigation is active selects it for the next
191
195
  shortcut. Clicking (or Enter on) a session opens it here. The full picker is one
192
196
  keystroke away.
@@ -194,7 +198,12 @@ keystroke away.
194
198
  The full picker pages through your whole global session list (no fixed window)
195
199
  and indexes transcript text for every session in the background, showing
196
200
  indexing progress under the search box. It only stops at a safety cap of 5,000
197
- sessions, and says so when it does.
201
+ sessions, and says so when it does. Transcript matches show a short excerpt
202
+ under the result; the Project, Needs input, and Pinned filters combine with
203
+ search. The picker remembers your query and filters until you restart opencode.
204
+ The Needs input dialog (`/sesh-needs`) shows why and how long each session has
205
+ been waiting, oldest first. Press `n` there to open the next waiting session,
206
+ or reopen it after answering one to continue through the list.
198
207
 
199
208
  ## Configuration
200
209
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-sesh",
3
- "version": "0.7.3",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "description": "Browse, search, preview and resume opencode sessions from the terminal and the TUI.",
6
6
  "license": "MIT",
@@ -248,6 +248,20 @@ function ensureRootMouse(renderer: TuiPluginApi["renderer"]) {
248
248
 
249
249
  type Entry = { id: string; title: string; dir: string; group: string; updated: number }
250
250
  type EntryResult = { entries: Entry[]; truncated: boolean }
251
+ type SidebarActivity = "waiting" | "running" | "active" | "idle"
252
+
253
+ function newestFirst(entries: Entry[]): Entry[] {
254
+ return [...entries].sort((a, b) => b.updated - a.updated || a.id.localeCompare(b.id))
255
+ }
256
+
257
+ // A question needs an answer even if the agent is busy. A stale running-tool
258
+ // record, on the other hand, must not hide a currently busy agent.
259
+ function sidebarActivity(current: boolean, live?: string, pending = false, reason?: string): SidebarActivity {
260
+ if (pending || reason === "question") return "waiting"
261
+ if (live === "busy" || live === "retry") return "running"
262
+ if (reason === "stuck") return "waiting"
263
+ return current ? "active" : "idle"
264
+ }
251
265
 
252
266
  const SESSION_PAGE_LIMIT = 200
253
267
  const SESSION_MAX = 5000
@@ -388,6 +402,43 @@ async function queryWaitingIds(db: any): Promise<Map<string, string>> {
388
402
  return out
389
403
  }
390
404
 
405
+ type WaitingDetail = { reason: "question" | "stuck"; since?: number }
406
+
407
+ // The shared needs-input query decides *which* sessions are waiting. Read the
408
+ // oldest still-unanswered question or stuck tool for each result so triage can
409
+ // show how long it has waited, rather than its last (unrelated) update time.
410
+ async function queryWaitingDetails(db: any): Promise<Map<string, WaitingDetail>> {
411
+ const reasons = await queryWaitingIds(db)
412
+ const details = new Map<string, WaitingDetail>(
413
+ [...reasons].map(([id, reason]) => [id, { reason: reason as WaitingDetail["reason"] }]),
414
+ )
415
+ const ids = [...details.keys()]
416
+ const cutoff = Date.now() - STUCK_AFTER_MS
417
+ for (let i = 0; i < ids.length; i += 200) {
418
+ const batch = ids.slice(i, i + 200)
419
+ const rows = (db.query(`
420
+ SELECT p.session_id AS id,
421
+ MIN(CASE WHEN json_extract(p.data, '$.tool') = 'question'
422
+ AND COALESCE(json_extract(p.data, '$.state.status'), 'pending') != 'completed'
423
+ AND NOT EXISTS (SELECT 1 FROM message m WHERE m.session_id = p.session_id
424
+ AND json_extract(m.data, '$.role') = 'user' AND m.time_created > p.time_created)
425
+ THEN p.time_created END) AS question_since,
426
+ MIN(CASE WHEN json_extract(p.data, '$.state.status') = 'running'
427
+ AND p.time_created < ${cutoff} THEN p.time_created END) AS stuck_since
428
+ FROM part p
429
+ WHERE p.session_id IN (${batch.map(() => "?").join(",")})
430
+ AND json_extract(p.data, '$.type') = 'tool'
431
+ GROUP BY p.session_id`).all(...batch) ?? []) as { id: string; question_since: number | null; stuck_since: number | null }[]
432
+ for (const row of rows) {
433
+ const detail = details.get(row.id)
434
+ if (!detail) continue
435
+ const since = detail.reason === "question" ? row.question_since : row.stuck_since
436
+ if (typeof since === "number") detail.since = since
437
+ }
438
+ }
439
+ return details
440
+ }
441
+
391
442
  function addTranscript(index: Map<string, string>, sid: string, data: string): void {
392
443
  try {
393
444
  const part = JSON.parse(data) as { type?: unknown; text?: unknown }
@@ -470,9 +521,14 @@ async function buildSearchIndexRemote(
470
521
  const base = index.get(id) ?? ""
471
522
  try {
472
523
  const raw = await readFile(`${cacheDir}/extractions/${id}.json`, "utf8")
473
- const record = JSON.parse(raw) as { fulltextLower?: unknown }
524
+ const record = JSON.parse(raw) as { fulltextLower?: unknown; title?: unknown }
474
525
  const fulltext = typeof record?.fulltextLower === "string" ? record.fulltextLower : ""
475
- index.set(id, `${base} ${fulltext}`.trim())
526
+ // Extraction caches prefix the transcript with the cached title. The
527
+ // live entry already indexes its own title; strip that duplicate so a
528
+ // title-only match cannot appear as a transcript excerpt.
529
+ const title = typeof record?.title === "string" ? `${record.title.toLowerCase()} ` : ""
530
+ const transcript = title && fulltext.startsWith(title) ? fulltext.slice(title.length) : fulltext
531
+ index.set(id, `${base} ${transcript}`.trim())
476
532
  } catch {
477
533
  try {
478
534
  const result = await api.client.session.messages({ sessionID: id })
@@ -494,6 +550,36 @@ async function buildSearchIndexRemote(
494
550
  await Promise.all(Array.from({ length: Math.min(REMOTE_CONCURRENCY, ids.length) }, worker))
495
551
  }
496
552
 
553
+ // The index is title + directory + text parts, all lowercased. Start looking
554
+ // after the metadata so a title match never masquerades as a transcript hit.
555
+ function transcriptMatchExcerpt(indexed: string | undefined, entry: Entry, query: string): string | undefined {
556
+ const needle = query.trim().toLowerCase()
557
+ if (!indexed || !needle) return undefined
558
+ const transcriptStart = `${entry.title.toLowerCase()} ${entry.dir.toLowerCase()}`.length + 1
559
+ const at = indexed.indexOf(needle, transcriptStart)
560
+ if (at < 0) return undefined
561
+ const start = Math.max(transcriptStart, at - 28)
562
+ const end = Math.min(indexed.length, Math.max(at + needle.length + 28, start + 90))
563
+ const excerpt = indexed.slice(start, end).replace(/\s+/g, " ").trim()
564
+ return `${start > transcriptStart ? "…" : ""}${excerpt}${end < indexed.length ? "…" : ""}`
565
+ }
566
+
567
+ type PickerFilters = { query: string; scope?: string; waitingOnly: boolean; pinnedOnly: boolean }
568
+
569
+ function filterPickerEntries(
570
+ entries: Entry[], filters: PickerFilters, index: Map<string, string>, waitingIds: Set<string>, pinnedSessions: string[],
571
+ ): Entry[] {
572
+ const q = filters.query.trim().toLowerCase()
573
+ const pinned = new Set(pinnedSessions)
574
+ return entries.filter((entry) => {
575
+ if (filters.scope && entry.group !== filters.scope) return false
576
+ if (filters.waitingOnly && !waitingIds.has(entry.id)) return false
577
+ if (filters.pinnedOnly && !pinned.has(entry.id)) return false
578
+ if (!q) return true
579
+ return (index.get(entry.id) ?? `${entry.title} ${entry.dir}`.toLowerCase()).includes(q)
580
+ })
581
+ }
582
+
497
583
  const TRANSCRIPT_PREVIEW_TURNS = 8
498
584
  const TRANSCRIPT_PREVIEW_CHARS = 2000
499
585
 
@@ -707,7 +793,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
707
793
 
708
794
  const filteredEntries = createMemo(() => {
709
795
  const q = query().trim().toLowerCase()
710
- const ordered = pinnedFirst(entries(), props.pins())
796
+ const ordered = entries()
711
797
  if (!q) return ordered
712
798
  return ordered.filter(
713
799
  (entry) => entry.title.toLowerCase().includes(q) || entry.dir.toLowerCase().includes(q),
@@ -720,9 +806,10 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
720
806
  const NEEDS_INPUT_DIR = "__needs_input__"
721
807
  const waitingEntries = createMemo(() => {
722
808
  const ids = waiting()
723
- return shownEntries().filter((entry) => ids.has(entry.id))
809
+ return newestFirst(shownEntries().filter((entry) => ids.has(entry.id)))
724
810
  })
725
- const waitingReason = (id: string) => (waiting().get(id) === "question" ? "awaiting answer" : "run stuck")
811
+ const waitingReason = (id: string) =>
812
+ waiting().get(id) === "question" ? "awaiting answer" : waiting().get(id) === "stuck" ? "run stuck" : "needs input"
726
813
 
727
814
  const tree = createMemo<TreeRow[]>(() => {
728
815
  const rows: TreeRow[] = []
@@ -751,10 +838,11 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
751
838
  return rank(b) - rank(a) || b.updated - a.updated
752
839
  })
753
840
  for (const group of ordered) {
841
+ const sessions = newestFirst(group.list)
754
842
  rows.push({ kind: "group", dir: group.dir, label: prettyDir(group.dir, home), count: group.list.length })
755
843
  if (collapsed()[group.dir]) continue
756
- for (let i = 0; i < group.list.length; i++) {
757
- rows.push({ kind: "item", entry: group.list[i], last: i === group.list.length - 1 })
844
+ for (let i = 0; i < sessions.length; i++) {
845
+ rows.push({ kind: "item", entry: sessions[i], last: i === sessions.length - 1 })
758
846
  }
759
847
  }
760
848
  return rows
@@ -796,21 +884,24 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
796
884
  previousRows = rows
797
885
  })
798
886
 
799
- const sessionStatus = (id: string): "running" | "waiting" | "idle" => {
887
+ const sessionStatus = (id: string): SidebarActivity => {
800
888
  try {
801
889
  const permissions = props.api.state.session.permission(id)
802
890
  const questions = props.api.state.session.question(id)
803
- if ((permissions?.length ?? 0) > 0 || (questions?.length ?? 0) > 0) return "waiting"
804
- const status = props.api.state.session.status(id)
805
- if (status?.type === "busy" || status?.type === "retry") return "running"
891
+ return sidebarActivity(
892
+ id === currentID(),
893
+ props.api.state.session.status(id)?.type,
894
+ (permissions?.length ?? 0) > 0 || (questions?.length ?? 0) > 0,
895
+ waiting().get(id),
896
+ )
806
897
  } catch {}
807
- return "idle"
898
+ return sidebarActivity(id === currentID(), undefined, false, waiting().get(id))
808
899
  }
809
900
 
810
- const statusColor = (id: string) => {
811
- const status = sessionStatus(id)
901
+ const statusColor = (status: SidebarActivity) => {
812
902
  if (status === "running") return theme().success
813
903
  if (status === "waiting") return theme().warning
904
+ if (status === "active") return theme().accent
814
905
  return theme().textMuted
815
906
  }
816
907
 
@@ -1107,8 +1198,8 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1107
1198
  horizontalScrollbarOptions={{ visible: false }}
1108
1199
  >
1109
1200
  <For each={tree()}>
1110
- {(row) =>
1111
- row.kind === "group" ? (
1201
+ {(row) => {
1202
+ if (row.kind === "group") return (
1112
1203
  <box flexDirection="row" paddingTop={1} onMouseDown={() => toggleGroup(row.dir)}>
1113
1204
  <text flexGrow={1} flexShrink={1} wrapMode="none">
1114
1205
  <span style={{ fg: theme().textMuted }}>{collapsed()[row.dir] ? "▸ " : "▾ "}</span>
@@ -1118,7 +1209,9 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1118
1209
  {" "}({row.count})
1119
1210
  </text>
1120
1211
  </box>
1121
- ) : (
1212
+ )
1213
+ const activity = createMemo(() => sessionStatus(row.entry.id))
1214
+ return (
1122
1215
  <box
1123
1216
  flexDirection="row"
1124
1217
  gap={1}
@@ -1150,24 +1243,24 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1150
1243
  ? theme().error
1151
1244
  : row.entry.id === currentID()
1152
1245
  ? theme().accent
1153
- : statusColor(row.entry.id),
1246
+ : statusColor(activity()),
1154
1247
  }}
1155
1248
  >
1156
- {row.entry.id === currentID() ? "●" : isActive(row.entry.id) ? "▸" : "○"}
1249
+ {row.entry.id === currentID() ? "●" : activity() === "waiting" ? "!" : activity() === "running" ? "▶" : isActive(row.entry.id) ? "▸" : "○"}
1157
1250
  </span>
1158
1251
  </text>
1159
1252
  <Highlighted
1160
1253
  text={`${props.pins().sessions.includes(row.entry.id) ? "★ " : ""}${truncate(row.entry.title, SIDEBAR_TITLE_WIDTH - (props.pins().sessions.includes(row.entry.id) ? 2 : 0))}`}
1161
1254
  query={query()}
1162
- color={isPendingDelete(row.entry.id) ? theme().error : theme().text}
1255
+ color={isPendingDelete(row.entry.id) ? theme().error : activity() === "idle" ? theme().text : statusColor(activity())}
1163
1256
  matchColor={theme().warning}
1164
1257
  />
1165
1258
  <text flexShrink={0} style={{ fg: isPendingDelete(row.entry.id) ? theme().error : theme().textMuted }}>
1166
1259
  {isPendingDelete(row.entry.id) ? (
1167
1260
  "ctrl+x again"
1168
- ) : waiting().has(row.entry.id) ? (
1261
+ ) : activity() !== "idle" ? (
1169
1262
  <span>
1170
- {ago(row.entry.updated)} · <span style={{ fg: theme().warning }}>{waitingReason(row.entry.id)}</span>
1263
+ {ago(row.entry.updated)} · <span style={{ fg: statusColor(activity()) }}>{activity() === "waiting" ? waitingReason(row.entry.id) : activity()}</span>
1171
1264
  </span>
1172
1265
  ) : (
1173
1266
  ago(row.entry.updated)
@@ -1175,7 +1268,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1175
1268
  </text>
1176
1269
  </box>
1177
1270
  )
1178
- }
1271
+ }}
1179
1272
  </For>
1180
1273
  </scrollbox>
1181
1274
  <Show when={remaining() > 0}>
@@ -1381,6 +1474,11 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
1381
1474
 
1382
1475
  const tui: TuiPlugin = async (api) => {
1383
1476
  const [pins, setPins] = createSignal<Pins>(emptyPins())
1477
+ // Keep exploratory context while the plugin remains loaded; a full OpenCode
1478
+ // restart starts with a clean picker rather than an old cross-project filter.
1479
+ let pickerState: PickerFilters = {
1480
+ query: "", waitingOnly: false, pinnedOnly: false,
1481
+ }
1384
1482
  let pinVersion = 0
1385
1483
  const refreshPins = () => {
1386
1484
  const version = pinVersion
@@ -1433,9 +1531,34 @@ const tui: TuiPlugin = async (api) => {
1433
1531
  const [allEntries, setAllEntries] = createSignal<Entry[]>(entries)
1434
1532
  const [previewText, setPreviewText] = createSignal("")
1435
1533
  const [showPreview, setShowPreview] = createSignal(false)
1436
- const [query, setQuery] = createSignal("")
1534
+ const [query, setQuery] = createSignal(pickerState.query)
1437
1535
  const [cursor, setCursor] = createSignal(0)
1438
- const [scope, setScope] = createSignal<string>()
1536
+ const [scope, setScope] = createSignal<string | undefined>(
1537
+ entries.some((entry) => entry.group === pickerState.scope) ? pickerState.scope : undefined,
1538
+ )
1539
+ const [waitingOnly, setWaitingOnly] = createSignal(pickerState.waitingOnly)
1540
+ const [pinnedOnly, setPinnedOnly] = createSignal(pickerState.pinnedOnly)
1541
+ const [waitingIds, setWaitingIds] = createSignal<Set<string>>(new Set())
1542
+ const [waitingCoverage, setWaitingCoverage] = createSignal<"loading" | "ready" | "unavailable">("loading")
1543
+ let alive = true
1544
+ void (async () => {
1545
+ const db = await openTranscriptDb()
1546
+ if (!db) {
1547
+ if (alive) setWaitingCoverage("unavailable")
1548
+ return
1549
+ }
1550
+ try {
1551
+ const ids = await queryWaitingIds(db)
1552
+ if (alive) {
1553
+ setWaitingIds(new Set(ids.keys()))
1554
+ setWaitingCoverage("ready")
1555
+ }
1556
+ } catch {
1557
+ if (alive) setWaitingCoverage("unavailable")
1558
+ } finally {
1559
+ db.close()
1560
+ }
1561
+ })()
1439
1562
  const [pendingDelete, setPendingDelete] = createSignal<Entry>()
1440
1563
  const [searchIndex, setSearchIndex] = createSignal<Map<string, string>>(new Map())
1441
1564
  const [indexProgress, setIndexProgress] = createSignal<IndexProgress>({
@@ -1444,6 +1567,7 @@ const tui: TuiPlugin = async (api) => {
1444
1567
  complete: false,
1445
1568
  })
1446
1569
  void buildSearchIndex(api, entries, (progress, index) => {
1570
+ if (!alive) return
1447
1571
  setIndexProgress(progress)
1448
1572
  // Publish a fresh snapshot: the indexing workers mutate their own map,
1449
1573
  // while Solid needs a new reference to update search results mid-scan.
@@ -1452,23 +1576,25 @@ const tui: TuiPlugin = async (api) => {
1452
1576
  type DialogRow = { kind: "group"; label: string; count: number } | { kind: "item"; entry: Entry }
1453
1577
 
1454
1578
  const orderedEntries = createMemo(() => pinnedFirst(allEntries(), pins()))
1579
+ const currentProject = () => {
1580
+ const route = api.route.current
1581
+ const id = route.name === "session" ? route.params?.sessionID : undefined
1582
+ return allEntries().find((entry) => entry.id === id)?.group ??
1583
+ allEntries().find((entry) => entry.dir === api.state.path.directory)?.group
1584
+ }
1455
1585
  const matchedGroups = createMemo(() => {
1456
- const q = query().trim().toLowerCase()
1457
- const index = searchIndex()
1458
- const group = scope()
1459
- const matched = orderedEntries().filter((entry) => {
1460
- if (group && entry.group !== group) return false
1461
- if (!q) return true
1462
- const haystack = index.get(entry.id) ?? `${entry.title} ${entry.dir}`.toLowerCase()
1463
- return haystack.includes(q)
1464
- })
1586
+ const matched = filterPickerEntries(
1587
+ orderedEntries(),
1588
+ { query: query(), scope: scope(), waitingOnly: waitingOnly(), pinnedOnly: pinnedOnly() },
1589
+ searchIndex(), waitingIds(), pins().sessions,
1590
+ )
1465
1591
  const byGroup = new Map<string, Entry[]>()
1466
1592
  for (const entry of matched) {
1467
1593
  const list = byGroup.get(entry.group) ?? []
1468
1594
  list.push(entry)
1469
1595
  byGroup.set(entry.group, list)
1470
1596
  }
1471
- const groups = [...byGroup.entries()].map(([name, list]) => ({ name, list }))
1597
+ const groups = [...byGroup.entries()].map(([name, list]) => ({ name, list: newestFirst(list) }))
1472
1598
  const currentPins = pins()
1473
1599
  const rank = (group: (typeof groups)[number]) =>
1474
1600
  group.list.some((entry) => currentPins.directories.includes(entry.dir)) ? 2
@@ -1486,6 +1612,8 @@ const tui: TuiPlugin = async (api) => {
1486
1612
  const progress = indexProgress()
1487
1613
  const bits: string[] = []
1488
1614
  if (scope()) bits.push(`scope ${scope()}`)
1615
+ if (waitingOnly()) bits.push(waitingCoverage() === "loading" ? "checking needs input" : waitingCoverage() === "unavailable" ? "needs-input unavailable" : "needs input")
1616
+ if (pinnedOnly()) bits.push("pinned sessions")
1489
1617
  if (progress.total > 0) {
1490
1618
  bits.push(
1491
1619
  progress.complete
@@ -1521,7 +1649,9 @@ const tui: TuiPlugin = async (api) => {
1521
1649
 
1522
1650
  const visiblePickerRows = createMemo<DialogRow[]>(() => {
1523
1651
  const flat = pickerRows()
1524
- const windowSize = listHeight
1652
+ // A transcript hit adds a second line to a row; bound by screen height,
1653
+ // not just the number of session IDs.
1654
+ const windowSize = query().trim() ? Math.max(2, Math.floor(listHeight / 3)) : listHeight
1525
1655
  const currentID = selectableEntries()[cursor()]?.id
1526
1656
  let pos = currentID
1527
1657
  ? flat.findIndex((row) => row.kind === "item" && row.entry.id === currentID)
@@ -1715,6 +1845,8 @@ const tui: TuiPlugin = async (api) => {
1715
1845
  else if (target) setScope(target.group)
1716
1846
  },
1717
1847
  },
1848
+ { key: "option+w", desc: "Filter needs input", preventDefault: true, cmd: () => setWaitingOnly((value) => !value) },
1849
+ { key: "option+p", desc: "Filter pinned sessions", preventDefault: true, cmd: () => setPinnedOnly((value) => !value) },
1718
1850
  {
1719
1851
  key: "escape",
1720
1852
  desc: "Close",
@@ -1728,6 +1860,8 @@ const tui: TuiPlugin = async (api) => {
1728
1860
  })
1729
1861
 
1730
1862
  const cleanup = () => {
1863
+ alive = false
1864
+ pickerState = { query: query(), scope: scope(), waitingOnly: waitingOnly(), pinnedOnly: pinnedOnly() }
1731
1865
  disposeNav()
1732
1866
  disposeConfirm?.()
1733
1867
  disposeConfirm = undefined
@@ -1770,6 +1904,21 @@ const tui: TuiPlugin = async (api) => {
1770
1904
  <text style={{ fg: api.theme.current.textMuted }}>{coverageLabel()}</text>
1771
1905
  </box>
1772
1906
  </Show>
1907
+ <box flexDirection="row" gap={2} paddingTop={1}>
1908
+ <text style={{ fg: scope() ? api.theme.current.accent : api.theme.current.textMuted }} onMouseDown={() => {
1909
+ const group = currentProject()
1910
+ if (group) setScope(scope() === group ? undefined : group)
1911
+ else api.ui.toast({ message: "No current project to filter", variant: "info" })
1912
+ }}>
1913
+ {scope() ? `Project: ${truncate(scope()!, 22)}` : "Project: all"}
1914
+ </text>
1915
+ <text style={{ fg: waitingOnly() ? api.theme.current.warning : api.theme.current.textMuted }} onMouseDown={() => setWaitingOnly((value) => !value)}>
1916
+ {waitingOnly() ? "● Needs input" : "○ Needs input"}
1917
+ </text>
1918
+ <text style={{ fg: pinnedOnly() ? api.theme.current.accent : api.theme.current.textMuted }} onMouseDown={() => setPinnedOnly((value) => !value)}>
1919
+ {pinnedOnly() ? "★ Pinned" : "☆ Pinned"}
1920
+ </text>
1921
+ </box>
1773
1922
  </box>
1774
1923
  <box
1775
1924
  flexDirection="column"
@@ -1780,17 +1929,18 @@ const tui: TuiPlugin = async (api) => {
1780
1929
  }}
1781
1930
  >
1782
1931
  <For each={visiblePickerRows()}>
1783
- {(row) =>
1784
- row.kind === "group" ? (
1932
+ {(row) => {
1933
+ if (row.kind === "group") return (
1785
1934
  <box paddingTop={1} paddingLeft={4}>
1786
1935
  <text style={{ fg: api.theme.current.accent }} attributes={TextAttributes.BOLD}>
1787
1936
  {matchedGroups().find((group) => group.name === row.label)?.list.some((entry) => pins().directories.includes(entry.dir)) ? "★ " : ""}{row.label} <span style={{ fg: api.theme.current.textMuted }}>({row.count})</span>
1788
1937
  </text>
1789
1938
  </box>
1790
- ) : (
1939
+ )
1940
+ const excerpt = createMemo(() => transcriptMatchExcerpt(searchIndex().get(row.entry.id), row.entry, query()))
1941
+ return (
1791
1942
  <box
1792
- flexDirection="row"
1793
- gap={1}
1943
+ flexDirection="column"
1794
1944
  paddingLeft={4}
1795
1945
  paddingRight={4}
1796
1946
  backgroundColor={
@@ -1800,6 +1950,7 @@ const tui: TuiPlugin = async (api) => {
1800
1950
  }
1801
1951
  onMouseDown={() => choose(row.entry)}
1802
1952
  >
1953
+ <box flexDirection="row" gap={1}>
1803
1954
  <Show when={row.entry.id === currentSessionID()}>
1804
1955
  <text
1805
1956
  flexShrink={0}
@@ -1848,17 +1999,39 @@ const tui: TuiPlugin = async (api) => {
1848
1999
  : ""
1849
2000
  }`}
1850
2001
  </text>
2002
+ </box>
2003
+ <Show when={excerpt()}>
2004
+ {(match) => (
2005
+ <box flexDirection="row" gap={1} paddingLeft={2}>
2006
+ <text flexShrink={0} style={{ fg: api.theme.current.textMuted }}>↳</text>
2007
+ <Highlighted
2008
+ text={match()}
2009
+ query={query()}
2010
+ color={row.entry.id === selectableEntries()[cursor()]?.id ? api.theme.current.selectedListItemText : api.theme.current.textMuted}
2011
+ matchColor={api.theme.current.warning}
2012
+ />
2013
+ </box>
2014
+ )}
2015
+ </Show>
1851
2016
  </box>
1852
2017
  )
1853
- }
2018
+ }}
1854
2019
  </For>
1855
2020
  <Show when={selectableEntries().length === 0}>
1856
2021
  <box paddingLeft={4} paddingRight={4}>
1857
2022
  <text style={{ fg: api.theme.current.textMuted }}>
1858
- {!indexProgress().complete
1859
- ? "No matches yet · still searching transcripts…"
2023
+ {waitingOnly() && waitingCoverage() === "loading"
2024
+ ? "Checking which sessions need input…"
2025
+ : waitingOnly() && waitingCoverage() === "unavailable"
2026
+ ? "Needs-input data unavailable · turn off the filter"
2027
+ : pinnedOnly() && pins().sessions.length === 0
2028
+ ? "No pinned sessions yet · option+s pins a session"
2029
+ : !indexProgress().complete && query().trim()
2030
+ ? "No matches yet · still searching transcripts…"
1860
2031
  : scope()
1861
2032
  ? `No matches in ${scope()} · ctrl+g for all projects`
2033
+ : waitingOnly()
2034
+ ? "No sessions need input with these filters"
1862
2035
  : "No sessions match · try another search"}
1863
2036
  </text>
1864
2037
  </box>
@@ -1888,7 +2061,7 @@ const tui: TuiPlugin = async (api) => {
1888
2061
  </scrollbox>
1889
2062
  </box>
1890
2063
  </Show>
1891
- <box paddingLeft={4} paddingRight={4} flexShrink={0}>
2064
+ <box paddingLeft={4} paddingRight={4} flexShrink={0} flexDirection="column">
1892
2065
  <text
1893
2066
  style={{
1894
2067
  fg: pendingDelete() ? api.theme.current.warning : api.theme.current.textMuted,
@@ -1896,8 +2069,11 @@ const tui: TuiPlugin = async (api) => {
1896
2069
  >
1897
2070
  {pendingDelete()
1898
2071
  ? `Delete "${truncate(pendingDelete()!.title, 40)}"? y confirm · n cancel`
1899
- : "↑↓ navigate · enter open · option+s/d pin · ctrl+x delete · ctrl+f fork · ctrl+g project · ctrl+p preview · esc close"}
2072
+ : "↑↓ move · enter open · ctrl+g project · option+w needs · option+p pinned · ctrl+p preview"}
1900
2073
  </text>
2074
+ <Show when={!pendingDelete()}>
2075
+ <text style={{ fg: api.theme.current.textMuted }}>option+s/d pin · ctrl+x delete · ctrl+f fork · esc close</text>
2076
+ </Show>
1901
2077
  </box>
1902
2078
  </box>
1903
2079
  ),
@@ -2022,27 +2198,32 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
2022
2198
 
2023
2199
  // Triage dialog: the waiting set as an openable list. Enter opens the
2024
2200
  // highlighted session; the sidebar section stays the always-visible view.
2201
+ let lastNeedsID: string | undefined
2025
2202
  const openNeeds = async () => {
2026
2203
  if (api.mode.current() !== BASE_MODE) return
2027
2204
  if (api.renderer.currentFocusedEditor === null) return
2028
- let items: { entry: Entry; reason: string }[] = []
2205
+ let items: { entry: Entry; reason: string; since?: number }[] = []
2029
2206
  try {
2030
2207
  const { entries } = await fetchEntries(api)
2031
2208
  const db = await openTranscriptDb()
2032
- let waiting = new Map<string, string>()
2033
- if (db) {
2034
- try {
2035
- waiting = await queryWaitingIds(db)
2036
- } finally {
2037
- db.close()
2038
- }
2209
+ if (!db) {
2210
+ api.ui.toast({ message: "Needs-input data unavailable", variant: "error" })
2211
+ return
2212
+ }
2213
+ let waiting: Map<string, WaitingDetail>
2214
+ try {
2215
+ waiting = await queryWaitingDetails(db)
2216
+ } finally {
2217
+ db.close()
2039
2218
  }
2040
2219
  items = entries
2041
2220
  .filter((entry) => waiting.has(entry.id))
2042
2221
  .map((entry) => ({
2043
2222
  entry,
2044
- reason: waiting.get(entry.id) === "question" ? "awaiting answer" : "run stuck",
2223
+ reason: waiting.get(entry.id)?.reason === "question" ? "awaiting answer" : "run stuck",
2224
+ since: waiting.get(entry.id)?.since,
2045
2225
  }))
2226
+ .sort((a, b) => (a.since ?? a.entry.updated) - (b.since ?? b.entry.updated))
2046
2227
  } catch {
2047
2228
  api.ui.toast({ message: "Could not load sessions", variant: "error" })
2048
2229
  return
@@ -2051,12 +2232,15 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
2051
2232
  api.ui.toast({ message: "No sessions are waiting on you.", variant: "info" })
2052
2233
  return
2053
2234
  }
2054
- const [cursor, setCursor] = createSignal(0)
2235
+ const route = api.route.current
2236
+ const currentID = route.name === "session" ? route.params?.sessionID : undefined
2237
+ const previous = items.findIndex((item) => item.entry.id === (currentID ?? lastNeedsID))
2238
+ const [cursor, setCursor] = createSignal(previous < 0 ? 0 : (previous + 1) % items.length)
2055
2239
  const moveCursor = (delta: number) =>
2056
2240
  setCursor((value) => Math.max(0, Math.min(items.length - 1, value + delta)))
2057
- const choose = () => {
2058
- const target = items[cursor()]
2241
+ const choose = (target = items[cursor()]) => {
2059
2242
  if (!target) return
2243
+ lastNeedsID = target.entry.id
2060
2244
  disposeNav()
2061
2245
  api.ui.dialog.clear()
2062
2246
  api.route.navigate("session", { sessionID: target.entry.id })
@@ -2066,6 +2250,10 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
2066
2250
  { key: "up", desc: "Previous session", preventDefault: true, cmd: () => moveCursor(-1) },
2067
2251
  { key: "down", desc: "Next session", preventDefault: true, cmd: () => moveCursor(1) },
2068
2252
  { key: "enter", desc: "Open session", preventDefault: true, cmd: () => choose() },
2253
+ { key: "n", desc: "Open next waiting session", preventDefault: true, cmd: () => {
2254
+ const at = items.findIndex((item) => item.entry.id === currentID)
2255
+ choose(items[at < 0 ? cursor() : (at + 1) % items.length])
2256
+ } },
2069
2257
  {
2070
2258
  key: "escape",
2071
2259
  desc: "Close",
@@ -2080,9 +2268,9 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
2080
2268
  <box flexDirection="column" paddingLeft={4} paddingRight={4} paddingTop={1} gap={1}>
2081
2269
  <text attributes={TextAttributes.BOLD}>
2082
2270
  Needs input{" "}
2083
- <span style={{ fg: api.theme.current.textMuted }}>({items.length})</span>
2271
+ <span style={{ fg: api.theme.current.textMuted }}>({items.length} · longest waiting first)</span>
2084
2272
  </text>
2085
- <box flexDirection="column">
2273
+ <scrollbox flexGrow={1} height={Math.max(6, Math.floor(api.renderer.height / 2) - 10)} scrollbarOptions={{ visible: false }}>
2086
2274
  <For each={items}>
2087
2275
  {(item, index) => (
2088
2276
  <box
@@ -2097,7 +2285,7 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
2097
2285
  }
2098
2286
  onMouseDown={() => {
2099
2287
  setCursor(index())
2100
- choose()
2288
+ choose(item)
2101
2289
  }}
2102
2290
  >
2103
2291
  <text
@@ -2133,14 +2321,14 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
2133
2321
  : api.theme.current.textMuted,
2134
2322
  }}
2135
2323
  >
2136
- {prettyDir(item.entry.dir, home)} · {ago(item.entry.updated)}
2324
+ {prettyDir(item.entry.dir, home)} · {item.since === undefined ? "time unknown" : `waiting ${ago(item.since)}`}
2137
2325
  </text>
2138
2326
  </box>
2139
2327
  )}
2140
2328
  </For>
2141
- </box>
2329
+ </scrollbox>
2142
2330
  <box flexShrink={0}>
2143
- <text style={{ fg: api.theme.current.textMuted }}>↑↓ navigate · enter open · esc close</text>
2331
+ <text style={{ fg: api.theme.current.textMuted }}>↑↓ navigate · enter open · n open next waiting · esc close</text>
2144
2332
  </box>
2145
2333
  </box>
2146
2334
  ),