opencode-sesh 0.8.0 → 0.9.1

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
@@ -83,8 +83,8 @@ the schema below. Keystrokes never touch the database:
83
83
  epoch, not a boolean).
84
84
  - Session and directory pins live in `${XDG_DATA_HOME:-$HOME/.local/share}/sesh/pins.json`
85
85
  (`SESH_PINS_FILE` overrides it for tests), shared by fzf and the TUI. Writes
86
- use a sibling lock directory and atomic rename. Option-S toggles a selected
87
- session; Option-D toggles its directory. Pinned directories sort before groups
86
+ use a sibling lock directory and atomic rename. Ctrl-S toggles a selected
87
+ session; Ctrl-D toggles its directory. Pinned directories sort before groups
88
88
  containing pinned sessions, with pinned sessions first inside each group.
89
89
 
90
90
  **Output contract** (TSV, 6 fields):
@@ -105,7 +105,8 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
105
105
  ## Conventions
106
106
 
107
107
  - Default scope is **all** sessions, every directory. `--cwd`/Ctrl-G narrows.
108
- Archived sessions and fork children are hidden unless `--archived`.
108
+ Archived sessions and child sessions (fork children, spawned subagents) are
109
+ hidden unless `--archived`; metadata and cache pruning still see them.
109
110
  - **Text parts only** in search/preview; reasoning and tool payloads are
110
111
  excluded (asserted in tests).
111
112
  - Validate session ids (`^ses_[A-Za-z0-9]+$`) before SQL interpolation — never
@@ -141,8 +142,9 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
141
142
  - **Sidebar:** `api.slots.register` on `sidebar_content` (append mode — native
142
143
  sidebar content stays). Pinned directories sort first, then directory groups
143
144
  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
145
+ Row markers alone signal session state: a running agent animates a loading
146
+ spinner marker, the open session shows its own ●, and every other idle row
147
+ stays neutral (polled every 15 s); Ctrl-P previews
146
148
  a row and ctrl+x deletes one (armed, then confirmed). The section lists every
147
149
  session without a row cap and flex-grows to fill the sidebar, with overflow
148
150
  scrolling inside; the picker remains the place for transcript search.
@@ -160,7 +162,13 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
160
162
  at `SESSION_MAX` and reported when truncated) instead of a fixed 500-row
161
163
  window, then indexes transcript text for **every** session in batches
162
164
  (`TRANSCRIPT_BATCH`) with bounded remote concurrency (`REMOTE_CONCURRENCY`).
163
- The header shows indexing progress/coverage. `tests/tui.mjs` locks this.
165
+ The header shows indexing progress/coverage. Visible transcript hits get
166
+ short excerpts from the text-only index (never tool or reasoning payloads).
167
+ Project, needs-input (Option-W), and pinned-session (Option-P) filters compose
168
+ with the query; the picker remembers query/scope/filters until plugin restart.
169
+ `/sesh-needs` uses the shared needs-input set plus the oldest unresolved
170
+ question or running-tool timestamp, lists longest-waiting first, and `n`
171
+ opens the next waiting session. `tests/tui.mjs` locks the data layer.
164
172
  - **Durable install:** opencode imports plugins once at startup and never hot
165
173
  reloads, so panel changes need a full quit/reopen. `install.sh --sync-panel`
166
174
  (run by the `postinstall`) refreshes an already-installed panel and is a
package/CHANGELOG.md CHANGED
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.1] - 2026-09-25
11
+
12
+ ### Changed
13
+
14
+ - Sidebar rows show state only in the marker column: a running agent animates
15
+ a loading spinner; idle rows and their titles go back to neutral.
16
+ - Pin keybinds move from Option-S/Option-D to Ctrl-S/Ctrl-D in the terminal
17
+ picker, the sidebar, and the picker dialog.
18
+ - The terminal picker hides child sessions (fork children, spawned subagents)
19
+ like the panel does; `--archived` still opts them back in.
20
+ ## [0.9.0] - 2026-09-24
21
+
22
+ ### Added
23
+
24
+ - The TUI picker shows a highlighted excerpt for matching transcript text,
25
+ offers composable project, needs-input and pinned-session filters, and keeps
26
+ the query and filters when reopened during the same opencode run.
27
+ - `/sesh-needs` shows how long each session has waited, puts the longest-waiting
28
+ first, and can open the next waiting session with `n`.
10
29
  ## [0.8.0] - 2026-09-24
11
30
 
12
31
  ### Changed
@@ -333,7 +352,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
333
352
  - Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
334
353
  CI.
335
354
 
