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,549 @@
1
+ import React from "react"
2
+ import { Box, Text, useStdout, useInput } from "ink"
3
+ import {
4
+ type OcmInstance,
5
+ type OcmSession,
6
+ type SessionStatus,
7
+ useStore,
8
+ } from "../store.js"
9
+ import { useDashboardKeys } from "../hooks/use-keybindings.js"
10
+ import { yieldToOpencode } from "../hooks/use-attach.js"
11
+ import { config } from "../config.js"
12
+ import { refreshNow, shortenModel } from "../poller.js"
13
+ import { killInstance } from "../registry/instances.js"
14
+ import {
15
+ getChildSessions,
16
+ countChildSessions,
17
+ hasChildSessions,
18
+ getSessionStatus,
19
+ getLastMessagePreview,
20
+ getSessionModel,
21
+ } from "../db/reader.js"
22
+
23
+ // ─── Status helpers ───────────────────────────────────────────────────────────
24
+
25
+ const STATUS_ORDER: Record<SessionStatus, number> = {
26
+ "needs-input": 0,
27
+ error: 1,
28
+ working: 2,
29
+ idle: 3,
30
+ }
31
+
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
+ // ─── Child session builder ────────────────────────────────────────────────────
42
+
43
+ function buildChildOcmSession(c: { id: string; projectId: string; title: string; directory: string; timeUpdated: number }): OcmSession {
44
+ const preview = getLastMessagePreview(c.id)
45
+ return {
46
+ id: c.id,
47
+ projectId: c.projectId,
48
+ title: c.title,
49
+ directory: c.directory,
50
+ status: getSessionStatus(c.id),
51
+ lastMessagePreview: preview.text,
52
+ lastMessageRole: preview.role,
53
+ model: (() => { const m = getSessionModel(c.id); return m ? shortenModel(m) : null })(),
54
+ timeUpdated: c.timeUpdated,
55
+ hasChildren: hasChildSessions(c.id),
56
+ }
57
+ }
58
+
59
+ // ─── Time helpers ─────────────────────────────────────────────────────────────
60
+
61
+ function relativeTime(ts: number): string {
62
+ const seconds = Math.floor((Date.now() - ts) / 1000)
63
+ if (seconds < 60) return "just now"
64
+ const minutes = Math.floor(seconds / 60)
65
+ if (minutes < 60) return `${minutes}m ago`
66
+ const hours = Math.floor(minutes / 60)
67
+ if (hours < 24) return `${hours}h ago`
68
+ const days = Math.floor(hours / 24)
69
+ return `${days}d ago`
70
+ }
71
+
72
+ // ─── Agent type extraction ────────────────────────────────────────────────────
73
+
74
+ function extractAgentType(title: string): string {
75
+ const match = title.match(/\(@(\w+)\s+subagent\)$/)
76
+ if (match) return `${match[1]}`
77
+ if (title.startsWith("Task:")) return "task"
78
+ if (title.startsWith("Background:")) return "bg"
79
+ return "agent"
80
+ }
81
+
82
+ function cleanTitle(title: string): string {
83
+ let cleaned = title.replace(/\s*\(@\w+\s+subagent\)\s*$/, "").trim()
84
+ cleaned = cleaned.replace(/^Task:\s*/, "").trim()
85
+ cleaned = cleaned.replace(/^Background:\s*/, "").trim()
86
+ return cleaned
87
+ }
88
+
89
+ // ─── Flat row model ───────────────────────────────────────────────────────────
90
+
91
+ type InstanceRow = {
92
+ kind: "instance"
93
+ instance: OcmInstance
94
+ }
95
+
96
+ type ChildRow = {
97
+ kind: "child"
98
+ session: OcmSession
99
+ agentType: string
100
+ cleanedTitle: string
101
+ depth: number
102
+ isLast: boolean
103
+ parentSessionId: string
104
+ }
105
+
106
+ type ScrollIndicatorRow = {
107
+ kind: "scroll-indicator"
108
+ direction: "above" | "below"
109
+ count: number
110
+ depth: number
111
+ parentSessionId: string
112
+ }
113
+
114
+ type VisibleRow = InstanceRow | ChildRow | ScrollIndicatorRow
115
+
116
+ // ─── Build visible rows ───────────────────────────────────────────────────────
117
+
118
+ function buildRows(
119
+ instances: OcmInstance[],
120
+ expandedSessions: Set<string>,
121
+ childSessions: Map<string, { children: OcmSession[]; totalCount: number }>,
122
+ childScrollOffsets: Map<string, number>,
123
+ ): VisibleRow[] {
124
+ const rows: VisibleRow[] = []
125
+
126
+ for (const instance of instances) {
127
+ rows.push({ kind: "instance", instance })
128
+
129
+ if (expandedSessions.has(instance.sessionId)) {
130
+ insertChildren(rows, instance.sessionId, 1, expandedSessions, childSessions, childScrollOffsets)
131
+ }
132
+ }
133
+
134
+ return rows
135
+ }
136
+
137
+ function insertChildren(
138
+ rows: VisibleRow[],
139
+ parentSessionId: string,
140
+ depth: number,
141
+ expandedSessions: Set<string>,
142
+ childSessions: Map<string, { children: OcmSession[]; totalCount: number }>,
143
+ childScrollOffsets: Map<string, number>,
144
+ ): void {
145
+ const data = childSessions.get(parentSessionId)
146
+ if (!data) return
147
+
148
+ const { children, totalCount } = data
149
+ const offset = childScrollOffsets.get(parentSessionId) ?? 0
150
+
151
+ if (offset > 0) {
152
+ rows.push({ kind: "scroll-indicator", direction: "above", count: offset, depth, parentSessionId })
153
+ }
154
+
155
+ children.forEach((child, i) => {
156
+ const isLast = i === children.length - 1 && offset + children.length >= totalCount
157
+ rows.push({
158
+ kind: "child",
159
+ session: child,
160
+ agentType: extractAgentType(child.title),
161
+ cleanedTitle: cleanTitle(child.title),
162
+ depth,
163
+ isLast,
164
+ parentSessionId,
165
+ })
166
+ if (expandedSessions.has(child.id)) {
167
+ insertChildren(rows, child.id, depth + 1, expandedSessions, childSessions, childScrollOffsets)
168
+ }
169
+ })
170
+
171
+ const remaining = totalCount - offset - children.length
172
+ if (remaining > 0) {
173
+ rows.push({ kind: "scroll-indicator", direction: "below", count: remaining, depth, parentSessionId })
174
+ }
175
+ }
176
+
177
+ // Navigable rows exclude scroll indicators
178
+ function getNavigableIndices(rows: VisibleRow[]): number[] {
179
+ return rows
180
+ .map((r, i) => (r.kind === "scroll-indicator" ? -1 : i))
181
+ .filter((i) => i >= 0)
182
+ }
183
+
184
+ // ─── Dashboard component ──────────────────────────────────────────────────────
185
+
186
+ export function Dashboard() {
187
+ const instances = useStore((s) => s.instances)
188
+ const cursorIndex = useStore((s) => s.cursorIndex)
189
+ const setCursorIndex = useStore((s) => s.setCursorIndex)
190
+ const navigate = useStore((s) => s.navigate)
191
+ const expandedSessions = useStore((s) => s.expandedSessions)
192
+ const toggleExpanded = useStore((s) => s.toggleExpanded)
193
+ const collapseSession = useStore((s) => s.collapseSession)
194
+ const childSessions = useStore((s) => s.childSessions)
195
+ const childScrollOffsets = useStore((s) => s.childScrollOffsets)
196
+ const setChildSessions = useStore((s) => s.setChildSessions)
197
+ const setChildScrollOffset = useStore((s) => s.setChildScrollOffset)
198
+ const { stdout } = useStdout()
199
+ const termWidth = stdout?.columns ?? 80
200
+
201
+ const [showHelp, setShowHelp] = React.useState(false)
202
+ const [killConfirm, setKillConfirm] = React.useState<OcmInstance | null>(null)
203
+
204
+ // Kill confirmation input handler
205
+ useInput((input, key) => {
206
+ if (!killConfirm) return
207
+ if (input === "y" || input === "Y") {
208
+ killInstance(killConfirm.worktree, killConfirm.sessionId)
209
+ setKillConfirm(null)
210
+ refreshNow()
211
+ } else if (input === "n" || input === "N" || key.escape) {
212
+ setKillConfirm(null)
213
+ }
214
+ })
215
+
216
+ const visibleRows = React.useMemo(
217
+ () => buildRows(instances, expandedSessions, childSessions, childScrollOffsets),
218
+ [instances, expandedSessions, childSessions, childScrollOffsets],
219
+ )
220
+
221
+ const navigableIndices = React.useMemo(() => getNavigableIndices(visibleRows), [visibleRows])
222
+
223
+ const rowToNavIdx = React.useMemo(() => {
224
+ const map = new Map<number, number>()
225
+ navigableIndices.forEach((rowIdx, navIdx) => map.set(rowIdx, navIdx))
226
+ return map
227
+ }, [navigableIndices])
228
+
229
+ const safeNavIndex = Math.min(cursorIndex, Math.max(0, navigableIndices.length - 1))
230
+ const safeRowIndex = navigableIndices[safeNavIndex] ?? 0
231
+ const currentRow = visibleRows[safeRowIndex]
232
+
233
+ const statusCounts = React.useMemo(() => {
234
+ const statuses = navigableIndices.map((i) => {
235
+ const row = visibleRows[i]
236
+ if (!row) return "idle" as SessionStatus
237
+ if (row.kind === "instance") return row.instance.status
238
+ if (row.kind === "child") return row.session.status
239
+ return "idle" as SessionStatus
240
+ })
241
+ return {
242
+ working: statuses.filter((s) => s === "working").length,
243
+ needsInput: statuses.filter((s) => s === "needs-input").length,
244
+ error: statuses.filter((s) => s === "error").length,
245
+ }
246
+ }, [visibleRows, navigableIndices])
247
+
248
+ const attentionNavIndices = React.useMemo(
249
+ () =>
250
+ navigableIndices
251
+ .map((rowIdx, navIdx) => {
252
+ const row = visibleRows[rowIdx]
253
+ if (!row) return -1
254
+ const status =
255
+ row.kind === "instance" ? row.instance.status
256
+ : row.kind === "child" ? row.session.status
257
+ : "idle"
258
+ return status === "needs-input" ? navIdx : -1
259
+ })
260
+ .filter((i) => i >= 0),
261
+ [visibleRows, navigableIndices],
262
+ )
263
+
264
+ useDashboardKeys({
265
+ onUp: () => {
266
+ if (navigableIndices.length === 0) return
267
+ const currentRowIdx = navigableIndices[safeNavIndex] ?? 0
268
+ const currentVisRow = visibleRows[currentRowIdx]
269
+
270
+ if (currentVisRow?.kind === "child") {
271
+ const rowAbove = visibleRows[currentRowIdx - 1]
272
+ if (rowAbove?.kind === "scroll-indicator" && rowAbove.direction === "above") {
273
+ const parentId = rowAbove.parentSessionId
274
+ const currentOffset = childScrollOffsets.get(parentId) ?? 0
275
+ if (currentOffset > 0) {
276
+ const newOffset = currentOffset - 1
277
+ setChildScrollOffset(parentId, newOffset)
278
+ const children = getChildSessions(parentId, 10, newOffset)
279
+ const totalCount = childSessions.get(parentId)?.totalCount ?? 0
280
+ setChildSessions(parentId, children.map(buildChildOcmSession), totalCount)
281
+ return
282
+ }
283
+ }
284
+ }
285
+ setCursorIndex(Math.max(0, safeNavIndex - 1))
286
+ },
287
+ onDown: () => {
288
+ if (navigableIndices.length === 0) return
289
+ const currentRowIdx = navigableIndices[safeNavIndex] ?? 0
290
+ const currentVisRow = visibleRows[currentRowIdx]
291
+
292
+ if (currentVisRow?.kind === "child") {
293
+ const rowBelow = visibleRows[currentRowIdx + 1]
294
+ if (rowBelow?.kind === "scroll-indicator" && rowBelow.direction === "below") {
295
+ const parentId = rowBelow.parentSessionId
296
+ const currentOffset = childScrollOffsets.get(parentId) ?? 0
297
+ const totalCount = childSessions.get(parentId)?.totalCount ?? 0
298
+ if (currentOffset + 10 < totalCount) {
299
+ const newOffset = currentOffset + 1
300
+ setChildScrollOffset(parentId, newOffset)
301
+ const children = getChildSessions(parentId, 10, newOffset)
302
+ setChildSessions(parentId, children.map(buildChildOcmSession), totalCount)
303
+ return
304
+ }
305
+ }
306
+ }
307
+ setCursorIndex(Math.min(navigableIndices.length - 1, safeNavIndex + 1))
308
+ },
309
+ onOpen: () => {
310
+ if (!currentRow) return
311
+ if (currentRow.kind === "instance") {
312
+ navigate("conversation", currentRow.instance.projectId, currentRow.instance.sessionId)
313
+ } else if (currentRow.kind === "child") {
314
+ navigate("conversation", currentRow.session.projectId, currentRow.session.id)
315
+ }
316
+ },
317
+ onAttach: () => {
318
+ if (!currentRow) return
319
+ if (currentRow.kind === "instance") {
320
+ yieldToOpencode(currentRow.instance.sessionId, currentRow.instance.worktree)
321
+ } else if (currentRow.kind === "child") {
322
+ yieldToOpencode(currentRow.session.id, currentRow.session.directory)
323
+ }
324
+ },
325
+ onExpand: () => {
326
+ if (!currentRow) return
327
+ const loadAndExpand = (sessionId: string) => {
328
+ toggleExpanded(sessionId)
329
+ if (!expandedSessions.has(sessionId)) {
330
+ const children = getChildSessions(sessionId, 10, 0)
331
+ const totalCount = countChildSessions(sessionId)
332
+ setChildSessions(sessionId, children.map(buildChildOcmSession), totalCount)
333
+ }
334
+ }
335
+ if (currentRow.kind === "instance" && currentRow.instance.hasChildren) {
336
+ loadAndExpand(currentRow.instance.sessionId)
337
+ } else if (currentRow.kind === "child" && currentRow.session.hasChildren) {
338
+ loadAndExpand(currentRow.session.id)
339
+ }
340
+ },
341
+ onCollapse: () => {
342
+ if (!currentRow) return
343
+ if (currentRow.kind === "instance") {
344
+ collapseSession(currentRow.instance.sessionId)
345
+ } else if (currentRow.kind === "child") {
346
+ collapseSession(currentRow.session.id)
347
+ }
348
+ },
349
+ onSpawn: () => navigate("spawn"),
350
+ onNextNeedsInput: () => {
351
+ if (attentionNavIndices.length === 0) return
352
+ const next = attentionNavIndices.find((i) => i > safeNavIndex) ?? attentionNavIndices[0]!
353
+ setCursorIndex(next)
354
+ },
355
+ onKill: () => {
356
+ if (!currentRow) return
357
+ if (currentRow.kind === "instance") {
358
+ setKillConfirm(currentRow.instance)
359
+ }
360
+ },
361
+ onRescan: () => { refreshNow() },
362
+ onHelp: () => setShowHelp((v) => !v),
363
+ onQuit: () => {
364
+ if (showHelp) { setShowHelp(false); return }
365
+ if (killConfirm) { setKillConfirm(null); return }
366
+ process.exit(0)
367
+ },
368
+ })
369
+
370
+ const kb = config.keybindings.dashboard
371
+
372
+ // ASCII logo — only show when terminal is tall enough (≥15 rows)
373
+ const showLogo = (stdout?.rows ?? 24) >= 15
374
+ const LOGO = [
375
+ " █▀▀█ █▀▀▀ █▄▀▄█ █ █ ▄ ▄",
376
+ " █ █ █___ █ ▀ █ █__█ _▀▀_",
377
+ " ▀▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀ ▀",
378
+ ]
379
+
380
+ return (
381
+ <Box flexDirection="column">
382
+ {/* ASCII logo */}
383
+ {showLogo && (
384
+ <Box flexDirection="column" paddingLeft={1}>
385
+ {LOGO.map((line, i) => (
386
+ <Text key={i} color="cyan">{line}</Text>
387
+ ))}
388
+ </Box>
389
+ )}
390
+
391
+ {/* Status bar */}
392
+ <Box paddingX={2} paddingY={0} borderStyle="single" borderColor="gray">
393
+ <Text bold color="cyan">OCMux</Text>
394
+ <Text dimColor> │ </Text>
395
+ <Text bold>{instances.length}</Text><Text dimColor> {instances.length === 1 ? "instance" : "instances"}</Text>
396
+ {statusCounts.working > 0 && <Text><Text dimColor> │ </Text><Text color="green">▶ {statusCounts.working} working</Text></Text>}
397
+ {statusCounts.needsInput > 0 && <Text><Text dimColor> │ </Text><Text color="yellow">● {statusCounts.needsInput} needs input</Text></Text>}
398
+ {statusCounts.error > 0 && <Text><Text dimColor> │ </Text><Text color="red">✖ {statusCounts.error} error</Text></Text>}
399
+ </Box>
400
+
401
+ {/* Help overlay */}
402
+ {showHelp ? (
403
+ <Box flexDirection="column" paddingX={2} paddingY={1} borderStyle="round" borderColor="cyan" marginX={2} marginY={1}>
404
+ <Box marginBottom={1}><Text bold color="cyan">Dashboard Keybindings</Text></Box>
405
+ <Box flexDirection="column" paddingLeft={2}>
406
+ <Box><Box width={12}><Text bold color="white">{kb.up}/{kb.down}</Text></Box><Text dimColor>navigate</Text></Box>
407
+ <Box><Box width={12}><Text bold color="white">Enter</Text></Box><Text dimColor>open conversation</Text></Box>
408
+ <Box><Box width={12}><Text bold color="white">{kb.attach}</Text></Box><Text dimColor>open in opencode (attach)</Text></Box>
409
+ <Box><Box width={12}><Text bold color="white">Tab/S-Tab</Text></Box><Text dimColor>expand/collapse subagents</Text></Box>
410
+ <Box><Box width={12}><Text bold color="white">Ctrl-N</Text></Box><Text dimColor>jump to next needs-input</Text></Box>
411
+ <Box><Box width={12}><Text bold color="white">{kb.spawn}</Text></Box><Text dimColor>spawn new opencode (background)</Text></Box>
412
+ <Box><Box width={12}><Text bold color="white">{kb.kill}</Text></Box><Text dimColor>kill selected instance</Text></Box>
413
+ <Box><Box width={12}><Text bold color="white">{kb.rescan}</Text></Box><Text dimColor>refresh from database</Text></Box>
414
+ <Box><Box width={12}><Text bold color="white">{kb.help}</Text></Box><Text dimColor>close help</Text></Box>
415
+ <Box><Box width={12}><Text bold color="white">{kb.quit}</Text></Box><Text dimColor>quit</Text></Box>
416
+ </Box>
417
+ <Box marginTop={1}>
418
+ <Text dimColor>Press </Text><Text bold color="white">{kb.help}</Text><Text dimColor> or </Text><Text bold color="white">{kb.quit}</Text><Text dimColor> to close</Text>
419
+ </Box>
420
+ </Box>
421
+ ) : (
422
+ <>
423
+ {instances.length === 0 && (
424
+ <Box paddingX={2} paddingY={1} borderStyle="round" borderColor="gray" marginX={2} marginY={1}>
425
+ <Text dimColor>No opencode instances running. Press </Text>
426
+ <Text bold color="cyan">{kb.spawn}</Text>
427
+ <Text dimColor> to start one.</Text>
428
+ </Box>
429
+ )}
430
+
431
+ {visibleRows.map((row, rowIdx) => {
432
+ const navIdx = rowToNavIdx.get(rowIdx) ?? -1
433
+ const isCursor = navIdx >= 0 && navIdx === safeNavIndex
434
+
435
+ if (row.kind === "scroll-indicator") {
436
+ const indent = " ".repeat(row.depth)
437
+ const arrow = row.direction === "above" ? "▲" : "▼"
438
+ return (
439
+ <Box key={`scroll-${row.direction}-${row.parentSessionId}-${row.depth}`} paddingLeft={1}>
440
+ <Text> </Text>
441
+ <Text dimColor>{indent + "⋮ "}</Text>
442
+ <Box paddingLeft={1}>
443
+ <Text dimColor>{arrow + " " + row.count} {row.direction === "above" ? "above" : "more below"}</Text>
444
+ </Box>
445
+ </Box>
446
+ )
447
+ }
448
+
449
+ if (row.kind === "instance") {
450
+ const { char, color } = statusIcon(row.instance.status)
451
+ const canExpand = row.instance.hasChildren
452
+ const isExpanded = expandedSessions.has(row.instance.sessionId)
453
+ const expandChar = !canExpand ? " " : isExpanded ? "▾ " : "▸ "
454
+
455
+ let preview = row.instance.lastPreview
456
+ if (row.instance.status === "working" && !preview) preview = "working..."
457
+
458
+ // Fixed prefix: paddingLeft(1) + cursor(2) + icon(2) + expand(2) = 7 chars
459
+ const model = row.instance.model
460
+ const modelStr = model ? model + " " : ""
461
+ const modelLen = modelStr.length
462
+ const labelLen = Math.min(36, Math.floor((termWidth - 7 - modelLen - 2) * 0.55))
463
+ const label = `${row.instance.repoName} / ${row.instance.sessionTitle}`
464
+ const truncLabel = label.length > labelLen ? label.slice(0, labelLen - 1) + "…" : label.padEnd(labelLen)
465
+ const previewLen = Math.max(0, termWidth - 7 - labelLen - modelLen - 2)
466
+ const truncPreview = preview.length > previewLen ? preview.slice(0, Math.max(0, previewLen - 1)) + (previewLen > 1 ? "…" : "") : preview
467
+
468
+ return (
469
+ <Box key={row.instance.id} paddingLeft={1}>
470
+ <Text color={isCursor ? "cyan" : undefined} bold={isCursor}>{isCursor ? "┃ " : " "}</Text>
471
+ <Text color={color}>{char} </Text>
472
+ <Text dimColor>{expandChar}</Text>
473
+ <Text bold={isCursor}>{truncLabel}</Text>
474
+ {model && <Text color="cyan" dimColor> {model}</Text>}
475
+ <Text dimColor> {truncPreview}</Text>
476
+ </Box>
477
+ )
478
+ }
479
+
480
+ if (row.kind === "child") {
481
+ const { char, color } = statusIcon(row.session.status)
482
+ const indent = " ".repeat(row.depth)
483
+ const treeChar = row.isLast ? "└─ " : "├─ "
484
+ const expandIndicator = row.session.hasChildren
485
+ ? (expandedSessions.has(row.session.id) ? " ▾" : " ▸")
486
+ : ""
487
+ const timeAgo = relativeTime(row.session.timeUpdated)
488
+
489
+ const titleLen = 30
490
+ const truncChildTitle = row.cleanedTitle.length > titleLen
491
+ ? row.cleanedTitle.slice(0, titleLen - 1) + "…"
492
+ : row.cleanedTitle.padEnd(titleLen)
493
+ const badge = ("[" + row.agentType + "]").padEnd(10).slice(0, 10)
494
+
495
+ return (
496
+ <Box key={`child-${row.parentSessionId}-${row.session.id}`} paddingLeft={1}>
497
+ <Text color={isCursor ? "cyan" : undefined} bold={isCursor}>{isCursor ? "┃ " : " "}</Text>
498
+ <Text dimColor>{indent + treeChar}</Text>
499
+ <Text color={color}>{char} </Text>
500
+ <Text color="magenta">{badge}</Text>
501
+ <Text bold={isCursor}> {truncChildTitle}</Text>
502
+ {row.session.model && <Text color="cyan" dimColor> {row.session.model}</Text>}
503
+ <Text dimColor>{expandIndicator} {timeAgo}</Text>
504
+ </Box>
505
+ )
506
+ }
507
+
508
+ return null
509
+ })}
510
+
511
+ {killConfirm ? (
512
+ <Box marginTop={1} paddingX={2} paddingY={0} borderStyle="single" borderColor="red">
513
+ <Text color="red">Kill </Text>
514
+ <Text bold color="red">
515
+ {killConfirm.repoName} / {killConfirm.sessionTitle.slice(0, 40)}
516
+ </Text>
517
+ <Text color="red">? </Text>
518
+ <Text bold color="white">y</Text>
519
+ <Text dimColor> confirm </Text>
520
+ <Text bold color="white">n</Text>
521
+ <Text dimColor>/</Text>
522
+ <Text bold color="white">Esc</Text>
523
+ <Text dimColor> cancel</Text>
524
+ </Box>
525
+ ) : (
526
+ <Box marginTop={1} paddingX={2} paddingY={0} borderStyle="single" borderColor="gray">
527
+ <Box flexGrow={1}>
528
+ <Text dimColor>
529
+ <Text bold color="white">{kb.up}/{kb.down}</Text> nav <Text dimColor>│</Text>{" "}
530
+ <Text bold color="white">Enter</Text> open <Text dimColor>│</Text>{" "}
531
+ <Text bold color="white">Tab</Text> expand <Text dimColor>│</Text>{" "}
532
+ <Text bold color="white">{kb.attach}</Text> attach
533
+ </Text>
534
+ </Box>
535
+ <Box>
536
+ <Text dimColor>
537
+ <Text bold color="white">{kb.spawn}</Text> new <Text dimColor>│</Text>{" "}
538
+ <Text bold color="white">{kb.kill}</Text> kill <Text dimColor>│</Text>{" "}
539
+ <Text bold color="white">?</Text> help <Text dimColor>│</Text>{" "}
540
+ <Text bold color="white">{kb.quit}</Text> quit
541
+ </Text>
542
+ </Box>
543
+ </Box>
544
+ )}
545
+ </>
546
+ )}
547
+ </Box>
548
+ )
549
+ }