opencode-multiplexer 0.2.2 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-multiplexer",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Multiplexer for opencode AI coding agent sessions",
6
6
  "keywords": ["opencode", "ai", "agent", "tui", "multiplexer"],
package/src/config.ts CHANGED
@@ -21,6 +21,11 @@ export interface KeybindingsConfig {
21
21
  back: string
22
22
  attach: string
23
23
  send: string
24
+ spawn: string
25
+ kill: string
26
+ refresh: string
27
+ help: string
28
+ nextNeedsInput: string
24
29
  scrollUp: string
25
30
  scrollDown: string
26
31
  scrollHalfPageUp: string
@@ -39,6 +44,7 @@ export interface KeybindingsConfig {
39
44
  export interface Config {
40
45
  keybindings: KeybindingsConfig
41
46
  pollIntervalMs: number
47
+ conversationPollIntervalMs: number
42
48
  dbPath: string
43
49
  }
44
50
 
@@ -62,6 +68,11 @@ const DEFAULTS: Config = {
62
68
  back: "escape",
63
69
  attach: "a",
64
70
  send: "return",
71
+ spawn: "n",
72
+ kill: "x",
73
+ refresh: "r",
74
+ help: "?",
75
+ nextNeedsInput: "ctrl-n",
65
76
  scrollUp: "k",
66
77
  scrollDown: "j",
67
78
  scrollHalfPageUp: "ctrl-u",
@@ -77,6 +88,7 @@ const DEFAULTS: Config = {
77
88
  },
78
89
  },
79
90
  pollIntervalMs: 2000,
91
+ conversationPollIntervalMs: 1000,
80
92
  dbPath: join(homedir(), ".local", "share", "opencode", "opencode.db"),
81
93
  }
82
94
 
package/src/db/reader.ts CHANGED
@@ -244,8 +244,20 @@ export function getLastMessagePreview(sessionId: string): { text: string; role:
244
244
  )
245
245
  .get(sessionId)
246
246
 
247
+ // Strip markdown syntax and collapse to a single line for dashboard preview
248
+ const raw = row?.text ?? ""
249
+ const clean = raw
250
+ .replace(/\*\*(.+?)\*\*/g, "$1") // **bold** → bold
251
+ .replace(/(?<!\*)\*([^*\n]+)\*(?!\*)/g, "$1") // *italic* → italic
252
+ .replace(/`([^`]+)`/g, "$1") // `code` → code
253
+ .replace(/^#{1,6}\s+/gm, "") // ## heading → heading
254
+ .replace(/^[-*]\s+/gm, "• ") // - list → • list
255
+ .replace(/\n+/g, " ") // collapse newlines to spaces
256
+ .replace(/\s{2,}/g, " ") // collapse multiple spaces
257
+ .trim()
258
+
247
259
  return {
248
- text: row?.text ?? "",
260
+ text: clean,
249
261
  role: (row?.role ?? "user") as "user" | "assistant",
250
262
  }
251
263
  }
@@ -30,7 +30,7 @@ export function setInkInstance(instance: ReturnType<typeof render>): void {
30
30
  * Yield terminal control to opencode for a specific session.
31
31
  * Exits alt screen, unmounts Ink, runs opencode, re-enters alt screen and remounts.
32
32
  */
33
- export function yieldToOpencode(sessionId: string, cwd: string): void {
33
+ export function yieldToOpencode(sessionId: string, cwd: string, port?: number | null): void {
34
34
  if (!_inkInstance) return
35
35
 
36
36
  _inkInstance.unmount()
@@ -40,7 +40,10 @@ export function yieldToOpencode(sessionId: string, cwd: string): void {
40
40
  if (process.stdout.isTTY) process.stdout.write(EXIT_ALT_SCREEN)
41
41
 
42
42
  try {
43
- execSync(`opencode -s ${sessionId}`, {
43
+ const cmd = port
44
+ ? `opencode attach http://localhost:${port} --session ${sessionId} --dir ${cwd}`
45
+ : `opencode -s ${sessionId}`
46
+ execSync(cmd, {
44
47
  stdio: "inherit",
45
48
  cwd,
46
49
  })
@@ -1,7 +1,7 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync } from "fs"
2
2
  import { homedir } from "os"
3
3
  import { join } from "path"
4
- import { execSync } from "child_process"
4
+ import { execSync, spawn } from "child_process"
5
5
 
6
6
  const CONFIG_DIR = join(homedir(), ".config", "ocmux")
7
7
  const INSTANCES_FILE = join(CONFIG_DIR, "instances.json")
@@ -175,3 +175,47 @@ export function killInstance(worktree: string, sessionId: string | null): void {
175
175
  try { process.kill(pid, "SIGTERM") } catch { /* already dead */ }
176
176
  }
177
177
  }
178
+
179
+ /**
180
+ * Ensure a serve process is running for the given working directory.
181
+ * If one already exists (in instances.json and responsive), return its port.
182
+ * Otherwise, spawn a new `opencode serve` process and wait for it to be ready.
183
+ * Returns the port number.
184
+ */
185
+ export async function ensureServeProcess(cwd: string): Promise<number> {
186
+ // 1. Check if we already have a live serve process for this directory
187
+ const instances = loadSpawnedInstances()
188
+ for (const inst of instances) {
189
+ const cwdMatch =
190
+ inst.cwd === cwd ||
191
+ inst.cwd.startsWith(cwd + "/") ||
192
+ cwd.startsWith(inst.cwd + "/")
193
+ if (cwdMatch && isPidAlive(inst.pid) && (await isPortAlive(inst.port))) {
194
+ return inst.port
195
+ }
196
+ }
197
+
198
+ // 2. No live serve process — spawn one
199
+ const port = await findNextPort()
200
+ const proc = spawn("opencode", ["serve", "--port", String(port)], {
201
+ cwd,
202
+ detached: true,
203
+ stdio: "ignore",
204
+ })
205
+ proc.unref()
206
+
207
+ // 3. Wait for it to be ready
208
+ await waitForServer(port)
209
+
210
+ // 4. Persist to instances.json (sessionId is null — auto-spawned, not tied to one session)
211
+ const updatedInstances = loadSpawnedInstances()
212
+ updatedInstances.push({
213
+ port,
214
+ pid: proc.pid!,
215
+ cwd,
216
+ sessionId: null,
217
+ })
218
+ saveSpawnedInstances(updatedInstances)
219
+
220
+ return port
221
+ }
@@ -9,7 +9,9 @@ import { useConversationKeys } from "../hooks/use-keybindings.js"
9
9
  import { yieldToOpencode, openInEditor, consumePendingEditorResult } from "../hooks/use-attach.js"
