@kitlangton/motel 0.1.3 → 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 (55) hide show
  1. package/AGENTS.md +11 -1
  2. package/package.json +5 -3
  3. package/src/App.tsx +239 -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 +62 -4
  8. package/src/httpApi.ts +4 -1
  9. package/src/localServer.ts +112 -121
  10. package/src/mcp.ts +172 -0
  11. package/src/motelClient.ts +166 -14
  12. package/src/registry.ts +26 -23
  13. package/src/runtime.ts +8 -2
  14. package/src/server.ts +10 -9
  15. package/src/services/AsyncIngest.ts +52 -0
  16. package/src/services/TelemetryStore.ts +285 -27
  17. package/src/services/TraceQueryService.ts +4 -2
  18. package/src/services/ingestRpc.ts +41 -0
  19. package/src/services/telemetryWorker.ts +62 -0
  20. package/src/storybook/aiChatStory.tsx +243 -0
  21. package/src/storybook/fixtures/errorState.ts +44 -0
  22. package/src/storybook/fixtures/imagePaste.ts +34 -0
  23. package/src/storybook/fixtures/index.ts +62 -0
  24. package/src/storybook/fixtures/kitchenSink.ts +148 -0
  25. package/src/storybook/fixtures/rawPrompt.ts +15 -0
  26. package/src/storybook/fixtures/short.ts +27 -0
  27. package/src/storybook/fixtures/toolHeavy.ts +65 -0
  28. package/src/telemetry.test.ts +61 -0
  29. package/src/ui/AiChatView.tsx +292 -0
  30. package/src/ui/SpanContentView.tsx +181 -0
  31. package/src/ui/SpanDetail.tsx +98 -17
  32. package/src/ui/TraceDetailsPane.tsx +35 -3
  33. package/src/ui/Waterfall.tsx +94 -167
  34. package/src/ui/aiChatModel.test.ts +347 -0
  35. package/src/ui/aiChatModel.ts +736 -0
  36. package/src/ui/aiState.ts +71 -0
  37. package/src/ui/app/TraceWorkspace.tsx +295 -120
  38. package/src/ui/app/useAppLayout.ts +14 -11
  39. package/src/ui/app/useTraceScreenData.ts +191 -35
  40. package/src/ui/atoms.ts +131 -0
  41. package/src/ui/filterParser.test.ts +56 -0
  42. package/src/ui/filterParser.ts +45 -0
  43. package/src/ui/loaders.ts +120 -0
  44. package/src/ui/persistence.ts +41 -0
  45. package/src/ui/primitives.tsx +47 -21
  46. package/src/ui/state.ts +4 -169
  47. package/src/ui/useAttrFilterPicker.ts +63 -23
  48. package/src/ui/useKeyboardNav.ts +576 -300
  49. package/src/ui/waterfallFilter.test.ts +84 -0
  50. package/src/ui/waterfallFilter.ts +59 -0
  51. package/src/ui/waterfallModel.ts +130 -0
  52. package/src/ui/waterfallNav.test.ts +17 -1
  53. package/src/ui/waterfallNav.ts +1 -1
  54. package/web/dist/assets/{index-DKinj-OE.js → index-DnyVo03x.js} +1 -1
  55. package/web/dist/index.html +1 -1
