@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.
- package/package.json +6 -3
- package/src/App.tsx +5 -2
- package/src/cli.ts +25 -5
- package/src/components/BottomBar.tsx +4 -3
- package/src/components/ControlsHint.tsx +19 -0
- package/src/components/MainView.tsx +8 -0
- package/src/components/Markdown.tsx +130 -0
- package/src/components/StatusBar.tsx +34 -2
- package/src/hooks/useAdapterFor.ts +10 -0
- package/src/hooks/useAgentJobs.test.tsx +149 -0
- package/src/hooks/useAgentJobs.ts +53 -0
- package/src/hooks/useAgentTimeline.ts +176 -0
- package/src/hooks/useHotkeys.test.tsx +8 -5
- package/src/hooks/useHotkeys.ts +69 -10
- package/src/lib/agent-glyphs.ts +52 -0
- package/src/lib/agent-pill.ts +25 -0
- package/src/lib/agent-title.test.ts +42 -0
- package/src/lib/agent-title.ts +29 -0
- package/src/lib/format-agent-timeline.test.ts +142 -0
- package/src/lib/format-agent-timeline.ts +224 -0
- package/src/lib/read-agent-diff.ts +27 -0
- package/src/lib/read-agent-overlay.ts +13 -0
- package/src/state/agents-context.tsx +59 -0
- package/src/state/view-context.tsx +20 -3
- package/src/views/AgentTimelineView.tsx +252 -0
- package/src/views/AgentsList.tsx +131 -0
- package/src/views/ServerList.test.tsx +28 -20
- package/src/views/ServerList.tsx +12 -6
|
@@ -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 =
|
|
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
|
-
|
|
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
|
+
}
|