opencode-sesh 0.11.1 → 0.13.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 +22 -1
- package/README.md +7 -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 +290 -5
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,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.13.0] - 2026-09-30
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Right-clicking a session in the TUI sidebar opens a floating menu at the click
|
|
15
|
+
with Open, Preview transcript, Open in new cmux workspace (inside cmux), Fork,
|
|
16
|
+
Pin/Unpin and Delete (with confirmation). It stays inside the terminal, moves
|
|
17
|
+
with ↑/↓/Enter, and closes on Esc or a click elsewhere. The home list and
|
|
18
|
+
picker keep the direct cmux action.
|
|
19
|
+
## [0.12.0] - 2026-09-30
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- Right-clicking a session in the TUI sidebar, home list or picker opens it in a
|
|
24
|
+
new cmux workspace at its directory; outside cmux it shows a toast.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- Avoid creating `.bak` backup files on install
|
|
10
29
|
## [0.11.1] - 2026-09-25
|
|
11
30
|
|
|
12
31
|
### Fixed
|
|
@@ -378,7 +397,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
378
397
|
- Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
|
|
379
398
|
CI.
|
|
380
399
|
|
|
381
|
-
[Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.
|
|
400
|
+
[Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.13.0...HEAD
|
|
401
|
+
[0.13.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.12.0...v0.13.0
|
|
402
|
+
[0.12.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.11.1...v0.12.0
|
|
382
403
|
[0.11.1]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.11.0...v0.11.1
|
|
383
404
|
[0.11.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.10.0...v0.11.0
|
|
384
405
|
[0.10.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.9.1...v0.10.0
|
package/README.md
CHANGED
|
@@ -185,6 +185,12 @@ 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 sidebar session to open its action menu: open, preview, fork,
|
|
189
|
+
pin/unpin, or delete with confirmation. Inside cmux, the menu also offers opening
|
|
190
|
+
the session in a new workspace at its directory. Use ↑/↓ and Enter to choose an
|
|
191
|
+
action, or Esc to dismiss the menu.
|
|
192
|
+
Right-click a session in the home list or picker to open it in a new cmux
|
|
193
|
+
workspace at its directory; outside cmux it shows a toast instead.
|
|
188
194
|
|
|
189
195
|
Click the sidebar's `search…` box to filter recent sessions in place; click
|
|
190
196
|
elsewhere or press Esc to leave search. Click the **Sessions** heading to drive
|
|
@@ -228,7 +234,7 @@ All variables are optional.
|
|
|
228
234
|
|
|
229
235
|
## Integrations
|
|
230
236
|
|
|
231
|
-
sesh is terminal-agnostic
|
|
237
|
+
sesh is terminal-agnostic apart from the TUI's right-click action. The
|
|
232
238
|
[`contrib/cmux/`](contrib/cmux/README.md) directory shows how to drive it from
|
|
233
239
|
cmux — command-palette entries, shortcuts, resuming a session in its own
|
|
234
240
|
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
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
2
|
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
|
3
3
|
import type { Project, Session } from "@opencode-ai/sdk/v2"
|
|
4
|
-
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
|
|
4
|
+
import { createEffect, createMemo, createSignal, For, on, 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"
|
|
9
|
+
import { Portal } from "@opentui/solid"
|
|
8
10
|
|
|
9
11
|
type ThemeColors = TuiPluginApi["theme"]["current"]
|
|
10
12
|
|
|
@@ -250,6 +252,145 @@ type Entry = { id: string; title: string; dir: string; group: string; updated: n
|
|
|
250
252
|
type EntryResult = { entries: Entry[]; truncated: boolean }
|
|
251
253
|
type SidebarMarker = "current" | "running" | "idle"
|
|
252
254
|
|
|
255
|
+
const LEFT_BUTTON = 0
|
|
256
|
+
const RIGHT_BUTTON = 2
|
|
257
|
+
|
|
258
|
+
function openInCmuxWorkspace(api: TuiPluginApi, entry: Entry) {
|
|
259
|
+
if (!process.env.CMUX_WORKSPACE_ID) {
|
|
260
|
+
api.ui.toast({ message: "Right-click opens a new workspace only inside cmux", variant: "info" })
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
const args = ["new-workspace", "--name", entry.title || entry.id, "--command", `opencode --session ${entry.id}`, "--focus", "true"]
|
|
264
|
+
if (entry.dir) args.push("--cwd", entry.dir)
|
|
265
|
+
const child = spawn("cmux", args, { stdio: "ignore" })
|
|
266
|
+
child.on("error", () => api.ui.toast({ message: "Could not open a cmux workspace", variant: "error" }))
|
|
267
|
+
child.on("exit", (code) => {
|
|
268
|
+
if (code) api.ui.toast({ message: "Could not open a cmux workspace", variant: "error" })
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
type MenuAction = "open" | "preview" | "workspace" | "fork" | "pin" | "delete" | "confirm" | "cancel"
|
|
273
|
+
type MenuItem = { id: MenuAction; label: string }
|
|
274
|
+
type MenuState = { entry: Entry; x: number; y: number; items: MenuItem[]; index: number; confirming: boolean }
|
|
275
|
+
type MenuLayer = {
|
|
276
|
+
mode: "base"
|
|
277
|
+
priority: number
|
|
278
|
+
bindings: { key: string; desc: string; preventDefault: boolean; cmd: () => void }[]
|
|
279
|
+
}
|
|
280
|
+
type ContextMenuHost = {
|
|
281
|
+
registerLayer: (layer: MenuLayer) => () => void
|
|
282
|
+
actions: Record<"open" | "preview" | "workspace" | "fork" | "pin" | "delete", (entry: Entry) => void>
|
|
283
|
+
pinned: (entry: Entry) => boolean
|
|
284
|
+
cmux: () => boolean
|
|
285
|
+
blocked: () => boolean
|
|
286
|
+
onChange: (state: MenuState | undefined) => void
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const CONTEXT_MENU_LAYER_PRIORITY = 30
|
|
290
|
+
|
|
291
|
+
function contextMenuItems(pinned: boolean, cmux: boolean): MenuItem[] {
|
|
292
|
+
return [
|
|
293
|
+
{ id: "open", label: "Open" },
|
|
294
|
+
{ id: "preview", label: "Preview transcript" },
|
|
295
|
+
...(cmux ? [{ id: "workspace" as const, label: "Open in new cmux workspace" }] : []),
|
|
296
|
+
{ id: "fork", label: "Fork" },
|
|
297
|
+
{ id: "pin", label: pinned ? "Unpin" : "Pin" },
|
|
298
|
+
{ id: "delete", label: "Delete" },
|
|
299
|
+
]
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function contextMenuSize(items: MenuItem[]): { width: number; height: number } {
|
|
303
|
+
return { width: Math.max(...items.map((item) => item.label.length)) + 4, height: items.length + 2 }
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function contextMenuBox(
|
|
307
|
+
anchor: { x: number; y: number; width: number; height: number },
|
|
308
|
+
cols: number,
|
|
309
|
+
rows: number,
|
|
310
|
+
): { left: number; top: number; width: number; height: number } {
|
|
311
|
+
const width = Math.max(1, Math.min(anchor.width, cols))
|
|
312
|
+
const height = Math.max(1, Math.min(anchor.height, rows))
|
|
313
|
+
const left = Math.max(0, Math.min(anchor.x, cols - width))
|
|
314
|
+
const below = anchor.y + 1
|
|
315
|
+
const top = below + height <= rows ? below : Math.max(0, anchor.y - height)
|
|
316
|
+
return { left, top, width, height }
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function createContextMenu(host: ContextMenuHost) {
|
|
320
|
+
let state: MenuState | undefined
|
|
321
|
+
let dispose: (() => void) | undefined
|
|
322
|
+
|
|
323
|
+
const publish = (next: MenuState | undefined) => {
|
|
324
|
+
state = next
|
|
325
|
+
host.onChange(next)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const close = () => {
|
|
329
|
+
dispose?.()
|
|
330
|
+
dispose = undefined
|
|
331
|
+
if (state) publish(undefined)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const select = (index: number) => {
|
|
335
|
+
if (state) publish({ ...state, index: Math.max(0, Math.min(state.items.length - 1, index)) })
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const move = (delta: number) => {
|
|
339
|
+
if (!state) return
|
|
340
|
+
const count = state.items.length
|
|
341
|
+
publish({ ...state, index: (state.index + delta + count) % count })
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const activate = (index?: number) => {
|
|
345
|
+
if (!state) return
|
|
346
|
+
const current = state
|
|
347
|
+
const item = current.items[index ?? current.index]
|
|
348
|
+
if (!item) return
|
|
349
|
+
if (item.id === "delete") {
|
|
350
|
+
publish({
|
|
351
|
+
...current,
|
|
352
|
+
confirming: true,
|
|
353
|
+
items: [
|
|
354
|
+
{ id: "confirm", label: "Confirm delete" },
|
|
355
|
+
{ id: "cancel", label: "Cancel" },
|
|
356
|
+
],
|
|
357
|
+
index: 1,
|
|
358
|
+
})
|
|
359
|
+
return
|
|
360
|
+
}
|
|
361
|
+
close()
|
|
362
|
+
if (item.id === "confirm") host.actions.delete(current.entry)
|
|
363
|
+
else if (item.id !== "cancel") host.actions[item.id](current.entry)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const click = (index: number, button?: number) => {
|
|
367
|
+
if ((button ?? LEFT_BUTTON) === LEFT_BUTTON) activate(index)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const guarded = (run: () => void) => () => {
|
|
371
|
+
if (host.blocked()) close()
|
|
372
|
+
else run()
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const open = (entry: Entry, x: number, y: number) => {
|
|
376
|
+
if (host.blocked()) return
|
|
377
|
+
publish({ entry, x, y, items: contextMenuItems(host.pinned(entry), host.cmux()), index: 0, confirming: false })
|
|
378
|
+
if (dispose) return
|
|
379
|
+
dispose = host.registerLayer({
|
|
380
|
+
mode: "base",
|
|
381
|
+
priority: CONTEXT_MENU_LAYER_PRIORITY,
|
|
382
|
+
bindings: [
|
|
383
|
+
{ key: "up", desc: "Previous menu item", preventDefault: true, cmd: guarded(() => move(-1)) },
|
|
384
|
+
{ key: "down", desc: "Next menu item", preventDefault: true, cmd: guarded(() => move(1)) },
|
|
385
|
+
{ key: "enter", desc: "Choose menu item", preventDefault: true, cmd: guarded(() => activate()) },
|
|
386
|
+
{ key: "escape", desc: "Close menu", preventDefault: true, cmd: close },
|
|
387
|
+
],
|
|
388
|
+
})
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return { open, close, select, activate, click, current: () => state }
|
|
392
|
+
}
|
|
393
|
+
|
|
253
394
|
// Loading-spinner frames for the marker of a session whose agent is working.
|
|
254
395
|
const SIDEBAR_SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
255
396
|
|
|
@@ -767,6 +908,14 @@ function pickerLastStart(rows: PickerRow[], height: number, excerpt: (entry: Ent
|
|
|
767
908
|
return start
|
|
768
909
|
}
|
|
769
910
|
|
|
911
|
+
type PortalContainer = {
|
|
912
|
+
position?: string
|
|
913
|
+
left?: number
|
|
914
|
+
top?: number
|
|
915
|
+
zIndex?: number
|
|
916
|
+
destroyRecursively?: () => void
|
|
917
|
+
}
|
|
918
|
+
|
|
770
919
|
type PinProps = { pins: () => Pins; onTogglePin: (kind: keyof Pins, value: string) => void; refreshPins: () => void }
|
|
771
920
|
|
|
772
921
|
function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
@@ -785,6 +934,8 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
785
934
|
const [cursor, setCursor] = createSignal(0)
|
|
786
935
|
const [pendingDelete, setPendingDelete] = createSignal<string>()
|
|
787
936
|
const [waiting, setWaiting] = createSignal<Map<string, string>>(new Map())
|
|
937
|
+
const [menuState, setMenuState] = createSignal<MenuState>()
|
|
938
|
+
let portalContainer: PortalContainer | undefined
|
|
788
939
|
let confirmTimer: ReturnType<typeof setTimeout> | undefined
|
|
789
940
|
const currentID = createMemo(() => {
|
|
790
941
|
const route = props.api.route.current
|
|
@@ -890,6 +1041,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
890
1041
|
}
|
|
891
1042
|
|
|
892
1043
|
const toggleSection = () => {
|
|
1044
|
+
menu.close()
|
|
893
1045
|
if (!sectionCollapsed()) {
|
|
894
1046
|
cancelDelete()
|
|
895
1047
|
setNavActive(false)
|
|
@@ -901,6 +1053,47 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
901
1053
|
|
|
902
1054
|
const openSession = (entry: Entry) => props.api.route.navigate("session", { sessionID: entry.id })
|
|
903
1055
|
|
|
1056
|
+
const forkEntry = async (entry: Entry) => {
|
|
1057
|
+
try {
|
|
1058
|
+
const result = await props.api.client.session.fork({
|
|
1059
|
+
sessionID: entry.id,
|
|
1060
|
+
directory: entry.dir || undefined,
|
|
1061
|
+
})
|
|
1062
|
+
const created = (result?.data ?? result) as Session | undefined
|
|
1063
|
+
if (!created?.id) throw new Error("fork returned no session")
|
|
1064
|
+
props.api.route.navigate("session", { sessionID: created.id })
|
|
1065
|
+
props.api.ui.toast({ message: `Forked "${truncate(entry.title, 40)}"`, variant: "info" })
|
|
1066
|
+
} catch {
|
|
1067
|
+
props.api.ui.toast({ message: "Could not fork session", variant: "error" })
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
const menu = createContextMenu({
|
|
1072
|
+
registerLayer: (layer) => props.api.keymap.registerLayer(layer),
|
|
1073
|
+
actions: {
|
|
1074
|
+
open: (entry) => void openSession(entry),
|
|
1075
|
+
preview: (entry) => preview.open(entry),
|
|
1076
|
+
workspace: (entry) => openInCmuxWorkspace(props.api, entry),
|
|
1077
|
+
fork: (entry) => void forkEntry(entry),
|
|
1078
|
+
pin: (entry) => props.onTogglePin("sessions", entry.id),
|
|
1079
|
+
delete: (entry) => void deleteEntry(entry),
|
|
1080
|
+
},
|
|
1081
|
+
pinned: (entry) => props.pins().sessions.includes(entry.id),
|
|
1082
|
+
cmux: () => Boolean(process.env.CMUX_WORKSPACE_ID),
|
|
1083
|
+
blocked: () => props.api.ui.dialog.open,
|
|
1084
|
+
onChange: setMenuState,
|
|
1085
|
+
})
|
|
1086
|
+
|
|
1087
|
+
const menuBox = createMemo(() => {
|
|
1088
|
+
const state = menuState()
|
|
1089
|
+
if (!state) return undefined
|
|
1090
|
+
return contextMenuBox(
|
|
1091
|
+
{ x: state.x, y: state.y, ...contextMenuSize(state.items) },
|
|
1092
|
+
props.api.renderer.width,
|
|
1093
|
+
props.api.renderer.height,
|
|
1094
|
+
)
|
|
1095
|
+
})
|
|
1096
|
+
|
|
904
1097
|
// Flat order of the rows a cursor can land on, so keyboard navigation and
|
|
905
1098
|
// mouse hover resolve to the same row.
|
|
906
1099
|
const itemRows = createMemo(() => tree().flatMap((row) => (row.kind === "item" ? [row.entry] : [])))
|
|
@@ -921,6 +1114,20 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
921
1114
|
previousRows = rows
|
|
922
1115
|
})
|
|
923
1116
|
|
|
1117
|
+
const routeKey = createMemo(() => {
|
|
1118
|
+
const route = props.api.route.current
|
|
1119
|
+
const id = route.name === "session" && typeof route.params?.sessionID === "string" ? route.params.sessionID : ""
|
|
1120
|
+
return `${route.name}:${id}`
|
|
1121
|
+
})
|
|
1122
|
+
createEffect(on(routeKey, () => menu.close(), { defer: true }))
|
|
1123
|
+
createEffect(() => {
|
|
1124
|
+
const state = menuState()
|
|
1125
|
+
if (state && !itemRows().some((entry) => entry.id === state.entry.id)) menu.close()
|
|
1126
|
+
})
|
|
1127
|
+
createEffect(() => {
|
|
1128
|
+
if (props.api.ui.dialog.open) menu.close()
|
|
1129
|
+
})
|
|
1130
|
+
|
|
924
1131
|
// Marker only: a spinner for a working agent, the open-session dot, and the
|
|
925
1132
|
// neutral row for idle sessions. Titles stay plain and no status text is
|
|
926
1133
|
// rendered.
|
|
@@ -1055,6 +1262,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1055
1262
|
})
|
|
1056
1263
|
|
|
1057
1264
|
const onRootMouseDown = () => {
|
|
1265
|
+
menu.close()
|
|
1058
1266
|
if (insideSearchBox) {
|
|
1059
1267
|
insideSearchBox = false
|
|
1060
1268
|
return
|
|
@@ -1065,7 +1273,12 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1065
1273
|
onMount(() => {
|
|
1066
1274
|
ensureRootMouse(props.api.renderer)
|
|
1067
1275
|
rootMouseHandlers.add(onRootMouseDown)
|
|
1068
|
-
|
|
1276
|
+
const closeMenu = () => menu.close()
|
|
1277
|
+
props.api.renderer.on("resize", closeMenu)
|
|
1278
|
+
onCleanup(() => {
|
|
1279
|
+
rootMouseHandlers.delete(onRootMouseDown)
|
|
1280
|
+
props.api.renderer.off("resize", closeMenu)
|
|
1281
|
+
})
|
|
1069
1282
|
})
|
|
1070
1283
|
|
|
1071
1284
|
const appendQuery = (input: string) => {
|
|
@@ -1219,6 +1432,10 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1219
1432
|
disposeSearch?.()
|
|
1220
1433
|
disposeNav?.()
|
|
1221
1434
|
disposeConfirm?.()
|
|
1435
|
+
menu.close()
|
|
1436
|
+
const container = portalContainer
|
|
1437
|
+
portalContainer = undefined
|
|
1438
|
+
process.nextTick(() => container?.destroyRecursively?.())
|
|
1222
1439
|
if (confirmTimer) clearTimeout(confirmTimer)
|
|
1223
1440
|
})
|
|
1224
1441
|
|
|
@@ -1282,6 +1499,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1282
1499
|
flexShrink={1}
|
|
1283
1500
|
verticalScrollbarOptions={{ visible: false }}
|
|
1284
1501
|
horizontalScrollbarOptions={{ visible: false }}
|
|
1502
|
+
onMouseScroll={() => menu.close()}
|
|
1285
1503
|
>
|
|
1286
1504
|
<For each={tree()}>
|
|
1287
1505
|
{(row) => {
|
|
@@ -1319,7 +1537,12 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1319
1537
|
}
|
|
1320
1538
|
}}
|
|
1321
1539
|
onMouseOut={() => setHovered(undefined)}
|
|
1322
|
-
onMouseDown={() => void
|
|
1540
|
+
onMouseDown={(event: { button?: number; x: number; y: number; stopPropagation: () => void }) => {
|
|
1541
|
+
if (event.button === RIGHT_BUTTON) {
|
|
1542
|
+
event.stopPropagation()
|
|
1543
|
+
menu.open(row.entry, event.x, event.y)
|
|
1544
|
+
} else void openSession(row.entry)
|
|
1545
|
+
}}
|
|
1323
1546
|
>
|
|
1324
1547
|
<text flexShrink={0}>
|
|
1325
1548
|
<span style={{ fg: theme().textMuted }}>{row.last ? "└" : "├"}</span>
|
|
@@ -1364,6 +1587,62 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1364
1587
|
</Show>
|
|
1365
1588
|
</Show>
|
|
1366
1589
|
</Show>
|
|
1590
|
+
<Portal
|
|
1591
|
+
mount={props.api.renderer.root as never}
|
|
1592
|
+
ref={(container: PortalContainer) => {
|
|
1593
|
+
portalContainer = container
|
|
1594
|
+
container.position = "absolute"
|
|
1595
|
+
container.left = 0
|
|
1596
|
+
container.top = 0
|
|
1597
|
+
container.zIndex = 1000
|
|
1598
|
+
}}
|
|
1599
|
+
>
|
|
1600
|
+
<Show when={menuState()}>
|
|
1601
|
+
<box
|
|
1602
|
+
position="absolute"
|
|
1603
|
+
left={menuBox()!.left}
|
|
1604
|
+
top={menuBox()!.top}
|
|
1605
|
+
width={menuBox()!.width}
|
|
1606
|
+
height={menuBox()!.height}
|
|
1607
|
+
flexDirection="column"
|
|
1608
|
+
border
|
|
1609
|
+
borderStyle="rounded"
|
|
1610
|
+
borderColor={theme().borderActive}
|
|
1611
|
+
backgroundColor={theme().backgroundPanel}
|
|
1612
|
+
paddingLeft={1}
|
|
1613
|
+
paddingRight={1}
|
|
1614
|
+
onMouseDown={(event: { stopPropagation: () => void }) => event.stopPropagation()}
|
|
1615
|
+
>
|
|
1616
|
+
<For each={menuState()!.items}>
|
|
1617
|
+
{(item, index) => (
|
|
1618
|
+
<box
|
|
1619
|
+
height={1}
|
|
1620
|
+
backgroundColor={menuState()!.index === index() ? theme().backgroundElement : RGBA.fromInts(0, 0, 0, 0)}
|
|
1621
|
+
onMouseOver={() => menu.select(index())}
|
|
1622
|
+
onMouseDown={(event: { button?: number; stopPropagation: () => void }) => {
|
|
1623
|
+
event.stopPropagation()
|
|
1624
|
+
menu.click(index(), event.button)
|
|
1625
|
+
}}
|
|
1626
|
+
>
|
|
1627
|
+
<text
|
|
1628
|
+
wrapMode="none"
|
|
1629
|
+
style={{
|
|
1630
|
+
fg:
|
|
1631
|
+
item.id === "delete" || item.id === "confirm"
|
|
1632
|
+
? theme().error
|
|
1633
|
+
: menuState()!.index === index()
|
|
1634
|
+
? theme().text
|
|
1635
|
+
: theme().textMuted,
|
|
1636
|
+
}}
|
|
1637
|
+
>
|
|
1638
|
+
{item.label}
|
|
1639
|
+
</text>
|
|
1640
|
+
</box>
|
|
1641
|
+
)}
|
|
1642
|
+
</For>
|
|
1643
|
+
</box>
|
|
1644
|
+
</Show>
|
|
1645
|
+
</Portal>
|
|
1367
1646
|
</box>
|
|
1368
1647
|
)
|
|
1369
1648
|
}
|
|
@@ -1617,7 +1896,11 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1617
1896
|
backgroundColor={entry.id === hovered() ? theme().backgroundElement : RGBA.fromInts(0, 0, 0, 0)}
|
|
1618
1897
|
onMouseOver={() => setHovered(entry.id)}
|
|
1619
1898
|
onMouseOut={() => setHovered(undefined)}
|
|
1620
|
-
onMouseDown={(
|
|
1899
|
+
onMouseDown={(event: { button?: number }) =>
|
|
1900
|
+
event.button === RIGHT_BUTTON
|
|
1901
|
+
? openInCmuxWorkspace(props.api, entry)
|
|
1902
|
+
: props.api.route.navigate("session", { sessionID: entry.id })
|
|
1903
|
+
}
|
|
1621
1904
|
>
|
|
1622
1905
|
<text flexShrink={0} style={{ fg: theme().textMuted }}>
|
|
1623
1906
|
○
|
|
@@ -2242,7 +2525,9 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2242
2525
|
const index = selectableEntries().findIndex((entry) => entry.id === row.entry.id)
|
|
2243
2526
|
if (index >= 0) setCursor(index)
|
|
2244
2527
|
}}
|
|
2245
|
-
onMouseDown={() =>
|
|
2528
|
+
onMouseDown={(event: { button?: number }) =>
|
|
2529
|
+
event.button === RIGHT_BUTTON ? (cleanup(), api.ui.dialog.clear(), openInCmuxWorkspace(api, row.entry)) : choose(row.entry)
|
|
2530
|
+
}
|
|
2246
2531
|
>
|
|
2247
2532
|
<box flexDirection="row" gap={1}>
|
|
2248
2533
|
<text flexShrink={0} style={{ fg: row.entry.id === selectableEntries()[cursor()]?.id
|