@@ -0,0 +1,736 @@
1
+ // Pure transforms that turn an AiCallDetail into a list of semantic
2
+ // "chunks" — the navigation unit in the chat view. Rendering from
3
+ // chunks to viewport lines is a separate step so the view can react
4
+ // to expansion state + width without rebuilding the model.
5
+ //
6
+ // A chunk is one logical piece of a conversation: a system prompt, a
7
+ // user text turn, an assistant text/reasoning block, a single tool
8
+ // call, a single tool result, or the trailing response. Each gets a
9
+ // stable id (`m{messageIndex}p{partIndex}`) so the UI can remember
10
+ // which chunks are expanded and which is selected across re-renders.
11
+
12
+ import { wrapTextLines } from "./format.ts"
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Roles + chunk kinds
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export type Role = "system" | "user" | "assistant" | "tool" | "response" | "unknown"
19
+
20
+ export type ChunkKind =
21
+ | "system"
22
+ | "user-text"
23
+ | "assistant-text"
24
+ | "reasoning"
25
+ | "tool-call"
26
+ | "tool-result"
27
+ | "response"
28
+ | "raw-prompt"
29
+ | "unknown"
30
+
31
+ /**
32
+ * A single navigable chunk. Width-independent: produced once per
33
+ * detail, then rendered to lines on demand with the current viewport
34
+ * width. Storing stable `id`s lets us keep expansion + selection
35
+ * across re-renders without re-deriving state.
36
+ */
37
+ export interface Chunk {
38
+ readonly id: string
39
+ readonly kind: ChunkKind
40
+ readonly role: Role
41
+ readonly messageIndex: number
42
+ readonly partIndex: number
43
+ /** One-line header label (e.g. "SYSTEM", "→ bash git status", "← read"). */
44
+ readonly header: string
45
+ /** Optional right-aligned metadata for the header (byte count, etc). */
46
+ readonly headerMeta: string | null
47
+ /** Full body text. For purely-header chunks like tool-calls, may be ""; for
48
+ * text-only chunks like user-text this is the message content. */
49
+ readonly body: string
50
+ /** Whether to render a chunk-header row for this chunk. Plain text kinds
51
+ * (user-text, assistant-text, system when expanded, response, raw-prompt)
52
+ * skip it — the role divider rendered once per turn provides enough
53
+ * context. Tool calls, tool results, reasoning, and unknown parts get
54
+ * their own header because they need to show the tool name / kind. */
55
+ readonly needsHeader: boolean
56
+ /** Whether the user can expand this chunk for more detail. */
57
+ readonly collapsible: boolean
58
+ /** When true, body is hidden by default unless expanded. When false the
59
+ * body is always shown in full. */
60
+ readonly collapsedByDefault: boolean
61
+ /** For tool chunks only — lets consumers correlate calls with results
62
+ * and later cross-reference child spans in the trace. */
63
+ readonly toolName?: string
64
+ readonly toolCallId?: string
65
+ }
66
+
67
+ /** One row in the main chat list. */
68
+ export interface ChatListRow {
69
+ readonly kind: "separator" | "role-divider" | "chunk"
70
+ readonly role: Role
71
+ readonly chunkId: string | null
72
+ readonly text: string
73
+ readonly meta: string | null
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Content sanitisation
78
+ //
79
+ // LLM prompts and tool outputs routinely contain data URLs (images
80
+ // pasted into a chat, files embedded as base64) and absurdly long
81
+ // single-field values. Scrubbing these before wrap keeps the viewport
82
+ // readable without losing the signal that they were there.
83
+ // ---------------------------------------------------------------------------
84
+
85
+ const MAX_INLINE_TEXT_LEN = 8_000
86
+
87
+ const formatKilo = (n: number) =>
88
+ n >= 1000 ? `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k` : `${n}`
89
+
90
+ const scrubDataUrls = (text: string): string =>
91
+ text.replace(
92
+ /data:([\w./+-]+);base64,[A-Za-z0-9+/=]+/g,
93
+ (_m, mime: string) => `[data:${mime} base64 ${formatKilo(_m.length)}]`,
94
+ )
95
+
96
+ const sanitizeText = (text: string): string => {
97
+ const scrubbed = scrubDataUrls(text)
98
+ if (scrubbed.length <= MAX_INLINE_TEXT_LEN) return scrubbed
99
+ return `${scrubbed.slice(0, MAX_INLINE_TEXT_LEN)}\u2026 [${formatKilo(scrubbed.length)} chars total]`
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Tool-input summarisation
104
+ // ---------------------------------------------------------------------------
105
+
106
+ const NOISY_INPUT_KEYS = new Set([
107
+ "timeout",
108
+ "workdir",
109
+ "description",
110
+ "filter",
111
+ "outputFormat",
112
+ "heredoc_delimiter",
113
+ ])
114
+
115
+ const shorten = (text: string, width: number) => {
116
+ const scrubbed = scrubDataUrls(text).replace(/\s+/g, " ").trim()
117
+ if (scrubbed.length <= width) return scrubbed
118
+ return `${scrubbed.slice(0, Math.max(1, width - 1))}\u2026`
119
+ }
120
+
121
+ const pickPrimaryField = (input: Record<string, unknown>): string | null => {
122
+ const preferred = [
123
+ "command",
124
+ "filePath",
125
+ "file_path",
126
+ "path",
127
+ "url",
128
+ "query",
129
+ "pattern",
130
+ "title",
131
+ "name",
132
+ "prompt",
133
+ ]
134
+ for (const key of preferred) {
135
+ const value = input[key]
136
+ if (typeof value === "string" && value.length > 0) return value
137
+ }
138
+ return null
139
+ }
140
+
141
+ interface ToolSummary {
142
+ readonly inline: string
143
+ readonly fullJson: string
144
+ }
145
+
146
+ const summarizeToolInput = (
147
+ toolName: string,
148
+ input: unknown,
149
+ inlineWidth: number,
150
+ ): ToolSummary => {
151
+ const toJson = () => {
152
+ try { return JSON.stringify(input, null, 2) } catch { return String(input) }
153
+ }
154
+ const fullJson = toJson()
155
+
156
+ if (input == null) return { inline: "", fullJson }
157
+ if (typeof input === "string") return { inline: shorten(input, inlineWidth), fullJson }
158
+ if (typeof input !== "object") return { inline: shorten(String(input), inlineWidth), fullJson }
159
+
160
+ const obj = input as Record<string, unknown>
161
+
162
+ if (toolName === "todowrite" && Array.isArray(obj.todos)) {
163
+ return { inline: `${obj.todos.length} todo${obj.todos.length === 1 ? "" : "s"}`, fullJson }
164
+ }
165
+ if (toolName === "task" && typeof obj.description === "string") {
166
+ const sub = typeof obj.subagent_type === "string" ? ` [${obj.subagent_type}]` : ""
167
+ return { inline: shorten(`${obj.description}${sub}`, inlineWidth), fullJson }
168
+ }
169
+ if (toolName === "read" || toolName === "write" || toolName === "edit") {
170
+ const path = obj.filePath ?? obj.file_path ?? obj.path
171
+ if (typeof path === "string") {
172
+ const offset = typeof obj.offset === "number" ? ` @${obj.offset}` : ""
173
+ const limit = typeof obj.limit === "number" ? ` +${obj.limit}` : ""
174
+ return { inline: `${path}${offset}${limit}`, fullJson }
175
+ }
176
+ }
177
+
178
+ const primaryValue = pickPrimaryField(obj)
179
+ if (primaryValue) return { inline: shorten(primaryValue, inlineWidth), fullJson }
180
+
181
+ const compact = (() => {
182
+ try {
183
+ return JSON.stringify(
184
+ Object.fromEntries(Object.entries(obj).filter(([k]) => !NOISY_INPUT_KEYS.has(k))),
185
+ )
186
+ } catch { return String(obj) }
187
+ })()
188
+ return { inline: shorten(compact, inlineWidth), fullJson }
189
+ }
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // Tool-result body extraction
193
+ // ---------------------------------------------------------------------------
194
+
195
+ const extractToolResultBody = (output: unknown): string => {
196
+ if (typeof output === "string") return output
197
+ if (output && typeof output === "object") {
198
+ const asObj = output as { readonly type?: string; readonly value?: unknown; readonly text?: unknown }
199
+ if (asObj.type === "text" && typeof asObj.value === "string") return asObj.value
200
+ if (typeof asObj.text === "string") return asObj.text
201
+ try { return JSON.stringify(output, null, 2) } catch { return String(output) }
202
+ }
203
+ return output == null ? "" : String(output)
204
+ }
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // Message extraction
208
+ // ---------------------------------------------------------------------------
209
+
210
+ interface Message {
211
+ readonly role?: string
212
+ readonly content?: unknown
213
+ }
214
+
215
+ const normalizeRole = (role: string | undefined): Role => {
216
+ switch (role) {
217
+ case "system":
218
+ case "user":
219
+ case "assistant":
220
+ case "tool":
221
+ return role
222
+ default:
223
+ return "unknown"
224
+ }
225
+ }
226
+
227
+ const extractMessages = (messages: unknown): readonly Message[] => {
228
+ if (!messages) return []
229
+ if (Array.isArray(messages)) return messages as readonly Message[]
230
+ if (typeof messages === "object" && messages !== null) {
231
+ const nested = (messages as { messages?: unknown }).messages
232
+ if (Array.isArray(nested)) return nested as readonly Message[]
233
+ }
234
+ return []
235
+ }
236
+
237
+ // ---------------------------------------------------------------------------
238
+ // Chunk building
239
+ // ---------------------------------------------------------------------------
240
+
241
+ /** Soft cap: how many wrapped lines a tool-result renders when collapsed. */
242
+ const TOOL_RESULT_PREVIEW_LINES = 8
243
+ /** Soft cap for a system-prompt preview in collapsed state. */
244
+ const SYSTEM_PREVIEW_LINES = 0 // hidden entirely when collapsed; header-only
245
+
246
+ const chunkId = (messageIndex: number, partIndex: number, suffix = "") =>
247
+ `m${messageIndex}p${partIndex}${suffix ? `-${suffix}` : ""}`
248
+
249
+ /**
250
+ * Build the navigable chunks for an AI call detail. Width-independent:
251
+ * tool-input summaries are formatted against a generous target width
252
+ * (80 cols) and the view is expected to re-wrap when it renders. This
253
+ * lets us keep chunk identity stable across viewport resizes.
254
+ */
255
+ export const buildChunks = (
256
+ detail: {
257
+ readonly promptMessages: unknown
258
+ readonly responseText: string | null
259
+ } | null,
260
+ ): readonly Chunk[] => {
261
+ if (!detail) return []
262
+
263
+ const chunks: Chunk[] = []
264
+ const messages = extractMessages(detail.promptMessages)
265
+
266
+ // Legacy ai.prompt-as-string: no messages, just a raw blob.
267
+ if (messages.length === 0 && typeof detail.promptMessages === "string") {
268
+ const raw = sanitizeText(detail.promptMessages as string)
269
+ chunks.push({
270
+ id: chunkId(0, 0),
271
+ kind: "raw-prompt",
272
+ role: "user",
273
+ messageIndex: 0,
274
+ partIndex: 0,
275
+ header: "PROMPT (raw)",
276
+ headerMeta: `${formatKilo(raw.length)} chars`,
277
+ body: raw,
278
+ needsHeader: false,
279
+ collapsible: true,
280
+ collapsedByDefault: false,
281
+ })
282
+ }
283
+
284
+ messages.forEach((message, mi) => {
285
+ const role = normalizeRole(message.role)
286
+ const content = message.content
287
+
288
+ // Skip empty messages — render-wise they'd leave a naked role header.
289
+ if (content == null
290
+ || (typeof content === "string" && content.length === 0)
291
+ || (Array.isArray(content) && content.length === 0)) {
292
+ return
293
+ }
294
+
295
+ if (role === "system" && typeof content === "string") {
296
+ const sanitized = sanitizeText(content)
297
+ chunks.push({
298
+ id: chunkId(mi, 0),
299
+ kind: "system",
300
+ role: "system",
301
+ messageIndex: mi,
302
+ partIndex: 0,
303
+ header: "SYSTEM",
304
+ headerMeta: `${formatKilo(sanitized.length)} chars`,
305
+ body: sanitized,
306
+ // System prompts keep their own "SYSTEM" header so the
307
+ // collapse marker (▸/▾) + char count have somewhere to
308
+ // sit when collapsed by default. The role divider just
309
+ // above renders "SYSTEM" again — acceptable since the
310
+ // collapsed system chunk has no body text visible.
311
+ needsHeader: true,
312
+ collapsible: true,
313
+ collapsedByDefault: true,
314
+ })
315
+ return
316
+ }
317
+
318
+ if (typeof content === "string") {
319
+ const sanitized = sanitizeText(content)
320
+ chunks.push({
321
+ id: chunkId(mi, 0),
322
+ kind: role === "assistant" ? "assistant-text" : role === "user" ? "user-text" : "unknown",
323
+ role,
324
+ messageIndex: mi,
325
+ partIndex: 0,
326
+ header: role.toUpperCase(),
327
+ headerMeta: null,
328
+ body: sanitized,
329
+ needsHeader: false,
330
+ collapsible: false,
331
+ collapsedByDefault: false,
332
+ })
333
+ return
334
+ }
335
+
336
+ if (!Array.isArray(content)) return
337
+
338
+ content.forEach((part, pi) => {
339
+ if (!part || typeof part !== "object") return
340
+ const t = (part as { readonly type?: string }).type
341
+
342
+ if (t === "text") {
343
+ const text = sanitizeText((part as { readonly text?: string }).text ?? "")
344
+ if (!text) return
345
+ chunks.push({
346
+ id: chunkId(mi, pi),
347
+ kind: role === "assistant" ? "assistant-text" : role === "user" ? "user-text" : "unknown",
348
+ role,
349
+ messageIndex: mi,
350
+ partIndex: pi,
351
+ header: role.toUpperCase(),
352
+ headerMeta: null,
353
+ body: text,
354
+ needsHeader: false,
355
+ collapsible: false,
356
+ collapsedByDefault: false,
357
+ })
358
+ return
359
+ }
360
+
361
+ if (t === "reasoning") {
362
+ const text = sanitizeText((part as { readonly text?: string }).text ?? "")
363
+ if (!text) return
364
+ chunks.push({
365
+ id: chunkId(mi, pi),
366
+ kind: "reasoning",
367
+ role,
368
+ messageIndex: mi,
369
+ partIndex: pi,
370
+ header: "reasoning",
371
+ headerMeta: `${formatKilo(text.length)} chars`,
372
+ body: text,
373
+ needsHeader: true,
374
+ collapsible: true,
375
+ collapsedByDefault: true,
376
+ })
377
+ return
378
+ }
379
+
380
+ if (t === "tool-call") {
381
+ const tc = part as {
382
+ readonly toolName?: string
383
+ readonly toolCallId?: string
384
+ readonly input?: unknown
385
+ }
386
+ const name = tc.toolName ?? "tool"
387
+ const summary = summarizeToolInput(name, tc.input, 80)
388
+ const header = summary.inline
389
+ ? `\u2192 ${name} ${summary.inline}`
390
+ : `\u2192 ${name}`
391
+ chunks.push({
392
+ id: chunkId(mi, pi),
393
+ kind: "tool-call",
394
+ role,
395
+ messageIndex: mi,
396
+ partIndex: pi,
397
+ header,
398
+ headerMeta: null,
399
+ body: summary.fullJson,
400
+ needsHeader: true,
401
+ collapsible: summary.fullJson.length > 0 && summary.fullJson.length > summary.inline.length,
402
+ collapsedByDefault: true,
403
+ toolName: name,
404
+ toolCallId: tc.toolCallId,
405
+ })
406
+ return
407
+ }
408
+
409
+ if (t === "tool-result") {
410
+ const tr = part as {
411
+ readonly toolName?: string
412
+ readonly toolCallId?: string
413
+ readonly output?: unknown
414
+ }
415
+ const name = tr.toolName ?? "tool"
416
+ const rawBody = extractToolResultBody(tr.output)
417
+ const body = sanitizeText(rawBody)
418
+ chunks.push({
419
+ id: chunkId(mi, pi),
420
+ kind: "tool-result",
421
+ role,
422
+ messageIndex: mi,
423
+ partIndex: pi,
424
+ header: `\u2190 ${name}`,
425
+ headerMeta: body.length > 0
426
+ ? `${formatKilo(body.length)} chars`
427
+ : "(empty)",
428
+ body,
429
+ needsHeader: true,
430
+ collapsible: body.length > 0,
431
+ // Long results collapse by default; short ones inline.
432
+ collapsedByDefault: body.length > 240 || body.split("\n").length > TOOL_RESULT_PREVIEW_LINES,
433
+ toolName: name,
434
+ toolCallId: tr.toolCallId,
435
+ })
436
+ return
437
+ }
438
+
439
+ // Unknown content-part type.
440
+ let body = ""
441
+ try { body = JSON.stringify(part, null, 2) } catch { body = String(part) }
442
+ chunks.push({
443
+ id: chunkId(mi, pi),
444
+ kind: "unknown",
445
+ role,
446
+ messageIndex: mi,
447
+ partIndex: pi,
448
+ header: `[${t ?? "unknown"}]`,
449
+ headerMeta: `${formatKilo(body.length)} chars`,
450
+ body,
451
+ needsHeader: true,
452
+ collapsible: body.length > 0,
453
+ collapsedByDefault: true,
454
+ })
455
+ })
456
+ })
457
+
458
+ if (detail.responseText && detail.responseText.length > 0) {
459
+ chunks.push({
460
+ id: chunkId(messages.length, 0, "response"),
461
+ kind: "response",
462
+ role: "response",
463
+ messageIndex: messages.length,
464
+ partIndex: 0,
465
+ header: "RESPONSE",
466
+ headerMeta: null,
467
+ body: sanitizeText(detail.responseText),
468
+ needsHeader: false,
469
+ collapsible: false,
470
+ collapsedByDefault: false,
471
+ })
472
+ }
473
+
474
+ return chunks
475
+ }
476
+
477
+ const firstBodyLine = (body: string) => {
478
+ const line = body.split("\n").find((part) => part.trim().length > 0) ?? ""
479
+ return line.replace(/\s+/g, " ").trim()
480
+ }
481
+
482
+ /**
483
+ * Stable list rows for the main chat pane. One role divider per turn,
484
+ * one selectable row per chunk. Plain text chunks use their first body
485
+ * line as the row text; structured chunks (tool call/result, reasoning,
486
+ * system) use their explicit header.
487
+ */
488
+ export const buildChatListRows = (chunks: readonly Chunk[]): readonly ChatListRow[] => {
489
+ const rows: ChatListRow[] = []
490
+ let prevRole: Role | null = null
491
+ let prevMessageIndex = -1
492
+
493
+ for (const chunk of chunks) {
494
+ const roleChanged = chunk.role !== prevRole || chunk.messageIndex !== prevMessageIndex
495
+ if (roleChanged) {
496
+ if (rows.length > 0) {
497
+ rows.push({
498
+ kind: "separator",
499
+ role: "unknown",
500
+ chunkId: null,
501
+ text: "",
502
+ meta: null,
503
+ })
504
+ }
505
+ rows.push({
506
+ kind: "role-divider",
507
+ role: chunk.role,
508
+ chunkId: null,
509
+ text: chunk.role.toUpperCase(),
510
+ meta: null,
511
+ })
512
+ }
513
+
514
+ let text = chunk.header
515
+ let meta = chunk.headerMeta
516
+ if (!chunk.needsHeader) {
517
+ text = firstBodyLine(chunk.body)
518
+ meta = null
519
+ }
520
+ // Main list rows add their own semantic prefix glyph via
521
+ // `rowPrefix()` in AiChatView, so strip the transport glyph from
522
+ // structured chunk headers here. Otherwise tool rows render as
523
+ // `→ → bash ...` and results as `← ← read ...`.
524
+ if (chunk.kind === "tool-call" || chunk.kind === "tool-result") {
525
+ text = text.replace(/^[→←]\s+/, "")
526
+ }
527
+ if (chunk.kind === "system") {
528
+ text = "prompt"
529
+ }
530
+ if (text.length === 0) {
531
+ text = chunk.kind.replace(/-/g, " ")
532
+ }
533
+
534
+ rows.push({
535
+ kind: "chunk",
536
+ role: chunk.role,
537
+ chunkId: chunk.id,
538
+ text,
539
+ meta,
540
+ })
541
+
542
+ prevRole = chunk.role
543
+ prevMessageIndex = chunk.messageIndex
544
+ }
545
+
546
+ return rows
547
+ }
548
+
549
+ /** Human-facing title for the detail modal. */
550
+ export const chunkDetailTitle = (chunk: Chunk): string => {
551
+ if (chunk.kind === "system") return "SYSTEM PROMPT"
552
+ if (chunk.kind === "raw-prompt") return "RAW PROMPT"
553
+ if (chunk.kind === "response") return "RESPONSE"
554
+ if (chunk.kind === "user-text") return "USER"
555
+ if (chunk.kind === "assistant-text") return "ASSISTANT"
556
+ if (chunk.kind === "reasoning") return "REASONING"
557
+ if (chunk.kind === "tool-call") return chunk.toolName ? `TOOL CALL · ${chunk.toolName}` : "TOOL CALL"
558
+ if (chunk.kind === "tool-result") return chunk.toolName ? `TOOL RESULT · ${chunk.toolName}` : "TOOL RESULT"
559
+ return chunk.header.toUpperCase()
560
+ }
561
+
562
+ /**
563
+ * Full wrapped body lines for the detail modal. Unlike the old in-line
564
+ * expansion view this is always the complete chunk body, never a preview.
565
+ */
566
+ export const renderChunkDetailLines = (chunk: Chunk, width: number): readonly string[] => {
567
+ const usableWidth = Math.max(16, width)
568
+ const source = chunk.body.length > 0 ? chunk.body : chunk.header
569
+ return wrapTextLines(source, usableWidth, 4_000)
570
+ }
571
+
572
+ // ---------------------------------------------------------------------------
573
+ // Rendering chunks → viewport lines
574
+ // ---------------------------------------------------------------------------
575
+
576
+ export type ChatLineKind =
577
+ | "role-divider"
578
+ | "chunk-header"
579
+ | "text"
580
+ | "reasoning"
581
+ | "tool-call-body"
582
+ | "tool-result-body"
583
+ | "hint"
584
+ | "separator"
585
+ | "empty"
586
+
587
+ export interface ChatLine {
588
+ readonly kind: ChatLineKind
589
+ readonly role: Role
590
+ readonly text: string
591
+ /** Right-aligned metadata for header lines only. */
592
+ readonly headerMeta?: string
593
+ /** Id of the chunk this line belongs to (or null for role dividers / separators). */
594
+ readonly chunkId: string | null
595
+ }
596
+
597
+ /** Is this chunk currently showing its body? */
598
+ export const isChunkExpanded = (
599
+ chunk: Chunk,
600
+ expanded: ReadonlySet<string>,
601
+ ): boolean => {
602
+ if (!chunk.collapsible) return true
603
+ const forceOpen = expanded.has(chunk.id)
604
+ if (chunk.collapsedByDefault) return forceOpen
605
+ const forceShut = expanded.has(`!${chunk.id}`)
606
+ return !forceShut
607
+ }
608
+
609
+ /** Flip a chunk's visible state. Uses the pair `id` / `!id` so toggling can
610
+ * both force-open a default-collapsed chunk and force-shut a default-open
611
+ * one without losing track of which state is the "default". */
612
+ export const toggleChunkExpansion = (
613
+ chunk: Chunk,
614
+ expanded: ReadonlySet<string>,
615
+ ): ReadonlySet<string> => {
616
+ if (!chunk.collapsible) return expanded
617
+ const next = new Set(expanded)
618
+ if (chunk.collapsedByDefault) {
619
+ if (next.has(chunk.id)) next.delete(chunk.id)
620
+ else next.add(chunk.id)
621
+ } else {
622
+ const key = `!${chunk.id}`
623
+ if (next.has(key)) next.delete(key)
624
+ else next.add(key)
625
+ }
626
+ return next
627
+ }
628
+
629
+ export interface RenderOptions {
630
+ readonly width: number
631
+ readonly expanded: ReadonlySet<string>
632
+ }
633
+
634
+ /** Render chunks into a flat list of viewport lines. Each line carries
635
+ * its `chunkId` so the view can highlight the selected chunk and so we
636
+ * can compute a scroll offset that keeps a chosen chunk visible. */
637
+ export const renderChunks = (
638
+ chunks: readonly Chunk[],
639
+ options: RenderOptions,
640
+ ): readonly ChatLine[] => {
641
+ const { width, expanded } = options
642
+ const wrapWidth = Math.max(24, width - 2)
643
+ const bodyWidth = Math.max(16, wrapWidth - 2)
644
+ const lines: ChatLine[] = []
645
+
646
+ if (chunks.length === 0) {
647
+ return [{ kind: "empty", role: "unknown", text: "no chat content", chunkId: null }]
648
+ }
649
+
650
+ // Two visual layers:
651
+ //
652
+ // 1. A role divider (USER / ASSISTANT / TOOL / SYSTEM / RESPONSE)
653
+ // renders once at every turn boundary. It's a lightweight title
654
+ // that anchors the reader and visually separates conversation
655
+ // beats without repeating per chunk.
656
+ //
657
+ // 2. Each chunk contributes either:
658
+ // - Text-kind chunks (user-text, assistant-text, response,
659
+ // raw-prompt): just their body, flush-indented under the role
660
+ // divider. No chunk-header row — `needsHeader` is false.
661
+ // - Structured chunks (reasoning, tool-call, tool-result, system,
662
+ // unknown): a chunk-header row carrying the kind + expand
663
+ // marker, followed by the body when expanded.
664
+ //
665
+ // Every rendered line carries the owning `chunkId` so the view can
666
+ // draw a continuous left-edge selection bar across the chunk's
667
+ // full footprint (header + body).
668
+ let prevRole: Role | null = null
669
+ let prevMessageIndex = -1
670
+
671
+ for (const chunk of chunks) {
672
+ const roleChanged = chunk.role !== prevRole || chunk.messageIndex !== prevMessageIndex
673
+ if (roleChanged) {
674
+ if (lines.length > 0) {
675
+ lines.push({ kind: "separator", role: "unknown", text: "", chunkId: null })
676
+ }
677
+ lines.push({
678
+ kind: "role-divider",
679
+ role: chunk.role,
680
+ text: chunk.role.toUpperCase(),
681
+ chunkId: null,
682
+ })
683
+ }
684
+
685
+ if (chunk.needsHeader) {
686
+ lines.push({
687
+ kind: "chunk-header",
688
+ role: chunk.role,
689
+ text: chunk.header,
690
+ headerMeta: chunk.headerMeta ?? undefined,
691
+ chunkId: chunk.id,
692
+ })
693
+ }
694
+
695
+ const expandedNow = isChunkExpanded(chunk, expanded)
696
+
697
+ if (expandedNow && chunk.body.length > 0) {
698
+ const bodyKind: ChatLineKind =
699
+ chunk.kind === "tool-call" ? "tool-call-body"
700
+ : chunk.kind === "tool-result" ? "tool-result-body"
701
+ : chunk.kind === "reasoning" ? "reasoning"
702
+ : chunk.kind === "unknown" ? "hint"
703
+ : "text"
704
+
705
+ // Text bodies hang off the role divider with a small indent
706
+ // so prose reads clean. Structured bodies (tool args, tool
707
+ // results, reasoning) indent deeper so they're obviously
708
+ // subordinate to their own chunk header.
709
+ const indent = chunk.needsHeader ? " " : " "
710
+ const wrapped = wrapTextLines(chunk.body, chunk.needsHeader ? bodyWidth : wrapWidth, 2_000)
711
+ for (const line of wrapped) {
712
+ lines.push({
713
+ kind: bodyKind,
714
+ role: chunk.role,
715
+ text: `${indent}${line}`,
716
+ chunkId: chunk.id,
717
+ })
718
+ }
719
+ }
720
+ // Collapsed chunks: the header already shows the expand marker
721
+ // + char count on the right; no "enter to expand" per-line hint.
722
+ // The footer carries the keyboard hint globally.
723
+
724
+ prevRole = chunk.role
725
+ prevMessageIndex = chunk.messageIndex
726
+ }
727
+
728
+ return lines
729
+ }
730
+
731
+ /** Find the viewport line index where the given chunk's header lives.
732
+ * Returns -1 if the chunk isn't in the rendered list. */
733
+ export const chunkHeaderLineIndex = (
734
+ lines: readonly ChatLine[],
735
+ chunkId: string,
736
+ ): number => lines.findIndex((l) => l.kind === "chunk-header" && l.chunkId === chunkId)