@linxin666/dsh-remote-web-ui 0.1.16 → 0.1.18

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/README.i18n.yaml +2 -2
  2. package/README.md +5 -2
  3. package/README.zh.md +2 -2
  4. package/assets/apple-touch-icon.png +0 -0
  5. package/lib/client.js +48 -58
  6. package/lib/client.js.map +1 -1
  7. package/lib/index.js +76 -14
  8. package/lib/mobile.js +649 -45
  9. package/lib/mobile.js.map +1 -1
  10. package/lib/types/client/PluginSettingsCard.d.ts +11 -0
  11. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  12. package/lib/types/client/PluginSettingsCard.js +11 -5
  13. package/lib/types/mobile/markdown.d.ts +26 -0
  14. package/lib/types/mobile/markdown.d.ts.map +1 -0
  15. package/lib/types/mobile/markdown.js +272 -0
  16. package/lib/types/mobile/markdown.test.d.ts +2 -0
  17. package/lib/types/mobile/markdown.test.d.ts.map +1 -0
  18. package/lib/types/mobile/markdown.test.js +67 -0
  19. package/lib/types/mobile/messages.d.ts +14 -0
  20. package/lib/types/mobile/messages.d.ts.map +1 -1
  21. package/lib/types/mobile/messages.js +54 -2
  22. package/lib/types/mobile/messages.test.js +99 -0
  23. package/lib/types/mobile/mobile-styles.d.ts +1 -1
  24. package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
  25. package/lib/types/mobile/mobile-styles.js +167 -0
  26. package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
  27. package/lib/types/mobile/views/ChatView.js +81 -5
  28. package/lib/types/mobile/views/ChatView.test.js +105 -0
  29. package/lib/types/mobile-api.d.ts.map +1 -1
  30. package/lib/types/mobile-api.js +19 -2
  31. package/lib/types/mobile-routes.d.ts.map +1 -1
  32. package/lib/types/mobile-routes.js +32 -0
  33. package/lib/types/update.d.ts +5 -3
  34. package/lib/types/update.d.ts.map +1 -1
  35. package/lib/types/update.js +22 -6
  36. package/package.json +2 -1
  37. package/src/client/PluginSettingsCard.tsx +50 -47
  38. package/src/client/settings-card.module.css +9 -0
  39. package/src/mobile/markdown.test.ts +77 -0
  40. package/src/mobile/markdown.ts +278 -0
  41. package/src/mobile/messages.test.ts +106 -0
  42. package/src/mobile/messages.ts +66 -2
  43. package/src/mobile/mobile-styles.ts +167 -0
  44. package/src/mobile/views/ChatView.test.tsx +121 -0
  45. package/src/mobile/views/ChatView.tsx +164 -6
  46. package/src/mobile-api.ts +19 -2
  47. package/src/mobile-routes.ts +33 -0
  48. package/src/update.ts +23 -6
