@kitlangton/motel 0.2.0 → 0.2.4

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 +7 -5
  3. package/src/App.tsx +233 -59
  4. package/src/daemon.test.ts +213 -6
  5. package/src/daemon.ts +174 -38
  6. package/src/domain.test.ts +62 -0
  7. package/src/domain.ts +16 -0
  8. package/src/localServer.ts +114 -128
  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 +68 -0
  15. package/src/services/TelemetryStore.ts +262 -119
  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 +244 -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 +308 -0
  29. package/src/ui/SpanContentView.tsx +181 -0
  30. package/src/ui/SpanDetail.tsx +98 -17
  31. package/src/ui/TraceDetailsPane.tsx +11 -28
  32. package/src/ui/Waterfall.tsx +43 -148
  33. package/src/ui/aiChatModel.test.ts +391 -0
  34. package/src/ui/aiChatModel.ts +773 -0
  35. package/src/ui/aiState.ts +71 -0
  36. package/src/ui/app/TraceWorkspace.tsx +288 -124
  37. package/src/ui/app/useAppLayout.ts +14 -11
  38. package/src/ui/app/useTraceScreenData.ts +174 -40
  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
@@ -0,0 +1,773 @@
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
+ const stripTransportGlyph = (text: string) => text.replace(/^[→←]\s+/, "")
483
+
484
+ const toolRowPreview = (text: string, width = 40) => {
485
+ const compact = firstBodyLine(text)
486
+ return compact.length > 0 ? shorten(compact, width) : null
487
+ }
488
+
489
+ /**
490
+ * Stable list rows for the main chat pane. One role divider per turn,
491
+ * one selectable row per chunk. Plain text chunks use their first body
492
+ * line as the row text; structured chunks (tool call/result, reasoning,
493
+ * system) use their explicit header.
494
+ */
495
+ export const buildChatListRows = (chunks: readonly Chunk[]): readonly ChatListRow[] => {
496
+ const rows: ChatListRow[] = []
497
+ const toolCallById = new Map<string, Chunk>()
498
+ for (const chunk of chunks) {
499
+ if (chunk.kind === "tool-call" && chunk.toolCallId) toolCallById.set(chunk.toolCallId, chunk)
500
+ }
501
+ let prevRole: Role | null = null
502
+ let prevMessageIndex = -1
503
+
504
+ for (const chunk of chunks) {
505
+ const roleChanged = chunk.role !== prevRole || chunk.messageIndex !== prevMessageIndex
506
+ if (roleChanged) {
507
+ if (rows.length > 0) {
508
+ rows.push({
509
+ kind: "separator",
510
+ role: "unknown",
511
+ chunkId: null,
512
+ text: "",
513
+ meta: null,
514
+ })
515
+ }
516
+ rows.push({
517
+ kind: "role-divider",
518
+ role: chunk.role,
519
+ chunkId: null,
520
+ text: chunk.role.toUpperCase(),
521
+ meta: null,
522
+ })
523
+ }
524
+
525
+ let text = chunk.header
526
+ let meta = chunk.headerMeta
527
+ if (!chunk.needsHeader) {
528
+ text = firstBodyLine(chunk.body)
529
+ meta = null
530
+ }
531
+ // Main list rows add their own semantic prefix glyph via
532
+ // `rowPrefix()` in AiChatView, so strip the transport glyph from
533
+ // structured chunk headers here. Otherwise tool rows render as
534
+ // `→ → bash ...` and results as `← ← read ...`.
535
+ if (chunk.kind === "tool-call" || chunk.kind === "tool-result") {
536
+ text = stripTransportGlyph(text)
537
+ }
538
+
539
+ if (chunk.kind === "tool-call") {
540
+ const preview = toolRowPreview(chunk.body)
541
+ // Keep row text focused on the primary action (already encoded in
542
+ // `header`) and use the dim right column for "there is more here"
543
+ // metadata only when it adds signal. For JSON-heavy tool args this
544
+ // is usually just noise, so we currently leave meta alone.
545
+ if (preview && preview !== text) {
546
+ meta = meta ?? null
547
+ }
548
+ }
549
+
550
+ if (chunk.kind === "tool-result") {
551
+ const matchingCall = chunk.toolCallId ? toolCallById.get(chunk.toolCallId) ?? null : null
552
+ if (matchingCall) {
553
+ // Carry the originating call summary into the result row so the
554
+ // list can answer "result of what?" without opening the modal.
555
+ // Example: `← bash git status --short --branch`,
556
+ // `← read /src/formatter.ts @40 +80`.
557
+ text = stripTransportGlyph(matchingCall.header)
558
+ }
559
+ const preview = toolRowPreview(chunk.body)
560
+ if (preview) {
561
+ meta = chunk.headerMeta ? `${chunk.headerMeta} · ${preview}` : preview
562
+ }
563
+ }
564
+ if (chunk.kind === "system") {
565
+ text = "prompt"
566
+ }
567
+ if (text.length === 0) {
568
+ text = chunk.kind.replace(/-/g, " ")
569
+ }
570
+
571
+ rows.push({
572
+ kind: "chunk",
573
+ role: chunk.role,
574
+ chunkId: chunk.id,
575
+ text,
576
+ meta,
577
+ })
578
+
579
+ prevRole = chunk.role
580
+ prevMessageIndex = chunk.messageIndex
581
+ }
582
+
583
+ return rows
584
+ }
585
+
586
+ /** Human-facing title for the detail modal. */
587
+ export const chunkDetailTitle = (chunk: Chunk): string => {
588
+ if (chunk.kind === "system") return "SYSTEM PROMPT"
589
+ if (chunk.kind === "raw-prompt") return "RAW PROMPT"
590
+ if (chunk.kind === "response") return "RESPONSE"
591
+ if (chunk.kind === "user-text") return "USER"
592
+ if (chunk.kind === "assistant-text") return "ASSISTANT"
593
+ if (chunk.kind === "reasoning") return "REASONING"
594
+ if (chunk.kind === "tool-call") return chunk.toolName ? `TOOL CALL · ${chunk.toolName}` : "TOOL CALL"
595
+ if (chunk.kind === "tool-result") return chunk.toolName ? `TOOL RESULT · ${chunk.toolName}` : "TOOL RESULT"
596
+ return chunk.header.toUpperCase()
597
+ }
598
+
599
+ /**
600
+ * Full wrapped body lines for the detail modal. Unlike the old in-line
601
+ * expansion view this is always the complete chunk body, never a preview.
602
+ */
603
+ export const renderChunkDetailLines = (chunk: Chunk, width: number): readonly string[] => {
604
+ const usableWidth = Math.max(16, width)
605
+ const source = chunk.body.length > 0 ? chunk.body : chunk.header
606
+ return wrapTextLines(source, usableWidth, 4_000)
607
+ }
608
+
609
+ // ---------------------------------------------------------------------------
610
+ // Rendering chunks → viewport lines
611
+ // ---------------------------------------------------------------------------
612
+
613
+ export type ChatLineKind =
614
+ | "role-divider"
615
+ | "chunk-header"
616
+ | "text"
617
+ | "reasoning"
618
+ | "tool-call-body"
619
+ | "tool-result-body"
620
+ | "hint"
621
+ | "separator"
622
+ | "empty"
623
+
624
+ export interface ChatLine {
625
+ readonly kind: ChatLineKind
626
+ readonly role: Role
627
+ readonly text: string
628
+ /** Right-aligned metadata for header lines only. */
629
+ readonly headerMeta?: string
630
+ /** Id of the chunk this line belongs to (or null for role dividers / separators). */
631
+ readonly chunkId: string | null
632
+ }
633
+
634
+ /** Is this chunk currently showing its body? */
635
+ export const isChunkExpanded = (
636
+ chunk: Chunk,
637
+ expanded: ReadonlySet<string>,
638
+ ): boolean => {
639
+ if (!chunk.collapsible) return true
640
+ const forceOpen = expanded.has(chunk.id)
641
+ if (chunk.collapsedByDefault) return forceOpen
642
+ const forceShut = expanded.has(`!${chunk.id}`)
643
+ return !forceShut
644
+ }
645
+
646
+ /** Flip a chunk's visible state. Uses the pair `id` / `!id` so toggling can
647
+ * both force-open a default-collapsed chunk and force-shut a default-open
648
+ * one without losing track of which state is the "default". */
649
+ export const toggleChunkExpansion = (
650
+ chunk: Chunk,
651
+ expanded: ReadonlySet<string>,
652
+ ): ReadonlySet<string> => {
653
+ if (!chunk.collapsible) return expanded
654
+ const next = new Set(expanded)
655
+ if (chunk.collapsedByDefault) {
656
+ if (next.has(chunk.id)) next.delete(chunk.id)
657
+ else next.add(chunk.id)
658
+ } else {
659
+ const key = `!${chunk.id}`
660
+ if (next.has(key)) next.delete(key)
661
+ else next.add(key)
662
+ }
663
+ return next
664
+ }
665
+
666
+ export interface RenderOptions {
667
+ readonly width: number
668
+ readonly expanded: ReadonlySet<string>
669
+ }
670
+
671
+ /** Render chunks into a flat list of viewport lines. Each line carries
672
+ * its `chunkId` so the view can highlight the selected chunk and so we
673
+ * can compute a scroll offset that keeps a chosen chunk visible. */
674
+ export const renderChunks = (
675
+ chunks: readonly Chunk[],
676
+ options: RenderOptions,
677
+ ): readonly ChatLine[] => {
678
+ const { width, expanded } = options
679
+ const wrapWidth = Math.max(24, width - 2)
680
+ const bodyWidth = Math.max(16, wrapWidth - 2)
681
+ const lines: ChatLine[] = []
682
+
683
+ if (chunks.length === 0) {
684
+ return [{ kind: "empty", role: "unknown", text: "no chat content", chunkId: null }]
685
+ }
686
+
687
+ // Two visual layers:
688
+ //
689
+ // 1. A role divider (USER / ASSISTANT / TOOL / SYSTEM / RESPONSE)
690
+ // renders once at every turn boundary. It's a lightweight title
691
+ // that anchors the reader and visually separates conversation
692
+ // beats without repeating per chunk.
693
+ //
694
+ // 2. Each chunk contributes either:
695
+ // - Text-kind chunks (user-text, assistant-text, response,
696
+ // raw-prompt): just their body, flush-indented under the role
697
+ // divider. No chunk-header row — `needsHeader` is false.
698
+ // - Structured chunks (reasoning, tool-call, tool-result, system,
699
+ // unknown): a chunk-header row carrying the kind + expand
700
+ // marker, followed by the body when expanded.
701
+ //
702
+ // Every rendered line carries the owning `chunkId` so the view can
703
+ // draw a continuous left-edge selection bar across the chunk's
704
+ // full footprint (header + body).
705
+ let prevRole: Role | null = null
706
+ let prevMessageIndex = -1
707
+
708
+ for (const chunk of chunks) {
709
+ const roleChanged = chunk.role !== prevRole || chunk.messageIndex !== prevMessageIndex
710
+ if (roleChanged) {
711
+ if (lines.length > 0) {
712
+ lines.push({ kind: "separator", role: "unknown", text: "", chunkId: null })
713
+ }
714
+ lines.push({
715
+ kind: "role-divider",
716
+ role: chunk.role,
717
+ text: chunk.role.toUpperCase(),
718
+ chunkId: null,
719
+ })
720
+ }
721
+
722
+ if (chunk.needsHeader) {
723
+ lines.push({
724
+ kind: "chunk-header",
725
+ role: chunk.role,
726
+ text: chunk.header,
727
+ headerMeta: chunk.headerMeta ?? undefined,
728
+ chunkId: chunk.id,
729
+ })
730
+ }
731
+
732
+ const expandedNow = isChunkExpanded(chunk, expanded)
733
+
734
+ if (expandedNow && chunk.body.length > 0) {
735
+ const bodyKind: ChatLineKind =
736
+ chunk.kind === "tool-call" ? "tool-call-body"
737
+ : chunk.kind === "tool-result" ? "tool-result-body"
738
+ : chunk.kind === "reasoning" ? "reasoning"
739
+ : chunk.kind === "unknown" ? "hint"
740
+ : "text"
741
+
742
+ // Text bodies hang off the role divider with a small indent
743
+ // so prose reads clean. Structured bodies (tool args, tool
744
+ // results, reasoning) indent deeper so they're obviously
745
+ // subordinate to their own chunk header.
746
+ const indent = chunk.needsHeader ? " " : " "
747
+ const wrapped = wrapTextLines(chunk.body, chunk.needsHeader ? bodyWidth : wrapWidth, 2_000)
748
+ for (const line of wrapped) {
749
+ lines.push({
750
+ kind: bodyKind,
751
+ role: chunk.role,
752
+ text: `${indent}${line}`,
753
+ chunkId: chunk.id,
754
+ })
755
+ }
756
+ }
757
+ // Collapsed chunks: the header already shows the expand marker
758
+ // + char count on the right; no "enter to expand" per-line hint.
759
+ // The footer carries the keyboard hint globally.
760
+
761
+ prevRole = chunk.role
762
+ prevMessageIndex = chunk.messageIndex
763
+ }
764
+
765
+ return lines
766
+ }
767
+
768
+ /** Find the viewport line index where the given chunk's header lives.
769
+ * Returns -1 if the chunk isn't in the rendered list. */
770
+ export const chunkHeaderLineIndex = (
771
+ lines: readonly ChatLine[],
772
+ chunkId: string,
773
+ ): number => lines.findIndex((l) => l.kind === "chunk-header" && l.chunkId === chunkId)