@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,176 @@
|
|
|
1
|
+
import fs from "node:fs"
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from "react"
|
|
3
|
+
|
|
4
|
+
import { readJob, type JobRecord } from "@davstack/open-agents/core/jobs"
|
|
5
|
+
import { walkToolUses } from "@davstack/open-agents/core/parse"
|
|
6
|
+
import type { ParsedEvent, RunSummary } from "@davstack/open-agents/adapters/types"
|
|
7
|
+
import type { MutableRefObject } from "react"
|
|
8
|
+
|
|
9
|
+
import { adapterForJob } from "./useAdapterFor.ts"
|
|
10
|
+
import { useRingBuffer, type LogLine } from "./useRingBuffer.ts"
|
|
11
|
+
import {
|
|
12
|
+
formatEventLines,
|
|
13
|
+
formatResultSummaryLine,
|
|
14
|
+
formatStartLine,
|
|
15
|
+
} from "../lib/format-agent-timeline.ts"
|
|
16
|
+
|
|
17
|
+
const TERMINAL = new Set(["done", "failed", "cancelled"])
|
|
18
|
+
|
|
19
|
+
export interface UseAgentTimelineResult {
|
|
20
|
+
job: JobRecord | null
|
|
21
|
+
lines: LogLine[]
|
|
22
|
+
summary: RunSummary | null
|
|
23
|
+
done: boolean
|
|
24
|
+
clear: () => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function useAgentTimeline(
|
|
28
|
+
repoPath: string,
|
|
29
|
+
id: string,
|
|
30
|
+
noColor: boolean,
|
|
31
|
+
): UseAgentTimelineResult {
|
|
32
|
+
const { lines, push, clear } = useRingBuffer()
|
|
33
|
+
const eventsRef = useRef<ParsedEvent[]>([])
|
|
34
|
+
const offsetRef = useRef(0)
|
|
35
|
+
const startLineRef = useRef(false)
|
|
36
|
+
const summaryRef = useRef<RunSummary | null>(null)
|
|
37
|
+
const [job, setJob] = useState<JobRecord | null>(null)
|
|
38
|
+
const [done, setDone] = useState(false)
|
|
39
|
+
const [summaryTick, setSummaryTick] = useState(0)
|
|
40
|
+
|
|
41
|
+
const adapter = useMemo(() => (job ? adapterForJob(job) : cursorAdapterFallback()), [job])
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
eventsRef.current = []
|
|
45
|
+
offsetRef.current = 0
|
|
46
|
+
startLineRef.current = false
|
|
47
|
+
summaryRef.current = null
|
|
48
|
+
setDone(false)
|
|
49
|
+
setSummaryTick(0)
|
|
50
|
+
clear()
|
|
51
|
+
}, [repoPath, id, clear])
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
let cancelled = false
|
|
55
|
+
|
|
56
|
+
const poll = () => {
|
|
57
|
+
if (cancelled) return
|
|
58
|
+
const fresh = readJob(repoPath, id)
|
|
59
|
+
if (!fresh) return
|
|
60
|
+
setJob(fresh)
|
|
61
|
+
|
|
62
|
+
if (!startLineRef.current) {
|
|
63
|
+
startLineRef.current = true
|
|
64
|
+
const start = formatStartLine({ prompt: fresh.prompt, noColor })
|
|
65
|
+
push({ ts: Date.now(), stream: "out", text: start.text })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (fs.existsSync(fresh.rawLogPath)) {
|
|
69
|
+
const txt = fs.readFileSync(fresh.rawLogPath, "utf8")
|
|
70
|
+
if (txt.length > offsetRef.current) {
|
|
71
|
+
const slice = txt.slice(offsetRef.current)
|
|
72
|
+
offsetRef.current = txt.length
|
|
73
|
+
ingestLines({ adapter, chunk: slice, eventsRef, push, noColor })
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (TERMINAL.has(fresh.status)) {
|
|
78
|
+
setDone(true)
|
|
79
|
+
if (!summaryRef.current) {
|
|
80
|
+
summaryRef.current = adapter.summarise(eventsRef.current)
|
|
81
|
+
const toolCalls = countToolCalls(eventsRef.current)
|
|
82
|
+
const usage = pickUsage(eventsRef.current)
|
|
83
|
+
const cost = pickCostFromEvents(eventsRef.current)
|
|
84
|
+
const summaryLine = formatResultSummaryLine({
|
|
85
|
+
toolCallCount: toolCalls,
|
|
86
|
+
filesChanged: summaryRef.current.filesChanged,
|
|
87
|
+
usage,
|
|
88
|
+
cost,
|
|
89
|
+
noColor,
|
|
90
|
+
})
|
|
91
|
+
push({ ts: Date.now(), stream: "out", text: summaryLine.text })
|
|
92
|
+
setSummaryTick((t) => t + 1)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
poll()
|
|
98
|
+
const idTimer = setInterval(poll, 700)
|
|
99
|
+
return () => {
|
|
100
|
+
cancelled = true
|
|
101
|
+
clearInterval(idTimer)
|
|
102
|
+
}
|
|
103
|
+
}, [repoPath, id, adapter, push, noColor])
|
|
104
|
+
|
|
105
|
+
const summary = useMemo(() => summaryRef.current, [summaryTick, done])
|
|
106
|
+
|
|
107
|
+
return { job, lines, summary, done, clear }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function ingestLines(opts: {
|
|
111
|
+
adapter: ReturnType<typeof adapterForJob>
|
|
112
|
+
chunk: string
|
|
113
|
+
eventsRef: MutableRefObject<ParsedEvent[]>
|
|
114
|
+
push: (line: LogLine) => void
|
|
115
|
+
noColor: boolean
|
|
116
|
+
}): void {
|
|
117
|
+
for (const line of opts.chunk.split("\n")) {
|
|
118
|
+
if (!line.trim()) continue
|
|
119
|
+
const ev = opts.adapter.parseLine(line)
|
|
120
|
+
if (!ev) continue
|
|
121
|
+
opts.eventsRef.current.push(ev)
|
|
122
|
+
for (const row of formatEventLines({ ev, noColor: opts.noColor })) {
|
|
123
|
+
opts.push({ ts: Date.now(), stream: "out", text: row.text })
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function countToolCalls(events: ParsedEvent[]): number {
|
|
129
|
+
let n = 0
|
|
130
|
+
for (const ev of events) {
|
|
131
|
+
for (const _ of walkToolUses(ev)) n += 1
|
|
132
|
+
const type = ev.type
|
|
133
|
+
if (type === "tool_use" || type === "tool_call") n += 1
|
|
134
|
+
}
|
|
135
|
+
return n
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function pickUsage(events: ParsedEvent[]): { input?: number; output?: number } | undefined {
|
|
139
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
140
|
+
const ev = events[i]
|
|
141
|
+
if (ev?.type !== "result") continue
|
|
142
|
+
const usage = ev.usage as Record<string, unknown> | undefined
|
|
143
|
+
if (!usage) continue
|
|
144
|
+
const input =
|
|
145
|
+
typeof usage.input_tokens === "number"
|
|
146
|
+
? usage.input_tokens
|
|
147
|
+
: typeof usage.input === "number"
|
|
148
|
+
? usage.input
|
|
149
|
+
: undefined
|
|
150
|
+
const output =
|
|
151
|
+
typeof usage.output_tokens === "number"
|
|
152
|
+
? usage.output_tokens
|
|
153
|
+
: typeof usage.output === "number"
|
|
154
|
+
? usage.output
|
|
155
|
+
: undefined
|
|
156
|
+
if (input != null || output != null) return { input, output }
|
|
157
|
+
}
|
|
158
|
+
return undefined
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function pickCostFromEvents(events: ParsedEvent[]): string | undefined {
|
|
162
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
163
|
+
const ev = events[i]
|
|
164
|
+
if (ev?.type !== "result") continue
|
|
165
|
+
if (typeof ev.cost === "number") return `$${ev.cost.toFixed(2)}`
|
|
166
|
+
if (typeof ev.cost === "string") return ev.cost
|
|
167
|
+
const pricing = ev.pricing as Record<string, unknown> | undefined
|
|
168
|
+
if (pricing && typeof pricing.total === "number") return `$${pricing.total.toFixed(2)}`
|
|
169
|
+
if (pricing && typeof pricing.total_cost === "number") return `$${pricing.total_cost.toFixed(2)}`
|
|
170
|
+
}
|
|
171
|
+
return undefined
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function cursorAdapterFallback(): ReturnType<typeof adapterForJob> {
|
|
175
|
+
return adapterForJob({ model: "composer-2.5" })
|
|
176
|
+
}
|
|
@@ -10,6 +10,7 @@ import { render } from "ink-testing-library"
|
|
|
10
10
|
|
|
11
11
|
import { ViewProvider, useView, type View } from "../state/view-context.tsx"
|
|
12
12
|
import { DaemonsProvider, useDaemons, type DaemonRow } from "../state/daemons-context.tsx"
|
|
13
|
+
import { AgentsProvider } from "../state/agents-context.tsx"
|
|
13
14
|
import { QuitProvider, useQuit } from "../state/quit-context.tsx"
|
|
14
15
|
import { useHotkeys, type HotkeyHandlers } from "./useHotkeys.ts"
|
|
15
16
|
import type { DaemonDescriptor } from "../lib/daemon-registry.ts"
|
|
@@ -66,11 +67,13 @@ function renderWithProviders(descriptors: DaemonDescriptor[], onQuit: () => void
|
|
|
66
67
|
let captured: CapturedApis | null = null
|
|
67
68
|
const r = render(
|
|
68
69
|
<ViewProvider>
|
|
69
|
-
<
|
|
70
|
-
<
|
|
71
|
-
<
|
|
72
|
-
|
|
73
|
-
|
|
70
|
+
<AgentsProvider>
|
|
71
|
+
<DaemonsProvider descriptors={descriptors}>
|
|
72
|
+
<QuitProvider>
|
|
73
|
+
<Capture onQuit={onQuit} onUpdate={(api) => (captured = api)} />
|
|
74
|
+
</QuitProvider>
|
|
75
|
+
</DaemonsProvider>
|
|
76
|
+
</AgentsProvider>
|
|
74
77
|
</ViewProvider>,
|
|
75
78
|
)
|
|
76
79
|
if (!captured) throw new Error("Capture never published")
|
package/src/hooks/useHotkeys.ts
CHANGED
|
@@ -7,8 +7,8 @@ import { useCallback } from "react"
|
|
|
7
7
|
|
|
8
8
|
import { useView } from "../state/view-context.tsx"
|
|
9
9
|
import { useDaemons } from "../state/daemons-context.tsx"
|
|
10
|
+
import { useAgents } from "../state/agents-context.tsx"
|
|
10
11
|
import { useQuit } from "../state/quit-context.tsx"
|
|
11
|
-
|
|
12
12
|
export interface KeyEvent {
|
|
13
13
|
ctrl?: boolean
|
|
14
14
|
escape?: boolean
|
|
@@ -39,8 +39,18 @@ export interface HotkeyHandlers {
|
|
|
39
39
|
// it directly when no daemons are live; otherwise we route through the
|
|
40
40
|
// confirm overlay (requestConfirm).
|
|
41
41
|
export function useHotkeys(quit: () => void): HotkeyHandlers {
|
|
42
|
-
const {
|
|
42
|
+
const {
|
|
43
|
+
showLog,
|
|
44
|
+
showList,
|
|
45
|
+
showAgents,
|
|
46
|
+
setFocusedIdx,
|
|
47
|
+
view,
|
|
48
|
+
focusedIdx,
|
|
49
|
+
highlightedAgentId,
|
|
50
|
+
setHighlightedAgentId,
|
|
51
|
+
} = useView()
|
|
43
52
|
const { rowsRef, toggleByKey, clearByKey, takeoverByKey, anyLive } = useDaemons()
|
|
53
|
+
const { jobs, agentPane, clearAgentTimeline } = useAgents()
|
|
44
54
|
const { confirming, requestConfirm, cancelConfirm } = useQuit()
|
|
45
55
|
|
|
46
56
|
const onQuit = useCallback(() => {
|
|
@@ -62,8 +72,15 @@ export function useHotkeys(quit: () => void): HotkeyHandlers {
|
|
|
62
72
|
)
|
|
63
73
|
|
|
64
74
|
const onEscape = useCallback(() => {
|
|
65
|
-
if (view.kind === "
|
|
66
|
-
|
|
75
|
+
if (view.kind === "agent") {
|
|
76
|
+
showAgents()
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
if (view.kind === "log" || view.kind === "agents") {
|
|
80
|
+
if (view.kind === "agents") setHighlightedAgentId(undefined)
|
|
81
|
+
showList()
|
|
82
|
+
}
|
|
83
|
+
}, [view, showList, showAgents, setHighlightedAgentId])
|
|
67
84
|
|
|
68
85
|
const onToggleFocused = useCallback(() => {
|
|
69
86
|
const target = rowsRef.current[focusedIdx]
|
|
@@ -119,8 +136,29 @@ export function useHotkeys(quit: () => void): HotkeyHandlers {
|
|
|
119
136
|
onQuit()
|
|
120
137
|
return
|
|
121
138
|
}
|
|
122
|
-
if (input === "c"
|
|
123
|
-
|
|
139
|
+
if (input === "c") {
|
|
140
|
+
if (view.kind === "log") {
|
|
141
|
+
onClearLog()
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
if (view.kind === "agent" && agentPane === "logs") {
|
|
145
|
+
clearAgentTimeline()
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (input === "g") {
|
|
150
|
+
if (view.kind !== "agents") {
|
|
151
|
+
showAgents()
|
|
152
|
+
if (highlightedAgentId) {
|
|
153
|
+
const idx = jobs.findIndex((j) => j.id === highlightedAgentId)
|
|
154
|
+
if (idx >= 0) setFocusedIdx(idx)
|
|
155
|
+
}
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
const safeFocus = jobs.length === 0 ? 0 : Math.min(focusedIdx, jobs.length - 1)
|
|
159
|
+
const job = jobs[safeFocus]
|
|
160
|
+
if (job?.status === "running") setHighlightedAgentId(job.id)
|
|
161
|
+
showList()
|
|
124
162
|
return
|
|
125
163
|
}
|
|
126
164
|
if (input === "k" && view.kind === "list") {
|
|
@@ -128,19 +166,20 @@ export function useHotkeys(quit: () => void): HotkeyHandlers {
|
|
|
128
166
|
return
|
|
129
167
|
}
|
|
130
168
|
if (/^[1-9]$/.test(input)) {
|
|
169
|
+
if (view.kind === "agents") return
|
|
131
170
|
onNumberKey(Number(input) - 1)
|
|
132
171
|
return
|
|
133
172
|
}
|
|
134
173
|
if (key.leftArrow) {
|
|
135
|
-
onCycleFocus(-1)
|
|
174
|
+
if (view.kind !== "agents") onCycleFocus(-1)
|
|
136
175
|
return
|
|
137
176
|
}
|
|
138
177
|
if (key.rightArrow) {
|
|
139
|
-
onCycleFocus(1)
|
|
178
|
+
if (view.kind !== "agents") onCycleFocus(1)
|
|
140
179
|
return
|
|
141
180
|
}
|
|
142
181
|
if (key.tab) {
|
|
143
|
-
onCycleFocus(key.shift ? -1 : 1)
|
|
182
|
+
if (view.kind !== "agents") onCycleFocus(key.shift ? -1 : 1)
|
|
144
183
|
return
|
|
145
184
|
}
|
|
146
185
|
if (key.escape) {
|
|
@@ -148,7 +187,27 @@ export function useHotkeys(quit: () => void): HotkeyHandlers {
|
|
|
148
187
|
return
|
|
149
188
|
}
|
|
150
189
|
},
|
|
151
|
-
[
|
|
190
|
+
[
|
|
191
|
+
confirming,
|
|
192
|
+
cancelConfirm,
|
|
193
|
+
quit,
|
|
194
|
+
onQuit,
|
|
195
|
+
onNumberKey,
|
|
196
|
+
onEscape,
|
|
197
|
+
onClearLog,
|
|
198
|
+
onTakeoverFocused,
|
|
199
|
+
onCycleFocus,
|
|
200
|
+
showAgents,
|
|
201
|
+
showList,
|
|
202
|
+
view,
|
|
203
|
+
jobs,
|
|
204
|
+
focusedIdx,
|
|
205
|
+
highlightedAgentId,
|
|
206
|
+
setHighlightedAgentId,
|
|
207
|
+
setFocusedIdx,
|
|
208
|
+
agentPane,
|
|
209
|
+
clearAgentTimeline,
|
|
210
|
+
],
|
|
152
211
|
)
|
|
153
212
|
|
|
154
213
|
return {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export type AgentEventKind =
|
|
2
|
+
| "start"
|
|
3
|
+
| "system"
|
|
4
|
+
| "tool_use"
|
|
5
|
+
| "tool_result"
|
|
6
|
+
| "assistant"
|
|
7
|
+
| "result"
|
|
8
|
+
| "other"
|
|
9
|
+
|
|
10
|
+
export const EVENT_GLYPH: Record<AgentEventKind, string> = {
|
|
11
|
+
start: "▶",
|
|
12
|
+
system: "◇",
|
|
13
|
+
tool_use: "◆",
|
|
14
|
+
tool_result: "▸",
|
|
15
|
+
assistant: "✎",
|
|
16
|
+
result: "■",
|
|
17
|
+
other: "·",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const EVENT_GLYPH_ASCII: Record<AgentEventKind, string> = {
|
|
21
|
+
start: ">",
|
|
22
|
+
system: "*",
|
|
23
|
+
tool_use: "+",
|
|
24
|
+
tool_result: "-",
|
|
25
|
+
assistant: "~",
|
|
26
|
+
result: "#",
|
|
27
|
+
other: ".",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function eventGlyph(kind: AgentEventKind, noColor: boolean): string {
|
|
31
|
+
return noColor ? EVENT_GLYPH_ASCII[kind] : EVENT_GLYPH[kind]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type AgentJobStatus = "running" | "done" | "failed" | "cancelled"
|
|
35
|
+
|
|
36
|
+
export const JOB_STATUS_GLYPH: Record<AgentJobStatus, string> = {
|
|
37
|
+
running: "●",
|
|
38
|
+
done: "✓",
|
|
39
|
+
failed: "✗",
|
|
40
|
+
cancelled: "○",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const JOB_STATUS_GLYPH_ASCII: Record<AgentJobStatus, string> = {
|
|
44
|
+
running: "*",
|
|
45
|
+
done: "+",
|
|
46
|
+
failed: "x",
|
|
47
|
+
cancelled: "o",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function jobStatusGlyph(status: AgentJobStatus, noColor: boolean): string {
|
|
51
|
+
return noColor ? JOB_STATUS_GLYPH_ASCII[status] : JOB_STATUS_GLYPH[status]
|
|
52
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { JobRecord } from "@davstack/open-agents/core/jobs"
|
|
2
|
+
|
|
3
|
+
export function inferAdapter(model: string): string {
|
|
4
|
+
const m = model.toLowerCase()
|
|
5
|
+
if (m.startsWith("gemini-")) return "gemini"
|
|
6
|
+
if (m.startsWith("composer-") || m.startsWith("cursor-")) return "cursor"
|
|
7
|
+
return "cursor"
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function jobIdSuffix(id: string): string {
|
|
11
|
+
const parts = id.split("-")
|
|
12
|
+
return parts[parts.length - 1] ?? id
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function formatAgentPillLabel(job: JobRecord): string {
|
|
16
|
+
const tag = job.edit ? "edit" : "explore"
|
|
17
|
+
return `${tag}:${jobIdSuffix(job.id)}`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getRunningPillJobs(jobs: JobRecord[], limit = 5): JobRecord[] {
|
|
21
|
+
return jobs
|
|
22
|
+
.filter((j) => j.status === "running")
|
|
23
|
+
.sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime())
|
|
24
|
+
.slice(0, limit)
|
|
25
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest"
|
|
2
|
+
|
|
3
|
+
import { inferAgentTitle } from "./agent-title.ts"
|
|
4
|
+
|
|
5
|
+
describe("inferAgentTitle", () => {
|
|
6
|
+
test("uses first markdown heading", () => {
|
|
7
|
+
const prompt = "# Phase A1 — Widen exports\n\nbody text..."
|
|
8
|
+
expect(inferAgentTitle({ prompt })).toBe("Phase A1 — Widen exports")
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
test("strips trailing hashes from heading", () => {
|
|
12
|
+
expect(inferAgentTitle({ prompt: "## Quick fix ##" })).toBe("Quick fix")
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test("ignores leading blank lines", () => {
|
|
16
|
+
expect(inferAgentTitle({ prompt: "\n\n\n# Real title\nbody" })).toBe("Real title")
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test("falls back to first 10 words when no heading", () => {
|
|
20
|
+
const prompt = "Refactor the auth flow to use JWT instead of cookies for sessions everywhere"
|
|
21
|
+
expect(inferAgentTitle({ prompt })).toBe("Refactor the auth flow to use JWT instead of cookies")
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test("strips XML tags when falling back", () => {
|
|
25
|
+
const prompt = "<intent>Rename fooBar to computeFoo</intent>"
|
|
26
|
+
expect(inferAgentTitle({ prompt })).toBe("Rename fooBar to computeFoo")
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test("first non-blank line that isn't a heading falls back to words", () => {
|
|
30
|
+
const prompt = "Not a heading\n# But this is\nmore"
|
|
31
|
+
expect(inferAgentTitle({ prompt })).toBe("Not a heading But this is more")
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test("respects maxWords override", () => {
|
|
35
|
+
const prompt = "one two three four five six seven"
|
|
36
|
+
expect(inferAgentTitle({ prompt }, 3)).toBe("one two three")
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test("empty prompt returns empty string", () => {
|
|
40
|
+
expect(inferAgentTitle({ prompt: "" })).toBe("")
|
|
41
|
+
})
|
|
42
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Title inference for a job. Convention: spec body's first markdown
|
|
2
|
+
// heading (`# …`) wins. Fallback to first 5 words of the prompt with
|
|
3
|
+
// tag noise (`<goal>`, `<intent>`, etc.) stripped, so tag-based specs
|
|
4
|
+
// still get a sensible label.
|
|
5
|
+
|
|
6
|
+
const HEADING_RE = /^#+\s+(.+?)\s*#*\s*$/
|
|
7
|
+
const TAG_OPEN_RE = /<[a-zA-Z][\w-]*>/g
|
|
8
|
+
const TAG_CLOSE_RE = /<\/[a-zA-Z][\w-]*>/g
|
|
9
|
+
|
|
10
|
+
export interface TitleInput {
|
|
11
|
+
prompt: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function inferAgentTitle(input: TitleInput, maxWords = 10): string {
|
|
15
|
+
const body = input.prompt ?? ""
|
|
16
|
+
for (const raw of body.split(/\r?\n/)) {
|
|
17
|
+
const line = raw.trim()
|
|
18
|
+
if (line.length === 0) continue
|
|
19
|
+
const m = HEADING_RE.exec(line)
|
|
20
|
+
if (m && m[1]) return m[1].trim()
|
|
21
|
+
break
|
|
22
|
+
}
|
|
23
|
+
const stripped = body
|
|
24
|
+
.replace(TAG_OPEN_RE, " ")
|
|
25
|
+
.replace(TAG_CLOSE_RE, " ")
|
|
26
|
+
.replace(/^\s*#+\s*/gm, " ")
|
|
27
|
+
const words = stripped.replace(/\s+/g, " ").trim().split(" ").filter(Boolean)
|
|
28
|
+
return words.slice(0, maxWords).join(" ")
|
|
29
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// parseLine (cursor + gemini adapters) → formatEventLines → timeline glyphs.
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from "vitest"
|
|
4
|
+
|
|
5
|
+
import { cursorAdapter } from "@davstack/open-agents/adapters/cursor"
|
|
6
|
+
import { geminiAdapter } from "@davstack/open-agents/adapters/gemini"
|
|
7
|
+
|
|
8
|
+
import { EVENT_GLYPH, EVENT_GLYPH_ASCII } from "./agent-glyphs.ts"
|
|
9
|
+
import { formatEventLines, formatStartLine } from "./format-agent-timeline.ts"
|
|
10
|
+
|
|
11
|
+
const CURSOR_SYSTEM = '{"type":"system","session_id":"abc-123"}'
|
|
12
|
+
const GEMINI_INIT = '{"type":"init","session_id":"s1"}'
|
|
13
|
+
const CURSOR_TOOL =
|
|
14
|
+
'{"type":"tool_use","name":"read_file","input":{"path":"src/foo.ts"}}'
|
|
15
|
+
const GEMINI_TOOL =
|
|
16
|
+
'{"type":"tool_use","name":"grep","input":{"pattern":"aggregationMode","glob":"**/*.tsx"}}'
|
|
17
|
+
const CURSOR_ASSISTANT = '{"type":"assistant","message":{"text":"working on it"}}'
|
|
18
|
+
const GEMINI_ASSISTANT =
|
|
19
|
+
'{"type":"message","role":"assistant","content":"hello from gemini"}'
|
|
20
|
+
const CURSOR_TOOL_RESULT =
|
|
21
|
+
'{"type":"tool_result","content":"export function QueryBuilder() {}"}'
|
|
22
|
+
|
|
23
|
+
describe("formatEventLines — cursor stream-json", () => {
|
|
24
|
+
test("system line uses ◇ glyph", () => {
|
|
25
|
+
const ev = cursorAdapter.parseLine(CURSOR_SYSTEM)!
|
|
26
|
+
const rows = formatEventLines({ ev, noColor: false })
|
|
27
|
+
expect(rows).toHaveLength(1)
|
|
28
|
+
expect(rows[0]!.text.startsWith(EVENT_GLYPH.system)).toBe(true)
|
|
29
|
+
expect(rows[0]!.text).toContain("model loaded")
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test("tool_use uses ◆ glyph with path hint", () => {
|
|
33
|
+
const ev = cursorAdapter.parseLine(CURSOR_TOOL)!
|
|
34
|
+
const rows = formatEventLines({ ev, noColor: false })
|
|
35
|
+
expect(rows[0]!.text.startsWith(EVENT_GLYPH.tool_use)).toBe(true)
|
|
36
|
+
expect(rows[0]!.text).toContain("read_file")
|
|
37
|
+
expect(rows[0]!.text).toContain("src/foo.ts")
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test("assistant text uses ✎ glyph", () => {
|
|
41
|
+
const ev = cursorAdapter.parseLine(CURSOR_ASSISTANT)!
|
|
42
|
+
const rows = formatEventLines({ ev, noColor: false })
|
|
43
|
+
expect(rows[0]!.text.startsWith(EVENT_GLYPH.assistant)).toBe(true)
|
|
44
|
+
expect(rows[0]!.text).toContain("working on it")
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test("tool_result uses ▸ glyph", () => {
|
|
48
|
+
const ev = cursorAdapter.parseLine(CURSOR_TOOL_RESULT)!
|
|
49
|
+
const rows = formatEventLines({ ev, noColor: false })
|
|
50
|
+
expect(rows[0]!.text.startsWith(EVENT_GLYPH.tool_result)).toBe(true)
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe("formatEventLines — gemini stream-json", () => {
|
|
55
|
+
test("init line uses same ◇ glyph as cursor system", () => {
|
|
56
|
+
const ev = geminiAdapter.parseLine(GEMINI_INIT)!
|
|
57
|
+
const rows = formatEventLines({ ev, noColor: false })
|
|
58
|
+
expect(rows[0]!.text.startsWith(EVENT_GLYPH.system)).toBe(true)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test("tool_use uses ◆ glyph with arg summary", () => {
|
|
62
|
+
const ev = geminiAdapter.parseLine(GEMINI_TOOL)!
|
|
63
|
+
const rows = formatEventLines({ ev, noColor: false })
|
|
64
|
+
expect(rows[0]!.text.startsWith(EVENT_GLYPH.tool_use)).toBe(true)
|
|
65
|
+
expect(rows[0]!.text).toContain("grep")
|
|
66
|
+
expect(rows[0]!.text).toContain("aggregationMode")
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test("assistant message uses ✎ glyph", () => {
|
|
70
|
+
const ev = geminiAdapter.parseLine(GEMINI_ASSISTANT)!
|
|
71
|
+
const rows = formatEventLines({ ev, noColor: false })
|
|
72
|
+
expect(rows[0]!.text.startsWith(EVENT_GLYPH.assistant)).toBe(true)
|
|
73
|
+
expect(rows[0]!.text).toContain("hello from gemini")
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe("formatEventLines — noColor", () => {
|
|
78
|
+
test("cursor tool_use falls back to ASCII + glyph", () => {
|
|
79
|
+
const ev = cursorAdapter.parseLine(CURSOR_TOOL)!
|
|
80
|
+
const rows = formatEventLines({ ev, noColor: true })
|
|
81
|
+
expect(rows[0]!.text.startsWith(EVENT_GLYPH_ASCII.tool_use)).toBe(true)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test("formatStartLine uses ▶ glyph", () => {
|
|
86
|
+
const row = formatStartLine({ prompt: "audit auth flows", noColor: false })
|
|
87
|
+
expect(row.text.startsWith(EVENT_GLYPH.start)).toBe(true)
|
|
88
|
+
expect(row.text).toContain("audit auth flows")
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe("formatEventLines — real cursor-agent shapes", () => {
|
|
92
|
+
const CURSOR_REAL_TOOL_STARTED = JSON.stringify({
|
|
93
|
+
type: "tool_call",
|
|
94
|
+
subtype: "started",
|
|
95
|
+
call_id: "tool_abc",
|
|
96
|
+
tool_call: { readToolCall: { args: { path: "C:/foo/bar.ts", offset: 1, limit: 100 } } },
|
|
97
|
+
})
|
|
98
|
+
const CURSOR_REAL_TOOL_COMPLETED = JSON.stringify({
|
|
99
|
+
type: "tool_call",
|
|
100
|
+
subtype: "completed",
|
|
101
|
+
call_id: "tool_abc",
|
|
102
|
+
tool_call: {
|
|
103
|
+
readToolCall: {
|
|
104
|
+
args: { path: "C:/foo/bar.ts" },
|
|
105
|
+
result: { success: { content: "export function x() { return 1 }" } },
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
})
|
|
109
|
+
const CURSOR_REAL_ASSISTANT = JSON.stringify({
|
|
110
|
+
type: "assistant",
|
|
111
|
+
message: {
|
|
112
|
+
role: "assistant",
|
|
113
|
+
content: [{ type: "text", text: "I'll start by reading the file." }],
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test("nested tool_call.readToolCall.args renders as tool_use with name + path", () => {
|
|
118
|
+
const ev = cursorAdapter.parseLine(CURSOR_REAL_TOOL_STARTED)!
|
|
119
|
+
const rows = formatEventLines({ ev, noColor: false })
|
|
120
|
+
expect(rows).toHaveLength(1)
|
|
121
|
+
expect(rows[0]!.text.startsWith(EVENT_GLYPH.tool_use)).toBe(true)
|
|
122
|
+
expect(rows[0]!.text).toContain("read")
|
|
123
|
+
expect(rows[0]!.text).toContain("C:/foo/bar.ts")
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test("tool_call subtype completed renders as tool_result with preview", () => {
|
|
127
|
+
const ev = cursorAdapter.parseLine(CURSOR_REAL_TOOL_COMPLETED)!
|
|
128
|
+
const rows = formatEventLines({ ev, noColor: false })
|
|
129
|
+
expect(rows).toHaveLength(1)
|
|
130
|
+
expect(rows[0]!.text.startsWith(EVENT_GLYPH.tool_result)).toBe(true)
|
|
131
|
+
expect(rows[0]!.text).toContain("read")
|
|
132
|
+
expect(rows[0]!.text).toContain("export function x()")
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test("assistant message.content[] text block renders as ✎ line", () => {
|
|
136
|
+
const ev = cursorAdapter.parseLine(CURSOR_REAL_ASSISTANT)!
|
|
137
|
+
const rows = formatEventLines({ ev, noColor: false })
|
|
138
|
+
expect(rows).toHaveLength(1)
|
|
139
|
+
expect(rows[0]!.text.startsWith(EVENT_GLYPH.assistant)).toBe(true)
|
|
140
|
+
expect(rows[0]!.text).toContain("I'll start by reading the file.")
|
|
141
|
+
})
|
|
142
|
+
})
|