opencode-sesh 0.8.0 → 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
@@ -160,7 +160,13 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
160
160
  at `SESSION_MAX` and reported when truncated) instead of a fixed 500-row
161
161
  window, then indexes transcript text for **every** session in batches
162
162
  (`TRANSCRIPT_BATCH`) with bounded remote concurrency (`REMOTE_CONCURRENCY`).
163
- 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.
164
170
  - **Durable install:** opencode imports plugins once at startup and never hot
165
171
  reloads, so panel changes need a full quit/reopen. `install.sh --sync-panel`
166
172
  (run by the `postinstall`) refreshes an already-installed panel and is a
package/CHANGELOG.md CHANGED
@@ -7,6 +7,15 @@ 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`.
10
19
  ## [0.8.0] - 2026-09-24
11
20
 
12
21
  ### Changed
@@ -333,7 +342,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
333
342
  - Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
334
343
  CI.
335
344
 
336
- [Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.8.0...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
337
347
  [0.8.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.7.3...v0.8.0
338
348
  [0.7.3]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.7.2...v0.7.3
339
349
  [0.7.2]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.7.1...v0.7.2
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
 
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-sesh",
3
- "version": "0.8.0",
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",
@@ -402,6 +402,43 @@ async function queryWaitingIds(db: any): Promise<Map<string, string>> {
402
402
  return out
403
403
  }
404
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
+
405
442
  function addTranscript(index: Map<string, string>, sid: string, data: string): void {
406
443
  try {
407
444
  const part = JSON.parse(data) as { type?: unknown; text?: unknown }
@@ -484,9 +521,14 @@ async function buildSearchIndexRemote(
484
521
  const base = index.get(id) ?? ""
485
522
  try {
486
523
  const raw = await readFile(`${cacheDir}/extractions/${id}.json`, "utf8")
487
- const record = JSON.parse(raw) as { fulltextLower?: unknown }
524
+ const record = JSON.parse(raw) as { fulltextLower?: unknown; title?: unknown }
488
525
  const fulltext = typeof record?.fulltextLower === "string" ? record.fulltextLower : ""
489
- 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())
490
532
  } catch {
491
533
  try {
492
534
  const result = await api.client.session.messages({ sessionID: id })
@@ -508,6 +550,36 @@ async function buildSearchIndexRemote(
508
550
  await Promise.all(Array.from({ length: Math.min(REMOTE_CONCURRENCY, ids.length) }, worker))
509
551
  }
510
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
+
511
583
  const TRANSCRIPT_PREVIEW_TURNS = 8
512
584
  const TRANSCRIPT_PREVIEW_CHARS = 2000
513
585
 
@@ -1402,6 +1474,11 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
1402
1474
 
1403
1475
  const tui: TuiPlugin = async (api) => {
1404
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
+ }
1405
1482
  let pinVersion = 0
1406
1483
  const refreshPins = () => {
1407
1484
  const version = pinVersion
@@ -1454,9 +1531,34 @@ const tui: TuiPlugin = async (api) => {
1454
1531
  const [allEntries, setAllEntries] = createSignal<Entry[]>(entries)
1455
1532
  const [previewText, setPreviewText] = createSignal("")
1456
1533
  const [showPreview, setShowPreview] = createSignal(false)
1457
- const [query, setQuery] = createSignal("")
1534
+ const [query, setQuery] = createSignal(pickerState.query)
1458
1535
  const [cursor, setCursor] = createSignal(0)
1459
- 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
+ })()
1460
1562
  const [pendingDelete, setPendingDelete] = createSignal<Entry>()
1461
1563
  const [searchIndex, setSearchIndex] = createSignal<Map<string, string>>(new Map())
1462
1564
  const [indexProgress, setIndexProgress] = createSignal<IndexProgress>({
@@ -1465,6 +1567,7 @@ const tui: TuiPlugin = async (api) => {
1465
1567
  complete: false,
1466
1568
  })
1467
1569
  void buildSearchIndex(api, entries, (progress, index) => {
1570
+ if (!alive) return
1468
1571
  setIndexProgress(progress)
1469
1572
  // Publish a fresh snapshot: the indexing workers mutate their own map,
1470
1573
  // while Solid needs a new reference to update search results mid-scan.
@@ -1473,16 +1576,18 @@ const tui: TuiPlugin = async (api) => {
1473
1576
  type DialogRow = { kind: "group"; label: string; count: number } | { kind: "item"; entry: Entry }
1474
1577
 
1475
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
+ }
1476
1585
  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
- })
1586
+ const matched = filterPickerEntries(
1587
+ orderedEntries(),
1588
+ { query: query(), scope: scope(), waitingOnly: waitingOnly(), pinnedOnly: pinnedOnly() },
1589
+ searchIndex(), waitingIds(), pins().sessions,
1590
+ )
1486
1591
  const byGroup = new Map<string, Entry[]>()
1487
1592
  for (const entry of matched) {
1488
1593
  const list = byGroup.get(entry.group) ?? []
@@ -1507,6 +1612,8 @@ const tui: TuiPlugin = async (api) => {
1507
1612
  const progress = indexProgress()
1508
1613
  const bits: string[] = []
1509
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")
1510
1617
  if (progress.total > 0) {
1511
1618
  bits.push(
1512
1619
  progress.complete
@@ -1542,7 +1649,9 @@ const tui: TuiPlugin = async (api) => {
1542
1649
 
1543
1650
  const visiblePickerRows = createMemo<DialogRow[]>(() => {
1544
1651
  const flat = pickerRows()
1545
- 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
1546
1655
  const currentID = selectableEntries()[cursor()]?.id
1547
1656
  let pos = currentID
1548
1657
  ? flat.findIndex((row) => row.kind === "item" && row.entry.id === currentID)
@@ -1736,6 +1845,8 @@ const tui: TuiPlugin = async (api) => {
1736
1845
  else if (target) setScope(target.group)
1737
1846
  },
1738
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) },
1739
1850
  {
1740
1851
  key: "escape",
1741
1852
  desc: "Close",
@@ -1749,6 +1860,8 @@ const tui: TuiPlugin = async (api) => {
1749
1860
  })
1750
1861
 
1751
1862
  const cleanup = () => {
1863
+ alive = false
1864
+ pickerState = { query: query(), scope: scope(), waitingOnly: waitingOnly(), pinnedOnly: pinnedOnly() }
1752
1865
  disposeNav()
1753
1866
  disposeConfirm?.()
1754
1867
  disposeConfirm = undefined
@@ -1791,6 +1904,21 @@ const tui: TuiPlugin = async (api) => {
1791
1904
  <text style={{ fg: api.theme.current.textMuted }}>{coverageLabel()}</text>
1792
1905
  </box>
1793
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>
1794
1922
  </box>
1795
1923
  <box
1796
1924
  flexDirection="column"
@@ -1801,17 +1929,18 @@ const tui: TuiPlugin = async (api) => {
1801
1929
  }}
1802
1930
  >
1803
1931
  <For each={visiblePickerRows()}>
1804
- {(row) =>
1805
- row.kind === "group" ? (
1932
+ {(row) => {
1933
+ if (row.kind === "group") return (
1806
1934
  <box paddingTop={1} paddingLeft={4}>
1807
1935
  <text style={{ fg: api.theme.current.accent }} attributes={TextAttributes.BOLD}>
1808
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>
1809
1937
  </text>
1810
1938
  </box>
1811
- ) : (
1939
+ )
1940
+ const excerpt = createMemo(() => transcriptMatchExcerpt(searchIndex().get(row.entry.id), row.entry, query()))
1941
+ return (
1812
1942
  <box
1813
- flexDirection="row"
1814
- gap={1}
1943
+ flexDirection="column"
1815
1944
  paddingLeft={4}
1816
1945
  paddingRight={4}
1817
1946
  backgroundColor={
@@ -1821,6 +1950,7 @@ const tui: TuiPlugin = async (api) => {
1821
1950
  }
1822
1951
  onMouseDown={() => choose(row.entry)}
1823
1952
  >
1953
+ <box flexDirection="row" gap={1}>
1824
1954
  <Show when={row.entry.id === currentSessionID()}>
1825
1955
  <text
1826
1956
  flexShrink={0}
@@ -1869,17 +1999,39 @@ const tui: TuiPlugin = async (api) => {
1869
1999
  : ""
1870
2000
  }`}
