@kitlangton/motel 0.2.0 → 0.2.1

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.
Files changed (48) hide show
  1. package/AGENTS.md +5 -0
  2. package/package.json +5 -3
  3. package/src/App.tsx +233 -59
  4. package/src/daemon.test.ts +144 -7
  5. package/src/daemon.ts +113 -8
  6. package/src/domain.test.ts +62 -0
  7. package/src/domain.ts +16 -0
  8. package/src/localServer.ts +111 -121
  9. package/src/mcp.ts +172 -0
  10. package/src/motelClient.ts +166 -14
  11. package/src/registry.ts +26 -23
  12. package/src/runtime.ts +8 -2
  13. package/src/server.ts +10 -9
  14. package/src/services/AsyncIngest.ts +52 -0
  15. package/src/services/TelemetryStore.ts +151 -26
  16. package/src/services/TraceQueryService.ts +3 -1
  17. package/src/services/ingestRpc.ts +41 -0
  18. package/src/services/telemetryWorker.ts +62 -0
  19. package/src/storybook/aiChatStory.tsx +243 -0
  20. package/src/storybook/fixtures/errorState.ts +44 -0
  21. package/src/storybook/fixtures/imagePaste.ts +34 -0
  22. package/src/storybook/fixtures/index.ts +62 -0
  23. package/src/storybook/fixtures/kitchenSink.ts +148 -0
  24. package/src/storybook/fixtures/rawPrompt.ts +15 -0
  25. package/src/storybook/fixtures/short.ts +27 -0
  26. package/src/storybook/fixtures/toolHeavy.ts +65 -0
  27. package/src/telemetry.test.ts +28 -0
  28. package/src/ui/AiChatView.tsx +292 -0
  29. package/src/ui/SpanContentView.tsx +181 -0
  30. package/src/ui/SpanDetail.tsx +98 -17
  31. package/src/ui/TraceDetailsPane.tsx +2 -1
  32. package/src/ui/Waterfall.tsx +38 -138
  33. package/src/ui/aiChatModel.test.ts +347 -0
  34. package/src/ui/aiChatModel.ts +736 -0
  35. package/src/ui/aiState.ts +71 -0
  36. package/src/ui/app/TraceWorkspace.tsx +291 -120
  37. package/src/ui/app/useAppLayout.ts +14 -11
  38. package/src/ui/app/useTraceScreenData.ts +173 -39
  39. package/src/ui/atoms.ts +131 -0
  40. package/src/ui/loaders.ts +120 -0
  41. package/src/ui/persistence.ts +41 -0
  42. package/src/ui/primitives.tsx +27 -13
  43. package/src/ui/state.ts +4 -199
  44. package/src/ui/useAttrFilterPicker.ts +63 -23
  45. package/src/ui/useKeyboardNav.ts +552 -364
  46. package/src/ui/waterfallModel.ts +130 -0
  47. package/src/ui/waterfallNav.test.ts +17 -1
  48. package/src/ui/waterfallNav.ts +1 -1
@@ -1,81 +1,17 @@
1
1
  import { memo, useLayoutEffect, useRef, useState } from "react"
2
- import type { LogItem, TraceItem, TraceSpanItem } from "../domain.ts"
2
+ import { isAiSpan, type LogItem, type TraceItem, type TraceSpanItem } from "../domain.ts"
3
3
  import { formatDuration, lifecycleLabel, splitDuration, truncateText } from "./format.ts"
4
4
  import { BlankRow, TextLine } from "./primitives.tsx"
5
5
  import { colors, waterfallColors } from "./theme.ts"
