opencode-sesh 0.12.0 → 0.14.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
@@ -49,8 +49,8 @@ HOME=/tmp/fakehome bash install.sh # installer smoke test (never touches ~/.co
49
49
  window management to sesh itself.
50
50
  - `tui/sesh-panel.tsx` — SolidJS panel, `/** @jsxImportSource @opentui/solid */`.
51
51
  - `opencode/tools/sesh-list.ts` — the agent-facing `sesh-list` tool.
52
- - `scripts/changelog.sh` — release tooling: drafts `[Unreleased]` from
53
- conventional commits, promotes it to a dated version, prints release notes and
52
+ - `scripts/changelog.sh` — release tooling: drafts conventional commits,
53
+ writes a dated version, prints release notes and
54
54
  checks the compare links. `tests/changelog.sh` covers it in a throwaway repo.
55
55
  - `themes/`, `tests/`, `install.sh`, `README.md`.
56
56
 
@@ -132,12 +132,10 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
132
132
  (`feat`, `fix`, `docs`, `ci`, …). See
133
133
  [CONTRIBUTING.md](CONTRIBUTING.md#commit-messages) for the types and the
134
134
  version mapping.
135
- - `CHANGELOG.md` follows Keep a Changelog: notable changes are written under
136
- `[Unreleased]` as the work lands, and the Release workflow promotes that
137
- section (dated, relinked) into the published version, using it verbatim as the
138
- GitHub release body. `scripts/changelog.sh draft` can seed entries from
139
- conventional commits, but hand-written prose is the expected form — never let
140
- a draft overwrite curated entries.
135
+ - `CHANGELOG.md` follows Keep a Changelog and contains released versions only.
136
+ The Release workflow drafts notable Conventional Commits since the last tag
137
+ and writes the new dated version and compare link. The released section is
138
+ used as the GitHub release body.
141
139
 
142
140
  ## TUI panel (`tui/sesh-panel.tsx`)
143
141
 
package/CHANGELOG.md CHANGED
@@ -5,8 +5,21 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [0.14.0] - 2026-09-30
9
9
 
10
+ ### Added
11
+
12
+ - **release:** Generate changelogs from released commit ranges
13
+
14
+ ## [0.13.0] - 2026-09-30
15
+
16
+ ### Changed
17
+
18
+ - Right-clicking a session in the TUI sidebar opens a floating menu at the click
19
+ with Open, Preview transcript, Open in new cmux workspace (inside cmux), Fork,
20
+ Pin/Unpin and Delete (with confirmation). It stays inside the terminal, moves
21
+ with ↑/↓/Enter, and closes on Esc or a click elsewhere. The home list and
22
+ picker keep the direct cmux action.
10
23
  ## [0.12.0] - 2026-09-30
11
24
 
12
25
  ### Added
@@ -388,7 +401,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
388
401
  - Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
389
402
  CI.
390
403
 
391
- [Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.12.0...HEAD
404
+ [0.14.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.13.0...v0.14.0
405
+ [0.13.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.12.0...v0.13.0
392
406
  [0.12.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.11.1...v0.12.0
393
407
  [0.11.1]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.11.0...v0.11.1
394
408
  [0.11.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.10.0...v0.11.0
package/README.md CHANGED
@@ -185,7 +185,11 @@ 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
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
189
193
  workspace at its directory; outside cmux it shows a toast instead.
190
194
 
191
195
  Click the sidebar's `search…` box to filter recent sessions in place; click
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-sesh",
3
- "version": "0.12.0",
3
+ "version": "0.14.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,11 +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
7
  import { spawn } from "node:child_process"
8
8
  import { getTreeSitterClient, RGBA, SyntaxStyle, TextAttributes } from "@opentui/core"
9
+ import { Portal } from "@opentui/solid"
9
10
 
10
11
  type ThemeColors = TuiPluginApi["theme"]["current"]
11
12
 
@@ -251,6 +252,7 @@ type Entry = { id: string; title: string; dir: string; group: string; updated: n
251
252
  type EntryResult = { entries: Entry[]; truncated: boolean }
252
253
  type SidebarMarker = "current" | "running" | "idle"
253
254
 
255
+ const LEFT_BUTTON = 0
254
256
  const RIGHT_BUTTON = 2
255
257
 
256
258
  function openInCmuxWorkspace(api: TuiPluginApi, entry: Entry) {
@@ -267,6 +269,128 @@ function openInCmuxWorkspace(api: TuiPluginApi, entry: Entry) {
267
269
  })
268
270
  }
269
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
+
270
394
  // Loading-spinner frames for the marker of a session whose agent is working.
271
395
  const SIDEBAR_SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
272
396
 
@@ -784,6 +908,14 @@ function pickerLastStart(rows: PickerRow[], height: number, excerpt: (entry: Ent
784
908
  return start
785
909
  }
786
910
 
911
+ type PortalContainer = {
912
+ position?: string
913
+ left?: number
914
+ top?: number
915
+ zIndex?: number
916
+ destroyRecursively?: () => void
917
+ }
918
+
787
919
  type PinProps = { pins: () => Pins; onTogglePin: (kind: keyof Pins, value: string) => void; refreshPins: () => void }
788
920
 
789
921
  function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
@@ -802,6 +934,8 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
802
934
  const [cursor, setCursor] = createSignal(0)
803
935
  const [pendingDelete, setPendingDelete] = createSignal<string>()
804
936
  const [waiting, setWaiting] = createSignal<Map<string, string>>(new Map())
937
+ const [menuState, setMenuState] = createSignal<MenuState>()
938
+ let portalContainer: PortalContainer | undefined
805
939
  let confirmTimer: ReturnType<typeof setTimeout> | undefined
806
940
  const currentID = createMemo(() => {
807
941
  const route = props.api.route.current
@@ -907,6 +1041,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
907
1041
  }
908
1042
 
909
1043
  const toggleSection = () => {
1044
+ menu.close()
910
1045
  if (!sectionCollapsed()) {
911
1046
  cancelDelete()
912
1047
  setNavActive(false)
@@ -918,6 +1053,47 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
918
1053
 
919
1054
  const openSession = (entry: Entry) => props.api.route.navigate("session", { sessionID: entry.id })
920
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
+
921
1097
  // Flat order of the rows a cursor can land on, so keyboard navigation and
922
1098
  // mouse hover resolve to the same row.
923
1099
  const itemRows = createMemo(() => tree().flatMap((row) => (row.kind === "item" ? [row.entry] : [])))
@@ -938,6 +1114,20 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
938
1114
  previousRows = rows
939
1115
  })
940
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
+
941
1131
  // Marker only: a spinner for a working agent, the open-session dot, and the
942
1132
  // neutral row for idle sessions. Titles stay plain and no status text is
943
1133
  // rendered.
@@ -1072,6 +1262,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1072
1262
  })
1073
1263
 
1074
1264
  const onRootMouseDown = () => {
1265
+ menu.close()
1075
1266
  if (insideSearchBox) {
1076
1267
  insideSearchBox = false
1077
1268
  return
@@ -1082,7 +1273,12 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1082
1273
  onMount(() => {
1083
1274
  ensureRootMouse(props.api.renderer)
1084
1275
  rootMouseHandlers.add(onRootMouseDown)
1085
- 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
+ })
1086
1282
  })
1087
1283
 
1088
1284
  const appendQuery = (input: string) => {
@@ -1236,6 +1432,10 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1236
1432
  disposeSearch?.()
1237
1433
  disposeNav?.()
1238
1434
  disposeConfirm?.()
1435
+ menu.close()
1436
+ const container = portalContainer
1437
+ portalContainer = undefined
1438
+ process.nextTick(() => container?.destroyRecursively?.())
1239
1439
  if (confirmTimer) clearTimeout(confirmTimer)
1240
1440
  })
1241
1441
 
@@ -1299,6 +1499,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1299
1499
  flexShrink={1}
1300
1500
  verticalScrollbarOptions={{ visible: false }}
1301
1501
  horizontalScrollbarOptions={{ visible: false }}
1502
+ onMouseScroll={() => menu.close()}
1302
1503
  >
1303
1504
  <For each={tree()}>
1304
1505
  {(row) => {
@@ -1336,9 +1537,12 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1336
1537
  }
1337
1538
  }}
1338
1539
  onMouseOut={() => setHovered(undefined)}
1339
- onMouseDown={(event: { button?: number }) =>
1340
- event.button === RIGHT_BUTTON ? openInCmuxWorkspace(props.api, row.entry) : void openSession(row.entry)
1341
- }
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
+ }}
1342
1546
  >
1343
1547
  <text flexShrink={0}>
1344
1548
  <span style={{ fg: theme().textMuted }}>{row.last ? "└" : "├"}</span>
@@ -1383,6 +1587,62 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1383
1587
  </Show>
1384
1588
  </Show>
1385
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>
1386
1646
  </box>
1387
1647
  )
1388
1648
  }