@ai-sdk/tui 0.0.0 → 1.0.0-beta.13

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.
@@ -0,0 +1,1411 @@
1
+ import type {
2
+ AgentTUIStreamResult,
3
+ AgentTUIToolApprovalRequest,
4
+ AgentTUIToolApprovalResponse,
5
+ } from '../agent-tui-runner';
6
+ import type {
7
+ ResponseStatisticsMode,
8
+ TerminalPartDisplayMode,
9
+ } from '../run-agent-tui';
10
+ import { renderScreenViewport, sliceVisible, visibleLength } from './layout';
11
+ import { renderMarkdown } from './markdown';
12
+ import { TerminalFrameBuffer } from './terminal-frame-buffer';
13
+ import {
14
+ getToolName,
15
+ isToolUIPart,
16
+ readUIMessageStream,
17
+ type StepResultPerformance,
18
+ type DynamicToolUIPart,
19
+ type ToolUIPart,
20
+ type UIMessage,
21
+ type UIMessageChunk,
22
+ } from 'ai';
23
+
24
+ export type TerminalInput = {
25
+ isTTY?: boolean;
26
+ on(event: 'data', listener: (chunk: Buffer) => void): TerminalInput;
27
+ off(event: 'data', listener: (chunk: Buffer) => void): TerminalInput;
28
+ resume(): TerminalInput;
29
+ pause(): TerminalInput;
30
+ setRawMode?: (mode: boolean) => TerminalInput;
31
+ };
32
+
33
+ export type TerminalOutput = {
34
+ columns?: number;
35
+ rows?: number;
36
+ write(
37
+ chunk: string | Uint8Array,
38
+ encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void),
39
+ callback?: (error?: Error | null) => void,
40
+ ): boolean;
41
+ on(event: 'resize', listener: () => void): TerminalOutput;
42
+ off(event: 'resize', listener: () => void): TerminalOutput;
43
+ };
44
+
45
+ const defaultResponseStatistics: ResponseStatisticsMode =
46
+ 'outputTokensPerSecond';
47
+
48
+ export type TerminalRendererOptions = {
49
+ input?: TerminalInput;
50
+ output?: TerminalOutput;
51
+ frameBuffer?: TerminalFrameBuffer;
52
+ tools?: TerminalPartDisplayMode;
53
+ reasoning?: TerminalPartDisplayMode;
54
+ responseStatistics?: ResponseStatisticsMode;
55
+ contextSize?: number;
56
+ };
57
+
58
+ export type TerminalSessionOptions = {
59
+ title?: string;
60
+ initialPrompt?: string;
61
+ submittedPrompt?: string;
62
+ waitForExit?: boolean;
63
+ continueSession?: boolean;
64
+ tools?: TerminalPartDisplayMode;
65
+ reasoning?: TerminalPartDisplayMode;
66
+ responseStatistics?: ResponseStatisticsMode;
67
+ contextSize?: number;
68
+ };
69
+
70
+ export type TerminalKey =
71
+ | { type: 'character'; value: string }
72
+ | { type: 'backspace' }
73
+ | { type: 'enter' }
74
+ | { type: 'up' }
75
+ | { type: 'down' }
76
+ | { type: 'page-up' }
77
+ | { type: 'page-down' }
78
+ | { type: 'ctrl-l' }
79
+ | { type: 'ctrl-c' }
80
+ | { type: 'escape' }
81
+ | { type: 'ignore' };
82
+
83
+ type ChatSectionKind = 'user' | 'assistant' | 'reasoning' | 'tool' | 'error';
84
+
85
+ type ChatSection = {
86
+ kind: ChatSectionKind;
87
+ title: string;
88
+ rightTitle?: string;
89
+ content: string;
90
+ collapsed?: boolean;
91
+ id?: string;
92
+ cache?: RenderedSectionCache;
93
+ };
94
+
95
+ type RenderedSectionCache = {
96
+ width: number;
97
+ kind: ChatSectionKind;
98
+ title: string;
99
+ rightTitle?: string;
100
+ content: string;
101
+ collapsed: boolean;
102
+ lines: string[];
103
+ };
104
+
105
+ type StreamUsage = {
106
+ totalTokens?: number | { total?: number };
107
+ inputTokens?: number | { total?: number };
108
+ promptTokens?: number;
109
+ outputTokens?: number | { total?: number };
110
+ completionTokens?: number;
111
+ };
112
+
113
+ type MessageMetadataWithStats = {
114
+ usage?: StreamUsage;
115
+ performance?: Pick<StepResultPerformance, 'outputTokensPerSecond'>;
116
+ };
117
+
118
+ const colors = {
119
+ reset: '\x1b[0m',
120
+ dim: '\x1b[2m',
121
+ user: '\x1b[96m',
122
+ assistant: '\x1b[92m',
123
+ reasoning: '\x1b[94m',
124
+ tool: '\x1b[95m',
125
+ error: '\x1b[91m',
126
+ };
127
+
128
+ const sectionStyles: Record<
129
+ ChatSectionKind,
130
+ { color: string; border: string }
131
+ > = {
132
+ user: { color: colors.user, border: '─' },
133
+ assistant: { color: colors.assistant, border: '─' },
134
+ reasoning: { color: colors.reasoning, border: '·' },
135
+ tool: { color: colors.tool, border: '─' },
136
+ error: { color: colors.error, border: '─' },
137
+ };
138
+
139
+ const inputCursorBlinkMs = 500;
140
+ const activeControls = '↑/↓ · PgUp/PgDn · Esc/Ctrl+C';
141
+ const doneControls = '↑/↓ · PgUp/PgDn · q/Esc/Ctrl+C';
142
+ const processingStatus = `Processing input... ${activeControls}`;
143
+ const processingToolResultsStatus = `Processing tool results... ${activeControls}`;
144
+ const streamingStatus = `Streaming... ${activeControls}`;
145
+ const executingToolsStatus = `Executing tools... ${activeControls}`;
146
+
147
+ export class TerminalRenderer {
148
+ readonly #input: TerminalInput;
149
+ readonly #output: TerminalOutput;
150
+ readonly #frameBuffer: TerminalFrameBuffer;
151
+ readonly #tools: TerminalPartDisplayMode;
152
+ readonly #reasoning: TerminalPartDisplayMode;
153
+ readonly #responseStatistics: ResponseStatisticsMode;
154
+ readonly #defaultContextSize?: number;
155
+
156
+ #sections: ChatSection[] = [];
157
+ #inputText = '';
158
+ #inputActive = false;
159
+ #scrollOffset = 0;
160
+ #title = '';
161
+ #status = streamingStatus;
162
+ #isInteractive = false;
163
+ #interrupted = false;
164
+ #totalTokens?: number;
165
+ #contextSize?: number;
166
+ #assistantOutputTokens?: number;
167
+ #assistantOutputTokensPerSecond?: number;
168
+ #inputCursorVisible = true;
169
+ #inputCursorTimer?: ReturnType<typeof setInterval>;
170
+ #onData?: (chunk: Buffer) => void;
171
+ #onResize?: () => void;
172
+ #resolveStreamInterrupt?: () => void;
173
+
174
+ constructor(options?: TerminalRendererOptions) {
175
+ this.#input = options?.input ?? process.stdin;
176
+ this.#output = options?.output ?? process.stdout;
177
+ this.#frameBuffer =
178
+ options?.frameBuffer ?? new TerminalFrameBuffer(this.#output);
179
+ this.#tools = options?.tools ?? 'auto-collapsed';
180
+ this.#reasoning = options?.reasoning ?? 'auto-collapsed';
181
+ this.#responseStatistics =
182
+ options?.responseStatistics ?? defaultResponseStatistics;
183
+ this.#defaultContextSize = options?.contextSize;
184
+ this.#contextSize = options?.contextSize;
185
+ }
186
+
187
+ async readPrompt(options?: TerminalSessionOptions): Promise<string> {
188
+ this.#start(options);
189
+ this.#inputActive = true;
190
+ this.#inputText = options?.initialPrompt ?? '';
191
+ this.#status = `Type a prompt and press Enter · ${activeControls}`;
192
+ this.#startInputCursorBlink();
193
+ this.#paint();
194
+
195
+ return await new Promise((resolve, reject) => {
196
+ this.#onData = chunk => {
197
+ const key = parseKey(chunk);
198
+
199
+ switch (key.type) {
200
+ case 'character':
201
+ this.#inputText += key.value;
202
+ this.#showInputCursor();
203
+ this.#paint();
204
+ break;
205
+ case 'backspace':
206
+ this.#inputText = this.#inputText.slice(0, -1);
207
+ this.#showInputCursor();
208
+ this.#paint();
209
+ break;
210
+ case 'enter': {
211
+ const prompt = this.#inputText;
212
+ this.#inputActive = false;
213
+ this.#stopInputCursorBlink();
214
+ this.#status = processingStatus;
215
+ this.#addUserSection(prompt);
216
+ this.#inputText = '';
217
+ this.#paint();
218
+ this.#detachInput();
219
+ resolve(prompt);
220
+ break;
221
+ }
222
+ case 'up':
223
+ case 'down':
224
+ this.#handleScroll(key.type);
225
+ break;
226
+ case 'page-up':
227
+ case 'page-down':
228
+ this.#handlePageScroll(key.type);
229
+ break;
230
+ case 'ctrl-l':
231
+ this.#repaint();
232
+ break;
233
+ case 'escape':
234
+ case 'ctrl-c':
235
+ this.#stopInputCursorBlink();
236
+ this.#stop();
237
+ reject(interruptedError());
238
+ break;
239
+ case 'ignore':
240
+ break;
241
+ }
242
+ };
243
+
244
+ this.#attachInput();
245
+ });
246
+ }
247
+
248
+ async renderStream(
249
+ result: AgentTUIStreamResult,
250
+ options?: TerminalSessionOptions,
251
+ ): Promise<UIMessage | undefined> {
252
+ this.#start(options);
253
+ this.#inputActive = false;
254
+ this.#status = processingStatus;
255
+ this.#addSubmittedPrompt(options?.submittedPrompt);
256
+ this.#interrupted = false;
257
+ this.#totalTokens = undefined;
258
+ this.#assistantOutputTokens = undefined;
259
+ this.#assistantOutputTokensPerSecond = undefined;
260
+ const displayModes = {
261
+ tools: options?.tools ?? this.#tools,
262
+ reasoning: options?.reasoning ?? this.#reasoning,
263
+ responseStatistics:
264
+ options?.responseStatistics ?? this.#responseStatistics,
265
+ };
266
+ this.#paint();
267
+ const streamInterrupted = new Promise<void>(resolve => {
268
+ this.#resolveStreamInterrupt = resolve;
269
+ });
270
+ this.#onData = chunk => this.#handleStreamingKey(chunk);
271
+ this.#attachInput();
272
+ let responseMessage: UIMessage | undefined;
273
+ const stream = toReadableStream(
274
+ this.#observeUIMessageStream(result.uiMessageStream),
275
+ );
276
+
277
+ try {
278
+ const messages = readUIMessageStream({
279
+ message: result.message,
280
+ stream,
281
+ onError: error =>
282
+ this.#addErrorSection('Error', formatStreamError(error)),
283
+ });
284
+
285
+ for await (const message of takeUntil(messages, streamInterrupted)) {
286
+ if (this.#interrupted) {
287
+ break;
288
+ }
289
+
290
+ responseMessage = message;
291
+ this.#renderAssistantMessage(message, displayModes);
292
+ }
293
+
294
+ if (
295
+ !this.#interrupted &&
296
+ responseMessage &&
297
+ this.#assistantOutputTokens != null
298
+ ) {
299
+ this.#renderAssistantMessage(responseMessage, displayModes);
300
+ }
301
+ } finally {
302
+ this.#resolveStreamInterrupt = undefined;
303
+ if (this.#interrupted) {
304
+ result.abort?.();
305
+ }
306
+ this.#detachInput();
307
+ this.#status = this.#interrupted
308
+ ? 'Interrupted'
309
+ : options?.continueSession
310
+ ? `Done · Enter another prompt · ${activeControls}`
311
+ : `Done · ${doneControls}`;
312
+ this.#paint();
313
+ await this.#waitForExit(options);
314
+
315
+ if (this.#interrupted || !options?.continueSession) {
316
+ this.#stop();
317
+ }
318
+ }
319
+
320
+ if (this.#interrupted) {
321
+ throw interruptedError();
322
+ }
323
+
324
+ return responseMessage;
325
+ }
326
+
327
+ async readToolApproval(
328
+ request: AgentTUIToolApprovalRequest,
329
+ options?: TerminalSessionOptions,
330
+ ): Promise<AgentTUIToolApprovalResponse> {
331
+ this.#start(options);
332
+ this.#inputActive = false;
333
+ this.#status = `Approve ${formatToolApprovalTitle(request)}? y/n · ${activeControls}`;
334
+ this.#interrupted = false;
335
+ this.#paint();
336
+
337
+ return await new Promise((resolve, reject) => {
338
+ this.#onData = chunk => {
339
+ const key = parseKey(chunk);
340
+
341
+ switch (key.type) {
342
+ case 'character': {
343
+ const value = key.value.toLowerCase();
344
+
345
+ if (value === 'y') {
346
+ this.#status = `Approved · ${processingStatus}`;
347
+ this.#detachInput();
348
+ this.#paint();
349
+ resolve({ approved: true });
350
+ } else if (value === 'n') {
351
+ this.#status = `Denied · ${processingStatus}`;
352
+ this.#detachInput();
353
+ this.#paint();
354
+ resolve({ approved: false, reason: 'Denied by user.' });
355
+ }
356
+ break;
357
+ }
358
+ case 'up':
359
+ case 'down':
360
+ this.#handleScroll(key.type);
361
+ break;
362
+ case 'page-up':
363
+ case 'page-down':
364
+ this.#handlePageScroll(key.type);
365
+ break;
366
+ case 'ctrl-l':
367
+ this.#repaint();
368
+ break;
369
+ case 'escape':
370
+ case 'ctrl-c':
371
+ this.#interrupted = true;
372
+ this.#stop();
373
+ reject(interruptedError());
374
+ break;
375
+ default:
376
+ break;
377
+ }
378
+ };
379
+
380
+ this.#attachInput();
381
+ });
382
+ }
383
+
384
+ #start(options?: TerminalSessionOptions) {
385
+ this.#title = options?.title ?? this.#title;
386
+ this.#contextSize = options?.contextSize ?? this.#defaultContextSize;
387
+
388
+ if (this.#isInteractive) {
389
+ return;
390
+ }
391
+
392
+ this.#isInteractive = true;
393
+ this.#frameBuffer.reset();
394
+ this.#output.write('\x1b[?1049h\x1b[?25l');
395
+
396
+ if (this.#input.isTTY) {
397
+ this.#input.setRawMode?.(true);
398
+ this.#input.resume();
399
+ }
400
+
401
+ this.#onResize = () => this.#repaint();
402
+ this.#output.on('resize', this.#onResize);
403
+ }
404
+
405
+ #stop() {
406
+ this.#detachInput();
407
+ this.#stopInputCursorBlink();
408
+
409
+ if (!this.#isInteractive) {
410
+ return;
411
+ }
412
+
413
+ if (this.#input.isTTY) {
414
+ this.#input.setRawMode?.(false);
415
+ this.#input.pause();
416
+ }
417
+
418
+ if (this.#onResize) {
419
+ this.#output.off('resize', this.#onResize);
420
+ this.#onResize = undefined;
421
+ }
422
+
423
+ this.#output.write('\x1b[?25h\x1b[?1049l');
424
+ this.#frameBuffer.reset();
425
+ this.#isInteractive = false;
426
+ }
427
+
428
+ #attachInput() {
429
+ if (this.#onData) {
430
+ this.#input.on('data', this.#onData);
431
+ }
432
+ }
433
+
434
+ #detachInput() {
435
+ if (this.#onData) {
436
+ this.#input.off('data', this.#onData);
437
+ this.#onData = undefined;
438
+ }
439
+ }
440
+
441
+ #handleStreamingKey(chunk: Buffer) {
442
+ const key = parseKey(chunk);
443
+
444
+ switch (key.type) {
445
+ case 'up':
446
+ case 'down':
447
+ this.#handleScroll(key.type);
448
+ break;
449
+ case 'page-up':
450
+ case 'page-down':
451
+ this.#handlePageScroll(key.type);
452
+ break;
453
+ case 'ctrl-l':
454
+ this.#repaint();
455
+ break;
456
+ case 'escape':
457
+ case 'ctrl-c':
458
+ this.#interrupted = true;
459
+ this.#resolveStreamInterrupt?.();
460
+ break;
461
+ default:
462
+ break;
463
+ }
464
+ }
465
+
466
+ #handleScroll(direction: 'up' | 'down', lines = 1) {
467
+ const delta = direction === 'up' ? lines : -lines;
468
+ this.#scrollOffset = this.#clampScrollOffset(this.#scrollOffset + delta);
469
+ this.#paint();
470
+ }
471
+
472
+ #handlePageScroll(direction: 'page-up' | 'page-down') {
473
+ this.#handleScroll(
474
+ direction === 'page-up' ? 'up' : 'down',
475
+ this.#bodyContentHeight(),
476
+ );
477
+ }
478
+
479
+ #startInputCursorBlink() {
480
+ this.#stopInputCursorBlink();
481
+ this.#showInputCursor();
482
+ this.#inputCursorTimer = setInterval(() => {
483
+ this.#inputCursorVisible = !this.#inputCursorVisible;
484
+ this.#paint();
485
+ }, inputCursorBlinkMs);
486
+ this.#inputCursorTimer.unref?.();
487
+ }
488
+
489
+ #stopInputCursorBlink() {
490
+ if (this.#inputCursorTimer) {
491
+ clearInterval(this.#inputCursorTimer);
492
+ this.#inputCursorTimer = undefined;
493
+ }
494
+
495
+ this.#inputCursorVisible = true;
496
+ }
497
+
498
+ #showInputCursor() {
499
+ this.#inputCursorVisible = true;
500
+ }
501
+
502
+ #addSubmittedPrompt(prompt: string | undefined) {
503
+ if (prompt == null) {
504
+ return;
505
+ }
506
+
507
+ const section = this.#sections.at(-1);
508
+
509
+ if (section?.kind === 'user' && section.content === prompt) {
510
+ return;
511
+ }
512
+
513
+ this.#addUserSection(prompt);
514
+ }
515
+
516
+ #addUserSection(prompt: string) {
517
+ const previousBodyLineCount = this.#bodyLineCount();
518
+ this.#sections.push({ kind: 'user', title: 'User', content: prompt });
519
+ this.#paintAfterBodyChange(previousBodyLineCount);
520
+ }
521
+
522
+ #renderAssistantMessage(
523
+ message: UIMessage,
524
+ displayModes: {
525
+ tools: TerminalPartDisplayMode;
526
+ reasoning: TerminalPartDisplayMode;
527
+ responseStatistics: ResponseStatisticsMode;
528
+ },
529
+ ) {
530
+ const previousBodyLineCount = this.#bodyLineCount();
531
+ const activeSectionIds = new Set<string>();
532
+ const metadataStats = extractResponseStatisticsFromMetadata(
533
+ message.metadata,
534
+ );
535
+ this.#totalTokens = metadataStats.totalTokens ?? this.#totalTokens;
536
+ this.#assistantOutputTokens =
537
+ metadataStats.outputTokens ?? this.#assistantOutputTokens;
538
+ this.#assistantOutputTokensPerSecond =
539
+ metadataStats.outputTokensPerSecond ??
540
+ this.#assistantOutputTokensPerSecond;
541
+
542
+ for (const [index, part] of message.parts.entries()) {
543
+ const id = sectionId(message.id, index);
544
+
545
+ switch (part.type) {
546
+ case 'text': {
547
+ const content = part.text.trim();
548
+
549
+ if (content.length === 0) {
550
+ break;
551
+ }
552
+
553
+ activeSectionIds.add(id);
554
+ this.#upsertSection({
555
+ id,
556
+ kind: 'assistant',
557
+ title: 'Assistant',
558
+ rightTitle: formatResponseStatistics(
559
+ {
560
+ totalTokens: this.#totalTokens,
561
+ outputTokens: this.#assistantOutputTokens,
562
+ outputTokensPerSecond: this.#assistantOutputTokensPerSecond,
563
+ },
564
+ displayModes.responseStatistics,
565
+ ),
566
+ content,
567
+ });
568
+ break;
569
+ }
570
+ case 'reasoning': {
571
+ const content = part.text.trim();
572
+
573
+ if (displayModes.reasoning === 'hidden' || content.length === 0) {
574
+ break;
575
+ }
576
+
577
+ activeSectionIds.add(id);
578
+ this.#upsertSection({
579
+ id,
580
+ kind: 'reasoning',
581
+ title: 'Reasoning',
582
+ content,
583
+ collapsed: shouldCollapsePart(
584
+ message,
585
+ index,
586
+ displayModes.reasoning,
587
+ displayModes,
588
+ ),
589
+ });
590
+ break;
591
+ }
592
+ default:
593
+ if (isToolUIPart(part)) {
594
+ if (displayModes.tools === 'hidden') {
595
+ break;
596
+ }
597
+
598
+ activeSectionIds.add(id);
599
+ this.#upsertSection({
600
+ id,
601
+ ...renderToolInvocation(part, {
602
+ mode: displayModes.tools,
603
+ collapsed: shouldCollapsePart(
604
+ message,
605
+ index,
606
+ displayModes.tools,
607
+ displayModes,
608
+ ),
609
+ }),
610
+ });
611
+ }
612
+ break;
613
+ }
614
+ }
615
+
616
+ this.#removeStaleAssistantSections(message.id, activeSectionIds);
617
+ this.#paintAfterBodyChange(previousBodyLineCount);
618
+ }
619
+
620
+ #upsertSection(section: ChatSection) {
621
+ const existingSection = section.id
622
+ ? this.#sections.find(candidate => candidate.id === section.id)
623
+ : undefined;
624
+
625
+ if (existingSection) {
626
+ const cache = existingSection.cache;
627
+ existingSection.kind = section.kind;
628
+ existingSection.title = section.title;
629
+ existingSection.rightTitle = section.rightTitle;
630
+ existingSection.content = section.content;
631
+ existingSection.collapsed = section.collapsed;
632
+ existingSection.cache =
633
+ cache && sectionMatchesCache(section, cache) ? cache : undefined;
634
+ return;
635
+ }
636
+
637
+ this.#sections.push(section);
638
+ }
639
+
640
+ #removeStaleAssistantSections(
641
+ messageId: string,
642
+ activeSectionIds: Set<string>,
643
+ ) {
644
+ const prefix = `${messageId}:`;
645
+ this.#sections = this.#sections.filter(
646
+ section =>
647
+ section.id == null ||
648
+ !section.id.startsWith(prefix) ||
649
+ activeSectionIds.has(section.id),
650
+ );
651
+ }
652
+
653
+ async *#observeUIMessageStream(
654
+ stream: AsyncIterable<UIMessageChunk> | ReadableStream<UIMessageChunk>,
655
+ ): AsyncIterable<UIMessageChunk> {
656
+ let hasPendingToolResults = false;
657
+
658
+ for await (const chunk of iterateUIMessageStream(stream)) {
659
+ const nextStatus = statusForStreamChunk(chunk, { hasPendingToolResults });
660
+
661
+ if (chunk.type === 'start-step') {
662
+ hasPendingToolResults = false;
663
+ } else if (finishesToolExecution(chunk)) {
664
+ hasPendingToolResults = true;
665
+ }
666
+
667
+ if (nextStatus && this.#status !== nextStatus) {
668
+ this.#status = nextStatus;
669
+ this.#paint();
670
+ } else if (
671
+ startsVisibleAssistantStream(chunk) &&
672
+ this.#status !== streamingStatus
673
+ ) {
674
+ this.#status = streamingStatus;
675
+ this.#paint();
676
+ }
677
+
678
+ if (chunk.type === 'error') {
679
+ this.#addErrorSection('Error', chunk.errorText);
680
+ }
681
+
682
+ if (chunk.type === 'finish') {
683
+ const stats = extractResponseStatistics(chunk);
684
+ this.#totalTokens = stats.totalTokens;
685
+ this.#assistantOutputTokens = stats.outputTokens;
686
+ this.#assistantOutputTokensPerSecond = stats.outputTokensPerSecond;
687
+ }
688
+
689
+ yield chunk;
690
+ }
691
+ }
692
+
693
+ #addErrorSection(title: string, content: string) {
694
+ const previousBodyLineCount = this.#bodyLineCount();
695
+ this.#sections.push({ kind: 'error', title, content });
696
+ this.#paintAfterBodyChange(previousBodyLineCount);
697
+ }
698
+
699
+ #paintAfterBodyChange(previousBodyLineCount: number) {
700
+ if (this.#scrollOffset !== 0) {
701
+ const bodyLineDelta = this.#bodyLineCount() - previousBodyLineCount;
702
+ this.#scrollOffset = this.#clampScrollOffset(
703
+ this.#scrollOffset + bodyLineDelta,
704
+ );
705
+ }
706
+
707
+ this.#paint();
708
+ }
709
+
710
+ #paint() {
711
+ const frame = renderScreenViewport({
712
+ width: this.#width(),
713
+ height: this.#height(),
714
+ title: this.#title,
715
+ rightTitle: formatTokenCount(this.#totalTokens, this.#contextSize),
716
+ visibleBodyLines: this.#visibleBodyLines(),
717
+ input: this.#inputText,
718
+ inputActive: this.#inputActive,
719
+ inputCursorVisible: this.#inputCursorVisible,
720
+ status: this.#status,
721
+ });
722
+
723
+ this.#frameBuffer.present(frame);
724
+ }
725
+
726
+ #repaint() {
727
+ this.#frameBuffer.reset();
728
+ this.#paint();
729
+ }
730
+
731
+ #visibleBodyLines() {
732
+ if (this.#sections.length === 0) {
733
+ return ['Waiting for input...'];
734
+ }
735
+
736
+ const bodyContentHeight = this.#bodyContentHeight();
737
+ const totalLineCount = this.#bodyLineCount();
738
+ const start = Math.max(
739
+ 0,
740
+ totalLineCount - bodyContentHeight - this.#scrollOffset,
741
+ );
742
+ const end = start + bodyContentHeight;
743
+ const visibleLines: string[] = [];
744
+ let sectionStart = 0;
745
+
746
+ for (const section of this.#sections) {
747
+ const sectionLines = renderSectionLines(section, this.#width() - 4);
748
+ const sectionEnd = sectionStart + sectionLines.length;
749
+
750
+ if (sectionEnd > start && sectionStart < end) {
751
+ visibleLines.push(
752
+ ...sectionLines.slice(
753
+ Math.max(0, start - sectionStart),
754
+ end - sectionStart,
755
+ ),
756
+ );
757
+ }
758
+
759
+ if (visibleLines.length >= bodyContentHeight) {
760
+ break;
761
+ }
762
+
763
+ sectionStart = sectionEnd;
764
+ }
765
+
766
+ return visibleLines;
767
+ }
768
+
769
+ #clampScrollOffset(scrollOffset: number) {
770
+ const maxScrollOffset = Math.max(
771
+ 0,
772
+ this.#bodyLineCount() - this.#bodyContentHeight(),
773
+ );
774
+
775
+ return Math.min(Math.max(0, scrollOffset), maxScrollOffset);
776
+ }
777
+
778
+ #bodyLineCount() {
779
+ if (this.#sections.length === 0) {
780
+ return 1;
781
+ }
782
+
783
+ let count = 0;
784
+ for (const section of this.#sections) {
785
+ count += renderSectionLines(section, this.#width() - 4).length;
786
+ }
787
+
788
+ return count;
789
+ }
790
+
791
+ #bodyContentHeight() {
792
+ return Math.max(1, this.#height() - 5);
793
+ }
794
+
795
+ #width() {
796
+ return Math.max(20, this.#output.columns ?? 80);
797
+ }
798
+
799
+ #height() {
800
+ return Math.max(8, this.#output.rows ?? 24);
801
+ }
802
+
803
+ async #waitForExit(options?: TerminalSessionOptions) {
804
+ if (
805
+ options?.waitForExit === false ||
806
+ !this.#input.isTTY ||
807
+ this.#interrupted
808
+ ) {
809
+ return;
810
+ }
811
+
812
+ await new Promise<void>(resolve => {
813
+ this.#onData = chunk => {
814
+ const key = parseKey(chunk);
815
+
816
+ switch (key.type) {
817
+ case 'up':
818
+ case 'down':
819
+ this.#handleScroll(key.type);
820
+ break;
821
+ case 'page-up':
822
+ case 'page-down':
823
+ this.#handlePageScroll(key.type);
824
+ break;
825
+ case 'ctrl-l':
826
+ this.#repaint();
827
+ break;
828
+ case 'escape':
829
+ this.#detachInput();
830
+ resolve();
831
+ break;
832
+ case 'character':
833
+ if (key.value === 'q') {
834
+ this.#detachInput();
835
+ resolve();
836
+ }
837
+ break;
838
+ case 'ctrl-c':
839
+ this.#detachInput();
840
+ process.exitCode = 130;
841
+ resolve();
842
+ break;
843
+ default:
844
+ break;
845
+ }
846
+ };
847
+
848
+ this.#attachInput();
849
+ });
850
+ }
851
+ }
852
+
853
+ function interruptedError() {
854
+ return new Error('Interrupted');
855
+ }
856
+
857
+ async function* takeUntil<T>(
858
+ source: AsyncIterable<T>,
859
+ stop: Promise<void>,
860
+ ): AsyncIterable<T> {
861
+ const iterator = source[Symbol.asyncIterator]();
862
+ const stopped = stop.then(
863
+ () => ({ done: true, value: undefined as T }) satisfies IteratorResult<T>,
864
+ );
865
+
866
+ while (true) {
867
+ const nextValue = await Promise.race([iterator.next(), stopped]);
868
+
869
+ if (nextValue.done) {
870
+ break;
871
+ }
872
+
873
+ yield nextValue.value;
874
+ }
875
+ }
876
+
877
+ function formatStreamError(error: unknown) {
878
+ if (error instanceof Error) {
879
+ return error.message;
880
+ }
881
+
882
+ if (typeof error === 'string') {
883
+ return error;
884
+ }
885
+
886
+ return JSON.stringify(error);
887
+ }
888
+
889
+ function renderToolInvocation(
890
+ part: ToolUIPart | DynamicToolUIPart,
891
+ options: {
892
+ mode: Exclude<TerminalPartDisplayMode, 'hidden'>;
893
+ collapsed: boolean;
894
+ },
895
+ ): ChatSection {
896
+ const toolName = getToolName(part);
897
+ const title = `Tool · ${part.title ?? toolName}`;
898
+ const input = 'input' in part ? part.input : undefined;
899
+ const inputText =
900
+ input === undefined
901
+ ? 'Input: (streaming...)'
902
+ : `Input:\n${formatValue(input)}`;
903
+ const status = toolStatus(part);
904
+
905
+ if (options.collapsed) {
906
+ return {
907
+ kind: 'tool',
908
+ title,
909
+ rightTitle: status,
910
+ content: '',
911
+ collapsed: true,
912
+ };
913
+ }
914
+
915
+ switch (part.state) {
916
+ case 'input-streaming':
917
+ return {
918
+ kind: 'tool',
919
+ title,
920
+ rightTitle: status,
921
+ content: inputText,
922
+ };
923
+ case 'input-available':
924
+ return {
925
+ kind: 'tool',
926
+ title,
927
+ rightTitle: status,
928
+ content: inputText,
929
+ };
930
+ case 'approval-requested':
931
+ return {
932
+ kind: 'tool',
933
+ title,
934
+ rightTitle: status,
935
+ content: inputText,
936
+ };
937
+ case 'approval-responded':
938
+ return {
939
+ kind: 'tool',
940
+ title,
941
+ rightTitle: status,
942
+ content: inputText,
943
+ };
944
+ case 'output-available':
945
+ return {
946
+ kind: 'tool',
947
+ title,
948
+ rightTitle: status,
949
+ content: `${inputText}\n\nOutput:\n${formatValue(part.output)}`,
950
+ };
951
+ case 'output-error':
952
+ return {
953
+ kind: 'error',
954
+ title: `Tool Error · ${part.title ?? toolName}`,
955
+ rightTitle: status,
956
+ content: `${inputText}\n\nError:\n${part.errorText}`,
957
+ };
958
+ case 'output-denied':
959
+ return {
960
+ kind: 'error',
961
+ title: `Tool Denied · ${part.title ?? toolName}`,
962
+ rightTitle: status,
963
+ content: `${inputText}\n\nReason: ${part.approval.reason ?? 'denied'}`,
964
+ };
965
+ }
966
+ }
967
+
968
+ function shouldCollapsePart(
969
+ message: UIMessage,
970
+ partIndex: number,
971
+ mode: TerminalPartDisplayMode,
972
+ displayModes: {
973
+ tools: TerminalPartDisplayMode;
974
+ reasoning: TerminalPartDisplayMode;
975
+ },
976
+ ) {
977
+ switch (mode) {
978
+ case 'collapsed':
979
+ return true;
980
+ case 'auto-collapsed':
981
+ return message.parts
982
+ .slice(partIndex + 1)
983
+ .some(part => isVisibleAssistantPart(part, displayModes));
984
+ case 'full':
985
+ case 'hidden':
986
+ return false;
987
+ }
988
+ }
989
+
990
+ function isVisibleAssistantPart(
991
+ part: UIMessage['parts'][number],
992
+ displayModes: {
993
+ tools: TerminalPartDisplayMode;
994
+ reasoning: TerminalPartDisplayMode;
995
+ },
996
+ ) {
997
+ switch (part.type) {
998
+ case 'text':
999
+ return part.text.trim().length > 0;
1000
+ case 'reasoning':
1001
+ return displayModes.reasoning !== 'hidden' && part.text.trim().length > 0;
1002
+ default:
1003
+ return isToolUIPart(part) && displayModes.tools !== 'hidden';
1004
+ }
1005
+ }
1006
+
1007
+ function startsVisibleAssistantStream(chunk: UIMessageChunk) {
1008
+ switch (chunk.type) {
1009
+ case 'text-start':
1010
+ case 'text-delta':
1011
+ case 'reasoning-start':
1012
+ case 'reasoning-delta':
1013
+ case 'tool-input-start':
1014
+ case 'tool-input-delta':
1015
+ return true;
1016
+ default:
1017
+ return false;
1018
+ }
1019
+ }
1020
+
1021
+ function statusForStreamChunk(
1022
+ chunk: UIMessageChunk,
1023
+ { hasPendingToolResults }: { hasPendingToolResults: boolean },
1024
+ ) {
1025
+ switch (chunk.type) {
1026
+ case 'start-step':
1027
+ return hasPendingToolResults
1028
+ ? processingToolResultsStatus
1029
+ : processingStatus;
1030
+ case 'tool-output-available':
1031
+ case 'tool-output-error':
1032
+ case 'tool-output-denied':
1033
+ return processingToolResultsStatus;
1034
+ case 'tool-input-available':
1035
+ return executingToolsStatus;
1036
+ case 'tool-approval-response':
1037
+ return chunk.approved ? executingToolsStatus : undefined;
1038
+ default:
1039
+ return undefined;
1040
+ }
1041
+ }
1042
+
1043
+ function finishesToolExecution(chunk: UIMessageChunk) {
1044
+ switch (chunk.type) {
1045
+ case 'tool-output-available':
1046
+ case 'tool-output-error':
1047
+ case 'tool-output-denied':
1048
+ return true;
1049
+ default:
1050
+ return false;
1051
+ }
1052
+ }
1053
+
1054
+ function toolStatus(part: ToolUIPart | DynamicToolUIPart) {
1055
+ switch (part.state) {
1056
+ case 'input-streaming':
1057
+ return 'waiting';
1058
+ case 'approval-requested':
1059
+ return 'approval requested';
1060
+ case 'input-available':
1061
+ return 'executing';
1062
+ case 'approval-responded':
1063
+ return part.approval.approved ? 'executing' : 'denied';
1064
+ case 'output-available':
1065
+ case 'output-error':
1066
+ return 'done';
1067
+ case 'output-denied':
1068
+ return 'denied';
1069
+ }
1070
+ }
1071
+
1072
+ function formatValue(value: unknown) {
1073
+ if (typeof value === 'string') {
1074
+ return value;
1075
+ }
1076
+
1077
+ return JSON.stringify(value, null, 2);
1078
+ }
1079
+
1080
+ function formatToolApprovalTitle(request: AgentTUIToolApprovalRequest) {
1081
+ return `tool ${request.title ?? request.toolName}`;
1082
+ }
1083
+
1084
+ function sectionId(messageId: string, partIndex: number) {
1085
+ return `${messageId}:${partIndex}`;
1086
+ }
1087
+
1088
+ function toReadableStream(
1089
+ stream: AsyncIterable<UIMessageChunk> | ReadableStream<UIMessageChunk>,
1090
+ ): ReadableStream<UIMessageChunk> {
1091
+ if (stream instanceof ReadableStream) {
1092
+ return stream;
1093
+ }
1094
+
1095
+ return new ReadableStream({
1096
+ async start(controller) {
1097
+ try {
1098
+ for await (const chunk of stream) {
1099
+ controller.enqueue(chunk);
1100
+ }
1101
+ controller.close();
1102
+ } catch (error) {
1103
+ controller.error(error);
1104
+ }
1105
+ },
1106
+ });
1107
+ }
1108
+
1109
+ async function* iterateUIMessageStream(
1110
+ stream: AsyncIterable<UIMessageChunk> | ReadableStream<UIMessageChunk>,
1111
+ ): AsyncIterable<UIMessageChunk> {
1112
+ if (stream instanceof ReadableStream) {
1113
+ const reader = stream.getReader();
1114
+
1115
+ try {
1116
+ while (true) {
1117
+ const { done, value } = await reader.read();
1118
+
1119
+ if (done) {
1120
+ return;
1121
+ }
1122
+
1123
+ yield value;
1124
+ }
1125
+ } finally {
1126
+ reader.releaseLock();
1127
+ }
1128
+
1129
+ return;
1130
+ }
1131
+
1132
+ yield* stream;
1133
+ }
1134
+
1135
+ function renderSectionLines(section: ChatSection, width: number) {
1136
+ if (section.cache && sectionMatchesCache(section, section.cache, width)) {
1137
+ return section.cache.lines;
1138
+ }
1139
+
1140
+ const lines = createSectionLines(section, width);
1141
+ section.cache = {
1142
+ width,
1143
+ kind: section.kind,
1144
+ title: section.title,
1145
+ rightTitle: section.rightTitle,
1146
+ content: section.content,
1147
+ collapsed: section.collapsed === true,
1148
+ lines,
1149
+ };
1150
+
1151
+ return lines;
1152
+ }
1153
+
1154
+ function sectionMatchesCache(
1155
+ section: ChatSection,
1156
+ cache: RenderedSectionCache,
1157
+ width = cache.width,
1158
+ ) {
1159
+ return (
1160
+ cache.width === width &&
1161
+ cache.kind === section.kind &&
1162
+ cache.title === section.title &&
1163
+ cache.rightTitle === section.rightTitle &&
1164
+ cache.content === section.content &&
1165
+ cache.collapsed === (section.collapsed === true)
1166
+ );
1167
+ }
1168
+
1169
+ function createSectionLines(section: ChatSection, width: number) {
1170
+ const style = sectionStyles[section.kind];
1171
+ const contentWidth = Math.max(1, width - 4);
1172
+ const title = ` ${section.title} `;
1173
+ const rightTitle = section.rightTitle ? ` ${section.rightTitle} ` : '';
1174
+
1175
+ if (section.collapsed) {
1176
+ const borderWidth = Math.max(
1177
+ 0,
1178
+ width - 2 - title.length - rightTitle.length,
1179
+ );
1180
+ const top = `${style.color}╭${title}${style.border.repeat(borderWidth)}${rightTitle}╮${colors.reset}`;
1181
+ const bottom = `${style.color}╰${style.border.repeat(Math.max(0, width - 2))}╯${colors.reset}`;
1182
+
1183
+ return [top, bottom];
1184
+ }
1185
+
1186
+ const borderWidth = Math.max(0, width - 2 - title.length - rightTitle.length);
1187
+ const top = `${style.color}╭${title}${style.border.repeat(borderWidth)}${rightTitle}╮${colors.reset}`;
1188
+ const bottom = `${style.color}╰${style.border.repeat(Math.max(0, width - 2))}╯${colors.reset}`;
1189
+ const content =
1190
+ section.content.length > 0
1191
+ ? renderMarkdown(section.content)
1192
+ : colors.dim + '(streaming...)' + colors.reset;
1193
+ const lines = content
1194
+ .split('\n')
1195
+ .flatMap(line => wrapVisibleLine(line, contentWidth));
1196
+
1197
+ return [
1198
+ top,
1199
+ ...lines.map(line => sectionLine(line, contentWidth, style.color)),
1200
+ bottom,
1201
+ ];
1202
+ }
1203
+
1204
+ function sectionLine(line: string, contentWidth: number, color: string) {
1205
+ const visible = sliceVisible(line, contentWidth);
1206
+ const padding = ' '.repeat(
1207
+ Math.max(0, contentWidth - visibleLength(visible)),
1208
+ );
1209
+
1210
+ return `${color}│${colors.reset} ${visible}${padding} ${color}│${colors.reset}`;
1211
+ }
1212
+
1213
+ function wrapVisibleLine(line: string, width: number): string[] {
1214
+ if (line.length === 0) {
1215
+ return [''];
1216
+ }
1217
+
1218
+ const lines: string[] = [];
1219
+ let remaining = line;
1220
+
1221
+ while (visibleLength(remaining) > width) {
1222
+ const breakAt = findVisibleBreakPoint(remaining, width);
1223
+ lines.push(remaining.slice(0, breakAt).trimEnd());
1224
+ remaining = remaining.slice(breakAt).trimStart();
1225
+ }
1226
+
1227
+ lines.push(remaining);
1228
+ return lines;
1229
+ }
1230
+
1231
+ function findVisibleBreakPoint(input: string, width: number) {
1232
+ const slice = sliceVisible(input, width + 1);
1233
+ const lastSpace = slice.lastIndexOf(' ');
1234
+
1235
+ if (lastSpace > 0) {
1236
+ return lastSpace;
1237
+ }
1238
+
1239
+ return sliceVisible(input, width).length;
1240
+ }
1241
+
1242
+ function extractResponseStatistics(chunk: UIMessageChunk) {
1243
+ const usage =
1244
+ 'usage' in chunk ? (chunk.usage as StreamUsage | undefined) : undefined;
1245
+ const metadataUsage =
1246
+ 'messageMetadata' in chunk
1247
+ ? (chunk.messageMetadata as MessageMetadataWithStats | undefined)?.usage
1248
+ : undefined;
1249
+ const metadataPerformance =
1250
+ 'messageMetadata' in chunk
1251
+ ? (chunk.messageMetadata as MessageMetadataWithStats | undefined)
1252
+ ?.performance
1253
+ : undefined;
1254
+
1255
+ return {
1256
+ totalTokens: extractTotalTokenCountFromUsage(usage ?? metadataUsage),
1257
+ outputTokens: extractOutputTokenCountFromUsage(usage ?? metadataUsage),
1258
+ outputTokensPerSecond: metadataPerformance?.outputTokensPerSecond,
1259
+ };
1260
+ }
1261
+
1262
+ function extractResponseStatisticsFromMetadata(metadata: unknown) {
1263
+ const stats = metadata as MessageMetadataWithStats | undefined;
1264
+
1265
+ return {
1266
+ totalTokens: extractTotalTokenCountFromUsage(stats?.usage),
1267
+ outputTokens: extractOutputTokenCountFromUsage(stats?.usage),
1268
+ outputTokensPerSecond: stats?.performance?.outputTokensPerSecond,
1269
+ };
1270
+ }
1271
+
1272
+ function extractTotalTokenCountFromUsage(usage: StreamUsage | undefined) {
1273
+ const totalTokens = usage?.totalTokens;
1274
+
1275
+ if (typeof totalTokens === 'number') {
1276
+ return totalTokens;
1277
+ }
1278
+
1279
+ if (typeof totalTokens?.total === 'number') {
1280
+ return totalTokens.total;
1281
+ }
1282
+
1283
+ const inputTokens = extractInputTokenCountFromUsage(usage);
1284
+ const outputTokens = extractOutputTokenCountFromUsage(usage);
1285
+
1286
+ if (inputTokens != null && outputTokens != null) {
1287
+ return inputTokens + outputTokens;
1288
+ }
1289
+
1290
+ return undefined;
1291
+ }
1292
+
1293
+ function extractInputTokenCountFromUsage(usage: StreamUsage | undefined) {
1294
+ const inputTokens = usage?.inputTokens;
1295
+
1296
+ if (typeof inputTokens === 'number') {
1297
+ return inputTokens;
1298
+ }
1299
+
1300
+ if (typeof inputTokens?.total === 'number') {
1301
+ return inputTokens.total;
1302
+ }
1303
+
1304
+ return usage?.promptTokens;
1305
+ }
1306
+
1307
+ function extractOutputTokenCountFromUsage(usage: StreamUsage | undefined) {
1308
+ const outputTokens = usage?.outputTokens;
1309
+
1310
+ if (typeof outputTokens === 'number') {
1311
+ return outputTokens;
1312
+ }
1313
+
1314
+ if (typeof outputTokens?.total === 'number') {
1315
+ return outputTokens.total;
1316
+ }
1317
+
1318
+ return usage?.completionTokens;
1319
+ }
1320
+
1321
+ function formatTokenCount(tokens: number | undefined, contextSize?: number) {
1322
+ if (tokens == null) {
1323
+ return undefined;
1324
+ }
1325
+
1326
+ const tokenCount = `${tokens.toLocaleString()} ${tokens === 1 ? 'token' : 'tokens'}`;
1327
+ const contextPercentage = formatContextPercentage(tokens, contextSize);
1328
+
1329
+ return contextPercentage == null
1330
+ ? tokenCount
1331
+ : `${tokenCount} ${contextPercentage}`;
1332
+ }
1333
+
1334
+ function formatContextPercentage(
1335
+ tokens: number,
1336
+ contextSize: number | undefined,
1337
+ ) {
1338
+ if (
1339
+ contextSize == null ||
1340
+ contextSize <= 0 ||
1341
+ !Number.isFinite(contextSize)
1342
+ ) {
1343
+ return undefined;
1344
+ }
1345
+
1346
+ return `${Math.round((tokens / contextSize) * 100).toLocaleString()}%`;
1347
+ }
1348
+
1349
+ function formatResponseStatistics(
1350
+ stats: {
1351
+ totalTokens: number | undefined;
1352
+ outputTokens: number | undefined;
1353
+ outputTokensPerSecond: number | undefined;
1354
+ },
1355
+ mode: ResponseStatisticsMode,
1356
+ ) {
1357
+ if (mode === 'outputTokensPerSecond') {
1358
+ return formatOutputTokensPerSecond(stats.outputTokensPerSecond);
1359
+ }
1360
+
1361
+ return formatTokenCount(stats.outputTokens);
1362
+ }
1363
+
1364
+ function formatOutputTokensPerSecond(
1365
+ outputTokensPerSecond: number | undefined,
1366
+ ) {
1367
+ if (outputTokensPerSecond == null) {
1368
+ return undefined;
1369
+ }
1370
+
1371
+ return `${formatNumber(outputTokensPerSecond)} tok/s`;
1372
+ }
1373
+
1374
+ function formatNumber(value: number) {
1375
+ return Number.isInteger(value)
1376
+ ? value.toLocaleString()
1377
+ : value.toLocaleString(undefined, { maximumFractionDigits: 1 });
1378
+ }
1379
+
1380
+ export function parseKey(chunk: Buffer): TerminalKey {
1381
+ const value = chunk.toString('utf8');
1382
+
1383
+ switch (value) {
1384
+ case '\u000c':
1385
+ return { type: 'ctrl-l' };
1386
+ case '\u0003':
1387
+ return { type: 'ctrl-c' };
1388
+ case '\x1B':
1389
+ return { type: 'escape' };
1390
+ case '\r':
1391
+ case '\n':
1392
+ return { type: 'enter' };
1393
+ case '\u007f':
1394
+ case '\b':
1395
+ return { type: 'backspace' };
1396
+ case '\x1B[A':
1397
+ return { type: 'up' };
1398
+ case '\x1B[B':
1399
+ return { type: 'down' };
1400
+ case '\x1B[5~':
1401
+ return { type: 'page-up' };
1402
+ case '\x1B[6~':
1403
+ return { type: 'page-down' };
1404
+ default:
1405
+ if (value >= ' ' && value !== '\x7F') {
1406
+ return { type: 'character', value };
1407
+ }
1408
+
1409
+ return { type: 'ignore' };
1410
+ }
1411
+ }