10
10
  import { getMessages, getSessionById, getSessionStatus, getSessionAgent } from "../db/reader.js"
11
11
  import { config } from "../config.js"
12
- import { shortenModel } from "../poller.js"
12
+ import { shortenModel, refreshNow } from "../poller.js"
13
+ import { ensureServeProcess, killInstance } from "../registry/instances.js"
14
+ import { statusIcon } from "./helpers.js"
13
15
 
14
16
  // ─── Markdown setup ───────────────────────────────────────────────────────────
15
17
 
@@ -56,7 +58,14 @@ function formatTime(ts: number): string {
56
58
 
57
59
  function getTextFromParts(parts: ConversationMessagePart[]): string {
58
60
  return parts
59
- .filter((p) => p.type === "text" && p.text && !p.text.trimStart().startsWith("<"))
61
+ .filter((p) => p.type === "text" && p.text)
62
+ .map((p) => p.text as string)
63
+ .join("")
64
+ }
65
+
66
+ function getThinkingFromParts(parts: ConversationMessagePart[]): string {
67
+ return parts
68
+ .filter((p) => p.type === "thinking" && p.text)
60
69
  .map((p) => p.text as string)
61
70
  .join("")
62
71
  }
@@ -69,6 +78,7 @@ function getToolParts(parts: ConversationMessagePart[]) {
69
78
 
70
79
  type DisplayLine =
71
80
  | { kind: "role-header"; role: "user" | "assistant"; time: string }
81
+ | { kind: "thinking"; text: string }
72
82
  | { kind: "text"; text: string }
73
83
  | { kind: "tool"; icon: string; color: string; name: string; callId?: string }
74
84
  | { kind: "spacer" }
@@ -79,6 +89,15 @@ function buildDisplayLines(messages: ConversationMessage[]): DisplayLine[] {
79
89
  for (const msg of messages) {
80
90
  lines.push({ kind: "role-header", role: msg.role, time: formatTime(msg.timeCreated) })
81
91
 
92
+ // Thinking blocks (full text, dimmed yellow)
93
+ const thinking = getThinkingFromParts(msg.parts)
94
+ if (thinking) {
95
+ lines.push({ kind: "thinking", text: "💭 Thinking" })
96
+ for (const line of thinking.split("\n")) {
97
+ lines.push({ kind: "thinking", text: line })
98
+ }
99
+ }
100
+
82
101
  const text = getTextFromParts(msg.parts)
83
102
  if (text) {
84
103
  try {
@@ -107,6 +126,95 @@ function buildDisplayLines(messages: ConversationMessage[]): DisplayLine[] {
107
126
  return lines
108
127
  }
109
128
 
129
+ // ─── Sidebar component ────────────────────────────────────────────────────────
130
+
131
+ const SIDEBAR_WIDTH = 26
132
+
133
+ function Sidebar({
134
+ instances,
135
+ currentSessionId,
136
+ cursorIndex,
137
+ focused,
138
+ }: {
139
+ instances: import("../store.js").OcmInstance[]
140
+ currentSessionId: string | null
141
+ cursorIndex: number
142
+ focused: boolean
143
+ }) {
144
+ // Inner width: total - 2 border chars
145
+ const innerWidth = SIDEBAR_WIDTH - 2
146
+ // Max chars for repo/title after cursor(1) + space(1) + icon(1) + space(1) = 4
147
+ const maxLabelWidth = innerWidth - 4
148
+
149
+ return (
150
+ <Box
151
+ flexDirection="column"
152
+ width={SIDEBAR_WIDTH}
153
+ flexShrink={0}
154
+ borderStyle="single"
155
+ borderColor={focused ? "cyan" : "gray"}
156
+ flexGrow={1}
157
+ overflow="hidden"
158
+ >
159
+ {/* Header */}
160
+ <Box paddingX={1} justifyContent="space-between">
161
+ <Text bold color={focused ? "cyan" : "gray"}>sessions</Text>
162
+ <Text dimColor>{instances.length}</Text>
163
+ </Box>
164
+ <Text dimColor>{"─".repeat(innerWidth)}</Text>
165
+
166
+ {/* Instance list */}
167
+ {instances.length === 0 && (
168
+ <Box paddingX={1}>
169
+ <Text dimColor>no instances</Text>
170
+ </Box>
171
+ )}
172
+ {instances.map((inst, i) => {
173
+ const isCurrent = inst.sessionId === currentSessionId
174
+ const isCursor = focused && i === cursorIndex
175
+ const { char, color } = statusIcon(inst.status)
176
+
177
+ // Compact single-line: "▸ ▶ repo/title"
178
+ const sep = "/"
179
+ const maxTotal = maxLabelWidth
180
+ const repoMax = Math.min(inst.repoName.length, Math.floor(maxTotal * 0.4))
181
+ const repo = inst.repoName.length > repoMax
182
+ ? inst.repoName.slice(0, repoMax - 1) + "…"
183
+ : inst.repoName
184
+ const titleMax = maxTotal - repo.length - sep.length
185
+ const title = inst.sessionTitle.length > titleMax
186
+ ? inst.sessionTitle.slice(0, Math.max(0, titleMax - 1)) + "…"
187
+ : inst.sessionTitle
188
+
189
+ return (
190
+ <Box key={inst.id} paddingLeft={1}>
191
+ <Text color={isCursor ? "cyan" : isCurrent ? "white" : "gray"}>
192
+ {isCursor ? "▸" : isCurrent ? "◆" : " "}
193
+ </Text>
194
+ <Text>{" "}</Text>
195
+ <Text color={color}>{char}</Text>
196
+ <Text>{" "}</Text>
197
+ <Text
198
+ bold={isCurrent || isCursor}
199
+ color={isCursor ? "cyan" : isCurrent ? "white" : undefined}
200
+ dimColor={!isCurrent && !isCursor}
201
+ >
202
+ {repo}
203
+ </Text>
204
+ <Text dimColor>{sep}</Text>
205
+ <Text
206
+ dimColor={!isCurrent && !isCursor}
207
+ color={isCursor ? "cyan" : undefined}
208
+ >
209
+ {title}
210
+ </Text>
211
+ </Box>
212
+ )
213
+ })}
214
+ </Box>
215
+ )
216
+ }
217
+
110
218
  // ─── Conversation component ───────────────────────────────────────────────────
111
219
 
112
220
  export function Conversation() {
@@ -125,6 +233,24 @@ export function Conversation() {
125
233
  const [sendError, setSendError] = React.useState<string | null>(null)
126
234
  // vim modal: "normal" (navigate) or "insert" (type into input)
127
235
  const [mode, setMode] = React.useState<"normal" | "insert">("normal")
236
+ // Pane focus: "conversation" (default) or "sidebar"
237
+ const [focus, setFocus] = React.useState<"conversation" | "sidebar">("conversation")
238
+ // Auto-spawned serve process port (for non-OCMux sessions)
239
+ const [autoSpawnedPort, setAutoSpawnedPort] = React.useState<number | null>(null)
240
+ const [autoSpawning, setAutoSpawning] = React.useState(false)
241
+ // Loading state: covers the gap between promptAsync returning and SSE status arriving
242
+ const [waitingForResponse, setWaitingForResponse] = React.useState(false)
243
+ // Kill confirmation
244
+ const [killConfirm, setKillConfirm] = React.useState<import("../store.js").OcmInstance | null>(null)
245
+ // Help overlay
246
+ const [showHelp, setShowHelp] = React.useState(false)
247
+ // Tick counter to force sessionStatus re-read when SSE session events arrive
248
+ const [statusTick, setStatusTick] = React.useState(0)
249
+ // Sidebar cursor (index into instances array)
250
+ const [sidebarCursor, setSidebarCursor] = React.useState(0)
251
+ // Ctrl-W combo: track first Ctrl-W press (normal mode only)
252
+ const [pendingCtrlW, setPendingCtrlW] = React.useState(false)
253
+ const pendingCtrlWTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null)
128
254
 
129
255
  // Agent/model selection (live instances only)
130
256
  type AgentOption = { name: string; model?: { providerID: string; modelID: string } }
@@ -199,7 +325,21 @@ export function Conversation() {
199
325
  if (instance) return instance.status
200
326
  if (!selectedSessionId) return "idle" as const
201
327
  return getSessionStatus(selectedSessionId)
202
- }, [instance, selectedSessionId])
328
+ }, [instance, selectedSessionId, statusTick])
329
+
330
+ // Clear waitingForResponse once session status catches up
331
+ React.useEffect(() => {
332
+ if (sessionStatus !== "idle") setWaitingForResponse(false)
333
+ }, [sessionStatus])
334
+
335
+ // Spinner animation for working state
336
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
337
+ const [spinnerIdx, setSpinnerIdx] = React.useState(0)
338
+ React.useEffect(() => {
339
+ if (sessionStatus !== "working" && !waitingForResponse) return
340
+ const id = setInterval(() => setSpinnerIdx((i) => (i + 1) % SPINNER_FRAMES.length), 80)
341
+ return () => clearInterval(id)
342
+ }, [sessionStatus, waitingForResponse])
203
343
 
204
344
  const sessionTitle = instance?.sessionTitle ?? sessionInfo?.title ?? selectedSessionId?.slice(0, 20) ?? "session"
205
345
  const repoName = instance?.repoName ?? ""
@@ -207,13 +347,16 @@ export function Conversation() {
207
347
  const model = instance?.model ?? null
208
348
 
209
349
  // Determine if this is an SDK-capable live instance
210
- const isLive = !!(instance?.port)
211
- const instancePort = instance?.port ?? null
350
+ const isLive = !!(instance?.port || autoSpawnedPort)
351
+ const instancePort = instance?.port ?? autoSpawnedPort
212
352
 
213
353
  // Fetch agents + models from SDK (live) or read agent from SQLite (read-only)
214
354
  React.useEffect(() => {
215
355
  if (!selectedSessionId) return
216
356
 
357
+ // Always read agent from SQLite as fallback (available before SDK loads)
358
+ setReadOnlyAgent(getSessionAgent(selectedSessionId))
359
+
217
360
  if (instancePort) {
218
361
  const client = createOpencodeClient({ baseUrl: `http://localhost:${instancePort}` })
219
362
 
@@ -237,15 +380,110 @@ export function Conversation() {
237
380
  }
238
381
  setAvailableModels(models)
239
382
  }).catch(() => {})
240
- } else {
241
- setReadOnlyAgent(getSessionAgent(selectedSessionId))
242
383
  }
243
384
  }, [selectedSessionId, instancePort])
244
385
 
386
+ // Subscribe to SSE events + always-on 1s polling as safety net
387
+ React.useEffect(() => {
388
+ if (!instancePort || !selectedSessionId) return
389
+ const sessionId = selectedSessionId
390
+
391
+ let cancelled = false
392
+
393
+ // Always-on polling — catches SSE stalls, external TUI writes, and status changes
394
+ const pollInterval = setInterval(() => {
395
+ if (cancelled) return
396
+ try {
397
+ const dbMessages = getMessages(sessionId)
398
+ setMessages(dbMessages as ConversationMessage[])
399
+ } catch {}
400
+ setStatusTick((t) => t + 1)
401
+ }, config.conversationPollIntervalMs)
402
+
403
+ // SSE for real-time updates (best-effort, faster than polling)
404
+ const client = createOpencodeClient({ baseUrl: `http://localhost:${instancePort}` })
405
+
406
+ async function listen() {
407
+ try {
408
+ const { stream } = await (client as any).event.subscribe()
409
+ for await (const event of stream) {
410
+ if (cancelled) break
411
+
412
+ const type = event?.type
413
+ const props = event?.properties
414
+
415
+ // Filter to events relevant to our session
416
+ const eventSessionId =
417
+ props?.info?.sessionID ??
418
+ props?.sessionID ??
419
+ null
420
+
421
+ if (eventSessionId && eventSessionId !== sessionId) continue
422
+
423
+ if (
424
+ type === "message.updated" ||
425
+ type === "message.part.updated" ||
426
+ type === "message.part.delta" ||
427
+ type === "message.removed"
428
+ ) {
429
+ try {
430
+ const dbMessages = getMessages(sessionId)
431
+ setMessages(dbMessages as ConversationMessage[])
432
+ } catch {}
433
+ }
434
+
435
+ if (type === "session.status" || type === "session.idle") {
436
+ setStatusTick((t) => t + 1)
437
+ }
438
+ }
439
+ } catch {
440
+ // SSE failed — polling is already running as safety net
441
+ }
442
+ }
443
+
444
+ listen()
445
+
446
+ return () => {
447
+ cancelled = true
448
+ clearInterval(pollInterval)
449
+ }
450
+ }, [instancePort, selectedSessionId]) // setMessages intentionally omitted — stable Zustand fn
451
+
452
+ // Auto-spawn a serve process for non-live instances so we can chat via SDK
453
+ React.useEffect(() => {
454
+ if (instance?.port || !selectedSessionId) return // already has a port
455
+
456
+ let cancelled = false
457
+
458
+ async function spawn() {
459
+ try {
460
+ setAutoSpawning(true)
461
+ const cwd = sessionCwd
462
+ if (!cwd) return
463
+ const port = await ensureServeProcess(cwd)
464
+ if (cancelled) return
465
+ setAutoSpawnedPort(port)
466
+ } catch (e) {
467
+ // Failed to spawn — instance stays read-only
468
+ } finally {
469
+ if (!cancelled) setAutoSpawning(false)
470
+ }
471
+ }
472
+
473
+ spawn()
474
+
475
+ return () => { cancelled = true }
476
+ }, [instance?.port, selectedSessionId, sessionCwd])
477
+
478
+ // Reset auto-spawned port when session changes (new session may need different serve)
479
+ React.useEffect(() => {
480
+ setAutoSpawnedPort(null)
481
+ }, [selectedSessionId])
482
+
245
483
  const openInOpencode = React.useCallback(() => {
246
484
  if (!selectedSessionId) return
247
- yieldToOpencode(selectedSessionId, sessionCwd)
248
- }, [selectedSessionId, sessionCwd])
485
+ yieldToOpencode(selectedSessionId, sessionCwd, instancePort)
486
+ }, [selectedSessionId, sessionCwd, instancePort])
249
487
 
250
488
  // Computed current agent and model
251
489
  const currentAgent = availableAgents[selectedAgentIdx]
@@ -264,11 +502,12 @@ export function Conversation() {
264
502
  // Send message via SDK
265
503
  const sendMessage = React.useCallback(async (text: string) => {
266
504
  if (!text.trim() || !selectedSessionId || !instancePort) return
505
+ const sessionId = selectedSessionId
267
506
  setSending(true)
268
507
  setSendError(null)
269
508
  try {
270
509
  const client = createOpencodeClient({ baseUrl: `http://localhost:${instancePort}` })
271
- await (client.session as any).prompt({
510
+ await (client.session as any).promptAsync({
272
511
  path: { id: selectedSessionId },
273
512
  body: {
274
513
  parts: [{ type: "text", text: text.trim() }],
@@ -277,8 +516,12 @@ export function Conversation() {
277
516
  },
278
517
  })
279
518
  setInputText("")
280
- const dbMessages = getMessages(selectedSessionId)
281
- setMessages(dbMessages as ConversationMessage[])
519
+ setWaitingForResponse(true)
520
+ // Show the user's message immediately (SSE will handle assistant updates)
521
+ try {
522
+ const dbMessages = getMessages(sessionId)
523
+ setMessages(dbMessages as ConversationMessage[])
524
+ } catch {}
282
525
  } catch (e) {
283
526
  setSendError(String(e))
284
527
  } finally {
@@ -295,10 +538,11 @@ export function Conversation() {
295
538
  const displayLines = React.useMemo(() => buildDisplayLines(messages), [messages])
296
539
  const totalLines = displayLines.length
297
540
 
298
- // Layout live instances need 2 extra rows for the input box
299
- const HEADER_ROWS = 3
300
- const FOOTER_ROWS = isLive ? 5 : 3
301
- const msgAreaHeight = Math.max(5, termHeight - HEADER_ROWS - FOOTER_ROWS)
541
+ // Estimate visible message lines for scroll/slice calculations.
542
+ // Layout is flexbox-driven; this only controls how many lines we render.
543
+ // Err on the side of too many — overflow="hidden" clips any excess.
544
+ const INNER_OVERHEAD = isLive ? 2 : 0 // input divider + input line (live only)
545
+ const msgAreaHeight = Math.max(5, termHeight - INNER_OVERHEAD)
302
546
  const maxScroll = Math.max(0, totalLines - msgAreaHeight)
303
547
  const halfPage = Math.max(1, Math.floor(msgAreaHeight / 2))
304
548
  const fullPage = Math.max(1, msgAreaHeight - 2)
@@ -309,7 +553,20 @@ export function Conversation() {
309
553
  setScrollOffset((o) => clampScroll(o + delta))
310
554
  }, [maxScroll])
311
555
 
312
- // Visible lines
556
+ const prevMessageCount = React.useRef(messages.length)
557
+ React.useEffect(() => {
558
+ if (messages.length > prevMessageCount.current && scrollOffset <= 2) {
559
+ setScrollOffset(0)
560
+ }
561
+ prevMessageCount.current = messages.length
562
+ }, [messages.length, scrollOffset])
563
+
564
+ // Keep sidebar cursor in bounds when instances change
565
+ React.useEffect(() => {
566
+ setSidebarCursor((c) => Math.min(c, Math.max(0, instances.length - 1)))
567
+ }, [instances.length])
568
+
569
+ // Visible lines — no padding needed, flexbox + overflow="hidden" handles layout
313
570
  const startIdx = Math.max(0, totalLines - msgAreaHeight - scrollOffset)
314
571
  const endIdx = Math.max(0, totalLines - scrollOffset)
315
572
  const visibleLines = displayLines.slice(startIdx, endIdx)
@@ -360,6 +617,129 @@ export function Conversation() {
360
617
 
361
618
  // ── NORMAL MODE ──────────────────────────────────────────────────────────
362
619
 
620
+ // Kill confirmation: y to confirm, n/Esc to cancel (captures all keys)
621
+ if (killConfirm) {
622
+ if (input === "y") {
623
+ const killed = killConfirm
624
+ setKillConfirm(null)
625
+ killInstance(killed.worktree, killed.sessionId)
626
+ refreshNow()
627
+ const remaining = instances.filter((i) => i.sessionId !== killed.sessionId)
628
+ if (remaining.length > 0) {
629
+ const next = remaining[0]!
630
+ navigate("conversation", next.projectId, next.sessionId)
631
+ } else {
632
+ navigate("dashboard")
633
+ }
634
+ } else if (input === "n" || key.escape) {
635
+ setKillConfirm(null)
636
+ }
637
+ return
638
+ }
639
+
640
+ // Help overlay: any key closes it
641
+ if (showHelp) {
642
+ setShowHelp(false)
643
+ return
644
+ }
645
+
646
+ // ?: toggle help overlay
647
+ if (input === "?" && key.shift) {
648
+ setShowHelp(true)
649
+ return
650
+ }
651
+
652
+ // x: kill session (context-dependent: sidebar cursor or current session)
653
+ if (input === "x") {
654
+ if (focus === "sidebar") {
655
+ const target = instances[sidebarCursor]
656
+ if (target) setKillConfirm(target)
657
+ } else {
658
+ if (instance) setKillConfirm(instance)
659
+ }
660
+ return
661
+ }
662
+
663
+ // Ctrl-W Ctrl-W: toggle focus between sidebar and conversation
664
+ if (key.ctrl && input === "w") {
665
+ if (pendingCtrlW) {
666
+ if (pendingCtrlWTimer.current) clearTimeout(pendingCtrlWTimer.current)
667
+ setPendingCtrlW(false)
668
+ setFocus((f) => f === "sidebar" ? "conversation" : "sidebar")
669
+ } else {
670
+ setPendingCtrlW(true)
671
+ pendingCtrlWTimer.current = setTimeout(() => setPendingCtrlW(false), 500)
672
+ }
673
+ return
674
+ }
675
+ // Clear pending Ctrl-W on any other key
676
+ if (pendingCtrlW) {
677
+ if (pendingCtrlWTimer.current) clearTimeout(pendingCtrlWTimer.current)
678
+ setPendingCtrlW(false)
679
+ }
680
+
681
+ // ── SIDEBAR FOCUSED ───────────────────────────────────────────────────────
682
+ if (focus === "sidebar") {
683
+ if (input === "j" || key.downArrow) {
684
+ const maxIdx = Math.max(0, instances.length - 1)
685
+ setSidebarCursor((c) => Math.min(c + 1, maxIdx))
686
+ return
687
+ }
688
+ if (input === "k" || key.upArrow) {
689
+ setSidebarCursor((c) => Math.max(c - 1, 0))
690
+ return
691
+ }
692
+ if (key.return) {
693
+ const target = instances[sidebarCursor]
694
+ if (target && target.sessionId !== selectedSessionId) {
695
+ navigate("conversation", target.projectId, target.sessionId)
696
+ }
697
+ // Switch focus back to conversation pane regardless
698
+ setFocus("conversation")
699
+ return
700
+ }
701
+ if (input === "q" || key.escape) {
702
+ navigate("dashboard")
703
+ return
704
+ }
705
+ // When sidebar is focused, block all other keys
706
+ return
707
+ }
708
+
709
+ // ── CONVERSATION FOCUSED — session management keys ────────────────────
710
+
711
+ // Ctrl-N: jump to next needs-input session (must be before 'n' check)
712
+ if (key.ctrl && input === "n") {
713
+ const needsInput = instances.filter((i) => i.status === "needs-input")
714
+ if (needsInput.length > 0) {
715
+ const currentIdx = instances.findIndex((i) => i.sessionId === selectedSessionId)
716
+ const next = needsInput.find((_, j) => {
717
+ const idx = instances.indexOf(needsInput[j]!)
718
+ return idx > currentIdx
719
+ }) ?? needsInput[0]!
720
+ navigate("conversation", next.projectId, next.sessionId)
721
+ }
722
+ return
723
+ }
724
+
725
+ // n: spawn new session
726
+ if (input === "n") {
727
+ navigate("spawn")
728
+ return
729
+ }
730
+
731
+ // r: refresh instances and messages
732
+ if (input === "r") {
733
+ refreshNow()
734
+ if (selectedSessionId) {
735
+ try {
736
+ const dbMessages = getMessages(selectedSessionId)
737
+ setMessages(dbMessages as ConversationMessage[])
738
+ } catch {}
739
+ }
740
+ return
741
+ }
742
+
363
743
  // Tab: cycle agent (live only), resets model to agent's default
364
744
  if (key.tab && !key.shift && isLive && availableAgents.length > 0) {
365
745
  setSelectedAgentIdx((prev) => (prev + 1) % availableAgents.length)
@@ -376,6 +756,7 @@ export function Conversation() {
376
756
  if (input === "i") {
377
757
  if (isLive) {
378
758
  setMode("insert")
759
+ setFocus("conversation")
379
760
  setScrollOffset(0) // auto-scroll to bottom
380
761
  } else {
381
762
  openInOpencode() // attach to TUI to reply
@@ -405,7 +786,7 @@ export function Conversation() {
405
786
  })
406
787
 
407
788
  // Normal mode keybindings — all disabled in insert mode
408
- useConversationKeys(mode === "normal" ? {
789
+ useConversationKeys(mode === "normal" && focus === "conversation" ? {
409
790
  onBack: () => navigate("dashboard"),
410
791
  onAttach: openInOpencode,
411
792
  onSend: isLive ? undefined : openInOpencode, // Enter attaches for read-only
@@ -427,131 +808,204 @@ export function Conversation() {
427
808
  return { char: "○", color: "white" }
428
809
  })()
429
810
 
430
- const divider = "─".repeat(termWidth)
811
+ const contentWidth = Math.max(1, termWidth - SIDEBAR_WIDTH - 1)
812
+ const fullDivider = "─".repeat(Math.max(1, termWidth))
813
+ const divider = "─".repeat(contentWidth)
814
+
431
815
 
432
816
  return (
433
- <Box flexDirection="column">
434
- {/* Header */}
817
+ <Box flexDirection="column" height={termHeight}>
818
+ {/* Header — full width, outside the sidebar/content row */}
435
819
  <Box paddingLeft={1} justifyContent="space-between">
436
820
  <Box>
437
- <Text bold color="cyan">{repoName}</Text>
821
+ <Text bold color={focus === "conversation" ? "cyan" : "gray"}>{repoName}</Text>
438
822
  <Text dimColor> / </Text>
439
- <Text bold>{sessionTitle}</Text>
823
+ <Text bold color={focus === "conversation" ? undefined : "gray"}>{sessionTitle}</Text>
440
824
  </Box>
441
825
  <Box>
442
826
  <Text color={statusInfo.color as any}>{statusInfo.char}</Text>
443
- {/* Agent indicator */}
444
- {isLive && currentAgent && (
827
+ {currentAgent ? (
445
828
  <Text color="yellow" dimColor> [{currentAgent.name}]</Text>
446
- )}
447
- {!isLive && readOnlyAgent && (
829
+ ) : readOnlyAgent ? (
448
830
  <Text color="yellow" dimColor> [{readOnlyAgent}]</Text>
449
- )}
450
- {/* Model indicator: current model override or agent default or dashboard model */}
451
- {isLive && currentModel ? (
831
+ ) : null}
832
+ {currentModel ? (
452
833
  <Text color="cyan" dimColor> {currentModel.label}</Text>
453
834
  ) : model ? (
454
835
  <Text color="cyan" dimColor> {model}</Text>
455
836
  ) : null}
456
- <Text dimColor> {isLive ? (mode === "insert" ? "[INSERT]" : "[NORMAL]") : "[read-only]"} </Text>
837
+ {isLive && mode === "insert" && <Text bold color="green"> [INSERT] </Text>}
838
+ {isLive && mode === "normal" && <Text bold color="gray"> [NORMAL] </Text>}
839
+ {!isLive && <Text bold color="yellow"> [read-only] </Text>}
457
840
  <Text dimColor>{scrollIndicator}</Text>
458
841
  </Box>
459
842
  </Box>
460
- <Text dimColor>{divider}</Text>
461
-
462
- {/* Messages area */}
463
- {messagesLoading && (
464
- <Box paddingLeft={2} marginTop={1}>
465
- <Text dimColor>Loading messages...</Text>
466
- </Box>
467
- )}
468
- {!messagesLoading && messages.length === 0 && !error && (
469
- <Box paddingLeft={2} marginTop={1}>
470
- <Text dimColor>No messages in this session yet.</Text>
471
- </Box>
472
- )}
473
- {error && (
474
- <Box paddingLeft={2} marginTop={1}>
475
- <Text color="red">Error: {error}</Text>
476
- </Box>
477
- )}
478
-
479
- {visibleLines.map((line, i) => {
480
- if (line.kind === "spacer") {
481
- return <Box key={`sp-${i}`}><Text> </Text></Box>
482
- }
483
- if (line.kind === "role-header") {
484
- const isUser = line.role === "user"
485
- return (
486
- <Box key={`rh-${i}`} paddingLeft={1} marginTop={1}>
487
- <Text bold color={isUser ? "blue" : "magenta"}>
488
- {isUser ? "▶ YOU" : "◆ ASSISTANT"}
489
- </Text>
490
- <Text dimColor> {line.time}</Text>
491
- </Box>
492
- )
493
- }
494
- if (line.kind === "tool") {
495
- return (
496
- <Box key={`tool-${i}`} paddingLeft={4}>
497
- <Text color={line.color as any}>{line.icon} </Text>
498
- <Text dimColor>{line.name}</Text>
499
- {line.callId && <Text dimColor> {line.callId.slice(0, 20)}</Text>}
500
- </Box>
501
- )
502
- }
503
- return (
504
- <Box key={`txt-${i}`} paddingLeft={3}>
505
- <Text>{line.text}</Text>
506
- </Box>
507
- )
508
- })}
843
+ <Text color={focus === "conversation" ? "cyan" : "gray"} dimColor>{fullDivider}</Text>
844
+
845
+ {/* Body row: sidebar + message area side by side */}
846
+ <Box flexDirection="row" flexGrow={1}>
847
+ <Sidebar
848
+ instances={instances}
849
+ currentSessionId={selectedSessionId}
850
+ cursorIndex={sidebarCursor}
851
+ focused={focus === "sidebar"}
852
+ />
853
+
854
+ <Box flexDirection="column" flexGrow={1}>
855
+ {/* Messages area — fixed height, clips any text wrapping */}
856
+ <Box flexDirection="column" flexGrow={1} overflow="hidden" justifyContent="flex-end">
857
+ {messagesLoading && (
858
+ <Box paddingLeft={2}>
859
+ <Text dimColor>Loading messages...</Text>
860
+ </Box>
861
+ )}
862
+ {!messagesLoading && messages.length === 0 && !error && (
863
+ <Box paddingLeft={2}>
864
+ <Text dimColor>No messages in this session yet.</Text>
865
+ </Box>
866
+ )}
867
+ {error && (
868
+ <Box paddingLeft={2}>
869
+ <Text color="red">Error: {error}</Text>
870
+ </Box>
871
+ )}
509
872
 
510
- {/* Input area — only for live (SDK-capable) instances */}
511
- {isLive && (
512
- <>
513
- <Text dimColor>{divider}</Text>
514
- <Box paddingLeft={1}>
515
- {sending ? (
516
- <Text dimColor>Sending...</Text>
517
- ) : mode === "insert" ? (
518
- <Box>
519
- <Text color="cyan">❯ </Text>
520
- <TextInput
521
- value={inputText}
522
- onChange={(val) => {
523
- if (blockNextInputChange.current) {
524
- blockNextInputChange.current = false
525
- return // discard the 'x' that TextInput added from Ctrl-X
526
- }
527
- setInputText(val)
528
- }}
529
- onSubmit={(text) => { void sendMessage(text) }}
530
- placeholder="Type a message... (^X E: editor)"
531
- focus={!pendingCtrlX}
532
- />
873
+ {visibleLines.map((line, i) => {
874
+ if (line.kind === "spacer") {
875
+ return <Box key={`sp-${i}`}><Text> </Text></Box>
876
+ }
877
+ if (line.kind === "role-header") {
878
+ const isUser = line.role === "user"
879
+ return (
880
+ <Box key={`rh-${i}`} paddingLeft={1}>
881
+ <Text bold color={isUser ? "blue" : "magenta"}>
882
+ {isUser ? "▶ YOU" : "◆ ASSISTANT"}
883
+ </Text>
884
+ <Text dimColor> {line.time}</Text>
885
+ </Box>
886
+ )
887
+ }
888
+ if (line.kind === "thinking") {
889
+ return (
890
+ <Box key={`th-${i}`} paddingLeft={4}>
891
+ <Text dimColor color="yellow" wrap="truncate">{line.text}</Text>
892
+ </Box>
893
+ )
894
+ }
895
+ if (line.kind === "tool") {
896
+ return (
897
+ <Box key={`tool-${i}`} paddingLeft={4}>
898
+ <Text color={line.color as any}>{line.icon} </Text>
899
+ <Text dimColor wrap="truncate">{line.name}</Text>
900
+ {line.callId && <Text dimColor> {line.callId.slice(0, 20)}</Text>}
901
+ </Box>
902
+ )
903
+ }
904
+ return (
905
+ <Box key={`txt-${i}`} paddingLeft={3}>
906
+ <Text wrap="truncate">{line.text}</Text>
907
+ </Box>
908
+ )
909
+ })}
910
+ {(sessionStatus === "working" || waitingForResponse) && scrollOffset === 0 && (
911
+ <Box paddingLeft={3}>
912
+ <Text color="green">{SPINNER_FRAMES[spinnerIdx]}</Text>
533
913
  </Box>
534
- ) : (
535
- <Text dimColor>○ Press <Text color="cyan" bold>i</Text> to type a message</Text>
536
914
  )}
537
915
  </Box>
538
- {sendError && (
539
- <Box paddingLeft={1}>
540
- <Text color="red">{sendError}</Text>
541
- </Box>
916
+
917
+ {/* Input area — pinned below messages, position independent of scroll */}
918
+ {isLive && (
919
+ <>
920
+ <Text dimColor>{divider}</Text>
921
+ <Box paddingLeft={1}>
922
+ {sending ? (
923
+ <Text dimColor>Sending...</Text>
924
+ ) : mode === "insert" ? (
925
+ <Box>
926
+ <Text color={focus === "conversation" ? "cyan" : "gray"}>❯ </Text>
927
+ <TextInput
928
+ value={inputText}
929
+ onChange={(val) => {
930
+ if (blockNextInputChange.current) {
931
+ blockNextInputChange.current = false
932
+ return // discard the 'x' that TextInput added from Ctrl-X
933
+ }
934
+ setInputText(val)
935
+ }}
936
+ onSubmit={(text) => { void sendMessage(text) }}
937
+ placeholder="Type a message... (^X E: editor)"
938
+ focus={!pendingCtrlX}
939
+ />
940
+ </Box>
941
+ ) : (
942
+ <Text dimColor>› Press <Text color={focus === "conversation" ? "cyan" : "gray"} bold>i</Text> to type a message</Text>
943
+ )}
944
+ </Box>
945
+ {sendError && (
946
+ <Box paddingLeft={1}>
947
+ <Text color="red">{sendError}</Text>
948
+ </Box>
949
+ )}
950
+ </>
951
+ )}
952
+ {!isLive && autoSpawning && (
953
+ <>
954
+ <Text dimColor>{divider}</Text>
955
+ <Box paddingLeft={1}>
956
+ <Text dimColor>Starting background server...</Text>
957
+ </Box>
958
+ </>
542
959
  )}
543
- </>
960
+ </Box>
961
+ </Box>
962
+
963
+ {/* Help overlay */}
964
+ {showHelp && (
965
+ <Box flexDirection="column" paddingX={2} paddingY={0} borderStyle="round" borderColor="cyan">
966
+ <Box><Text bold color="cyan">Conversation Keybindings</Text></Box>
967
+ <Box flexDirection="column" paddingLeft={2}>
968
+ <Box><Box width={16}><Text bold color="white">i</Text></Box><Text dimColor>insert mode (type message)</Text></Box>
969
+ <Box><Box width={16}><Text bold color="white">Esc</Text></Box><Text dimColor>normal mode</Text></Box>
970
+ <Box><Box width={16}><Text bold color="white">^W ^W</Text></Box><Text dimColor>toggle sidebar focus</Text></Box>
971
+ <Box><Box width={16}><Text bold color="white">j/k</Text></Box><Text dimColor>scroll messages</Text></Box>
972
+ <Box><Box width={16}><Text bold color="white">^U/^D</Text></Box><Text dimColor>half page up/down</Text></Box>
973
+ <Box><Box width={16}><Text bold color="white">G/gg</Text></Box><Text dimColor>scroll to bottom/top</Text></Box>
974
+ <Box><Box width={16}><Text bold color="white">Tab/S-Tab</Text></Box><Text dimColor>cycle agent/model</Text></Box>
975
+ <Box><Box width={16}><Text bold color="white">a</Text></Box><Text dimColor>attach opencode TUI</Text></Box>
976
+ <Box><Box width={16}><Text bold color="white">n</Text></Box><Text dimColor>spawn new session</Text></Box>
977
+ <Box><Box width={16}><Text bold color="white">x</Text></Box><Text dimColor>kill session</Text></Box>
978
+ <Box><Box width={16}><Text bold color="white">r</Text></Box><Text dimColor>refresh</Text></Box>
979
+ <Box><Box width={16}><Text bold color="white">Ctrl-N</Text></Box><Text dimColor>next needs-input session</Text></Box>
980
+ <Box><Box width={16}><Text bold color="white">q</Text></Box><Text dimColor>back to dashboard</Text></Box>
981
+ </Box>
982
+ <Text dimColor>Press any key to close</Text>
983
+ </Box>
984
+ )}
985
+
986
+ {/* Kill confirmation */}
987
+ {killConfirm && (
988
+ <Box paddingX={1} paddingY={0} borderStyle="single" borderColor="red">
989
+ <Text color="red">Kill </Text>
990
+ <Text bold color="red">{killConfirm.repoName} / {killConfirm.sessionTitle.slice(0, 30)}</Text>
991
+ <Text color="red">? </Text>
992
+ <Text bold color="white">y</Text>
993
+ <Text dimColor> confirm </Text>
994
+ <Text bold color="white">n</Text>
995
+ <Text dimColor>/</Text>
996
+ <Text bold color="white">Esc</Text>
997
+ <Text dimColor> cancel</Text>
998
+ </Box>
544
999
  )}
545
1000
 
546
- {/* Footer */}
547
- <Text dimColor>{divider}</Text>
548
- <Box paddingLeft={1}>
1001
+ {/* Footer — full width, outside the sidebar/content row */}
1002
+ <Box paddingX={1} paddingY={0} borderStyle="single" borderColor="gray">
549
1003
  <Text dimColor wrap="truncate">
550
1004
  {isLive && mode === "insert"
551
- ? `Esc: normal mode Enter: send ^XE: editor [INSERT] [${scrollIndicator}]`
1005
+ ? `Esc: normal Enter: send ^XE: editor`
552
1006
  : isLive
553
- ? `q: back i: insert Tab: agent S-Tab: model a: attach j/k: scroll [NORMAL] [${scrollIndicator}]`
554
- : `q: back a/i/Enter: open in opencode to reply j/k: scroll ^U/^D: ½ page G/gg: nav [${scrollIndicator}]`
1007
+ ? `q: back i: insert ^W^W: sidebar Tab: agent a: attach j/k: scroll ? help`
1008
+ : `q: back a/i/Enter: attach j/k: scroll ^U/^D: ½pg G/gg: nav`
555
1009
  }
556
1010
  </Text>
557
1011
  </Box>
@@ -11,6 +11,7 @@ import { yieldToOpencode } from "../hooks/use-attach.js"
11
11
  import { config } from "../config.js"
12
12
  import { refreshNow, shortenModel } from "../poller.js"
13
13
  import { killInstance } from "../registry/instances.js"
14
+ import { statusIcon } from "./helpers.js"
14
15
  import {
15
16
  getChildSessions,
16
17
  countChildSessions,
@@ -29,15 +30,6 @@ const STATUS_ORDER: Record<SessionStatus, number> = {
29
30
  idle: 3,
30
31
  }
31
32
 
32
- function statusIcon(status: SessionStatus): { char: string; color: string } {
33
- switch (status) {
34
- case "working": return { char: "▶", color: "green" }
35
- case "needs-input": return { char: "●", color: "yellow" }
36
- case "idle": return { char: "✔", color: "gray" }
37
- case "error": return { char: "✖", color: "red" }
38
- }
39
- }
40
-
41
33
  // ─── Child session builder ────────────────────────────────────────────────────
42
34
 
43
35
  function buildChildOcmSession(c: { id: string; projectId: string; title: string; directory: string; timeUpdated: number }): OcmSession {
@@ -317,7 +309,7 @@ export function Dashboard() {
317
309
  onAttach: () => {
318
310
  if (!currentRow) return
319
311
  if (currentRow.kind === "instance") {
320
- yieldToOpencode(currentRow.instance.sessionId, currentRow.instance.worktree)
312
+ yieldToOpencode(currentRow.instance.sessionId, currentRow.instance.worktree, currentRow.instance.port)
321
313
  } else if (currentRow.kind === "child") {
322
314
  yieldToOpencode(currentRow.session.id, currentRow.session.directory)
323
315
  }
@@ -370,7 +362,7 @@ export function Dashboard() {
370
362
  const kb = config.keybindings.dashboard
371
363
 
372
364
  // ASCII logo — only show when terminal is tall enough (≥15 rows)
373
- const showLogo = (stdout?.rows ?? 24) >= 15
365
+ const showLogo = (stdout?.rows ?? 24) >= 20
374
366
  const LOGO = [
375
367
  " █▀▀█ █▀▀▀ █▄▀▄█ █ █ ▄ ▄",
376
368
  " █ █ █___ █ ▀ █ █__█ _▀▀_",
@@ -472,7 +464,7 @@ export function Dashboard() {
472
464
  <Text dimColor>{expandChar}</Text>
473
465
  <Text bold={isCursor}>{truncLabel}</Text>
474
466
  {model && <Text color="cyan" dimColor> {model}</Text>}
475
- <Text dimColor> {truncPreview}</Text>
467
+ <Text dimColor wrap="truncate"> {truncPreview}</Text>
476
468
  </Box>
477
469
  )
478
470
  }
@@ -0,0 +1,10 @@
1
+ import type { SessionStatus } from "../store.js"
2
+
3
+ export function statusIcon(status: SessionStatus): { char: string; color: string } {
4
+ switch (status) {
5
+ case "working": return { char: "▶", color: "green" }
6
+ case "needs-input": return { char: "●", color: "yellow" }
7
+ case "idle": return { char: "✔", color: "gray" }
8
+ case "error": return { char: "✖", color: "red" }
9
+ }
10
+ }
@@ -7,7 +7,6 @@ import { createOpencodeClient } from "@opencode-ai/sdk"
7
7
  import { useStore } from "../store.js"
8
8
  import { useSpawnKeys } from "../hooks/use-keybindings.js"
9
9
  import { refreshNow } from "../poller.js"
10
- import { yieldToOpencode } from "../hooks/use-attach.js"
11
10
  import {
12
11
  findNextPort,
13
12
  waitForServer,
@@ -111,12 +110,20 @@ export function Spawn() {
111
110
  // 6. Refresh so the dashboard knows about the new instance
112
111
  refreshNow()
113
112
 
114
- // 7. Immediately attach to the new session
113
+ // 7. Navigate to conversation view for the new session
115
114
  if (sessionId) {
116
- yieldToOpencode(sessionId, expanded)
115
+ const projectId = useStore.getState().instances.find(
116
+ (i) => i.sessionId === sessionId
117
+ )?.projectId ?? null
118
+
119
+ if (projectId) {
120
+ navigate("conversation", projectId, sessionId)
121
+ } else {
122
+ navigate("dashboard")
123
+ }
124
+ } else {
125
+ navigate("dashboard")
117
126
  }
118
-
119
- navigate("dashboard")
120
127
  } catch (e) {
121
128
  setErrorMsg(String(e))
122
129
  setStatus("error")