@davstack/tui 0.2.0 → 0.3.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,224 @@
1
+ import { walkToolUses } from "@davstack/open-agents/core/parse"
2
+ import type { ParsedEvent } from "@davstack/open-agents/adapters/types"
3
+
4
+ import { eventGlyph, type AgentEventKind } from "./agent-glyphs.ts"
5
+
6
+ const TOOL_ARG_KEYS = [
7
+ "path",
8
+ "file_path",
9
+ "pattern",
10
+ "glob",
11
+ "command",
12
+ "target",
13
+ "target_file",
14
+ "filename",
15
+ "file",
16
+ ]
17
+
18
+ export interface TimelineRenderLine {
19
+ kind: AgentEventKind
20
+ text: string
21
+ }
22
+
23
+ export function formatStartLine(opts: { prompt: string; noColor: boolean }): TimelineRenderLine {
24
+ const g = eventGlyph("start", opts.noColor)
25
+ const prompt = truncateOneLine(opts.prompt, 60)
26
+ return { kind: "start", text: `${g} run started · prompt: "${prompt}"` }
27
+ }
28
+
29
+ export function formatEventLines(opts: {
30
+ ev: ParsedEvent
31
+ noColor: boolean
32
+ }): TimelineRenderLine[] {
33
+ const { ev, noColor } = opts
34
+ const type = typeof ev.type === "string" ? ev.type : "other"
35
+
36
+ if (type === "system" || type === "init") {
37
+ return [line("system", noColor, "system", "model loaded")]
38
+ }
39
+
40
+ if (type === "tool_result" || type === "tool_result_error") {
41
+ const body = extractText(ev)
42
+ const preview = truncateOneLine(body.replace(/\s+/g, " "), 160)
43
+ const size = body.length > 0 ? ` (${formatByteSize(body.length)})` : ""
44
+ return [line("tool_result", noColor, `tool_result${size}`, preview || "ok")]
45
+ }
46
+
47
+ if (type === "assistant" || (type === "message" && ev.role === "assistant")) {
48
+ const body = extractAssistantText(ev)
49
+ if (!body.trim()) return []
50
+ return [line("assistant", noColor, "assistant", `"${truncateOneLine(body.replace(/\s+/g, " "), 120)}"`)]
51
+ }
52
+
53
+ if (type === "result") {
54
+ return []
55
+ }
56
+
57
+ const cursorTC = extractCursorToolCall(ev, noColor)
58
+ if (cursorTC) return [cursorTC]
59
+
60
+ const out: TimelineRenderLine[] = []
61
+ for (const tu of walkToolUses(ev)) {
62
+ const summary = summarizeToolInput(tu.input)
63
+ const label = summary ? `${tu.name}(${summary})` : tu.name
64
+ out.push(line("tool_use", noColor, "tool_use", label))
65
+ }
66
+ if (out.length > 0) return out
67
+
68
+ if (type === "tool_use" || type === "tool_call") {
69
+ const name = typeof ev.name === "string" ? ev.name : "tool"
70
+ out.push(line("tool_use", noColor, "tool_use", `${name}(${summarizeToolInput(ev.input ?? ev.arguments)})`))
71
+ return out
72
+ }
73
+
74
+ return []
75
+ }
76
+
77
+ function extractCursorToolCall(ev: ParsedEvent, noColor: boolean): TimelineRenderLine | null {
78
+ if (ev.type !== "tool_call") return null
79
+ const tc = ev.tool_call as Record<string, unknown> | undefined
80
+ if (!tc || typeof tc !== "object") return null
81
+ const entries = Object.entries(tc)
82
+ if (entries.length === 0) return null
83
+ const [rawKey, payload] = entries[0]!
84
+ if (!payload || typeof payload !== "object") return null
85
+ const toolName = rawKey.replace(/ToolCall$/, "")
86
+ const pl = payload as Record<string, unknown>
87
+ const subtype = typeof ev.subtype === "string" ? ev.subtype : "started"
88
+ if (subtype === "completed") {
89
+ const body = extractCursorResultText(pl.result)
90
+ const preview = truncateOneLine(body.replace(/\s+/g, " "), 160)
91
+ const size = body.length > 0 ? ` (${formatByteSize(body.length)})` : ""
92
+ return line("tool_result", noColor, `${toolName}${size}`, preview || "ok")
93
+ }
94
+ const summary = summarizeToolInput(pl.args ?? pl.input)
95
+ return line("tool_use", noColor, "tool_use", summary ? `${toolName}(${summary})` : toolName)
96
+ }
97
+
98
+ function extractCursorResultText(result: unknown): string {
99
+ if (result == null) return ""
100
+ if (typeof result === "string") return result
101
+ if (typeof result !== "object") return String(result)
102
+ const rec = result as Record<string, unknown>
103
+ // cursor-agent: { success: { content, ... } } or { error: { message } }
104
+ const success = rec.success as Record<string, unknown> | undefined
105
+ if (success) {
106
+ const direct = pickString(success, ["content", "text", "output", "result"])
107
+ if (direct) return direct
108
+ }
109
+ const error = rec.error as Record<string, unknown> | undefined
110
+ if (error) {
111
+ const msg = pickString(error, ["message", "error", "text"])
112
+ if (msg) return `error: ${msg}`
113
+ }
114
+ const top = pickString(rec, ["content", "text", "output", "message"])
115
+ if (top) return top
116
+ return ""
117
+ }
118
+
119
+ export function formatResultSummaryLine(opts: {
120
+ toolCallCount: number
121
+ filesChanged: string[]
122
+ usage?: { input?: number; output?: number }
123
+ cost?: string
124
+ noColor: boolean
125
+ }): TimelineRenderLine {
126
+ const parts: string[] = []
127
+ parts.push(`${opts.toolCallCount} tool calls`)
128
+ if (opts.filesChanged.length > 0) {
129
+ parts.push(`${opts.filesChanged.length} files changed`)
130
+ }
131
+ if (opts.usage?.input != null && opts.usage?.output != null) {
132
+ parts.push(`${formatTokenCount(opts.usage.input)} in / ${formatTokenCount(opts.usage.output)} out`)
133
+ }
134
+ if (opts.cost) parts.push(opts.cost)
135
+ return line("result", opts.noColor, "result", parts.join(" · "))
136
+ }
137
+
138
+ function line(
139
+ kind: AgentEventKind,
140
+ noColor: boolean,
141
+ label: string,
142
+ detail: string,
143
+ ): TimelineRenderLine {
144
+ const g = eventGlyph(kind, noColor)
145
+ const body = detail ? ` ${label} ${detail}` : ` ${label}`
146
+ return { kind, text: `${g}${body}`.trimEnd() }
147
+ }
148
+
149
+ function summarizeToolInput(input: unknown): string {
150
+ if (input == null) return ""
151
+ if (typeof input === "string") return truncateOneLine(input, 80)
152
+ if (typeof input !== "object") return truncateOneLine(String(input), 80)
153
+ const rec = input as Record<string, unknown>
154
+ const parts: string[] = []
155
+ for (const k of TOOL_ARG_KEYS) {
156
+ const v = rec[k]
157
+ if (typeof v === "string" && v.length > 0) parts.push(v)
158
+ }
159
+ if (parts.length === 0) {
160
+ for (const [k, v] of Object.entries(rec).slice(0, 2)) {
161
+ if (typeof v === "string" && v.length > 0) parts.push(`${k}=${v}`)
162
+ }
163
+ }
164
+ return truncateOneLine(parts.join(", "), 80)
165
+ }
166
+
167
+ function extractText(ev: ParsedEvent): string {
168
+ const direct =
169
+ pickString(ev, ["content", "result", "text", "output"]) ??
170
+ pickString(ev.message as Record<string, unknown> | undefined, ["content", "text"])
171
+ if (direct) return direct
172
+ if (typeof ev.content === "string") return ev.content
173
+ return JSON.stringify(ev).slice(0, 500)
174
+ }
175
+
176
+ function extractAssistantText(ev: ParsedEvent): string {
177
+ if (typeof ev.text === "string") return ev.text
178
+ if (typeof ev.content === "string") return ev.content
179
+ const msg = ev.message as Record<string, unknown> | undefined
180
+ if (msg) {
181
+ const content = msg.content
182
+ if (Array.isArray(content)) {
183
+ const parts: string[] = []
184
+ for (const block of content) {
185
+ if (!block || typeof block !== "object") continue
186
+ const b = block as Record<string, unknown>
187
+ if (b.type === "text" && typeof b.text === "string" && b.text.length > 0) {
188
+ parts.push(b.text)
189
+ }
190
+ }
191
+ if (parts.length > 0) return parts.join(" ")
192
+ }
193
+ const t = pickString(msg, ["text", "content"])
194
+ if (t) return t
195
+ }
196
+ return ""
197
+ }
198
+
199
+ function pickString(obj: Record<string, unknown> | undefined, keys: string[]): string | undefined {
200
+ if (!obj) return undefined
201
+ for (const k of keys) {
202
+ const v = obj[k]
203
+ if (typeof v === "string" && v.length > 0) return v
204
+ }
205
+ return undefined
206
+ }
207
+
208
+ function truncateOneLine(s: string, n: number): string {
209
+ const one = s.replace(/\s+/g, " ").trim()
210
+ if (one.length <= n) return one
211
+ return one.slice(0, n - 1) + "…"
212
+ }
213
+
214
+ function formatByteSize(n: number): string {
215
+ if (n < 1024) return `${n} B`
216
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
217
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`
218
+ }
219
+
220
+ function formatTokenCount(n: number): string {
221
+ if (n < 1000) return String(n)
222
+ if (n < 1_000_000) return `${Math.round(n / 1000)}k`
223
+ return `${(n / 1_000_000).toFixed(1)}M`
224
+ }
@@ -0,0 +1,27 @@
1
+ import { spawn } from "node:child_process"
2
+
3
+ import type { JobRecord } from "@davstack/open-agents/core/jobs"
4
+
5
+ // Render the diff for a job's filesChanged list by shelling out to git.
6
+ // Async + non-blocking so the render thread isn't stalled while git
7
+ // runs (Ink can otherwise leave the previous pane's frame on screen
8
+ // for hundreds of ms). Uses `git diff HEAD -- <files>` so unstaged +
9
+ // staged changes both show. Empty stdout means the agent's edits were
10
+ // already committed — callers should fall back to the file list.
11
+ export function readAgentDiff(job: JobRecord): Promise<{ diff: string; files: string[] }> {
12
+ const files = job.filesChanged ?? []
13
+ if (files.length === 0) return Promise.resolve({ diff: "", files: [] })
14
+ return new Promise((resolve) => {
15
+ const proc = spawn("git", ["diff", "--no-color", "HEAD", "--", ...files], {
16
+ cwd: job.repoPath,
17
+ windowsHide: true,
18
+ })
19
+ const chunks: Buffer[] = []
20
+ proc.stdout?.on("data", (b: Buffer) => chunks.push(b))
21
+ proc.once("error", () => resolve({ diff: "", files }))
22
+ proc.once("close", () => {
23
+ const diff = Buffer.concat(chunks).toString("utf8")
24
+ resolve({ diff, files })
25
+ })
26
+ })
27
+ }
@@ -0,0 +1,13 @@
1
+ import fs from "node:fs"
2
+ import { join } from "node:path"
3
+
4
+ import { jobsDir } from "@davstack/open-agents/core/paths"
5
+
6
+ export function readAgentSpecContent(opts: {
7
+ repoPath: string
8
+ jobId: string
9
+ }): string | null {
10
+ const path = join(jobsDir(opts.repoPath), `${opts.jobId}.spec.md`)
11
+ if (!fs.existsSync(path)) return null
12
+ return fs.readFileSync(path, "utf8")
13
+ }
@@ -0,0 +1,59 @@
1
+ import React, {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useRef,
6
+ useState,
7
+ type ReactNode,
8
+ } from "react"
9
+
10
+ import type { JobRecord } from "@davstack/open-agents/core/jobs"
11
+ import { useAgentJobs } from "../hooks/useAgentJobs.ts"
12
+ import { getRepoRootSafe } from "../lib/package-info.ts"
13
+
14
+ export type AgentPane = "spec" | "logs" | "diff"
15
+
16
+ interface AgentsContextValue {
17
+ jobs: JobRecord[]
18
+ agentPane: AgentPane
19
+ setAgentPane: (pane: AgentPane) => void
20
+ registerTimelineClear: (fn: (() => void) | null) => void
21
+ clearAgentTimeline: () => void
22
+ }
23
+
24
+ const AgentsContext = createContext<AgentsContextValue | undefined>(undefined)
25
+
26
+ export function AgentsProvider({ children }: { children: ReactNode }): React.ReactElement {
27
+ const repoPath = getRepoRootSafe()
28
+ const { jobs } = useAgentJobs(repoPath)
29
+ const clearRef = useRef<(() => void) | null>(null)
30
+ const [agentPane, setAgentPane] = useState<AgentPane>("spec")
31
+
32
+ const registerTimelineClear = useCallback((fn: (() => void) | null) => {
33
+ clearRef.current = fn
34
+ }, [])
35
+
36
+ const clearAgentTimeline = useCallback(() => {
37
+ clearRef.current?.()
38
+ }, [])
39
+
40
+ return (
41
+ <AgentsContext.Provider
42
+ value={{
43
+ jobs,
44
+ agentPane,
45
+ setAgentPane,
46
+ registerTimelineClear,
47
+ clearAgentTimeline,
48
+ }}
49
+ >
50
+ {children}
51
+ </AgentsContext.Provider>
52
+ )
53
+ }
54
+
55
+ export function useAgents(): AgentsContextValue {
56
+ const value = useContext(AgentsContext)
57
+ if (!value) throw new Error("useAgents must be used within an AgentsProvider")
58
+ return value
59
+ }
@@ -6,16 +6,33 @@ import React, { createContext, useCallback, useContext, useState, type ReactNode
6
6
 
7
7
  import type { DaemonKey } from "../lib/daemon-registry.ts"
8
8
 
9
- export type View = { kind: "list" } | { kind: "log"; key: DaemonKey }
9
+ export type View =
10
+ | { kind: "list" }
11
+ | { kind: "log"; key: DaemonKey }
12
+ | { kind: "agents" }
13
+ | { kind: "agent"; id: string }
10
14
 
11
15
  function useViewInner() {
12
16
  const [view, setView] = useState<View>({ kind: "list" })
13
17
  const [focusedIdx, setFocusedIdx] = useState(0)
18
+ const [highlightedAgentId, setHighlightedAgentId] = useState<string | undefined>(undefined)
14
19
 
15
20
  const showList = useCallback(() => setView({ kind: "list" }), [])
16
21
  const showLog = useCallback((key: DaemonKey) => setView({ kind: "log", key }), [])
17
-
18
- return { view, focusedIdx, setFocusedIdx, showList, showLog }
22
+ const showAgents = useCallback(() => setView({ kind: "agents" }), [])
23
+ const showAgent = useCallback((id: string) => setView({ kind: "agent", id }), [])
24
+
25
+ return {
26
+ view,
27
+ focusedIdx,
28
+ setFocusedIdx,
29
+ highlightedAgentId,
30
+ setHighlightedAgentId,
31
+ showList,
32
+ showLog,
33
+ showAgents,
34
+ showAgent,
35
+ }
19
36
  }
20
37
 
21
38
  type ViewContextValue = ReturnType<typeof useViewInner>
@@ -0,0 +1,252 @@
1
+ import React, { useEffect, useMemo, useState } from "react"
2
+ import { Box, Text, useInput, useStdout } from "ink"
3
+
4
+ import type { JobStatus } from "@davstack/open-agents/core/jobs"
5
+
6
+ import { useAgentTimeline } from "../hooks/useAgentTimeline.ts"
7
+ import { useNoColor, colorOrUndef } from "../hooks/useNoColor.ts"
8
+ import { getRepoRootSafe } from "../lib/package-info.ts"
9
+ import { readAgentSpecContent } from "../lib/read-agent-overlay.ts"
10
+ import { readAgentDiff } from "../lib/read-agent-diff.ts"
11
+ import { useAgents, type AgentPane } from "../state/agents-context.tsx"
12
+ import { inferAgentTitle } from "../lib/agent-title.ts"
13
+ import { ControlsHint } from "../components/ControlsHint.tsx"
14
+ import { Markdown } from "../components/Markdown.tsx"
15
+
16
+ const STATUS_COLOR: Record<JobStatus, string> = {
17
+ running: "yellow",
18
+ done: "gray",
19
+ failed: "red",
20
+ cancelled: "gray",
21
+ }
22
+
23
+ const TIMELINE_CONTROLS =
24
+ "s spec l logs d diff c clear logs esc back q quit"
25
+
26
+ export interface AgentTimelineViewProps {
27
+ jobId: string
28
+ }
29
+
30
+ export function AgentTimelineView(props: AgentTimelineViewProps): React.ReactElement {
31
+ const { jobId } = props
32
+ const repoPath = getRepoRootSafe()
33
+ const noColor = useNoColor()
34
+ const { agentPane, setAgentPane, registerTimelineClear } = useAgents()
35
+ const { lines, job, clear } = useAgentTimeline(repoPath, jobId, noColor)
36
+ const { stdout } = useStdout()
37
+ const rows = stdout?.rows ?? 24
38
+ const visible = Math.max(8, rows - 6)
39
+
40
+ useEffect(() => {
41
+ registerTimelineClear(clear)
42
+ return () => registerTimelineClear(null)
43
+ }, [clear, registerTimelineClear])
44
+
45
+ // Default to the spec pane every time the user enters a new job.
46
+ useEffect(() => {
47
+ setAgentPane("spec")
48
+ }, [jobId, setAgentPane])
49
+
50
+ const rawModeSupported = process.stdin.isTTY === true
51
+ const [controlsOpen, setControlsOpen] = useState(false)
52
+ useInput(
53
+ (input) => {
54
+ if (input === "s") setAgentPane("spec")
55
+ else if (input === "l") setAgentPane("logs")
56
+ else if (input === "d") setAgentPane("diff")
57
+ else if (input === "c") setControlsOpen((v) => !v)
58
+ },
59
+ { isActive: rawModeSupported },
60
+ )
61
+
62
+ if (!job) {
63
+ return (
64
+ <Box flexDirection="column">
65
+ <Text dimColor>Job not found: {jobId}</Text>
66
+ <Box marginTop={1}>
67
+ <Text dimColor>esc back · q quit</Text>
68
+ </Box>
69
+ </Box>
70
+ )
71
+ }
72
+
73
+ const title = inferAgentTitle({ prompt: job.prompt })
74
+ const adapter = job.model.toLowerCase().startsWith("gemini-") ? "gemini" : "cursor"
75
+
76
+ return (
77
+ <Box flexDirection="column">
78
+ <Box>
79
+ <Text bold>{title || jobId}</Text>
80
+ <Text dimColor> · {jobId} · {adapter} · {job.model} · </Text>
81
+ <Text color={colorOrUndef(STATUS_COLOR[job.status], noColor)}>({job.status})</Text>
82
+ </Box>
83
+ <PaneTabs active={agentPane} />
84
+ <Box flexDirection="column" marginTop={1} height={visible} flexShrink={0}>
85
+ <PaneBody pane={agentPane} jobId={jobId} repoPath={repoPath} job={job} lines={lines} visible={visible} />
86
+ </Box>
87
+ <ControlsHint expanded={controlsOpen} controls={TIMELINE_CONTROLS} />
88
+ </Box>
89
+ )
90
+ }
91
+
92
+ function PaneTabs({ active }: { active: AgentPane }): React.ReactElement {
93
+ const entries: Array<{ key: AgentPane; label: string; hotkey: string }> = [
94
+ { key: "spec", label: "spec", hotkey: "s" },
95
+ { key: "logs", label: "logs", hotkey: "l" },
96
+ { key: "diff", label: "diff", hotkey: "d" },
97
+ ]
98
+ return (
99
+ <Box marginTop={1}>
100
+ {entries.map((e, i) => (
101
+ <Box key={e.key} marginRight={i === entries.length - 1 ? 0 : 2}>
102
+ <Text bold={active === e.key} inverse={active === e.key}>
103
+ {" "}
104
+ {e.hotkey} {e.label}{" "}
105
+ </Text>
106
+ </Box>
107
+ ))}
108
+ </Box>
109
+ )
110
+ }
111
+
112
+ interface PaneBodyProps {
113
+ pane: AgentPane
114
+ jobId: string
115
+ repoPath: string
116
+ job: NonNullable<ReturnType<typeof useAgentTimeline>["job"]>
117
+ lines: ReturnType<typeof useAgentTimeline>["lines"]
118
+ visible: number
119
+ }
120
+
121
+ function PaneBody(props: PaneBodyProps): React.ReactElement {
122
+ if (props.pane === "spec") return <SpecPane repoPath={props.repoPath} jobId={props.jobId} visible={props.visible} />
123
+ if (props.pane === "diff") return <DiffPane job={props.job} visible={props.visible} />
124
+ return <LogsPane lines={props.lines} visible={props.visible} rawLogPath={props.job.rawLogPath} />
125
+ }
126
+
127
+ function SpecPane({
128
+ repoPath,
129
+ jobId,
130
+ visible,
131
+ }: {
132
+ repoPath: string
133
+ jobId: string
134
+ visible: number
135
+ }): React.ReactElement {
136
+ const content = useMemo(() => readAgentSpecContent({ repoPath, jobId }), [repoPath, jobId])
137
+ if (!content) {
138
+ return <Text dimColor>(no spec file on disk)</Text>
139
+ }
140
+ const totalLines = content.replace(/\r\n/g, "\n").split("\n").length
141
+ return (
142
+ <Box flexDirection="column">
143
+ <Markdown source={content} maxLines={visible} />
144
+ {totalLines > visible ? (
145
+ <Text dimColor>… {totalLines - visible} more lines</Text>
146
+ ) : null}
147
+ </Box>
148
+ )
149
+ }
150
+
151
+ function LogsPane({
152
+ lines,
153
+ visible,
154
+ rawLogPath,
155
+ }: {
156
+ lines: ReturnType<typeof useAgentTimeline>["lines"]
157
+ visible: number
158
+ rawLogPath: string
159
+ }): React.ReactElement {
160
+ const tail = lines.slice(-visible)
161
+ return (
162
+ <Box flexDirection="column">
163
+ {tail.length === 0 ? (
164
+ <Text dimColor>(no events yet)</Text>
165
+ ) : (
166
+ tail.map((l, i) => (
167
+ <Text key={i}>
168
+ {formatLineTs(l.ts)} {l.text}
169
+ </Text>
170
+ ))
171
+ )}
172
+ <Box marginTop={1}>
173
+ <Text dimColor>Raw log: {rawLogPath}</Text>
174
+ </Box>
175
+ </Box>
176
+ )
177
+ }
178
+
179
+ function DiffPane({
180
+ job,
181
+ visible,
182
+ }: {
183
+ job: PaneBodyProps["job"]
184
+ visible: number
185
+ }): React.ReactElement {
186
+ // Cache by jobId + filesChanged size — re-running git on every poll
187
+ // tick (job ref changes every 700ms) blocks the render thread and
188
+ // causes stale frames to stick around between pane switches.
189
+ const jobId = job.id
190
+ const filesCount = job.filesChanged?.length ?? 0
191
+ const repoPath = job.repoPath
192
+ const [state, setState] = useState<{ diff: string; files: string[] } | null>(null)
193
+ useEffect(() => {
194
+ let cancelled = false
195
+ setState(null)
196
+ readAgentDiff(job).then((r) => {
197
+ if (!cancelled) setState(r)
198
+ })
199
+ return () => {
200
+ cancelled = true
201
+ }
202
+ // eslint-disable-next-line react-hooks/exhaustive-deps
203
+ }, [jobId, filesCount, repoPath])
204
+
205
+ if (state == null) return <Text dimColor>(computing diff…)</Text>
206
+ const { diff, files } = state
207
+ if (files.length === 0) {
208
+ return <Text dimColor>(no filesChanged recorded for this job)</Text>
209
+ }
210
+ if (!diff.trim()) {
211
+ return (
212
+ <Box flexDirection="column">
213
+ <Text dimColor>
214
+ (no diff against HEAD — changes were committed, or files are outside the repo)
215
+ </Text>
216
+ <Box marginTop={1} flexDirection="column">
217
+ <Text bold>Files reported:</Text>
218
+ {files.map((f) => (
219
+ <Text key={f}> · {f}</Text>
220
+ ))}
221
+ </Box>
222
+ </Box>
223
+ )
224
+ }
225
+ const allLines = diff.split("\n")
226
+ const slice = allLines.slice(0, visible)
227
+ return (
228
+ <Box flexDirection="column">
229
+ {slice.map((l, i) => (
230
+ <Text key={i} wrap="truncate" color={colorForDiffLine(l)}>
231
+ {l}
232
+ </Text>
233
+ ))}
234
+ {allLines.length > visible ? (
235
+ <Text dimColor>… {allLines.length - visible} more lines</Text>
236
+ ) : null}
237
+ </Box>
238
+ )
239
+ }
240
+
241
+ function colorForDiffLine(line: string): string | undefined {
242
+ if (line.startsWith("+") && !line.startsWith("+++")) return "green"
243
+ if (line.startsWith("-") && !line.startsWith("---")) return "red"
244
+ if (line.startsWith("@@")) return "cyan"
245
+ if (line.startsWith("diff --git") || line.startsWith("index ")) return "yellow"
246
+ return undefined
247
+ }
248
+
249
+ function formatLineTs(ts: number): string {
250
+ const d = new Date(ts)
251
+ return d.toTimeString().slice(0, 8)
252
+ }