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 +14 -6
- package/CHANGELOG.md +22 -1
- package/README.md +14 -8
- package/bin/sesh-list.sh +4 -2
- package/bin/sesh-shortcuts.sh +2 -2
- package/bin/sesh.sh +2 -2
- package/package.json +1 -1
- package/tui/sesh-panel.tsx +259 -91
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.
|
|
87
|
-
session;
|
|
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
|
|
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
|
-
|
|
145
|
-
|
|
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.
|
|
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.
|
|
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.**
|
|
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
|
-
|
|
|
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-
|
|
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,
|
|
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
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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: "",
|
package/bin/sesh-shortcuts.sh
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
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="
|
|
150
|
-
--bind="
|
|
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
package/tui/sesh-panel.tsx
CHANGED
|
@@ -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
|
|
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
|
|
258
|
-
//
|
|
259
|
-
function
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
894
|
+
return sidebarMarker(id === currentID(), live)
|
|
827
895
|
}
|
|
828
896
|
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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: "
|
|
959
|
-
{ key: "
|
|
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: "
|
|
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: "
|
|
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
|
|
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
|
|
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
|
-
:
|
|
1251
|
+
: marker() === "current"
|
|
1173
1252
|
? theme().accent
|
|
1174
|
-
:
|
|
1253
|
+
: marker() === "running"
|
|
1254
|
+
? theme().success
|
|
1255
|
+
: theme().textMuted,
|
|
1175
1256
|
}}
|
|
1176
1257
|
>
|
|
1177
|
-
{
|
|
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 :
|
|
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 ·
|
|
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: "
|
|
1282
|
-
{ key: "
|
|
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
|
|
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
|
-
})
|
|
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
|
-
|
|
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: "
|
|
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: "
|
|
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="
|
|
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
|
-
{
|
|
1880
|
-
|
|
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
|
-
: "↑↓
|
|
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
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
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
|
|
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
|
-
<
|
|
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.
|
|
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
|
-
</
|
|
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
|
),
|