opencode-multiplexer 0.1.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/LICENSE +201 -0
- package/README.md +190 -0
- package/bun.lock +228 -0
- package/package.json +28 -0
- package/src/app.tsx +18 -0
- package/src/config.ts +118 -0
- package/src/db/reader.ts +459 -0
- package/src/hooks/use-attach.ts +144 -0
- package/src/hooks/use-keybindings.ts +103 -0
- package/src/hooks/use-vim-navigation.ts +43 -0
- package/src/index.tsx +52 -0
- package/src/poller.ts +270 -0
- package/src/registry/instances.ts +176 -0
- package/src/store.ts +159 -0
- package/src/types/marked-terminal.d.ts +10 -0
- package/src/views/conversation.tsx +560 -0
- package/src/views/dashboard.tsx +549 -0
- package/src/views/spawn.tsx +198 -0
- package/test/spike-attach.tsx +32 -0
- package/test/spike-chat.ts +67 -0
- package/test/spike-status.ts +33 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import { Box, Text, useInput, useStdout } from "ink"
|
|
3
|
+
import TextInput from "ink-text-input"
|
|
4
|
+
import { marked } from "marked"
|
|
5
|
+
import { markedTerminal } from "marked-terminal"
|
|
6
|
+
import { createOpencodeClient } from "@opencode-ai/sdk"
|
|
7
|
+
import { useStore, type ConversationMessage, type ConversationMessagePart } from "../store.js"
|
|
8
|
+
import { useConversationKeys } from "../hooks/use-keybindings.js"
|
|
9
|
+
import { yieldToOpencode, openInEditor, consumePendingEditorResult } from "../hooks/use-attach.js"
|
|
10
|
+
import { getMessages, getSessionById, getSessionStatus, getSessionAgent } from "../db/reader.js"
|
|
11
|
+
import { config } from "../config.js"
|
|
12
|
+
import { shortenModel } from "../poller.js"
|
|
13
|
+
|
|
14
|
+
// ─── Markdown setup ───────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
marked.use(markedTerminal({ reflowText: true }))
|
|
17
|
+
|
|
18
|
+
// ─── Inline markdown fix ──────────────────────────────────────────────────────
|
|
19
|
+
// marked-terminal has a known bug: inline formatting (**bold**, `code`, *italic*)
|
|
20
|
+
// is not applied inside list items. Post-process to catch remaining raw markers.
|
|
21
|
+
|
|
22
|
+
const BOLD_RE = /\*\*(.+?)\*\*/g
|
|
23
|
+
const ITALIC_RE = /(?<!\*)\*([^*\n]+)\*(?!\*)/g
|
|
24
|
+
const CODE_RE = /(?<!`)`([^`\n]+)`(?!`)/g
|
|
25
|
+
|
|
26
|
+
function fixInlineMarkdown(text: string): string {
|
|
27
|
+
return text
|
|
28
|
+
.replace(BOLD_RE, "\x1b[1m$1\x1b[22m")
|
|
29
|
+
.replace(ITALIC_RE, "\x1b[3m$1\x1b[23m")
|
|
30
|
+
.replace(CODE_RE, "\x1b[2m$1\x1b[22m")
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
function toolIcon(status: string | undefined): string {
|
|
36
|
+
switch (status) {
|
|
37
|
+
case "completed": return "✓"
|
|
38
|
+
case "running": return "⟳"
|
|
39
|
+
case "error": return "✗"
|
|
40
|
+
default: return "⏳"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function toolColor(status: string | undefined): string {
|
|
45
|
+
switch (status) {
|
|
46
|
+
case "completed": return "green"
|
|
47
|
+
case "running": return "yellow"
|
|
48
|
+
case "error": return "red"
|
|
49
|
+
default: return "gray"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function formatTime(ts: number): string {
|
|
54
|
+
return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getTextFromParts(parts: ConversationMessagePart[]): string {
|
|
58
|
+
return parts
|
|
59
|
+
.filter((p) => p.type === "text" && p.text && !p.text.trimStart().startsWith("<"))
|
|
60
|
+
.map((p) => p.text as string)
|
|
61
|
+
.join("")
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getToolParts(parts: ConversationMessagePart[]) {
|
|
65
|
+
return parts.filter((p) => p.type === "tool" && p.tool)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ─── Display line types ───────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
type DisplayLine =
|
|
71
|
+
| { kind: "role-header"; role: "user" | "assistant"; time: string }
|
|
72
|
+
| { kind: "text"; text: string }
|
|
73
|
+
| { kind: "tool"; icon: string; color: string; name: string; callId?: string }
|
|
74
|
+
| { kind: "spacer" }
|
|
75
|
+
|
|
76
|
+
function buildDisplayLines(messages: ConversationMessage[]): DisplayLine[] {
|
|
77
|
+
const lines: DisplayLine[] = []
|
|
78
|
+
|
|
79
|
+
for (const msg of messages) {
|
|
80
|
+
lines.push({ kind: "role-header", role: msg.role, time: formatTime(msg.timeCreated) })
|
|
81
|
+
|
|
82
|
+
const text = getTextFromParts(msg.parts)
|
|
83
|
+
if (text) {
|
|
84
|
+
try {
|
|
85
|
+
const rendered = fixInlineMarkdown((marked(text) as string).trimEnd())
|
|
86
|
+
for (const line of rendered.split("\n")) {
|
|
87
|
+
lines.push({ kind: "text", text: line })
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
lines.push({ kind: "text", text })
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for (const part of getToolParts(msg.parts)) {
|
|
95
|
+
lines.push({
|
|
96
|
+
kind: "tool",
|
|
97
|
+
icon: toolIcon(part.toolStatus),
|
|
98
|
+
color: toolColor(part.toolStatus),
|
|
99
|
+
name: part.tool as string,
|
|
100
|
+
callId: part.callId,
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
lines.push({ kind: "spacer" })
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return lines
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─── Conversation component ───────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
export function Conversation() {
|
|
113
|
+
const messages = useStore((s) => s.messages)
|
|
114
|
+
const messagesLoading = useStore((s) => s.messagesLoading)
|
|
115
|
+
const selectedSessionId = useStore((s) => s.selectedSessionId)
|
|
116
|
+
const instances = useStore((s) => s.instances)
|
|
117
|
+
const navigate = useStore((s) => s.navigate)
|
|
118
|
+
const setMessages = useStore((s) => s.setMessages)
|
|
119
|
+
const setMessagesLoading = useStore((s) => s.setMessagesLoading)
|
|
120
|
+
|
|
121
|
+
const [scrollOffset, setScrollOffset] = React.useState(0)
|
|
122
|
+
const [error, setError] = React.useState<string | null>(null)
|
|
123
|
+
const [inputText, setInputText] = React.useState("")
|
|
124
|
+
const [sending, setSending] = React.useState(false)
|
|
125
|
+
const [sendError, setSendError] = React.useState<string | null>(null)
|
|
126
|
+
// vim modal: "normal" (navigate) or "insert" (type into input)
|
|
127
|
+
const [mode, setMode] = React.useState<"normal" | "insert">("normal")
|
|
128
|
+
|
|
129
|
+
// Agent/model selection (live instances only)
|
|
130
|
+
type AgentOption = { name: string; model?: { providerID: string; modelID: string } }
|
|
131
|
+
type ModelOption = { providerID: string; modelID: string; label: string }
|
|
132
|
+
const [availableAgents, setAvailableAgents] = React.useState<AgentOption[]>([])
|
|
133
|
+
const [availableModels, setAvailableModels] = React.useState<ModelOption[]>([])
|
|
134
|
+
const [selectedAgentIdx, setSelectedAgentIdx] = React.useState(0)
|
|
135
|
+
const [modelOverrideIdx, setModelOverrideIdx] = React.useState<number | null>(null)
|
|
136
|
+
// For read-only instances: agent from last assistant message
|
|
137
|
+
const [readOnlyAgent, setReadOnlyAgent] = React.useState<string | null>(null)
|
|
138
|
+
// gg combo: track first 'g' press
|
|
139
|
+
const [pendingG, setPendingG] = React.useState(false)
|
|
140
|
+
const pendingGTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
141
|
+
// Ctrl-X E combo (only active in insert mode)
|
|
142
|
+
const [pendingCtrlX, setPendingCtrlX] = React.useState(false)
|
|
143
|
+
const pendingCtrlXTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
144
|
+
// Ref (not state) to block the onChange that TextInput fires for the Ctrl-X keystroke.
|
|
145
|
+
// Must be a ref because TextInput's onChange fires synchronously in the same tick —
|
|
146
|
+
// React batched state wouldn't be visible yet.
|
|
147
|
+
const blockNextInputChange = React.useRef(false)
|
|
148
|
+
|
|
149
|
+
const { stdout } = useStdout()
|
|
150
|
+
const termWidth = stdout?.columns ?? 80
|
|
151
|
+
const termHeight = stdout?.rows ?? 24
|
|
152
|
+
|
|
153
|
+
// Track whether this mount was triggered by the editor returning
|
|
154
|
+
const hadEditorResult = React.useRef(false)
|
|
155
|
+
|
|
156
|
+
// On mount: check for editor result from openInEditor.
|
|
157
|
+
// The pending result is set before remount, so it's available on first render.
|
|
158
|
+
React.useEffect(() => {
|
|
159
|
+
const pending = consumePendingEditorResult()
|
|
160
|
+
if (pending !== null) {
|
|
161
|
+
hadEditorResult.current = true
|
|
162
|
+
setInputText(pending)
|
|
163
|
+
setMode("insert")
|
|
164
|
+
}
|
|
165
|
+
}, []) // only fires on initial mount
|
|
166
|
+
|
|
167
|
+
// Load messages when session changes, reset state for fresh navigation
|
|
168
|
+
React.useEffect(() => {
|
|
169
|
+
if (!selectedSessionId) return
|
|
170
|
+
// Don't reset if we just returned from the editor — preserve the edited text
|
|
171
|
+
if (!hadEditorResult.current) {
|
|
172
|
+
setMode("normal")
|
|
173
|
+
setInputText("")
|
|
174
|
+
setSendError(null)
|
|
175
|
+
}
|
|
176
|
+
hadEditorResult.current = false
|
|
177
|
+
setMessagesLoading(true)
|
|
178
|
+
setMessages([])
|
|
179
|
+
setError(null)
|
|
180
|
+
setScrollOffset(0)
|
|
181
|
+
try {
|
|
182
|
+
const dbMessages = getMessages(selectedSessionId)
|
|
183
|
+
setMessages(dbMessages as ConversationMessage[])
|
|
184
|
+
} catch (e) {
|
|
185
|
+
setError(String(e))
|
|
186
|
+
} finally {
|
|
187
|
+
setMessagesLoading(false)
|
|
188
|
+
}
|
|
189
|
+
}, [selectedSessionId, setMessages, setMessagesLoading])
|
|
190
|
+
|
|
191
|
+
// Instance/session info
|
|
192
|
+
const instance = instances.find((i) => i.sessionId === selectedSessionId)
|
|
193
|
+
const sessionInfo = React.useMemo(() => {
|
|
194
|
+
if (!selectedSessionId) return null
|
|
195
|
+
return getSessionById(selectedSessionId)
|
|
196
|
+
}, [selectedSessionId])
|
|
197
|
+
|
|
198
|
+
const sessionStatus = React.useMemo(() => {
|
|
199
|
+
if (instance) return instance.status
|
|
200
|
+
if (!selectedSessionId) return "idle" as const
|
|
201
|
+
return getSessionStatus(selectedSessionId)
|
|
202
|
+
}, [instance, selectedSessionId])
|
|
203
|
+
|
|
204
|
+
const sessionTitle = instance?.sessionTitle ?? sessionInfo?.title ?? selectedSessionId?.slice(0, 20) ?? "session"
|
|
205
|
+
const repoName = instance?.repoName ?? ""
|
|
206
|
+
const sessionCwd = sessionInfo?.directory ?? instance?.worktree ?? process.cwd()
|
|
207
|
+
const model = instance?.model ?? null
|
|
208
|
+
|
|
209
|
+
// Determine if this is an SDK-capable live instance
|
|
210
|
+
const isLive = !!(instance?.port)
|
|
211
|
+
const instancePort = instance?.port ?? null
|
|
212
|
+
|
|
213
|
+
// Fetch agents + models from SDK (live) or read agent from SQLite (read-only)
|
|
214
|
+
React.useEffect(() => {
|
|
215
|
+
if (!selectedSessionId) return
|
|
216
|
+
|
|
217
|
+
if (instancePort) {
|
|
218
|
+
const client = createOpencodeClient({ baseUrl: `http://localhost:${instancePort}` })
|
|
219
|
+
|
|
220
|
+
;(client.app as any).agents().then((result: any) => {
|
|
221
|
+
const all = (result?.data ?? []) as Array<{ name: string; mode: string; model?: { providerID: string; modelID: string } }>
|
|
222
|
+
const primary = all.filter((a) => a.mode === "primary" || a.mode === "all")
|
|
223
|
+
setAvailableAgents(primary)
|
|
224
|
+
setSelectedAgentIdx(0)
|
|
225
|
+
setModelOverrideIdx(null)
|
|
226
|
+
}).catch(() => {})
|
|
227
|
+
|
|
228
|
+
;(client as any).provider.list().then((result: any) => {
|
|
229
|
+
const data = result?.data as any
|
|
230
|
+
const connected = new Set<string>(data?.connected ?? [])
|
|
231
|
+
const models: ModelOption[] = []
|
|
232
|
+
for (const provider of data?.all ?? []) {
|
|
233
|
+
if (!connected.has(provider.id)) continue
|
|
234
|
+
for (const modelId of Object.keys(provider.models ?? {})) {
|
|
235
|
+
models.push({ providerID: provider.id, modelID: modelId, label: shortenModel(modelId) })
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
setAvailableModels(models)
|
|
239
|
+
}).catch(() => {})
|
|
240
|
+
} else {
|
|
241
|
+
setReadOnlyAgent(getSessionAgent(selectedSessionId))
|
|
242
|
+
}
|
|
243
|
+
}, [selectedSessionId, instancePort])
|
|
244
|
+
|
|
245
|
+
const openInOpencode = React.useCallback(() => {
|
|
246
|
+
if (!selectedSessionId) return
|
|
247
|
+
yieldToOpencode(selectedSessionId, sessionCwd)
|
|
248
|
+
}, [selectedSessionId, sessionCwd])
|
|
249
|
+
|
|
250
|
+
// Computed current agent and model
|
|
251
|
+
const currentAgent = availableAgents[selectedAgentIdx]
|
|
252
|
+
const currentModel: ModelOption | null = React.useMemo(() => {
|
|
253
|
+
if (modelOverrideIdx !== null) return availableModels[modelOverrideIdx] ?? null
|
|
254
|
+
if (currentAgent?.model) {
|
|
255
|
+
return {
|
|
256
|
+
providerID: currentAgent.model.providerID,
|
|
257
|
+
modelID: currentAgent.model.modelID,
|
|
258
|
+
label: shortenModel(currentAgent.model.modelID),
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return null
|
|
262
|
+
}, [currentAgent, modelOverrideIdx, availableModels])
|
|
263
|
+
|
|
264
|
+
// Send message via SDK
|
|
265
|
+
const sendMessage = React.useCallback(async (text: string) => {
|
|
266
|
+
if (!text.trim() || !selectedSessionId || !instancePort) return
|
|
267
|
+
setSending(true)
|
|
268
|
+
setSendError(null)
|
|
269
|
+
try {
|
|
270
|
+
const client = createOpencodeClient({ baseUrl: `http://localhost:${instancePort}` })
|
|
271
|
+
await (client.session as any).prompt({
|
|
272
|
+
path: { id: selectedSessionId },
|
|
273
|
+
body: {
|
|
274
|
+
parts: [{ type: "text", text: text.trim() }],
|
|
275
|
+
...(currentAgent ? { agent: currentAgent.name } : {}),
|
|
276
|
+
...(currentModel ? { model: { providerID: currentModel.providerID, modelID: currentModel.modelID } } : {}),
|
|
277
|
+
},
|
|
278
|
+
})
|
|
279
|
+
setInputText("")
|
|
280
|
+
const dbMessages = getMessages(selectedSessionId)
|
|
281
|
+
setMessages(dbMessages as ConversationMessage[])
|
|
282
|
+
} catch (e) {
|
|
283
|
+
setSendError(String(e))
|
|
284
|
+
} finally {
|
|
285
|
+
setSending(false)
|
|
286
|
+
}
|
|
287
|
+
}, [selectedSessionId, instancePort, currentAgent, currentModel, setMessages])
|
|
288
|
+
|
|
289
|
+
// Open in $EDITOR (Ctrl-X E)
|
|
290
|
+
const handleEditorOpen = React.useCallback(() => {
|
|
291
|
+
openInEditor(inputText, (edited) => setInputText(edited))
|
|
292
|
+
}, [inputText])
|
|
293
|
+
|
|
294
|
+
// Build display lines
|
|
295
|
+
const displayLines = React.useMemo(() => buildDisplayLines(messages), [messages])
|
|
296
|
+
const totalLines = displayLines.length
|
|
297
|
+
|
|
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)
|
|
302
|
+
const maxScroll = Math.max(0, totalLines - msgAreaHeight)
|
|
303
|
+
const halfPage = Math.max(1, Math.floor(msgAreaHeight / 2))
|
|
304
|
+
const fullPage = Math.max(1, msgAreaHeight - 2)
|
|
305
|
+
|
|
306
|
+
const clampScroll = (v: number) => Math.max(0, Math.min(v, maxScroll))
|
|
307
|
+
|
|
308
|
+
const scrollBy = React.useCallback((delta: number) => {
|
|
309
|
+
setScrollOffset((o) => clampScroll(o + delta))
|
|
310
|
+
}, [maxScroll])
|
|
311
|
+
|
|
312
|
+
// Visible lines
|
|
313
|
+
const startIdx = Math.max(0, totalLines - msgAreaHeight - scrollOffset)
|
|
314
|
+
const endIdx = Math.max(0, totalLines - scrollOffset)
|
|
315
|
+
const visibleLines = displayLines.slice(startIdx, endIdx)
|
|
316
|
+
|
|
317
|
+
// Scroll position indicator
|
|
318
|
+
const scrollPct = totalLines <= msgAreaHeight
|
|
319
|
+
? 100
|
|
320
|
+
: Math.round(((totalLines - msgAreaHeight - scrollOffset) / Math.max(1, totalLines - msgAreaHeight)) * 100)
|
|
321
|
+
const scrollIndicator = totalLines <= msgAreaHeight
|
|
322
|
+
? "ALL"
|
|
323
|
+
: `${Math.max(0, Math.min(100, scrollPct))}%`
|
|
324
|
+
|
|
325
|
+
// Combined useInput: mode switching + gg + Ctrl-X E
|
|
326
|
+
useInput((input, key) => {
|
|
327
|
+
// ── INSERT MODE ──────────────────────────────────────────────────────────
|
|
328
|
+
if (mode === "insert") {
|
|
329
|
+
// Esc exits insert mode (does NOT go back to dashboard)
|
|
330
|
+
if (key.escape) {
|
|
331
|
+
setMode("normal")
|
|
332
|
+
// Clear pending combos
|
|
333
|
+
if (pendingCtrlXTimer.current) clearTimeout(pendingCtrlXTimer.current)
|
|
334
|
+
setPendingCtrlX(false)
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Ctrl-X E: open in editor (only from insert mode)
|
|
339
|
+
if (key.ctrl && input === "x") {
|
|
340
|
+
// Block synchronously — TextInput's onChange fires in the same tick
|
|
341
|
+
// before React can re-render with the updated pendingCtrlX state
|
|
342
|
+
blockNextInputChange.current = true
|
|
343
|
+
if (pendingCtrlXTimer.current) clearTimeout(pendingCtrlXTimer.current)
|
|
344
|
+
setPendingCtrlX(true)
|
|
345
|
+
pendingCtrlXTimer.current = setTimeout(() => setPendingCtrlX(false), 1000)
|
|
346
|
+
return
|
|
347
|
+
}
|
|
348
|
+
if (pendingCtrlX) {
|
|
349
|
+
if (pendingCtrlXTimer.current) clearTimeout(pendingCtrlXTimer.current)
|
|
350
|
+
setPendingCtrlX(false)
|
|
351
|
+
if ((input === "e" || input === "E") && !key.ctrl) {
|
|
352
|
+
handleEditorOpen()
|
|
353
|
+
}
|
|
354
|
+
return
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// All other keys in insert mode go to TextInput — don't intercept
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ── NORMAL MODE ──────────────────────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
// Tab: cycle agent (live only), resets model to agent's default
|
|
364
|
+
if (key.tab && !key.shift && isLive && availableAgents.length > 0) {
|
|
365
|
+
setSelectedAgentIdx((prev) => (prev + 1) % availableAgents.length)
|
|
366
|
+
setModelOverrideIdx(null)
|
|
367
|
+
return
|
|
368
|
+
}
|
|
369
|
+
// Shift-Tab: cycle model override (live only)
|
|
370
|
+
if (key.tab && key.shift && isLive && availableModels.length > 0) {
|
|
371
|
+
setModelOverrideIdx((prev) => ((prev ?? -1) + 1) % availableModels.length)
|
|
372
|
+
return
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// 'i': enter insert mode for live instances, or attach for read-only
|
|
376
|
+
if (input === "i") {
|
|
377
|
+
if (isLive) {
|
|
378
|
+
setMode("insert")
|
|
379
|
+
setScrollOffset(0) // auto-scroll to bottom
|
|
380
|
+
} else {
|
|
381
|
+
openInOpencode() // attach to TUI to reply
|
|
382
|
+
}
|
|
383
|
+
return
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// gg combo
|
|
387
|
+
if (key.escape || key.ctrl || key.return) {
|
|
388
|
+
if (pendingGTimer.current) clearTimeout(pendingGTimer.current)
|
|
389
|
+
setPendingG(false)
|
|
390
|
+
return
|
|
391
|
+
}
|
|
392
|
+
if (input === "g" && !key.shift) {
|
|
393
|
+
if (pendingG) {
|
|
394
|
+
if (pendingGTimer.current) clearTimeout(pendingGTimer.current)
|
|
395
|
+
setPendingG(false)
|
|
396
|
+
setScrollOffset(maxScroll)
|
|
397
|
+
} else {
|
|
398
|
+
setPendingG(true)
|
|
399
|
+
pendingGTimer.current = setTimeout(() => setPendingG(false), 500)
|
|
400
|
+
}
|
|
401
|
+
} else if (pendingG) {
|
|
402
|
+
if (pendingGTimer.current) clearTimeout(pendingGTimer.current)
|
|
403
|
+
setPendingG(false)
|
|
404
|
+
}
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
// Normal mode keybindings — all disabled in insert mode
|
|
408
|
+
useConversationKeys(mode === "normal" ? {
|
|
409
|
+
onBack: () => navigate("dashboard"),
|
|
410
|
+
onAttach: openInOpencode,
|
|
411
|
+
onSend: isLive ? undefined : openInOpencode, // Enter attaches for read-only
|
|
412
|
+
onScrollUp: () => scrollBy(1),
|
|
413
|
+
onScrollDown: () => scrollBy(-1),
|
|
414
|
+
onScrollHalfPageUp: () => scrollBy(halfPage),
|
|
415
|
+
onScrollHalfPageDown: () => scrollBy(-halfPage),
|
|
416
|
+
onScrollPageUp: () => scrollBy(fullPage),
|
|
417
|
+
onScrollPageDown: () => scrollBy(-fullPage),
|
|
418
|
+
onScrollBottom: () => setScrollOffset(0),
|
|
419
|
+
onScrollTop: () => setScrollOffset(maxScroll),
|
|
420
|
+
} : {})
|
|
421
|
+
|
|
422
|
+
// Status indicator
|
|
423
|
+
const statusInfo = (() => {
|
|
424
|
+
if (sessionStatus === "working") return { char: "●", color: "green" }
|
|
425
|
+
if (sessionStatus === "needs-input") return { char: "◐", color: "yellow" }
|
|
426
|
+
if (sessionStatus === "error") return { char: "✗", color: "red" }
|
|
427
|
+
return { char: "○", color: "white" }
|
|
428
|
+
})()
|
|
429
|
+
|
|
430
|
+
const divider = "─".repeat(termWidth)
|
|
431
|
+
|
|
432
|
+
return (
|
|
433
|
+
<Box flexDirection="column">
|
|
434
|
+
{/* Header */}
|
|
435
|
+
<Box paddingLeft={1} justifyContent="space-between">
|
|
436
|
+
<Box>
|
|
437
|
+
<Text bold color="cyan">{repoName}</Text>
|
|
438
|
+
<Text dimColor> / </Text>
|
|
439
|
+
<Text bold>{sessionTitle}</Text>
|
|
440
|
+
</Box>
|
|
441
|
+
<Box>
|
|
442
|
+
<Text color={statusInfo.color as any}>{statusInfo.char}</Text>
|
|
443
|
+
{/* Agent indicator */}
|
|
444
|
+
{isLive && currentAgent && (
|
|
445
|
+
<Text color="yellow" dimColor> [{currentAgent.name}]</Text>
|
|
446
|
+
)}
|
|
447
|
+
{!isLive && readOnlyAgent && (
|
|
448
|
+
<Text color="yellow" dimColor> [{readOnlyAgent}]</Text>
|
|
449
|
+
)}
|
|
450
|
+
{/* Model indicator: current model override or agent default or dashboard model */}
|
|
451
|
+
{isLive && currentModel ? (
|
|
452
|
+
<Text color="cyan" dimColor> {currentModel.label}</Text>
|
|
453
|
+
) : model ? (
|
|
454
|
+
<Text color="cyan" dimColor> {model}</Text>
|
|
455
|
+
) : null}
|
|
456
|
+
<Text dimColor> {isLive ? (mode === "insert" ? "[INSERT]" : "[NORMAL]") : "[read-only]"} </Text>
|
|
457
|
+
<Text dimColor>{scrollIndicator}</Text>
|
|
458
|
+
</Box>
|
|
459
|
+
</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
|
+
})}
|
|
509
|
+
|
|
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
|
+
/>
|
|
533
|
+
</Box>
|
|
534
|
+
) : (
|
|
535
|
+
<Text dimColor>○ Press <Text color="cyan" bold>i</Text> to type a message</Text>
|
|
536
|
+
)}
|
|
537
|
+
</Box>
|
|
538
|
+
{sendError && (
|
|
539
|
+
<Box paddingLeft={1}>
|
|
540
|
+
<Text color="red">{sendError}</Text>
|
|
541
|
+
</Box>
|
|
542
|
+
)}
|
|
543
|
+
</>
|
|
544
|
+
)}
|
|
545
|
+
|
|
546
|
+
{/* Footer */}
|
|
547
|
+
<Text dimColor>{divider}</Text>
|
|
548
|
+
<Box paddingLeft={1}>
|
|
549
|
+
<Text dimColor wrap="truncate">
|
|
550
|
+
{isLive && mode === "insert"
|
|
551
|
+
? `Esc: normal mode Enter: send ^XE: editor [INSERT] [${scrollIndicator}]`
|
|
552
|
+
: 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}]`
|
|
555
|
+
}
|
|
556
|
+
</Text>
|
|
557
|
+
</Box>
|
|
558
|
+
</Box>
|
|
559
|
+
)
|
|
560
|
+
}
|