6
-
7
- /** Filter spans to only those visible given a set of collapsed span IDs. */
8
- export const getVisibleSpans = (spans: readonly TraceSpanItem[], collapsedIds: ReadonlySet<string>): readonly TraceSpanItem[] => {
9
- if (collapsedIds.size === 0) return spans
10
- const result: TraceSpanItem[] = []
11
- let skipDepth = -1
12
- for (const span of spans) {
13
- if (skipDepth >= 0 && span.depth > skipDepth) continue
14
- skipDepth = -1
15
- result.push(span)
16
- if (collapsedIds.has(span.spanId)) {
17
- skipDepth = span.depth
18
- }
19
- }
20
- return result
21
- }
22
-
23
- /** Find the index of a span's parent in the visible list. */
24
- export const findParentIndex = (spans: readonly TraceSpanItem[], index: number): number | null => {
25
- const span = spans[index]
26
- if (!span || span.depth === 0) return null
27
- for (let i = index - 1; i >= 0; i--) {
28
- if (spans[i]!.depth < span.depth) return i
29
- }
30
- return null
31
- }
32
-
33
- /** Find the index of a span's first child in the visible list. */
34
- export const findFirstChildIndex = (spans: readonly TraceSpanItem[], index: number): number | null => {
35
- const span = spans[index]
36
- const next = spans[index + 1]
37
- if (span && next && next.depth > span.depth) return index + 1
38
- return null
39
- }
40
-
41
- const INTERESTING_TAGS = [
42
- "http.method", "http.url", "http.status_code", "http.route",
43
- "db.system", "db.statement", "db.name",
44
- "messaging.system", "messaging.destination",
45
- "error", "error.message",
46
- "net.peer.name", "net.peer.port",
47
- ] as const
48
-
49
- const buildTreePrefix = (spans: readonly TraceSpanItem[], index: number): string => {
50
- const span = spans[index]
51
- if (span.depth === 0) return ""
52
-
53
- const parts: string[] = []
54
-
55
- const isLastChild = (spanIndex: number, depth: number): boolean => {
56
- for (let i = spanIndex + 1; i < spans.length; i++) {
57
- if (spans[i].depth < depth) return true
58
- if (spans[i].depth === depth) return false
59
- }
60
- return true
61
- }
62
-
63
- parts.push(isLastChild(index, span.depth) ? "\u2514\u2500" : "\u251c\u2500")
64
-
65
- for (let d = span.depth - 1; d >= 1; d--) {
66
- let parentIndex = index
67
- for (let i = index - 1; i >= 0; i--) {
68
- if (spans[i].depth === d) {
69
- parentIndex = i
70
- break
71
- }
72
- if (spans[i].depth < d) break
73
- }
74
- parts.push(isLastChild(parentIndex, d) ? " " : "\u2502 ")
75
- }
76
-
77
- return parts.reverse().join("")
78
- }
6
+ export { getVisibleSpans } from "./waterfallModel.ts"
7
+ import {
8
+ buildTreePrefix,
9
+ findFirstChildIndex,
10
+ getWaterfallLayout,
11
+ getWaterfallSuffixMetrics,
12
+ type WaterfallSuffixMetrics,
13
+ spanPreviewEntries,
14
+ } from "./waterfallModel.ts"
79
15
 
80
16
  const PARTIAL_BLOCKS = ["", "\u258f", "\u258e", "\u258d", "\u258c", "\u258b", "\u258a", "\u2589", "\u2588"] as const
81
17
  const ULTRA_SHORT_MARKERS = ["\u258f", "\u258e", "\u258d", "\u258c"] as const
@@ -180,66 +116,6 @@ const durationColor = (durationMs: number) => {
180
116
  return colors.muted
181
117
  }
182
118
 