336
- [Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.8.0...HEAD
355
+ [Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.9.1...HEAD
356
+ [0.9.1]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.9.0...v0.9.1
357
+ [0.9.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.8.0...v0.9.0
337
358
  [0.8.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.7.3...v0.8.0
338
359
  [0.7.3]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.7.2...v0.7.3
339
360
  [0.7.2]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.7.1...v0.7.2
package/README.md CHANGED
@@ -28,7 +28,7 @@ grouped by directory, full-text searchable, and previewable from anywhere.
28
28
 
29
29
  - **Every session, everywhere.** Sessions are grouped by project directory and
30
30
  sorted by pins, then recency — across *all* your projects, not just the current one.
31
- - **Pin what matters.** Option-S pins a session and Option-D pins its directory in
31
+ - **Pin what matters.** Ctrl-S pins a session and Ctrl-D pins its directory in
32
32
  either picker. Pins are shared across the terminal and opencode TUI and
33
33
  persist across restarts; pinned directories and sessions sort first.
34
34
  - **Full-text search.** Type to match against session titles *and* the text of
@@ -115,7 +115,7 @@ bash install.sh --uninstall # from source
115
115
  | Enter | Resume the selected session in this terminal |
116
116
  | Ctrl-F | Resume as a fork (the original is untouched) |
117
117
  | Ctrl-G | Toggle current-directory scope / all sessions |
118
- | Option-S / Option-D | Pin or unpin the selected session / directory |
118
+ | Ctrl-S / Ctrl-D | Pin or unpin the selected session / directory |
119
119
  | Ctrl-P | Toggle the transcript preview |
120
120
  | `?` (empty query) | Toggle shortcut help |
121
121
  | Ctrl-X | Delete the selected session (asks to confirm) |
@@ -175,21 +175,22 @@ 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-S / Option-D | Pin or unpin the selected session / directory |
178
+ | Option-W / Option-P | Show only sessions needing input / pinned sessions |
179
+ | Ctrl-S / Ctrl-D | Pin or unpin the selected session / directory |
179
180
  | Esc | Close |
180
181
 
181
182
  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
- Ctrl-P to preview, Option-S/Option-D to pin, Ctrl-X to delete, Esc to leave). Pinned
185
+ Ctrl-P to preview, Ctrl-S/Ctrl-D to pin, Ctrl-X to delete, Esc to leave). Pinned
185
186
  directories rise first, while sessions within each directory are ordered by
186
187
  last update, newest first. The section lists every session and scrolls to fill
187
188
  the sidebar, so click **▾ hide** to collapse it when you need the room.
188
189
  Sessions waiting on you (unanswered agent questions, runs stuck mid-tool) pin
189
190
  themselves to a **Needs input** group above the directories, with the count in
190
- the heading; `sesh --needs-input` lists the same set in the terminal. Row titles
191
- and labels mark waiting (warning), running (success), and the current idle
192
- session (accent); other sessions stay neutral.
191
+ the heading; `sesh --needs-input` lists the same set in the terminal. Row
192
+ markers are the only status signal: a loading spinner marks the session whose
193
+ agent is working; the open session shows ● and every other row stays neutral.
193
194
  Hovering a row while keyboard navigation is active selects it for the next
194
195
  shortcut. Clicking (or Enter on) a session opens it here. The full picker is one
195
196
  keystroke away.
@@ -197,7 +198,12 @@ keystroke away.
197
198
  The full picker pages through your whole global session list (no fixed window)
198
199
  and indexes transcript text for every session in the background, showing
199
200
  indexing progress under the search box. It only stops at a safety cap of 5,000
200
- 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.
201
207
 
202
208
  ## Configuration
203
209
 
package/bin/sesh-list.sh CHANGED
@@ -276,7 +276,7 @@ refresh() {
276
276
  historical="$scratch/historical.jsonl"; needs_extract="$scratch/needs-extract.json"
277
277
  out="$scratch/snapshot.jsonl"
278
278
 
279
- db_json "SELECT id, directory, title, time_created, time_updated, COALESCE(time_archived, 0) AS archived FROM session ORDER BY time_updated DESC;" > "$sessions" 2>/dev/null || {
279
+ db_json "SELECT id, directory, title, time_created, time_updated, COALESCE(time_archived, 0) AS archived, COALESCE(parent_id, '') AS parent FROM session ORDER BY time_updated DESC;" > "$sessions" 2>/dev/null || {
280
280
  if [ -f "$SNAPSHOT" ]; then set_status 'stale: opencode database query failed; showing last good snapshot'; else set_status 'unavailable: opencode database query failed'; fi
281
281
  return 0
282
282
  }
@@ -388,7 +388,9 @@ refresh() {
388
388
  | ($hist | map({key: .sessionId, value: .}) | from_entries) as $histById
389
389
  | [$meta[]
390
390
  | select((.id | type) == "string" and (.id | test("^ses_[A-Za-z0-9]+$")))
391
- | select($archived == 1 or .archived == 0)
391
+ # Child sessions (fork children, spawned subagents) surface only with
392
+ # --archived; metadata still sees them, so cache pruning stays safe.
393
+ | select($archived == 1 or (.archived == 0 and .parent == ""))
392
394
  | select($scope == "" or .directory == $scope)
393
395
  | . as $m | ($histById[$m.id] // {}) as $h
394
396
  | {sessionId: $m.id, malformedRecords: 0, agentId: "", agentState: "",
@@ -8,8 +8,8 @@ sesh shortcuts
8
8
  Ctrl-F Resume the selected session as a fork
9
9
  Ctrl-G Toggle current-directory scope / all sessions
10
10
  Ctrl-P Toggle transcript preview
11
- Option-S Pin / unpin selected session
12
- Option-D Pin / unpin selected directory
11
+ Ctrl-S Pin / unpin selected session
12
+ Ctrl-D Pin / unpin selected directory
13
13
  ? Toggle this help
14
14
  Ctrl-X Delete the selected session (asks to confirm)
15
15
  Escape Exit the picker
package/bin/sesh.sh CHANGED
@@ -146,8 +146,8 @@ while :; do
146
146
  --bind="$help_bind" \
147
147
  --bind="ctrl-x:execute($delete_cmd)+transform-header($header_cmd)+reload($cache_cmd)" \
148
148
  --bind="ctrl-g:execute($toggle_cmd)+transform-header($header_cmd)+reload($cache_cmd)" \
149
- --bind="alt-s:execute-silent($pin_session_cmd)+reload($cache_cmd)" \
150
- --bind="alt-d:execute-silent($pin_directory_cmd)+reload($cache_cmd)" \
149
+ --bind="ctrl-s:execute-silent($pin_session_cmd)+reload($cache_cmd)" \
150
+ --bind="ctrl-d:execute-silent($pin_directory_cmd)+reload($cache_cmd)" \
151
151
  --bind="change:reload:$cache_cmd" \
152
152
  --bind="start,every(3):transform-header($header_cmd)+reload-sync:$cache_cmd")
153
153
  status=$?
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-sesh",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
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,19 +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"
251
+ type SidebarMarker = "current" | "running" | "idle"
252
+
253
+ // Loading-spinner frames for the marker of a session whose agent is working.
254
+ const SIDEBAR_SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
252
255
 
253
256
  function newestFirst(entries: Entry[]): Entry[] {
254
257
  return [...entries].sort((a, b) => b.updated - a.updated || a.id.localeCompare(b.id))
255
258
  }
256
259
 
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"
260
+ // A working agent is the only state the marker displays: everything that
261
+ // finished goes back to the neutral row, with the open session marked inside.
262
+ function sidebarMarker(current: boolean, live?: string): SidebarMarker {
261
263
  if (live === "busy" || live === "retry") return "running"
262
- if (reason === "stuck") return "waiting"
263
- return current ? "active" : "idle"
264
+ return current ? "current" : "idle"
264
265
  }
265
266
 
266
267
  const SESSION_PAGE_LIMIT = 200
@@ -402,6 +403,43 @@ async function queryWaitingIds(db: any): Promise<Map<string, string>> {
402
403
  return out
403
404
  }
404
405
 
406
+ type WaitingDetail = { reason: "question" | "stuck"; since?: number }
407
+
408
+ // The shared needs-input query decides *which* sessions are waiting. Read the
409
+ // oldest still-unanswered question or stuck tool for each result so triage can
410
+ // show how long it has waited, rather than its last (unrelated) update time.
411
+ async function queryWaitingDetails(db: any): Promise<Map<string, WaitingDetail>> {
412
+ const reasons = await queryWaitingIds(db)
413
+ const details = new Map<string, WaitingDetail>(
414
+ [...reasons].map(([id, reason]) => [id, { reason: reason as WaitingDetail["reason"] }]),
415
+ )
416
+ const ids = [...details.keys()]
417
+ const cutoff = Date.now() - STUCK_AFTER_MS
418
+ for (let i = 0; i < ids.length; i += 200) {
419
+ const batch = ids.slice(i, i + 200)
420
+ const rows = (db.query(`
421
+ SELECT p.session_id AS id,
422
+ MIN(CASE WHEN json_extract(p.data, '$.tool') = 'question'
423
+ AND COALESCE(json_extract(p.data, '$.state.status'), 'pending') != 'completed'
424
+ AND NOT EXISTS (SELECT 1 FROM message m WHERE m.session_id = p.session_id
425
+ AND json_extract(m.data, '$.role') = 'user' AND m.time_created > p.time_created)
426
+ THEN p.time_created END) AS question_since,
427
+ MIN(CASE WHEN json_extract(p.data, '$.state.status') = 'running'
428
+ AND p.time_created < ${cutoff} THEN p.time_created END) AS stuck_since
429
+ FROM part p
430
+ WHERE p.session_id IN (${batch.map(() => "?").join(",")})
431
+ AND json_extract(p.data, '$.type') = 'tool'
432
+ GROUP BY p.session_id`).all(...batch) ?? []) as { id: string; question_since: number | null; stuck_since: number | null }[]
433
+ for (const row of rows) {
434
+ const detail = details.get(row.id)
435
+ if (!detail) continue
436
+ const since = detail.reason === "question" ? row.question_since : row.stuck_since
437
+ if (typeof since === "number") detail.since = since
438
+ }
439
+ }
440
+ return details
441
+ }
442
+
405
443
  function addTranscript(index: Map<string, string>, sid: string, data: string): void {
406
444
  try {
407
445
  const part = JSON.parse(data) as { type?: unknown; text?: unknown }
@@ -484,9 +522,14 @@ async function buildSearchIndexRemote(
484
522
  const base = index.get(id) ?? ""
485
523
  try {
486
524
  const raw = await readFile(`${cacheDir}/extractions/${id}.json`, "utf8")
487
- const record = JSON.parse(raw) as { fulltextLower?: unknown }
525
+ const record = JSON.parse(raw) as { fulltextLower?: unknown; title?: unknown }
488
526
  const fulltext = typeof record?.fulltextLower === "string" ? record.fulltextLower : ""
489
- index.set(id, `${base} ${fulltext}`.trim())
527
+ // Extraction caches prefix the transcript with the cached title. The
528
+ // live entry already indexes its own title; strip that duplicate so a
529
+ // title-only match cannot appear as a transcript excerpt.
530
+ const title = typeof record?.title === "string" ? `${record.title.toLowerCase()} ` : ""
531
+ const transcript = title && fulltext.startsWith(title) ? fulltext.slice(title.length) : fulltext
532
+ index.set(id, `${base} ${transcript}`.trim())
490
533
  } catch {
491
534
  try {
492
535
  const result = await api.client.session.messages({ sessionID: id })
@@ -508,6 +551,36 @@ async function buildSearchIndexRemote(
508
551
  await Promise.all(Array.from({ length: Math.min(REMOTE_CONCURRENCY, ids.length) }, worker))
509
552
  }
510
553
 
554
+ // The index is title + directory + text parts, all lowercased. Start looking
555
+ // after the metadata so a title match never masquerades as a transcript hit.
556
+ function transcriptMatchExcerpt(indexed: string | undefined, entry: Entry, query: string): string | undefined {
557
+ const needle = query.trim().toLowerCase()
558
+ if (!indexed || !needle) return undefined
559
+ const transcriptStart = `${entry.title.toLowerCase()} ${entry.dir.toLowerCase()}`.length + 1
560
+ const at = indexed.indexOf(needle, transcriptStart)
561
+ if (at < 0) return undefined
562
+ const start = Math.max(transcriptStart, at - 28)
563
+ const end = Math.min(indexed.length, Math.max(at + needle.length + 28, start + 90))
564
+ const excerpt = indexed.slice(start, end).replace(/\s+/g, " ").trim()
565
+ return `${start > transcriptStart ? "…" : ""}${excerpt}${end < indexed.length ? "…" : ""}`
566
+ }
567
+
568
+ type PickerFilters = { query: string; scope?: string; waitingOnly: boolean; pinnedOnly: boolean }
569
+
570
+ function filterPickerEntries(
571
+ entries: Entry[], filters: PickerFilters, index: Map<string, string>, waitingIds: Set<string>, pinnedSessions: string[],
572
+ ): Entry[] {
573
+ const q = filters.query.trim().toLowerCase()
574
+ const pinned = new Set(pinnedSessions)
575
+ return entries.filter((entry) => {
576
+ if (filters.scope && entry.group !== filters.scope) return false
577
+ if (filters.waitingOnly && !waitingIds.has(entry.id)) return false
578
+ if (filters.pinnedOnly && !pinned.has(entry.id)) return false
579
+ if (!q) return true
580
+ return (index.get(entry.id) ?? `${entry.title} ${entry.dir}`.toLowerCase()).includes(q)
581
+ })
582
+ }
583
+
511
584
  const TRANSCRIPT_PREVIEW_TURNS = 8
512
585
  const TRANSCRIPT_PREVIEW_CHARS = 2000
513
586
 
@@ -736,8 +809,6 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
736
809
  const ids = waiting()
737
810
  return newestFirst(shownEntries().filter((entry) => ids.has(entry.id)))
738
811
  })
739
- const waitingReason = (id: string) =>
740
- waiting().get(id) === "question" ? "awaiting answer" : waiting().get(id) === "stuck" ? "run stuck" : "needs input"
741
812
 
742
813
  const tree = createMemo<TreeRow[]>(() => {
743
814
  const rows: TreeRow[] = []
@@ -812,26 +883,34 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
812
883
  previousRows = rows
813
884
  })
814
885
 
815
- const sessionStatus = (id: string): SidebarActivity => {
886
+ // Marker only: a spinner for a working agent, the open-session dot, and the
887
+ // neutral row for idle sessions. Titles stay plain and no status text is
888
+ // rendered.
889
+ const markerOf = (id: string): SidebarMarker => {
890
+ let live: string | undefined
816
891
  try {
817
- const permissions = props.api.state.session.permission(id)
818
- const questions = props.api.state.session.question(id)
819
- return sidebarActivity(
820
- id === currentID(),
821
- props.api.state.session.status(id)?.type,
822
- (permissions?.length ?? 0) > 0 || (questions?.length ?? 0) > 0,
823
- waiting().get(id),
824
- )
892
+ live = props.api.state.session.status(id)?.type
825
893
  } catch {}
826
- return sidebarActivity(id === currentID(), undefined, false, waiting().get(id))
894
+ return sidebarMarker(id === currentID(), live)
827
895
  }
828
896
 
829
- const statusColor = (status: SidebarActivity) => {
830
- if (status === "running") return theme().success
831
- if (status === "waiting") return theme().warning
832
- if (status === "active") return theme().accent
833
- return theme().textMuted
834
- }
897
+ // The spinner animates only while a listed session is actually running, so
898
+ // an idle list costs no timer and only running rows re-render per frame.
899
+ const [spinFrame, setSpinFrame] = createSignal(0)
900
+ let spinTimer: ReturnType<typeof setInterval> | undefined
901
+ createEffect(() => {
902
+ const spinning = itemRows().some((entry) => markerOf(entry.id) === "running")
903
+ if (spinning && !spinTimer) {
904
+ spinTimer = setInterval(() => setSpinFrame((value) => (value + 1) % SIDEBAR_SPINNER.length), 120)
905
+ } else if (!spinning && spinTimer) {
906
+ clearInterval(spinTimer)
907
+ spinTimer = undefined
908
+ setSpinFrame(0)
909
+ }
910
+ })
911
+ onCleanup(() => {
912
+ if (spinTimer) clearInterval(spinTimer)
913
+ })
835
914
 
836
915
  const cancelDelete = () => {
837
916
  if (confirmTimer) clearTimeout(confirmTimer)
@@ -955,8 +1034,8 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
955
1034
  if (!confirmArmed()) requestDelete(entry)
956
1035
  },
957
1036
  },
958
- { key: "option+s", desc: "Pin session", preventDefault: true, cmd: () => props.onTogglePin("sessions", entry.id) },
959
- { key: "option+d", desc: "Pin directory", preventDefault: true, cmd: () => props.onTogglePin("directories", entry.dir) },
1037
+ { key: "ctrl+s", desc: "Pin session", preventDefault: true, cmd: () => props.onTogglePin("sessions", entry.id) },
1038
+ { key: "ctrl+d", desc: "Pin directory", preventDefault: true, cmd: () => props.onTogglePin("directories", entry.dir) },
960
1039
  {
961
1040
  key: "/",
962
1041
  desc: "Search sessions",
@@ -1005,7 +1084,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1005
1084
  },
1006
1085
  },
1007
1086
  {
1008
- key: "option+s",
1087
+ key: "ctrl+s",
1009
1088
  desc: "Pin session",
1010
1089
  preventDefault: true,
1011
1090
  cmd: () => {
@@ -1014,7 +1093,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1014
1093
  },
1015
1094
  },
1016
1095
  {
1017
- key: "option+d",
1096
+ key: "ctrl+d",
1018
1097
  desc: "Pin directory",
1019
1098
  preventDefault: true,
1020
1099
  cmd: () => {
@@ -1138,7 +1217,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1138
1217
  </text>
1139
1218
  </box>
1140
1219
  )
1141
- const activity = createMemo(() => sessionStatus(row.entry.id))
1220
+ const marker = createMemo(() => markerOf(row.entry.id))
1142
1221
  return (
1143
1222
  <box
1144
1223
  flexDirection="row"
@@ -1153,7 +1232,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1153
1232
  onMouseOver={() => {
1154
1233
  setHovered(row.entry.id)
1155
1234
  // The keyboard layer wins over the hover layer while nav is
1156
- // active. Keep its cursor on the hovered row so Option-S/Option-D
1235
+ // active. Keep its cursor on the hovered row so Ctrl-S/Ctrl-D
1157
1236
  // cannot silently pin a different (often current) session.
1158
1237
  if (navActive()) {
1159
1238
  const index = itemRows().findIndex((entry) => entry.id === row.entry.id)
@@ -1169,30 +1248,24 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1169
1248
  style={{
1170
1249
  fg: isPendingDelete(row.entry.id)
1171
1250
  ? theme().error
1172
- : row.entry.id === currentID()
1251
+ : marker() === "current"
1173
1252
  ? theme().accent
1174
- : statusColor(activity()),
1253
+ : marker() === "running"
1254
+ ? theme().success
1255
+ : theme().textMuted,
1175
1256
  }}
1176
1257
  >
1177
- {row.entry.id === currentID() ? "●" : activity() === "waiting" ? "!" : activity() === "running" ? "▶" : isActive(row.entry.id) ? "▸" : "○"}
1258
+ {marker() === "current" ? "●" : marker() === "running" ? SIDEBAR_SPINNER[spinFrame()] : isActive(row.entry.id) ? "▸" : "○"}
1178
1259
  </span>
1179
1260
  </text>
1180
1261
  <Highlighted
1181
1262
  text={`${props.pins().sessions.includes(row.entry.id) ? "★ " : ""}${truncate(row.entry.title, SIDEBAR_TITLE_WIDTH - (props.pins().sessions.includes(row.entry.id) ? 2 : 0))}`}
1182
1263
  query={query()}
1183
- color={isPendingDelete(row.entry.id) ? theme().error : activity() === "idle" ? theme().text : statusColor(activity())}
1264
+ color={isPendingDelete(row.entry.id) ? theme().error : theme().text}
1184
1265
  matchColor={theme().warning}
1185
1266
  />
1186
1267
  <text flexShrink={0} style={{ fg: isPendingDelete(row.entry.id) ? theme().error : theme().textMuted }}>
1187
- {isPendingDelete(row.entry.id) ? (
1188
- "ctrl+x again"
1189
- ) : activity() !== "idle" ? (
1190
- <span>
1191
- {ago(row.entry.updated)} · <span style={{ fg: statusColor(activity()) }}>{activity() === "waiting" ? waitingReason(row.entry.id) : activity()}</span>
1192
- </span>
1193
- ) : (
1194
- ago(row.entry.updated)
1195
- )}
1268
+ {isPendingDelete(row.entry.id) ? "ctrl+x again" : ago(row.entry.updated)}
1196
1269
  </text>
1197
1270
  </box>
1198
1271
  )
@@ -1207,7 +1280,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1207
1280
  </Show>
1208
1281
  <Show when={navActive()}>
1209
1282
  <box paddingTop={1}>
1210
- <text style={{ fg: theme().textMuted }}>↑↓ move · enter open · ctrl+p preview · option+s/d pin · ctrl+x delete · esc done</text>
1283
+ <text style={{ fg: theme().textMuted }}>↑↓ move · enter open · ctrl+p preview · ctrl+s/d pin · ctrl+x delete · esc done</text>
1211
1284
  </box>
1212
1285
  </Show>
1213
1286
  </Show>
@@ -1278,8 +1351,8 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
1278
1351
  preventDefault: true,
1279
1352
  cmd: () => preview.open(entry),
1280
1353
  },
1281
- { key: "option+s", desc: "Pin session", preventDefault: true, cmd: () => props.onTogglePin("sessions", entry.id) },
1282
- { key: "option+d", desc: "Pin directory", preventDefault: true, cmd: () => props.onTogglePin("directories", entry.dir) },
1354
+ { key: "ctrl+s", desc: "Pin session", preventDefault: true, cmd: () => props.onTogglePin("sessions", entry.id) },
1355
+ { key: "ctrl+d", desc: "Pin directory", preventDefault: true, cmd: () => props.onTogglePin("directories", entry.dir) },
1283
1356
  ],
1284
1357
  })
1285
1358
  })
@@ -1402,6 +1475,11 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
1402
1475
 
1403
1476
  const tui: TuiPlugin = async (api) => {
1404
1477
  const [pins, setPins] = createSignal<Pins>(emptyPins())
1478
+ // Keep exploratory context while the plugin remains loaded; a full OpenCode
1479
+ // restart starts with a clean picker rather than an old cross-project filter.
1480
+ let pickerState: PickerFilters = {
1481
+ query: "", waitingOnly: false, pinnedOnly: false,
1482
+ }
1405
1483
  let pinVersion = 0
1406
1484
  const refreshPins = () => {
1407
1485
  const version = pinVersion
@@ -1454,9 +1532,34 @@ const tui: TuiPlugin = async (api) => {
1454
1532
  const [allEntries, setAllEntries] = createSignal<Entry[]>(entries)
1455
1533
  const [previewText, setPreviewText] = createSignal("")
1456
1534
  const [showPreview, setShowPreview] = createSignal(false)
1457
- const [query, setQuery] = createSignal("")
1535
+ const [query, setQuery] = createSignal(pickerState.query)
1458
1536
  const [cursor, setCursor] = createSignal(0)
1459
- const [scope, setScope] = createSignal<string>()
1537
+ const [scope, setScope] = createSignal<string | undefined>(
1538
+ entries.some((entry) => entry.group === pickerState.scope) ? pickerState.scope : undefined,
1539
+ )
1540
+ const [waitingOnly, setWaitingOnly] = createSignal(pickerState.waitingOnly)
1541
+ const [pinnedOnly, setPinnedOnly] = createSignal(pickerState.pinnedOnly)
1542
+ const [waitingIds, setWaitingIds] = createSignal<Set<string>>(new Set())
1543
+ const [waitingCoverage, setWaitingCoverage] = createSignal<"loading" | "ready" | "unavailable">("loading")
1544
+ let alive = true
1545
+ void (async () => {
1546
+ const db = await openTranscriptDb()
1547
+ if (!db) {
1548
+ if (alive) setWaitingCoverage("unavailable")
1549
+ return
1550
+ }
1551
+ try {
1552
+ const ids = await queryWaitingIds(db)
1553
+ if (alive) {
1554
+ setWaitingIds(new Set(ids.keys()))
1555
+ setWaitingCoverage("ready")
1556
+ }
1557
+ } catch {
1558
+ if (alive) setWaitingCoverage("unavailable")
1559
+ } finally {
1560
+ db.close()
1561
+ }
1562
+ })()
1460
1563
  const [pendingDelete, setPendingDelete] = createSignal<Entry>()
1461
1564
  const [searchIndex, setSearchIndex] = createSignal<Map<string, string>>(new Map())
1462
1565
  const [indexProgress, setIndexProgress] = createSignal<IndexProgress>({
@@ -1465,6 +1568,7 @@ const tui: TuiPlugin = async (api) => {
1465
1568
  complete: false,
1466
1569
  })
1467
1570
  void buildSearchIndex(api, entries, (progress, index) => {
1571
+ if (!alive) return
1468
1572
  setIndexProgress(progress)
1469
1573
  // Publish a fresh snapshot: the indexing workers mutate their own map,
1470
1574
  // while Solid needs a new reference to update search results mid-scan.
@@ -1473,16 +1577,18 @@ const tui: TuiPlugin = async (api) => {
1473
1577
  type DialogRow = { kind: "group"; label: string; count: number } | { kind: "item"; entry: Entry }
1474
1578
 
1475
1579
  const orderedEntries = createMemo(() => pinnedFirst(allEntries(), pins()))
1580
+ const currentProject = () => {
1581
+ const route = api.route.current
1582
+ const id = route.name === "session" ? route.params?.sessionID : undefined
1583
+ return allEntries().find((entry) => entry.id === id)?.group ??
1584
+ allEntries().find((entry) => entry.dir === api.state.path.directory)?.group
1585
+ }
1476
1586
  const matchedGroups = createMemo(() => {
1477
- const q = query().trim().toLowerCase()
1478
- const index = searchIndex()
1479
- const group = scope()
1480
- const matched = orderedEntries().filter((entry) => {
1481
- if (group && entry.group !== group) return false
1482
- if (!q) return true
1483
- const haystack = index.get(entry.id) ?? `${entry.title} ${entry.dir}`.toLowerCase()
1484
- return haystack.includes(q)
1485
- })
1587
+ const matched = filterPickerEntries(
1588
+ orderedEntries(),
1589
+ { query: query(), scope: scope(), waitingOnly: waitingOnly(), pinnedOnly: pinnedOnly() },
1590
+ searchIndex(), waitingIds(), pins().sessions,
1591
+ )
1486
1592
  const byGroup = new Map<string, Entry[]>()
1487
1593
  for (const entry of matched) {
1488
1594
  const list = byGroup.get(entry.group) ?? []
@@ -1507,6 +1613,8 @@ const tui: TuiPlugin = async (api) => {
1507
1613
  const progress = indexProgress()
1508
1614
  const bits: string[] = []
1509
1615
  if (scope()) bits.push(`scope ${scope()}`)
1616
+ if (waitingOnly()) bits.push(waitingCoverage() === "loading" ? "checking needs input" : waitingCoverage() === "unavailable" ? "needs-input unavailable" : "needs input")
1617
+ if (pinnedOnly()) bits.push("pinned sessions")
1510
1618
  if (progress.total > 0) {
1511
1619
  bits.push(
1512
1620
  progress.complete
@@ -1542,7 +1650,9 @@ const tui: TuiPlugin = async (api) => {
1542
1650
 
1543
1651
  const visiblePickerRows = createMemo<DialogRow[]>(() => {
1544
1652
  const flat = pickerRows()
1545
- const windowSize = listHeight
1653
+ // A transcript hit adds a second line to a row; bound by screen height,
1654
+ // not just the number of session IDs.
1655
+ const windowSize = query().trim() ? Math.max(2, Math.floor(listHeight / 3)) : listHeight
1546
1656
  const currentID = selectableEntries()[cursor()]?.id
1547
1657
  let pos = currentID
1548
1658
  ? flat.findIndex((row) => row.kind === "item" && row.entry.id === currentID)
@@ -1697,11 +1807,11 @@ const tui: TuiPlugin = async (api) => {
1697
1807
  },
1698
1808
  { key: "enter", desc: "Open session", preventDefault: true, cmd: () => choose() },
1699
1809
  { key: "ctrl+p", desc: "Toggle transcript preview", preventDefault: true, cmd: togglePreview },
1700
- { key: "option+s", desc: "Pin session", preventDefault: true, cmd: () => {
1810
+ { key: "ctrl+s", desc: "Pin session", preventDefault: true, cmd: () => {
1701
1811
  const entry = selectableEntries()[cursor()]
1702
1812
  if (entry) onTogglePin("sessions", entry.id)
1703
1813
  } },
1704
- { key: "option+d", desc: "Pin directory", preventDefault: true, cmd: () => {
1814
+ { key: "ctrl+d", desc: "Pin directory", preventDefault: true, cmd: () => {
1705
1815
  const entry = selectableEntries()[cursor()]
1706
1816
  if (entry) onTogglePin("directories", entry.dir)
1707
1817
  } },
@@ -1736,6 +1846,8 @@ const tui: TuiPlugin = async (api) => {
1736
1846
  else if (target) setScope(target.group)
1737
1847
  },
1738
1848
  },
1849
+ { key: "option+w", desc: "Filter needs input", preventDefault: true, cmd: () => setWaitingOnly((value) => !value) },
1850
+ { key: "option+p", desc: "Filter pinned sessions", preventDefault: true, cmd: () => setPinnedOnly((value) => !value) },
1739
1851
  {
1740
1852
  key: "escape",
1741
1853
  desc: "Close",
@@ -1749,6 +1861,8 @@ const tui: TuiPlugin = async (api) => {
1749
1861
  })
1750
1862
 
1751
1863
  const cleanup = () => {
1864
+ alive = false
1865
+ pickerState = { query: query(), scope: scope(), waitingOnly: waitingOnly(), pinnedOnly: pinnedOnly() }
1752
1866
  disposeNav()
1753
1867
  disposeConfirm?.()
1754
1868
  disposeConfirm = undefined
@@ -1791,6 +1905,21 @@ const tui: TuiPlugin = async (api) => {
1791
1905
  <text style={{ fg: api.theme.current.textMuted }}>{coverageLabel()}</text>
1792
1906
  </box>
1793
1907
  </Show>
1908
+ <box flexDirection="row" gap={2} paddingTop={1}>
1909
+ <text style={{ fg: scope() ? api.theme.current.accent : api.theme.current.textMuted }} onMouseDown={() => {
1910
+ const group = currentProject()
1911
+ if (group) setScope(scope() === group ? undefined : group)
1912
+ else api.ui.toast({ message: "No current project to filter", variant: "info" })
1913
+ }}>
1914
+ {scope() ? `Project: ${truncate(scope()!, 22)}` : "Project: all"}
1915
+ </text>
1916
+ <text style={{ fg: waitingOnly() ? api.theme.current.warning : api.theme.current.textMuted }} onMouseDown={() => setWaitingOnly((value) => !value)}>
1917
+ {waitingOnly() ? "● Needs input" : "○ Needs input"}
1918
+ </text>
1919
+ <text style={{ fg: pinnedOnly() ? api.theme.current.accent : api.theme.current.textMuted }} onMouseDown={() => setPinnedOnly((value) => !value)}>
1920
+ {pinnedOnly() ? "★ Pinned" : "☆ Pinned"}
1921
+ </text>
1922
+ </box>
1794
1923
  </box>
1795
1924
  <box
1796
1925
  flexDirection="column"
@@ -1801,17 +1930,18 @@ const tui: TuiPlugin = async (api) => {
1801
1930
  }}
1802
1931
  >
1803
1932
  <For each={visiblePickerRows()}>
1804
- {(row) =>
1805
- row.kind === "group" ? (
1933
+ {(row) => {
1934
+ if (row.kind === "group") return (
1806
1935
  <box paddingTop={1} paddingLeft={4}>
1807
1936
  <text style={{ fg: api.theme.current.accent }} attributes={TextAttributes.BOLD}>
1808
1937
  {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>
1809
1938
  </text>
1810
1939
  </box>
1811
- ) : (
1940
+ )
1941
+ const excerpt = createMemo(() => transcriptMatchExcerpt(searchIndex().get(row.entry.id), row.entry, query()))
1942
+ return (
1812
1943
  <box
1813
- flexDirection="row"
1814
- gap={1}
1944
+ flexDirection="column"
1815
1945
  paddingLeft={4}
1816
1946
  paddingRight={4}
1817
1947
  backgroundColor={
@@ -1821,6 +1951,7 @@ const tui: TuiPlugin = async (api) => {
1821
1951
  }
1822
1952
  onMouseDown={() => choose(row.entry)}
1823
1953
  >
1954
+ <box flexDirection="row" gap={1}>
1824
1955
  <Show when={row.entry.id === currentSessionID()}>
1825
1956
  <text
1826
1957
  flexShrink={0}
@@ -1869,17 +2000,39 @@ const tui: TuiPlugin = async (api) => {
1869
2000
  : ""
1870
2001
  }`}
1871
2002
  </text>
2003
+ </box>
2004
+ <Show when={excerpt()}>
2005
+ {(match) => (
2006
+ <box flexDirection="row" gap={1} paddingLeft={2}>
2007
+ <text flexShrink={0} style={{ fg: api.theme.current.textMuted }}>↳</text>
2008
+ <Highlighted
2009
+ text={match()}
2010
+ query={query()}
2011
+ color={row.entry.id === selectableEntries()[cursor()]?.id ? api.theme.current.selectedListItemText : api.theme.current.textMuted}
2012
+ matchColor={api.theme.current.warning}
2013
+ />
2014
+ </box>
2015
+ )}
2016
+ </Show>
1872
2017
  </box>
1873
2018
  )
1874
- }
2019
+ }}
1875
2020
  </For>
1876
2021
  <Show when={selectableEntries().length === 0}>
1877
2022
  <box paddingLeft={4} paddingRight={4}>
1878
2023
  <text style={{ fg: api.theme.current.textMuted }}>
1879
- {!indexProgress().complete
1880
- ? "No matches yet · still searching transcripts…"
2024
+ {waitingOnly() && waitingCoverage() === "loading"
2025
+ ? "Checking which sessions need input…"
2026
+ : waitingOnly() && waitingCoverage() === "unavailable"
2027
+ ? "Needs-input data unavailable · turn off the filter"
2028
+ : pinnedOnly() && pins().sessions.length === 0
2029
+ ? "No pinned sessions yet · ctrl+s pins a session"
2030
+ : !indexProgress().complete && query().trim()
2031
+ ? "No matches yet · still searching transcripts…"
1881
2032
  : scope()
1882
2033
  ? `No matches in ${scope()} · ctrl+g for all projects`
2034
+ : waitingOnly()
2035
+ ? "No sessions need input with these filters"
1883
2036
  : "No sessions match · try another search"}
1884
2037
  </text>
1885
2038
  </box>
@@ -1909,7 +2062,7 @@ const tui: TuiPlugin = async (api) => {
1909
2062
  </scrollbox>
1910
2063
  </box>
1911
2064
  </Show>
1912
- <box paddingLeft={4} paddingRight={4} flexShrink={0}>
2065
+ <box paddingLeft={4} paddingRight={4} flexShrink={0} flexDirection="column">
1913
2066
  <text
1914
2067
  style={{
1915
2068
  fg: pendingDelete() ? api.theme.current.warning : api.theme.current.textMuted,
@@ -1917,8 +2070,11 @@ const tui: TuiPlugin = async (api) => {
1917
2070
  >
1918
2071
  {pendingDelete()
1919
2072
  ? `Delete "${truncate(pendingDelete()!.title, 40)}"? y confirm · n cancel`
1920
- : "↑↓ navigate · enter open · option+s/d pin · ctrl+x delete · ctrl+f fork · ctrl+g project · ctrl+p preview · esc close"}
2073
+ : "↑↓ move · enter open · ctrl+g project · option+w needs · option+p pinned · ctrl+p preview"}
1921
2074
  </text>
2075
+ <Show when={!pendingDelete()}>
2076
+ <text style={{ fg: api.theme.current.textMuted }}>ctrl+s/d pin · ctrl+x delete · ctrl+f fork · esc close</text>
2077
+ </Show>
1922
2078
  </box>
1923
2079
  </box>
1924
2080
  ),
@@ -2043,27 +2199,32 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
2043
2199
 
2044
2200
  // Triage dialog: the waiting set as an openable list. Enter opens the
2045
2201
  // highlighted session; the sidebar section stays the always-visible view.
2202
+ let lastNeedsID: string | undefined
2046
2203
  const openNeeds = async () => {
2047
2204
  if (api.mode.current() !== BASE_MODE) return
2048
2205
  if (api.renderer.currentFocusedEditor === null) return
2049
- let items: { entry: Entry; reason: string }[] = []
2206
+ let items: { entry: Entry; reason: string; since?: number }[] = []
2050
2207
  try {
2051
2208
  const { entries } = await fetchEntries(api)
2052
2209
  const db = await openTranscriptDb()
2053
- let waiting = new Map<string, string>()
2054
- if (db) {
2055
- try {
2056
- waiting = await queryWaitingIds(db)
2057
- } finally {
2058
- db.close()
2059
- }
2210
+ if (!db) {
2211
+ api.ui.toast({ message: "Needs-input data unavailable", variant: "error" })
2212
+ return
2213
+ }
2214
+ let waiting: Map<string, WaitingDetail>
2215
+ try {
2216
+ waiting = await queryWaitingDetails(db)
2217
+ } finally {
2218
+ db.close()
2060
2219
  }
2061
2220
  items = entries
2062
2221
  .filter((entry) => waiting.has(entry.id))
2063
2222
  .map((entry) => ({
2064
2223
  entry,
2065
- reason: waiting.get(entry.id) === "question" ? "awaiting answer" : "run stuck",
2224
+ reason: waiting.get(entry.id)?.reason === "question" ? "awaiting answer" : "run stuck",
2225
+ since: waiting.get(entry.id)?.since,
2066
2226
  }))
2227
+ .sort((a, b) => (a.since ?? a.entry.updated) - (b.since ?? b.entry.updated))
2067
2228
  } catch {
2068
2229
  api.ui.toast({ message: "Could not load sessions", variant: "error" })
2069
2230
  return
@@ -2072,12 +2233,15 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
2072
2233
  api.ui.toast({ message: "No sessions are waiting on you.", variant: "info" })
2073
2234
  return
2074
2235
  }
2075
- const [cursor, setCursor] = createSignal(0)
2236
+ const route = api.route.current
2237
+ const currentID = route.name === "session" ? route.params?.sessionID : undefined
2238
+ const previous = items.findIndex((item) => item.entry.id === (currentID ?? lastNeedsID))
2239
+ const [cursor, setCursor] = createSignal(previous < 0 ? 0 : (previous + 1) % items.length)
2076
2240
  const moveCursor = (delta: number) =>
2077
2241
  setCursor((value) => Math.max(0, Math.min(items.length - 1, value + delta)))
2078
- const choose = () => {
2079
- const target = items[cursor()]
2242
+ const choose = (target = items[cursor()]) => {
2080
2243
  if (!target) return
2244
+ lastNeedsID = target.entry.id
2081
2245
  disposeNav()
2082
2246
  api.ui.dialog.clear()
2083
2247
  api.route.navigate("session", { sessionID: target.entry.id })
@@ -2087,6 +2251,10 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
2087
2251
  { key: "up", desc: "Previous session", preventDefault: true, cmd: () => moveCursor(-1) },
2088
2252
  { key: "down", desc: "Next session", preventDefault: true, cmd: () => moveCursor(1) },
2089
2253
  { key: "enter", desc: "Open session", preventDefault: true, cmd: () => choose() },
2254
+ { key: "n", desc: "Open next waiting session", preventDefault: true, cmd: () => {
2255
+ const at = items.findIndex((item) => item.entry.id === currentID)
2256
+ choose(items[at < 0 ? cursor() : (at + 1) % items.length])
2257
+ } },
2090
2258
  {
2091
2259
  key: "escape",
2092
2260
  desc: "Close",
@@ -2101,9 +2269,9 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
2101
2269
  <box flexDirection="column" paddingLeft={4} paddingRight={4} paddingTop={1} gap={1}>
2102
2270
  <text attributes={TextAttributes.BOLD}>
2103
2271
  Needs input{" "}
2104
- <span style={{ fg: api.theme.current.textMuted }}>({items.length})</span>
2272
+ <span style={{ fg: api.theme.current.textMuted }}>({items.length} · longest waiting first)</span>
2105
2273
  </text>
2106
- <box flexDirection="column">
2274
+ <scrollbox flexGrow={1} height={Math.max(6, Math.floor(api.renderer.height / 2) - 10)} scrollbarOptions={{ visible: false }}>
2107
2275
  <For each={items}>
2108
2276
  {(item, index) => (
2109
2277
  <box
@@ -2118,7 +2286,7 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
2118
2286
  }
2119
2287
  onMouseDown={() => {
2120
2288
  setCursor(index())
2121
- choose()
2289
+ choose(item)
2122
2290
  }}
2123
2291
  >
2124
2292
  <text
@@ -2154,14 +2322,14 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
2154
2322
  : api.theme.current.textMuted,
2155
2323
  }}
2156
2324
  >
2157
- {prettyDir(item.entry.dir, home)} · {ago(item.entry.updated)}
2325
+ {prettyDir(item.entry.dir, home)} · {item.since === undefined ? "time unknown" : `waiting ${ago(item.since)}`}
2158
2326
  </text>
2159
2327
  </box>
2160
2328
  )}
2161
2329
  </For>
2162
- </box>
2330
+ </scrollbox>
2163
2331
  <box flexShrink={0}>
2164
- <text style={{ fg: api.theme.current.textMuted }}>↑↓ navigate · enter open · esc close</text>
2332
+ <text style={{ fg: api.theme.current.textMuted }}>↑↓ navigate · enter open · n open next waiting · esc close</text>
2165
2333
  </box>
2166
2334
  </box>
2167
2335
  ),