@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,85 @@
1
+ import { AgentTUIRunner } from './agent-tui-runner';
2
+ import type { Agent } from 'ai';
3
+
4
+ /**
5
+ * Controls how terminal UI sections for stream parts are displayed.
6
+ *
7
+ * - `full`: show the section header and full content.
8
+ * - `collapsed`: show only the section header.
9
+ * - `auto-collapsed`: show the latest section expanded until another visible
10
+ * section appears, then collapse it.
11
+ * - `hidden`: omit the section entirely.
12
+ */
13
+ export type TerminalPartDisplayMode =
14
+ | 'full'
15
+ | 'collapsed'
16
+ | 'auto-collapsed'
17
+ | 'hidden';
18
+
19
+ /**
20
+ * Controls which response statistic is shown.
21
+ *
22
+ * - `outputTokenCount`: show the number of output tokens in the response.
23
+ * - `outputTokensPerSecond`: show output token throughput for the response.
24
+ */
25
+ export type ResponseStatisticsMode =
26
+ | 'outputTokenCount'
27
+ | 'outputTokensPerSecond';
28
+
29
+ /**
30
+ * An agent that is compatible with the terminal UI.
31
+ *
32
+ * It has no call options and no structured output.
33
+ */
34
+ export type AgentTUIAgent = Agent<undefined, any, any, never>;
35
+
36
+ /**
37
+ * Options for starting an agent in the default terminal UI.
38
+ */
39
+ export type RunAgentTUIOptions = {
40
+ /**
41
+ * The agent to run.
42
+ */
43
+ agent: AgentTUIAgent;
44
+
45
+ /**
46
+ * The title shown in the terminal UI.
47
+ */
48
+ title?: string;
49
+
50
+ /**
51
+ * How tool calls should render.
52
+ *
53
+ * @default "auto-collapsed"
54
+ */
55
+ tools?: TerminalPartDisplayMode;
56
+
57
+ /**
58
+ * How reasoning parts should render.
59
+ *
60
+ * @default "auto-collapsed"
61
+ */
62
+ reasoning?: TerminalPartDisplayMode;
63
+
64
+ /**
65
+ * Which response statistic to show.
66
+ *
67
+ * @default "outputTokensPerSecond"
68
+ */
69
+ responseStatistics?: ResponseStatisticsMode;
70
+
71
+ /**
72
+ * The model context window size in tokens.
73
+ *
74
+ * When provided, the terminal UI shows the current total token usage as a
75
+ * percentage of this context window.
76
+ */
77
+ contextSize?: number;
78
+ };
79
+
80
+ /**
81
+ * Runs an agent in the default terminal UI until the user exits.
82
+ */
83
+ export async function runAgentTUI(options: RunAgentTUIOptions) {
84
+ await new AgentTUIRunner(options).run();
85
+ }
@@ -0,0 +1,207 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import type { TerminalInput, TerminalOutput } from '../tui/terminal-renderer';
3
+
4
+ const ansiControlSequencePattern = new RegExp(
5
+ `^${String.fromCharCode(27)}\\[([0-9?;]*)([ -/]*)([@-~])`,
6
+ );
7
+
8
+ export class MockUserInput extends EventEmitter implements TerminalInput {
9
+ isTTY = true;
10
+ rawModes: boolean[] = [];
11
+ resumeCalls = 0;
12
+ pauseCalls = 0;
13
+
14
+ setRawMode(mode: boolean) {
15
+ this.rawModes.push(mode);
16
+ return this;
17
+ }
18
+
19
+ resume() {
20
+ this.resumeCalls += 1;
21
+ return this;
22
+ }
23
+
24
+ pause() {
25
+ this.pauseCalls += 1;
26
+ return this;
27
+ }
28
+
29
+ type(text: string) {
30
+ this.emit('data', Buffer.from(text));
31
+ }
32
+
33
+ enter() {
34
+ this.emit('data', Buffer.from('\r'));
35
+ }
36
+
37
+ ctrlC() {
38
+ this.emit('data', Buffer.from('\u0003'));
39
+ }
40
+ }
41
+
42
+ export class MockScreen extends EventEmitter implements TerminalOutput {
43
+ columns: number;
44
+ rows: number;
45
+ #rawOutput = '';
46
+ #lines: string[] = [];
47
+ #cursorLine = 0;
48
+ #cursorColumn = 0;
49
+ #waiters: Array<{
50
+ text: string;
51
+ resolve: () => void;
52
+ reject: (error: Error) => void;
53
+ timeout: ReturnType<typeof setTimeout>;
54
+ }> = [];
55
+
56
+ constructor({ columns, rows }: { columns: number; rows: number }) {
57
+ super();
58
+ this.columns = columns;
59
+ this.rows = rows;
60
+ }
61
+
62
+ write(
63
+ chunk: string | Uint8Array,
64
+ encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void),
65
+ callback?: (error?: Error | null) => void,
66
+ ) {
67
+ const text = String(chunk);
68
+ this.#rawOutput += text;
69
+ this.#apply(text);
70
+
71
+ if (typeof encodingOrCallback === 'function') {
72
+ encodingOrCallback();
73
+ }
74
+ callback?.();
75
+
76
+ this.#resolveWaiters();
77
+ return true;
78
+ }
79
+
80
+ resize(columns: number, rows: number) {
81
+ this.columns = columns;
82
+ this.rows = rows;
83
+ this.emit('resize');
84
+ }
85
+
86
+ snapshot() {
87
+ return this.#lines.join('\n');
88
+ }
89
+
90
+ rawOutput() {
91
+ return this.#rawOutput;
92
+ }
93
+
94
+ async waitForText(
95
+ text: string,
96
+ timeoutMs = 1000,
97
+ getDebugOutput = () => this.snapshot(),
98
+ ) {
99
+ if (this.snapshot().includes(text)) {
100
+ return;
101
+ }
102
+
103
+ await new Promise<void>((resolve, reject) => {
104
+ const waiter = {
105
+ text,
106
+ resolve,
107
+ reject,
108
+ timeout: setTimeout(() => {
109
+ this.#waiters = this.#waiters.filter(
110
+ candidate => candidate !== waiter,
111
+ );
112
+ reject(
113
+ new Error(
114
+ `Timed out waiting for screen text: ${text}\n\nScreen:\n${getDebugOutput()}`,
115
+ ),
116
+ );
117
+ }, timeoutMs),
118
+ };
119
+ this.#waiters.push(waiter);
120
+ });
121
+ }
122
+
123
+ #resolveWaiters() {
124
+ const snapshot = this.snapshot();
125
+
126
+ for (const waiter of this.#waiters.slice()) {
127
+ if (!snapshot.includes(waiter.text)) {
128
+ continue;
129
+ }
130
+
131
+ clearTimeout(waiter.timeout);
132
+ this.#waiters = this.#waiters.filter(candidate => candidate !== waiter);
133
+ waiter.resolve();
134
+ }
135
+ }
136
+
137
+ #apply(input: string) {
138
+ let index = 0;
139
+
140
+ while (index < input.length) {
141
+ if (input[index] === '\x1b') {
142
+ const nextIndex = this.#applyEscape(input, index);
143
+
144
+ if (nextIndex > index) {
145
+ index = nextIndex;
146
+ continue;
147
+ }
148
+ }
149
+
150
+ const character = input[index];
151
+ index += 1;
152
+
153
+ if (character === undefined) {
154
+ continue;
155
+ }
156
+
157
+ if (character === '\n') {
158
+ this.#cursorLine += 1;
159
+ this.#cursorColumn = 0;
160
+ continue;
161
+ }
162
+
163
+ if (character === '\r') {
164
+ this.#cursorColumn = 0;
165
+ continue;
166
+ }
167
+
168
+ this.#writeCharacter(character);
169
+ }
170
+ }
171
+
172
+ #applyEscape(input: string, startIndex: number) {
173
+ const match = input.slice(startIndex).match(ansiControlSequencePattern);
174
+
175
+ if (!match) {
176
+ return startIndex;
177
+ }
178
+
179
+ const [sequence, rawParameters, , command] = match;
180
+ const parameters = rawParameters ? rawParameters.split(';') : [];
181
+
182
+ if (command === 'H' && parameters.length === 0) {
183
+ this.#cursorLine = 0;
184
+ this.#cursorColumn = 0;
185
+ } else if (command === 'J' && parameters[0] === '2') {
186
+ this.#lines = [];
187
+ } else if (command === 'K' && parameters[0] === '2') {
188
+ this.#lines[this.#cursorLine] = '';
189
+ this.#cursorColumn = 0;
190
+ } else if (command === 'H') {
191
+ this.#cursorLine = Number(parameters[0] ?? 1) - 1;
192
+ this.#cursorColumn = Number(parameters[1] ?? 1) - 1;
193
+ }
194
+
195
+ return startIndex + sequence.length;
196
+ }
197
+
198
+ #writeCharacter(character: string) {
199
+ const line = this.#lines[this.#cursorLine] ?? '';
200
+ const nextLine =
201
+ line.slice(0, this.#cursorColumn) +
202
+ character +
203
+ line.slice(this.#cursorColumn + character.length);
204
+ this.#lines[this.#cursorLine] = nextLine;
205
+ this.#cursorColumn += character.length;
206
+ }
207
+ }
@@ -0,0 +1,260 @@
1
+ import { renderMarkdown } from './markdown';
2
+ import {
3
+ ansiPrefixPattern,
4
+ codePointWidth,
5
+ sliceVisible,
6
+ visibleLength,
7
+ } from './terminal-text';
8
+
9
+ export { sliceVisible, stripAnsi, visibleLength } from './terminal-text';
10
+
11
+ const horizontal = '─';
12
+
13
+ export type TUIScreenState = {
14
+ width: number;
15
+ height: number;
16
+ title: string;
17
+ rightTitle?: string;
18
+ body: string;
19
+ input: string;
20
+ inputActive: boolean;
21
+ inputCursorVisible?: boolean;
22
+ scrollOffset: number;
23
+ status?: string;
24
+ };
25
+
26
+ export type TUIScreenLinesState = Omit<TUIScreenState, 'body'> & {
27
+ bodyLines: string[];
28
+ };
29
+
30
+ export type TUIScreenViewportState = Omit<
31
+ TUIScreenLinesState,
32
+ 'bodyLines' | 'scrollOffset'
33
+ > & {
34
+ visibleBodyLines: string[];
35
+ };
36
+
37
+ export function renderScreen(state: TUIScreenState): string {
38
+ const width = Math.max(20, state.width);
39
+ const contentWidth = width - 4;
40
+ const bodyLines = wrapText(renderMarkdown(state.body), contentWidth);
41
+
42
+ return renderScreenLines({ ...state, bodyLines });
43
+ }
44
+
45
+ export function renderScreenLines(state: TUIScreenLinesState): string {
46
+ const height = Math.max(8, state.height);
47
+ const inputHeight = 3;
48
+ const bodyHeight = height - inputHeight;
49
+ const bodyContentHeight = bodyHeight - 2;
50
+
51
+ const maxScrollOffset = Math.max(
52
+ 0,
53
+ state.bodyLines.length - bodyContentHeight,
54
+ );
55
+ const scrollOffset = Math.min(
56
+ Math.max(0, state.scrollOffset),
57
+ maxScrollOffset,
58
+ );
59
+ const start = Math.max(
60
+ 0,
61
+ state.bodyLines.length - bodyContentHeight - scrollOffset,
62
+ );
63
+ const visibleBody = state.bodyLines.slice(start, start + bodyContentHeight);
64
+
65
+ return renderScreenViewport({ ...state, visibleBodyLines: visibleBody });
66
+ }
67
+
68
+ export function renderScreenViewport(state: TUIScreenViewportState): string {
69
+ const width = Math.max(20, state.width);
70
+ const height = Math.max(8, state.height);
71
+ const inputHeight = 3;
72
+ const bodyHeight = height - inputHeight;
73
+ const bodyContentHeight = bodyHeight - 2;
74
+ const visibleBody = state.visibleBodyLines.slice(0, bodyContentHeight);
75
+
76
+ while (visibleBody.length < bodyContentHeight) {
77
+ visibleBody.push('');
78
+ }
79
+
80
+ const lines = [
81
+ topBorder(width, state.title, state.rightTitle),
82
+ ...visibleBody.map(line => boxLine(line, width)),
83
+ bottomBorder(width),
84
+ topBorder(width, state.inputActive ? 'Input' : 'Status'),
85
+ boxLine(
86
+ state.inputActive
87
+ ? `> ${state.input}${state.inputCursorVisible === false ? ' ' : '█'}`
88
+ : (state.status ?? 'Streaming... ↑/↓ scroll · Ctrl+C quit'),
89
+ width,
90
+ ),
91
+ bottomBorder(width),
92
+ ];
93
+
94
+ return lines.join('\n');
95
+ }
96
+
97
+ export function wrapText(input: string, width: number): string[] {
98
+ if (width <= 0) {
99
+ return [''];
100
+ }
101
+
102
+ const output: string[] = [];
103
+
104
+ for (const rawLine of input.split('\n')) {
105
+ if (rawLine.length === 0) {
106
+ output.push('');
107
+ continue;
108
+ }
109
+
110
+ let remaining = rawLine;
111
+
112
+ while (visibleLength(remaining) > width) {
113
+ const breakAt = findBreakPoint(remaining, width);
114
+ output.push(remaining.slice(0, breakAt).trimEnd());
115
+ remaining = remaining.slice(breakAt).trimStart();
116
+ }
117
+
118
+ output.push(remaining);
119
+ }
120
+
121
+ return output;
122
+ }
123
+
124
+ export function clampScrollOffset(
125
+ scrollOffset: number,
126
+ body: string,
127
+ bodyHeight: number,
128
+ width: number,
129
+ ): number {
130
+ const bodyContentHeight = Math.max(1, bodyHeight - 2);
131
+ const bodyLines = wrapText(renderMarkdown(body), Math.max(1, width - 4));
132
+ const maxScrollOffset = Math.max(0, bodyLines.length - bodyContentHeight);
133
+
134
+ return Math.min(Math.max(0, scrollOffset), maxScrollOffset);
135
+ }
136
+
137
+ function findBreakPoint(input: string, width: number): number {
138
+ let index = 0;
139
+ let visible = 0;
140
+ let lastSpace = -1;
141
+
142
+ while (index < input.length && visible < width) {
143
+ const ansiMatch = input.slice(index).match(ansiPrefixPattern);
144
+
145
+ if (ansiMatch) {
146
+ index += ansiMatch[0].length;
147
+ continue;
148
+ }
149
+
150
+ const codePoint = input.codePointAt(index);
151
+
152
+ if (codePoint == null) {
153
+ break;
154
+ }
155
+
156
+ const character = String.fromCodePoint(codePoint);
157
+ const characterWidth = codePointWidth(codePoint);
158
+
159
+ if (characterWidth > 0 && visible + characterWidth > width) {
160
+ break;
161
+ }
162
+
163
+ if (character === ' ') {
164
+ lastSpace = index;
165
+ }
166
+
167
+ index += character.length;
168
+ visible += characterWidth;
169
+ }
170
+
171
+ const nextBreakIndex = indexAfterAnsiSequences(input, index);
172
+ if (visible === width && input.codePointAt(nextBreakIndex) === 0x20) {
173
+ return nextBreakIndex;
174
+ }
175
+
176
+ if (lastSpace > 0) {
177
+ return lastSpace;
178
+ }
179
+
180
+ return indexAtVisibleWidth(input, width);
181
+ }
182
+
183
+ function topBorder(width: number, title: string, rightTitle?: string): string {
184
+ const contentWidth = Math.max(0, width - 2);
185
+ const label = title ? sliceVisible(` ${title} `, contentWidth) : '';
186
+ const rightLabel = rightTitle
187
+ ? sliceVisible(
188
+ ` ${rightTitle} `,
189
+ Math.max(0, contentWidth - visibleLength(label)),
190
+ )
191
+ : '';
192
+ const remaining = Math.max(
193
+ 0,
194
+ contentWidth - visibleLength(label) - visibleLength(rightLabel),
195
+ );
196
+
197
+ return `┌${label}${horizontal.repeat(remaining)}${rightLabel}┐`;
198
+ }
199
+
200
+ function bottomBorder(width: number): string {
201
+ return `└${horizontal.repeat(width - 2)}┘`;
202
+ }
203
+
204
+ function boxLine(line: string, width: number): string {
205
+ const contentWidth = width - 4;
206
+ const visible = sliceVisible(line, contentWidth);
207
+ const padding = ' '.repeat(
208
+ Math.max(0, contentWidth - visibleLength(visible)),
209
+ );
210
+
211
+ return `│ ${visible}${padding} │`;
212
+ }
213
+
214
+ function indexAtVisibleWidth(input: string, width: number): number {
215
+ let index = 0;
216
+ let visible = 0;
217
+
218
+ while (index < input.length && visible < width) {
219
+ const ansiMatch = input.slice(index).match(ansiPrefixPattern);
220
+
221
+ if (ansiMatch) {
222
+ index += ansiMatch[0].length;
223
+ continue;
224
+ }
225
+
226
+ const codePoint = input.codePointAt(index);
227
+
228
+ if (codePoint == null) {
229
+ break;
230
+ }
231
+
232
+ const character = String.fromCodePoint(codePoint);
233
+ const characterWidth = codePointWidth(codePoint);
234
+
235
+ if (characterWidth > 0 && visible + characterWidth > width) {
236
+ break;
237
+ }
238
+
239
+ index += character.length;
240
+ visible += characterWidth;
241
+ }
242
+
243
+ return index;
244
+ }
245
+
246
+ function indexAfterAnsiSequences(input: string, startIndex: number): number {
247
+ let index = startIndex;
248
+
249
+ while (index < input.length) {
250
+ const ansiMatch = input.slice(index).match(ansiPrefixPattern);
251
+
252
+ if (!ansiMatch) {
253
+ break;
254
+ }
255
+
256
+ index += ansiMatch[0].length;
257
+ }
258
+
259
+ return index;
260
+ }