opencode-sesh 0.7.2 → 0.8.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
@@ -139,34 +139,16 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
139
139
  ## TUI panel (`tui/sesh-panel.tsx`)
140
140
 
141
141
  - **Sidebar:** `api.slots.register` on `sidebar_content` (append mode — native
142
- sidebar content stays). Renders pinned sessions before the newest unarchived
143
- sessions, with the current one highlighted, polled every 15 s; Ctrl-P previews
142
+ sidebar content stays). Pinned directories sort first, then directory groups
143
+ by recency; sessions within each group sort by last update regardless of pins.
144
+ Waiting, running, and current-idle sessions have distinct title colors and
145
+ text labels (in addition to row markers), polled every 15 s; Ctrl-P previews
144
146
  a row and ctrl+x deletes one (armed, then confirmed). The section lists every
145
147
  session without a row cap and flex-grows to fill the sidebar, with overflow
146
148
  scrolling inside; the picker remains the place for transcript search.
147
149
  Sessions waiting on the user (unanswered questions, stuck runs) pin a
148
150
  **Needs input** group above the directories, with the count in the heading —
149
151
  same `NEEDS_INPUT_SQL` heuristic as `bin/sesh-waiting.sh`.
150
- - **cmux bridge (cmux-only, opt-out by absence).** When `CMUX_WORKSPACE_ID` is
151
- set the sidebar talks to the `cmux` CLI; outside cmux no cmux call is made and
152
- every click opens locally. Two behaviors, both keyed on cmux's *own* per-
153
- surface resume record (`cmux surface resume show` → `restore_record.
154
- checkpoint_id`), not on anything sesh writes:
155
- - **Click-to-focus.** `pickFocusWorkspace` (pure, covered by `tests/tui.mjs`)
156
- scans `cmux tree --json --all` plus each surface's checkpoint and, when
157
- another workspace runs the clicked session, runs
158
- `cmux workspace select <ref>` instead of `api.route.navigate`. The caller's
159
- own surface/workspace never match (a same-workspace focus is a no-op, so it
160
- opens locally). Works for background agents and older panels too.
161
- - **Workspace naming.** The poll renames this workspace to the current
162
- session title (`cmux workspace rename <id> --title …`), skipping titles that
163
- must never name a workspace: the pre-first-turn placeholder
164
- (`/^New session\b/`) and plugin sentinels (`ghost-hidden`, which
165
- opencode-ghost titles its internal sessions). `bin/sesh-cmux-sync.sh` does
166
- the same for every workspace at once. The session title is the source of
167
- truth; a manual cmux rename is overwritten on the next session-title change.
168
- These are the only cmux calls in the panel; keep them behind the
169
- `CMUX_WORKSPACE_ID` guard.
170
152
  - **Picker:** a custom dialog (not `DialogSelect`) grouped by
171
153
  project/directory, recency-ordered, resume via
172
154
  `api.route.navigate("session", …)`. `ctrl+o` and the command palette open it;
package/CHANGELOG.md CHANGED
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.0] - 2026-09-24
11
+
12
+ ### Changed
13
+
14
+ - Sidebar and picker sessions within each directory now sort by last update,
15
+ newest first, even when a session is pinned. Sidebar titles and labels make
16
+ waiting, running, and currently open idle sessions distinguishable at a glance.
17
+ ## [0.7.3] - 2026-09-24
18
+
19
+ ### Removed
20
+
21
+ - The cmux bridge, panel and CLI. Click-to-focus, the sidebar's workspace
22
+ renaming, and the `sesh cmux-sync` command (with `bin/sesh-cmux-sync.sh`) are
23
+ gone: cmux already tracks each surface's session and names workspaces, so sesh
24
+ no longer shells out to the `cmux` CLI at all.
10
25
  ## [0.7.2] - 2026-09-24
11
26
 
12
27
  ### Fixed
@@ -318,7 +333,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
318
333
  - Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
319
334
  CI.
320
335
 
