opencode-sesh 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +7 -5
- package/CHANGELOG.md +21 -1
- package/README.md +9 -7
- package/bin/sesh-list.sh +4 -2
- package/bin/sesh-shortcuts.sh +2 -2
- package/bin/sesh.sh +2 -2
- package/package.json +5 -4
- package/tui/sesh-panel.tsx +140 -77
package/AGENTS.md
CHANGED
|
@@ -83,8 +83,8 @@ the schema below. Keystrokes never touch the database:
|
|
|
83
83
|
epoch, not a boolean).
|
|
84
84
|
- Session and directory pins live in `${XDG_DATA_HOME:-$HOME/.local/share}/sesh/pins.json`
|
|
85
85
|
(`SESH_PINS_FILE` overrides it for tests), shared by fzf and the TUI. Writes
|
|
86
|
-
use a sibling lock directory and atomic rename.
|
|
87
|
-
session;
|
|
86
|
+
use a sibling lock directory and atomic rename. Ctrl-S toggles a selected
|
|
87
|
+
session; Ctrl-D toggles its directory. Pinned directories sort before groups
|
|
88
88
|
containing pinned sessions, with pinned sessions first inside each group.
|
|
89
89
|
|
|
90
90
|
**Output contract** (TSV, 6 fields):
|
|
@@ -105,7 +105,8 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
|
|
|
105
105
|
## Conventions
|
|
106
106
|
|
|
107
107
|
- Default scope is **all** sessions, every directory. `--cwd`/Ctrl-G narrows.
|
|
108
|
-
Archived sessions and fork children
|
|
108
|
+
Archived sessions and child sessions (fork children, spawned subagents) are
|
|
109
|
+
hidden unless `--archived`; metadata and cache pruning still see them.
|
|
109
110
|
- **Text parts only** in search/preview; reasoning and tool payloads are
|
|
110
111
|
excluded (asserted in tests).
|
|
111
112
|
- Validate session ids (`^ses_[A-Za-z0-9]+$`) before SQL interpolation — never
|
|
@@ -141,8 +142,9 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
|
|
|
141
142
|
- **Sidebar:** `api.slots.register` on `sidebar_content` (append mode — native
|
|
142
143
|
sidebar content stays). Pinned directories sort first, then directory groups
|
|
143
144
|
by recency; sessions within each group sort by last update regardless of pins.
|
|
144
|
-
|
|
145
|
-
|
|
145
|
+
Row markers alone signal session state: a running agent animates a loading
|
|
146
|
+
spinner marker, the open session shows its own ●, and every other idle row
|
|
147
|
+
stays neutral (polled every 15 s); Ctrl-P previews
|
|
146
148
|
a row and ctrl+x deletes one (armed, then confirmed). The section lists every
|
|
147
149
|
session without a row cap and flex-grows to fill the sidebar, with overflow
|
|
148
150
|
scrolling inside; the picker remains the place for transcript search.
|
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.10.0] - 2026-09-25
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- The TUI session picker is taller (about three quarters of the screen) and
|
|
15
|
+
stays vertically centred; hovering a row moves the selection.
|
|
16
|
+
- `ctrl+p` opens the transcript preview as a centred overlay on top of the
|
|
17
|
+
picker that follows the hovered session and closes with Esc.
|
|
18
|
+
## [0.9.1] - 2026-09-25
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- Sidebar rows show state only in the marker column: a running agent animates
|
|
23
|
+
a loading spinner; idle rows and their titles go back to neutral.
|
|
24
|
+
- Pin keybinds move from Option-S/Option-D to Ctrl-S/Ctrl-D in the terminal
|
|
25
|
+
picker, the sidebar, and the picker dialog.
|
|
26
|
+
- The terminal picker hides child sessions (fork children, spawned subagents)
|
|
27
|
+
like the panel does; `--archived` still opts them back in.
|
|
10
28
|
## [0.9.0] - 2026-09-24
|
|
11
29
|
|
|
12
30
|
### Added
|
|
@@ -342,7 +360,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
342
360
|
- Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
|
|
343
361
|
CI.
|
|
344
362
|
|
|
345
|
-
[Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.
|
|
363
|
+
[Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.10.0...HEAD
|
|
364
|
+
[0.10.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.9.1...v0.10.0
|
|
365
|
+
[0.9.1]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.9.0...v0.9.1
|
|
346
366
|
[0.9.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.8.0...v0.9.0
|
|
347
367
|
[0.8.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.7.3...v0.8.0
|
|
348
368
|
[0.7.3]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.7.2...v0.7.3
|
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.
|
|
@@ -28,7 +30,7 @@ grouped by directory, full-text searchable, and previewable from anywhere.
|
|
|
28
30
|
|
|
29
31
|
- **Every session, everywhere.** Sessions are grouped by project directory and
|
|
30
32
|
sorted by pins, then recency — across *all* your projects, not just the current one.
|
|
31
|
-
- **Pin what matters.**
|
|
33
|
+
- **Pin what matters.** Ctrl-S pins a session and Ctrl-D pins its directory in
|
|
32
34
|
either picker. Pins are shared across the terminal and opencode TUI and
|
|
33
35
|
persist across restarts; pinned directories and sessions sort first.
|
|
34
36
|
- **Full-text search.** Type to match against session titles *and* the text of
|
|
@@ -115,7 +117,7 @@ bash install.sh --uninstall # from source
|
|
|
115
117
|
| Enter | Resume the selected session in this terminal |
|
|
116
118
|
| Ctrl-F | Resume as a fork (the original is untouched) |
|
|
117
119
|
| Ctrl-G | Toggle current-directory scope / all sessions |
|
|
118
|
-
|
|
|
120
|
+
| Ctrl-S / Ctrl-D | Pin or unpin the selected session / directory |
|
|
119
121
|
| Ctrl-P | Toggle the transcript preview |
|
|
120
122
|
| `?` (empty query) | Toggle shortcut help |
|
|
121
123
|
| Ctrl-X | Delete the selected session (asks to confirm) |
|
|
@@ -176,21 +178,21 @@ non-interactive `sesh-delete.sh` refuses unless given `--yes`.
|
|
|
176
178
|
| Ctrl-F | Fork the selected session |
|
|
177
179
|
| Ctrl-G | Toggle scope: the selected session's project, or every project |
|
|
178
180
|
| Option-W / Option-P | Show only sessions needing input / pinned sessions |
|
|
179
|
-
|
|
|
181
|
+
| Ctrl-S / Ctrl-D | Pin or unpin the selected session / directory |
|
|
180
182
|
| Esc | Close |
|
|
181
183
|
|
|
182
184
|
Click the sidebar's `search…` box to filter recent sessions in place; click
|
|
183
185
|
elsewhere or press Esc to leave search. Click the **Sessions** heading to drive
|
|
184
186
|
the list from the keyboard instead of the mouse (`↑`/`↓` to move, Enter to open,
|
|
185
|
-
Ctrl-P to preview,
|
|
187
|
+
Ctrl-P to preview, Ctrl-S/Ctrl-D to pin, Ctrl-X to delete, Esc to leave). Pinned
|
|
186
188
|
directories rise first, while sessions within each directory are ordered by
|
|
187
189
|
last update, newest first. The section lists every session and scrolls to fill
|
|
188
190
|
the sidebar, so click **▾ hide** to collapse it when you need the room.
|
|
189
191
|
Sessions waiting on you (unanswered agent questions, runs stuck mid-tool) pin
|
|
190
192
|
themselves to a **Needs input** group above the directories, with the count in
|
|
191
|
-
the heading; `sesh --needs-input` lists the same set in the terminal. Row
|
|
192
|
-
|
|
193
|
-
|
|
193
|
+
the heading; `sesh --needs-input` lists the same set in the terminal. Row
|
|
194
|
+
markers are the only status signal: a loading spinner marks the session whose
|
|
195
|
+
agent is working; the open session shows ● and every other row stays neutral.
|
|
194
196
|
Hovering a row while keyboard navigation is active selects it for the next
|
|
195
197
|
shortcut. Clicking (or Enter on) a session opens it here. The full picker is one
|
|
196
198
|
keystroke away.
|
package/bin/sesh-list.sh
CHANGED
|
@@ -276,7 +276,7 @@ refresh() {
|
|
|
276
276
|
historical="$scratch/historical.jsonl"; needs_extract="$scratch/needs-extract.json"
|
|
277
277
|
out="$scratch/snapshot.jsonl"
|
|
278
278
|
|
|
279
|
-
db_json "SELECT id, directory, title, time_created, time_updated, COALESCE(time_archived, 0) AS archived FROM session ORDER BY time_updated DESC;" > "$sessions" 2>/dev/null || {
|
|
279
|
+
db_json "SELECT id, directory, title, time_created, time_updated, COALESCE(time_archived, 0) AS archived, COALESCE(parent_id, '') AS parent FROM session ORDER BY time_updated DESC;" > "$sessions" 2>/dev/null || {
|
|
280
280
|
if [ -f "$SNAPSHOT" ]; then set_status 'stale: opencode database query failed; showing last good snapshot'; else set_status 'unavailable: opencode database query failed'; fi
|
|
281
281
|
return 0
|
|
282
282
|
}
|
|
@@ -388,7 +388,9 @@ refresh() {
|
|
|
388
388
|
| ($hist | map({key: .sessionId, value: .}) | from_entries) as $histById
|
|
389
389
|
| [$meta[]
|
|
390
390
|
| select((.id | type) == "string" and (.id | test("^ses_[A-Za-z0-9]+$")))
|
|
391
|
-
|
|
391
|
+
# Child sessions (fork children, spawned subagents) surface only with
|
|
392
|
+
# --archived; metadata still sees them, so cache pruning stays safe.
|
|
393
|
+
| select($archived == 1 or (.archived == 0 and .parent == ""))
|
|
392
394
|
| select($scope == "" or .directory == $scope)
|
|
393
395
|
| . as $m | ($histById[$m.id] // {}) as $h
|
|
394
396
|
| {sessionId: $m.id, malformedRecords: 0, agentId: "", agentState: "",
|
package/bin/sesh-shortcuts.sh
CHANGED
|
@@ -8,8 +8,8 @@ sesh shortcuts
|
|
|
8
8
|
Ctrl-F Resume the selected session as a fork
|
|
9
9
|
Ctrl-G Toggle current-directory scope / all sessions
|
|
10
10
|
Ctrl-P Toggle transcript preview
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
Ctrl-S Pin / unpin selected session
|
|
12
|
+
Ctrl-D Pin / unpin selected directory
|
|
13
13
|
? Toggle this help
|
|
14
14
|
Ctrl-X Delete the selected session (asks to confirm)
|
|
15
15
|
Escape Exit the picker
|
package/bin/sesh.sh
CHANGED
|
@@ -146,8 +146,8 @@ while :; do
|
|
|
146
146
|
--bind="$help_bind" \
|
|
147
147
|
--bind="ctrl-x:execute($delete_cmd)+transform-header($header_cmd)+reload($cache_cmd)" \
|
|
148
148
|
--bind="ctrl-g:execute($toggle_cmd)+transform-header($header_cmd)+reload($cache_cmd)" \
|
|
149
|
-
--bind="
|
|
150
|
-
--bind="
|
|
149
|
+
--bind="ctrl-s:execute-silent($pin_session_cmd)+reload($cache_cmd)" \
|
|
150
|
+
--bind="ctrl-d:execute-silent($pin_directory_cmd)+reload($cache_cmd)" \
|
|
151
151
|
--bind="change:reload:$cache_cmd" \
|
|
152
152
|
--bind="start,every(3):transform-header($header_cmd)+reload-sync:$cache_cmd")
|
|
153
153
|
status=$?
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-sesh",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"type": "module",
|
|
3
|
+
"version": "0.10.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
|
@@ -248,19 +248,20 @@ function ensureRootMouse(renderer: TuiPluginApi["renderer"]) {
|
|
|
248
248
|
|
|
249
249
|
type Entry = { id: string; title: string; dir: string; group: string; updated: number }
|
|
250
250
|
type EntryResult = { entries: Entry[]; truncated: boolean }
|
|
251
|
-
type
|
|
251
|
+
type SidebarMarker = "current" | "running" | "idle"
|
|
252
|
+
|
|
253
|
+
// Loading-spinner frames for the marker of a session whose agent is working.
|
|
254
|
+
const SIDEBAR_SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
252
255
|
|
|
253
256
|
function newestFirst(entries: Entry[]): Entry[] {
|
|
254
257
|
return [...entries].sort((a, b) => b.updated - a.updated || a.id.localeCompare(b.id))
|
|
255
258
|
}
|
|
256
259
|
|
|
257
|
-
// A
|
|
258
|
-
//
|
|
259
|
-
function
|
|
260
|
-
if (pending || reason === "question") return "waiting"
|
|
260
|
+
// A working agent is the only state the marker displays: everything that
|
|
261
|
+
// finished goes back to the neutral row, with the open session marked inside.
|
|
262
|
+
function sidebarMarker(current: boolean, live?: string): SidebarMarker {
|
|
261
263
|
if (live === "busy" || live === "retry") return "running"
|
|
262
|
-
|
|
263
|
-
return current ? "active" : "idle"
|
|
264
|
+
return current ? "current" : "idle"
|
|
264
265
|
}
|
|
265
266
|
|
|
266
267
|
const SESSION_PAGE_LIMIT = 200
|
|
@@ -808,8 +809,6 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
808
809
|
const ids = waiting()
|
|
809
810
|
return newestFirst(shownEntries().filter((entry) => ids.has(entry.id)))
|
|
810
811
|
})
|
|
811
|
-
const waitingReason = (id: string) =>
|
|
812
|
-
waiting().get(id) === "question" ? "awaiting answer" : waiting().get(id) === "stuck" ? "run stuck" : "needs input"
|
|
813
812
|
|
|
814
813
|
const tree = createMemo<TreeRow[]>(() => {
|
|
815
814
|
const rows: TreeRow[] = []
|
|
@@ -884,26 +883,34 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
884
883
|
previousRows = rows
|
|
885
884
|
})
|
|
886
885
|
|
|
887
|
-
|
|
886
|
+
// Marker only: a spinner for a working agent, the open-session dot, and the
|
|
887
|
+
// neutral row for idle sessions. Titles stay plain and no status text is
|
|
888
|
+
// rendered.
|
|
889
|
+
const markerOf = (id: string): SidebarMarker => {
|
|
890
|
+
let live: string | undefined
|
|
888
891
|
try {
|
|
889
|
-
|
|
890
|
-
const questions = props.api.state.session.question(id)
|
|
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
|
-
)
|
|
892
|
+
live = props.api.state.session.status(id)?.type
|
|
897
893
|
} catch {}
|
|
898
|
-
return
|
|
894
|
+
return sidebarMarker(id === currentID(), live)
|
|
899
895
|
}
|
|
900
896
|
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
897
|
+
// The spinner animates only while a listed session is actually running, so
|
|
898
|
+
// an idle list costs no timer and only running rows re-render per frame.
|
|
899
|
+
const [spinFrame, setSpinFrame] = createSignal(0)
|
|
900
|
+
let spinTimer: ReturnType<typeof setInterval> | undefined
|
|
901
|
+
createEffect(() => {
|
|
902
|
+
const spinning = itemRows().some((entry) => markerOf(entry.id) === "running")
|
|
903
|
+
if (spinning && !spinTimer) {
|
|
904
|
+
spinTimer = setInterval(() => setSpinFrame((value) => (value + 1) % SIDEBAR_SPINNER.length), 120)
|
|
905
|
+
} else if (!spinning && spinTimer) {
|
|
906
|
+
clearInterval(spinTimer)
|
|
907
|
+
spinTimer = undefined
|
|
908
|
+
setSpinFrame(0)
|
|
909
|
+
}
|
|
910
|
+
})
|
|
911
|
+
onCleanup(() => {
|
|
912
|
+
if (spinTimer) clearInterval(spinTimer)
|
|
913
|
+
})
|
|
907
914
|
|
|
908
915
|
const cancelDelete = () => {
|
|
909
916
|
if (confirmTimer) clearTimeout(confirmTimer)
|
|
@@ -1027,8 +1034,8 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1027
1034
|
if (!confirmArmed()) requestDelete(entry)
|
|
1028
1035
|
},
|
|
1029
1036
|
},
|
|
1030
|
-
{ key: "
|
|
1031
|
-
{ key: "
|
|
1037
|
+
{ key: "ctrl+s", desc: "Pin session", preventDefault: true, cmd: () => props.onTogglePin("sessions", entry.id) },
|
|
1038
|
+
{ key: "ctrl+d", desc: "Pin directory", preventDefault: true, cmd: () => props.onTogglePin("directories", entry.dir) },
|
|
1032
1039
|
{
|
|
1033
1040
|
key: "/",
|
|
1034
1041
|
desc: "Search sessions",
|
|
@@ -1077,7 +1084,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1077
1084
|
},
|
|
1078
1085
|
},
|
|
1079
1086
|
{
|
|
1080
|
-
key: "
|
|
1087
|
+
key: "ctrl+s",
|
|
1081
1088
|
desc: "Pin session",
|
|
1082
1089
|
preventDefault: true,
|
|
1083
1090
|
cmd: () => {
|
|
@@ -1086,7 +1093,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1086
1093
|
},
|
|
1087
1094
|
},
|
|
1088
1095
|
{
|
|
1089
|
-
key: "
|
|
1096
|
+
key: "ctrl+d",
|
|
1090
1097
|
desc: "Pin directory",
|
|
1091
1098
|
preventDefault: true,
|
|
1092
1099
|
cmd: () => {
|
|
@@ -1210,7 +1217,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1210
1217
|
</text>
|
|
1211
1218
|
</box>
|
|
1212
1219
|
)
|
|
1213
|
-
const
|
|
1220
|
+
const marker = createMemo(() => markerOf(row.entry.id))
|
|
1214
1221
|
return (
|
|
1215
1222
|
<box
|
|
1216
1223
|
flexDirection="row"
|
|
@@ -1225,7 +1232,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1225
1232
|
onMouseOver={() => {
|
|
1226
1233
|
setHovered(row.entry.id)
|
|
1227
1234
|
// The keyboard layer wins over the hover layer while nav is
|
|
1228
|
-
// active. Keep its cursor on the hovered row so
|
|
1235
|
+
// active. Keep its cursor on the hovered row so Ctrl-S/Ctrl-D
|
|
1229
1236
|
// cannot silently pin a different (often current) session.
|
|
1230
1237
|
if (navActive()) {
|
|
1231
1238
|
const index = itemRows().findIndex((entry) => entry.id === row.entry.id)
|
|
@@ -1241,30 +1248,24 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1241
1248
|
style={{
|
|
1242
1249
|
fg: isPendingDelete(row.entry.id)
|
|
1243
1250
|
? theme().error
|
|
1244
|
-
:
|
|
1251
|
+
: marker() === "current"
|
|
1245
1252
|
? theme().accent
|
|
1246
|
-
:
|
|
1253
|
+
: marker() === "running"
|
|
1254
|
+
? theme().success
|
|
1255
|
+
: theme().textMuted,
|
|
1247
1256
|
}}
|
|
1248
1257
|
>
|
|
1249
|
-
{
|
|
1258
|
+
{marker() === "current" ? "●" : marker() === "running" ? SIDEBAR_SPINNER[spinFrame()] : isActive(row.entry.id) ? "▸" : "○"}
|
|
1250
1259
|
</span>
|
|
1251
1260
|
</text>
|
|
1252
1261
|
<Highlighted
|
|
1253
1262
|
text={`${props.pins().sessions.includes(row.entry.id) ? "★ " : ""}${truncate(row.entry.title, SIDEBAR_TITLE_WIDTH - (props.pins().sessions.includes(row.entry.id) ? 2 : 0))}`}
|
|
1254
1263
|
query={query()}
|
|
1255
|
-
color={isPendingDelete(row.entry.id) ? theme().error :
|
|
1264
|
+
color={isPendingDelete(row.entry.id) ? theme().error : theme().text}
|
|
1256
1265
|
matchColor={theme().warning}
|
|
1257
1266
|
/>
|
|
1258
1267
|
<text flexShrink={0} style={{ fg: isPendingDelete(row.entry.id) ? theme().error : theme().textMuted }}>
|
|
1259
|
-
{isPendingDelete(row.entry.id) ? (
|
|
1260
|
-
"ctrl+x again"
|
|
1261
|
-
) : activity() !== "idle" ? (
|
|
1262
|
-
<span>
|
|
1263
|
-
{ago(row.entry.updated)} · <span style={{ fg: statusColor(activity()) }}>{activity() === "waiting" ? waitingReason(row.entry.id) : activity()}</span>
|
|
1264
|
-
</span>
|
|
1265
|
-
) : (
|
|
1266
|
-
ago(row.entry.updated)
|
|
1267
|
-
)}
|
|
1268
|
+
{isPendingDelete(row.entry.id) ? "ctrl+x again" : ago(row.entry.updated)}
|
|
1268
1269
|
</text>
|
|
1269
1270
|
</box>
|
|
1270
1271
|
)
|
|
@@ -1279,7 +1280,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1279
1280
|
</Show>
|
|
1280
1281
|
<Show when={navActive()}>
|
|
1281
1282
|
<box paddingTop={1}>
|
|
1282
|
-
<text style={{ fg: theme().textMuted }}>↑↓ move · enter open · ctrl+p preview ·
|
|
1283
|
+
<text style={{ fg: theme().textMuted }}>↑↓ move · enter open · ctrl+p preview · ctrl+s/d pin · ctrl+x delete · esc done</text>
|
|
1283
1284
|
</box>
|
|
1284
1285
|
</Show>
|
|
1285
1286
|
</Show>
|
|
@@ -1350,8 +1351,8 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1350
1351
|
preventDefault: true,
|
|
1351
1352
|
cmd: () => preview.open(entry),
|
|
1352
1353
|
},
|
|
1353
|
-
{ key: "
|
|
1354
|
-
{ key: "
|
|
1354
|
+
{ key: "ctrl+s", desc: "Pin session", preventDefault: true, cmd: () => props.onTogglePin("sessions", entry.id) },
|
|
1355
|
+
{ key: "ctrl+d", desc: "Pin directory", preventDefault: true, cmd: () => props.onTogglePin("directories", entry.dir) },
|
|
1355
1356
|
],
|
|
1356
1357
|
})
|
|
1357
1358
|
})
|
|
@@ -1645,13 +1646,23 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1645
1646
|
})
|
|
1646
1647
|
|
|
1647
1648
|
const termHeight = api.renderer.height
|
|
1648
|
-
|
|
1649
|
+
// The dialog host anchors content a quarter of the way down the screen and
|
|
1650
|
+
// lets it size to content, so a taller picker drifts downward. Pin the
|
|
1651
|
+
// content height and offset it up by the same amount to keep it centred.
|
|
1652
|
+
const dialogWidth = Math.min(116, api.renderer.width - 2)
|
|
1653
|
+
const CHROME_ROWS = 12
|
|
1654
|
+
const listHeight = () => Math.max(6, Math.floor(termHeight * 0.75) - CHROME_ROWS)
|
|
1655
|
+
const dialogHeight = () => listHeight() + CHROME_ROWS
|
|
1656
|
+
const dialogOffset = () => Math.min(0, Math.floor(termHeight / 4 - dialogHeight() / 2 - 1))
|
|
1657
|
+
const previewWidth = () => Math.max(40, Math.min(94, dialogWidth - 6))
|
|
1658
|
+
const previewHeight = () => Math.max(8, Math.floor(termHeight / 2))
|
|
1649
1659
|
|
|
1650
1660
|
const visiblePickerRows = createMemo<DialogRow[]>(() => {
|
|
1651
1661
|
const flat = pickerRows()
|
|
1652
1662
|
// A transcript hit adds a second line to a row; bound by screen height,
|
|
1653
1663
|
// not just the number of session IDs.
|
|
1654
|
-
const
|
|
1664
|
+
const height = listHeight()
|
|
1665
|
+
const windowSize = query().trim() ? Math.max(2, Math.floor(height / 3)) : height
|
|
1655
1666
|
const currentID = selectableEntries()[cursor()]?.id
|
|
1656
1667
|
let pos = currentID
|
|
1657
1668
|
? flat.findIndex((row) => row.kind === "item" && row.entry.id === currentID)
|
|
@@ -1726,6 +1737,22 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1726
1737
|
setPendingDelete(undefined)
|
|
1727
1738
|
}
|
|
1728
1739
|
|
|
1740
|
+
// Escape first closes the transcript overlay, then the picker. One Escape
|
|
1741
|
+
// can reach both the focused search input and the key layer (sometimes in
|
|
1742
|
+
// separate ticks), so ignore the duplicate that trails right behind.
|
|
1743
|
+
let escapeHandledAt = 0
|
|
1744
|
+
const handleEscape = () => {
|
|
1745
|
+
const now = Date.now()
|
|
1746
|
+
if (escapeHandledAt > now) return
|
|
1747
|
+
if (showPreview()) {
|
|
1748
|
+
setShowPreview(false)
|
|
1749
|
+
escapeHandledAt = now + 150
|
|
1750
|
+
return
|
|
1751
|
+
}
|
|
1752
|
+
cancelDelete()
|
|
1753
|
+
close()
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1729
1756
|
const deleteEntry = async (entry: Entry) => {
|
|
1730
1757
|
cancelDelete()
|
|
1731
1758
|
try {
|
|
@@ -1792,6 +1819,9 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1792
1819
|
}
|
|
1793
1820
|
|
|
1794
1821
|
const disposeNav = api.keymap.registerLayer({
|
|
1822
|
+
// Outrank the built-in dialog layer so Escape reaches this picker before
|
|
1823
|
+
// the dialog host closes the whole thing.
|
|
1824
|
+
priority: 1,
|
|
1795
1825
|
bindings: [
|
|
1796
1826
|
{ key: "up", desc: "Previous session", preventDefault: true, cmd: () => moveCursor(-1) },
|
|
1797
1827
|
{ key: "down", desc: "Next session", preventDefault: true, cmd: () => moveCursor(1) },
|
|
@@ -1806,11 +1836,11 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1806
1836
|
},
|
|
1807
1837
|
{ key: "enter", desc: "Open session", preventDefault: true, cmd: () => choose() },
|
|
1808
1838
|
{ key: "ctrl+p", desc: "Toggle transcript preview", preventDefault: true, cmd: togglePreview },
|
|
1809
|
-
{ key: "
|
|
1839
|
+
{ key: "ctrl+s", desc: "Pin session", preventDefault: true, cmd: () => {
|
|
1810
1840
|
const entry = selectableEntries()[cursor()]
|
|
1811
1841
|
if (entry) onTogglePin("sessions", entry.id)
|
|
1812
1842
|
} },
|
|
1813
|
-
{ key: "
|
|
1843
|
+
{ key: "ctrl+d", desc: "Pin directory", preventDefault: true, cmd: () => {
|
|
1814
1844
|
const entry = selectableEntries()[cursor()]
|
|
1815
1845
|
if (entry) onTogglePin("directories", entry.dir)
|
|
1816
1846
|
} },
|
|
@@ -1851,10 +1881,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1851
1881
|
key: "escape",
|
|
1852
1882
|
desc: "Close",
|
|
1853
1883
|
preventDefault: true,
|
|
1854
|
-
cmd:
|
|
1855
|
-
cancelDelete()
|
|
1856
|
-
close()
|
|
1857
|
-
},
|
|
1884
|
+
cmd: handleEscape,
|
|
1858
1885
|
},
|
|
1859
1886
|
],
|
|
1860
1887
|
})
|
|
@@ -1870,7 +1897,14 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1870
1897
|
|
|
1871
1898
|
api.ui.dialog.replace(
|
|
1872
1899
|
() => (
|
|
1873
|
-
<box
|
|
1900
|
+
<box
|
|
1901
|
+
flexDirection="column"
|
|
1902
|
+
height={dialogHeight()}
|
|
1903
|
+
marginTop={dialogOffset()}
|
|
1904
|
+
paddingBottom={1}
|
|
1905
|
+
gap={1}
|
|
1906
|
+
backgroundColor={api.theme.current.backgroundPanel}
|
|
1907
|
+
>
|
|
1874
1908
|
<box paddingLeft={4} paddingRight={4}>
|
|
1875
1909
|
<box flexDirection="row" justifyContent="space-between">
|
|
1876
1910
|
<text attributes={TextAttributes.BOLD}>
|
|
@@ -1894,7 +1928,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1894
1928
|
onKeyDown={(event) => {
|
|
1895
1929
|
if (event.name === "escape") {
|
|
1896
1930
|
event.preventDefault()
|
|
1897
|
-
|
|
1931
|
+
handleEscape()
|
|
1898
1932
|
}
|
|
1899
1933
|
}}
|
|
1900
1934
|
/>
|
|
@@ -1922,6 +1956,8 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1922
1956
|
</box>
|
|
1923
1957
|
<box
|
|
1924
1958
|
flexDirection="column"
|
|
1959
|
+
flexGrow={1}
|
|
1960
|
+
overflow="hidden"
|
|
1925
1961
|
onMouseScroll={(event: { scroll?: { direction: string } }) => {
|
|
1926
1962
|
const direction = event.scroll?.direction
|
|
1927
1963
|
if (direction === "down") moveCursor(3)
|
|
@@ -1931,7 +1967,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1931
1967
|
<For each={visiblePickerRows()}>
|
|
1932
1968
|
{(row) => {
|
|
1933
1969
|
if (row.kind === "group") return (
|
|
1934
|
-
<box
|
|
1970
|
+
<box paddingLeft={4}>
|
|
1935
1971
|
<text style={{ fg: api.theme.current.accent }} attributes={TextAttributes.BOLD}>
|
|
1936
1972
|
{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>
|
|
1937
1973
|
</text>
|
|
@@ -1948,6 +1984,12 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1948
1984
|
? api.theme.current.primary
|
|
1949
1985
|
: RGBA.fromInts(0, 0, 0, 0)
|
|
1950
1986
|
}
|
|
1987
|
+
onMouseOver={() => {
|
|
1988
|
+
// Hovering moves the cursor so the highlight, Ctrl-S/Ctrl-D
|
|
1989
|
+
// and Enter all resolve to the row under the pointer.
|
|
1990
|
+
const index = selectableEntries().findIndex((entry) => entry.id === row.entry.id)
|
|
1991
|
+
if (index >= 0) setCursor(index)
|
|
1992
|
+
}}
|
|
1951
1993
|
onMouseDown={() => choose(row.entry)}
|
|
1952
1994
|
>
|
|
1953
1995
|
<box flexDirection="row" gap={1}>
|
|
@@ -2025,7 +2067,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2025
2067
|
: waitingOnly() && waitingCoverage() === "unavailable"
|
|
2026
2068
|
? "Needs-input data unavailable · turn off the filter"
|
|
2027
2069
|
: pinnedOnly() && pins().sessions.length === 0
|
|
2028
|
-
? "No pinned sessions yet ·
|
|
2070
|
+
? "No pinned sessions yet · ctrl+s pins a session"
|
|
2029
2071
|
: !indexProgress().complete && query().trim()
|
|
2030
2072
|
? "No matches yet · still searching transcripts…"
|
|
2031
2073
|
: scope()
|
|
@@ -2037,11 +2079,46 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2037
2079
|
</box>
|
|
2038
2080
|
</Show>
|
|
2039
2081
|
</box>
|
|
2082
|
+
<box paddingLeft={4} paddingRight={4} flexShrink={0} flexDirection="column">
|
|
2083
|
+
<text
|
|
2084
|
+
style={{
|
|
2085
|
+
fg: pendingDelete() ? api.theme.current.warning : api.theme.current.textMuted,
|
|
2086
|
+
}}
|
|
2087
|
+
>
|
|
2088
|
+
{pendingDelete()
|
|
2089
|
+
? `Delete "${truncate(pendingDelete()!.title, 40)}"? y confirm · n cancel`
|
|
2090
|
+
: "↑↓ move · enter open · ctrl+g project · option+w needs · option+p pinned · ctrl+p preview"}
|
|
2091
|
+
</text>
|
|
2092
|
+
<Show when={!pendingDelete()}>
|
|
2093
|
+
<text style={{ fg: api.theme.current.textMuted }}>ctrl+s/d pin · ctrl+x delete · ctrl+f fork · esc close</text>
|
|
2094
|
+
</Show>
|
|
2095
|
+
</box>
|
|
2040
2096
|
<Show when={showPreview()}>
|
|
2041
|
-
<box
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2097
|
+
<box
|
|
2098
|
+
position="absolute"
|
|
2099
|
+
left={Math.floor((dialogWidth - previewWidth()) / 2)}
|
|
2100
|
+
top={Math.max(0, Math.floor((dialogHeight() - previewHeight()) / 2))}
|
|
2101
|
+
width={previewWidth()}
|
|
2102
|
+
height={previewHeight()}
|
|
2103
|
+
flexDirection="column"
|
|
2104
|
+
backgroundColor={api.theme.current.backgroundElement}
|
|
2105
|
+
paddingLeft={2}
|
|
2106
|
+
paddingRight={2}
|
|
2107
|
+
paddingTop={1}
|
|
2108
|
+
paddingBottom={1}
|
|
2109
|
+
gap={1}
|
|
2110
|
+
>
|
|
2111
|
+
<box flexDirection="row" justifyContent="space-between">
|
|
2112
|
+
<text attributes={TextAttributes.BOLD}>
|
|
2113
|
+
{previewID() ? truncate(selectableEntries()[cursor()]!.title, previewWidth() - 8) : "Preview"}
|
|
2114
|
+
</text>
|
|
2115
|
+
<text style={{ fg: api.theme.current.textMuted }}>esc close</text>
|
|
2116
|
+
</box>
|
|
2117
|
+
<Show when={previewID()}>
|
|
2118
|
+
<text style={{ fg: api.theme.current.textMuted }}>
|
|
2119
|
+
{prettyDir(selectableEntries()[cursor()]!.dir, process.env.HOME ?? "")}
|
|
2120
|
+
</text>
|
|
2121
|
+
</Show>
|
|
2045
2122
|
<scrollbox flexGrow={1} scrollbarOptions={{ visible: false }}>
|
|
2046
2123
|
<Show when={previewID()} fallback={<text style={{ fg: api.theme.current.textMuted }}>Select a session to preview</text>}>
|
|
2047
2124
|
<Show when={previewText()} fallback={<text style={{ fg: api.theme.current.textMuted }}>Loading…</text>}>
|
|
@@ -2061,20 +2138,6 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2061
2138
|
</scrollbox>
|
|
2062
2139
|
</box>
|
|
2063
2140
|
</Show>
|
|
2064
|
-
<box paddingLeft={4} paddingRight={4} flexShrink={0} flexDirection="column">
|
|
2065
|
-
<text
|
|
2066
|
-
style={{
|
|
2067
|
-
fg: pendingDelete() ? api.theme.current.warning : api.theme.current.textMuted,
|
|
2068
|
-
}}
|
|
2069
|
-
>
|
|
2070
|
-
{pendingDelete()
|
|
2071
|
-
? `Delete "${truncate(pendingDelete()!.title, 40)}"? y confirm · n cancel`
|
|
2072
|
-
: "↑↓ move · enter open · ctrl+g project · option+w needs · option+p pinned · ctrl+p preview"}
|
|
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>
|
|
2077
|
-
</box>
|
|
2078
2141
|
</box>
|
|
2079
2142
|
),
|
|
2080
2143
|
() => {
|