183
- export const getWaterfallLayout = (contentWidth: number, suffixWidth: number) => {
184
- const labelMaxWidth = Math.min(Math.floor(contentWidth * 0.4), 32)
185
- // Two single-space gaps: one between label and bar, one between bar and suffix.
186
- const barWidth = Math.max(6, contentWidth - labelMaxWidth - suffixWidth - 2)
187
- return { labelMaxWidth, barWidth } as const
188
- }
189
-
190
- export type WaterfallSuffixMetrics = {
191
- readonly maxDurationWidth: number
192
- readonly suffixWidth: number
193
- }
194
-
195
- /**
196
- * Compute a shared suffix (duration) width from the visible viewport.
197
- * Reserving the width once keeps every row's duration right-aligned on the
198
- * same column regardless of per-row content. Log correlation lives in the
199
- * span detail pane, not the row suffix.
200
- */
201
- export const getWaterfallSuffixMetrics = (
202
- spans: readonly { readonly durationMs: number; readonly spanId: string }[],
203
- ): WaterfallSuffixMetrics => {
204
- let maxDurationWidth = 0
205
- for (const span of spans) {
206
- const d = formatDuration(Math.max(0, span.durationMs)).length
207
- if (d > maxDurationWidth) maxDurationWidth = d
208
- }
209
- return { maxDurationWidth, suffixWidth: maxDurationWidth }
210
- }
211
-
212
- // Retained for tests: per-row view of the shared layout.
213
- export const getWaterfallColumns = (
214
- contentWidth: number,
215
- metrics: WaterfallSuffixMetrics,
216
- ) => {
217
- const { labelMaxWidth, barWidth } = getWaterfallLayout(contentWidth, metrics.suffixWidth)
218
- return { labelMaxWidth, barWidth, suffixWidth: metrics.suffixWidth } as const
219
- }
220
-
221
- export const spanPreviewEntries = (span: TraceSpanItem, logs: readonly LogItem[], maxEntries: number): Array<{ key: string; value: string; isWarning?: boolean }> => {
222
- const entries = Object.entries(span.tags)
223
- const interesting = entries.filter(([key]) =>
224
- INTERESTING_TAGS.includes(key as (typeof INTERESTING_TAGS)[number]) || key.startsWith("error"),
225
- )
226
- const rest = entries.filter(([key]) =>
227
- !INTERESTING_TAGS.includes(key as (typeof INTERESTING_TAGS)[number]) && !key.startsWith("error") && !key.startsWith("otel.") && key !== "span.kind",
228
- )
229
- const tagResults: Array<{ key: string; value: string; isWarning?: boolean }> = []
230
- if (logs.length > 0) {
231
- tagResults.push({ key: "logs", value: `${logs.length} correlated` })
232
- tagResults.push({ key: "log", value: logs[0]!.body.replace(/\s+/g, " ") })
233
- }
234
-
235
- tagResults.push(...[...interesting, ...rest]
236
- .slice(0, maxEntries - span.warnings.length)
237
- .map(([key, value]) => ({ key, value })))
238
- for (const warning of span.warnings) {
239
- tagResults.push({ key: "warning", value: warning, isWarning: true })
240
- }
241
- return tagResults.slice(0, maxEntries)
242
- }
243
119
 