@@ -0,0 +1,278 @@
1
+ /**
2
+ * A compact GFM-subset markdown renderer for the mobile chat: headings,
3
+ * paragraphs, fenced + inline code, bold/italic/strikethrough, links,
4
+ * images, lists, blockquotes, hr, and tables. All HTML is escaped before
5
+ * transformation — the output only ever contains the renderer's own tags.
6
+ * Dependency-free on purpose (the mobile bundle stays at ~456 KB); the
7
+ * escape-first + protocol allow-list design mirrors the desktop panel's
8
+ * preview renderer (dsh-aionui-panel/src/client/preview/markdown.ts).
9
+ * Pure and exported for tests.
10
+ * @module dsh-remote-web-ui/mobile/markdown
11
+ */
12
+
13
+ /** Escape HTML special characters. */
14
+ export function escapeHtml(text: string): string {
15
+ return text
16
+ .replace(/&/g, '&')
17
+ .replace(/</g, '&lt;')
18
+ .replace(/>/g, '&gt;')
19
+ .replace(/"/g, '&quot;')
20
+ .replace(/'/g, '&#39;')
21
+ }
22
+
23
+ /**
24
+ * Guard a raw link/image target against dangerous protocols. Returns the
25
+ * (trimmed) raw string when safe, else null. Only http:, https:, mailto:
26
+ * and fragment anchors (#...) are allowed; anything with another scheme
27
+ * — javascript:, data:, vbscript:, etc. — is rejected so the value never
28
+ * reaches dangerouslySetInnerHTML.
29
+ */
30
+ export function safeUrl(raw: string): string | null {
31
+ const trimmed = raw.trim()
32
+ if (trimmed === '') return null
33
+ if (trimmed.startsWith('#')) return trimmed
34
+ const scheme = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(trimmed)
35
+ if (scheme === null) return trimmed
36
+ const name = scheme[1].toLowerCase()
37
+ return name === 'http' || name === 'https' || name === 'mailto' ? trimmed : null
38
+ }
39
+
40
+ /** Find the ')' closing a link/image target, skipping nested parens. */
41
+ function findCloseParen(text: string, from: number): number {
42
+ let depth = 0
43
+ for (let i = from; i < text.length; i += 1) {
44
+ const char = text[i]
45
+ if (char === '(') depth += 1
46
+ else if (char === ')') {
47
+ if (depth === 0) return i
48
+ depth -= 1
49
+ }
50
+ }
51
+ return -1
52
+ }
53
+
54
+ /** Inline pass: code spans, bold, italic, strikethrough, images, links. */
55
+ export function renderInline(text: string): string {
56
+ let out = ''
57
+ let i = 0
58
+ const n = text.length
59
+ while (i < n) {
60
+ const char = text[i]
61
+ // Fenced inline code first.
62
+ if (char === '`') {
63
+ const end = text.indexOf('`', i + 1)
64
+ if (end !== -1) {
65
+ out += '<code>' + escapeHtml(text.slice(i + 1, end)) + '</code>'
66
+ i = end + 1
67
+ continue
68
+ }
69
+ }
70
+ // Image ![alt](src)
71
+ if (char === '!' && text[i + 1] === '[') {
72
+ const close = text.indexOf('](', i + 2)
73
+ if (close !== -1) {
74
+ const parenEnd = findCloseParen(text, close + 2)
75
+ if (parenEnd !== -1) {
76
+ const alt = text.slice(i + 2, close)
77
+ const src = text.slice(close + 2, parenEnd)
78
+ const safe = safeUrl(src)
79
+ if (safe === null) {
80
+ out += escapeHtml(alt)
81
+ } else {
82
+ const srcEsc = escapeHtml(safe).replace(/\s+/g, '%20')
83
+ out += '<img alt="' + escapeHtml(alt) + '" src="' + srcEsc + '" />'
84
+ }
85
+ i = parenEnd + 1
86
+ continue
87
+ }
88
+ }
89
+ }
90
+ // Link [text](href)
91
+ if (char === '[') {
92
+ const close = text.indexOf('](', i + 1)
93
+ if (close !== -1) {
94
+ const parenEnd = findCloseParen(text, close + 2)
95
+ if (parenEnd !== -1) {
96
+ const label = text.slice(i + 1, close)
97
+ const href = text.slice(close + 2, parenEnd)
98
+ const safe = safeUrl(href)
99
+ if (safe === null) {
100
+ out += renderInline(label)
101
+ } else {
102
+ out += '<a href="' + escapeHtml(safe) + '" target="_blank" rel="noopener noreferrer">' + renderInline(label) + '</a>'
103
+ }
104
+ i = parenEnd + 1
105
+ continue
106
+ }
107
+ }
108
+ }
109
+ // Bold **text**
110
+ if (char === '*' && text[i + 1] === '*') {
111
+ const end = text.indexOf('**', i + 2)
112
+ if (end !== -1) {
113
+ out += '<strong>' + renderInline(text.slice(i + 2, end)) + '</strong>'
114
+ i = end + 2
115
+ continue
116
+ }
117
+ }
118
+ // Italic *text*
119
+ if (char === '*' && text[i - 1] !== '*' && text[i + 1] !== '*') {
120
+ const end = text.indexOf('*', i + 1)
121
+ if (end !== -1 && text[end + 1] !== '*') {
122
+ out += '<em>' + renderInline(text.slice(i + 1, end)) + '</em>'
123
+ i = end + 1
124
+ continue
125
+ }
126
+ }
127
+ // Strikethrough ~~text~~
128
+ if (char === '~' && text[i + 1] === '~') {
129
+ const end = text.indexOf('~~', i + 2)
130
+ if (end !== -1) {
131
+ out += '<del>' + renderInline(text.slice(i + 2, end)) + '</del>'
132
+ i = end + 2
133
+ continue
134
+ }
135
+ }
136
+ out += escapeHtml(char)
137
+ i += 1
138
+ }
139
+ return out
140
+ }
141
+
142
+ /** Render a markdown document to HTML (block pass). */
143
+ export function renderMarkdown(source: string): string {
144
+ const lines = source.replace(/\r\n/g, '\n').split('\n')
145
+ const out: string[] = []
146
+ let i = 0
147
+ const n = lines.length
148
+
149
+ const flushParagraph = (buffer: string[]): void => {
150
+ if (buffer.length === 0) return
151
+ out.push('<p>' + renderInline(buffer.join('\n')) + '</p>')
152
+ buffer.length = 0
153
+ }
154
+
155
+ let paragraph: string[] = []
156
+ while (i < n) {
157
+ const line = lines[i]
158
+
159
+ // Fenced code block.
160
+ const fence = /^```([\w+-]*)\s*$/.exec(line)
161
+ if (fence !== null) {
162
+ flushParagraph(paragraph)
163
+ const lang = fence[1] ?? ''
164
+ i += 1
165
+ const code: string[] = []
166
+ while (i < n && !/^```\s*$/.test(lines[i])) {
167
+ code.push(lines[i])
168
+ i += 1
169
+ }
170
+ i += 1 // closing fence
171
+ const langAttr = lang === '' ? '' : ' class="language-' + escapeHtml(lang) + '"'
172
+ out.push('<pre' + langAttr + '><code>' + escapeHtml(code.join('\n')) + '</code></pre>')
173
+ continue
174
+ }
175
+
176
+ // Heading.
177
+ const heading = /^(#{1,6})\s+(.*)$/.exec(line)
178
+ if (heading !== null) {
179
+ flushParagraph(paragraph)
180
+ const level = heading[1].length
181
+ out.push('<h' + level + '>' + renderInline(heading[2] ?? '') + '</h' + level + '>')
182
+ i += 1
183
+ continue
184
+ }
185
+
186
+ // Horizontal rule.
187
+ if (/^\s*(---+|\*\*\*+|___+)\s*$/.test(line)) {
188
+ flushParagraph(paragraph)
189
+ out.push('<hr />')
190
+ i += 1
191
+ continue
192
+ }
193
+
194
+ // Table: header row then separator row.
195
+ if (line.includes('|') && i + 1 < n && /^\s*\|?[\s:|-]+\|?\s*$/.test(lines[i + 1]) && lines[i + 1].includes('-')) {
196
+ flushParagraph(paragraph)
197
+ const headerCells = splitTableRow(line)
198
+ i += 2
199
+ const rows: string[][] = []
200
+ while (i < n && lines[i].includes('|')) {
201
+ rows.push(splitTableRow(lines[i]))
202
+ i += 1
203
+ }
204
+ out.push('<table>')
205
+ out.push('<thead><tr>' + headerCells.map((cell) => '<th>' + renderInline(cell) + '</th>').join('') + '</tr></thead>')
206
+ if (rows.length > 0) {
207
+ out.push('<tbody>' + rows.map((row) => '<tr>' + row.map((cell) => '<td>' + renderInline(cell) + '</td>').join('') + '</tr>').join('') + '</tbody>')
208
+ }
209
+ out.push('</table>')
210
+ continue
211
+ }
212
+
213
+ // Blockquote (one level).
214
+ const quote = /^>\s?(.*)$/.exec(line)
215
+ if (quote !== null) {
216
+ flushParagraph(paragraph)
217
+ const body: string[] = []
218
+ while (i < n) {
219
+ const q = /^>\s?(.*)$/.exec(lines[i])
220
+ if (q === null) break
221
+ body.push(q[1] ?? '')
222
+ i += 1
223
+ }
224
+ out.push('<blockquote><p>' + body.map((bodyLine) => renderInline(bodyLine)).join('<br />') + '</p></blockquote>')
225
+ continue
226
+ }
227
+
228
+ // Unordered list.
229
+ const ul = /^\s*([-*+])\s+(.*)$/.exec(line)
230
+ if (ul !== null) {
231
+ flushParagraph(paragraph)
232
+ const items: string[] = []
233
+ while (i < n) {
234
+ const item = /^\s*([-*+])\s+(.*)$/.exec(lines[i])
235
+ if (item === null) break
236
+ items.push('<li>' + renderInline(item[2] ?? '') + '</li>')
237
+ i += 1
238
+ }
239
+ out.push('<ul>' + items.join('') + '</ul>')
240
+ continue
241
+ }
242
+
243
+ // Ordered list.
244
+ const ol = /^\s*\d+[.)]\s+(.*)$/.exec(line)
245
+ if (ol !== null) {
246
+ flushParagraph(paragraph)
247
+ const items: string[] = []
248
+ while (i < n) {
249
+ const item = /^\s*\d+[.)]\s+(.*)$/.exec(lines[i])
250
+ if (item === null) break
251
+ items.push('<li>' + renderInline(item[1] ?? '') + '</li>')
252
+ i += 1
253
+ }
254
+ out.push('<ol>' + items.join('') + '</ol>')
255
+ continue
256
+ }
257
+
258
+ // Blank line: flush the paragraph.
259
+ if (line.trim() === '') {
260
+ flushParagraph(paragraph)
261
+ i += 1
262
+ continue
263
+ }
264
+
265
+ paragraph.push(line)
266
+ i += 1
267
+ }
268
+ flushParagraph(paragraph)
269
+ return out.join('\n')
270
+ }
271
+
272
+ /** Split one table row into cells (respecting the leading/trailing pipes). */
273
+ function splitTableRow(line: string): string[] {
274
+ const trimmed = line.trim()
275
+ const inner = trimmed.startsWith('|') ? trimmed.slice(1) : trimmed
276
+ const withoutTrailing = inner.endsWith('|') ? inner.slice(0, -1) : inner
277
+ return withoutTrailing.split('|').map((cell) => cell.trim())
278
+ }
@@ -219,4 +219,110 @@ describe('foldEvents', () => {
219
219
  foldEvents([makeEvent('assistant/message', assistantMessageData('a-1', 0, 0, 'world'), 1)], first)
220
220
  expect(JSON.stringify(first)).toBe(snapshot)
221
221
  })
