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 +4 -22
- package/CHANGELOG.md +18 -1
- package/README.md +8 -11
- package/bin/sesh +0 -4
- package/contrib/cmux/README.md +3 -10
- package/install.sh +0 -1
- package/package.json +1 -1
- package/tui/sesh-panel.tsx +47 -139
- package/bin/sesh-cmux-sync.sh +0 -149
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).
|
|
143
|
-
sessions
|
|
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.
|
|
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
|
-
|
|
190
|
-
|
|
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
|
|
196
|
-
|
|
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
package/contrib/cmux/README.md
CHANGED
|
@@ -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
|
-
|
|
41
|
-
|
|
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
package/package.json
CHANGED
package/tui/sesh-panel.tsx
CHANGED
|
@@ -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 =
|
|
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) =>
|
|
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 <
|
|
868
|
-
rows.push({ kind: "item", entry:
|
|
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
|
-
|
|
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):
|
|
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
|
-
|
|
917
|
-
|
|
918
|
-
|
|
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
|
|
826
|
+
return sidebarActivity(id === currentID(), undefined, false, waiting().get(id))
|
|
921
827
|
}
|
|
922
828
|
|
|
923
|
-
const statusColor = (
|
|
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(
|
|
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
|
-
) :
|
|
1189
|
+
) : activity() !== "idle" ? (
|
|
1282
1190
|
<span>
|
|
1283
|
-
{ago(row.entry.updated)} · <span style={{ fg:
|
|
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={() =>
|
|
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
|
-
|
|
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
|
-
|
|
2083
|
+
api.route.navigate("session", { sessionID: target.entry.id })
|
|
2176
2084
|
}
|
|
2177
2085
|
const disposeNav = api.keymap.registerLayer({
|
|
2178
2086
|
bindings: [
|
package/bin/sesh-cmux-sync.sh
DELETED
|
@@ -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)."
|