moqi-tui 0.2.0

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 (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +782 -0
  3. package/bin/moqi.mjs +40 -0
  4. package/cordis.patch.yml +41 -0
  5. package/lib/cross-find.js +217 -0
  6. package/lib/file-index.js +121 -0
  7. package/lib/fleet-sources.js +114 -0
  8. package/lib/index.js +3999 -0
  9. package/lib/persist.js +194 -0
  10. package/lib/plugins.js +371 -0
  11. package/lib/presence.js +144 -0
  12. package/lib/rename.js +35 -0
  13. package/lib/rewind.js +94 -0
  14. package/lib/sessions-store.js +134 -0
  15. package/lib/startup.js +92 -0
  16. package/lib/tui/atfile.js +154 -0
  17. package/lib/tui/export.js +48 -0
  18. package/lib/tui/fleet.js +346 -0
  19. package/lib/tui/i18n.js +201 -0
  20. package/lib/tui/jobs.js +65 -0
  21. package/lib/tui/keys.js +205 -0
  22. package/lib/tui/markdown.js +368 -0
  23. package/lib/tui/mcp.js +95 -0
  24. package/lib/tui/panels.js +231 -0
  25. package/lib/tui/screen.js +156 -0
  26. package/lib/tui/state.js +502 -0
  27. package/lib/tui/stream.js +109 -0
  28. package/lib/tui/text.js +173 -0
  29. package/lib/tui/theme.js +183 -0
  30. package/lib/tui/themes.js +153 -0
  31. package/lib/tui/tooldetail.js +140 -0
  32. package/lib/tui/view.js +830 -0
  33. package/lib/tui/vim.js +222 -0
  34. package/lib/tui-host-core.js +141 -0
  35. package/lib/tui-host.js +48 -0
  36. package/lib/types/cross-find.d.ts +66 -0
  37. package/lib/types/file-index.d.ts +34 -0
  38. package/lib/types/fleet-sources.d.ts +34 -0
  39. package/lib/types/index.d.ts +51 -0
  40. package/lib/types/persist.d.ts +116 -0
  41. package/lib/types/plugins.d.ts +218 -0
  42. package/lib/types/presence.d.ts +48 -0
  43. package/lib/types/rename.d.ts +32 -0
  44. package/lib/types/rewind.d.ts +75 -0
  45. package/lib/types/sessions-store.d.ts +46 -0
  46. package/lib/types/startup.d.ts +45 -0
  47. package/lib/types/tui/atfile.d.ts +90 -0
  48. package/lib/types/tui/export.d.ts +18 -0
  49. package/lib/types/tui/fleet.d.ts +209 -0
  50. package/lib/types/tui/i18n.d.ts +34 -0
  51. package/lib/types/tui/jobs.d.ts +28 -0
  52. package/lib/types/tui/keys.d.ts +52 -0
  53. package/lib/types/tui/markdown.d.ts +14 -0
  54. package/lib/types/tui/mcp.d.ts +34 -0
  55. package/lib/types/tui/panels.d.ts +125 -0
  56. package/lib/types/tui/screen.d.ts +79 -0
  57. package/lib/types/tui/state.d.ts +323 -0
  58. package/lib/types/tui/stream.d.ts +78 -0
  59. package/lib/types/tui/text.d.ts +28 -0
  60. package/lib/types/tui/theme.d.ts +87 -0
  61. package/lib/types/tui/themes.d.ts +70 -0
  62. package/lib/types/tui/tooldetail.d.ts +45 -0
  63. package/lib/types/tui/view.d.ts +163 -0
  64. package/lib/types/tui/vim.d.ts +64 -0
  65. package/lib/types/tui-host-core.d.ts +62 -0
  66. package/lib/types/tui-host.d.ts +42 -0
  67. package/lib/types/version.d.ts +8 -0
  68. package/lib/types/voice.d.ts +227 -0
  69. package/lib/version.js +32 -0
  70. package/lib/voice.js +405 -0
  71. package/package.json +119 -0
  72. package/scripts/harness-root.mjs +88 -0
  73. package/scripts/install-profile.mjs +133 -0
@@ -0,0 +1,502 @@
1
+ /**
2
+ * App state: the transcript, the composer, the slash palette, and the picker.
3
+ *
4
+ * This module is deliberately free of terminal and Harness concerns so the
5
+ * interaction rules stay testable on their own — it only knows strings,
6
+ * cursors, and selections.
7
+ * @module
8
+ */
9
+ import { displayWidth, wrap } from "./text.js";
10
+ /** Minimum and maximum composer height, in text rows. */
11
+ export const MIN_INPUT_LINES = 1;
12
+ export const MAX_INPUT_LINES = 10;
13
+ /** How many sent prompts the composer history retains. */
14
+ export const HISTORY_LIMIT = 500;
15
+ /** A turn that is nothing but text: a prompt, a command result, a log replay. */
16
+ export function textMessage(role, text, rest = {}) {
17
+ return { role, segments: [{ kind: 'text', text }], ...rest };
18
+ }
19
+ /**
20
+ * The turn's prose with the tool calls dropped, for `/copy`, `/export`,
21
+ * `/find`, and the tab title.
22
+ *
23
+ * Segments are joined with a blank line because a tool call is where the model
24
+ * stopped and started again — running the two halves together is what made a
25
+ * reply read as one endless paragraph.
26
+ */
27
+ export function segmentsText(segments) {
28
+ return segments
29
+ .flatMap((segment) => (segment.kind === 'text' ? [segment.text.replace(/\s+$/, '')] : []))
30
+ .filter((text) => text !== '')
31
+ .join('\n\n');
32
+ }
33
+ export function messageText(message) {
34
+ return segmentsText(message.segments);
35
+ }
36
+ /**
37
+ * Every tool row, in order. The rows are the live objects, not copies, so a
38
+ * later `tool/result` event settles the row where it already sits in the turn.
39
+ */
40
+ export function segmentTools(segments) {
41
+ return segments.flatMap((segment) => (segment.kind === 'tool' ? [segment.tool] : []));
42
+ }
43
+ /** Every tool row in the turn, in order — for result events and counting. */
44
+ export function messageTools(message) {
45
+ return segmentTools(message.segments);
46
+ }
47
+ /**
48
+ * Append streamed text to the turn, continuing the trailing run when there is
49
+ * one. A tool call in between is what opens a new text segment, which is how
50
+ * the order gets recorded at all.
51
+ */
52
+ export function appendText(segments, text) {
53
+ if (text === '')
54
+ return;
55
+ const last = segments[segments.length - 1];
56
+ if (last !== undefined && last.kind === 'text')
57
+ last.text += text;
58
+ else
59
+ segments.push({ kind: 'text', text });
60
+ }
61
+ /** The tool row for a call id, wherever it sits in the turn. */
62
+ export function findTool(segments, match) {
63
+ for (const segment of segments) {
64
+ if (segment.kind === 'tool' && match(segment.tool))
65
+ return segment.tool;
66
+ }
67
+ return undefined;
68
+ }
69
+ /**
70
+ * Whether a turn's queued prompts should send once it settles.
71
+ *
72
+ * A clean finish always drains the queue. An interrupt is the user asking for
73
+ * silence — so it freezes the queue — *unless* the interrupt was asked for as
74
+ * a redirection (`/interrupt`): stop this answer, then run what was queued.
75
+ */
76
+ export function queueShouldDrain(options) {
77
+ return !options.interrupted || options.drainRequested;
78
+ }
79
+ /**
80
+ * The composer. A plain multi-line buffer with a cursor, wide enough in
81
+ * behavior to feel like an editor: word motion, line motion, and kill-to-end.
82
+ */
83
+ export class Composer {
84
+ text = '';
85
+ cursor = 0;
86
+ value() {
87
+ return this.text;
88
+ }
89
+ position() {
90
+ return this.cursor;
91
+ }
92
+ setValue(value) {
93
+ this.text = value;
94
+ this.cursor = value.length;
95
+ }
96
+ /** Replace the whole buffer and land the cursor at an explicit offset. */
97
+ adopt(text, cursor) {
98
+ this.text = text;
99
+ this.cursor = Math.min(Math.max(cursor, 0), text.length);
100
+ }
101
+ reset() {
102
+ this.text = '';
103
+ this.cursor = 0;
104
+ }
105
+ insert(chunk) {
106
+ this.text = this.text.slice(0, this.cursor) + chunk + this.text.slice(this.cursor);
107
+ this.cursor += chunk.length;
108
+ }
109
+ backspace() {
110
+ if (this.cursor === 0)
111
+ return;
112
+ this.text = this.text.slice(0, this.cursor - 1) + this.text.slice(this.cursor);
113
+ this.cursor -= 1;
114
+ }
115
+ deleteForward() {
116
+ if (this.cursor >= this.text.length)
117
+ return;
118
+ this.text = this.text.slice(0, this.cursor) + this.text.slice(this.cursor + 1);
119
+ }
120
+ /** Delete from the cursor back to the start of the current word. */
121
+ deleteWord() {
122
+ if (this.cursor === 0)
123
+ return;
124
+ let start = this.cursor;
125
+ while (start > 0 && /\s/.test(this.text[start - 1] ?? ''))
126
+ start -= 1;
127
+ while (start > 0 && !/\s/.test(this.text[start - 1] ?? ''))
128
+ start -= 1;
129
+ this.text = this.text.slice(0, start) + this.text.slice(this.cursor);
130
+ this.cursor = start;
131
+ }
132
+ /** Delete from the cursor to the end of the buffer. */
133
+ killToEnd() {
134
+ this.text = this.text.slice(0, this.cursor);
135
+ }
136
+ /** Delete from the start of the buffer to the cursor. */
137
+ killToStart() {
138
+ this.text = this.text.slice(this.cursor);
139
+ this.cursor = 0;
140
+ }
141
+ left() {
142
+ if (this.cursor > 0)
143
+ this.cursor -= 1;
144
+ }
145
+ right() {
146
+ if (this.cursor < this.text.length)
147
+ this.cursor += 1;
148
+ }
149
+ wordLeft() {
150
+ while (this.cursor > 0 && /\s/.test(this.text[this.cursor - 1] ?? ''))
151
+ this.cursor -= 1;
152
+ while (this.cursor > 0 && !/\s/.test(this.text[this.cursor - 1] ?? ''))
153
+ this.cursor -= 1;
154
+ }
155
+ wordRight() {
156
+ const length = this.text.length;
157
+ while (this.cursor < length && /\s/.test(this.text[this.cursor] ?? ''))
158
+ this.cursor += 1;
159
+ while (this.cursor < length && !/\s/.test(this.text[this.cursor] ?? ''))
160
+ this.cursor += 1;
161
+ }
162
+ home() {
163
+ const start = this.text.lastIndexOf('\n', Math.max(this.cursor - 1, 0));
164
+ this.cursor = start === -1 ? 0 : start + 1;
165
+ }
166
+ end() {
167
+ const next = this.text.indexOf('\n', this.cursor);
168
+ this.cursor = next === -1 ? this.text.length : next;
169
+ }
170
+ toStart() {
171
+ this.cursor = 0;
172
+ }
173
+ toEnd() {
174
+ this.cursor = this.text.length;
175
+ }
176
+ /** Offset of the first character of the cursor's logical line. */
177
+ lineStartIndex() {
178
+ const at = this.text.lastIndexOf('\n', Math.max(this.cursor - 1, 0));
179
+ return at === -1 ? 0 : at + 1;
180
+ }
181
+ /** Offset of the newline (or end of buffer) that closes the cursor's line. */
182
+ lineEndIndex() {
183
+ const at = this.text.indexOf('\n', this.cursor);
184
+ return at === -1 ? this.text.length : at;
185
+ }
186
+ /**
187
+ * Delete a half-open range and park the cursor at its start.
188
+ *
189
+ * The bounds are clamped, so a motion that ran off either end of the buffer
190
+ * deletes what it actually covered rather than throwing.
191
+ */
192
+ deleteRange(start, end) {
193
+ const from = Math.min(Math.max(start, 0), this.text.length);
194
+ const to = Math.min(Math.max(end, from), this.text.length);
195
+ this.text = this.text.slice(0, from) + this.text.slice(to);
196
+ this.cursor = Math.min(from, this.text.length);
197
+ }
198
+ /** Move the cursor one visual row up or down within the wrapped composer. */
199
+ moveRow(delta, width) {
200
+ const rows = this.layout(width);
201
+ const current = rows.findIndex((row) => this.cursor >= row.start && this.cursor <= row.end);
202
+ if (current === -1)
203
+ return;
204
+ const target = current + delta;
205
+ if (target < 0 || target >= rows.length)
206
+ return;
207
+ const column = this.cursor - (rows[current]?.start ?? 0);
208
+ const destination = rows[target];
209
+ if (destination === undefined)
210
+ return;
211
+ this.cursor = Math.min(destination.start + column, destination.end);
212
+ }
213
+ /**
214
+ * Wrap the buffer to `width`, returning each visual row with the buffer
215
+ * offsets it covers. The view and the cursor both read this, so they cannot
216
+ * disagree about where a row begins.
217
+ */
218
+ layout(width) {
219
+ const rows = [];
220
+ let offset = 0;
221
+ for (const logical of this.text.split('\n')) {
222
+ const pieces = width > 0 ? wrap(logical, width) : [logical];
223
+ let consumed = 0;
224
+ for (const piece of pieces) {
225
+ // wrap() drops the space it broke on; find the true span in the source.
226
+ const start = offset + consumed;
227
+ const pieceLength = piece.length;
228
+ rows.push({ text: piece, start, end: start + pieceLength });
229
+ consumed += pieceLength;
230
+ if (logical[start + pieceLength - offset] === ' ')
231
+ consumed += 1;
232
+ }
233
+ if (pieces.length === 0)
234
+ rows.push({ text: '', start: offset, end: offset });
235
+ offset += logical.length + 1;
236
+ }
237
+ return rows;
238
+ }
239
+ /** Height in rows the composer wants at `width`, clamped to the app's bounds. */
240
+ height(width) {
241
+ const rows = this.layout(width).length;
242
+ return Math.min(Math.max(rows, MIN_INPUT_LINES), MAX_INPUT_LINES);
243
+ }
244
+ /**
245
+ * Whether the cursor sits on the first visual row, so `↑` would otherwise be
246
+ * a no-op — the moment input-history recall should take over.
247
+ */
248
+ atFirstRow(width) {
249
+ const first = this.layout(width)[0];
250
+ return first === undefined || this.cursor <= first.end;
251
+ }
252
+ /** The mirror of {@link atFirstRow} for `↓` and newer history entries. */
253
+ atLastRow(width) {
254
+ const rows = this.layout(width);
255
+ const last = rows[rows.length - 1];
256
+ return last === undefined || this.cursor >= last.start;
257
+ }
258
+ }
259
+ /**
260
+ * Recall of previously sent prompts, the way a shell recalls its history.
261
+ *
262
+ * `recall` walks older (`-1`) or newer (`+1`) entries and returns the text to
263
+ * show, or `undefined` when there is nothing further in that direction — the
264
+ * caller then falls back to ordinary cursor motion. The draft being typed is
265
+ * remembered the first time recall leaves it, so walking back down to the end
266
+ * restores it rather than stranding the user on the last sent prompt.
267
+ */
268
+ export class InputHistory {
269
+ entries = [];
270
+ /** Position while recalling; `-1` means the live draft, not any entry. */
271
+ index = -1;
272
+ draft = '';
273
+ /** Record a sent prompt, ignoring empties and immediate repeats. */
274
+ add(text) {
275
+ const trimmed = text.trim();
276
+ if (trimmed === '')
277
+ return;
278
+ if (this.entries[this.entries.length - 1] === trimmed) {
279
+ this.reset();
280
+ return;
281
+ }
282
+ this.entries.push(trimmed);
283
+ if (this.entries.length > HISTORY_LIMIT) {
284
+ this.entries.splice(0, this.entries.length - HISTORY_LIMIT);
285
+ }
286
+ this.reset();
287
+ }
288
+ /** Adopt persisted entries (oldest first), keeping the most recent ones. */
289
+ load(entries) {
290
+ this.entries = entries.filter((entry) => typeof entry === 'string' && entry.trim() !== '');
291
+ if (this.entries.length > HISTORY_LIMIT) {
292
+ this.entries = this.entries.slice(this.entries.length - HISTORY_LIMIT);
293
+ }
294
+ this.reset();
295
+ }
296
+ /** Every recorded prompt, oldest first, for persistence. */
297
+ snapshot() {
298
+ return [...this.entries];
299
+ }
300
+ /**
301
+ * Walk the history. `draft` is what the composer holds now; it is saved the
302
+ * first time recall moves away from live typing.
303
+ */
304
+ recall(delta, draft) {
305
+ if (this.entries.length === 0)
306
+ return undefined;
307
+ if (this.index === -1 && delta < 0) {
308
+ // Leaving the live draft: remember it so recall(+1) can come back.
309
+ this.draft = draft;
310
+ this.index = this.entries.length - 1;
311
+ return this.entries[this.index];
312
+ }
313
+ if (this.index === -1)
314
+ return undefined;
315
+ const next = this.index + delta;
316
+ if (next < 0)
317
+ return undefined;
318
+ if (next >= this.entries.length) {
319
+ const draft = this.draft;
320
+ this.reset();
321
+ return draft;
322
+ }
323
+ this.index = next;
324
+ return this.entries[next];
325
+ }
326
+ /** Whether a recall is in flight, i.e. ↑/↓ should keep walking history. */
327
+ isRecalling() {
328
+ return this.index !== -1;
329
+ }
330
+ /** Return to live typing; called whenever the composer is edited or sent. */
331
+ reset() {
332
+ this.index = -1;
333
+ this.draft = '';
334
+ }
335
+ }
336
+ /** The popup that filters slash commands as they are typed. */
337
+ export class Palette {
338
+ open = false;
339
+ matches = [];
340
+ selected = 0;
341
+ /**
342
+ * Recompute from the composer text. The palette lives only while the input
343
+ * is a single unfinished `/word`; once a space is typed the user has moved
344
+ * on to the command's own arguments.
345
+ */
346
+ update(input, commands) {
347
+ if (!input.startsWith('/') || /[\s\n]/.test(input)) {
348
+ this.close();
349
+ return;
350
+ }
351
+ const prefix = input.slice(1).toLowerCase();
352
+ this.matches = commands.filter((command) => command.name.startsWith(prefix));
353
+ this.open = this.matches.length > 0;
354
+ if (this.selected >= this.matches.length)
355
+ this.selected = this.matches.length - 1;
356
+ if (this.selected < 0)
357
+ this.selected = 0;
358
+ }
359
+ move(delta) {
360
+ if (!this.open || this.matches.length === 0)
361
+ return;
362
+ this.selected = (this.selected + delta + this.matches.length) % this.matches.length;
363
+ }
364
+ current() {
365
+ if (!this.open)
366
+ return undefined;
367
+ return this.matches[this.selected];
368
+ }
369
+ close() {
370
+ this.open = false;
371
+ this.matches = [];
372
+ this.selected = 0;
373
+ }
374
+ }
375
+ /**
376
+ * Subsequence match, the same shape of filter an editor's command palette
377
+ * uses: every character of the query appears in order, not necessarily
378
+ * adjacent, so "g53" finds "glm-5.3".
379
+ */
380
+ export function fuzzyMatch(query, text) {
381
+ if (query === '')
382
+ return true;
383
+ let index = 0;
384
+ for (const char of text) {
385
+ if (char === query[index]) {
386
+ index += 1;
387
+ if (index === query.length)
388
+ return true;
389
+ }
390
+ }
391
+ return false;
392
+ }
393
+ /**
394
+ * The full-pane list that replaces the transcript for `/resume` and `/model`.
395
+ *
396
+ * It filters as you type and, when grouped, prints a header each time the
397
+ * subtitle changes — so a model list reads provider by provider rather than as
398
+ * one undifferentiated column.
399
+ */
400
+ export class Picker {
401
+ kind = 'none';
402
+ title = '';
403
+ items = [];
404
+ selected = 0;
405
+ query = '';
406
+ /** Whether rows are grouped under their subtitle. */
407
+ grouped = false;
408
+ show(kind, title, items, options = {}) {
409
+ this.kind = kind;
410
+ this.title = title;
411
+ this.items = items;
412
+ this.selected = 0;
413
+ this.query = '';
414
+ this.grouped = options.grouped === true;
415
+ }
416
+ hide() {
417
+ this.kind = 'none';
418
+ this.items = [];
419
+ this.selected = 0;
420
+ this.query = '';
421
+ this.grouped = false;
422
+ }
423
+ /** Rows surviving the current query, in their original order. */
424
+ matches() {
425
+ const query = this.query.trim().toLowerCase();
426
+ if (query === '')
427
+ return this.items;
428
+ return this.items.filter((item) => fuzzyMatch(query, `${item.subtitle} ${item.title} ${item.id}`.toLowerCase()));
429
+ }
430
+ /** Narrow or widen the filter, keeping the selection in range. */
431
+ setQuery(query) {
432
+ this.query = query;
433
+ this.selected = 0;
434
+ }
435
+ move(delta) {
436
+ const count = this.matches().length;
437
+ if (count === 0)
438
+ return;
439
+ this.selected = Math.min(Math.max(this.selected + delta, 0), count - 1);
440
+ }
441
+ /** Put the cursor on a given row of the unfiltered list, if it survives. */
442
+ selectById(id) {
443
+ const index = this.matches().findIndex((item) => item.id === id);
444
+ if (index !== -1)
445
+ this.selected = index;
446
+ }
447
+ current() {
448
+ return this.matches()[this.selected];
449
+ }
450
+ }
451
+ /**
452
+ * Which open session a delegated agent belongs to, as an index.
453
+ *
454
+ * The Harness does not report a delegation parent, so this is a rule rather
455
+ * than a lookup, and it is worth stating plainly because the alternative --
456
+ * one list shared by every session -- is what made another conversation's
457
+ * subagents appear in whichever tab was on screen.
458
+ *
459
+ * Fork lineage wins when it names a session that is actually open. Otherwise
460
+ * timing decides: delegated work is spawned while its parent's turn runs, so
461
+ * the streaming session claims it. With neither, the active session is the
462
+ * only honest guess.
463
+ */
464
+ export function ownerOfDelegated(sessions, parentSessionId, activeIndex) {
465
+ if (sessions.length === 0)
466
+ return -1;
467
+ if (parentSessionId !== undefined) {
468
+ const byLineage = sessions.findIndex((session) => session.id === parentSessionId);
469
+ if (byLineage !== -1)
470
+ return byLineage;
471
+ }
472
+ const streaming = sessions.findIndex((session) => session.streaming);
473
+ if (streaming !== -1)
474
+ return streaming;
475
+ return activeIndex >= 0 && activeIndex < sessions.length ? activeIndex : 0;
476
+ }
477
+ /**
478
+ * Walk a transcript selection.
479
+ *
480
+ * There is no selection until the first key: `alt+↑`/`alt+↓` then start at the
481
+ * newest turn and move, clamped at both ends and empty when there is nothing
482
+ * to select.
483
+ */
484
+ export function moveSelection(current, delta, count) {
485
+ if (count <= 0)
486
+ return undefined;
487
+ if (current === undefined)
488
+ return count - 1;
489
+ return Math.min(Math.max(current + delta, 0), count - 1);
490
+ }
491
+ /** Format a token count the way a status bar wants it: 834, 1.2K, 64K. */
492
+ export function formatTokens(count) {
493
+ if (count < 1000)
494
+ return String(count);
495
+ if (count < 10000)
496
+ return `${(count / 1000).toFixed(1)}K`;
497
+ return `${Math.floor(count / 1000)}K`;
498
+ }
499
+ /** A deliberately rough chars/4 estimate, used until real usage is reported. */
500
+ export function estimateTokens(text) {
501
+ return Math.ceil(displayWidth(text) / 4);
502
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Pure projection of a Harness assistant-stream chunk onto the transcript.
3
+ *
4
+ * `TuiApp.onFrame` used to hold this switch inline, which made the one path
5
+ * that turns a live model reply into visible text untestable without a full
6
+ * Harness runtime. This module keeps the identical logic but depends on
7
+ * nothing: it imports no Harness types and no npm packages, so a synthetic
8
+ * chunk sequence can be replayed in a dependency-free test (see
9
+ * `tests/stream-smoke.ts`) the same way `render-smoke.ts` exercises `view.ts`.
10
+ *
11
+ * @module moqi-tui/tui/stream
12
+ */
13
+ import { appendText, findTool } from "./state.js";
14
+ import { describeToolCall } from "./tooldetail.js";
15
+ /**
16
+ * Argument text accumulated per live row, keyed weakly so committed rows
17
+ * carry no buffer of their own. A settled block's complete `arguments`
18
+ * replace whatever streamed in.
19
+ */
20
+ const argumentBuffers = new WeakMap();
21
+ /**
22
+ * Apply one stream chunk to a streaming surface, mutating it in place.
23
+ *
24
+ * `text-delta` continues the turn's trailing run of prose, a `tool-call-delta`
25
+ * adds or names a running tool row keyed by call id, `usage` lands the token
26
+ * counters, and `block-end` settles the matching row to `ok`. Unknown chunk
27
+ * kinds are ignored on purpose — the chunk union is merge-extensible and a
28
+ * plugin may emit one this app has never heard of.
29
+ *
30
+ * Order is the point: text after a call opens a new segment rather than
31
+ * extending the text before it, so "checking…", the call, and "found it" stay
32
+ * three things in sequence instead of one paragraph and a detached list.
33
+ */
34
+ export function projectStreamChunk(surface, chunk) {
35
+ switch (chunk.type) {
36
+ case 'text-delta':
37
+ appendText(surface.streamingSegments, chunk.text ?? '');
38
+ break;
39
+ case 'reasoning-delta':
40
+ surface.streamingReasoning += chunk.text ?? '';
41
+ break;
42
+ case 'tool-call-delta': {
43
+ // The name arrives on the first delta of a call and is omitted on the
44
+ // argument deltas that follow, so the call id is what identifies a row.
45
+ // The argument deltas accumulate into the row's detail as they arrive —
46
+ // partial JSON still reads as the command typing itself out.
47
+ const id = String(chunk.id);
48
+ let row = findTool(surface.streamingSegments, (tool) => tool.id === id);
49
+ if (row === undefined) {
50
+ row = { id, name: chunk.name ?? 'tool', status: 'running' };
51
+ surface.streamingSegments.push({ kind: 'tool', tool: row });
52
+ }
53
+ if (chunk.name !== undefined && row.name === 'tool')
54
+ row.name = chunk.name;
55
+ if (chunk.argumentsDelta !== undefined && chunk.argumentsDelta !== '') {
56
+ argumentBuffers.set(row, (argumentBuffers.get(row) ?? '') + chunk.argumentsDelta);
57
+ const detail = describeToolCall(row.name, argumentBuffers.get(row));
58
+ if (detail !== '')
59
+ row.detail = detail;
60
+ }
61
+ break;
62
+ }
63
+ case 'usage': {
64
+ const usage = chunk.usage;
65
+ if (usage === undefined)
66
+ break;
67
+ surface.promptTokens = usage.inputTokens;
68
+ surface.completionTokens = usage.outputTokens;
69
+ surface.totalTokens = usage.totalTokens ?? usage.inputTokens + usage.outputTokens;
70
+ surface.cacheReadTokens = usage.cacheReadTokens ?? 0;
71
+ surface.cacheWriteTokens = usage.cacheWriteTokens ?? 0;
72
+ surface.haveUsage = true;
73
+ break;
74
+ }
75
+ case 'block-end': {
76
+ // A settled tool-call block flips its row from running to done, fills
77
+ // in the name the deltas may have omitted, and says what the call does:
78
+ // the block carries the complete raw `arguments`, which summarize into
79
+ // the row's one-line detail (the command, the path, the query).
80
+ const block = chunk.block;
81
+ if (block === undefined || block.type !== 'tool-call')
82
+ break;
83
+ const row = findTool(surface.streamingSegments, (tool) => tool.id === String(block.id)) ??
84
+ findTool(surface.streamingSegments, (tool) => tool.name === block.name);
85
+ if (row === undefined) {
86
+ surface.streamingSegments.push({
87
+ kind: 'tool',
88
+ tool: {
89
+ id: String(block.id),
90
+ name: block.name ?? 'tool',
91
+ status: 'ok',
92
+ detail: describeToolCall(block.name ?? 'tool', block.arguments),
93
+ },
94
+ });
95
+ }
96
+ else {
97
+ row.name = block.name ?? row.name;
98
+ row.status = 'ok';
99
+ argumentBuffers.delete(row);
100
+ const detail = describeToolCall(row.name, block.arguments);
101
+ if (detail !== '')
102
+ row.detail = detail;
103
+ }
104
+ break;
105
+ }
106
+ default:
107
+ break;
108
+ }
109
+ }