@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,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
- <DaemonsProvider descriptors={descriptors}>
70
- <QuitProvider>
71
- <Capture onQuit={onQuit} onUpdate={(api) => (captured = api)} />
72
- </QuitProvider>
73
- </DaemonsProvider>
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")
@@ -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 { showLog, showList, setFocusedIdx, view, focusedIdx } = useView()
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 === "log") showList()
66
- }, [view, showList])
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" && view.kind === "log") {
123
- onClearLog()
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
- [confirming, cancelConfirm, quit, onQuit, onNumberKey, onEscape, onClearLog, onTakeoverFocused, onCycleFocus, view],
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
+ })