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 +7 -1
- package/CHANGELOG.md +11 -1
- package/README.md +7 -1
- package/package.json +1 -1
- package/tui/sesh-panel.tsx +209 -42
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.
|
|
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.
|
|
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
package/tui/sesh-panel.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
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
|
-
|
|
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="
|
|
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
|
-
{
|
|
1880
|
-
|
|
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
|
-
: "↑↓
|
|
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
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
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
|
|
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
|
-
<
|
|
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.
|
|
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
|
-
</
|
|
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
|
),
|