@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.
- package/AGENTS.md +5 -0
- package/package.json +7 -5
- package/src/App.tsx +233 -59
- package/src/daemon.test.ts +213 -6
- package/src/daemon.ts +174 -38
- package/src/domain.test.ts +62 -0
- package/src/domain.ts +16 -0
- package/src/localServer.ts +114 -128
- package/src/mcp.ts +172 -0
- package/src/motelClient.ts +166 -14
- package/src/registry.ts +26 -23
- package/src/runtime.ts +8 -2
- package/src/server.ts +10 -9
- package/src/services/AsyncIngest.ts +68 -0
- package/src/services/TelemetryStore.ts +262 -119
- package/src/services/TraceQueryService.ts +3 -1
- package/src/services/ingestRpc.ts +41 -0
- package/src/services/telemetryWorker.ts +62 -0
- package/src/storybook/aiChatStory.tsx +244 -0
- package/src/storybook/fixtures/errorState.ts +44 -0
- package/src/storybook/fixtures/imagePaste.ts +34 -0
- package/src/storybook/fixtures/index.ts +62 -0
- package/src/storybook/fixtures/kitchenSink.ts +148 -0
- package/src/storybook/fixtures/rawPrompt.ts +15 -0
- package/src/storybook/fixtures/short.ts +27 -0
- package/src/storybook/fixtures/toolHeavy.ts +65 -0
- package/src/telemetry.test.ts +28 -0
- package/src/ui/AiChatView.tsx +308 -0
- package/src/ui/SpanContentView.tsx +181 -0
- package/src/ui/SpanDetail.tsx +98 -17
- package/src/ui/TraceDetailsPane.tsx +11 -28
- package/src/ui/Waterfall.tsx +43 -148
- package/src/ui/aiChatModel.test.ts +391 -0
- package/src/ui/aiChatModel.ts +773 -0
- package/src/ui/aiState.ts +71 -0
- package/src/ui/app/TraceWorkspace.tsx +288 -124
- package/src/ui/app/useAppLayout.ts +14 -11
- package/src/ui/app/useTraceScreenData.ts +174 -40
- package/src/ui/atoms.ts +131 -0
- package/src/ui/loaders.ts +120 -0
- package/src/ui/persistence.ts +41 -0
- package/src/ui/primitives.tsx +27 -13
- package/src/ui/state.ts +4 -199
- package/src/ui/useAttrFilterPicker.ts +63 -23
- package/src/ui/useKeyboardNav.ts +552 -364
- package/src/ui/waterfallModel.ts +130 -0
- package/src/ui/waterfallNav.test.ts +17 -1
- 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)
|