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