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,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
|
+
}
|