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 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.11.1...HEAD
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 on purpose: it has no window-management code. The
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:** kept caller-side. sesh has no window-management code (a hard
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. cmux calls sesh; sesh never calls cmux.
40
+ own workspace; the TUI's right-click action opens one directly.
42
41
 
43
42
  ## 4. Per-project cost digest (done)
44
43
 
@@ -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: sesh has no window-management code, and these recipes
5
- keep it that way. cmux calls sesh; sesh never calls cmux.
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" || { cp -p "$TUI_JSON" "$TUI_JSON.bak.$(date +%Y%m%d%H%M%S)"; mv "$tmp" "$TUI_JSON"; }
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-sesh",
3
- "version": "0.11.1",
3
+ "version": "0.13.0",
4
4
  "description": "Browse, search, preview and resume opencode sessions from the terminal and the TUI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
- onCleanup(() => rootMouseHandlers.delete(onRootMouseDown))
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 openSession(row.entry)}
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={() => props.api.route.navigate("session", { sessionID: entry.id })}
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={() => choose(row.entry)}
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