opencode-multiplexer 0.2.1 → 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/README.md +5 -0
- package/package.json +3 -2
- package/src/config.ts +12 -0
- package/src/db/reader.ts +13 -1
- package/src/hooks/use-attach.ts +5 -2
- package/src/registry/instances.ts +45 -1
- package/src/views/conversation.tsx +570 -116
- package/src/views/dashboard.tsx +4 -12
- package/src/views/helpers.ts +10 -0
- package/src/views/spawn.tsx +12 -5
package/README.md
CHANGED
|
@@ -43,8 +43,13 @@ When you're juggling several repositories at once, OCMux removes the friction of
|
|
|
43
43
|
### Install from npm
|
|
44
44
|
|
|
45
45
|
```bash
|
|
46
|
+
# install
|
|
46
47
|
npm install -g opencode-multiplexer
|
|
48
|
+
|
|
49
|
+
# to run, run
|
|
47
50
|
ocmux
|
|
51
|
+
# or
|
|
52
|
+
opencode-multiplexer
|
|
48
53
|
```
|
|
49
54
|
|
|
50
55
|
### Run from source
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-multiplexer",
|
|
3
|
-
"version": "0.
|
|
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"],
|
|
7
7
|
"bin": {
|
|
8
|
-
"ocmux": "./src/index.tsx"
|
|
8
|
+
"ocmux": "./src/index.tsx",
|
|
9
|
+
"opencode-multiplexer": "./src/index.tsx"
|
|
9
10
|
},
|
|
10
11
|
"scripts": {
|
|
11
12
|
"dev": "bun src/index.tsx",
|
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:
|
|
260
|
+
text: clean,
|
|
249
261
|
role: (row?.role ?? "user") as "user" | "assistant",
|
|
250
262
|
}
|
|
251
263
|
}
|
package/src/hooks/use-attach.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 ??
|
|
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).
|
|
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
|
-
|
|
281
|
-
|
|
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
|
-
//
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
|
|
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>{
|
|
461
|
-
|
|
462
|
-
{/*
|
|
463
|
-
|
|
464
|
-
<
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
-
<
|
|
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
|
|
1005
|
+
? `Esc: normal Enter: send ^XE: editor`
|
|
552
1006
|
: isLive
|
|
553
|
-
? `q: back i: insert
|
|
554
|
-
: `q: back a/i/Enter:
|
|
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>
|
package/src/views/dashboard.tsx
CHANGED
|
@@ -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) >=
|
|
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
|
+
}
|
package/src/views/spawn.tsx
CHANGED
|
@@ -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.
|
|
113
|
+
// 7. Navigate to conversation view for the new session
|
|
115
114
|
if (sessionId) {
|
|
116
|
-
|
|
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")
|