321
- [Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.7.2...HEAD
336
+ [Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.8.0...HEAD
337
+ [0.8.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.7.3...v0.8.0
338
+ [0.7.3]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.7.2...v0.7.3
322
339
  [0.7.2]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.7.1...v0.7.2
323
340
  [0.7.1]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.7.0...v0.7.1
324
341
  [0.7.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.6.0...v0.7.0
package/README.md CHANGED
@@ -145,10 +145,6 @@ per-project record.
145
145
  (`New session - …`) with the start of the session's first user message. Only
146
146
  placeholder titles are touched, and the same dry-run/`--yes` gating applies.
147
147
 
148
- `sesh cmux-sync [--dry-run] [--json]` names every [cmux](https://cmux.dev)
149
- workspace after the opencode session its pane is running, so the sidebar and the
150
- workspace list agree. cmux-only; a no-op without the cmux CLI.
151
-
152
148
  When searching, title matches outrank transcript-only matches and the current
153
149
  project rises, so the list answers "where was that thing I worked on" before it
154
150
  answers "what is newest". Pins still win outright.
@@ -186,16 +182,17 @@ Click the sidebar's `search…` box to filter recent sessions in place; click
186
182
  elsewhere or press Esc to leave search. Click the **Sessions** heading to drive
187
183
  the list from the keyboard instead of the mouse (`↑`/`↓` to move, Enter to open,
188
184
  Ctrl-P to preview, Option-S/Option-D to pin, Ctrl-X to delete, Esc to leave). Pinned
189
- sessions appear ahead of the rest; the section lists every session and scrolls
190
- to fill the sidebar, so click **▾ hide** to collapse it when you need the room.
185
+ directories rise first, while sessions within each directory are ordered by
186
+ last update, newest first. The section lists every session and scrolls to fill
187
+ the sidebar, so click **▾ hide** to collapse it when you need the room.
191
188
  Sessions waiting on you (unanswered agent questions, runs stuck mid-tool) pin
192
189
  themselves to a **Needs input** group above the directories, with the count in
193
- the heading; `sesh --needs-input` lists the same set in the terminal.
190
+ the heading; `sesh --needs-input` lists the same set in the terminal. Row titles
191
+ and labels mark waiting (warning), running (success), and the current idle
192
+ session (accent); other sessions stay neutral.
194
193
  Hovering a row while keyboard navigation is active selects it for the next
195
- shortcut. Clicking (or Enter on) a session opens it here — unless another cmux
196
- workspace is already running that session, in which case the sidebar focuses
197
- that workspace instead of opening a duplicate (and the workspace is kept named
198
- after its session). The full picker is one keystroke away.
194
+ shortcut. Clicking (or Enter on) a session opens it here. The full picker is one
195
+ keystroke away.
199
196
 
200
197
  The full picker pages through your whole global session list (no fixed window)
201
198
  and indexes transcript text for every session in the background, showing
package/bin/sesh CHANGED
@@ -34,9 +34,5 @@ case ${1:-} in
34
34
  shift
35
35
  exec bash "$SCRIPT_DIR/sesh-retitle.sh" "$@"
36
36
  ;;
37
- cmux-sync)
38
- shift
39
- exec bash "$SCRIPT_DIR/sesh-cmux-sync.sh" "$@"
40
- ;;
41
37
  esac
42
38
  exec "$SCRIPT_DIR/sesh.sh" "$@"
@@ -37,16 +37,9 @@ caller-side on purpose, so sesh stays terminal-agnostic.
37
37
 
38
38
  ## Keep workspace names in step
39
39
 
40
- The sidebar renames its workspace to the opencode session title as you work, and
41
- `sesh cmux-sync` does it for every workspace at once:
42
-
43
- ```bash
44
- sesh cmux-sync --dry-run # preview
45
- sesh cmux-sync # rename to match
46
- ```
47
-
48
- It reads cmux's own per-surface resume record, so it also names workspaces whose
49
- agent was started outside the sidebar.
40
+ sesh no longer renames workspaces — that's cmux's own concern (its auto-naming,
41
+ or a title-mirroring hook on the session title). The sidebar only shows the
42
+ session; naming stays caller-side like everything else here.
50
43
 
51
44
  ## Needs-input triage from cmux
52
45
 
package/install.sh CHANGED
@@ -235,7 +235,6 @@ for path in \
235
235
  bin/sesh.sh \
236
236
  bin/sesh-list.sh \
237
237
  bin/sesh-preview.sh \
238
- bin/sesh-cmux-sync.sh \
239
238
  bin/sesh-costs.sh \
240
239
  bin/sesh-delete.sh \
241
240
  bin/sesh-prune.sh \
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-sesh",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "description": "Browse, search, preview and resume opencode sessions from the terminal and the TUI.",
6
6
  "license": "MIT",
@@ -4,8 +4,6 @@ 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 { execFile } from "node:child_process"
8
- import { promisify } from "node:util"
9
7
  import { getTreeSitterClient, RGBA, SyntaxStyle, TextAttributes } from "@opentui/core"
10
8
 
11
9
  type ThemeColors = TuiPluginApi["theme"]["current"]
@@ -177,10 +175,6 @@ function pinnedFirst(entries: Entry[], pins: Pins): Entry[] {
177
175
  // dialog). Native `/sessions` and session_list (<leader>l) stay untouched.
178
176
  const BASE_MODE = "base"
179
177
  const POLL_MS = 15_000
180
- // Titles that must never become a cmux workspace name: opencode's pre-first-
181
- // turn placeholder, and the sentinel other plugins (opencode-ghost) use for
182
- // internal sessions so their scaffolding rows stay out of the UI.
183
- const PLACEHOLDER_TITLE = /^New session\b|^ghost-hidden$/i
184
178
 
185
179
  function shortDir(dir: string, home: string): string {
186
180
  if (!dir || dir === "/") return "other"
@@ -254,6 +248,20 @@ function ensureRootMouse(renderer: TuiPluginApi["renderer"]) {
254
248
 
255
249
  type Entry = { id: string; title: string; dir: string; group: string; updated: number }
256
250
  type EntryResult = { entries: Entry[]; truncated: boolean }
251
+ type SidebarActivity = "waiting" | "running" | "active" | "idle"
252
+
253
+ function newestFirst(entries: Entry[]): Entry[] {
254
+ return [...entries].sort((a, b) => b.updated - a.updated || a.id.localeCompare(b.id))
255
+ }
256
+
257
+ // A question needs an answer even if the agent is busy. A stale running-tool
258
+ // record, on the other hand, must not hide a currently busy agent.
259
+ function sidebarActivity(current: boolean, live?: string, pending = false, reason?: string): SidebarActivity {
260
+ if (pending || reason === "question") return "waiting"
261
+ if (live === "busy" || live === "retry") return "running"
262
+ if (reason === "stuck") return "waiting"
263
+ return current ? "active" : "idle"
264
+ }
257
265
 
258
266
  const SESSION_PAGE_LIMIT = 200
259
267
  const SESSION_MAX = 5000
@@ -394,96 +402,6 @@ async function queryWaitingIds(db: any): Promise<Map<string, string>> {
394
402
  return out
395
403
  }
396
404
 
397
- // cmux bridge: which surface is running which agent session, so opening a
398
- // session that is live in another cmux workspace focuses that workspace
399
- // instead of opening a duplicate here. The truth is cmux's own resume record
400
- // (`checkpoint_id` per surface), not anything this panel writes — so it also
401
- // covers background agents and panes running an older panel. Outside cmux
402
- // (no CMUX_WORKSPACE_ID) no cmux call is made.
403
- type CmuxSurface = { ref: string; workspaceRef: string; here: boolean; checkpoint?: string }
404
-
405
- async function cmuxJson(args: string[]): Promise<any | undefined> {
406
- try {
407
- const { stdout } = await promisify(execFile)("cmux", args)
408
- return JSON.parse(stdout)
409
- } catch {
410
- return undefined
411
- }
412
- }
413
-
414
- async function cmuxSurfaces(): Promise<CmuxSurface[]> {
415
- const tree = await cmuxJson(["tree", "--json", "--all"])
416
- if (!tree) return []
417
- const surfaces: CmuxSurface[] = []
418
- for (const w of tree.windows ?? []) {
419
- for (const ws of w.workspaces ?? []) {
420
- if (typeof ws?.ref !== "string") continue
421
- for (const pane of ws.panes ?? []) {
422
- for (const s of pane.surfaces ?? []) {
423
- if (s?.type === "terminal" && typeof s.ref === "string") {
424
- surfaces.push({ ref: s.ref, workspaceRef: ws.ref, here: s.here === true })
425
- }
426
- }
427
- }
428
- }
429
- }
430
- await Promise.all(
431
- surfaces.map(async (surface) => {
432
- const info = await cmuxJson(["surface", "resume", "show", "--surface", surface.ref, "--json"])
433
- const checkpoint = info?.restore_record?.checkpoint_id
434
- if (typeof checkpoint === "string") surface.checkpoint = checkpoint
435
- }),
436
- )
437
- return surfaces
438
- }
439
-
440
- // The bridge walks `cmux tree` plus one `surface resume show` per surface, and
441
- // switching sessions is frequent while the surface set changes rarely, so reuse
442
- // a recent scan for a moment instead of re-walking cmux on every open.
443
- let cmuxSurfacesCache: { at: number; surfaces: CmuxSurface[] } | undefined
444
- async function cmuxSurfacesCached(): Promise<CmuxSurface[]> {
445
- const now = Date.now()
446
- if (cmuxSurfacesCache && now - cmuxSurfacesCache.at < 1500) return cmuxSurfacesCache.surfaces
447
- const surfaces = await cmuxSurfaces()
448
- cmuxSurfacesCache = { at: now, surfaces }
449
- return surfaces
450
- }
451
-
452
- // Pure selection: the workspace to focus for a session, or undefined to open
453
- // locally. Prefers a live surface in a different workspace; ignores the
454
- // caller's own surface and workspace (focusing where you already are is a
455
- // no-op, so a local open is the better answer).
456
- function pickFocusWorkspace(surfaces: CmuxSurface[], sessionID: string): string | undefined {
457
- const self = surfaces.find((surface) => surface.here)
458
- for (const surface of surfaces) {
459
- if (self && surface.ref === self.ref) continue
460
- if (surface.checkpoint !== sessionID) continue
461
- if (self && surface.workspaceRef === self.workspaceRef) continue
462
- return surface.workspaceRef
463
- }
464
- return undefined
465
- }
466
-
467
- // Open a session from anywhere in the panel, cmux-aware: if another cmux
468
- // workspace is already running it, focus that workspace instead of opening a
469
- // duplicate here; otherwise open it locally. Every switch path (sidebar, home
470
- // list, picker, needs-input picker) goes through here so none of them silently
471
- // skips the bridge. cmux calls only run inside cmux.
472
- async function openSessionEntry(api: TuiPluginApi, entry: Entry): Promise<void> {
473
- if (process.env.CMUX_WORKSPACE_ID) {
474
- const target = pickFocusWorkspace(await cmuxSurfacesCached(), entry.id)
475
- if (target) {
476
- try {
477
- await promisify(execFile)("cmux", ["workspace", "select", target])
478
- return
479
- } catch {
480
- // cmux unavailable; fall through to a local open
481
- }
482
- }
483
- }
484
- api.route.navigate("session", { sessionID: entry.id })
485
- }
486
-
487
405
  function addTranscript(index: Map<string, string>, sid: string, data: string): void {
488
406
  try {
489
407
  const part = JSON.parse(data) as { type?: unknown; text?: unknown }
@@ -757,7 +675,6 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
757
675
  const [pendingDelete, setPendingDelete] = createSignal<string>()
758
676
  const [waiting, setWaiting] = createSignal<Map<string, string>>(new Map())
759
677
  let confirmTimer: ReturnType<typeof setTimeout> | undefined
760
- let lastSyncedTitle: string | undefined
761
678
  const currentID = createMemo(() => {
762
679
  const route = props.api.route.current
763
680
  if (route.name !== "session") return undefined
@@ -793,20 +710,6 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
793
710
  } catch {
794
711
  // leave the previous waiting set in place rather than flashing
795
712
  }
796
- // Keep this cmux workspace named after the session it is showing, so the
797
- // sidebar and the workspace list agree. The session title is the source
798
- // of truth; a placeholder title is never pushed. cmux-only.
799
- try {
800
- const workspace = process.env.CMUX_WORKSPACE_ID
801
- const id = currentID()
802
- const title = id ? (props.api.state.session.get(id)?.title ?? "").trim() : ""
803
- if (workspace && title && !PLACEHOLDER_TITLE.test(title) && title !== lastSyncedTitle) {
804
- await promisify(execFile)("cmux", ["workspace", "rename", workspace, "--title", title])
805
- lastSyncedTitle = title
806
- }
807
- } catch {
808
- // renaming is best-effort; a failure leaves the workspace name alone
809
- }
810
713
  }
811
714
  void load()
812
715
  const clock = setInterval(() => void load(), POLL_MS)
@@ -818,7 +721,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
818
721
 
819
722
  const filteredEntries = createMemo(() => {
820
723
  const q = query().trim().toLowerCase()
821
- const ordered = pinnedFirst(entries(), props.pins())
724
+ const ordered = entries()
822
725
  if (!q) return ordered
823
726
  return ordered.filter(
824
727
  (entry) => entry.title.toLowerCase().includes(q) || entry.dir.toLowerCase().includes(q),
@@ -831,9 +734,10 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
831
734
  const NEEDS_INPUT_DIR = "__needs_input__"
832
735
  const waitingEntries = createMemo(() => {
833
736
  const ids = waiting()
834
- return shownEntries().filter((entry) => ids.has(entry.id))
737
+ return newestFirst(shownEntries().filter((entry) => ids.has(entry.id)))
835
738
  })
836
- const waitingReason = (id: string) => (waiting().get(id) === "question" ? "awaiting answer" : "run stuck")
739
+ const waitingReason = (id: string) =>
740
+ waiting().get(id) === "question" ? "awaiting answer" : waiting().get(id) === "stuck" ? "run stuck" : "needs input"
837
741
 
838
742
  const tree = createMemo<TreeRow[]>(() => {
839
743
  const rows: TreeRow[] = []
@@ -862,10 +766,11 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
862
766
  return rank(b) - rank(a) || b.updated - a.updated
863
767
  })
864
768
  for (const group of ordered) {
769
+ const sessions = newestFirst(group.list)
865
770
  rows.push({ kind: "group", dir: group.dir, label: prettyDir(group.dir, home), count: group.list.length })
866
771
  if (collapsed()[group.dir]) continue
867
- for (let i = 0; i < group.list.length; i++) {
868
- rows.push({ kind: "item", entry: group.list[i], last: i === group.list.length - 1 })
772
+ for (let i = 0; i < sessions.length; i++) {
773
+ rows.push({ kind: "item", entry: sessions[i], last: i === sessions.length - 1 })
869
774
  }
870
775
  }
871
776
  return rows
@@ -885,9 +790,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
885
790
  setSectionCollapsed((value) => !value)
886
791
  }
887
792
 
888
- // Sidebar rows open through the shared cmux-aware path, so they focus the
889
- // workspace already running the session instead of opening a duplicate.
890
- const openSession = (entry: Entry) => openSessionEntry(props.api, entry)
793
+ const openSession = (entry: Entry) => props.api.route.navigate("session", { sessionID: entry.id })
891
794
 
892
795
  // Flat order of the rows a cursor can land on, so keyboard navigation and
893
796
  // mouse hover resolve to the same row.
@@ -909,21 +812,24 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
909
812
  previousRows = rows
910
813
  })
911
814
 
912
- const sessionStatus = (id: string): "running" | "waiting" | "idle" => {
815
+ const sessionStatus = (id: string): SidebarActivity => {
913
816
  try {
914
817
  const permissions = props.api.state.session.permission(id)
915
818
  const questions = props.api.state.session.question(id)
916
- if ((permissions?.length ?? 0) > 0 || (questions?.length ?? 0) > 0) return "waiting"
917
- const status = props.api.state.session.status(id)
918
- if (status?.type === "busy" || status?.type === "retry") return "running"
819
+ return sidebarActivity(
820
+ id === currentID(),
821
+ props.api.state.session.status(id)?.type,
822
+ (permissions?.length ?? 0) > 0 || (questions?.length ?? 0) > 0,
823
+ waiting().get(id),
824
+ )
919
825
  } catch {}
920
- return "idle"
826
+ return sidebarActivity(id === currentID(), undefined, false, waiting().get(id))
921
827
  }
922
828
 
923
- const statusColor = (id: string) => {
924
- const status = sessionStatus(id)
829
+ const statusColor = (status: SidebarActivity) => {
925
830
  if (status === "running") return theme().success
926
831
  if (status === "waiting") return theme().warning
832
+ if (status === "active") return theme().accent
927
833
  return theme().textMuted
928
834
  }
929
835
 
@@ -1220,8 +1126,8 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1220
1126
  horizontalScrollbarOptions={{ visible: false }}
1221
1127
  >
1222
1128
  <For each={tree()}>
1223
- {(row) =>
1224
- row.kind === "group" ? (
1129
+ {(row) => {
1130
+ if (row.kind === "group") return (
1225
1131
  <box flexDirection="row" paddingTop={1} onMouseDown={() => toggleGroup(row.dir)}>
1226
1132
  <text flexGrow={1} flexShrink={1} wrapMode="none">
1227
1133
  <span style={{ fg: theme().textMuted }}>{collapsed()[row.dir] ? "▸ " : "▾ "}</span>
@@ -1231,7 +1137,9 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1231
1137
  {" "}({row.count})
1232
1138
  </text>
1233
1139
  </box>
1234
- ) : (
1140
+ )
1141
+ const activity = createMemo(() => sessionStatus(row.entry.id))
1142
+ return (
1235
1143
  <box
1236
1144
  flexDirection="row"
1237
1145
  gap={1}
@@ -1263,24 +1171,24 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1263
1171
  ? theme().error
1264
1172
  : row.entry.id === currentID()
1265
1173
  ? theme().accent
1266
- : statusColor(row.entry.id),
1174
+ : statusColor(activity()),
1267
1175
  }}
1268
1176
  >
1269
- {row.entry.id === currentID() ? "●" : isActive(row.entry.id) ? "▸" : "○"}
1177
+ {row.entry.id === currentID() ? "●" : activity() === "waiting" ? "!" : activity() === "running" ? "▶" : isActive(row.entry.id) ? "▸" : "○"}
1270
1178
  </span>
1271
1179
  </text>
1272
1180
  <Highlighted
1273
1181
  text={`${props.pins().sessions.includes(row.entry.id) ? "★ " : ""}${truncate(row.entry.title, SIDEBAR_TITLE_WIDTH - (props.pins().sessions.includes(row.entry.id) ? 2 : 0))}`}
1274
1182
  query={query()}
1275
- color={isPendingDelete(row.entry.id) ? theme().error : theme().text}
1183
+ color={isPendingDelete(row.entry.id) ? theme().error : activity() === "idle" ? theme().text : statusColor(activity())}
1276
1184
  matchColor={theme().warning}
1277
1185
  />
1278
1186
  <text flexShrink={0} style={{ fg: isPendingDelete(row.entry.id) ? theme().error : theme().textMuted }}>
1279
1187
  {isPendingDelete(row.entry.id) ? (
1280
1188
  "ctrl+x again"
1281
- ) : waiting().has(row.entry.id) ? (
1189
+ ) : activity() !== "idle" ? (
1282
1190
  <span>
1283
- {ago(row.entry.updated)} · <span style={{ fg: theme().warning }}>{waitingReason(row.entry.id)}</span>
1191
+ {ago(row.entry.updated)} · <span style={{ fg: statusColor(activity()) }}>{activity() === "waiting" ? waitingReason(row.entry.id) : activity()}</span>
1284
1192
  </span>
1285
1193
  ) : (
1286
1194
  ago(row.entry.updated)
@@ -1288,7 +1196,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1288
1196
  </text>
1289
1197
  </box>
1290
1198
  )
1291
- }
1199
+ }}
1292
1200
  </For>
1293
1201
  </scrollbox>
1294
1202
  <Show when={remaining() > 0}>
@@ -1467,7 +1375,7 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
1467
1375
  backgroundColor={entry.id === hovered() ? theme().backgroundElement : RGBA.fromInts(0, 0, 0, 0)}
1468
1376
  onMouseOver={() => setHovered(entry.id)}
1469
1377
  onMouseOut={() => setHovered(undefined)}
1470
- onMouseDown={() => void openSessionEntry(props.api, entry)}
1378
+ onMouseDown={() => props.api.route.navigate("session", { sessionID: entry.id })}
1471
1379
  >
1472
1380
  <text flexShrink={0} style={{ fg: theme().textMuted }}>
1473
1381
  ○
@@ -1581,7 +1489,7 @@ const tui: TuiPlugin = async (api) => {
1581
1489
  list.push(entry)
1582
1490
  byGroup.set(entry.group, list)
1583
1491
  }
1584
- const groups = [...byGroup.entries()].map(([name, list]) => ({ name, list }))
1492
+ const groups = [...byGroup.entries()].map(([name, list]) => ({ name, list: newestFirst(list) }))
1585
1493
  const currentPins = pins()
1586
1494
  const rank = (group: (typeof groups)[number]) =>
1587
1495
  group.list.some((entry) => currentPins.directories.includes(entry.dir)) ? 2
@@ -1771,7 +1679,7 @@ const tui: TuiPlugin = async (api) => {
1771
1679
  if (!target) return
1772
1680
  cleanup()
1773
1681
  api.ui.dialog.clear()
1774
- void openSessionEntry(api, target)
1682
+ api.route.navigate("session", { sessionID: target.id })
1775
1683
  }
1776
1684
 
1777
1685
  const disposeNav = api.keymap.registerLayer({
@@ -2172,7 +2080,7 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
2172
2080
  if (!target) return
2173
2081
  disposeNav()
2174
2082
  api.ui.dialog.clear()
2175
- void openSessionEntry(api, target.entry)
2083
+ api.route.navigate("session", { sessionID: target.entry.id })
2176
2084
  }
2177
2085
  const disposeNav = api.keymap.registerLayer({
2178
2086
  bindings: [
@@ -1,149 +0,0 @@
1
- #!/usr/bin/env bash
2
- # Name each cmux workspace after the opencode session it is running.
3
- #
4
- # cmux records the session behind every terminal surface (its resume
5
- # `checkpoint_id`), so the mapping needs no bookkeeping: for each surface whose
6
- # checkpoint is an opencode session (`ses_…`), rename its workspace to that
7
- # session's current title. Titles are the opencode session title (the source of
8
- # truth) unless it is still the pre-first-turn placeholder, which is skipped.
9
- #
10
- # cmux-only: exits cleanly when the cmux CLI is unavailable. Renames are
11
- # lossless (a title), so there is no confirmation; --dry-run previews instead.
12
- #
13
- # Usage: sesh-cmux-sync.sh [--dry-run] [--json]
14
- set -euo pipefail
15
-
16
- OPENCODE_BIN=${SESH_OPENCODE:-opencode}
17
- CMUX_BIN=${SESH_CMUX:-cmux}
18
- dry_run=0
19
- emit_json=0
20
- while [ "$#" -gt 0 ]; do
21
- case "$1" in
22
- --dry-run) dry_run=1 ;;
23
- --json) emit_json=1 ;;
24
- --help) echo "usage: ${0##*/} [--dry-run] [--json]"; exit 0 ;;
25
- *) echo "unknown option: $1" >&2; exit 2 ;;
26
- esac
27
- shift
28
- done
29
-
30
- command -v "$CMUX_BIN" >/dev/null 2>&1 || { echo 'sesh: cmux is not available' >&2; exit 1; }
31
- export CMUX_QUIET=1
32
-
33
- resolve_db() {
34
- if [ -n "${SESH_DB:-}" ]; then printf '%s\n' "$SESH_DB"; return 0; fi
35
- local from_cli=''
36
- if command -v "$OPENCODE_BIN" >/dev/null 2>&1; then
37
- from_cli=$("$OPENCODE_BIN" db path 2>/dev/null || true)
38
- fi
39
- if [ -n "$from_cli" ] && [ -f "$from_cli" ]; then printf '%s\n' "$from_cli"; return 0; fi
40
- if [ -f "$HOME/.local/share/opencode/opencode.db" ]; then printf '%s\n' "$HOME/.local/share/opencode/opencode.db"; return 0; fi
41
- return 1
42
- }
43
-
44
- SQLITE_BIN=''
45
- if [ -n "${SESH_SQLITE:-}" ]; then
46
- SQLITE_BIN=$SESH_SQLITE
47
- elif command -v sqlite3 >/dev/null 2>&1; then
48
- SQLITE_BIN=sqlite3
49
- fi
50
-
51
- JQ_BIN=${SESH_JQ:-$(command -v jq || true)}
52
- [ -n "$JQ_BIN" ] || { echo 'sesh: jq is required' >&2; exit 1; }
53
-
54
- DB_PATH=$(resolve_db) || { echo 'sesh: session database unavailable' >&2; exit 1; }
55
- [ -f "$DB_PATH" ] || { echo 'sesh: session database unavailable' >&2; exit 1; }
56
-
57
- # 1. Every terminal surface cmux knows about, with its workspace.
58
- SURFACES=$("$CMUX_BIN" tree --json --all 2>/dev/null | "$JQ_BIN" -c '
59
- [ .windows[]?.workspaces[]? as $w
60
- | $w.panes[]?.surfaces[]?
61
- | select(.type == "terminal" and (.ref | type) == "string")
62
- | {surface: .ref, workspace: $w.ref} ]') || SURFACES='[]'
63
- [ -n "$SURFACES" ] || SURFACES='[]'
64
-
65
- # 2. The opencode session each surface is running (cmux resume checkpoint).
66
- PAIRS='[]'
67
- while IFS=$'\t' read -r surface workspace; do
68
- [ -n "$surface" ] || continue
69
- checkpoint=$("$CMUX_BIN" surface resume show --surface "$surface" --json 2>/dev/null \
70
- | "$JQ_BIN" -r '.restore_record.checkpoint_id // empty' 2>/dev/null || true)
71
- case "$checkpoint" in
72
- ses_[A-Za-z0-9]*) ;;
73
- *) continue ;;
74
- esac
75
- PAIRS=$(printf '%s' "$PAIRS" | "$JQ_BIN" -c \
76
- --arg ws "$workspace" --arg sid "$checkpoint" \
77
- '. + [{workspace: $ws, session: $sid}]')
78
- done < <(printf '%s' "$SURFACES" | "$JQ_BIN" -r '.[] | [.surface, .workspace] | @tsv')
79
-
80
- if [ "$(printf '%s' "$PAIRS" | "$JQ_BIN" 'length')" = 0 ]; then
81
- echo 'No cmux workspace is running an opencode session.'
82
- exit 0
83
- fi
84
-
85
- # 3. Session titles (one query) and current workspace titles.
86
- IN_LIST=''
87
- for id in $(printf '%s' "$PAIRS" | "$JQ_BIN" -r '.[].session'); do
88
- case "$id" in
89
- ses_[A-Za-z0-9]*) IN_LIST="$IN_LIST'$id'," ;;
90
- *) echo 'sesh: unexpected session id shape' >&2; exit 1 ;;
91
- esac
92
- done
93
- IN_LIST=${IN_LIST%,}
94
- if [ -n "$SQLITE_BIN" ]; then
95
- TITLE_ROWS=$("$SQLITE_BIN" -json -readonly "$DB_PATH" "SELECT id, title FROM session WHERE id IN ($IN_LIST);")
96
- else
97
- TITLE_ROWS=$("$OPENCODE_BIN" db "SELECT id, title FROM session WHERE id IN ($IN_LIST);" --format json)
98
- fi
99
- [ -n "$TITLE_ROWS" ] || TITLE_ROWS='[]'
100
-
101
- CURRENT=$("$CMUX_BIN" workspace list --json 2>/dev/null | "$JQ_BIN" -c \
102
- '[.workspaces[] | {workspace: .ref, title: (.custom_title // .title // "")}]') || CURRENT='[]'
103
- [ -n "$CURRENT" ] || CURRENT='[]'
104
-
105
- # 4. Keep only the real renames: a non-placeholder session title that differs
106
- # from what the workspace already shows.
107
- FINAL=$(printf '%s' "$PAIRS" | "$JQ_BIN" -c \
108
- --slurpfile sessions <(printf '%s' "$TITLE_ROWS") \
109
- --slurpfile current <(printf '%s' "$CURRENT") '
110
- ($sessions[0] | map({key: .id, value: .title}) | from_entries) as $byId
111
- | ($current[0] | map({key: .workspace, value: .title}) | from_entries) as $shown
112
- | [ .[]
113
- | ($byId[.session] // "" | gsub("^ +| +$"; "")) as $t
114
- | select($t != "")
115
- # Never push a placeholder or a plugin sentinel (opencode-ghost titles its
116
- # internal sessions "ghost-hidden").
117
- | select(($t | test("^New session\\b|^ghost-hidden$"; "i") | not))
118
- | select(($shown[.workspace] // "") != $t)
119
- | . + {title: $t} ]')
120
-
121
- COUNT=$(printf '%s' "$FINAL" | "$JQ_BIN" 'length')
122
- if [ "$COUNT" = 0 ]; then
123
- echo 'Every cmux workspace already matches its session.'
124
- exit 0
125
- fi
126
-
127
- if [ "$emit_json" = 1 ]; then
128
- printf '%s\n' "$FINAL"
129
- exit 0
130
- fi
131
-
132
- printf '%s' "$FINAL" | "$JQ_BIN" -r '.[] | "\(.workspace)\t\(.title)"' | while IFS=$'\t' read -r ws title; do
133
- printf '%s → %s\n' "$ws" "$title"
134
- done
135
-
136
- if [ "$dry_run" = 1 ]; then
137
- printf 'Would rename %s workspace(s) (--dry-run: nothing changed).\n' "$COUNT" >&2
138
- exit 0
139
- fi
140
-
141
- renamed=0
142
- while IFS=$'\t' read -r ws title; do
143
- if "$CMUX_BIN" workspace rename "$ws" --title "$title" >/dev/null 2>&1; then
144
- renamed=$((renamed + 1))
145
- else
146
- echo "sesh: could not rename $ws" >&2
147
- fi
148
- done < <(printf '%s' "$FINAL" | "$JQ_BIN" -r '.[] | [.workspace, .title] | @tsv')
149
- echo "Renamed $renamed workspace(s)."