222
+
223
+ it('request/context sets the context window used by a later assistant/message with usage', () => {
224
+ const events: WireEvent[] = [
225
+ makeEvent('request/context', { provider: 'fx', model: 'fx-1', contextWindow: 100_000 }, 0),
226
+ makeEvent('user/message', userMessageData('u-1', 'hi'), 1),
227
+ makeEvent('assistant/message', {
228
+ turn: 0,
229
+ step: 0,
230
+ message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
231
+ usage: { inputTokens: 10, outputTokens: 5 },
232
+ }, 2),
233
+ ]
234
+ const result = foldEvents(events)
235
+ const assistant = result.find(message => message.kind === 'assistant')
236
+ expect(assistant).toMatchObject({
237
+ usage: { inputTokens: 10, outputTokens: 5 },
238
+ contextWindow: 100_000,
239
+ })
240
+ })
241
+
242
+ it('usage without a context window carries no contextWindow', () => {
243
+ const events: WireEvent[] = [
244
+ makeEvent('user/message', userMessageData('u-1', 'hi'), 0),
245
+ makeEvent('assistant/message', {
246
+ turn: 0,
247
+ step: 0,
248
+ message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
249
+ usage: { inputTokens: 10, outputTokens: 5 },
250
+ }, 1),
251
+ ]
252
+ const result = foldEvents(events)
253
+ const assistant = result.find(message => message.kind === 'assistant')
254
+ expect(assistant).toMatchObject({ usage: { inputTokens: 10, outputTokens: 5 } })
255
+ expect(assistant?.contextWindow).toBeUndefined()
256
+ })
257
+
258
+ it('usage and contextWindow are both attached when request/context precedes the message', () => {
259
+ const events: WireEvent[] = [
260
+ makeEvent('request/context', { provider: 'fx', model: 'fx-1', contextWindow: 200_000 }, 0),
261
+ makeEvent('user/message', userMessageData('u-1', 'hi'), 1),
262
+ makeEvent('assistant/message', {
263
+ turn: 0,
264
+ step: 0,
265
+ message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
266
+ usage: { inputTokens: 30, outputTokens: 2, cacheReadTokens: 8, cacheWriteTokens: 1 },
267
+ }, 2),
268
+ ]
269
+ const result = foldEvents(events)
270
+ const assistant = result.find(message => message.kind === 'assistant')
271
+ expect(assistant).toMatchObject({
272
+ usage: { inputTokens: 30, outputTokens: 2, cacheReadTokens: 8, cacheWriteTokens: 1 },
273
+ contextWindow: 200_000,
274
+ })
275
+ })
276
+
277
+ it('assistant/message without usage has no usage field', () => {
278
+ const events: WireEvent[] = [
279
+ makeEvent('user/message', userMessageData('u-1', 'hi'), 0),
280
+ makeEvent('assistant/message', { turn: 0, step: 0, message: { id: 'a-1', role: 'assistant', content: [{ type: 'text', text: 'ok' }] } }, 1),
281
+ ]
282
+ const result = foldEvents(events)
283
+ const assistant = result.find(message => message.kind === 'assistant')
284
+ expect(assistant?.usage).toBeUndefined()
285
+ expect(assistant?.contextWindow).toBeUndefined()
286
+ })
287
+
288
+ it('attaches sourceKind from source.kind for a user message', () => {
289
+ const events: WireEvent[] = [
290
+ makeEvent('user/message', {
291
+ id: 'u-plugin',
292
+ role: 'user',
293
+ content: [{ type: 'text', text: '系统注入' }],
294
+ source: { kind: 'plugin', name: 'react-extension' },
295
+ }, 0),
296
+ ]
297
+ const result = foldEvents(events)
298
+ expect(result[0]).toMatchObject({ id: 'u-plugin', kind: 'user', sourceKind: 'plugin' })
299
+ })
300
+
301
+ it('keeps sourceKind on a replayed/replaced user/message', () => {
302
+ const first = foldEvents([makeEvent('user/message', {
303
+ id: 'u-1',
304
+ role: 'user',
305
+ content: [{ type: 'text', text: '第一版' }],
306
+ source: { kind: 'plugin' },
307
+ }, 0)])
308
+ const second = foldEvents([makeEvent('user/message', {
309
+ id: 'u-1',
310
+ role: 'user',
311
+ content: [{ type: 'text', text: '第二版' }],
312
+ source: { kind: 'plugin' },
313
+ }, 1)], first)
314
+ expect(second[0]).toMatchObject({ id: 'u-1', text: '第二版', sourceKind: 'plugin' })
315
+ })
316
+
317
+ it('request/context and unknown events are still ignored by the fold', () => {
318
+ const events: WireEvent[] = [
319
+ makeEvent('some/future-event', { nope: true }, 0),
320
+ makeEvent('request/context', { provider: 'fx', model: 'fx-1' }, 1),
321
+ makeEvent('user/message', userMessageData('u-1', '真实消息'), 2),
322
+ ]
323
+ const result = foldEvents(events)
324
+ // request/context with no window and the unknown event render nothing.
325
+ expect(result).toHaveLength(1)
326
+ expect(result[0]).toMatchObject({ id: 'u-1', kind: 'user' })
327
+ })
222
328
  })
