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