vizier 2.0.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/.claude/settings.local.json +9 -0
- package/AGENTS.md +14 -0
- package/Makefile +15 -0
- package/README.md +94 -0
- package/assets/vizier.png +0 -0
- package/bun.lock +120 -0
- package/demo.sh +26 -0
- package/package.json +21 -0
- package/src/app.tsx +413 -0
- package/src/components/CommandInput.tsx +26 -0
- package/src/components/DetailsPanel.tsx +200 -0
- package/src/components/SessionList.tsx +39 -0
- package/src/components/StatusBar.tsx +38 -0
- package/src/components/Timeline.tsx +522 -0
- package/src/components/ToolPrompt.tsx +22 -0
- package/src/core/graph.ts +153 -0
- package/src/core/parser.ts +126 -0
- package/src/core/types.ts +76 -0
- package/src/core/watcher.ts +111 -0
- package/src/core/zoom.ts +126 -0
- package/src/index.tsx +70 -0
- package/src/sdk/claude.ts +36 -0
- package/tsconfig.json +15 -0
package/src/app.tsx
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import React, { useState, useEffect, useCallback, useRef, useMemo } from "react"
|
|
2
|
+
import { Box, useInput, useStdout, useApp } from "ink"
|
|
3
|
+
import type { Graph, SessionInfo } from "./core/types"
|
|
4
|
+
import type { ZoomLevel, CellMode } from "./core/zoom"
|
|
5
|
+
import { getVisualBranch } from "./core/zoom"
|
|
6
|
+
import { buildGraph } from "./core/graph"
|
|
7
|
+
import {
|
|
8
|
+
watchSession,
|
|
9
|
+
readAllEvents,
|
|
10
|
+
getSessionFile,
|
|
11
|
+
discoverAgentFiles,
|
|
12
|
+
listSessions,
|
|
13
|
+
} from "./core/watcher"
|
|
14
|
+
import { Timeline } from "./components/Timeline"
|
|
15
|
+
import { DetailsPanel } from "./components/DetailsPanel"
|
|
16
|
+
import { SessionList } from "./components/SessionList"
|
|
17
|
+
import { StatusBar } from "./components/StatusBar"
|
|
18
|
+
import { CommandInput } from "./components/CommandInput"
|
|
19
|
+
|
|
20
|
+
type Mode = "normal" | "input"
|
|
21
|
+
|
|
22
|
+
type Props = {
|
|
23
|
+
initialGraph: Graph
|
|
24
|
+
sessionId: string
|
|
25
|
+
claudeDir: string
|
|
26
|
+
project: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Get the nth node at a given level (returns global index)
|
|
30
|
+
function getNthNodeInLevel(graph: Graph, level: number, zoom: ZoomLevel, nth: number): number | null {
|
|
31
|
+
let count = 0
|
|
32
|
+
for (let i = 0; i < graph.nodes.length; i++) {
|
|
33
|
+
if (getVisualBranch(graph.nodes[i], zoom) === level) {
|
|
34
|
+
if (count === nth) return i
|
|
35
|
+
count++
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Find max visual branch in graph
|
|
42
|
+
function getMaxLevel(graph: Graph, zoom: ZoomLevel): number {
|
|
43
|
+
let max = 0
|
|
44
|
+
for (const n of graph.nodes) {
|
|
45
|
+
const b = getVisualBranch(n, zoom)
|
|
46
|
+
if (b > max) max = b
|
|
47
|
+
}
|
|
48
|
+
return Math.max(max, 1)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Find nearest node in level by timestamp
|
|
52
|
+
function findNearestInLevel(graph: Graph, level: number, zoom: ZoomLevel, targetTs: number): number {
|
|
53
|
+
let bestPos = 0
|
|
54
|
+
let bestDiff = Infinity
|
|
55
|
+
let pos = 0
|
|
56
|
+
for (const n of graph.nodes) {
|
|
57
|
+
if (getVisualBranch(n, zoom) === level) {
|
|
58
|
+
const diff = Math.abs(n.timestamp - targetTs)
|
|
59
|
+
if (diff < bestDiff) {
|
|
60
|
+
bestDiff = diff
|
|
61
|
+
bestPos = pos
|
|
62
|
+
}
|
|
63
|
+
pos++
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return bestPos
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Move to the next/prev node chronologically across all levels
|
|
70
|
+
// Returns { level, pos } for the target node, or null if at boundary
|
|
71
|
+
function stepChronological(
|
|
72
|
+
graph: Graph, zoom: ZoomLevel, currentNodeIdx: number | null, direction: 1 | -1
|
|
73
|
+
): { level: number; pos: number } | null {
|
|
74
|
+
if (currentNodeIdx === null) return null
|
|
75
|
+
const nextIdx = currentNodeIdx + direction
|
|
76
|
+
if (nextIdx < 0 || nextIdx >= graph.nodes.length) return null
|
|
77
|
+
const node = graph.nodes[nextIdx]
|
|
78
|
+
const level = getVisualBranch(node, zoom)
|
|
79
|
+
let pos = 0
|
|
80
|
+
for (let i = 0; i < nextIdx; i++) {
|
|
81
|
+
if (getVisualBranch(graph.nodes[i], zoom) === level) pos++
|
|
82
|
+
}
|
|
83
|
+
return { level, pos }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const DETAILS_HEIGHT = 20
|
|
87
|
+
|
|
88
|
+
// Find the last node's visual branch and position within that branch
|
|
89
|
+
function getLatestNodePosition(graph: Graph, zoom: ZoomLevel): { level: number; pos: number } {
|
|
90
|
+
if (graph.nodes.length === 0) return { level: 0, pos: 0 }
|
|
91
|
+
const lastNode = graph.nodes[graph.nodes.length - 1]
|
|
92
|
+
const level = getVisualBranch(lastNode, zoom)
|
|
93
|
+
let pos = 0
|
|
94
|
+
for (const n of graph.nodes) {
|
|
95
|
+
if (getVisualBranch(n, zoom) === level) pos++
|
|
96
|
+
}
|
|
97
|
+
return { level, pos: Math.max(0, pos - 1) }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function App({ initialGraph, sessionId: initialSessionId, claudeDir, project }: Props) {
|
|
101
|
+
const { stdout } = useStdout()
|
|
102
|
+
const { exit } = useApp()
|
|
103
|
+
const termWidth = stdout?.columns ?? 120
|
|
104
|
+
const termHeight = stdout?.rows ?? 40
|
|
105
|
+
|
|
106
|
+
const [graph, setGraph] = useState<Graph>(initialGraph)
|
|
107
|
+
const [sessionId, setSessionId] = useState(initialSessionId)
|
|
108
|
+
const [currentLevel, setCurrentLevel] = useState(0)
|
|
109
|
+
const [cursorInLevel, setCursorInLevel] = useState(() => {
|
|
110
|
+
// Start on last user message
|
|
111
|
+
let count = 0
|
|
112
|
+
let lastUserPos = 0
|
|
113
|
+
for (const n of initialGraph.nodes) {
|
|
114
|
+
if (getVisualBranch(n, "details") === 0) {
|
|
115
|
+
if (n.nodeType.kind === "user") lastUserPos = count
|
|
116
|
+
count++
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return lastUserPos
|
|
120
|
+
})
|
|
121
|
+
const [zoom, setZoom] = useState<ZoomLevel>("details")
|
|
122
|
+
const [cellMode, setCellMode] = useState<CellMode>("symbol")
|
|
123
|
+
const [blinkState, setBlinkState] = useState(false)
|
|
124
|
+
const [focusedNode, setFocusedNode] = useState<number | null>(null)
|
|
125
|
+
|
|
126
|
+
const [timelineOpen, setTimelineOpen] = useState(true)
|
|
127
|
+
const [detailsOpen, setDetailsOpen] = useState(false)
|
|
128
|
+
const [sessionListOpen, setSessionListOpen] = useState(false)
|
|
129
|
+
const [sessionListCursor, setSessionListCursor] = useState(0)
|
|
130
|
+
const [sessions, setSessions] = useState<SessionInfo[]>(() => listSessions(claudeDir, project))
|
|
131
|
+
|
|
132
|
+
const [detailsScroll, setDetailsScroll] = useState(0)
|
|
133
|
+
const [follow, setFollow] = useState(false)
|
|
134
|
+
const followRef = useRef(false)
|
|
135
|
+
const [mode, setMode] = useState<Mode>("normal")
|
|
136
|
+
|
|
137
|
+
// Only blink when the session is still running (last node is a pending tool call)
|
|
138
|
+
const hasActiveNodes = useMemo(() => {
|
|
139
|
+
if (graph.nodes.length === 0) return false
|
|
140
|
+
const last = graph.nodes[graph.nodes.length - 1]
|
|
141
|
+
return last.nodeType.kind === "tool_call" && last.nodeType.output === null
|
|
142
|
+
}, [graph])
|
|
143
|
+
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
if (!hasActiveNodes) {
|
|
146
|
+
setBlinkState(false)
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
const interval = setInterval(() => setBlinkState(b => !b), 500)
|
|
150
|
+
return () => clearInterval(interval)
|
|
151
|
+
}, [hasActiveNodes])
|
|
152
|
+
|
|
153
|
+
// File watcher
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
const watcher = watchSession(claudeDir, project, sessionId, () => {
|
|
156
|
+
const sessionFile = getSessionFile(claudeDir, project, sessionId)
|
|
157
|
+
const agentFiles = discoverAgentFiles(claudeDir, project, sessionId)
|
|
158
|
+
const events = readAllEvents(sessionFile, agentFiles)
|
|
159
|
+
const newGraph = buildGraph(events)
|
|
160
|
+
setGraph(prev => {
|
|
161
|
+
if (followRef.current) {
|
|
162
|
+
const latest = getLatestNodePosition(newGraph, zoom)
|
|
163
|
+
setCurrentLevel(latest.level)
|
|
164
|
+
setCursorInLevel(latest.pos)
|
|
165
|
+
} else {
|
|
166
|
+
const oldCount = prev.nodes.filter(n => getVisualBranch(n, zoom) === currentLevel).length
|
|
167
|
+
const newCount = newGraph.nodes.filter(n => getVisualBranch(n, zoom) === currentLevel).length
|
|
168
|
+
const isAtEnd = cursorInLevel >= oldCount - 2
|
|
169
|
+
if (isAtEnd && newCount > oldCount) {
|
|
170
|
+
setCursorInLevel(Math.max(0, newCount - 1))
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return newGraph
|
|
174
|
+
})
|
|
175
|
+
setSessions(listSessions(claudeDir, project))
|
|
176
|
+
})
|
|
177
|
+
return () => { watcher.close() }
|
|
178
|
+
}, [sessionId, claudeDir, project])
|
|
179
|
+
|
|
180
|
+
// Derived values
|
|
181
|
+
const nodesInLevel = graph.nodes.filter(n => getVisualBranch(n, zoom) === currentLevel).length
|
|
182
|
+
const currentNodeIdx = getNthNodeInLevel(graph, currentLevel, zoom, cursorInLevel)
|
|
183
|
+
const currentNode = currentNodeIdx !== null ? graph.nodes[currentNodeIdx] : null
|
|
184
|
+
const levelName = currentLevel === 0 ? "User" : currentLevel === 1 ? "Asst" : "Tools"
|
|
185
|
+
|
|
186
|
+
// Reset detail scroll when selected node changes — only if scrolled and details open
|
|
187
|
+
const prevNodeRef = useRef<string | null>(null)
|
|
188
|
+
const currentNodeId = currentNode?.id ?? null
|
|
189
|
+
if (currentNodeId !== prevNodeRef.current) {
|
|
190
|
+
prevNodeRef.current = currentNodeId
|
|
191
|
+
if (detailsOpen && detailsScroll !== 0) setDetailsScroll(0)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Switch session helper
|
|
195
|
+
const switchSession = useCallback((newSessionId: string) => {
|
|
196
|
+
const sessionFile = getSessionFile(claudeDir, project, newSessionId)
|
|
197
|
+
const agentFiles = discoverAgentFiles(claudeDir, project, newSessionId)
|
|
198
|
+
const events = readAllEvents(sessionFile, agentFiles)
|
|
199
|
+
const newGraph = buildGraph(events)
|
|
200
|
+
setGraph(newGraph)
|
|
201
|
+
setSessionId(newSessionId)
|
|
202
|
+
setCurrentLevel(0)
|
|
203
|
+
setCursorInLevel(0)
|
|
204
|
+
setSessionListOpen(false)
|
|
205
|
+
setTimelineOpen(true)
|
|
206
|
+
}, [claudeDir, project])
|
|
207
|
+
|
|
208
|
+
useInput((input, key) => {
|
|
209
|
+
if (mode === "input") {
|
|
210
|
+
if (key.escape) setMode("normal")
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Normal mode
|
|
215
|
+
if (input === "q") {
|
|
216
|
+
exit()
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (input === "s") {
|
|
221
|
+
setSessionListOpen(prev => !prev)
|
|
222
|
+
if (!sessionListOpen) {
|
|
223
|
+
const idx = sessions.findIndex(s => s.id === sessionId)
|
|
224
|
+
setSessionListCursor(idx >= 0 ? idx : 0)
|
|
225
|
+
}
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (input === "t") { setTimelineOpen(prev => !prev); return }
|
|
230
|
+
if (input === "d") { setDetailsOpen(prev => !prev); return }
|
|
231
|
+
if (input === "w") { setCellMode(prev => prev === "symbol" ? "preview" : "symbol"); return }
|
|
232
|
+
|
|
233
|
+
if (input === "z") {
|
|
234
|
+
if (currentNodeIdx !== null) {
|
|
235
|
+
setFocusedNode(prev => prev === currentNodeIdx ? null : currentNodeIdx)
|
|
236
|
+
}
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (input === "f") {
|
|
241
|
+
setFollow(prev => {
|
|
242
|
+
const next = !prev
|
|
243
|
+
followRef.current = next
|
|
244
|
+
if (next) {
|
|
245
|
+
const latest = getLatestNodePosition(graph, zoom)
|
|
246
|
+
setCurrentLevel(latest.level)
|
|
247
|
+
setCursorInLevel(latest.pos)
|
|
248
|
+
}
|
|
249
|
+
return next
|
|
250
|
+
})
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (input === "i") {
|
|
255
|
+
setMode("input")
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Detail scroll (Shift+J / Shift+K)
|
|
260
|
+
if (detailsOpen && input === "J") {
|
|
261
|
+
setDetailsScroll(prev => prev + 1)
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
if (detailsOpen && input === "K") {
|
|
265
|
+
setDetailsScroll(prev => Math.max(0, prev - 1))
|
|
266
|
+
return
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Session list navigation
|
|
270
|
+
if (sessionListOpen) {
|
|
271
|
+
if (input === "j" || key.downArrow) {
|
|
272
|
+
setSessionListCursor(prev => Math.min(prev + 1, sessions.length - 1))
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
if (input === "k" || key.upArrow) {
|
|
276
|
+
setSessionListCursor(prev => Math.max(prev - 1, 0))
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
if (key.return) {
|
|
280
|
+
const selected = sessions[sessionListCursor]
|
|
281
|
+
if (selected && selected.id !== sessionId) {
|
|
282
|
+
switchSession(selected.id)
|
|
283
|
+
}
|
|
284
|
+
setSessionListOpen(false)
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Timeline navigation — any manual nav disables follow
|
|
291
|
+
|
|
292
|
+
// Shift+arrow: stay within current level
|
|
293
|
+
if (key.shift && key.leftArrow) {
|
|
294
|
+
setFollow(false); followRef.current = false
|
|
295
|
+
setCursorInLevel(prev => Math.max(prev - 1, 0))
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
if (key.shift && key.rightArrow) {
|
|
299
|
+
setFollow(false); followRef.current = false
|
|
300
|
+
setCursorInLevel(prev => Math.min(prev + 1, nodesInLevel - 1))
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// h/l/arrows: chronological — move to next/prev node across all levels
|
|
305
|
+
if (input === "h" || key.leftArrow) {
|
|
306
|
+
setFollow(false); followRef.current = false
|
|
307
|
+
const target = stepChronological(graph, zoom, currentNodeIdx, -1)
|
|
308
|
+
if (target) {
|
|
309
|
+
setCurrentLevel(target.level)
|
|
310
|
+
setCursorInLevel(target.pos)
|
|
311
|
+
}
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
if (input === "l" || key.rightArrow) {
|
|
315
|
+
setFollow(false); followRef.current = false
|
|
316
|
+
const target = stepChronological(graph, zoom, currentNodeIdx, 1)
|
|
317
|
+
if (target) {
|
|
318
|
+
setCurrentLevel(target.level)
|
|
319
|
+
setCursorInLevel(target.pos)
|
|
320
|
+
}
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
if (input === "j" || key.downArrow) {
|
|
324
|
+
setFollow(false); followRef.current = false
|
|
325
|
+
const maxLevel = getMaxLevel(graph, zoom)
|
|
326
|
+
if (currentLevel < maxLevel) {
|
|
327
|
+
const ts = currentNode?.timestamp
|
|
328
|
+
setCurrentLevel(prev => prev + 1)
|
|
329
|
+
if (ts) setCursorInLevel(findNearestInLevel(graph, currentLevel + 1, zoom, ts))
|
|
330
|
+
else setCursorInLevel(0)
|
|
331
|
+
}
|
|
332
|
+
return
|
|
333
|
+
}
|
|
334
|
+
if (input === "k" || key.upArrow) {
|
|
335
|
+
setFollow(false); followRef.current = false
|
|
336
|
+
if (currentLevel > 0) {
|
|
337
|
+
const ts = currentNode?.timestamp
|
|
338
|
+
setCurrentLevel(prev => prev - 1)
|
|
339
|
+
if (ts) setCursorInLevel(findNearestInLevel(graph, currentLevel - 1, zoom, ts))
|
|
340
|
+
else setCursorInLevel(0)
|
|
341
|
+
}
|
|
342
|
+
return
|
|
343
|
+
}
|
|
344
|
+
if (input === "g") {
|
|
345
|
+
setFollow(false); followRef.current = false
|
|
346
|
+
setCursorInLevel(0)
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
if (input === "G") {
|
|
350
|
+
setFollow(false); followRef.current = false
|
|
351
|
+
setCursorInLevel(Math.max(0, nodesInLevel - 1))
|
|
352
|
+
return
|
|
353
|
+
}
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
const handleCommandSubmit = useCallback((_text: string) => {
|
|
357
|
+
// SDK integration: will be wired in Phase 3
|
|
358
|
+
setMode("normal")
|
|
359
|
+
}, [])
|
|
360
|
+
|
|
361
|
+
// Use termHeight - 1 so Ink uses eraseLines (with output diff) instead of
|
|
362
|
+
// clearTerminal (full screen flash). Ink triggers clearTerminal when
|
|
363
|
+
// outputHeight >= stdout.rows, which causes visible flicker in iTerm.
|
|
364
|
+
return (
|
|
365
|
+
<Box flexDirection="column" width={termWidth} height={termHeight - 1}>
|
|
366
|
+
{sessionListOpen && (
|
|
367
|
+
<SessionList
|
|
368
|
+
sessions={sessions}
|
|
369
|
+
currentSessionId={sessionId}
|
|
370
|
+
cursor={sessionListCursor}
|
|
371
|
+
/>
|
|
372
|
+
)}
|
|
373
|
+
{timelineOpen && (
|
|
374
|
+
<Timeline
|
|
375
|
+
graph={graph}
|
|
376
|
+
currentLevel={currentLevel}
|
|
377
|
+
cursorInLevel={cursorInLevel}
|
|
378
|
+
zoom={zoom}
|
|
379
|
+
cellMode={cellMode}
|
|
380
|
+
blinkState={blinkState}
|
|
381
|
+
termWidth={termWidth}
|
|
382
|
+
/>
|
|
383
|
+
)}
|
|
384
|
+
{detailsOpen && (
|
|
385
|
+
<DetailsPanel
|
|
386
|
+
node={currentNode}
|
|
387
|
+
levelName={levelName}
|
|
388
|
+
position={cursorInLevel + 1}
|
|
389
|
+
total={nodesInLevel}
|
|
390
|
+
height={DETAILS_HEIGHT}
|
|
391
|
+
scrollOffset={detailsScroll}
|
|
392
|
+
/>
|
|
393
|
+
)}
|
|
394
|
+
{mode === "input" && (
|
|
395
|
+
<CommandInput
|
|
396
|
+
onSubmit={handleCommandSubmit}
|
|
397
|
+
onCancel={() => setMode("normal")}
|
|
398
|
+
/>
|
|
399
|
+
)}
|
|
400
|
+
<Box flexGrow={1} />
|
|
401
|
+
<StatusBar
|
|
402
|
+
levelName={levelName}
|
|
403
|
+
position={cursorInLevel + 1}
|
|
404
|
+
total={nodesInLevel}
|
|
405
|
+
totalNodes={graph.nodes.length}
|
|
406
|
+
zoom={zoom}
|
|
407
|
+
isLive={true}
|
|
408
|
+
follow={follow}
|
|
409
|
+
stats={graph.stats}
|
|
410
|
+
/>
|
|
411
|
+
</Box>
|
|
412
|
+
)
|
|
413
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import React, { useState } from "react"
|
|
2
|
+
import { Box, Text } from "ink"
|
|
3
|
+
import TextInput from "ink-text-input"
|
|
4
|
+
|
|
5
|
+
type Props = {
|
|
6
|
+
onSubmit: (text: string) => void
|
|
7
|
+
onCancel: () => void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function CommandInput({ onSubmit, onCancel }: Props) {
|
|
11
|
+
const [value, setValue] = useState("")
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<Box borderStyle="single" borderColor="cyan" paddingX={1}>
|
|
15
|
+
<Text color="cyan" bold>{">"} </Text>
|
|
16
|
+
<TextInput
|
|
17
|
+
value={value}
|
|
18
|
+
onChange={setValue}
|
|
19
|
+
onSubmit={(text) => {
|
|
20
|
+
if (text.trim()) onSubmit(text.trim())
|
|
21
|
+
}}
|
|
22
|
+
/>
|
|
23
|
+
<Text dimColor> (Enter to send, Esc to cancel)</Text>
|
|
24
|
+
</Box>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import { Box, Text } from "ink"
|
|
3
|
+
import type { Node } from "../core/types"
|
|
4
|
+
|
|
5
|
+
type Props = {
|
|
6
|
+
node: Node | null
|
|
7
|
+
levelName: string
|
|
8
|
+
position: number
|
|
9
|
+
total: number
|
|
10
|
+
height: number
|
|
11
|
+
scrollOffset: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Flatten node content into plain text lines with optional color hints
|
|
15
|
+
type ContentLine = { text: string; color?: string; dimColor?: boolean; bold?: boolean }
|
|
16
|
+
|
|
17
|
+
function nodeToLines(node: Node): ContentLine[] {
|
|
18
|
+
const lines: ContentLine[] = []
|
|
19
|
+
const time = new Date(node.timestamp).toISOString().replace("T", " ").slice(0, 19)
|
|
20
|
+
lines.push({ text: `ID: ${node.id}`, dimColor: true })
|
|
21
|
+
lines.push({ text: `Time: ${time}`, dimColor: true })
|
|
22
|
+
lines.push({ text: `Branch Level: ${node.branchLevel}`, dimColor: true })
|
|
23
|
+
if (node.model) lines.push({ text: `Model: ${node.model}`, dimColor: true })
|
|
24
|
+
if (node.usage) {
|
|
25
|
+
const u = node.usage
|
|
26
|
+
const parts = [`in:${u.input_tokens ?? 0}`, `out:${u.output_tokens ?? 0}`]
|
|
27
|
+
if (u.cache_read_input_tokens) parts.push(`cache_read:${u.cache_read_input_tokens}`)
|
|
28
|
+
if (u.cache_creation_input_tokens) parts.push(`cache_create:${u.cache_creation_input_tokens}`)
|
|
29
|
+
lines.push({ text: `Tokens: ${parts.join(" ")}`, dimColor: true })
|
|
30
|
+
}
|
|
31
|
+
lines.push({ text: "" })
|
|
32
|
+
|
|
33
|
+
switch (node.nodeType.kind) {
|
|
34
|
+
case "user":
|
|
35
|
+
lines.push({ text: "User Message:", color: "cyan" })
|
|
36
|
+
for (const l of node.nodeType.text.split("\n")) lines.push({ text: l })
|
|
37
|
+
break
|
|
38
|
+
case "assistant":
|
|
39
|
+
lines.push({ text: "Assistant Message:", color: "green" })
|
|
40
|
+
for (const l of node.nodeType.text.split("\n")) lines.push({ text: l })
|
|
41
|
+
break
|
|
42
|
+
case "tool_use":
|
|
43
|
+
lines.push({ text: `Tool: ${node.nodeType.name}`, color: "yellow" })
|
|
44
|
+
lines.push({ text: "" })
|
|
45
|
+
jsonToLines(lines, node.nodeType.input, 0)
|
|
46
|
+
break
|
|
47
|
+
case "tool_result": {
|
|
48
|
+
const color = node.nodeType.isError ? "red" : "green"
|
|
49
|
+
lines.push({ text: "Tool Result:", color })
|
|
50
|
+
lines.push({ text: "" })
|
|
51
|
+
const out = node.nodeType.output.trim()
|
|
52
|
+
if (out) {
|
|
53
|
+
jsonToLines(lines, out, 0)
|
|
54
|
+
} else {
|
|
55
|
+
lines.push({ text: "(empty result)", dimColor: true })
|
|
56
|
+
}
|
|
57
|
+
break
|
|
58
|
+
}
|
|
59
|
+
case "tool_call": {
|
|
60
|
+
const statusColor = node.nodeType.output === null ? "yellow"
|
|
61
|
+
: node.nodeType.isError ? "red" : "green"
|
|
62
|
+
const statusLabel = node.nodeType.output === null ? "PENDING"
|
|
63
|
+
: node.nodeType.isError ? "ERROR" : "OK"
|
|
64
|
+
lines.push({ text: `Tool: ${node.nodeType.name} [${statusLabel}]`, color: statusColor })
|
|
65
|
+
lines.push({ text: "" })
|
|
66
|
+
lines.push({ text: "\u2500\u2500 Request \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500", color: "yellow" })
|
|
67
|
+
jsonToLines(lines, node.nodeType.input, 1)
|
|
68
|
+
if (node.nodeType.output !== null) {
|
|
69
|
+
lines.push({ text: "" })
|
|
70
|
+
lines.push({ text: "\u2500\u2500 Response \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500", color: statusColor })
|
|
71
|
+
const out = node.nodeType.output.trim()
|
|
72
|
+
if (out) {
|
|
73
|
+
jsonToLines(lines, out, 1)
|
|
74
|
+
} else {
|
|
75
|
+
lines.push({ text: " (empty)", dimColor: true })
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
break
|
|
79
|
+
}
|
|
80
|
+
case "agent_start":
|
|
81
|
+
lines.push({ text: "Agent Start:", color: "magenta" })
|
|
82
|
+
lines.push({ text: `Type: ${node.nodeType.agentType}` })
|
|
83
|
+
lines.push({ text: `ID: ${node.nodeType.agentId}` })
|
|
84
|
+
break
|
|
85
|
+
case "agent_end":
|
|
86
|
+
lines.push({ text: "Agent End:", dimColor: true })
|
|
87
|
+
lines.push({ text: `ID: ${node.nodeType.agentId}` })
|
|
88
|
+
break
|
|
89
|
+
case "progress":
|
|
90
|
+
lines.push({ text: "Progress:", dimColor: true })
|
|
91
|
+
lines.push({ text: node.nodeType.text })
|
|
92
|
+
break
|
|
93
|
+
}
|
|
94
|
+
return lines
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function jsonToLines(lines: ContentLine[], text: string, indent: number) {
|
|
98
|
+
try {
|
|
99
|
+
const parsed = JSON.parse(text)
|
|
100
|
+
jsonValueToLines(lines, parsed, indent)
|
|
101
|
+
} catch {
|
|
102
|
+
for (const l of text.split("\n")) {
|
|
103
|
+
lines.push({ text: " ".repeat(indent) + l })
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function jsonValueToLines(lines: ContentLine[], value: unknown, indent: number) {
|
|
109
|
+
const pad = " ".repeat(indent)
|
|
110
|
+
|
|
111
|
+
if (value === null || value === undefined) {
|
|
112
|
+
lines.push({ text: `${pad}null`, dimColor: true })
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
if (typeof value === "string") {
|
|
116
|
+
for (const l of value.split("\n")) {
|
|
117
|
+
lines.push({ text: `${pad}${l}` })
|
|
118
|
+
}
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
122
|
+
lines.push({ text: `${pad}${value}` })
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
if (Array.isArray(value)) {
|
|
126
|
+
for (let i = 0; i < Math.min(value.length, 10); i++) {
|
|
127
|
+
lines.push({ text: `${pad}[${i}]`, dimColor: true })
|
|
128
|
+
jsonValueToLines(lines, value[i], indent + 1)
|
|
129
|
+
}
|
|
130
|
+
if (value.length > 10) {
|
|
131
|
+
lines.push({ text: `${pad}... ${value.length - 10} more items`, dimColor: true })
|
|
132
|
+
}
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
if (typeof value === "object") {
|
|
136
|
+
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
|
|
137
|
+
if (typeof val === "object" && val !== null) {
|
|
138
|
+
lines.push({ text: `${pad}${key}:`, color: "yellow" })
|
|
139
|
+
jsonValueToLines(lines, val, indent + 1)
|
|
140
|
+
} else {
|
|
141
|
+
const valStr = val === null ? "null" : typeof val === "string" ? val : String(val)
|
|
142
|
+
// Store as composite — key colored, value plain
|
|
143
|
+
lines.push({ text: `${pad}${key}: ${valStr}`, color: "yellow", _keyLen: key.length + 2 } as any)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
lines.push({ text: `${pad}${value}` })
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function DetailsPanel({ node, levelName, position, total, height, scrollOffset }: Props) {
|
|
152
|
+
if (!node) {
|
|
153
|
+
return (
|
|
154
|
+
<Box flexDirection="column" height={height} borderStyle="single" borderColor="gray" paddingX={1}>
|
|
155
|
+
<Text dimColor>No node selected</Text>
|
|
156
|
+
</Box>
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const allLines = nodeToLines(node)
|
|
161
|
+
const innerHeight = height - 2 // border top + bottom
|
|
162
|
+
const maxScroll = Math.max(0, allLines.length - innerHeight)
|
|
163
|
+
const offset = Math.min(scrollOffset, maxScroll)
|
|
164
|
+
const visibleLines = allLines.slice(offset, offset + innerHeight)
|
|
165
|
+
const hasMore = allLines.length > innerHeight
|
|
166
|
+
|
|
167
|
+
const title = ` ${levelName} ${position}/${total} `
|
|
168
|
+
const scrollHint = hasMore
|
|
169
|
+
? ` [${offset + 1}-${Math.min(offset + innerHeight, allLines.length)}/${allLines.length}] J/K:scroll `
|
|
170
|
+
: ""
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<Box flexDirection="column" height={height} borderStyle="single" borderColor="gray" paddingX={1}>
|
|
174
|
+
<Text>
|
|
175
|
+
<Text bold>{title}</Text>
|
|
176
|
+
<Text dimColor>{scrollHint}</Text>
|
|
177
|
+
</Text>
|
|
178
|
+
{visibleLines.map((line, i) => {
|
|
179
|
+
// Handle key:value lines where key should be colored
|
|
180
|
+
const keyLen = (line as any)._keyLen as number | undefined
|
|
181
|
+
if (keyLen && line.color) {
|
|
182
|
+
const pad = line.text.length - line.text.trimStart().length
|
|
183
|
+
const prefix = line.text.slice(0, pad)
|
|
184
|
+
const keyPart = line.text.slice(pad, pad + keyLen)
|
|
185
|
+
const valPart = line.text.slice(pad + keyLen)
|
|
186
|
+
return (
|
|
187
|
+
<Text key={i}>
|
|
188
|
+
{prefix}<Text color={line.color as any}>{keyPart}</Text>{valPart}
|
|
189
|
+
</Text>
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
return (
|
|
193
|
+
<Text key={i} color={line.color as any} dimColor={line.dimColor} bold={line.bold}>
|
|
194
|
+
{line.text}
|
|
195
|
+
</Text>
|
|
196
|
+
)
|
|
197
|
+
})}
|
|
198
|
+
</Box>
|
|
199
|
+
)
|
|
200
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import React from "react"
|
|
2
|
+
import { Box, Text } from "ink"
|
|
3
|
+
import type { SessionInfo } from "../core/types"
|
|
4
|
+
|
|
5
|
+
type Props = {
|
|
6
|
+
sessions: SessionInfo[]
|
|
7
|
+
currentSessionId: string
|
|
8
|
+
cursor: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function SessionList({ sessions, currentSessionId, cursor }: Props) {
|
|
12
|
+
return (
|
|
13
|
+
<Box flexDirection="column" borderStyle="single" borderColor="cyan" paddingX={1}>
|
|
14
|
+
<Text bold> Sessions (Enter to switch, s to close) </Text>
|
|
15
|
+
{sessions.map((session, idx) => {
|
|
16
|
+
const isCurrent = session.id === currentSessionId
|
|
17
|
+
const isSelected = idx === cursor
|
|
18
|
+
const prefix = isSelected ? "> " : " "
|
|
19
|
+
const shortId = session.id.slice(0, 8)
|
|
20
|
+
const time = new Date(session.timestamp).toLocaleDateString("en-US", {
|
|
21
|
+
month: "2-digit",
|
|
22
|
+
day: "2-digit",
|
|
23
|
+
hour: "2-digit",
|
|
24
|
+
minute: "2-digit",
|
|
25
|
+
})
|
|
26
|
+
const currentMarker = isCurrent ? " (current)" : ""
|
|
27
|
+
const waitingMarker = session.waitingForUser ? " \u23F8" : ""
|
|
28
|
+
|
|
29
|
+
const color = session.waitingForUser ? "yellow" : isCurrent ? "green" : undefined
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<Text key={session.id} color={color} bold={isSelected}>
|
|
33
|
+
{prefix}{shortId} | {time} | {String(session.nodeCount).padStart(4)} events{currentMarker}{waitingMarker}
|
|
34
|
+
</Text>
|
|
35
|
+
)
|
|
36
|
+
})}
|
|
37
|
+
</Box>
|
|
38
|
+
)
|
|
39
|
+
}
|