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.
@@ -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
+ }