opencode-sesh 0.11.0 → 0.12.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 +2 -0
- package/CHANGELOG.md +20 -1
- package/README.md +3 -1
- package/ROADMAP.md +2 -3
- package/contrib/cmux/README.md +2 -2
- package/install.sh +1 -7
- package/package.json +1 -1
- package/tui/sesh-panel.tsx +235 -16
package/AGENTS.md
CHANGED
|
@@ -17,6 +17,8 @@ territory: read this first, then the specific file you need.
|
|
|
17
17
|
db` store (the native `opencode session list` is project-scoped).
|
|
18
18
|
|
|
19
19
|
No iTerm2, no AppleScript, no panes, no window management — **never add any.**
|
|
20
|
+
The one exception is the TUI's right-click action, which shells out to
|
|
21
|
+
`cmux new-workspace` when running inside cmux.
|
|
20
22
|
opencode has no persistent-sidebar API, so the "sidebar" is a `sidebar_content`
|
|
21
23
|
slot section plus an xlarge modal picker; the terminal UI is fullscreen fzf.
|
|
22
24
|
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.12.0] - 2026-09-30
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Right-clicking a session in the TUI sidebar, home list or picker opens it in a
|
|
15
|
+
new cmux workspace at its directory; outside cmux it shows a toast.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- Avoid creating `.bak` backup files on install
|
|
20
|
+
## [0.11.1] - 2026-09-25
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- Confirming a session delete in the TUI picker no longer leaks `y` or `n` into
|
|
25
|
+
the search input; `Enter` and a second `Ctrl-X` commit the armed delete, and
|
|
26
|
+
session deletion is supported on the home screen.
|
|
10
27
|
## [0.11.0] - 2026-09-25
|
|
11
28
|
|
|
12
29
|
### Changed
|
|
@@ -371,7 +388,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
371
388
|
- Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
|
|
372
389
|
CI.
|
|
373
390
|
|
|
374
|
-
[Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.
|
|
391
|
+
[Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.12.0...HEAD
|
|
392
|
+
[0.12.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.11.1...v0.12.0
|
|
393
|
+
[0.11.1]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.11.0...v0.11.1
|
|
375
394
|
[0.11.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.10.0...v0.11.0
|
|
376
395
|
[0.10.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.9.1...v0.10.0
|
|
377
396
|
[0.9.1]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.9.0...v0.9.1
|
package/README.md
CHANGED
|
@@ -185,6 +185,8 @@ The picker groups sessions under collapsible directory headings (click ▾/▸).
|
|
|
185
185
|
Mouse-wheel scrolling moves through the tree without changing the selection
|
|
186
186
|
under a stationary pointer. Option-P opens a full-size transcript preview over
|
|
187
187
|
the picker; use ↑/↓ to preview other sessions, then Esc to return to the list.
|
|
188
|
+
Right-click a session (sidebar, home list or picker) to open it in a new cmux
|
|
189
|
+
workspace at its directory; outside cmux it shows a toast instead.
|
|
188
190
|
|
|
189
191
|
Click the sidebar's `search…` box to filter recent sessions in place; click
|
|
190
192
|
elsewhere or press Esc to leave search. Click the **Sessions** heading to drive
|
|
@@ -228,7 +230,7 @@ All variables are optional.
|
|
|
228
230
|
|
|
229
231
|
## Integrations
|
|
230
232
|
|
|
231
|
-
sesh is terminal-agnostic
|
|
233
|
+
sesh is terminal-agnostic apart from the TUI's right-click action. The
|
|
232
234
|
[`contrib/cmux/`](contrib/cmux/README.md) directory shows how to drive it from
|
|
233
235
|
cmux — command-palette entries, shortcuts, resuming a session in its own
|
|
234
236
|
workspace, and a global "resume from anywhere" hotkey — all caller-side.
|
package/ROADMAP.md
CHANGED
|
@@ -35,10 +35,9 @@ candidate summary; non-TTY runs require `--yes`; `--dry-run` lists only.
|
|
|
35
35
|
|
|
36
36
|
## 3. sesh ↔ cmux bridge (done)
|
|
37
37
|
|
|
38
|
-
**Approach:**
|
|
39
|
-
repo rule), so [`contrib/cmux/`](contrib/cmux/README.md) ships the cmux
|
|
38
|
+
**Approach:** [`contrib/cmux/`](contrib/cmux/README.md) ships the cmux
|
|
40
39
|
actions, shortcuts and a `--print --json` recipe that resumes a session in its
|
|
41
|
-
own workspace
|
|
40
|
+
own workspace; the TUI's right-click action opens one directly.
|
|
42
41
|
|
|
43
42
|
## 4. Per-project cost digest (done)
|
|
44
43
|
|
package/contrib/cmux/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# sesh ↔ cmux
|
|
2
2
|
|
|
3
3
|
Optional glue for running sesh inside [cmux](https://cmux.dev). Nothing here is
|
|
4
|
-
bundled into the picker:
|
|
5
|
-
|
|
4
|
+
bundled into the picker: these recipes are caller-side. The only place sesh
|
|
5
|
+
calls cmux is the TUI's right-click action, which runs `cmux new-workspace`.
|
|
6
6
|
|
|
7
7
|
## Command palette + shortcuts
|
|
8
8
|
|
package/install.sh
CHANGED
|
@@ -87,7 +87,7 @@ remove_tui_entry() {
|
|
|
87
87
|
local tmp
|
|
88
88
|
tmp=$(mktemp "${TMPDIR:-/tmp}/sesh-tui.XXXXXX") || return 0
|
|
89
89
|
if jq 'if (.plugin | type) == "array" then .plugin = [.plugin[] | select(. != "./plugins/sesh-panel.tsx")] else . end' "$TUI_JSON" > "$tmp" 2>/dev/null; then
|
|
90
|
-
cmp -s "$TUI_JSON" "$tmp" ||
|
|
90
|
+
cmp -s "$TUI_JSON" "$tmp" || mv "$tmp" "$TUI_JSON"
|
|
91
91
|
rm -f "$tmp"
|
|
92
92
|
else
|
|
93
93
|
rm -f "$tmp"
|
|
@@ -120,10 +120,6 @@ install_file() {
|
|
|
120
120
|
note "up to date $dst"
|
|
121
121
|
return 0
|
|
122
122
|
fi
|
|
123
|
-
if [ -f "$dst" ]; then
|
|
124
|
-
cp -p "$dst" "$dst.bak.$(date +%Y%m%d%H%M%S)"
|
|
125
|
-
note "backed up existing $(basename "$dst")"
|
|
126
|
-
fi
|
|
127
123
|
cp -p "$src" "$dst"
|
|
128
124
|
note "installed $dst"
|
|
129
125
|
}
|
|
@@ -157,7 +153,6 @@ install_tui_panel() {
|
|
|
157
153
|
note "tui.json already registers the sesh panel"
|
|
158
154
|
rm -f "$tui_tmp"
|
|
159
155
|
else
|
|
160
|
-
[ "$tui_created" = 1 ] || cp -p "$TUI_JSON" "$TUI_JSON.bak.$(date +%Y%m%d%H%M%S)"
|
|
161
156
|
mv "$tui_tmp" "$TUI_JSON"
|
|
162
157
|
note "registered ./plugins/sesh-panel.tsx in tui.json"
|
|
163
158
|
fi
|
|
@@ -190,7 +185,6 @@ install_tui_panel() {
|
|
|
190
185
|
note "plugin dependencies already present in $PACKAGE_JSON"
|
|
191
186
|
rm -f "$pkg_tmp"
|
|
192
187
|
else
|
|
193
|
-
[ "$pkg_created" = 1 ] || cp -p "$PACKAGE_JSON" "$PACKAGE_JSON.bak.$(date +%Y%m%d%H%M%S)"
|
|
194
188
|
mv "$pkg_tmp" "$PACKAGE_JSON"
|
|
195
189
|
note "declared plugin dependencies in $PACKAGE_JSON (opencode installs them on next start)"
|
|
196
190
|
fi
|
package/package.json
CHANGED
package/tui/sesh-panel.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import type { Project, Session } from "@opencode-ai/sdk/v2"
|
|
|
4
4
|
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
|
|
5
5
|
import { mkdir, readFile, rename, rmdir, unlink, writeFile } from "node:fs/promises"
|
|
6
6
|
import { dirname, join } from "node:path"
|
|
7
|
+
import { spawn } from "node:child_process"
|
|
7
8
|
import { getTreeSitterClient, RGBA, SyntaxStyle, TextAttributes } from "@opentui/core"
|
|
8
9
|
|
|
9
10
|
type ThemeColors = TuiPluginApi["theme"]["current"]
|
|
@@ -250,6 +251,22 @@ type Entry = { id: string; title: string; dir: string; group: string; updated: n
|
|
|
250
251
|
type EntryResult = { entries: Entry[]; truncated: boolean }
|
|
251
252
|
type SidebarMarker = "current" | "running" | "idle"
|
|
252
253
|
|
|
254
|
+
const RIGHT_BUTTON = 2
|
|
255
|
+
|
|
256
|
+
function openInCmuxWorkspace(api: TuiPluginApi, entry: Entry) {
|
|
257
|
+
if (!process.env.CMUX_WORKSPACE_ID) {
|
|
258
|
+
api.ui.toast({ message: "Right-click opens a new workspace only inside cmux", variant: "info" })
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
const args = ["new-workspace", "--name", entry.title || entry.id, "--command", `opencode --session ${entry.id}`, "--focus", "true"]
|
|
262
|
+
if (entry.dir) args.push("--cwd", entry.dir)
|
|
263
|
+
const child = spawn("cmux", args, { stdio: "ignore" })
|
|
264
|
+
child.on("error", () => api.ui.toast({ message: "Could not open a cmux workspace", variant: "error" }))
|
|
265
|
+
child.on("exit", (code) => {
|
|
266
|
+
if (code) api.ui.toast({ message: "Could not open a cmux workspace", variant: "error" })
|
|
267
|
+
})
|
|
268
|
+
}
|
|
269
|
+
|
|
253
270
|
// Loading-spinner frames for the marker of a session whose agent is working.
|
|
254
271
|
const SIDEBAR_SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
255
272
|
|
|
@@ -1013,8 +1030,47 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1013
1030
|
let disposeHoverSpace: (() => void) | undefined
|
|
1014
1031
|
let disposeSearch: (() => void) | undefined
|
|
1015
1032
|
let disposeNav: (() => void) | undefined
|
|
1033
|
+
let disposeConfirm: (() => void) | undefined
|
|
1016
1034
|
let insideSearchBox = false
|
|
1017
1035
|
|
|
1036
|
+
createEffect(() => {
|
|
1037
|
+
disposeConfirm?.()
|
|
1038
|
+
disposeConfirm = undefined
|
|
1039
|
+
if (!pendingDelete()) return
|
|
1040
|
+
disposeConfirm = props.api.keymap.registerLayer({
|
|
1041
|
+
mode: "base",
|
|
1042
|
+
priority: 25,
|
|
1043
|
+
bindings: [
|
|
1044
|
+
{
|
|
1045
|
+
key: "y",
|
|
1046
|
+
desc: "Confirm delete",
|
|
1047
|
+
preventDefault: true,
|
|
1048
|
+
cmd: () => {
|
|
1049
|
+
confirmArmed()
|
|
1050
|
+
},
|
|
1051
|
+
},
|
|
1052
|
+
{
|
|
1053
|
+
key: "ctrl+x",
|
|
1054
|
+
desc: "Confirm delete",
|
|
1055
|
+
preventDefault: true,
|
|
1056
|
+
cmd: () => {
|
|
1057
|
+
confirmArmed()
|
|
1058
|
+
},
|
|
1059
|
+
},
|
|
1060
|
+
{
|
|
1061
|
+
key: "enter",
|
|
1062
|
+
desc: "Confirm delete",
|
|
1063
|
+
preventDefault: true,
|
|
1064
|
+
cmd: () => {
|
|
1065
|
+
confirmArmed()
|
|
1066
|
+
},
|
|
1067
|
+
},
|
|
1068
|
+
{ key: "n", desc: "Cancel delete", preventDefault: true, cmd: cancelDelete },
|
|
1069
|
+
{ key: "escape", desc: "Cancel delete", preventDefault: true, cmd: cancelDelete },
|
|
1070
|
+
],
|
|
1071
|
+
})
|
|
1072
|
+
})
|
|
1073
|
+
|
|
1018
1074
|
const onRootMouseDown = () => {
|
|
1019
1075
|
if (insideSearchBox) {
|
|
1020
1076
|
insideSearchBox = false
|
|
@@ -1144,6 +1200,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1144
1200
|
desc: "Open session",
|
|
1145
1201
|
preventDefault: true,
|
|
1146
1202
|
cmd: () => {
|
|
1203
|
+
if (confirmArmed()) return
|
|
1147
1204
|
const entry = itemRows()[cursor()]
|
|
1148
1205
|
if (entry) void openSession(entry)
|
|
1149
1206
|
},
|
|
@@ -1178,6 +1235,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1178
1235
|
disposeHoverSpace?.()
|
|
1179
1236
|
disposeSearch?.()
|
|
1180
1237
|
disposeNav?.()
|
|
1238
|
+
disposeConfirm?.()
|
|
1181
1239
|
if (confirmTimer) clearTimeout(confirmTimer)
|
|
1182
1240
|
})
|
|
1183
1241
|
|
|
@@ -1278,7 +1336,9 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1278
1336
|
}
|
|
1279
1337
|
}}
|
|
1280
1338
|
onMouseOut={() => setHovered(undefined)}
|
|
1281
|
-
onMouseDown={(
|
|
1339
|
+
onMouseDown={(event: { button?: number }) =>
|
|
1340
|
+
event.button === RIGHT_BUTTON ? openInCmuxWorkspace(props.api, row.entry) : void openSession(row.entry)
|
|
1341
|
+
}
|
|
1282
1342
|
>
|
|
1283
1343
|
<text flexShrink={0}>
|
|
1284
1344
|
<span style={{ fg: theme().textMuted }}>{row.last ? "└" : "├"}</span>
|
|
@@ -1337,11 +1397,89 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1337
1397
|
const [hovered, setHovered] = createSignal<string>()
|
|
1338
1398
|
const [searching, setSearching] = createSignal(false)
|
|
1339
1399
|
const [loadFailed, setLoadFailed] = createSignal(false)
|
|
1400
|
+
const [pendingDelete, setPendingDelete] = createSignal<string>()
|
|
1401
|
+
const [deleting, setDeleting] = createSignal<string>()
|
|
1402
|
+
let confirmTimer: ReturnType<typeof setTimeout> | undefined
|
|
1403
|
+
let disposeConfirm: (() => void) | undefined
|
|
1340
1404
|
const preview = createTranscriptPreview(props.api)
|
|
1341
1405
|
let disposeHoverSpace: (() => void) | undefined
|
|
1342
1406
|
let disposeSearch: (() => void) | undefined
|
|
1343
1407
|
let insideSearchBox = false
|
|
1344
1408
|
|
|
1409
|
+
const cancelDelete = () => {
|
|
1410
|
+
if (confirmTimer) clearTimeout(confirmTimer)
|
|
1411
|
+
confirmTimer = undefined
|
|
1412
|
+
disposeConfirm?.()
|
|
1413
|
+
disposeConfirm = undefined
|
|
1414
|
+
setPendingDelete(undefined)
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
const confirmArmed = (): boolean => {
|
|
1418
|
+
const id = pendingDelete()
|
|
1419
|
+
if (!id) return false
|
|
1420
|
+
const entry = entries().find((e) => e.id === id)
|
|
1421
|
+
if (!entry) cancelDelete()
|
|
1422
|
+
else void deleteEntry(entry)
|
|
1423
|
+
return true
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
const requestDelete = (entry: Entry) => {
|
|
1427
|
+
if (deleting()) return
|
|
1428
|
+
if (pendingDelete() === entry.id) {
|
|
1429
|
+
void deleteEntry(entry)
|
|
1430
|
+
return
|
|
1431
|
+
}
|
|
1432
|
+
setPendingDelete(entry.id)
|
|
1433
|
+
if (confirmTimer) clearTimeout(confirmTimer)
|
|
1434
|
+
confirmTimer = setTimeout(() => setPendingDelete(undefined), 5000)
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
const deleteEntry = async (entry: Entry) => {
|
|
1438
|
+
cancelDelete()
|
|
1439
|
+
if (deleting()) return
|
|
1440
|
+
setDeleting(entry.id)
|
|
1441
|
+
try {
|
|
1442
|
+
const result = await props.api.client.session.delete({
|
|
1443
|
+
sessionID: entry.id,
|
|
1444
|
+
directory: entry.dir || undefined,
|
|
1445
|
+
})
|
|
1446
|
+
if (result && "error" in result && result.error) throw result.error
|
|
1447
|
+
const { entries: remaining } = await fetchEntries(props.api)
|
|
1448
|
+
if (remaining.some((item) => item.id === entry.id)) {
|
|
1449
|
+
setEntries(remaining)
|
|
1450
|
+
props.api.ui.toast({ message: "Session was not deleted", variant: "error" })
|
|
1451
|
+
return
|
|
1452
|
+
}
|
|
1453
|
+
setEntries(remaining)
|
|
1454
|
+
setHovered(undefined)
|
|
1455
|
+
props.api.ui.toast({ message: `Deleted "${truncate(entry.title, 40)}"`, variant: "info" })
|
|
1456
|
+
if ("params" in props.api.route.current && (props.api.route.current.params as any)?.sessionID === entry.id) {
|
|
1457
|
+
props.api.route.navigate("home")
|
|
1458
|
+
}
|
|
1459
|
+
} catch {
|
|
1460
|
+
props.api.ui.toast({ message: "Could not delete session", variant: "error" })
|
|
1461
|
+
} finally {
|
|
1462
|
+
setDeleting(undefined)
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
createEffect(() => {
|
|
1467
|
+
disposeConfirm?.()
|
|
1468
|
+
disposeConfirm = undefined
|
|
1469
|
+
if (!pendingDelete()) return
|
|
1470
|
+
disposeConfirm = props.api.keymap.registerLayer({
|
|
1471
|
+
mode: "base",
|
|
1472
|
+
priority: 25,
|
|
1473
|
+
bindings: [
|
|
1474
|
+
{ key: "y", desc: "Confirm delete", preventDefault: true, cmd: () => { confirmArmed() } },
|
|
1475
|
+
{ key: "ctrl+x", desc: "Confirm delete", preventDefault: true, cmd: () => { confirmArmed() } },
|
|
1476
|
+
{ key: "enter", desc: "Confirm delete", preventDefault: true, cmd: () => { confirmArmed() } },
|
|
1477
|
+
{ key: "n", desc: "Cancel delete", preventDefault: true, cmd: cancelDelete },
|
|
1478
|
+
{ key: "escape", desc: "Cancel delete", preventDefault: true, cmd: cancelDelete },
|
|
1479
|
+
],
|
|
1480
|
+
})
|
|
1481
|
+
})
|
|
1482
|
+
|
|
1345
1483
|
const onRootMouseDown = () => {
|
|
1346
1484
|
if (insideSearchBox) {
|
|
1347
1485
|
insideSearchBox = false
|
|
@@ -1389,14 +1527,26 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1389
1527
|
preventDefault: true,
|
|
1390
1528
|
cmd: () => preview.open(entry),
|
|
1391
1529
|
},
|
|
1530
|
+
{
|
|
1531
|
+
key: "ctrl+x",
|
|
1532
|
+
desc: "Delete session",
|
|
1533
|
+
preventDefault: true,
|
|
1534
|
+
cmd: () => {
|
|
1535
|
+
if (!confirmArmed()) requestDelete(entry)
|
|
1536
|
+
},
|
|
1537
|
+
},
|
|
1392
1538
|
{ key: "ctrl+s", desc: "Pin session", preventDefault: true, cmd: () => props.onTogglePin("sessions", entry.id) },
|
|
1393
1539
|
{ key: "ctrl+d", desc: "Pin directory", preventDefault: true, cmd: () => props.onTogglePin("directories", entry.dir) },
|
|
1394
1540
|
],
|
|
1395
1541
|
})
|
|
1396
1542
|
})
|
|
1397
1543
|
|
|
1398
|
-
onCleanup(() =>
|
|
1399
|
-
|
|
1544
|
+
onCleanup(() => {
|
|
1545
|
+
disposeSearch?.()
|
|
1546
|
+
disposeHoverSpace?.()
|
|
1547
|
+
disposeConfirm?.()
|
|
1548
|
+
if (confirmTimer) clearTimeout(confirmTimer)
|
|
1549
|
+
})
|
|
1400
1550
|
|
|
1401
1551
|
onMount(() => {
|
|
1402
1552
|
let alive = true
|
|
@@ -1486,7 +1636,11 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1486
1636
|
backgroundColor={entry.id === hovered() ? theme().backgroundElement : RGBA.fromInts(0, 0, 0, 0)}
|
|
1487
1637
|
onMouseOver={() => setHovered(entry.id)}
|
|
1488
1638
|
onMouseOut={() => setHovered(undefined)}
|
|
1489
|
-
onMouseDown={(
|
|
1639
|
+
onMouseDown={(event: { button?: number }) =>
|
|
1640
|
+
event.button === RIGHT_BUTTON
|
|
1641
|
+
? openInCmuxWorkspace(props.api, entry)
|
|
1642
|
+
: props.api.route.navigate("session", { sessionID: entry.id })
|
|
1643
|
+
}
|
|
1490
1644
|
>
|
|
1491
1645
|
<text flexShrink={0} style={{ fg: theme().textMuted }}>
|
|
1492
1646
|
○
|
|
@@ -1497,8 +1651,10 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1497
1651
|
color={theme().text}
|
|
1498
1652
|
matchColor={theme().warning}
|
|
1499
1653
|
/>
|
|
1500
|
-
<text flexShrink={0} style={{ fg: theme().textMuted }}>
|
|
1501
|
-
{
|
|
1654
|
+
<text flexShrink={0} style={{ fg: pendingDelete() === entry.id ? theme().error : theme().textMuted }}>
|
|
1655
|
+
{pendingDelete() === entry.id
|
|
1656
|
+
? "ctrl+x again"
|
|
1657
|
+
: `${props.pins().directories.includes(entry.dir) ? "★ " : ""}${prettyDir(entry.dir, home)} · ${ago(entry.updated)}`}
|
|
1502
1658
|
</text>
|
|
1503
1659
|
</box>
|
|
1504
1660
|
)}
|
|
@@ -1789,12 +1945,22 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1789
1945
|
// Delete confirmation lives in its own layer, registered only while a row
|
|
1790
1946
|
// is armed, so y/n never shadow the search box's typing.
|
|
1791
1947
|
let disposeConfirm: (() => void) | undefined
|
|
1948
|
+
let confirmTimer: ReturnType<typeof setTimeout> | undefined
|
|
1792
1949
|
const cancelDelete = () => {
|
|
1950
|
+
if (confirmTimer) clearTimeout(confirmTimer)
|
|
1951
|
+
confirmTimer = undefined
|
|
1793
1952
|
disposeConfirm?.()
|
|
1794
1953
|
disposeConfirm = undefined
|
|
1795
1954
|
setPendingDelete(undefined)
|
|
1796
1955
|
}
|
|
1797
1956
|
|
|
1957
|
+
const confirmArmed = (): boolean => {
|
|
1958
|
+
const armed = pendingDelete()
|
|
1959
|
+
if (!armed) return false
|
|
1960
|
+
void deleteEntry(armed)
|
|
1961
|
+
return true
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1798
1964
|
// Escape first closes the transcript overlay, then the picker. One Escape
|
|
1799
1965
|
// can reach both the focused search input and the key layer (sometimes in
|
|
1800
1966
|
// separate ticks), so ignore the duplicate that trails right behind.
|
|
@@ -1807,6 +1973,11 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1807
1973
|
escapeHandledAt = now + 150
|
|
1808
1974
|
return
|
|
1809
1975
|
}
|
|
1976
|
+
if (pendingDelete()) {
|
|
1977
|
+
cancelDelete()
|
|
1978
|
+
escapeHandledAt = now + 150
|
|
1979
|
+
return
|
|
1980
|
+
}
|
|
1810
1981
|
cancelDelete()
|
|
1811
1982
|
close()
|
|
1812
1983
|
}
|
|
@@ -1827,6 +1998,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1827
1998
|
}
|
|
1828
1999
|
setAllEntries(remaining)
|
|
1829
2000
|
api.ui.toast({ message: `Deleted "${truncate(entry.title, 40)}"`, variant: "info" })
|
|
2001
|
+
if (currentSessionID() === entry.id) api.route.navigate("home")
|
|
1830
2002
|
} catch {
|
|
1831
2003
|
api.ui.toast({ message: "Could not delete session", variant: "error" })
|
|
1832
2004
|
}
|
|
@@ -1834,19 +2006,38 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1834
2006
|
|
|
1835
2007
|
const armDelete = (entry: Entry) => {
|
|
1836
2008
|
setPendingDelete(entry)
|
|
2009
|
+
if (confirmTimer) clearTimeout(confirmTimer)
|
|
2010
|
+
confirmTimer = setTimeout(cancelDelete, 5000)
|
|
1837
2011
|
disposeConfirm?.()
|
|
1838
2012
|
disposeConfirm = api.keymap.registerLayer({
|
|
2013
|
+
priority: 20,
|
|
1839
2014
|
bindings: [
|
|
1840
2015
|
{
|
|
1841
2016
|
key: "y",
|
|
1842
2017
|
desc: "Confirm delete",
|
|
1843
2018
|
preventDefault: true,
|
|
1844
2019
|
cmd: () => {
|
|
1845
|
-
|
|
1846
|
-
|
|
2020
|
+
confirmArmed()
|
|
2021
|
+
},
|
|
2022
|
+
},
|
|
2023
|
+
{
|
|
2024
|
+
key: "ctrl+x",
|
|
2025
|
+
desc: "Confirm delete",
|
|
2026
|
+
preventDefault: true,
|
|
2027
|
+
cmd: () => {
|
|
2028
|
+
confirmArmed()
|
|
2029
|
+
},
|
|
2030
|
+
},
|
|
2031
|
+
{
|
|
2032
|
+
key: "enter",
|
|
2033
|
+
desc: "Confirm delete",
|
|
2034
|
+
preventDefault: true,
|
|
2035
|
+
cmd: () => {
|
|
2036
|
+
confirmArmed()
|
|
1847
2037
|
},
|
|
1848
2038
|
},
|
|
1849
2039
|
{ key: "n", desc: "Cancel delete", preventDefault: true, cmd: cancelDelete },
|
|
2040
|
+
{ key: "escape", desc: "Cancel delete", preventDefault: true, cmd: cancelDelete },
|
|
1850
2041
|
],
|
|
1851
2042
|
})
|
|
1852
2043
|
}
|
|
@@ -1892,7 +2083,15 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1892
2083
|
preventDefault: true,
|
|
1893
2084
|
cmd: () => selectCursor(selectableEntries().length - 1),
|
|
1894
2085
|
},
|
|
1895
|
-
{
|
|
2086
|
+
{
|
|
2087
|
+
key: "enter",
|
|
2088
|
+
desc: "Open session",
|
|
2089
|
+
preventDefault: true,
|
|
2090
|
+
cmd: () => {
|
|
2091
|
+
if (confirmArmed()) return
|
|
2092
|
+
choose()
|
|
2093
|
+
},
|
|
2094
|
+
},
|
|
1896
2095
|
{ key: "option+p", desc: "Toggle transcript preview", preventDefault: true, cmd: togglePreview },
|
|
1897
2096
|
{ key: "ctrl+s", desc: "Pin session", preventDefault: true, cmd: () => {
|
|
1898
2097
|
const entry = selectableEntries()[cursor()]
|
|
@@ -1907,11 +2106,9 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1907
2106
|
desc: "Delete session",
|
|
1908
2107
|
preventDefault: true,
|
|
1909
2108
|
cmd: () => {
|
|
2109
|
+
if (confirmArmed()) return
|
|
1910
2110
|
const target = selectableEntries()[cursor()]
|
|
1911
|
-
if (
|
|
1912
|
-
const armed = pendingDelete()
|
|
1913
|
-
if (armed && armed.id === target.id) void deleteEntry(target)
|
|
1914
|
-
else armDelete(target)
|
|
2111
|
+
if (target) armDelete(target)
|
|
1915
2112
|
},
|
|
1916
2113
|
},
|
|
1917
2114
|
{
|
|
@@ -1946,6 +2143,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1946
2143
|
|
|
1947
2144
|
const cleanup = () => {
|
|
1948
2145
|
alive = false
|
|
2146
|
+
if (confirmTimer) clearTimeout(confirmTimer)
|
|
1949
2147
|
pickerState = { query: query(), scope: scope(), waitingOnly: waitingOnly(), pinnedOnly: pinnedOnly() }
|
|
1950
2148
|
disposeNav()
|
|
1951
2149
|
disposeConfirm?.()
|
|
@@ -1981,12 +2179,31 @@ const tui: TuiPlugin = async (api) => {
|
|
|
1981
2179
|
focusedBackgroundColor={api.theme.current.backgroundPanel}
|
|
1982
2180
|
focusedTextColor={api.theme.current.text}
|
|
1983
2181
|
cursorColor={api.theme.current.primary}
|
|
1984
|
-
onInput={
|
|
1985
|
-
|
|
2182
|
+
onInput={(value) => {
|
|
2183
|
+
if (pendingDelete()) return
|
|
2184
|
+
setQuery(value)
|
|
2185
|
+
}}
|
|
2186
|
+
onSubmit={() => {
|
|
2187
|
+
if (confirmArmed()) return
|
|
2188
|
+
choose()
|
|
2189
|
+
}}
|
|
1986
2190
|
onKeyDown={(event) => {
|
|
1987
2191
|
if (event.name === "escape") {
|
|
1988
2192
|
event.preventDefault()
|
|
1989
2193
|
handleEscape()
|
|
2194
|
+
return
|
|
2195
|
+
}
|
|
2196
|
+
if (pendingDelete()) {
|
|
2197
|
+
if (event.name === "y" || (event.ctrl && event.name === "x") || event.name === "enter" || event.name === "return") {
|
|
2198
|
+
event.preventDefault()
|
|
2199
|
+
confirmArmed()
|
|
2200
|
+
return
|
|
2201
|
+
}
|
|
2202
|
+
if (event.name === "n") {
|
|
2203
|
+
event.preventDefault()
|
|
2204
|
+
cancelDelete()
|
|
2205
|
+
return
|
|
2206
|
+
}
|
|
1990
2207
|
}
|
|
1991
2208
|
}}
|
|
1992
2209
|
/>
|
|
@@ -2048,7 +2265,9 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2048
2265
|
const index = selectableEntries().findIndex((entry) => entry.id === row.entry.id)
|
|
2049
2266
|
if (index >= 0) setCursor(index)
|
|
2050
2267
|
}}
|
|
2051
|
-
onMouseDown={() =>
|
|
2268
|
+
onMouseDown={(event: { button?: number }) =>
|
|
2269
|
+
event.button === RIGHT_BUTTON ? (cleanup(), api.ui.dialog.clear(), openInCmuxWorkspace(api, row.entry)) : choose(row.entry)
|
|
2270
|
+
}
|
|
2052
2271
|
>
|
|
2053
2272
|
<box flexDirection="row" gap={1}>
|
|
2054
2273
|
<text flexShrink={0} style={{ fg: row.entry.id === selectableEntries()[cursor()]?.id
|