@@ -68,6 +68,20 @@ export interface RenderMessage {
68
68
  readonly toolSummary?: string
69
69
  /** Set when the owning turn ended in an error. */
70
70
  readonly failed?: boolean
71
+ /**
72
+ * Token usage reported by the final assistant event. cacheReadTokens and
73
+ * cacheWriteTokens are only attached when the wire carried finite values.
74
+ */
75
+ readonly usage?: {
76
+ inputTokens: number
77
+ outputTokens: number
78
+ cacheReadTokens?: number
79
+ cacheWriteTokens?: number
80
+ }
81
+ /** Context window for the model that produced this message (from request/context). */
82
+ readonly contextWindow?: number
83
+ /** Wire source.kind of a user message (e.g. plugin or user). */
84
+ readonly sourceKind?: string
71
85
  }
72
86
 
73
87
  /** One tool call attached to an assistant message (callId dedupes repeats). */
@@ -199,6 +213,8 @@ interface FoldState {
199
213
  messageTurn: Map<string, number>
200
214
  /** Deduped tool names per assistant message id. */
201
215
  toolNames: Map<string, Set<string>>
216
+ /** Context window for the current model (from request/context). */
217
+ contextWindow?: number
202
218
  }
203
219
 
204
220
  function createState(existing: readonly RenderMessage[] | undefined): FoldState {
@@ -294,6 +310,14 @@ function applyEvent(state: FoldState, event: WireEvent): void {
294
310
  case 'tool/call':
295
311
  applyToolCall(state, event)
296
312
  break
313
+ case 'request/context': {
314
+ // Wire shape: { provider, model, contextWindow? }. A present finite
315
+ // contextWindow seeds every later assistant message that reports usage.
316
+ const data = isRecord(event.data) ? event.data : {}
317
+ const window = pickNumber(data['contextWindow'])
318
+ if (window !== undefined) state.contextWindow = window
319
+ break
320
+ }
297
321
  // turn/start, session/end-seed, and every other/unknown type render nothing.
298
322
  default:
299
323
  break
@@ -304,13 +328,28 @@ function applyUserMessage(state: FoldState, event: WireEvent): void {
304
328
  const data = isRecord(event.data) ? event.data : {}
305
329
  const id = pickString(data['id']) ?? syntheticId('user', event.seq)
306
330
  const text = textFromContent(data['content'])
331
+ const source = isRecord(data['source']) ? data['source'] : {}
332
+ const sourceKind = pickString(source['kind'])
307
333
  const existing = state.byId.get(id)
308
334
  if (existing !== undefined) {
309
335
  // Idempotent replace (replayed events update in place, never duplicate).
310
- replaceMessage(state, existing, { ...existing, text, seq: event.seq, time: event.time })
336
+ replaceMessage(state, existing, {
337
+ ...existing,
338
+ ...(sourceKind !== undefined ? { sourceKind } : {}),
339
+ text,
340
+ seq: event.seq,
341
+ time: event.time,
342
+ })
311
343
  return
312
344
  }
313
- const message: RenderMessage = { id, kind: 'user', text, seq: event.seq, time: event.time }
345
+ const message: RenderMessage = {
346
+ id,
347
+ kind: 'user',
348
+ text,
349
+ ...(sourceKind !== undefined ? { sourceKind } : {}),
350
+ seq: event.seq,
351
+ time: event.time,
352
+ }
314
353
  state.messages.push(message)
315
354
  state.byId.set(id, message)
316
355
  }
@@ -324,6 +363,8 @@ function applyAssistantMessage(state: FoldState, event: WireEvent): void {
324
363
  const finalText = textFromContent(messageData['content'])
325
364
  const finalReasoning = reasoningFromContent(messageData['content'])
326
365
  const key = tsKey(turn, step)
366
+ const usage = usageFromData(data)
367
+ const contextWindow = state.contextWindow
327
368
 
328
369
  // Finalize the matching assistant message (by id, or by turn/step for the
329
370
  // streaming partial that chunks built before the final event arrived).
@@ -338,6 +379,8 @@ function applyAssistantMessage(state: FoldState, event: WireEvent): void {
338
379
  // The final content block list is authoritative; an adapter that omits
339
380
  // reasoning from the final message keeps the streamed reasoning text.
340
381
  ...(finalReasoning !== '' ? { reasoning: finalReasoning } : {}),
382
+ ...(usage !== undefined ? { usage } : {}),
383
+ ...(usage !== undefined && contextWindow !== undefined ? { contextWindow } : {}),
341
384
  seq: event.seq,
342
385
  time: event.time,
343
386
  pending: false,
@@ -353,6 +396,8 @@ function applyAssistantMessage(state: FoldState, event: WireEvent): void {
353
396
  kind: 'assistant',
354
397
  text: finalText,
355
398
  ...(finalReasoning !== '' ? { reasoning: finalReasoning } : {}),
399
+ ...(usage !== undefined ? { usage } : {}),
400
+ ...(usage !== undefined && contextWindow !== undefined ? { contextWindow } : {}),
356
401
  seq: event.seq,
357
402
  time: event.time,
358
403
  }
@@ -365,6 +410,25 @@ function applyAssistantMessage(state: FoldState, event: WireEvent): void {
365
410
  if (turn !== undefined) state.messageTurn.set(id, turn)
366
411
  }
367
412
 
413
+ /**
414
+ * Extract token usage from an assistant event payload. Only attaches when the
415
+ * wire carries finite `inputTokens` AND `outputTokens`; the cache fields are
416
+ * included only for finite numbers.
417
+ */
418
+ function usageFromData(data: Record<string, unknown>): { inputTokens: number; outputTokens: number; cacheReadTokens?: number; cacheWriteTokens?: number } | undefined {
419
+ const usageData = data['usage']
420
+ if (!isRecord(usageData)) return undefined
421
+ const inputTokens = pickNumber(usageData['inputTokens'])
422
+ const outputTokens = pickNumber(usageData['outputTokens'])
423
+ if (inputTokens === undefined || outputTokens === undefined) return undefined
424
+ const usage: { inputTokens: number; outputTokens: number; cacheReadTokens?: number; cacheWriteTokens?: number } = { inputTokens, outputTokens }
425
+ const cacheReadTokens = pickNumber(usageData['cacheReadTokens'])
426
+ const cacheWriteTokens = pickNumber(usageData['cacheWriteTokens'])
427
+ if (cacheReadTokens !== undefined) usage.cacheReadTokens = cacheReadTokens
428
+ if (cacheWriteTokens !== undefined) usage.cacheWriteTokens = cacheWriteTokens
429
+ return usage
430
+ }
431
+
368
432
  function applyChunk(state: FoldState, event: WireEvent): void {
369
433
  const target = chunkTarget(event.data)
370
434
  if (target === null) return