244
120
  const WaterfallRow = memo(({
245
121
  span,
@@ -267,14 +143,33 @@ const WaterfallRow = memo(({
267
143
  onSelect: () => void
268
144
  }) => {
269
145
  const prefix = buildTreePrefix(spans, index)
270
- // Match the trace list indicator: `!` on error, chevron on collapsible parents, `·` on leaves.
271
- const indicator = span.status === "error" ? "!" : hasChildSpans ? (collapsed ? "\u25b8" : "\u25be") : "\u00b7"
146
+ const isAi = isAiSpan(span.tags)
147
+ // Indicator column: `!` on error, chevron on collapsible parents,
148
+ // `✦` on AI leaves (LLM payloads detected — enter drills into a
149
+ // specialized chat view), `·` on other leaves. AI parents keep the
150
+ // chevron glyph so tree structure stays readable; the accent color
151
+ // (applied below) carries the "AI content lives here" signal.
152
+ const indicator = span.status === "error" ? "!"
153
+ : hasChildSpans ? (collapsed ? "\u25b8" : "\u25be")
154
+ : isAi ? "\u2726"
155
+ : "\u00b7"
272
156
  const opName = span.isRunning ? `${span.operationName} [${lifecycleLabel(span)}]` : span.operationName
273
157
 
274
158
  const { labelMaxWidth, barWidth } = getWaterfallLayout(contentWidth, suffixMetrics.suffixWidth)
275
159
 
276
- const opMaxWidth = Math.max(4, labelMaxWidth - prefix.length - 2)
277
- const opTruncated = opName.length > opMaxWidth ? `${opName.slice(0, opMaxWidth - 1)}\u2026` : opName
160
+ // Op name budget = labelMaxWidth minus (prefix + indicator + 1 space).
161
+ // Never force a minimum: at very deep nesting or narrow widths the
162
+ // prefix + indicator may already fill the label column, in which
163
+ // case we render the op as an empty string (or a lone ellipsis) so
164
+ // the line stays within contentWidth. Previous code forced op to 4
165
+ // chars which could push total row width past the pane and make
166
+ // OpenTUI smear "..." across the right edge.
167
+ const opMaxWidth = Math.max(0, labelMaxWidth - prefix.length - 2)
168
+ const opTruncated = opMaxWidth === 0
169
+ ? ""
170
+ : opName.length > opMaxWidth
171
+ ? `${opName.slice(0, Math.max(0, opMaxWidth - 1))}\u2026`
172
+ : opName
278
173
  const labelLen = prefix.length + 2 + opTruncated.length
279
174
  const labelPad = " ".repeat(Math.max(0, labelMaxWidth - labelLen))
280
175
 
@@ -292,6 +187,11 @@ const WaterfallRow = memo(({
292
187
  const indicatorColor = selected ? colors.selectedText
293
188
  : dimmed ? colors.separator
294
189
  : isError ? colors.error
190
+ // AI accent outranks parent/leaf color so both AI parents and AI
191
+ // leaves scan as "there's an LLM payload here" from across the
192
+ // waterfall. Error still wins because a failed AI span is first
193
+ // and foremost a failure.
194
+ : isAi ? colors.accent
295
195
  : hasChildSpans ? colors.muted
296
196
  : colors.passing
297
197
  const opColor = selected ? colors.selectedText
@@ -0,0 +1,347 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import {
3
+ buildChatListRows,
4
+ buildChunks,
5
+ chunkDetailTitle,
6
+ type Chunk,
7
+ isChunkExpanded,
8
+ renderChunkDetailLines,
9
+ renderChunks,
10
+ toggleChunkExpansion,
11
+ } from "./aiChatModel.ts"
12
+
13
+ const makeDetail = (messages: unknown, responseText: string | null = null) => ({
14
+ promptMessages: messages,
15
+ responseText,
16
+ })
17
+
18
+ const findByKind = (chunks: readonly Chunk[], kind: Chunk["kind"]) =>
19
+ chunks.filter((c) => c.kind === kind)
20
+
21
+ describe("buildChunks", () => {
22
+ it("returns no chunks for a null detail", () => {
23
+ expect(buildChunks(null).length).toBe(0)
24
+ })
25
+
26
+ it("unwraps the `{ messages: [...] }` Vercel AI SDK shape", () => {
27
+ const chunks = buildChunks(
28
+ makeDetail({ messages: [{ role: "user", content: "hi" }] }),
29
+ )
30
+ expect(chunks.length).toBe(1)
31
+ expect(chunks[0]!.kind).toBe("user-text")
32
+ expect(chunks[0]!.body).toBe("hi")
33
+ })
34
+
35
+ it("accepts a bare message array", () => {
36
+ const chunks = buildChunks(
37
+ makeDetail([{ role: "assistant", content: "hello" }]),
38
+ )
39
+ expect(chunks[0]!.kind).toBe("assistant-text")
40
+ expect(chunks[0]!.body).toBe("hello")
41
+ })
42
+
43
+ it("marks system prompts collapsible + default-collapsed", () => {
44
+ const chunks = buildChunks(
45
+ makeDetail([{ role: "system", content: "you are helpful" }]),
46
+ )
47
+ expect(chunks[0]!.kind).toBe("system")
48
+ expect(chunks[0]!.collapsible).toBe(true)
49
+ expect(chunks[0]!.collapsedByDefault).toBe(true)
50
+ })
51
+
52
+ it("builds a tool-call chunk with inline summary in the header", () => {
53
+ const chunks = buildChunks(
54
+ makeDetail([
55
+ {
56
+ role: "assistant",
57
+ content: [
58
+ { type: "tool-call", toolName: "read", input: { filePath: "/tmp/x.ts" } },
59
+ ],
60
+ },
61
+ ]),
62
+ )
63
+ const tc = findByKind(chunks, "tool-call")[0]!
64
+ expect(tc.header).toContain("read")
65
+ expect(tc.header).toContain("/tmp/x.ts")
66
+ expect(tc.toolName).toBe("read")
67
+ })
68
+
69
+ it("strips noisy infra fields from bash tool summaries", () => {
70
+ const chunks = buildChunks(
71
+ makeDetail([
72
+ {
73
+ role: "assistant",
74
+ content: [
75
+ {
76
+ type: "tool-call",
77
+ toolName: "bash",
78
+ input: {
79
+ command: "git status --short",
80
+ timeout: 120_000,
81
+ workdir: "/home/me",
82
+ description: "ignored",
83
+ },
84
+ },
85
+ ],
86
+ },
87
+ ]),
88
+ )
89
+ const tc = findByKind(chunks, "tool-call")[0]!
90
+ expect(tc.header).toContain("git status --short")
91
+ expect(tc.header).not.toContain("timeout")
92
+ expect(tc.header).not.toContain("workdir")
93
+ })
94
+
95
+ it("summarises todowrite with a count", () => {
96
+ const chunks = buildChunks(
97
+ makeDetail([
98
+ {
99
+ role: "assistant",
100
+ content: [{ type: "tool-call", toolName: "todowrite", input: { todos: [{}, {}, {}] } }],
101
+ },
102
+ ]),
103
+ )
104
+ expect(findByKind(chunks, "tool-call")[0]!.header).toContain("3 todos")
105
+ })
106
+
107
+ it("scrubs base64 data URLs from user content", () => {
108
+ const big = "a".repeat(400)
109
+ const chunks = buildChunks(
110
+ makeDetail([
111
+ {
112
+ role: "user",
113
+ content: [{ type: "text", text: `look at data:image/png;base64,${big} thanks` }],
114
+ },
115
+ ]),
116
+ )
117
+ const body = chunks[0]!.body
118
+ expect(body).not.toContain("aaaaaaaaaa")
119
+ expect(body).toContain("[data:image/png base64")
120
+ })
121
+
122
+ it("collapses long tool results by default", () => {
123
+ const longOutput = Array.from({ length: 50 }, (_, i) => `line ${i}`).join("\n")
124
+ const chunks = buildChunks(
125
+ makeDetail([
126
+ {
127
+ role: "tool",
128
+ content: [
129
+ {
130
+ type: "tool-result",
131
+ toolName: "bash",
132
+ output: { type: "text", value: longOutput },
133
+ },
134
+ ],
135
+ },
136
+ ]),
137
+ )
138
+ const tr = findByKind(chunks, "tool-result")[0]!
139
+ expect(tr.collapsible).toBe(true)
140
+ expect(tr.collapsedByDefault).toBe(true)
141
+ })
142
+
143
+ it("keeps short tool results expanded by default", () => {
144
+ const chunks = buildChunks(
145
+ makeDetail([
146
+ {
147
+ role: "tool",
148
+ content: [
149
+ { type: "tool-result", toolName: "bash", output: { type: "text", value: "ok" } },
150
+ ],
151
+ },
152
+ ]),
153
+ )
154
+ const tr = findByKind(chunks, "tool-result")[0]!
155
+ expect(tr.collapsedByDefault).toBe(false)
156
+ })
157
+
158
+ it("appends a response chunk when responseText is set", () => {
159
+ const chunks = buildChunks(
160
+ makeDetail([{ role: "user", content: "hi" }], "final answer"),
161
+ )
162
+ const response = findByKind(chunks, "response")[0]!
163
+ expect(response.body).toBe("final answer")
164
+ })
165
+
166
+ it("falls back to a raw-prompt chunk when messages is a plain string", () => {
167
+ const chunks = buildChunks(makeDetail("bare prompt string"))
168
+ expect(chunks[0]!.kind).toBe("raw-prompt")
169
+ expect(chunks[0]!.body).toBe("bare prompt string")
170
+ })
171
+
172
+ it("gives each chunk a stable id keyed by message + part index", () => {
173
+ const chunks = buildChunks(
174
+ makeDetail([
175
+ { role: "user", content: "a" },
176
+ {
177
+ role: "assistant",
178
+ content: [
179
+ { type: "tool-call", toolName: "read", input: { filePath: "/x" } },
180
+ { type: "tool-call", toolName: "read", input: { filePath: "/y" } },
181
+ ],
182
+ },
183
+ ]),
184
+ )
185
+ const ids = chunks.map((c) => c.id)
186
+ expect(new Set(ids).size).toBe(ids.length)
187
+ // Second assistant part gets messageIndex=1, partIndex=1.
188
+ expect(chunks.find((c) => c.id === "m1p1")).toBeDefined()
189
+ })
190
+ })
191
+
192
+ describe("isChunkExpanded + toggleChunkExpansion", () => {
193
+ const makeChunk = (over: Partial<Chunk> = {}): Chunk => ({
194
+ id: "x",
195
+ kind: "reasoning",
196
+ role: "assistant",
197
+ messageIndex: 0,
198
+ partIndex: 0,
199
+ header: "reasoning",
200
+ headerMeta: null,
201
+ body: "thinking",
202
+ needsHeader: true,
203
+ collapsible: true,
204
+ collapsedByDefault: true,
205
+ ...over,
206
+ })
207
+
208
+ it("non-collapsible chunks are always expanded", () => {
209
+ const chunk = makeChunk({ collapsible: false, collapsedByDefault: false })
210
+ expect(isChunkExpanded(chunk, new Set())).toBe(true)
211
+ })
212
+
213
+ it("default-collapsed chunks need a positive override to expand", () => {
214
+ const chunk = makeChunk({ collapsedByDefault: true })
215
+ expect(isChunkExpanded(chunk, new Set())).toBe(false)
216
+ expect(isChunkExpanded(chunk, new Set(["x"]))).toBe(true)
217
+ })
218
+
219
+ it("default-open chunks need a negative override to collapse", () => {
220
+ const chunk = makeChunk({ collapsedByDefault: false })
221
+ expect(isChunkExpanded(chunk, new Set())).toBe(true)
222
+ expect(isChunkExpanded(chunk, new Set(["!x"]))).toBe(false)
223
+ })
224
+
225
+ it("toggleChunkExpansion flips visibility per chunk", () => {
226
+ const chunk = makeChunk({ collapsedByDefault: true })
227
+ const once = toggleChunkExpansion(chunk, new Set())
228
+ expect(isChunkExpanded(chunk, once)).toBe(true)
229
+ const twice = toggleChunkExpansion(chunk, once)
230
+ expect(isChunkExpanded(chunk, twice)).toBe(false)
231
+ })
232
+ })
233
+
234
+ describe("renderChunks", () => {
235
+ it("emits a role divider when the role changes", () => {
236
+ const chunks = buildChunks(
237
+ makeDetail([
238
+ { role: "user", content: "hi" },
239
+ { role: "assistant", content: "hello" },
240
+ ]),
241
+ )
242
+ const lines = renderChunks(chunks, { width: 80, expanded: new Set() })
243
+ const dividers = lines.filter((l) => l.kind === "role-divider")
244
+ expect(dividers.length).toBe(2)
245
+ })
246
+
247
+ it("hides bodies for collapsed chunks (no per-chunk expand hint)", () => {
248
+ const chunks = buildChunks(
249
+ makeDetail([
250
+ { role: "system", content: "long system prompt here " .repeat(5) },
251
+ { role: "user", content: "hi" },
252
+ ]),
253
+ )
254
+ const lines = renderChunks(chunks, { width: 80, expanded: new Set() })
255
+ const systemChunkId = chunks.find((c) => c.kind === "system")!.id
256
+ const systemBodyLines = lines.filter((l) => l.chunkId === systemChunkId && l.kind === "text")
257
+ // Collapsed: only the chunk-header line survives, no body text
258
+ // and no "enter to expand" filler (the bottom footer carries the
259
+ // global keyboard hint now).
260
+ expect(systemBodyLines.length).toBe(0)
261
+ const systemHeaders = lines.filter((l) => l.chunkId === systemChunkId && l.kind === "chunk-header")
262
+ expect(systemHeaders.length).toBe(1)
263
+ })
264
+
265
+ it("renders role dividers on turn boundaries", () => {
266
+ const chunks = buildChunks(
267
+ makeDetail([
268
+ { role: "user", content: "hi" },
269
+ { role: "assistant", content: "hello" },
270
+ { role: "user", content: "thanks" },
271
+ ]),
272
+ )
273
+ const lines = renderChunks(chunks, { width: 80, expanded: new Set() })
274
+ const dividers = lines.filter((l) => l.kind === "role-divider").map((l) => l.text)
275
+ expect(dividers).toEqual(["USER", "ASSISTANT", "USER"])
276
+ })
277
+
278
+ it("omits chunk-header rows for plain text chunks (user/assistant/response)", () => {
279
+ const chunks = buildChunks(
280
+ makeDetail([
281
+ { role: "user", content: "hi" },
282
+ { role: "assistant", content: "hello" },
283
+ ], "final"),
284
+ )
285
+ const lines = renderChunks(chunks, { width: 80, expanded: new Set() })
286
+ // No chunk-header rows should exist for the user-text / assistant-text / response chunks.
287
+ const plainTextChunkIds = chunks
288
+ .filter((c) => ["user-text", "assistant-text", "response"].includes(c.kind))
289
+ .map((c) => c.id)
290
+ const headersForPlainText = lines.filter(
291
+ (l) => l.kind === "chunk-header" && plainTextChunkIds.includes(l.chunkId ?? ""),
292
+ )
293
+ expect(headersForPlainText.length).toBe(0)
294
+ })
295
+
296
+ it("annotates each line with its chunkId so selection can find it", () => {
297
+ const chunks = buildChunks(
298
+ makeDetail([{ role: "user", content: "hi" }]),
299
+ )
300
+ const lines = renderChunks(chunks, { width: 80, expanded: new Set() })
301
+ const userChunkId = chunks[0]!.id
302
+ const taggedLines = lines.filter((l) => l.chunkId === userChunkId)
303
+ expect(taggedLines.length).toBeGreaterThan(0)
304
+ })
305
+ })
306
+
307
+ describe("buildChatListRows", () => {
308
+ it("emits one role divider per turn and one chunk row per chunk", () => {
309
+ const chunks = buildChunks(
310
+ makeDetail([
311
+ { role: "user", content: "hi" },
312
+ { role: "assistant", content: [{ type: "tool-call", toolName: "read", input: { filePath: "/x" } }] },
313
+ ]),
314
+ )
315
+ const rows = buildChatListRows(chunks)
316
+ expect(rows.filter((r) => r.kind === "role-divider").map((r) => r.text)).toEqual(["USER", "ASSISTANT"])
317
+ expect(rows.filter((r) => r.kind === "chunk").length).toBe(chunks.length)
318
+ })
319
+
320
+ it("uses first body line for plain text chunks and header text for structured chunks", () => {
321
+ const chunks = buildChunks(
322
+ makeDetail([
323
+ { role: "user", content: "hello there" },
324
+ { role: "assistant", content: [{ type: "tool-call", toolName: "bash", input: { command: "git status" } }] },
325
+ ]),
326
+ )
327
+ const rows = buildChatListRows(chunks).filter((r) => r.kind === "chunk")
328
+ expect(rows[0]!.text).toBe("hello there")
329
+ expect(rows[1]!.text).toContain("bash")
330
+ })
331
+ })
332
+
333
+ describe("chunkDetailTitle + renderChunkDetailLines", () => {
334
+ it("returns a readable modal title per kind", () => {
335
+ const chunks = buildChunks(makeDetail([{ role: "assistant", content: [{ type: "tool-call", toolName: "bash", input: { command: "git status" } }] }]))
336
+ expect(chunkDetailTitle(chunks[0]!)).toBe("TOOL CALL · bash")
337
+ })
338
+
339
+ it("wraps full detail lines for the modal", () => {
340
+ const chunks = buildChunks(makeDetail([{ role: "user", content: "a ".repeat(200) }]))
341
+ const lines = renderChunkDetailLines(chunks[0]!, 40)
342
+ expect(lines.length).toBeGreaterThan(1)
343
+ for (const line of lines) {
344
+ expect(line.length).toBeLessThanOrEqual(40)
345
+ }
346
+ })
347
+ })