opencode-sesh 0.10.0 → 0.11.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 +11 -4
- package/CHANGELOG.md +21 -1
- package/README.md +13 -8
- package/install.sh +1 -1
- package/package.json +1 -1
- package/tui/sesh-panel.tsx +349 -92
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,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.11.1] - 2026-09-25
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Confirming a session delete in the TUI picker no longer leaks `y` or `n` into
|
|
15
|
+
the search input; `Enter` and a second `Ctrl-X` commit the armed delete, and
|
|
16
|
+
session deletion is supported on the home screen.
|
|
17
|
+
## [0.11.0] - 2026-09-25
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- OpenCode TUI shortcuts use Option-O for the picker and Option-P for previews;
|
|
22
|
+
Option-S filters pinned sessions, and the Option-W hint now says "input".
|
|
23
|
+
- The TUI picker groups sessions in collapsible directory trees, with the
|
|
24
|
+
directory path on a separate heading and branch markers on session rows.
|
|
25
|
+
- Mouse-wheel scrolling follows OpenTUI's native per-event speed without moving
|
|
26
|
+
the selection under a stationary pointer. The transcript preview now covers
|
|
27
|
+
the entire picker, with arrow keys to switch sessions while it is open.
|
|
10
28
|
## [0.10.0] - 2026-09-25
|
|
11
29
|
|
|
12
30
|
### Changed
|
|
@@ -360,7 +378,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
360
378
|
- Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
|
|
361
379
|
CI.
|
|
362
380
|
|
|
363
|
-
[Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.
|
|
381
|
+
[Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.11.1...HEAD
|
|
382
|
+
[0.11.1]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.11.0...v0.11.1
|
|
383
|
+
[0.11.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.10.0...v0.11.0
|
|
364
384
|
[0.10.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.9.1...v0.10.0
|
|
365
385
|
[0.9.1]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.9.0...v0.9.1
|
|
366
386
|
[0.9.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.8.0...v0.9.0
|
package/README.md
CHANGED
|
@@ -41,8 +41,8 @@ grouped by directory, full-text searchable, and previewable from anywhere.
|
|
|
41
41
|
- **Resume in place.** Enter `exec`s `opencode --session <id>` in the session's
|
|
42
42
|
own directory. Ctrl-F forks instead. Your terminal becomes the session — no
|
|
43
43
|
tabs, no panes, no window management.
|
|
44
|
-
- **Native TUI, too.** A recent-sessions section in the opencode sidebar plus
|
|
45
|
-
`
|
|
44
|
+
- **Native TUI, too.** A recent-sessions section in the opencode sidebar plus an
|
|
45
|
+
`option+o` picker, both themed by your opencode theme.
|
|
46
46
|
- **Fast.** The database is scanned once per refresh and cached; keystrokes only
|
|
47
47
|
re-render the last snapshot, so typing never triggers a query storm.
|
|
48
48
|
|
|
@@ -87,7 +87,7 @@ sesh # terminal picker
|
|
|
87
87
|
|
|
88
88
|
```text
|
|
89
89
|
/sesh # inside opencode — picker (TUI plugin)
|
|
90
|
-
|
|
90
|
+
option+o # inside opencode — picker
|
|
91
91
|
```
|
|
92
92
|
|
|
93
93
|
### Requirements
|
|
@@ -169,22 +169,27 @@ non-interactive `sesh-delete.sh` refuses unless given `--yes`.
|
|
|
169
169
|
| `/sesh` | Open the full picker |
|
|
170
170
|
| `/sesh-costs` | Per-project cost digest (24h beside lifetime) |
|
|
171
171
|
| `/sesh-needs` | Sessions waiting on you (Enter opens) |
|
|
172
|
-
| `
|
|
172
|
+
| `option+o` | Open the full picker (also in the command palette) |
|
|
173
173
|
| Type | Search titles, directories and transcript text |
|
|
174
174
|
| `↑`/`↓`, `PgUp`/`PgDn`, `Home`/`End` | Move the selection |
|
|
175
|
-
|
|
|
175
|
+
| Option-P | Toggle the transcript preview |
|
|
176
176
|
| Enter | Open the selected session |
|
|
177
177
|
| Ctrl-X | Delete the selected session (asks to confirm) |
|
|
178
178
|
| Ctrl-F | Fork the selected session |
|
|
179
179
|
| Ctrl-G | Toggle scope: the selected session's project, or every project |
|
|
180
|
-
| Option-W / Option-
|
|
180
|
+
| Option-W / Option-S | Show only sessions needing input / pinned sessions |
|
|
181
181
|
| Ctrl-S / Ctrl-D | Pin or unpin the selected session / directory |
|
|
182
|
-
| 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.
|
|
183
188
|
|
|
184
189
|
Click the sidebar's `search…` box to filter recent sessions in place; click
|
|
185
190
|
elsewhere or press Esc to leave search. Click the **Sessions** heading to drive
|
|
186
191
|
the list from the keyboard instead of the mouse (`↑`/`↓` to move, Enter to open,
|
|
187
|
-
|
|
192
|
+
Option-P to preview, Ctrl-S/Ctrl-D to pin, Ctrl-X to delete, Esc to leave). Pinned
|
|
188
193
|
directories rise first, while sessions within each directory are ordered by
|
|
189
194
|
last update, newest first. The section lists every session and scrolls to fill
|
|
190
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
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) {
|
|
@@ -975,8 +1013,47 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
975
1013
|
let disposeHoverSpace: (() => void) | undefined
|
|
976
1014
|
let disposeSearch: (() => void) | undefined
|
|
977
1015
|
let disposeNav: (() => void) | undefined
|
|
1016
|
+
let disposeConfirm: (() => void) | undefined
|
|
978
1017
|
let insideSearchBox = false
|
|
979
1018
|
|
|
1019
|
+
createEffect(() => {
|
|
1020
|
+
disposeConfirm?.()
|
|
1021
|
+
disposeConfirm = undefined
|
|
1022
|
+
if (!pendingDelete()) return
|
|
1023
|
+
disposeConfirm = props.api.keymap.registerLayer({
|
|
1024
|
+
mode: "base",
|
|
1025
|
+
priority: 25,
|
|
1026
|
+
bindings: [
|
|
1027
|
+
{
|
|
1028
|
+
key: "y",
|
|
1029
|
+
desc: "Confirm delete",
|
|
1030
|
+
preventDefault: true,
|
|
1031
|
+
cmd: () => {
|
|
1032
|
+
confirmArmed()
|
|
1033
|
+
},
|
|
1034
|
+
},
|
|
1035
|
+
{
|
|
1036
|
+
key: "ctrl+x",
|
|
1037
|
+
desc: "Confirm delete",
|
|
1038
|
+
preventDefault: true,
|
|
1039
|
+
cmd: () => {
|
|
1040
|
+
confirmArmed()
|
|
1041
|
+
},
|
|
1042
|
+
},
|
|
1043
|
+
{
|
|
1044
|
+
key: "enter",
|
|
1045
|
+
desc: "Confirm delete",
|
|
1046
|
+
preventDefault: true,
|
|
1047
|
+
cmd: () => {
|
|
1048
|
+
confirmArmed()
|
|
1049
|
+
},
|
|
1050
|
+
},
|
|
1051
|
+
{ key: "n", desc: "Cancel delete", preventDefault: true, cmd: cancelDelete },
|
|
1052
|
+
{ key: "escape", desc: "Cancel delete", preventDefault: true, cmd: cancelDelete },
|
|
1053
|
+
],
|
|
1054
|
+
})
|
|
1055
|
+
})
|
|
1056
|
+
|
|
980
1057
|
const onRootMouseDown = () => {
|
|
981
1058
|
if (insideSearchBox) {
|
|
982
1059
|
insideSearchBox = false
|
|
@@ -1021,7 +1098,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1021
1098
|
priority: 20,
|
|
1022
1099
|
bindings: [
|
|
1023
1100
|
{
|
|
1024
|
-
key: "
|
|
1101
|
+
key: "option+p",
|
|
1025
1102
|
desc: "Preview session transcript",
|
|
1026
1103
|
preventDefault: true,
|
|
1027
1104
|
cmd: () => openSidebarPreview(entry),
|
|
@@ -1065,7 +1142,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1065
1142
|
cmd: () => setCursor(Math.max(0, itemRows().length - 1)),
|
|
1066
1143
|
},
|
|
1067
1144
|
{
|
|
1068
|
-
key: "
|
|
1145
|
+
key: "option+p",
|
|
1069
1146
|
desc: "Preview session transcript",
|
|
1070
1147
|
preventDefault: true,
|
|
1071
1148
|
cmd: () => {
|
|
@@ -1106,6 +1183,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1106
1183
|
desc: "Open session",
|
|
1107
1184
|
preventDefault: true,
|
|
1108
1185
|
cmd: () => {
|
|
1186
|
+
if (confirmArmed()) return
|
|
1109
1187
|
const entry = itemRows()[cursor()]
|
|
1110
1188
|
if (entry) void openSession(entry)
|
|
1111
1189
|
},
|
|
@@ -1140,6 +1218,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1140
1218
|
disposeHoverSpace?.()
|
|
1141
1219
|
disposeSearch?.()
|
|
1142
1220
|
disposeNav?.()
|
|
1221
|
+
disposeConfirm?.()
|
|
1143
1222
|
if (confirmTimer) clearTimeout(confirmTimer)
|
|
1144
1223
|
})
|
|
1145
1224
|
|
|
@@ -1167,7 +1246,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1167
1246
|
when={entries().length > 0}
|
|
1168
1247
|
fallback={
|
|
1169
1248
|
<text style={{ fg: theme().textMuted }}>
|
|
1170
|
-
{loadFailed() ? "Session list unavailable · will retry" : "No sessions yet ·
|
|
1249
|
+
{loadFailed() ? "Session list unavailable · will retry" : "No sessions yet · option+o to browse"}
|
|
1171
1250
|
</text>
|
|
1172
1251
|
}
|
|
1173
1252
|
>
|
|
@@ -1273,14 +1352,14 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1273
1352
|
</For>
|
|
1274
1353
|
</scrollbox>
|
|
1275
1354
|
<Show when={remaining() > 0}>
|
|
1276
|
-
<text style={{ fg: theme().textMuted }}>{`… ${remaining()} more ·
|
|
1355
|
+
<text style={{ fg: theme().textMuted }}>{`… ${remaining()} more · option+o for all`}</text>
|
|
1277
1356
|
</Show>
|
|
1278
1357
|
<Show when={query().trim() && filteredEntries().length === 0}>
|
|
1279
|
-
|
|
1358
|
+
<text style={{ fg: theme().textMuted }}>No title or directory matches · option+o to search transcripts</text>
|
|
1280
1359
|
</Show>
|
|
1281
1360
|
<Show when={navActive()}>
|
|
1282
1361
|
<box paddingTop={1}>
|
|
1283
|
-
<text style={{ fg: theme().textMuted }}>↑↓ move · enter open ·
|
|
1362
|
+
<text style={{ fg: theme().textMuted }}>↑↓ move · enter open · option+p preview · ctrl+s/d pin · ctrl+x delete · esc done</text>
|
|
1284
1363
|
</box>
|
|
1285
1364
|
</Show>
|
|
1286
1365
|
</Show>
|
|
@@ -1299,11 +1378,89 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1299
1378
|
const [hovered, setHovered] = createSignal<string>()
|
|
1300
1379
|
const [searching, setSearching] = createSignal(false)
|
|
1301
1380
|
const [loadFailed, setLoadFailed] = createSignal(false)
|
|
1381
|
+
const [pendingDelete, setPendingDelete] = createSignal<string>()
|
|
1382
|
+
const [deleting, setDeleting] = createSignal<string>()
|
|
1383
|
+
let confirmTimer: ReturnType<typeof setTimeout> | undefined
|
|
1384
|
+
let disposeConfirm: (() => void) | undefined
|
|
1302
1385
|
const preview = createTranscriptPreview(props.api)
|
|
1303
1386
|
let disposeHoverSpace: (() => void) | undefined
|
|
1304
1387
|
let disposeSearch: (() => void) | undefined
|
|
1305
1388
|
let insideSearchBox = false
|
|
1306
1389
|
|
|
1390
|
+
const cancelDelete = () => {
|
|
1391
|
+
if (confirmTimer) clearTimeout(confirmTimer)
|
|
1392
|
+
confirmTimer = undefined
|
|
1393
|
+
disposeConfirm?.()
|
|
1394
|
+
disposeConfirm = undefined
|
|
1395
|
+
setPendingDelete(undefined)
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
const confirmArmed = (): boolean => {
|
|
1399
|
+
const id = pendingDelete()
|
|
1400
|
+
if (!id) return false
|
|
1401
|
+
const entry = entries().find((e) => e.id === id)
|
|
1402
|
+
if (!entry) cancelDelete()
|
|
1403
|
+
else void deleteEntry(entry)
|
|
1404
|
+
return true
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
const requestDelete = (entry: Entry) => {
|
|
1408
|
+
if (deleting()) return
|
|
1409
|
+
if (pendingDelete() === entry.id) {
|
|
1410
|
+
void deleteEntry(entry)
|
|
1411
|
+
return
|
|
1412
|
+
}
|
|
1413
|
+
setPendingDelete(entry.id)
|
|
1414
|
+
if (confirmTimer) clearTimeout(confirmTimer)
|
|
1415
|
+
confirmTimer = setTimeout(() => setPendingDelete(undefined), 5000)
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
const deleteEntry = async (entry: Entry) => {
|
|
1419
|
+
cancelDelete()
|
|
1420
|
+
if (deleting()) return
|
|
1421
|
+
setDeleting(entry.id)
|
|
1422
|
+
try {
|
|
1423
|
+
const result = await props.api.client.session.delete({
|
|
1424
|
+
sessionID: entry.id,
|
|
1425
|
+
directory: entry.dir || undefined,
|
|
1426
|
+
})
|
|
1427
|
+
if (result && "error" in result && result.error) throw result.error
|
|
1428
|
+
const { entries: remaining } = await fetchEntries(props.api)
|
|
1429
|
+
if (remaining.some((item) => item.id === entry.id)) {
|
|
1430
|
+
setEntries(remaining)
|
|
1431
|
+
props.api.ui.toast({ message: "Session was not deleted", variant: "error" })
|
|
1432
|
+
return
|
|
1433
|
+
}
|
|
1434
|
+
setEntries(remaining)
|
|
1435
|
+
setHovered(undefined)
|
|
1436
|
+
props.api.ui.toast({ message: `Deleted "${truncate(entry.title, 40)}"`, variant: "info" })
|
|
1437
|
+
if ("params" in props.api.route.current && (props.api.route.current.params as any)?.sessionID === entry.id) {
|
|
1438
|
+
props.api.route.navigate("home")
|
|
1439
|
+
}
|
|
1440
|
+
} catch {
|
|
1441
|
+
props.api.ui.toast({ message: "Could not delete session", variant: "error" })
|
|
1442
|
+
} finally {
|
|
1443
|
+
setDeleting(undefined)
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
createEffect(() => {
|
|
1448
|
+
disposeConfirm?.()
|
|
1449
|
+
disposeConfirm = undefined
|
|
1450
|
+
if (!pendingDelete()) return
|
|
1451
|
+
disposeConfirm = props.api.keymap.registerLayer({
|
|
1452
|
+
mode: "base",
|
|
1453
|
+
priority: 25,
|
|
1454
|
+
bindings: [
|
|
1455
|
+
{ key: "y", desc: "Confirm delete", preventDefault: true, cmd: () => { confirmArmed() } },
|
|
1456
|
+
{ key: "ctrl+x", desc: "Confirm delete", preventDefault: true, cmd: () => { confirmArmed() } },
|
|
1457
|
+
{ key: "enter", desc: "Confirm delete", preventDefault: true, cmd: () => { confirmArmed() } },
|
|
1458
|
+
{ key: "n", desc: "Cancel delete", preventDefault: true, cmd: cancelDelete },
|
|
1459
|
+
{ key: "escape", desc: "Cancel delete", preventDefault: true, cmd: cancelDelete },
|
|
1460
|
+
],
|
|
1461
|
+
})
|
|
1462
|
+
})
|
|
1463
|
+
|
|
1307
1464
|
const onRootMouseDown = () => {
|
|
1308
1465
|
if (insideSearchBox) {
|
|
1309
1466
|
insideSearchBox = false
|
|
@@ -1346,19 +1503,31 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1346
1503
|
priority: 20,
|
|
1347
1504
|
bindings: [
|
|
1348
1505
|
{
|
|
1349
|
-
key: "
|
|
1506
|
+
key: "option+p",
|
|
1350
1507
|
desc: "Preview session transcript",
|
|
1351
1508
|
preventDefault: true,
|
|
1352
1509
|
cmd: () => preview.open(entry),
|
|
1353
1510
|
},
|
|
1511
|
+
{
|
|
1512
|
+
key: "ctrl+x",
|
|
1513
|
+
desc: "Delete session",
|
|
1514
|
+
preventDefault: true,
|
|
1515
|
+
cmd: () => {
|
|
1516
|
+
if (!confirmArmed()) requestDelete(entry)
|
|
1517
|
+
},
|
|
1518
|
+
},
|
|
1354
1519
|
{ key: "ctrl+s", desc: "Pin session", preventDefault: true, cmd: () => props.onTogglePin("sessions", entry.id) },
|
|
1355
1520
|
{ key: "ctrl+d", desc: "Pin directory", preventDefault: true, cmd: () => props.onTogglePin("directories", entry.dir) },
|
|
1356
1521
|
],
|
|
1357
1522
|
})
|
|
1358
1523
|
})
|
|
1359
1524
|
|
|
1360
|
-
onCleanup(() =>
|
|
1361
|
-
|
|
1525
|
+
onCleanup(() => {
|
|
1526
|
+
disposeSearch?.()
|
|
1527
|
+
disposeHoverSpace?.()
|
|
1528
|
+
disposeConfirm?.()
|
|
1529
|
+
if (confirmTimer) clearTimeout(confirmTimer)
|
|
1530
|
+
})
|
|
1362
1531
|
|
|
1363
1532
|
onMount(() => {
|
|
1364
1533
|
let alive = true
|
|
@@ -1400,7 +1569,7 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1400
1569
|
<box flexDirection="row" gap={1}>
|
|
1401
1570
|
<text style={{ fg: theme().textMuted }}>Recent sessions</text>
|
|
1402
1571
|
<text style={{ fg: theme().textMuted }}>
|
|
1403
|
-
{loadFailed() ? "· list unavailable, will retry" : "· none yet,
|
|
1572
|
+
{loadFailed() ? "· list unavailable, will retry" : "· none yet, option+o to browse"}
|
|
1404
1573
|
</text>
|
|
1405
1574
|
</box>
|
|
1406
1575
|
}
|
|
@@ -1408,7 +1577,7 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1408
1577
|
<box flexDirection="row" justifyContent="space-between" gap={2}>
|
|
1409
1578
|
<box flexDirection="row" gap={1}>
|
|
1410
1579
|
<text style={{ fg: theme().textMuted }}>Recent sessions</text>
|
|
1411
|
-
<text style={{ fg: theme().textMuted }}>·
|
|
1580
|
+
<text style={{ fg: theme().textMuted }}>· option+o for all</text>
|
|
1412
1581
|
</box>
|
|
1413
1582
|
<box
|
|
1414
1583
|
flexDirection="row"
|
|
@@ -1459,14 +1628,16 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1459
1628
|
color={theme().text}
|
|
1460
1629
|
matchColor={theme().warning}
|
|
1461
1630
|
/>
|
|
1462
|
-
<text flexShrink={0} style={{ fg: theme().textMuted }}>
|
|
1463
|
-
{
|
|
1631
|
+
<text flexShrink={0} style={{ fg: pendingDelete() === entry.id ? theme().error : theme().textMuted }}>
|
|
1632
|
+
{pendingDelete() === entry.id
|
|
1633
|
+
? "ctrl+x again"
|
|
1634
|
+
: `${props.pins().directories.includes(entry.dir) ? "★ " : ""}${prettyDir(entry.dir, home)} · ${ago(entry.updated)}`}
|
|
1464
1635
|
</text>
|
|
1465
1636
|
</box>
|
|
1466
1637
|
)}
|
|
1467
1638
|
</For>
|
|
1468
1639
|
<Show when={visible().length === 0}>
|
|
1469
|
-
|
|
1640
|
+
<text style={{ fg: theme().textMuted }}>No title or directory matches · option+o to search transcripts</text>
|
|
1470
1641
|
</Show>
|
|
1471
1642
|
</Show>
|
|
1472
1643
|
</box>
|
|
@@ -1534,6 +1705,8 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1534
1705
|
const [showPreview, setShowPreview] = createSignal(false)
|
|
1535
1706
|
const [query, setQuery] = createSignal(pickerState.query)
|
|
1536
1707
|
const [cursor, setCursor] = createSignal(0)
|
|
1708
|
+
const [viewportStart, setViewportStart] = createSignal(0)
|
|
1709
|
+
const [collapsed, setCollapsed] = createSignal<Record<string, boolean>>({})
|
|
1537
1710
|
const [scope, setScope] = createSignal<string | undefined>(
|
|
1538
1711
|
entries.some((entry) => entry.group === pickerState.scope) ? pickerState.scope : undefined,
|
|
1539
1712
|
)
|
|
@@ -1574,8 +1747,6 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1574
1747
|
// while Solid needs a new reference to update search results mid-scan.
|
|
1575
1748
|
setSearchIndex(new Map(index))
|
|
1576
1749
|
})
|
|
1577
|
-
type DialogRow = { kind: "group"; label: string; count: number } | { kind: "item"; entry: Entry }
|
|
1578
|
-
|
|
1579
1750
|
const orderedEntries = createMemo(() => pinnedFirst(allEntries(), pins()))
|
|
1580
1751
|
const currentProject = () => {
|
|
1581
1752
|
const route = api.route.current
|
|
@@ -1591,21 +1762,23 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1591
1762
|
)
|
|
1592
1763
|
const byGroup = new Map<string, Entry[]>()
|
|
1593
1764
|
for (const entry of matched) {
|
|
1594
|
-
const list = byGroup.get(entry.
|
|
1765
|
+
const list = byGroup.get(entry.dir) ?? []
|
|
1595
1766
|
list.push(entry)
|
|
1596
|
-
byGroup.set(entry.
|
|
1767
|
+
byGroup.set(entry.dir, list)
|
|
1597
1768
|
}
|
|
1598
|
-
const groups = [...byGroup.entries()].map(([
|
|
1769
|
+
const groups = [...byGroup.entries()].map(([dir, list]) => ({ dir, list: newestFirst(list) }))
|
|
1599
1770
|
const currentPins = pins()
|
|
1600
1771
|
const rank = (group: (typeof groups)[number]) =>
|
|
1601
|
-
|
|
1772
|
+
currentPins.directories.includes(group.dir) ? 2
|
|
1602
1773
|
: group.list.some((entry) => currentPins.sessions.includes(entry.id)) ? 1 : 0
|
|
1603
1774
|
return groups.sort((a, b) =>
|
|
1604
1775
|
rank(b) - rank(a) || Math.max(...b.list.map((entry) => entry.updated)) - Math.max(...a.list.map((entry) => entry.updated)),
|
|
1605
1776
|
)
|
|
1606
1777
|
})
|
|
1607
1778
|
|
|
1608
|
-
const selectableEntries = createMemo(() => matchedGroups().flatMap((g) =>
|
|
1779
|
+
const selectableEntries = createMemo(() => matchedGroups().flatMap((g) =>
|
|
1780
|
+
collapsed()[g.dir] && !query().trim() ? [] : g.list,
|
|
1781
|
+
))
|
|
1609
1782
|
|
|
1610
1783
|
// Visible bounding: say how much transcript text search actually covers and
|
|
1611
1784
|
// when the metadata walk hit its cap, instead of silently under-reporting.
|
|
@@ -1626,21 +1799,15 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1626
1799
|
return bits.join(" · ")
|
|
1627
1800
|
})
|
|
1628
1801
|
|
|
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[] = []
|
|
1802
|
+
const pickerRows = createMemo<PickerRow[]>(() => {
|
|
1803
|
+
const flat: PickerRow[] = []
|
|
1641
1804
|
for (const group of matchedGroups()) {
|
|
1642
|
-
flat.push({ kind: "group",
|
|
1643
|
-
|
|
1805
|
+
flat.push({ kind: "group", dir: group.dir, label: prettyDir(group.dir, process.env.HOME ?? ""),
|
|
1806
|
+
count: group.list.length, pinned: pins().directories.includes(group.dir) })
|
|
1807
|
+
if (collapsed()[group.dir] && !query().trim()) continue
|
|
1808
|
+
for (let i = 0; i < group.list.length; i++) {
|
|
1809
|
+
flat.push({ kind: "item", entry: group.list[i], last: i === group.list.length - 1 })
|
|
1810
|
+
}
|
|
1644
1811
|
}
|
|
1645
1812
|
return flat
|
|
1646
1813
|
})
|
|
@@ -1654,23 +1821,28 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1654
1821
|
const listHeight = () => Math.max(6, Math.floor(termHeight * 0.75) - CHROME_ROWS)
|
|
1655
1822
|
const dialogHeight = () => listHeight() + CHROME_ROWS
|
|
1656
1823
|
const dialogOffset = () => Math.min(0, Math.floor(termHeight / 4 - dialogHeight() / 2 - 1))
|
|
1657
|
-
const
|
|
1658
|
-
const
|
|
1824
|
+
const hasExcerpt = (entry: Entry) => !!transcriptMatchExcerpt(searchIndex().get(entry.id), entry, query())
|
|
1825
|
+
const maxViewportStart = createMemo(() => pickerLastStart(pickerRows(), listHeight(), hasExcerpt))
|
|
1826
|
+
const visiblePickerRows = createMemo(() => pickerWindow(
|
|
1827
|
+
pickerRows(), Math.min(viewportStart(), maxViewportStart()), listHeight(), hasExcerpt,
|
|
1828
|
+
))
|
|
1659
1829
|
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1830
|
+
let previousSelection: Entry[] = []
|
|
1831
|
+
createEffect(() => {
|
|
1832
|
+
const rows = selectableEntries()
|
|
1833
|
+
const position = rows.findIndex((entry) => entry.id === previousSelection[cursor()]?.id)
|
|
1834
|
+
if (rows !== previousSelection && (rows.length !== previousSelection.length ||
|
|
1835
|
+
rows.some((entry, index) => entry.id !== previousSelection[index]?.id))) {
|
|
1836
|
+
// A search/filter/collapse changed the tree: reveal the retained row or
|
|
1837
|
+
// start at the first result rather than leaving a stale window open.
|
|
1838
|
+
const next = position >= 0 ? position : 0
|
|
1839
|
+
setCursor(next)
|
|
1840
|
+
const id = rows[next]?.id
|
|
1841
|
+
setViewportStart(position < 0 || next === 0 ? 0 : Math.max(0, pickerRows().findIndex(
|
|
1842
|
+
(row) => row.kind === "item" && row.entry.id === id,
|
|
1843
|
+
)))
|
|
1844
|
+
} else if (cursor() >= rows.length) setCursor(Math.max(0, rows.length - 1))
|
|
1845
|
+
previousSelection = rows
|
|
1674
1846
|
})
|
|
1675
1847
|
|
|
1676
1848
|
const previewCache = new Map<string, string>()
|
|
@@ -1721,22 +1893,51 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1721
1893
|
|
|
1722
1894
|
const togglePreview = () => setShowPreview((value) => !value)
|
|
1723
1895
|
|
|
1724
|
-
const
|
|
1896
|
+
const selectCursor = (index: number) => {
|
|
1725
1897
|
const count = selectableEntries().length
|
|
1726
1898
|
if (count === 0) return
|
|
1727
1899
|
cancelDelete()
|
|
1728
|
-
|
|
1900
|
+
const next = Math.max(0, Math.min(count - 1, index))
|
|
1901
|
+
const id = selectableEntries()[next].id
|
|
1902
|
+
const flat = pickerRows()
|
|
1903
|
+
const target = flat.findIndex((row) => row.kind === "item" && row.entry.id === id)
|
|
1904
|
+
let start = Math.min(viewportStart(), maxViewportStart())
|
|
1905
|
+
if (target < start) start = target
|
|
1906
|
+
else while (start < target && !pickerWindow(flat, start, listHeight(), hasExcerpt).some(
|
|
1907
|
+
(row) => row.kind === "item" && row.entry.id === id,
|
|
1908
|
+
)) start++
|
|
1909
|
+
setViewportStart(Math.max(0, Math.min(start, maxViewportStart())))
|
|
1910
|
+
setCursor(next)
|
|
1911
|
+
}
|
|
1912
|
+
const moveCursor = (delta: number) => selectCursor(cursor() + delta)
|
|
1913
|
+
|
|
1914
|
+
// Match OpenTUI's scrollbox: honor every wheel event's delta. The viewport
|
|
1915
|
+
// stays independent of selection, so fast scrolling cannot recenter on hover.
|
|
1916
|
+
const scrollPicker = (scroll?: { direction: string; delta: number }) => {
|
|
1917
|
+
if (showPreview() || !scroll || (scroll.direction !== "up" && scroll.direction !== "down")) return
|
|
1918
|
+
const amount = Math.trunc(scroll.delta) * (scroll.direction === "down" ? 1 : -1)
|
|
1919
|
+
setViewportStart((start) => Math.max(0, Math.min(maxViewportStart(), start + amount)))
|
|
1729
1920
|
}
|
|
1730
1921
|
|
|
1731
1922
|
// Delete confirmation lives in its own layer, registered only while a row
|
|
1732
1923
|
// is armed, so y/n never shadow the search box's typing.
|
|
1733
1924
|
let disposeConfirm: (() => void) | undefined
|
|
1925
|
+
let confirmTimer: ReturnType<typeof setTimeout> | undefined
|
|
1734
1926
|
const cancelDelete = () => {
|
|
1927
|
+
if (confirmTimer) clearTimeout(confirmTimer)
|
|
1928
|
+
confirmTimer = undefined
|
|
1735
1929
|
disposeConfirm?.()
|
|
1736
1930
|
disposeConfirm = undefined
|
|
1737
1931
|
setPendingDelete(undefined)
|
|
1738
1932
|
}
|
|
1739
1933
|
|
|
1934
|
+
const confirmArmed = (): boolean => {
|
|
1935
|
+
const armed = pendingDelete()
|
|
1936
|
+
if (!armed) return false
|
|
1937
|
+
void deleteEntry(armed)
|
|
1938
|
+
return true
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1740
1941
|
// Escape first closes the transcript overlay, then the picker. One Escape
|
|
1741
1942
|
// can reach both the focused search input and the key layer (sometimes in
|
|
1742
1943
|
// separate ticks), so ignore the duplicate that trails right behind.
|
|
@@ -1749,6 +1950,11 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1749
1950
|
escapeHandledAt = now + 150
|
|
1750
1951
|
return
|
|
1751
1952
|
}
|
|
1953
|
+
if (pendingDelete()) {
|
|
1954
|
+
cancelDelete()
|
|
1955
|
+
escapeHandledAt = now + 150
|
|
1956
|
+
return
|
|
1957
|
+
}
|
|
1752
1958
|
cancelDelete()
|
|
1753
1959
|
close()
|
|
1754
1960
|
}
|
|
@@ -1769,6 +1975,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1769
1975
|
}
|
|
1770
1976
|
setAllEntries(remaining)
|
|
1771
1977
|
api.ui.toast({ message: `Deleted "${truncate(entry.title, 40)}"`, variant: "info" })
|
|
1978
|
+
if (currentSessionID() === entry.id) api.route.navigate("home")
|
|
1772
1979
|
} catch {
|
|
1773
1980
|
api.ui.toast({ message: "Could not delete session", variant: "error" })
|
|
1774
1981
|
}
|
|
@@ -1776,19 +1983,38 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1776
1983
|
|
|
1777
1984
|
const armDelete = (entry: Entry) => {
|
|
1778
1985
|
setPendingDelete(entry)
|
|
1986
|
+
if (confirmTimer) clearTimeout(confirmTimer)
|
|
1987
|
+
confirmTimer = setTimeout(cancelDelete, 5000)
|
|
1779
1988
|
disposeConfirm?.()
|
|
1780
1989
|
disposeConfirm = api.keymap.registerLayer({
|
|
1990
|
+
priority: 20,
|
|
1781
1991
|
bindings: [
|
|
1782
1992
|
{
|
|
1783
1993
|
key: "y",
|
|
1784
1994
|
desc: "Confirm delete",
|
|
1785
1995
|
preventDefault: true,
|
|
1786
1996
|
cmd: () => {
|
|
1787
|
-
|
|
1788
|
-
|
|
1997
|
+
confirmArmed()
|
|
1998
|
+
},
|
|
1999
|
+
},
|
|
2000
|
+
{
|
|
2001
|
+
key: "ctrl+x",
|
|
2002
|
+
desc: "Confirm delete",
|
|
2003
|
+
preventDefault: true,
|
|
2004
|
+
cmd: () => {
|
|
2005
|
+
confirmArmed()
|
|
2006
|
+
},
|
|
2007
|
+
},
|
|
2008
|
+
{
|
|
2009
|
+
key: "enter",
|
|
2010
|
+
desc: "Confirm delete",
|
|
2011
|
+
preventDefault: true,
|
|
2012
|
+
cmd: () => {
|
|
2013
|
+
confirmArmed()
|
|
1789
2014
|
},
|
|
1790
2015
|
},
|
|
1791
2016
|
{ key: "n", desc: "Cancel delete", preventDefault: true, cmd: cancelDelete },
|
|
2017
|
+
{ key: "escape", desc: "Cancel delete", preventDefault: true, cmd: cancelDelete },
|
|
1792
2018
|
],
|
|
1793
2019
|
})
|
|
1794
2020
|
}
|
|
@@ -1827,15 +2053,23 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1827
2053
|
{ key: "down", desc: "Next session", preventDefault: true, cmd: () => moveCursor(1) },
|
|
1828
2054
|
{ key: "pageup", desc: "Page up", preventDefault: true, cmd: () => moveCursor(-10) },
|
|
1829
2055
|
{ key: "pagedown", desc: "Page down", preventDefault: true, cmd: () => moveCursor(10) },
|
|
1830
|
-
{ key: "home", desc: "First session", preventDefault: true, cmd: () =>
|
|
2056
|
+
{ key: "home", desc: "First session", preventDefault: true, cmd: () => selectCursor(0) },
|
|
1831
2057
|
{
|
|
1832
2058
|
key: "end",
|
|
1833
2059
|
desc: "Last session",
|
|
1834
2060
|
preventDefault: true,
|
|
1835
|
-
cmd: () =>
|
|
2061
|
+
cmd: () => selectCursor(selectableEntries().length - 1),
|
|
1836
2062
|
},
|
|
1837
|
-
{
|
|
1838
|
-
|
|
2063
|
+
{
|
|
2064
|
+
key: "enter",
|
|
2065
|
+
desc: "Open session",
|
|
2066
|
+
preventDefault: true,
|
|
2067
|
+
cmd: () => {
|
|
2068
|
+
if (confirmArmed()) return
|
|
2069
|
+
choose()
|
|
2070
|
+
},
|
|
2071
|
+
},
|
|
2072
|
+
{ key: "option+p", desc: "Toggle transcript preview", preventDefault: true, cmd: togglePreview },
|
|
1839
2073
|
{ key: "ctrl+s", desc: "Pin session", preventDefault: true, cmd: () => {
|
|
1840
2074
|
const entry = selectableEntries()[cursor()]
|
|
1841
2075
|
if (entry) onTogglePin("sessions", entry.id)
|
|
@@ -1849,11 +2083,9 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1849
2083
|
desc: "Delete session",
|
|
1850
2084
|
preventDefault: true,
|
|
1851
2085
|
cmd: () => {
|
|
2086
|
+
if (confirmArmed()) return
|
|
1852
2087
|
const target = selectableEntries()[cursor()]
|
|
1853
|
-
if (
|
|
1854
|
-
const armed = pendingDelete()
|
|
1855
|
-
if (armed && armed.id === target.id) void deleteEntry(target)
|
|
1856
|
-
else armDelete(target)
|
|
2088
|
+
if (target) armDelete(target)
|
|
1857
2089
|
},
|
|
1858
2090
|
},
|
|
1859
2091
|
{
|
|
@@ -1876,7 +2108,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1876
2108
|
},
|
|
1877
2109
|
},
|
|
1878
2110
|
{ key: "option+w", desc: "Filter needs input", preventDefault: true, cmd: () => setWaitingOnly((value) => !value) },
|
|
1879
|
-
{ key: "option+
|
|
2111
|
+
{ key: "option+s", desc: "Filter pinned sessions", preventDefault: true, cmd: () => setPinnedOnly((value) => !value) },
|
|
1880
2112
|
{
|
|
1881
2113
|
key: "escape",
|
|
1882
2114
|
desc: "Close",
|
|
@@ -1888,6 +2120,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1888
2120
|
|
|
1889
2121
|
const cleanup = () => {
|
|
1890
2122
|
alive = false
|
|
2123
|
+
if (confirmTimer) clearTimeout(confirmTimer)
|
|
1891
2124
|
pickerState = { query: query(), scope: scope(), waitingOnly: waitingOnly(), pinnedOnly: pinnedOnly() }
|
|
1892
2125
|
disposeNav()
|
|
1893
2126
|
disposeConfirm?.()
|
|
@@ -1923,12 +2156,31 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1923
2156
|
focusedBackgroundColor={api.theme.current.backgroundPanel}
|
|
1924
2157
|
focusedTextColor={api.theme.current.text}
|
|
1925
2158
|
cursorColor={api.theme.current.primary}
|
|
1926
|
-
onInput={
|
|
1927
|
-
|
|
2159
|
+
onInput={(value) => {
|
|
2160
|
+
if (pendingDelete()) return
|
|
2161
|
+
setQuery(value)
|
|
2162
|
+
}}
|
|
2163
|
+
onSubmit={() => {
|
|
2164
|
+
if (confirmArmed()) return
|
|
2165
|
+
choose()
|
|
2166
|
+
}}
|
|
1928
2167
|
onKeyDown={(event) => {
|
|
1929
2168
|
if (event.name === "escape") {
|
|
1930
2169
|
event.preventDefault()
|
|
1931
2170
|
handleEscape()
|
|
2171
|
+
return
|
|
2172
|
+
}
|
|
2173
|
+
if (pendingDelete()) {
|
|
2174
|
+
if (event.name === "y" || (event.ctrl && event.name === "x") || event.name === "enter" || event.name === "return") {
|
|
2175
|
+
event.preventDefault()
|
|
2176
|
+
confirmArmed()
|
|
2177
|
+
return
|
|
2178
|
+
}
|
|
2179
|
+
if (event.name === "n") {
|
|
2180
|
+
event.preventDefault()
|
|
2181
|
+
cancelDelete()
|
|
2182
|
+
return
|
|
2183
|
+
}
|
|
1932
2184
|
}
|
|
1933
2185
|
}}
|
|
1934
2186
|
/>
|
|
@@ -1958,41 +2210,45 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1958
2210
|
flexDirection="column"
|
|
1959
2211
|
flexGrow={1}
|
|
1960
2212
|
overflow="hidden"
|
|
1961
|
-
onMouseScroll={(event: { scroll?: { direction: string } }) => {
|
|
1962
|
-
|
|
1963
|
-
if (direction === "down") moveCursor(3)
|
|
1964
|
-
else if (direction === "up") moveCursor(-3)
|
|
2213
|
+
onMouseScroll={(event: { scroll?: { direction: string; delta: number } }) => {
|
|
2214
|
+
scrollPicker(event.scroll)
|
|
1965
2215
|
}}
|
|
1966
2216
|
>
|
|
1967
2217
|
<For each={visiblePickerRows()}>
|
|
1968
2218
|
{(row) => {
|
|
1969
2219
|
if (row.kind === "group") return (
|
|
1970
|
-
<box paddingLeft={4}
|
|
1971
|
-
|
|
1972
|
-
|
|
2220
|
+
<box flexDirection="row" paddingTop={row.continued ? 0 : 1} paddingLeft={4} paddingRight={4}
|
|
2221
|
+
onMouseDown={() => setCollapsed((state) => ({ ...state, [row.dir]: !state[row.dir] }))}>
|
|
2222
|
+
<text flexGrow={1} flexShrink={1} wrapMode="none" style={{ fg: api.theme.current.accent }} attributes={TextAttributes.BOLD}>
|
|
2223
|
+
{collapsed()[row.dir] && !query().trim() ? "▸ " : "▾ "}{row.pinned ? "★ " : ""}{row.label}
|
|
1973
2224
|
</text>
|
|
2225
|
+
<text flexShrink={0} style={{ fg: api.theme.current.textMuted }}> ({row.count})</text>
|
|
1974
2226
|
</box>
|
|
1975
2227
|
)
|
|
1976
2228
|
const excerpt = createMemo(() => transcriptMatchExcerpt(searchIndex().get(row.entry.id), row.entry, query()))
|
|
1977
2229
|
return (
|
|
1978
2230
|
<box
|
|
1979
2231
|
flexDirection="column"
|
|
1980
|
-
paddingLeft={
|
|
2232
|
+
paddingLeft={6}
|
|
1981
2233
|
paddingRight={4}
|
|
1982
2234
|
backgroundColor={
|
|
1983
2235
|
row.entry.id === selectableEntries()[cursor()]?.id
|
|
1984
2236
|
? api.theme.current.primary
|
|
1985
2237
|
: RGBA.fromInts(0, 0, 0, 0)
|
|
1986
2238
|
}
|
|
1987
|
-
|
|
1988
|
-
//
|
|
1989
|
-
//
|
|
2239
|
+
onMouseMove={() => {
|
|
2240
|
+
// Repainting under a stationary pointer must not select
|
|
2241
|
+
// another row while the wheel is moving the viewport.
|
|
1990
2242
|
const index = selectableEntries().findIndex((entry) => entry.id === row.entry.id)
|
|
1991
2243
|
if (index >= 0) setCursor(index)
|
|
1992
2244
|
}}
|
|
1993
2245
|
onMouseDown={() => choose(row.entry)}
|
|
1994
2246
|
>
|
|
1995
2247
|
<box flexDirection="row" gap={1}>
|
|
2248
|
+
<text flexShrink={0} style={{ fg: row.entry.id === selectableEntries()[cursor()]?.id
|
|
2249
|
+
? api.theme.current.selectedListItemText : api.theme.current.textMuted }}>
|
|
2250
|
+
{row.last ? "└" : "├"}
|
|
2251
|
+
</text>
|
|
1996
2252
|
<Show when={row.entry.id === currentSessionID()}>
|
|
1997
2253
|
<text
|
|
1998
2254
|
flexShrink={0}
|
|
@@ -2034,7 +2290,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2034
2290
|
>
|
|
2035
2291
|
{pendingDelete()?.id === row.entry.id
|
|
2036
2292
|
? "press y to delete"
|
|
2037
|
-
: `${
|
|
2293
|
+
: `${ago(row.entry.updated)}${
|
|
2038
2294
|
query().trim() &&
|
|
2039
2295
|
!`${row.entry.title} ${row.entry.dir}`.toLowerCase().includes(query().trim().toLowerCase())
|
|
2040
2296
|
? " · transcript match"
|
|
@@ -2059,7 +2315,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2059
2315
|
)
|
|
2060
2316
|
}}
|
|
2061
2317
|
</For>
|
|
2062
|
-
<Show when={
|
|
2318
|
+
<Show when={matchedGroups().length === 0}>
|
|
2063
2319
|
<box paddingLeft={4} paddingRight={4}>
|
|
2064
2320
|
<text style={{ fg: api.theme.current.textMuted }}>
|
|
2065
2321
|
{waitingOnly() && waitingCoverage() === "loading"
|
|
@@ -2087,7 +2343,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2087
2343
|
>
|
|
2088
2344
|
{pendingDelete()
|
|
2089
2345
|
? `Delete "${truncate(pendingDelete()!.title, 40)}"? y confirm · n cancel`
|
|
2090
|
-
: "↑↓ move · enter open · ctrl+g project · option+w
|
|
2346
|
+
: "↑↓ move · enter open · ctrl+g project · option+w input · option+s pinned · option+p preview"}
|
|
2091
2347
|
</text>
|
|
2092
2348
|
<Show when={!pendingDelete()}>
|
|
2093
2349
|
<text style={{ fg: api.theme.current.textMuted }}>ctrl+s/d pin · ctrl+x delete · ctrl+f fork · esc close</text>
|
|
@@ -2096,27 +2352,27 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2096
2352
|
<Show when={showPreview()}>
|
|
2097
2353
|
<box
|
|
2098
2354
|
position="absolute"
|
|
2099
|
-
left={
|
|
2100
|
-
top={
|
|
2101
|
-
width={
|
|
2102
|
-
height={
|
|
2355
|
+
left={0}
|
|
2356
|
+
top={0}
|
|
2357
|
+
width={dialogWidth}
|
|
2358
|
+
height={dialogHeight()}
|
|
2103
2359
|
flexDirection="column"
|
|
2104
2360
|
backgroundColor={api.theme.current.backgroundElement}
|
|
2105
|
-
paddingLeft={
|
|
2106
|
-
paddingRight={
|
|
2361
|
+
paddingLeft={4}
|
|
2362
|
+
paddingRight={4}
|
|
2107
2363
|
paddingTop={1}
|
|
2108
2364
|
paddingBottom={1}
|
|
2109
2365
|
gap={1}
|
|
2110
2366
|
>
|
|
2111
2367
|
<box flexDirection="row" justifyContent="space-between">
|
|
2112
2368
|
<text attributes={TextAttributes.BOLD}>
|
|
2113
|
-
{previewID() ? truncate(selectableEntries()[cursor()]!.title,
|
|
2369
|
+
{previewID() ? truncate(selectableEntries()[cursor()]!.title, dialogWidth - 18) : "Preview"}
|
|
2114
2370
|
</text>
|
|
2115
2371
|
<text style={{ fg: api.theme.current.textMuted }}>esc close</text>
|
|
2116
2372
|
</box>
|
|
2117
2373
|
<Show when={previewID()}>
|
|
2118
2374
|
<text style={{ fg: api.theme.current.textMuted }}>
|
|
2119
|
-
{prettyDir(selectableEntries()[cursor()]!.dir, process.env.HOME ?? "")}
|
|
2375
|
+
{prettyDir(selectableEntries()[cursor()]!.dir, process.env.HOME ?? "")} · {ago(selectableEntries()[cursor()]!.updated)}
|
|
2120
2376
|
</text>
|
|
2121
2377
|
</Show>
|
|
2122
2378
|
<scrollbox flexGrow={1} scrollbarOptions={{ visible: false }}>
|
|
@@ -2136,6 +2392,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2136
2392
|
</Show>
|
|
2137
2393
|
</Show>
|
|
2138
2394
|
</scrollbox>
|
|
2395
|
+
<text style={{ fg: api.theme.current.textMuted }}>↑↓ switch session · esc back to picker</text>
|
|
2139
2396
|
</box>
|
|
2140
2397
|
</Show>
|
|
2141
2398
|
</box>
|
|
@@ -2436,7 +2693,7 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
|
|
|
2436
2693
|
])
|
|
2437
2694
|
|
|
2438
2695
|
api.keymap.registerLayer({
|
|
2439
|
-
bindings: [{ key: "
|
|
2696
|
+
bindings: [{ key: "option+o", desc: "Pick session", preventDefault: true, cmd: () => void openPicker() }],
|
|
2440
2697
|
})
|
|
2441
2698
|
}
|
|
2442
2699
|
|