opencode-sesh 0.9.1 → 0.11.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 -4
- package/CHANGELOG.md +22 -1
- package/README.md +15 -8
- package/install.sh +1 -1
- package/package.json +5 -4
- package/tui/sesh-panel.tsx +214 -89
package/AGENTS.md
CHANGED
|
@@ -11,7 +11,7 @@ territory: read this first, then the specific file you need.
|
|
|
11
11
|
any terminal. Lists **all** sessions across **all** project directories,
|
|
12
12
|
full-text searchable, with transcript preview and resume/fork/delete.
|
|
13
13
|
2. **`tui/sesh-panel.tsx`** — a SolidJS/OpenTUI plugin for the opencode TUI: a
|
|
14
|
-
recent-sessions section in the sidebar plus
|
|
14
|
+
recent-sessions section in the sidebar plus an `option+o` / `/sesh` picker.
|
|
15
15
|
3. **`opencode/tools/sesh-list.ts`** — the `sesh-list` custom tool, so the agent
|
|
16
16
|
can list sessions across every directory by querying the global `opencode
|
|
17
17
|
db` store (the native `opencode session list` is project-scoped).
|
|
@@ -144,7 +144,7 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
|
|
|
144
144
|
by recency; sessions within each group sort by last update regardless of pins.
|
|
145
145
|
Row markers alone signal session state: a running agent animates a loading
|
|
146
146
|
spinner marker, the open session shows its own ●, and every other idle row
|
|
147
|
-
stays neutral (polled every 15 s);
|
|
147
|
+
stays neutral (polled every 15 s); Option-P previews
|
|
148
148
|
a row and ctrl+x deletes one (armed, then confirmed). The section lists every
|
|
149
149
|
session without a row cap and flex-grows to fill the sidebar, with overflow
|
|
150
150
|
scrolling inside; the picker remains the place for transcript search.
|
|
@@ -153,7 +153,7 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
|
|
|
153
153
|
same `NEEDS_INPUT_SQL` heuristic as `bin/sesh-waiting.sh`.
|
|
154
154
|
- **Picker:** a custom dialog (not `DialogSelect`) grouped by
|
|
155
155
|
project/directory, recency-ordered, resume via
|
|
156
|
-
`api.route.navigate("session", …)`. `
|
|
156
|
+
`api.route.navigate("session", …)`. `option+o` and the command palette open it;
|
|
157
157
|
Ctrl-X arms and then confirms a delete through `api.client.session.delete` and
|
|
158
158
|
removes the row only after the server confirms; Ctrl-F forks through
|
|
159
159
|
`api.client.session.fork`; Ctrl-G scopes the list to the selected session's
|
|
@@ -164,7 +164,7 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
|
|
|
164
164
|
(`TRANSCRIPT_BATCH`) with bounded remote concurrency (`REMOTE_CONCURRENCY`).
|
|
165
165
|
The header shows indexing progress/coverage. Visible transcript hits get
|
|
166
166
|
short excerpts from the text-only index (never tool or reasoning payloads).
|
|
167
|
-
Project, needs-input (Option-W), and pinned-session (Option-
|
|
167
|
+
Project, needs-input (Option-W), and pinned-session (Option-S) filters compose
|
|
168
168
|
with the query; the picker remembers query/scope/filters until plugin restart.
|
|
169
169
|
`/sesh-needs` uses the shared needs-input set plus the oldest unresolved
|
|
170
170
|
question or running-tool timestamp, lists longest-waiting first, and `n`
|
|
@@ -181,6 +181,13 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
|
|
|
181
181
|
`dialog.replace(...)`; `replace` resets the size to medium.
|
|
182
182
|
- Use the `backgroundColor` **prop** on boxes, never `style={{backgroundColor}}`.
|
|
183
183
|
- No percentage heights/widths in the picker; size the list window explicitly.
|
|
184
|
+
- The picker windows by visible terminal lines: directory headings take two
|
|
185
|
+
lines, transcript excerpts can take a second line, and a sticky directory
|
|
186
|
+
heading keeps a scrolled group identifiable. Wheel scroll follows every
|
|
187
|
+
OpenTUI delta and changes only the viewport; real mouse movement changes
|
|
188
|
+
selection without recentering it.
|
|
189
|
+
Option-P previews in a full-size overlay over the picker; Esc first closes the
|
|
190
|
+
preview, then the picker on the next press.
|
|
184
191
|
- An inline `<input>` in a slot does **not** receive keyboard focus. Sidebar and
|
|
185
192
|
home search are keymap-captured queries (priority-20 base layer, letters /
|
|
186
193
|
space / backspace, Esc exits); click the box to activate, click elsewhere to
|
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.11.0] - 2026-09-25
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- OpenCode TUI shortcuts use Option-O for the picker and Option-P for previews;
|
|
15
|
+
Option-S filters pinned sessions, and the Option-W hint now says "input".
|
|
16
|
+
- The TUI picker groups sessions in collapsible directory trees, with the
|
|
17
|
+
directory path on a separate heading and branch markers on session rows.
|
|
18
|
+
- Mouse-wheel scrolling follows OpenTUI's native per-event speed without moving
|
|
19
|
+
the selection under a stationary pointer. The transcript preview now covers
|
|
20
|
+
the entire picker, with arrow keys to switch sessions while it is open.
|
|
21
|
+
## [0.10.0] - 2026-09-25
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- The TUI session picker is taller (about three quarters of the screen) and
|
|
26
|
+
stays vertically centred; hovering a row moves the selection.
|
|
27
|
+
- `ctrl+p` opens the transcript preview as a centred overlay on top of the
|
|
28
|
+
picker that follows the hovered session and closes with Esc.
|
|
10
29
|
## [0.9.1] - 2026-09-25
|
|
11
30
|
|
|
12
31
|
### Changed
|
|
@@ -352,7 +371,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
352
371
|
- Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
|
|
353
372
|
CI.
|
|
354
373
|
|
|
355
|
-
[Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.
|
|
374
|
+
[Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.11.0...HEAD
|
|
375
|
+
[0.11.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.10.0...v0.11.0
|
|
376
|
+
[0.10.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.9.1...v0.10.0
|
|
356
377
|
[0.9.1]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.9.0...v0.9.1
|
|
357
378
|
[0.9.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.8.0...v0.9.0
|
|
358
379
|
[0.8.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.7.3...v0.8.0
|
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# sesh
|
|
2
2
|
|
|
3
|
+
[](https://github.com/ozandogrultan/opencode-sesh/actions/workflows/ci.yml)
|
|
4
|
+
|
|
3
5
|
**A session browser for [opencode](https://opencode.ai).** Search every session
|
|
4
6
|
across every project, preview the transcript, and resume in place — from your
|
|
5
7
|
terminal or from inside the TUI.
|
|
@@ -39,8 +41,8 @@ grouped by directory, full-text searchable, and previewable from anywhere.
|
|
|
39
41
|
- **Resume in place.** Enter `exec`s `opencode --session <id>` in the session's
|
|
40
42
|
own directory. Ctrl-F forks instead. Your terminal becomes the session — no
|
|
41
43
|
tabs, no panes, no window management.
|
|
42
|
-
- **Native TUI, too.** A recent-sessions section in the opencode sidebar plus
|
|
43
|
-
`
|
|
44
|
+
- **Native TUI, too.** A recent-sessions section in the opencode sidebar plus an
|
|
45
|
+
`option+o` picker, both themed by your opencode theme.
|
|
44
46
|
- **Fast.** The database is scanned once per refresh and cached; keystrokes only
|
|
45
47
|
re-render the last snapshot, so typing never triggers a query storm.
|
|
46
48
|
|
|
@@ -85,7 +87,7 @@ sesh # terminal picker
|
|
|
85
87
|
|
|
86
88
|
```text
|
|
87
89
|
/sesh # inside opencode — picker (TUI plugin)
|
|
88
|
-
|
|
90
|
+
option+o # inside opencode — picker
|
|
89
91
|
```
|
|
90
92
|
|
|
91
93
|
### Requirements
|
|
@@ -167,22 +169,27 @@ non-interactive `sesh-delete.sh` refuses unless given `--yes`.
|
|
|
167
169
|
| `/sesh` | Open the full picker |
|
|
168
170
|
| `/sesh-costs` | Per-project cost digest (24h beside lifetime) |
|
|
169
171
|
| `/sesh-needs` | Sessions waiting on you (Enter opens) |
|
|
170
|
-
| `
|
|
172
|
+
| `option+o` | Open the full picker (also in the command palette) |
|
|
171
173
|
| Type | Search titles, directories and transcript text |
|
|
172
174
|
| `↑`/`↓`, `PgUp`/`PgDn`, `Home`/`End` | Move the selection |
|
|
173
|
-
|
|
|
175
|
+
| Option-P | Toggle the transcript preview |
|
|
174
176
|
| Enter | Open the selected session |
|
|
175
177
|
| Ctrl-X | Delete the selected session (asks to confirm) |
|
|
176
178
|
| Ctrl-F | Fork the selected session |
|
|
177
179
|
| Ctrl-G | Toggle scope: the selected session's project, or every project |
|
|
178
|
-
| Option-W / Option-
|
|
180
|
+
| Option-W / Option-S | Show only sessions needing input / pinned sessions |
|
|
179
181
|
| Ctrl-S / Ctrl-D | Pin or unpin the selected session / directory |
|
|
180
|
-
| Esc | Close |
|
|
182
|
+
| Esc | Close the preview first, then the picker |
|
|
183
|
+
|
|
184
|
+
The picker groups sessions under collapsible directory headings (click ▾/▸).
|
|
185
|
+
Mouse-wheel scrolling moves through the tree without changing the selection
|
|
186
|
+
under a stationary pointer. Option-P opens a full-size transcript preview over
|
|
187
|
+
the picker; use ↑/↓ to preview other sessions, then Esc to return to the list.
|
|
181
188
|
|
|
182
189
|
Click the sidebar's `search…` box to filter recent sessions in place; click
|
|
183
190
|
elsewhere or press Esc to leave search. Click the **Sessions** heading to drive
|
|
184
191
|
the list from the keyboard instead of the mouse (`↑`/`↓` to move, Enter to open,
|
|
185
|
-
|
|
192
|
+
Option-P to preview, Ctrl-S/Ctrl-D to pin, Ctrl-X to delete, Esc to leave). Pinned
|
|
186
193
|
directories rise first, while sessions within each directory are ordered by
|
|
187
194
|
last update, newest first. The section lists every session and scrolls to fill
|
|
188
195
|
the sidebar, so click **▾ hide** to collapse it when you need the room.
|
package/install.sh
CHANGED
|
@@ -298,7 +298,7 @@ fi
|
|
|
298
298
|
"$LAUNCHER" --check || fail "post-install check failed."
|
|
299
299
|
note "done"
|
|
300
300
|
note " picker: run 'sesh' in any terminal, or '/sesh' inside opencode"
|
|
301
|
-
note " sidebar:
|
|
301
|
+
note " sidebar: option+o opens the full picker"
|
|
302
302
|
note_restart
|
|
303
303
|
case ":$PATH:" in
|
|
304
304
|
*":$BIN_DIR:"*) ;;
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-sesh",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"type": "module",
|
|
3
|
+
"version": "0.11.0",
|
|
5
4
|
"description": "Browse, search, preview and resume opencode sessions from the terminal and the TUI.",
|
|
5
|
+
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "Ozan Dogrultan",
|
|
8
8
|
"repository": {
|
|
@@ -44,12 +44,13 @@
|
|
|
44
44
|
},
|
|
45
45
|
"scripts": {
|
|
46
46
|
"prepare": "husky",
|
|
47
|
-
"postinstall": "bash install.sh --sync-panel || true",
|
|
48
47
|
"test": "bash tests/sesh.sh && bash tests/changelog.sh",
|
|
48
|
+
"test:unit": "bash tests/sesh.sh",
|
|
49
49
|
"test:changelog": "bash tests/changelog.sh",
|
|
50
50
|
"test:picker": "python3 tests/picker.py",
|
|
51
|
-
"typecheck": "tsc
|
|
51
|
+
"typecheck": "tsc --noEmit",
|
|
52
52
|
"lint:sh": "for script in bin/*.sh bin/sesh install.sh tests/*.sh scripts/*.sh; do bash -n \"$script\" || exit; done",
|
|
53
|
+
"postinstall": "bash install.sh --sync-panel || true",
|
|
53
54
|
"prepublishOnly": "bun run test && bun run typecheck"
|
|
54
55
|
},
|
|
55
56
|
"devDependencies": {
|
package/tui/sesh-panel.tsx
CHANGED
|
@@ -167,8 +167,8 @@ function pinnedFirst(entries: Entry[], pins: Pins): Entry[] {
|
|
|
167
167
|
// 1. Sidebar (always visible): a compact recent-sessions section appended to
|
|
168
168
|
// the native sidebar via the `sidebar_content` slot. Native session_list
|
|
169
169
|
// (<leader>l) is left untouched; this complements it with a cross-project
|
|
170
|
-
// view. Rows are display-only;
|
|
171
|
-
// 2. Picker (
|
|
170
|
+
// view. Rows are display-only; option+o opens the full picker.
|
|
171
|
+
// 2. Picker (option+o, `/sesh`, command palette): an xlarge grouped picker over
|
|
172
172
|
// every session across all project directories, newest first.
|
|
173
173
|
//
|
|
174
174
|
// `/sesh` is registered by this plugin (a markdown command cannot open the
|
|
@@ -214,7 +214,7 @@ const HOME_TITLE_WIDTH = 30
|
|
|
214
214
|
|
|
215
215
|
// The sidebar lists every session (newest first, pins ahead) and fills
|
|
216
216
|
// whatever vertical space it gets: overflow scrolls inside the stretched
|
|
217
|
-
// scrollbox. The picker (
|
|
217
|
+
// scrollbox. The picker (option+o) remains the place for transcript search.
|
|
218
218
|
|
|
219
219
|
// Uppercase is included deliberately: a search box that silently drops shifted
|
|
220
220
|
// letters cannot be used for acronyms or paths like README.
|
|
@@ -729,6 +729,44 @@ type TreeRow =
|
|
|
729
729
|
| { kind: "group"; dir: string; label: string; count: number }
|
|
730
730
|
| { kind: "item"; entry: Entry; last: boolean }
|
|
731
731
|
|
|
732
|
+
type PickerRow =
|
|
733
|
+
| { kind: "group"; dir: string; label: string; count: number; pinned: boolean; continued?: boolean }
|
|
734
|
+
| { kind: "item"; entry: Entry; last: boolean }
|
|
735
|
+
|
|
736
|
+
// Window by terminal lines, not session count: group spacing and transcript
|
|
737
|
+
// excerpts each consume an extra line. Carry the directory heading forward
|
|
738
|
+
// when a long group starts above the viewport.
|
|
739
|
+
function pickerWindow(rows: PickerRow[], start: number, height: number, excerpt: (entry: Entry) => boolean): PickerRow[] {
|
|
740
|
+
if (!rows.length) return []
|
|
741
|
+
const first = Math.max(0, Math.min(start, rows.length - 1))
|
|
742
|
+
const shown: PickerRow[] = []
|
|
743
|
+
let remaining = height
|
|
744
|
+
if (rows[first].kind === "item") {
|
|
745
|
+
for (let i = first - 1; i >= 0; i--) {
|
|
746
|
+
const row = rows[i]
|
|
747
|
+
if (row.kind !== "group") continue
|
|
748
|
+
shown.push({ ...row, continued: true })
|
|
749
|
+
remaining--
|
|
750
|
+
break
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
for (let i = first; i < rows.length; i++) {
|
|
754
|
+
const row = rows[i]
|
|
755
|
+
const lines = row.kind === "group" ? 2 : excerpt(row.entry) ? 2 : 1
|
|
756
|
+
if (lines > remaining && shown.length) break
|
|
757
|
+
shown.push(row)
|
|
758
|
+
remaining -= lines
|
|
759
|
+
}
|
|
760
|
+
return shown
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function pickerLastStart(rows: PickerRow[], height: number, excerpt: (entry: Entry) => boolean): number {
|
|
764
|
+
if (!rows.length) return 0
|
|
765
|
+
let start = rows.length - 1
|
|
766
|
+
while (start > 0 && pickerWindow(rows, start - 1, height, excerpt).at(-1) === rows.at(-1)) start--
|
|
767
|
+
return start
|
|
768
|
+
}
|
|
769
|
+
|
|
732
770
|
type PinProps = { pins: () => Pins; onTogglePin: (kind: keyof Pins, value: string) => void; refreshPins: () => void }
|
|
733
771
|
|
|
734
772
|
function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
@@ -1021,7 +1059,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1021
1059
|
priority: 20,
|
|
1022
1060
|
bindings: [
|
|
1023
1061
|
{
|
|
1024
|
-
key: "
|
|
1062
|
+
key: "option+p",
|
|
1025
1063
|
desc: "Preview session transcript",
|
|
1026
1064
|
preventDefault: true,
|
|
1027
1065
|
cmd: () => openSidebarPreview(entry),
|
|
@@ -1065,7 +1103,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1065
1103
|
cmd: () => setCursor(Math.max(0, itemRows().length - 1)),
|
|
1066
1104
|
},
|
|
1067
1105
|
{
|
|
1068
|
-
key: "
|
|
1106
|
+
key: "option+p",
|
|
1069
1107
|
desc: "Preview session transcript",
|
|
1070
1108
|
preventDefault: true,
|
|
1071
1109
|
cmd: () => {
|
|
@@ -1167,7 +1205,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1167
1205
|
when={entries().length > 0}
|
|
1168
1206
|
fallback={
|
|
1169
1207
|
<text style={{ fg: theme().textMuted }}>
|
|
1170
|
-
{loadFailed() ? "Session list unavailable · will retry" : "No sessions yet ·
|
|
1208
|
+
{loadFailed() ? "Session list unavailable · will retry" : "No sessions yet · option+o to browse"}
|
|
1171
1209
|
</text>
|
|
1172
1210
|
}
|
|
1173
1211
|
>
|
|
@@ -1273,14 +1311,14 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1273
1311
|
</For>
|
|
1274
1312
|
</scrollbox>
|
|
1275
1313
|
<Show when={remaining() > 0}>
|
|
1276
|
-
<text style={{ fg: theme().textMuted }}>{`… ${remaining()} more ·
|
|
1314
|
+
<text style={{ fg: theme().textMuted }}>{`… ${remaining()} more · option+o for all`}</text>
|
|
1277
1315
|
</Show>
|
|
1278
1316
|
<Show when={query().trim() && filteredEntries().length === 0}>
|
|
1279
|
-
|
|
1317
|
+
<text style={{ fg: theme().textMuted }}>No title or directory matches · option+o to search transcripts</text>
|
|
1280
1318
|
</Show>
|
|
1281
1319
|
<Show when={navActive()}>
|
|
1282
1320
|
<box paddingTop={1}>
|
|
1283
|
-
<text style={{ fg: theme().textMuted }}>↑↓ move · enter open ·
|
|
1321
|
+
<text style={{ fg: theme().textMuted }}>↑↓ move · enter open · option+p preview · ctrl+s/d pin · ctrl+x delete · esc done</text>
|
|
1284
1322
|
</box>
|
|
1285
1323
|
</Show>
|
|
1286
1324
|
</Show>
|
|
@@ -1346,7 +1384,7 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1346
1384
|
priority: 20,
|
|
1347
1385
|
bindings: [
|
|
1348
1386
|
{
|
|
1349
|
-
key: "
|
|
1387
|
+
key: "option+p",
|
|
1350
1388
|
desc: "Preview session transcript",
|
|
1351
1389
|
preventDefault: true,
|
|
1352
1390
|
cmd: () => preview.open(entry),
|
|
@@ -1400,7 +1438,7 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1400
1438
|
<box flexDirection="row" gap={1}>
|
|
1401
1439
|
<text style={{ fg: theme().textMuted }}>Recent sessions</text>
|
|
1402
1440
|
<text style={{ fg: theme().textMuted }}>
|
|
1403
|
-
{loadFailed() ? "· list unavailable, will retry" : "· none yet,
|
|
1441
|
+
{loadFailed() ? "· list unavailable, will retry" : "· none yet, option+o to browse"}
|
|
1404
1442
|
</text>
|
|
1405
1443
|
</box>
|
|
1406
1444
|
}
|
|
@@ -1408,7 +1446,7 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1408
1446
|
<box flexDirection="row" justifyContent="space-between" gap={2}>
|
|
1409
1447
|
<box flexDirection="row" gap={1}>
|
|
1410
1448
|
<text style={{ fg: theme().textMuted }}>Recent sessions</text>
|
|
1411
|
-
<text style={{ fg: theme().textMuted }}>·
|
|
1449
|
+
<text style={{ fg: theme().textMuted }}>· option+o for all</text>
|
|
1412
1450
|
</box>
|
|
1413
1451
|
<box
|
|
1414
1452
|
flexDirection="row"
|
|
@@ -1466,7 +1504,7 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1466
1504
|
)}
|
|
1467
1505
|
</For>
|
|
1468
1506
|
<Show when={visible().length === 0}>
|
|
1469
|
-
|
|
1507
|
+
<text style={{ fg: theme().textMuted }}>No title or directory matches · option+o to search transcripts</text>
|
|
1470
1508
|
</Show>
|
|
1471
1509
|
</Show>
|
|
1472
1510
|
</box>
|
|
@@ -1534,6 +1572,8 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1534
1572
|
const [showPreview, setShowPreview] = createSignal(false)
|
|
1535
1573
|
const [query, setQuery] = createSignal(pickerState.query)
|
|
1536
1574
|
const [cursor, setCursor] = createSignal(0)
|
|
1575
|
+
const [viewportStart, setViewportStart] = createSignal(0)
|
|
1576
|
+
const [collapsed, setCollapsed] = createSignal<Record<string, boolean>>({})
|
|
1537
1577
|
const [scope, setScope] = createSignal<string | undefined>(
|
|
1538
1578
|
entries.some((entry) => entry.group === pickerState.scope) ? pickerState.scope : undefined,
|
|
1539
1579
|
)
|
|
@@ -1574,8 +1614,6 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1574
1614
|
// while Solid needs a new reference to update search results mid-scan.
|
|
1575
1615
|
setSearchIndex(new Map(index))
|
|
1576
1616
|
})
|
|
1577
|
-
type DialogRow = { kind: "group"; label: string; count: number } | { kind: "item"; entry: Entry }
|
|
1578
|
-
|
|
1579
1617
|
const orderedEntries = createMemo(() => pinnedFirst(allEntries(), pins()))
|
|
1580
1618
|
const currentProject = () => {
|
|
1581
1619
|
const route = api.route.current
|
|
@@ -1591,21 +1629,23 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1591
1629
|
)
|
|
1592
1630
|
const byGroup = new Map<string, Entry[]>()
|
|
1593
1631
|
for (const entry of matched) {
|
|
1594
|
-
const list = byGroup.get(entry.
|
|
1632
|
+
const list = byGroup.get(entry.dir) ?? []
|
|
1595
1633
|
list.push(entry)
|
|
1596
|
-
byGroup.set(entry.
|
|
1634
|
+
byGroup.set(entry.dir, list)
|
|
1597
1635
|
}
|
|
1598
|
-
const groups = [...byGroup.entries()].map(([
|
|
1636
|
+
const groups = [...byGroup.entries()].map(([dir, list]) => ({ dir, list: newestFirst(list) }))
|
|
1599
1637
|
const currentPins = pins()
|
|
1600
1638
|
const rank = (group: (typeof groups)[number]) =>
|
|
1601
|
-
|
|
1639
|
+
currentPins.directories.includes(group.dir) ? 2
|
|
1602
1640
|
: group.list.some((entry) => currentPins.sessions.includes(entry.id)) ? 1 : 0
|
|
1603
1641
|
return groups.sort((a, b) =>
|
|
1604
1642
|
rank(b) - rank(a) || Math.max(...b.list.map((entry) => entry.updated)) - Math.max(...a.list.map((entry) => entry.updated)),
|
|
1605
1643
|
)
|
|
1606
1644
|
})
|
|
1607
1645
|
|
|
1608
|
-
const selectableEntries = createMemo(() => matchedGroups().flatMap((g) =>
|
|
1646
|
+
const selectableEntries = createMemo(() => matchedGroups().flatMap((g) =>
|
|
1647
|
+
collapsed()[g.dir] && !query().trim() ? [] : g.list,
|
|
1648
|
+
))
|
|
1609
1649
|
|
|
1610
1650
|
// Visible bounding: say how much transcript text search actually covers and
|
|
1611
1651
|
// when the metadata walk hit its cap, instead of silently under-reporting.
|
|
@@ -1626,41 +1666,50 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1626
1666
|
return bits.join(" · ")
|
|
1627
1667
|
})
|
|
1628
1668
|
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
const rows = selectableEntries()
|
|
1632
|
-
const selected = previousSelection[cursor()]?.id
|
|
1633
|
-
const position = rows.findIndex((entry) => entry.id === selected)
|
|
1634
|
-
if (position >= 0 && position !== cursor()) setCursor(position)
|
|
1635
|
-
else if (cursor() >= rows.length) setCursor(Math.max(0, rows.length - 1))
|
|
1636
|
-
previousSelection = rows
|
|
1637
|
-
})
|
|
1638
|
-
|
|
1639
|
-
const pickerRows = createMemo<DialogRow[]>(() => {
|
|
1640
|
-
const flat: DialogRow[] = []
|
|
1669
|
+
const pickerRows = createMemo<PickerRow[]>(() => {
|
|
1670
|
+
const flat: PickerRow[] = []
|
|
1641
1671
|
for (const group of matchedGroups()) {
|
|
1642
|
-
flat.push({ kind: "group",
|
|
1643
|
-
|
|
1672
|
+
flat.push({ kind: "group", dir: group.dir, label: prettyDir(group.dir, process.env.HOME ?? ""),
|
|
1673
|
+
count: group.list.length, pinned: pins().directories.includes(group.dir) })
|
|
1674
|
+
if (collapsed()[group.dir] && !query().trim()) continue
|
|
1675
|
+
for (let i = 0; i < group.list.length; i++) {
|
|
1676
|
+
flat.push({ kind: "item", entry: group.list[i], last: i === group.list.length - 1 })
|
|
1677
|
+
}
|
|
1644
1678
|
}
|
|
1645
1679
|
return flat
|
|
1646
1680
|
})
|
|
1647
1681
|
|
|
1648
1682
|
const termHeight = api.renderer.height
|
|
1649
|
-
|
|
1683
|
+
// The dialog host anchors content a quarter of the way down the screen and
|
|
1684
|
+
// lets it size to content, so a taller picker drifts downward. Pin the
|
|
1685
|
+
// content height and offset it up by the same amount to keep it centred.
|
|
1686
|
+
const dialogWidth = Math.min(116, api.renderer.width - 2)
|
|
1687
|
+
const CHROME_ROWS = 12
|
|
1688
|
+
const listHeight = () => Math.max(6, Math.floor(termHeight * 0.75) - CHROME_ROWS)
|
|
1689
|
+
const dialogHeight = () => listHeight() + CHROME_ROWS
|
|
1690
|
+
const dialogOffset = () => Math.min(0, Math.floor(termHeight / 4 - dialogHeight() / 2 - 1))
|
|
1691
|
+
const hasExcerpt = (entry: Entry) => !!transcriptMatchExcerpt(searchIndex().get(entry.id), entry, query())
|
|
1692
|
+
const maxViewportStart = createMemo(() => pickerLastStart(pickerRows(), listHeight(), hasExcerpt))
|
|
1693
|
+
const visiblePickerRows = createMemo(() => pickerWindow(
|
|
1694
|
+
pickerRows(), Math.min(viewportStart(), maxViewportStart()), listHeight(), hasExcerpt,
|
|
1695
|
+
))
|
|
1650
1696
|
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
: 0
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1697
|
+
let previousSelection: Entry[] = []
|
|
1698
|
+
createEffect(() => {
|
|
1699
|
+
const rows = selectableEntries()
|
|
1700
|
+
const position = rows.findIndex((entry) => entry.id === previousSelection[cursor()]?.id)
|
|
1701
|
+
if (rows !== previousSelection && (rows.length !== previousSelection.length ||
|
|
1702
|
+
rows.some((entry, index) => entry.id !== previousSelection[index]?.id))) {
|
|
1703
|
+
// A search/filter/collapse changed the tree: reveal the retained row or
|
|
1704
|
+
// start at the first result rather than leaving a stale window open.
|
|
1705
|
+
const next = position >= 0 ? position : 0
|
|
1706
|
+
setCursor(next)
|
|
1707
|
+
const id = rows[next]?.id
|
|
1708
|
+
setViewportStart(position < 0 || next === 0 ? 0 : Math.max(0, pickerRows().findIndex(
|
|
1709
|
+
(row) => row.kind === "item" && row.entry.id === id,
|
|
1710
|
+
)))
|
|
1711
|
+
} else if (cursor() >= rows.length) setCursor(Math.max(0, rows.length - 1))
|
|
1712
|
+
previousSelection = rows
|
|
1664
1713
|
})
|
|
1665
1714
|
|
|
1666
1715
|
const previewCache = new Map<string, string>()
|
|
@@ -1711,11 +1760,30 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1711
1760
|
|
|
1712
1761
|
const togglePreview = () => setShowPreview((value) => !value)
|
|
1713
1762
|
|
|
1714
|
-
const
|
|
1763
|
+
const selectCursor = (index: number) => {
|
|
1715
1764
|
const count = selectableEntries().length
|
|
1716
1765
|
if (count === 0) return
|
|
1717
1766
|
cancelDelete()
|
|
1718
|
-
|
|
1767
|
+
const next = Math.max(0, Math.min(count - 1, index))
|
|
1768
|
+
const id = selectableEntries()[next].id
|
|
1769
|
+
const flat = pickerRows()
|
|
1770
|
+
const target = flat.findIndex((row) => row.kind === "item" && row.entry.id === id)
|
|
1771
|
+
let start = Math.min(viewportStart(), maxViewportStart())
|
|
1772
|
+
if (target < start) start = target
|
|
1773
|
+
else while (start < target && !pickerWindow(flat, start, listHeight(), hasExcerpt).some(
|
|
1774
|
+
(row) => row.kind === "item" && row.entry.id === id,
|
|
1775
|
+
)) start++
|
|
1776
|
+
setViewportStart(Math.max(0, Math.min(start, maxViewportStart())))
|
|
1777
|
+
setCursor(next)
|
|
1778
|
+
}
|
|
1779
|
+
const moveCursor = (delta: number) => selectCursor(cursor() + delta)
|
|
1780
|
+
|
|
1781
|
+
// Match OpenTUI's scrollbox: honor every wheel event's delta. The viewport
|
|
1782
|
+
// stays independent of selection, so fast scrolling cannot recenter on hover.
|
|
1783
|
+
const scrollPicker = (scroll?: { direction: string; delta: number }) => {
|
|
1784
|
+
if (showPreview() || !scroll || (scroll.direction !== "up" && scroll.direction !== "down")) return
|
|
1785
|
+
const amount = Math.trunc(scroll.delta) * (scroll.direction === "down" ? 1 : -1)
|
|
1786
|
+
setViewportStart((start) => Math.max(0, Math.min(maxViewportStart(), start + amount)))
|
|
1719
1787
|
}
|
|
1720
1788
|
|
|
1721
1789
|
// Delete confirmation lives in its own layer, registered only while a row
|
|
@@ -1727,6 +1795,22 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1727
1795
|
setPendingDelete(undefined)
|
|
1728
1796
|
}
|
|
1729
1797
|
|
|
1798
|
+
// Escape first closes the transcript overlay, then the picker. One Escape
|
|
1799
|
+
// can reach both the focused search input and the key layer (sometimes in
|
|
1800
|
+
// separate ticks), so ignore the duplicate that trails right behind.
|
|
1801
|
+
let escapeHandledAt = 0
|
|
1802
|
+
const handleEscape = () => {
|
|
1803
|
+
const now = Date.now()
|
|
1804
|
+
if (escapeHandledAt > now) return
|
|
1805
|
+
if (showPreview()) {
|
|
1806
|
+
setShowPreview(false)
|
|
1807
|
+
escapeHandledAt = now + 150
|
|
1808
|
+
return
|
|
1809
|
+
}
|
|
1810
|
+
cancelDelete()
|
|
1811
|
+
close()
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1730
1814
|
const deleteEntry = async (entry: Entry) => {
|
|
1731
1815
|
cancelDelete()
|
|
1732
1816
|
try {
|
|
@@ -1793,20 +1877,23 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1793
1877
|
}
|
|
1794
1878
|
|
|
1795
1879
|
const disposeNav = api.keymap.registerLayer({
|
|
1880
|
+
// Outrank the built-in dialog layer so Escape reaches this picker before
|
|
1881
|
+
// the dialog host closes the whole thing.
|
|
1882
|
+
priority: 1,
|
|
1796
1883
|
bindings: [
|
|
1797
1884
|
{ key: "up", desc: "Previous session", preventDefault: true, cmd: () => moveCursor(-1) },
|
|
1798
1885
|
{ key: "down", desc: "Next session", preventDefault: true, cmd: () => moveCursor(1) },
|
|
1799
1886
|
{ key: "pageup", desc: "Page up", preventDefault: true, cmd: () => moveCursor(-10) },
|
|
1800
1887
|
{ key: "pagedown", desc: "Page down", preventDefault: true, cmd: () => moveCursor(10) },
|
|
1801
|
-
{ key: "home", desc: "First session", preventDefault: true, cmd: () =>
|
|
1888
|
+
{ key: "home", desc: "First session", preventDefault: true, cmd: () => selectCursor(0) },
|
|
1802
1889
|
{
|
|
1803
1890
|
key: "end",
|
|
1804
1891
|
desc: "Last session",
|
|
1805
1892
|
preventDefault: true,
|
|
1806
|
-
cmd: () =>
|
|
1893
|
+
cmd: () => selectCursor(selectableEntries().length - 1),
|
|
1807
1894
|
},
|
|
1808
1895
|
{ key: "enter", desc: "Open session", preventDefault: true, cmd: () => choose() },
|
|
1809
|
-
{ key: "
|
|
1896
|
+
{ key: "option+p", desc: "Toggle transcript preview", preventDefault: true, cmd: togglePreview },
|
|
1810
1897
|
{ key: "ctrl+s", desc: "Pin session", preventDefault: true, cmd: () => {
|
|
1811
1898
|
const entry = selectableEntries()[cursor()]
|
|
1812
1899
|
if (entry) onTogglePin("sessions", entry.id)
|
|
@@ -1847,15 +1934,12 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1847
1934
|
},
|
|
1848
1935
|
},
|
|
1849
1936
|
{ key: "option+w", desc: "Filter needs input", preventDefault: true, cmd: () => setWaitingOnly((value) => !value) },
|
|
1850
|
-
{ key: "option+
|
|
1937
|
+
{ key: "option+s", desc: "Filter pinned sessions", preventDefault: true, cmd: () => setPinnedOnly((value) => !value) },
|
|
1851
1938
|
{
|
|
1852
1939
|
key: "escape",
|
|
1853
1940
|
desc: "Close",
|
|
1854
1941
|
preventDefault: true,
|
|
1855
|
-
cmd:
|
|
1856
|
-
cancelDelete()
|
|
1857
|
-
close()
|
|
1858
|
-
},
|
|
1942
|
+
cmd: handleEscape,
|
|
1859
1943
|
},
|
|
1860
1944
|
],
|
|
1861
1945
|
})
|
|
@@ -1871,7 +1955,14 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1871
1955
|
|
|
1872
1956
|
api.ui.dialog.replace(
|
|
1873
1957
|
() => (
|
|
1874
|
-
<box
|
|
1958
|
+
<box
|
|
1959
|
+
flexDirection="column"
|
|
1960
|
+
height={dialogHeight()}
|
|
1961
|
+
marginTop={dialogOffset()}
|
|
1962
|
+
paddingBottom={1}
|
|
1963
|
+
gap={1}
|
|
1964
|
+
backgroundColor={api.theme.current.backgroundPanel}
|
|
1965
|
+
>
|
|
1875
1966
|
<box paddingLeft={4} paddingRight={4}>
|
|
1876
1967
|
<box flexDirection="row" justifyContent="space-between">
|
|
1877
1968
|
<text attributes={TextAttributes.BOLD}>
|
|
@@ -1895,7 +1986,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1895
1986
|
onKeyDown={(event) => {
|
|
1896
1987
|
if (event.name === "escape") {
|
|
1897
1988
|
event.preventDefault()
|
|
1898
|
-
|
|
1989
|
+
handleEscape()
|
|
1899
1990
|
}
|
|
1900
1991
|
}}
|
|
1901
1992
|
/>
|
|
@@ -1923,35 +2014,47 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1923
2014
|
</box>
|
|
1924
2015
|
<box
|
|
1925
2016
|
flexDirection="column"
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
2017
|
+
flexGrow={1}
|
|
2018
|
+
overflow="hidden"
|
|
2019
|
+
onMouseScroll={(event: { scroll?: { direction: string; delta: number } }) => {
|
|
2020
|
+
scrollPicker(event.scroll)
|
|
1930
2021
|
}}
|
|
1931
2022
|
>
|
|
1932
2023
|
<For each={visiblePickerRows()}>
|
|
1933
2024
|
{(row) => {
|
|
1934
2025
|
if (row.kind === "group") return (
|
|
1935
|
-
<box paddingTop={1} paddingLeft={4}
|
|
1936
|
-
|
|
1937
|
-
|
|
2026
|
+
<box flexDirection="row" paddingTop={row.continued ? 0 : 1} paddingLeft={4} paddingRight={4}
|
|
2027
|
+
onMouseDown={() => setCollapsed((state) => ({ ...state, [row.dir]: !state[row.dir] }))}>
|
|
2028
|
+
<text flexGrow={1} flexShrink={1} wrapMode="none" style={{ fg: api.theme.current.accent }} attributes={TextAttributes.BOLD}>
|
|
2029
|
+
{collapsed()[row.dir] && !query().trim() ? "▸ " : "▾ "}{row.pinned ? "★ " : ""}{row.label}
|
|
1938
2030
|
</text>
|
|
2031
|
+
<text flexShrink={0} style={{ fg: api.theme.current.textMuted }}> ({row.count})</text>
|
|
1939
2032
|
</box>
|
|
1940
2033
|
)
|
|
1941
2034
|
const excerpt = createMemo(() => transcriptMatchExcerpt(searchIndex().get(row.entry.id), row.entry, query()))
|
|
1942
2035
|
return (
|
|
1943
2036
|
<box
|
|
1944
2037
|
flexDirection="column"
|
|
1945
|
-
paddingLeft={
|
|
2038
|
+
paddingLeft={6}
|
|
1946
2039
|
paddingRight={4}
|
|
1947
2040
|
backgroundColor={
|
|
1948
2041
|
row.entry.id === selectableEntries()[cursor()]?.id
|
|
1949
2042
|
? api.theme.current.primary
|
|
1950
2043
|
: RGBA.fromInts(0, 0, 0, 0)
|
|
1951
2044
|
}
|
|
2045
|
+
onMouseMove={() => {
|
|
2046
|
+
// Repainting under a stationary pointer must not select
|
|
2047
|
+
// another row while the wheel is moving the viewport.
|
|
2048
|
+
const index = selectableEntries().findIndex((entry) => entry.id === row.entry.id)
|
|
2049
|
+
if (index >= 0) setCursor(index)
|
|
2050
|
+
}}
|
|
1952
2051
|
onMouseDown={() => choose(row.entry)}
|
|
1953
2052
|
>
|
|
1954
2053
|
<box flexDirection="row" gap={1}>
|
|
2054
|
+
<text flexShrink={0} style={{ fg: row.entry.id === selectableEntries()[cursor()]?.id
|
|
2055
|
+
? api.theme.current.selectedListItemText : api.theme.current.textMuted }}>
|
|
2056
|
+
{row.last ? "└" : "├"}
|
|
2057
|
+
</text>
|
|
1955
2058
|
<Show when={row.entry.id === currentSessionID()}>
|
|
1956
2059
|
<text
|
|
1957
2060
|
flexShrink={0}
|
|
@@ -1993,7 +2096,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1993
2096
|
>
|
|
1994
2097
|
{pendingDelete()?.id === row.entry.id
|
|
1995
2098
|
? "press y to delete"
|
|
1996
|
-
: `${
|
|
2099
|
+
: `${ago(row.entry.updated)}${
|
|
1997
2100
|
query().trim() &&
|
|
1998
2101
|
!`${row.entry.title} ${row.entry.dir}`.toLowerCase().includes(query().trim().toLowerCase())
|
|
1999
2102
|
? " · transcript match"
|
|
@@ -2018,7 +2121,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2018
2121
|
)
|
|
2019
2122
|
}}
|
|
2020
2123
|
</For>
|
|
2021
|
-
<Show when={
|
|
2124
|
+
<Show when={matchedGroups().length === 0}>
|
|
2022
2125
|
<box paddingLeft={4} paddingRight={4}>
|
|
2023
2126
|
<text style={{ fg: api.theme.current.textMuted }}>
|
|
2024
2127
|
{waitingOnly() && waitingCoverage() === "loading"
|
|
@@ -2038,11 +2141,46 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2038
2141
|
</box>
|
|
2039
2142
|
</Show>
|
|
2040
2143
|
</box>
|
|
2144
|
+
<box paddingLeft={4} paddingRight={4} flexShrink={0} flexDirection="column">
|
|
2145
|
+
<text
|
|
2146
|
+
style={{
|
|
2147
|
+
fg: pendingDelete() ? api.theme.current.warning : api.theme.current.textMuted,
|
|
2148
|
+
}}
|
|
2149
|
+
>
|
|
2150
|
+
{pendingDelete()
|
|
2151
|
+
? `Delete "${truncate(pendingDelete()!.title, 40)}"? y confirm · n cancel`
|
|
2152
|
+
: "↑↓ move · enter open · ctrl+g project · option+w input · option+s pinned · option+p preview"}
|
|
2153
|
+
</text>
|
|
2154
|
+
<Show when={!pendingDelete()}>
|
|
2155
|
+
<text style={{ fg: api.theme.current.textMuted }}>ctrl+s/d pin · ctrl+x delete · ctrl+f fork · esc close</text>
|
|
2156
|
+
</Show>
|
|
2157
|
+
</box>
|
|
2041
2158
|
<Show when={showPreview()}>
|
|
2042
|
-
<box
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2159
|
+
<box
|
|
2160
|
+
position="absolute"
|
|
2161
|
+
left={0}
|
|
2162
|
+
top={0}
|
|
2163
|
+
width={dialogWidth}
|
|
2164
|
+
height={dialogHeight()}
|
|
2165
|
+
flexDirection="column"
|
|
2166
|
+
backgroundColor={api.theme.current.backgroundElement}
|
|
2167
|
+
paddingLeft={4}
|
|
2168
|
+
paddingRight={4}
|
|
2169
|
+
paddingTop={1}
|
|
2170
|
+
paddingBottom={1}
|
|
2171
|
+
gap={1}
|
|
2172
|
+
>
|
|
2173
|
+
<box flexDirection="row" justifyContent="space-between">
|
|
2174
|
+
<text attributes={TextAttributes.BOLD}>
|
|
2175
|
+
{previewID() ? truncate(selectableEntries()[cursor()]!.title, dialogWidth - 18) : "Preview"}
|
|
2176
|
+
</text>
|
|
2177
|
+
<text style={{ fg: api.theme.current.textMuted }}>esc close</text>
|
|
2178
|
+
</box>
|
|
2179
|
+
<Show when={previewID()}>
|
|
2180
|
+
<text style={{ fg: api.theme.current.textMuted }}>
|
|
2181
|
+
{prettyDir(selectableEntries()[cursor()]!.dir, process.env.HOME ?? "")} · {ago(selectableEntries()[cursor()]!.updated)}
|
|
2182
|
+
</text>
|
|
2183
|
+
</Show>
|
|
2046
2184
|
<scrollbox flexGrow={1} scrollbarOptions={{ visible: false }}>
|
|
2047
2185
|
<Show when={previewID()} fallback={<text style={{ fg: api.theme.current.textMuted }}>Select a session to preview</text>}>
|
|
2048
2186
|
<Show when={previewText()} fallback={<text style={{ fg: api.theme.current.textMuted }}>Loading…</text>}>
|
|
@@ -2060,22 +2198,9 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2060
2198
|
</Show>
|
|
2061
2199
|
</Show>
|
|
2062
2200
|
</scrollbox>
|
|
2201
|
+
<text style={{ fg: api.theme.current.textMuted }}>↑↓ switch session · esc back to picker</text>
|
|
2063
2202
|
</box>
|
|
2064
2203
|
</Show>
|
|
2065
|
-
<box paddingLeft={4} paddingRight={4} flexShrink={0} flexDirection="column">
|
|
2066
|
-
<text
|
|
2067
|
-
style={{
|
|
2068
|
-
fg: pendingDelete() ? api.theme.current.warning : api.theme.current.textMuted,
|
|
2069
|
-
}}
|
|
2070
|
-
>
|
|
2071
|
-
{pendingDelete()
|
|
2072
|
-
? `Delete "${truncate(pendingDelete()!.title, 40)}"? y confirm · n cancel`
|
|
2073
|
-
: "↑↓ move · enter open · ctrl+g project · option+w needs · option+p pinned · ctrl+p preview"}
|
|
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>
|
|
2078
|
-
</box>
|
|
2079
2204
|
</box>
|
|
2080
2205
|
),
|
|
2081
2206
|
() => {
|
|
@@ -2374,7 +2499,7 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
|
|
|
2374
2499
|
])
|
|
2375
2500
|
|
|
2376
2501
|
api.keymap.registerLayer({
|
|
2377
|
-
bindings: [{ key: "
|
|
2502
|
+
bindings: [{ key: "option+o", desc: "Pick session", preventDefault: true, cmd: () => void openPicker() }],
|
|
2378
2503
|
})
|
|
2379
2504
|
}
|
|
2380
2505
|
|