1871
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>
1872
2016
  </box>
1873
2017
  )
1874
- }
2018
+ }}
1875
2019
  </For>
1876
2020
  <Show when={selectableEntries().length === 0}>
1877
2021
  <box paddingLeft={4} paddingRight={4}>
1878
2022
  <text style={{ fg: api.theme.current.textMuted }}>
1879
- {!indexProgress().complete
1880
- ? "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…"
1881
2031
  : scope()
1882
2032
  ? `No matches in ${scope()} · ctrl+g for all projects`
2033
+ : waitingOnly()
2034
+ ? "No sessions need input with these filters"
1883
2035
  : "No sessions match · try another search"}
1884
2036
  </text>
1885
2037
  </box>
@@ -1909,7 +2061,7 @@ const tui: TuiPlugin = async (api) => {
1909
2061
  </scrollbox>
1910
2062
  </box>
1911
2063
  </Show>
1912
- <box paddingLeft={4} paddingRight={4} flexShrink={0}>
2064
+ <box paddingLeft={4} paddingRight={4} flexShrink={0} flexDirection="column">
1913
2065
  <text
1914
2066
  style={{
1915
2067
  fg: pendingDelete() ? api.theme.current.warning : api.theme.current.textMuted,
@@ -1917,8 +2069,11 @@ const tui: TuiPlugin = async (api) => {
1917
2069
  >
1918
2070
  {pendingDelete()
1919
2071
  ? `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"}
2072
+ : "↑↓ move · enter open · ctrl+g project · option+w needs · option+p pinned · ctrl+p preview"}
1921
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>
1922
2077
  </box>
1923
2078
  </box>
1924
2079
  ),
@@ -2043,27 +2198,32 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
2043
2198
 
2044
2199
  // Triage dialog: the waiting set as an openable list. Enter opens the
2045
2200
  // highlighted session; the sidebar section stays the always-visible view.
2201
+ let lastNeedsID: string | undefined
2046
2202
  const openNeeds = async () => {
2047
2203
  if (api.mode.current() !== BASE_MODE) return
2048
2204
  if (api.renderer.currentFocusedEditor === null) return
2049
- let items: { entry: Entry; reason: string }[] = []
2205
+ let items: { entry: Entry; reason: string; since?: number }[] = []
2050
2206
  try {
2051
2207
  const { entries } = await fetchEntries(api)
2052
2208
  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
- }
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()
2060
2218
  }
2061
2219
  items = entries
2062
2220
  .filter((entry) => waiting.has(entry.id))
2063
2221
  .map((entry) => ({
2064
2222
  entry,
2065
- 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,
2066
2225
  }))
2226
+ .sort((a, b) => (a.since ?? a.entry.updated) - (b.since ?? b.entry.updated))
2067
2227
  } catch {
2068
2228
  api.ui.toast({ message: "Could not load sessions", variant: "error" })
2069
2229
  return
@@ -2072,12 +2232,15 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
2072
2232
  api.ui.toast({ message: "No sessions are waiting on you.", variant: "info" })
2073
2233
  return
2074
2234
  }
2075
- 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)
2076
2239
  const moveCursor = (delta: number) =>
2077
2240
  setCursor((value) => Math.max(0, Math.min(items.length - 1, value + delta)))
2078
- const choose = () => {
2079
- const target = items[cursor()]
2241
+ const choose = (target = items[cursor()]) => {
2080
2242
  if (!target) return
2243
+ lastNeedsID = target.entry.id
2081
2244
  disposeNav()
2082
2245
  api.ui.dialog.clear()
2083
2246
  api.route.navigate("session", { sessionID: target.entry.id })
@@ -2087,6 +2250,10 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
2087
2250
  { key: "up", desc: "Previous session", preventDefault: true, cmd: () => moveCursor(-1) },
2088
2251
  { key: "down", desc: "Next session", preventDefault: true, cmd: () => moveCursor(1) },
2089
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
+ } },
2090
2257
  {
2091
2258
  key: "escape",
2092
2259
  desc: "Close",
@@ -2101,9 +2268,9 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
2101
2268
  <box flexDirection="column" paddingLeft={4} paddingRight={4} paddingTop={1} gap={1}>
2102
2269
  <text attributes={TextAttributes.BOLD}>
2103
2270
  Needs input{" "}
2104
- <span style={{ fg: api.theme.current.textMuted }}>({items.length})</span>
2271
+ <span style={{ fg: api.theme.current.textMuted }}>({items.length} · longest waiting first)</span>
2105
2272
  </text>
2106
- <box flexDirection="column">
2273
+ <scrollbox flexGrow={1} height={Math.max(6, Math.floor(api.renderer.height / 2) - 10)} scrollbarOptions={{ visible: false }}>
2107
2274
  <For each={items}>
2108
2275
  {(item, index) => (
2109
2276
  <box
@@ -2118,7 +2285,7 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
2118
2285
  }
2119
2286
  onMouseDown={() => {
2120
2287
  setCursor(index())
2121
- choose()
2288
+ choose(item)
2122
2289
  }}
2123
2290
  >
2124
2291
  <text
@@ -2154,14 +2321,14 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
2154
2321
  : api.theme.current.textMuted,
2155
2322
  }}
2156
2323
  >
2157
- {prettyDir(item.entry.dir, home)} · {ago(item.entry.updated)}
2324
+ {prettyDir(item.entry.dir, home)} · {item.since === undefined ? "time unknown" : `waiting ${ago(item.since)}`}
2158
2325
  </text>
2159
2326
  </box>
2160
2327
  )}
2161
2328
  </For>
2162
- </box>
2329
+ </scrollbox>
2163
2330
  <box flexShrink={0}>
2164
- <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>
2165
2332
  </box>
2166
2333
  </box>
2167
2334
  ),