subrouter-cli 0.0.0-stage → 0.1.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.
- package/CLAUDE.md +51 -0
- package/README.md +397 -2
- package/config.example.json +9 -0
- package/package.json +21 -4
- package/scripts/sse-harness.ts +252 -0
- package/scripts/sub-wrapper.sh +11 -0
- package/src/adapters/anthropic.ts +311 -0
- package/src/adapters/openai.ts +227 -0
- package/src/approval.ts +21 -0
- package/src/chatviewport.ts +123 -0
- package/src/client.ts +398 -0
- package/src/clipboard.ts +62 -0
- package/src/commandpolicy.ts +272 -0
- package/src/commands.ts +18 -0
- package/src/config.ts +107 -0
- package/src/effort.ts +18 -0
- package/src/images.ts +77 -0
- package/src/index.ts +248 -0
- package/src/lineinput.ts +726 -0
- package/src/loop.ts +201 -0
- package/src/markdown.ts +244 -0
- package/src/repl.ts +700 -0
- package/src/sessions.ts +58 -0
- package/src/sse.ts +64 -0
- package/src/terminal.ts +228 -0
- package/src/token.ts +36 -0
- package/src/toolpreview.ts +54 -0
- package/src/tools.ts +790 -0
- package/src/types.ts +73 -0
- package/src/ui.ts +813 -0
- package/src/usage.ts +186 -0
- package/test/absolute-tools.test.ts +296 -0
- package/test/absolute-ui.test.ts +153 -0
- package/test/adapters.test.ts +205 -0
- package/test/anthropic.test.ts +246 -0
- package/test/auto-ui.test.ts +106 -0
- package/test/chatviewport.test.ts +49 -0
- package/test/client.test.ts +327 -0
- package/test/clipboard.test.ts +63 -0
- package/test/command-input.test.ts +183 -0
- package/test/command-tools.test.ts +141 -0
- package/test/commandpolicy.test.ts +252 -0
- package/test/disk-tools.test.ts +220 -0
- package/test/effort.test.ts +123 -0
- package/test/fixtures/openai-tools.sse +84 -0
- package/test/image-adapters.test.ts +78 -0
- package/test/image-ui.test.ts +151 -0
- package/test/images.test.ts +60 -0
- package/test/loop.test.ts +387 -0
- package/test/m5.test.ts +127 -0
- package/test/markdown.test.ts +201 -0
- package/test/repl-ui.test.ts +293 -0
- package/test/sse.test.ts +74 -0
- package/test/steering-ui.test.ts +182 -0
- package/test/steering.test.ts +68 -0
- package/test/terminal-ui.test.ts +144 -0
- package/test/terminal.test.ts +229 -0
- package/test/toolpreview.test.ts +51 -0
- package/test/tools.test.ts +227 -0
- package/test/ui.test.ts +635 -0
- package/test/usage-footer.test.ts +180 -0
- package/tsconfig.json +17 -0
package/test/ui.test.ts
ADDED
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { test } from 'node:test';
|
|
4
|
+
|
|
5
|
+
function render(script: string): string {
|
|
6
|
+
const child = spawnSync(process.execPath, ['--input-type=module', '-e', `
|
|
7
|
+
Object.defineProperty(process.stdout, 'isTTY', { value: true });
|
|
8
|
+
Object.defineProperty(process.stdout, 'columns', { value: 60 });
|
|
9
|
+
delete process.env.NO_COLOR;
|
|
10
|
+
const { ui, green } = await import('./src/ui.ts');
|
|
11
|
+
${script}
|
|
12
|
+
`], { cwd: new URL('..', import.meta.url), encoding: 'utf8' });
|
|
13
|
+
assert.equal(child.status, 0, child.stderr);
|
|
14
|
+
return child.stdout;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const plain = (text: string): string => text.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
|
|
18
|
+
|
|
19
|
+
// Small screen emulator: verifies visible rows rather than just emitted ANSI bytes.
|
|
20
|
+
function screen(output: string, rows = 24, columns = 60): string[] {
|
|
21
|
+
const grid = Array.from({ length: rows }, () => Array(columns).fill(' '));
|
|
22
|
+
let row = 0, col = 0, top = 0, bottom = rows - 1;
|
|
23
|
+
let saved = [0, 0];
|
|
24
|
+
const tokens = output.match(/\x1b\[[0-9;?]*[A-Za-z]|\x1b[78]|[^\x1b]/gu) ?? [];
|
|
25
|
+
for (const token of tokens) {
|
|
26
|
+
if (token === '\x1b7') { saved = [row, col]; continue; }
|
|
27
|
+
if (token === '\x1b8') { [row, col] = saved; continue; }
|
|
28
|
+
if (token.startsWith('\x1b[')) {
|
|
29
|
+
const args = token.slice(2, -1).split(';').map(Number);
|
|
30
|
+
switch (token.at(-1)) {
|
|
31
|
+
case 'H': row = (args[0] || 1) - 1; col = (args[1] || 1) - 1; break;
|
|
32
|
+
case 'r': top = (args[0] || 1) - 1; bottom = (args[1] || rows) - 1; row = col = 0; break;
|
|
33
|
+
case 'K':
|
|
34
|
+
if (grid[row]) grid[row].fill(' ', args[0] === 2 ? 0 : col);
|
|
35
|
+
break;
|
|
36
|
+
case 'J':
|
|
37
|
+
if (grid[row]) grid[row].fill(' ', col);
|
|
38
|
+
for (let r = row + 1; r < rows; r++) grid[r].fill(' ');
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (token === '\r') { col = 0; continue; }
|
|
44
|
+
if (token === '\n') {
|
|
45
|
+
col = 0;
|
|
46
|
+
if (row === bottom) {
|
|
47
|
+
grid.splice(top, 1);
|
|
48
|
+
grid.splice(bottom, 0, Array(columns).fill(' '));
|
|
49
|
+
} else row = Math.min(rows - 1, row + 1);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (row < rows && col < columns) grid[row][col++] = token;
|
|
53
|
+
}
|
|
54
|
+
return grid.map((line) => line.join('').trimEnd());
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function dockRender(script: string): string {
|
|
58
|
+
return render(`
|
|
59
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: true });
|
|
60
|
+
Object.defineProperty(process.stdout, 'rows', { value: 24, configurable: true });
|
|
61
|
+
ui.welcome('openai/test', '/tmp/project');
|
|
62
|
+
ui.composer('openai/test', green('❯ '), '', 0);
|
|
63
|
+
${script}
|
|
64
|
+
process.stdout.write('SNAPSHOT_END');
|
|
65
|
+
ui.closeDock();
|
|
66
|
+
`).split('SNAPSHOT_END')[0];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
test('permission approvals render as a bordered panel with a separate choice line', () => {
|
|
70
|
+
const rows = screen(dockRender(`
|
|
71
|
+
ui.openApproval('File permission required', ['write_file "Desktop/quick-file.txt"'],
|
|
72
|
+
' type y to allow once · a to allow file changes this session · n to deny');
|
|
73
|
+
ui.setApprovalChoice('y', 1);
|
|
74
|
+
`));
|
|
75
|
+
assert.ok(rows.some((line) => line.includes('╭─ File permission required')));
|
|
76
|
+
assert.ok(rows.some((line) => line.includes('│ write_file "Desktop/quick-file.txt"')));
|
|
77
|
+
assert.ok(rows.some((line) => line.includes('type y to allow once')));
|
|
78
|
+
assert.ok(rows.some((line) => line.includes('╰─ › y')));
|
|
79
|
+
assert.ok(rows.some((line) => line === '❯'));
|
|
80
|
+
assert.ok(rows.every((line) => !line.includes('Allow file change [y/a/n]')));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('approval panel keeps the path and composer visible in a ten-row terminal', () => {
|
|
84
|
+
const out = dockRender(`
|
|
85
|
+
Object.defineProperty(process.stdout, 'rows', { value: 10, configurable: true });
|
|
86
|
+
process.stdout.emit('resize');
|
|
87
|
+
ui.openApproval('File permission required', ['write_file "quick-file.txt"'], 'y once · a all · n deny');
|
|
88
|
+
ui.setApprovalChoice('n', 1);
|
|
89
|
+
`);
|
|
90
|
+
const rows = screen(out, 10);
|
|
91
|
+
assert.ok(!out.includes('\x1b[1;1r'));
|
|
92
|
+
assert.ok(rows.some((line) => line.includes('quick-file.txt')));
|
|
93
|
+
assert.ok(rows.some((line) => line.includes('╰─ › n')));
|
|
94
|
+
assert.ok(rows.some((line) => line === '❯'));
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('long approval details wrap and scroll without replacing the composer draft', () => {
|
|
98
|
+
const path = 'Desktop/' + 'a'.repeat(105) + '/destination.txt';
|
|
99
|
+
const out = dockRender(`
|
|
100
|
+
Object.defineProperty(process.stdout, 'rows', { value: 12, configurable: true });
|
|
101
|
+
process.stdout.emit('resize');
|
|
102
|
+
ui.composer('openai/test', green('❯ '), 'unsent draft', 12);
|
|
103
|
+
ui.openApproval('File permission required', [${JSON.stringify(path)}], 'y once · n deny');
|
|
104
|
+
ui.scrollApproval(100);
|
|
105
|
+
ui.setApprovalChoice('n', 1);
|
|
106
|
+
`);
|
|
107
|
+
const rows = screen(out, 12);
|
|
108
|
+
assert.ok(rows.some((line) => line.includes('/destination.txt')));
|
|
109
|
+
assert.ok(rows.some((line) => line.includes('↑')));
|
|
110
|
+
assert.ok(rows.some((line) => line.includes('╰─ › n')));
|
|
111
|
+
assert.ok(rows.some((line) => line.includes('❯ unsent draft')));
|
|
112
|
+
const dismissed = screen(dockRender(`
|
|
113
|
+
ui.openApproval('File permission required', ['write_file "test"'], 'y once · n deny');
|
|
114
|
+
ui.closeApproval();
|
|
115
|
+
`));
|
|
116
|
+
assert.ok(dismissed.every((line) => !line.includes('File permission required')));
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('absolute permission warning remains visible during steering and command controls', () => {
|
|
120
|
+
for (const commandMode of [false, true]) {
|
|
121
|
+
const out = dockRender(`
|
|
122
|
+
ui.setMode('ABSOLUTE — NO APPROVALS');
|
|
123
|
+
ui.setStatus('running run_command · ctrl+c stop');
|
|
124
|
+
ui.setSteering(1, ['pending message']);
|
|
125
|
+
ui.commandNotice('working');
|
|
126
|
+
ui.setCommandMode(${commandMode});
|
|
127
|
+
`);
|
|
128
|
+
assert.ok(screen(out).some((line) => line.includes('ABSOLUTE — NO APPROVALS')));
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('absolute acknowledgement warning is removed from the chat after its prompt closes', () => {
|
|
133
|
+
const out = dockRender(`
|
|
134
|
+
ui.say('existing chat');
|
|
135
|
+
const warningStart = ui.chatPosition();
|
|
136
|
+
ui.say('WARNING: ABSOLUTE PERMISSIONS — ENTIRE DRIVE, NO APPROVALS');
|
|
137
|
+
const warningEnd = ui.chatPosition();
|
|
138
|
+
ui.say('agent output while confirmation is open');
|
|
139
|
+
ui.removeChat(warningStart, warningEnd);
|
|
140
|
+
`);
|
|
141
|
+
const lines = screen(out);
|
|
142
|
+
assert.ok(lines.includes('existing chat'));
|
|
143
|
+
assert.ok(lines.includes('agent output while confirmation is open'));
|
|
144
|
+
assert.ok(lines.every((line) => !line.includes('WARNING: ABSOLUTE PERMISSIONS')));
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('removing the absolute warning preserves and updates an active chat tool card', () => {
|
|
148
|
+
const rows = screen(dockRender(`
|
|
149
|
+
ui.startTool('write_file');
|
|
150
|
+
const start = ui.chatPosition();
|
|
151
|
+
ui.say('WARNING: ABSOLUTE PERMISSIONS — ENTIRE DRIVE, NO APPROVALS');
|
|
152
|
+
const end = ui.chatPosition();
|
|
153
|
+
ui.preview('Write(a.txt)', ['+ content']);
|
|
154
|
+
ui.removeChat(start, end);
|
|
155
|
+
ui.finishActivity('write_file', '✓ write_file');
|
|
156
|
+
`));
|
|
157
|
+
assert.equal(rows.filter((line) => line.includes('Write(a.txt)')).length, 1);
|
|
158
|
+
assert.ok(rows.some((line) => line.includes('+ content')));
|
|
159
|
+
assert.ok(rows.every((line) => !line.includes('WARNING: ABSOLUTE PERMISSIONS')));
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('TTY startup shows a distinct header and model-aware composer', () => {
|
|
163
|
+
const out = render(`
|
|
164
|
+
ui.welcome('openai/test-model', '/tmp/project');
|
|
165
|
+
ui.composer('openai/test-model', green('❯ '), '', 0);
|
|
166
|
+
`);
|
|
167
|
+
const text = plain(out);
|
|
168
|
+
assert.match(text, /sub \/tmp\/project/);
|
|
169
|
+
assert.match(text, /What are we building\?/);
|
|
170
|
+
assert.match(text, /─ openai\/test-model ─+/);
|
|
171
|
+
assert.ok(text.endsWith('❯ '));
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('composer redraw clears both rows and updates the model', () => {
|
|
175
|
+
const out = render(`
|
|
176
|
+
ui.composer('openai/first', green('❯ '), '', 0);
|
|
177
|
+
ui.composer('openai/second', green('❯ '), 'hello', 5);
|
|
178
|
+
ui.say('done');
|
|
179
|
+
`);
|
|
180
|
+
assert.equal(out.split('\x1b[1A').length - 1, 2);
|
|
181
|
+
assert.match(plain(out), /openai\/second/);
|
|
182
|
+
assert.ok(out.endsWith('done\n'));
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('long input stays within the composer row', () => {
|
|
186
|
+
const out = render(`ui.composer('openai/test', '❯ ', 'x'.repeat(200), 200);`);
|
|
187
|
+
assert.ok(plain(out).split('\n').every((line) => line.length <= 59));
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('NO_COLOR set after import disables styling and spinner animation', () => {
|
|
191
|
+
const out = render(`
|
|
192
|
+
process.env.NO_COLOR = '1';
|
|
193
|
+
ui.welcome('openai/test', '/tmp/project');
|
|
194
|
+
ui.spinner('loading').start(0).stop('done');
|
|
195
|
+
`);
|
|
196
|
+
assert.ok(!out.includes('\x1b['));
|
|
197
|
+
assert.match(out, /sub \/tmp\/project/);
|
|
198
|
+
assert.match(out, /loading/);
|
|
199
|
+
assert.match(out, /done/);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('thinking animates in the chat area without duplicating it in the footer or moving the draft', () => {
|
|
203
|
+
const out = dockRender(`
|
|
204
|
+
ui.composer('openai/test', green('❯ '), 'my draft', 8);
|
|
205
|
+
ui.setStatus('thinking · ctrl+c stop');
|
|
206
|
+
for (const frame of ['⠋', '⠙', '⠹']) ui.activity('thinking', frame + ' thinking');
|
|
207
|
+
`);
|
|
208
|
+
const lines = screen(out);
|
|
209
|
+
assert.equal(lines.filter((line) => line.includes('thinking')).length, 1);
|
|
210
|
+
assert.ok(lines.findIndex((line) => line.includes('⠹ thinking')) < 19);
|
|
211
|
+
assert.equal(lines[20], '❯ my draft');
|
|
212
|
+
assert.ok(lines.slice(19).every((line) => !line.includes('thinking')));
|
|
213
|
+
assert.match(lines[22], /ctrl\+c stop/);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('thinking is transient: streaming text replaces it and scrollback does not retain old frames', () => {
|
|
217
|
+
const completed = screen(dockRender(`
|
|
218
|
+
ui.setStatus('thinking · ctrl+c stop');
|
|
219
|
+
ui.activity('thinking', '⠋ thinking');
|
|
220
|
+
ui.finishActivity('thinking', '');
|
|
221
|
+
ui.streamText('Visible response');
|
|
222
|
+
ui.endTurn();
|
|
223
|
+
ui.setStatus('ready');
|
|
224
|
+
`));
|
|
225
|
+
assert.ok(completed.some((line) => line === 'Visible response'));
|
|
226
|
+
assert.ok(completed.every((line) => !line.includes('thinking')));
|
|
227
|
+
const scrolled = screen(dockRender(`
|
|
228
|
+
for (let i = 0; i < 40; i++) ui.say('message ' + i);
|
|
229
|
+
ui.setStatus('thinking · ctrl+c stop');
|
|
230
|
+
ui.activity('thinking', '⠙ thinking');
|
|
231
|
+
ui.scrollChat(10);
|
|
232
|
+
ui.activity('thinking', '⠹ thinking');
|
|
233
|
+
`));
|
|
234
|
+
assert.ok(scrolled.every((line) => !line.includes('thinking')));
|
|
235
|
+
const live = screen(dockRender(`
|
|
236
|
+
for (let i = 0; i < 40; i++) ui.say('message ' + i);
|
|
237
|
+
ui.setStatus('thinking · ctrl+c stop');
|
|
238
|
+
ui.activity('thinking', '⠙ thinking');
|
|
239
|
+
ui.scrollChat(10);
|
|
240
|
+
ui.activity('thinking', '⠹ thinking');
|
|
241
|
+
ui.scrollChat(-Infinity);
|
|
242
|
+
`));
|
|
243
|
+
assert.equal(live.filter((line) => line.includes('thinking')).length, 1);
|
|
244
|
+
assert.ok(live.some((line) => line === 'message 39'));
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('thinking survives command geometry changes and becomes static under NO_COLOR', () => {
|
|
248
|
+
const lines = screen(dockRender(`
|
|
249
|
+
process.env.NO_COLOR = '1';
|
|
250
|
+
ui.setStatus('thinking · ctrl+c stop');
|
|
251
|
+
ui.spinner('thinking').start();
|
|
252
|
+
ui.menu(['first', 'second'], 0);
|
|
253
|
+
ui.closeMenu();
|
|
254
|
+
ui.setUsage('12 in / 7 out', 'account: 20 rpm');
|
|
255
|
+
`));
|
|
256
|
+
assert.equal(lines.filter((line) => line.includes('thinking')).length, 1);
|
|
257
|
+
assert.ok(lines.findIndex((line) => line.includes('● thinking')) < 18);
|
|
258
|
+
assert.equal(lines[19], '❯');
|
|
259
|
+
assert.equal(lines[23], 'account: 20 rpm');
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test('chat scrolls above the bottom-anchored input and provider usage', () => {
|
|
263
|
+
const out = dockRender(`
|
|
264
|
+
ui.setUsage('12 in / 7 out · openai: 5h 80% available');
|
|
265
|
+
for (let i = 0; i < 50; i++) ui.say('answer ' + i);
|
|
266
|
+
ui.composer('openai/test', green('❯ '), 'hello', 5);
|
|
267
|
+
`);
|
|
268
|
+
const lines = screen(out);
|
|
269
|
+
assert.equal(lines[17], 'answer 49');
|
|
270
|
+
assert.match(lines[19], /─+ openai\/test ─$/);
|
|
271
|
+
assert.equal(lines[20], '❯ hello');
|
|
272
|
+
assert.equal(lines[21], '─'.repeat(59));
|
|
273
|
+
assert.equal(lines[22], 'ask before writes · effort auto · ready');
|
|
274
|
+
assert.equal(lines[23], '12 in / 7 out · openai: 5h 80% available');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test('model popup is attached below the bar and selection restores the chat region', () => {
|
|
278
|
+
const opened = dockRender(`ui.menu(['first', 'second'], 1, 0);`);
|
|
279
|
+
const rows = screen(opened);
|
|
280
|
+
assert.match(rows[18], /openai\/test/);
|
|
281
|
+
assert.equal(rows[19], '❯');
|
|
282
|
+
assert.equal(rows[20], '─'.repeat(59));
|
|
283
|
+
assert.equal(rows[21], ' first');
|
|
284
|
+
assert.equal(rows[22], '› second');
|
|
285
|
+
assert.match(rows[23], /enter select/);
|
|
286
|
+
const closed = dockRender(`
|
|
287
|
+
ui.menu(['first', 'second'], 1, 0);
|
|
288
|
+
ui.closeMenu();
|
|
289
|
+
ui.say('selection complete');
|
|
290
|
+
ui.composer('openai/second', green('❯ '), '', 0);
|
|
291
|
+
`);
|
|
292
|
+
assert.ok(closed.includes('\x1b[1;19r'));
|
|
293
|
+
assert.match(screen(closed)[19], /openai\/second/);
|
|
294
|
+
assert.ok(!screen(closed).some((line) => line.includes('enter select')));
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test('account limits and command feedback stay below the bar, outside chat', () => {
|
|
298
|
+
const out = dockRender(`
|
|
299
|
+
ui.say('agent answer');
|
|
300
|
+
ui.setUsage('openai: 5h 80% available', 'account: 60 rpm · day 0/1,000 tokens');
|
|
301
|
+
ui.commandNotice('model saved');
|
|
302
|
+
`);
|
|
303
|
+
const rows = screen(out);
|
|
304
|
+
assert.match(rows[18], /openai\/test/);
|
|
305
|
+
assert.equal(rows[19], '❯');
|
|
306
|
+
assert.equal(rows[21], 'model saved');
|
|
307
|
+
assert.equal(rows[22], 'openai: 5h 80% available');
|
|
308
|
+
assert.equal(rows[23], 'account: 60 rpm · day 0/1,000 tokens');
|
|
309
|
+
assert.ok(!rows.slice(0, 18).some((line) => /model saved|account:/.test(line)));
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test('filtering command options resizes the menu without stale rows', () => {
|
|
313
|
+
const out = dockRender(`
|
|
314
|
+
ui.menu(['one', 'two', 'three', 'four'], 0, 0);
|
|
315
|
+
ui.menu(['one'], 0, 0);
|
|
316
|
+
`);
|
|
317
|
+
const rows = screen(out);
|
|
318
|
+
assert.equal(rows[22], '› one');
|
|
319
|
+
assert.match(rows[23], /enter select/);
|
|
320
|
+
assert.ok(!rows.some((line) => /two|three|four/.test(line)));
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test('usage panel wraps long lines, scrolls below input and closes cleanly', () => {
|
|
324
|
+
const out = dockRender(`
|
|
325
|
+
ui.panel(['x'.repeat(100), ...Array.from({ length: 10 }, (_, i) => 'pool ' + i)]);
|
|
326
|
+
ui.scrollPanel(4);
|
|
327
|
+
`);
|
|
328
|
+
const rows = screen(out);
|
|
329
|
+
assert.equal(rows[13], '❯');
|
|
330
|
+
assert.equal(rows[15], 'pool 2');
|
|
331
|
+
assert.match(rows[23], /r refresh/);
|
|
332
|
+
const closed = dockRender(`ui.panel(['usage']); ui.closePanel();`);
|
|
333
|
+
assert.ok(!screen(closed).some((line) => line === 'usage'));
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
test('command notices clear on new input or agent activity', () => {
|
|
337
|
+
const out = dockRender(`
|
|
338
|
+
ui.commandNotice('model saved');
|
|
339
|
+
ui.composer('openai/test', '❯ ', 'hello', 5);
|
|
340
|
+
ui.commandNotice('another notice');
|
|
341
|
+
ui.setStatus('thinking');
|
|
342
|
+
`);
|
|
343
|
+
const rows = screen(out);
|
|
344
|
+
assert.equal(rows[22], 'ask before writes · effort auto · ctrl+c stop');
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test('dock uses semantic colors for model, effort, activity and availability', () => {
|
|
348
|
+
const out = dockRender(`
|
|
349
|
+
ui.setEffort('high');
|
|
350
|
+
ui.setStatus('thinking');
|
|
351
|
+
ui.activity('thinking', '⠋ thinking');
|
|
352
|
+
ui.setUsage('915 in / 82 out · opencode: 5h 100% · 7d 36% · monthly 10% available',
|
|
353
|
+
'account: 60 rpm · day 374,311,594/unlimited tokens · week $200/$500');
|
|
354
|
+
`);
|
|
355
|
+
assert.ok(out.includes('\x1b[38;2;143;167;255m\x1b[1m openai/test ─'));
|
|
356
|
+
assert.ok(out.includes('\x1b[38;2;143;167;255m\x1b[1msub'));
|
|
357
|
+
assert.ok(out.includes('\x1b[38;2;166;116;255meffort high\x1b[0m'));
|
|
358
|
+
assert.ok(out.includes('\x1b[38;2;166;116;255m❯ '));
|
|
359
|
+
assert.ok(out.includes('\x1b[38;2;166;116;255m⠋\x1b[0m\x1b[2m thinking\x1b[0m'));
|
|
360
|
+
assert.ok(out.includes('\x1b[38;2;143;167;255m\x1b[1m100%'));
|
|
361
|
+
assert.ok(out.includes('\x1b[93m\x1b[1m36%'));
|
|
362
|
+
assert.ok(out.includes('\x1b[91m\x1b[1m10%'));
|
|
363
|
+
assert.match(screen(out)[22], /915 in \/ 82 out · opencode/);
|
|
364
|
+
const noColor = dockRender(`
|
|
365
|
+
process.env.NO_COLOR = '1';
|
|
366
|
+
ui.setEffort('high');
|
|
367
|
+
ui.setUsage('opencode: 5h 100% available');
|
|
368
|
+
process.stdout.write('COLOR_OFF');
|
|
369
|
+
ui.composer('openai/test', '❯ ', '', 0);
|
|
370
|
+
`).split('COLOR_OFF')[1];
|
|
371
|
+
assert.ok(!/\x1b\[[\d;]*m/.test(noColor));
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test('file preview and tool activity update one in-flow chat card', () => {
|
|
375
|
+
const out = dockRender(`
|
|
376
|
+
ui.say('previous conversation');
|
|
377
|
+
ui.beginTurn();
|
|
378
|
+
ui.startTool('write_file');
|
|
379
|
+
ui.preview('Write(x.ts) · +1 lines', ['+ const first = 1;']);
|
|
380
|
+
ui.preview('Write(x.ts) · +2 lines', ['+ const first = 1;', '+ const second = 2;']);
|
|
381
|
+
const spinner = ui.spinner('write_file {"path":"x.ts"}').start(100000);
|
|
382
|
+
spinner.stop('✓ write_file');
|
|
383
|
+
ui.say('final answer after tool');
|
|
384
|
+
`);
|
|
385
|
+
const rows = screen(out);
|
|
386
|
+
assert.equal(rows.filter((line) => line.includes('const first = 1')).length, 1);
|
|
387
|
+
assert.equal(rows.filter((line) => line.includes('const second = 2')).length, 1);
|
|
388
|
+
assert.equal(rows.filter((line) => line.includes('Write(x.ts)')).length, 1);
|
|
389
|
+
const card = rows.findIndex((line) => /1 tool · ✓ Write\(x.ts\)/.test(line));
|
|
390
|
+
assert.ok(card > rows.indexOf('previous conversation'));
|
|
391
|
+
assert.ok(card < rows.indexOf('final answer after tool'));
|
|
392
|
+
assert.ok(card < 19);
|
|
393
|
+
assert.equal(rows[20], '❯');
|
|
394
|
+
const nextTurn = screen(dockRender(`
|
|
395
|
+
ui.startTool('write_file'); ui.preview('Write(x.ts)', ['+ code']);
|
|
396
|
+
ui.beginTurn(); ui.say('new turn');
|
|
397
|
+
`));
|
|
398
|
+
assert.ok(nextTurn.some((line) => line.includes('Write(x.ts)')));
|
|
399
|
+
assert.ok(nextTurn.some((line) => line.includes('new turn')));
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test('tool spinner updates its in-flow card without duplicating frames or moving the composer', () => {
|
|
403
|
+
const rows = screen(dockRender(`
|
|
404
|
+
ui.say('existing history');
|
|
405
|
+
ui.startTool('grep');
|
|
406
|
+
ui.activity('grep', '⠋ grep');
|
|
407
|
+
ui.activity('grep', '⠙ grep');
|
|
408
|
+
ui.activity('grep', '⠹ grep');
|
|
409
|
+
`));
|
|
410
|
+
assert.equal(rows.filter((line) => line.includes('grep')).length, 1);
|
|
411
|
+
assert.ok(rows.findIndex((line) => line.includes('⠹ grep')) > rows.indexOf('existing history'));
|
|
412
|
+
assert.ok(rows.findIndex((line) => line.includes('⠹ grep')) < 19);
|
|
413
|
+
assert.equal(rows[20], '❯');
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test('in-flow tool cards survive command controls and fit tiny dock heights', () => {
|
|
417
|
+
const out = dockRender(`
|
|
418
|
+
ui.startTool('write_file'); ui.preview('Write(x.ts)', ['+ one', '+ two']);
|
|
419
|
+
ui.menu(['effort high'], 0, 0); ui.closeMenu();
|
|
420
|
+
Object.defineProperty(process.stdout, 'rows', { value: 10, configurable: true });
|
|
421
|
+
process.stdout.emit('resize');
|
|
422
|
+
`);
|
|
423
|
+
assert.ok(!out.includes('\x1b[1;1r'));
|
|
424
|
+
const rows = screen(out, 10);
|
|
425
|
+
assert.equal(rows[6], '❯');
|
|
426
|
+
assert.ok(rows.some((line) => line.includes('Write(x.ts)')));
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test('repeated command menus do not scroll empty space or fill chat with blank lines', () => {
|
|
430
|
+
const out = dockRender(`
|
|
431
|
+
ui.say('existing answer');
|
|
432
|
+
process.stdout.write('MENU_START');
|
|
433
|
+
for (let i = 0; i < 10; i++) {
|
|
434
|
+
ui.menu(['model', 'usage', 'effort', 'help'], 0, 0);
|
|
435
|
+
ui.menu(['model'], 0, 0);
|
|
436
|
+
ui.closeMenu();
|
|
437
|
+
ui.panel(['provider usage', 'account limits']);
|
|
438
|
+
ui.closePanel();
|
|
439
|
+
}
|
|
440
|
+
process.stdout.write('MENU_END');
|
|
441
|
+
`);
|
|
442
|
+
const menuOutput = out.split('MENU_START')[1].split('MENU_END')[0];
|
|
443
|
+
assert.ok(!menuOutput.includes('\n'), 'menu layout must not write blank lines when chat has room');
|
|
444
|
+
const rows = screen(out);
|
|
445
|
+
assert.ok(rows.includes('existing answer'));
|
|
446
|
+
assert.ok(rows.some((line) => line.includes('sub /tmp/project')));
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test('opening controls only scrolls chat when real content needs room', () => {
|
|
450
|
+
const out = dockRender(`
|
|
451
|
+
for (let i = 0; i < 50; i++) ui.say('answer ' + i);
|
|
452
|
+
ui.menu(['first', 'second'], 1, 0);
|
|
453
|
+
`);
|
|
454
|
+
assert.equal(screen(out)[16], 'answer 49');
|
|
455
|
+
assert.equal(screen(out)[22], '› second');
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test('command controls hide footer clutter and restore it when dismissed', () => {
|
|
459
|
+
const opened = dockRender(`
|
|
460
|
+
ui.setUsage('provider footer', 'account footer');
|
|
461
|
+
ui.setCommandMode(true);
|
|
462
|
+
ui.panel(['Usage', 'openai: 5h 80% available', 'Account limits', '60 rpm']);
|
|
463
|
+
`);
|
|
464
|
+
const rows = screen(opened);
|
|
465
|
+
assert.ok(!rows.some((line) => /provider footer|account footer|ask before writes|effort auto/.test(line)));
|
|
466
|
+
assert.equal(rows.filter((line) => line.includes('80% available')).length, 1);
|
|
467
|
+
const closed = dockRender(`
|
|
468
|
+
ui.setUsage('provider footer', 'account footer');
|
|
469
|
+
ui.setCommandMode(true); ui.panel(['Usage']);
|
|
470
|
+
ui.closePanel(); ui.setCommandMode(false);
|
|
471
|
+
`);
|
|
472
|
+
assert.equal(screen(closed)[22], 'provider footer');
|
|
473
|
+
assert.equal(screen(closed)[23], 'account footer');
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test('queued image names stay in the footer and disappear without changing dock height', () => {
|
|
477
|
+
const queued = dockRender(`ui.setAttachments(['shot.png', 'diagram.jpg']);`);
|
|
478
|
+
const rows = screen(queued);
|
|
479
|
+
assert.equal(rows[20], '❯');
|
|
480
|
+
assert.equal(rows[22], '2 images attached · shot.png · diagram.jpg');
|
|
481
|
+
const cleared = dockRender(`ui.setAttachments(['shot.png']); ui.setAttachments([]);`);
|
|
482
|
+
assert.equal(screen(cleared)[22], 'ask before writes · effort auto · ready');
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test('steering queue updates the existing footer without moving or clearing the draft', () => {
|
|
486
|
+
const out = dockRender(`
|
|
487
|
+
ui.setSteering(0); ui.setStatus('responding · ctrl+c stop');
|
|
488
|
+
ui.composer('openai/test', '❯ ', 'still typing', 12);
|
|
489
|
+
ui.setSteering(2);
|
|
490
|
+
`);
|
|
491
|
+
const rows = screen(out);
|
|
492
|
+
assert.equal(rows[20], '❯ still typing');
|
|
493
|
+
assert.match(rows[22], /2 steering queued · responding/);
|
|
494
|
+
const cleared = dockRender(`ui.setSteering(2); ui.setSteering(null); ui.setStatus('ready');`);
|
|
495
|
+
assert.equal(screen(cleared)[22], 'ask before writes · effort auto · ready');
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
test('queued steering appears in muted message rows and clears without moving the composer', () => {
|
|
499
|
+
const pending = dockRender(`
|
|
500
|
+
ui.setSteering(2, ['first direction', 'second direction']);
|
|
501
|
+
ui.composer('openai/test', '❯ ', 'draft', 5);
|
|
502
|
+
`);
|
|
503
|
+
const rows = screen(pending);
|
|
504
|
+
assert.equal(rows[16], '› first direction');
|
|
505
|
+
assert.equal(rows[17], '› second direction');
|
|
506
|
+
assert.match(rows[18], /pending · sent at next model call/);
|
|
507
|
+
assert.equal(rows[20], '❯ draft');
|
|
508
|
+
assert.ok(pending.includes('\x1b[48;2;56;56;56;38;2;160;160;160m› first direction'));
|
|
509
|
+
const cleared = dockRender(`ui.setSteering(1, ['queued only']); ui.setSteering(0);`);
|
|
510
|
+
assert.ok(!screen(cleared).some((row) => /queued only|pending ·/.test(row)));
|
|
511
|
+
assert.equal(screen(cleared)[20], '❯');
|
|
512
|
+
const compact = dockRender(`
|
|
513
|
+
ui.startTool('write_file'); ui.preview('Write(x.ts)', ['+ one', '+ two', '+ three']);
|
|
514
|
+
ui.setSteering(5, ['one', 'two', 'three', 'four', '界'.repeat(100)]);
|
|
515
|
+
Object.defineProperty(process.stdout, 'rows', { value: 10, configurable: true });
|
|
516
|
+
process.stdout.emit('resize');
|
|
517
|
+
`);
|
|
518
|
+
assert.equal(screen(compact, 10)[6], '❯');
|
|
519
|
+
assert.ok(!compact.includes('\x1b[1;1r'));
|
|
520
|
+
const overflow = dockRender(`ui.setSteering(5, ['one', 'two', 'three', 'four', '界'.repeat(100)]);`);
|
|
521
|
+
assert.ok(screen(overflow).some((row) => row.includes('2 earlier')));
|
|
522
|
+
assert.ok(screen(overflow).some((row) => row.endsWith('…')));
|
|
523
|
+
const noColor = dockRender(`process.env.NO_COLOR = '1'; ui.setSteering(1, ['plain pending']);`);
|
|
524
|
+
assert.ok(!noColor.slice(noColor.indexOf('plain pending') - 40).includes('\x1b[48;'));
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
test('multiple replies retain one blank separator even when model output has oversized boundary gaps', () => {
|
|
528
|
+
const out = dockRender(`
|
|
529
|
+
const { MarkdownStream } = await import('./src/markdown.ts');
|
|
530
|
+
for (const prompt of ['hi', 'e', 'edt4']) {
|
|
531
|
+
ui.submitInput('❯ ', prompt);
|
|
532
|
+
ui.say('');
|
|
533
|
+
const md = new MarkdownStream();
|
|
534
|
+
md.write('\\n\\n\\nReply to ' + prompt + '.\\n\\n\\n');
|
|
535
|
+
md.flush(); ui.endTurn();
|
|
536
|
+
ui.say('');
|
|
537
|
+
}
|
|
538
|
+
`);
|
|
539
|
+
const rows = screen(out);
|
|
540
|
+
for (const prompt of ['hi', 'e', 'edt4']) {
|
|
541
|
+
const input = rows.indexOf('❯ ' + prompt);
|
|
542
|
+
assert.ok(input >= 0);
|
|
543
|
+
assert.equal(rows[input + 1], '');
|
|
544
|
+
assert.equal(rows[input + 2], 'Reply to ' + prompt + '.');
|
|
545
|
+
}
|
|
546
|
+
assert.equal(rows.indexOf('❯ e') - rows.indexOf('Reply to hi.'), 2);
|
|
547
|
+
assert.equal(rows.indexOf('❯ edt4') - rows.indexOf('Reply to e.'), 2);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
test('history scrolling preserves the anchored composer and viewed content during streaming', () => {
|
|
551
|
+
const scrolled = dockRender(`
|
|
552
|
+
for (let i = 0; i < 50; i++) ui.say('answer ' + i);
|
|
553
|
+
ui.composer('openai/test', '❯ ', 'draft', 5);
|
|
554
|
+
ui.scrollChat(8);
|
|
555
|
+
ui.say('new streaming answer');
|
|
556
|
+
`);
|
|
557
|
+
const rows = screen(scrolled);
|
|
558
|
+
assert.equal(rows[17], 'answer 41');
|
|
559
|
+
assert.equal(rows[20], '❯ draft');
|
|
560
|
+
assert.match(rows[22], /history · PgDn to live/);
|
|
561
|
+
const live = dockRender(`
|
|
562
|
+
for (let i = 0; i < 50; i++) ui.say('answer ' + i);
|
|
563
|
+
ui.scrollChat(8); ui.say('new answer'); ui.scrollChat(-1000);
|
|
564
|
+
ui.streamText('live'); ui.endTurn();
|
|
565
|
+
`);
|
|
566
|
+
assert.ok(screen(live).includes('new answer'));
|
|
567
|
+
assert.ok(screen(live).includes('live'));
|
|
568
|
+
assert.equal(screen(live)[20], '❯');
|
|
569
|
+
const resized = dockRender(`
|
|
570
|
+
for (let i = 0; i < 50; i++) ui.say('answer ' + i);
|
|
571
|
+
ui.scrollChat(8);
|
|
572
|
+
Object.defineProperty(process.stdout, 'rows', { value: 12, configurable: true });
|
|
573
|
+
process.stdout.emit('resize');
|
|
574
|
+
`);
|
|
575
|
+
assert.equal(screen(resized, 12)[8], '❯');
|
|
576
|
+
assert.match(screen(resized, 12)[10], /history/);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test('full-width streamed lines continue correctly after a viewport repaint', () => {
|
|
580
|
+
const out = dockRender(`
|
|
581
|
+
ui.streamText('x'.repeat(60));
|
|
582
|
+
process.stdout.emit('resize');
|
|
583
|
+
ui.streamText('next'); ui.endTurn();
|
|
584
|
+
`);
|
|
585
|
+
assert.ok(screen(out).includes('x'.repeat(60)));
|
|
586
|
+
assert.ok(screen(out).includes('next'));
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
test('resizing reanchors the bar and terminal shutdown restores scroll margins', () => {
|
|
590
|
+
const out = dockRender(`
|
|
591
|
+
Object.defineProperty(process.stdout, 'rows', { value: 12, configurable: true });
|
|
592
|
+
process.stdout.emit('resize');
|
|
593
|
+
`);
|
|
594
|
+
assert.ok(out.includes('\x1b[1;7r'));
|
|
595
|
+
assert.match(screen(out, 12)[7], /openai\/test/);
|
|
596
|
+
const exited = render(`
|
|
597
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: true });
|
|
598
|
+
ui.welcome('openai/test', '/tmp/project');
|
|
599
|
+
ui.closeDock();
|
|
600
|
+
`);
|
|
601
|
+
assert.ok(exited.includes('\x1b[r'));
|
|
602
|
+
assert.ok(exited.includes('\x1b[?25h'));
|
|
603
|
+
assert.ok(exited.includes('\x1b[?1000h\x1b[?1006h'));
|
|
604
|
+
assert.ok(exited.includes('\x1b[?1000l\x1b[?1006l'));
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
test('width-only resize keeps partial streamed text on the same chat line', () => {
|
|
608
|
+
const out = dockRender(`
|
|
609
|
+
ui.streamText('hel');
|
|
610
|
+
process.stdout.emit('resize');
|
|
611
|
+
ui.streamText('lo');
|
|
612
|
+
ui.endTurn();
|
|
613
|
+
`);
|
|
614
|
+
assert.ok(screen(out).includes('hello'));
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
test('growing the terminal erases the former dock footprint', () => {
|
|
618
|
+
const out = dockRender(`
|
|
619
|
+
Object.defineProperty(process.stdout, 'rows', { value: 30, configurable: true });
|
|
620
|
+
process.stdout.emit('resize');
|
|
621
|
+
`);
|
|
622
|
+
const rows = screen(out, 30);
|
|
623
|
+
assert.equal(rows.filter((line) => line.includes('openai/test')).length, 1);
|
|
624
|
+
assert.equal(rows.filter((line) => line.includes('ask before writes')).length, 1);
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
test('tiny resize cancels the popup without emitting invalid scroll margins', () => {
|
|
628
|
+
const out = dockRender(`
|
|
629
|
+
ui.menu(['first', 'second'], 1, 0, () => process.stdout.write('CANCELLED'));
|
|
630
|
+
Object.defineProperty(process.stdout, 'rows', { value: 8, configurable: true });
|
|
631
|
+
process.stdout.emit('resize');
|
|
632
|
+
`);
|
|
633
|
+
assert.ok(!out.includes('\x1b[1;1r'));
|
|
634
|
+
assert.ok(out.includes('CANCELLED'));
|
|
635
|
+
});
|