@selesai/code 0.8.1 → 0.8.3

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 (46) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +4 -2
  3. package/dist/cli/args.d.ts +2 -0
  4. package/dist/cli/args.js +18 -0
  5. package/dist/cli/config-selector.js +2 -2
  6. package/dist/cli/startup-ui.d.ts +1 -1
  7. package/dist/cli/startup-ui.js +2 -2
  8. package/dist/core/keybindings.d.ts +44 -4
  9. package/dist/core/llama/provider.js +22 -7
  10. package/dist/core/model-resolver.js +2 -0
  11. package/dist/core/model-runtime.d.ts +4 -2
  12. package/dist/core/model-runtime.js +23 -2
  13. package/dist/core/remote-catalog-provider.js +72 -67
  14. package/dist/core/settings-manager.d.ts +8 -0
  15. package/dist/core/settings-manager.js +18 -0
  16. package/dist/extensions/pi-powerline-footer/CHANGELOG.md +3 -1
  17. package/dist/extensions/pi-powerline-footer/guide.ts +11 -3
  18. package/dist/extensions/pi-powerline-footer/index.ts +3 -272
  19. package/dist/extensions/pi-powerline-footer/package.json +4 -5
  20. package/dist/extensions/pi-powerline-footer/powerline-config.ts +0 -6
  21. package/dist/extensions/pi-powerline-footer/tests/fixed-editor-border.test.ts +2 -2
  22. package/dist/extensions/pi-powerline-footer/tests/guide.test.ts +24 -1
  23. package/dist/extensions/pi-powerline-footer/tsconfig.json +1 -1
  24. package/dist/extensions/pi-subagents/package-lock.json +2 -2
  25. package/dist/extensions/pi-subagents/package.json +1 -1
  26. package/dist/extensions/pi-subagents/test/unit/package-manifest.test.ts +1 -1
  27. package/dist/index.d.ts +1 -1
  28. package/dist/main.js +1 -0
  29. package/dist/modes/interactive/components/settings-selector.d.ts +6 -2
  30. package/dist/modes/interactive/components/settings-selector.js +20 -0
  31. package/dist/modes/interactive/interactive-mode.d.ts +28 -0
  32. package/dist/modes/interactive/interactive-mode.js +214 -28
  33. package/dist/modes/interactive/theme/dark.json +1 -0
  34. package/dist/modes/interactive/theme/light.json +1 -0
  35. package/dist/modes/interactive/theme/theme-controller.d.ts +3 -0
  36. package/dist/modes/interactive/theme/theme-controller.js +10 -1
  37. package/dist/modes/interactive/theme/theme.d.ts +2 -2
  38. package/dist/modes/interactive/theme/theme.js +24 -6
  39. package/docs/keybindings.md +35 -7
  40. package/docs/settings.md +16 -14
  41. package/docs/usage.md +5 -0
  42. package/package.json +5 -5
  43. package/dist/extensions/pi-powerline-footer/fixed-editor/cluster.ts +0 -113
  44. package/dist/extensions/pi-powerline-footer/fixed-editor/terminal-split.ts +0 -1080
  45. package/dist/extensions/pi-powerline-footer/tests/fixed-editor.test.ts +0 -1452
  46. package/dist/extensions/pi-powerline-footer/tests/jump-shortcuts.test.ts +0 -49
@@ -1,1452 +0,0 @@
1
- import test from "node:test";
2
- import assert from "node:assert/strict";
3
- import { TUI, visibleWidth } from "@earendil-works/pi-tui";
4
- import { CURSOR_MARKER, renderFixedEditorCluster } from "../fixed-editor/cluster.ts";
5
- import {
6
- buildFixedClusterPaint,
7
- emergencyTerminalModeReset,
8
- endSynchronizedOutput,
9
- beginSynchronizedOutput,
10
- moveCursor,
11
- resetScrollRegion,
12
- setScrollRegion,
13
- TerminalSplitCompositor,
14
- } from "../fixed-editor/terminal-split.ts";
15
-
16
- class FakeTerminal {
17
- columns = 40;
18
- private rowCount = 12;
19
- writes: string[] = [];
20
-
21
- get rows(): number {
22
- return this.rowCount;
23
- }
24
-
25
- setRows(rows: number): void {
26
- this.rowCount = rows;
27
- }
28
-
29
- write(data: string): void {
30
- this.writes.push(data);
31
- }
32
-
33
- hideCursor(): void {}
34
-
35
- showCursor(): void {}
36
- }
37
-
38
- test("fixed cluster keeps the editor visible before optional rows", () => {
39
- const rendered = renderFixedEditorCluster({
40
- width: 80,
41
- terminalRows: 6,
42
- statusLines: ["status"],
43
- topLines: ["top"],
44
- editorLines: ["edit-a", `edit-b ${CURSOR_MARKER}`, "edit-c"],
45
- secondaryLines: ["secondary"],
46
- transcriptLines: ["old-1", "old-2"],
47
- lastPromptLines: ["last"],
48
- });
49
-
50
- assert.deepEqual(rendered.lines, ["top", "edit-a", "edit-b ", "edit-c", "secondary"]);
51
- assert.deepEqual(rendered.cursor, { row: 2, col: 7 });
52
- });
53
-
54
- test("fixed cluster caps oversized editor around the cursor", () => {
55
- const rendered = renderFixedEditorCluster({
56
- width: 80,
57
- terminalRows: 4,
58
- statusLines: ["status"],
59
- editorLines: ["edit-a", "edit-b", `edit-c ${CURSOR_MARKER}`, "edit-d", "edit-e"],
60
- transcriptLines: ["old"],
61
- });
62
-
63
- assert.deepEqual(rendered.lines, ["edit-a", "edit-b", "edit-c "]);
64
- assert.deepEqual(rendered.cursor, { row: 2, col: 7 });
65
- });
66
-
67
- test("fixed cluster caps selector-style editor replacements around the selected row", () => {
68
- const rendered = renderFixedEditorCluster({
69
- width: 80,
70
- terminalRows: 4,
71
- editorLines: [
72
- "title",
73
- " option-a",
74
- " option-b",
75
- "\x1b[38;5;39m→ \x1b[0m\x1b[38;5;39moption-c\x1b[0m",
76
- " option-d",
77
- "hint",
78
- ],
79
- });
80
-
81
- assert.deepEqual(rendered.lines, [" option-b", "\x1b[38;5;39m→ \x1b[0m\x1b[38;5;39moption-c\x1b[0m", " option-d"]);
82
- });
83
-
84
- test("fixed cluster keeps tail status lines when compact", () => {
85
- const rendered = renderFixedEditorCluster({
86
- width: 80,
87
- terminalRows: 3,
88
- statusLines: ["above-widget", "powerline-status", "⠏ Shaolin Switchblade Sync..."],
89
- editorLines: ["edit"],
90
- });
91
-
92
- assert.deepEqual(rendered.lines, ["⠏ Shaolin Switchblade Sync...", "edit"]);
93
- });
94
-
95
- test("terminal split can render a hidden status container in the fixed cluster", () => {
96
- const terminal = new FakeTerminal();
97
- const status = {
98
- text: "⠏ Shaolin Switchblade Sync...",
99
- render() {
100
- return ["", this.text];
101
- },
102
- };
103
- const editor = {
104
- render() {
105
- return ["editor"];
106
- },
107
- };
108
- const tui = {
109
- terminal,
110
- render() {
111
- return ["chat"];
112
- },
113
- doRender() {
114
- this.terminal.write("body");
115
- },
116
- };
117
-
118
- const compositor = new TerminalSplitCompositor({
119
- tui,
120
- terminal,
121
- getShowHardwareCursor: () => false,
122
- renderCluster: (width) => ({
123
- lines: [
124
- ...compositor.renderHidden(status, width).filter((line) => visibleWidth(line) > 0),
125
- ...compositor.renderHidden(editor, width),
126
- ],
127
- cursor: null,
128
- }),
129
- });
130
-
131
- compositor.hideRenderable(status);
132
- compositor.hideRenderable(editor);
133
- compositor.install();
134
-
135
- assert.deepEqual(status.render(), []);
136
- tui.doRender();
137
- assert.ok(terminal.writes.at(-1)?.includes("⠏ Shaolin Switchblade Sync..."));
138
-
139
- status.text = "⠙ Shaolin Switchblade Sync...";
140
- compositor.requestRepaint();
141
- assert.ok(terminal.writes.at(-1)?.includes("⠙ Shaolin Switchblade Sync..."));
142
-
143
- compositor.dispose();
144
- assert.deepEqual(status.render(), ["", "⠙ Shaolin Switchblade Sync..."]);
145
- });
146
-
147
- test("terminal split escape helpers generate DEC scroll region controls", () => {
148
- assert.equal(beginSynchronizedOutput(), "\x1b[?2026h");
149
- assert.equal(endSynchronizedOutput(), "\x1b[?2026l");
150
- assert.equal(setScrollRegion(1, 18), "\x1b[1;18r");
151
- assert.equal(resetScrollRegion(), "\x1b[r");
152
- assert.equal(moveCursor(20, 3), "\x1b[20;3H");
153
- });
154
-
155
- test("fixed cluster paint clears bottom rows and positions hardware cursor", () => {
156
- const paint = buildFixedClusterPaint(
157
- { lines: ["top", "edit"], cursor: { row: 1, col: 2 } },
158
- 10,
159
- 20,
160
- true,
161
- );
162
-
163
- assert.match(paint, /^\x1b\[r/);
164
- assert.ok(paint.includes("\x1b[9;1H\x1b[2Ktop"));
165
- assert.ok(paint.includes("\x1b[10;1H\x1b[2Kedit"));
166
- assert.ok(paint.endsWith("\x1b[10;3H\x1b[?25h"));
167
- });
168
-
169
- test("terminal split reserves rows, hides root renderables, repaints, and cleans up", () => {
170
- const terminal = new FakeTerminal();
171
- const hidden = {
172
- render(width: number) {
173
- return [`hidden:${width}`];
174
- },
175
- };
176
- const tui = {
177
- terminal,
178
- hardwareCursorRow: 2,
179
- cursorRow: 2,
180
- previousViewportTop: 0,
181
- rendered: 0,
182
- doRender() {
183
- this.rendered += 1;
184
- this.terminal.write("body");
185
- },
186
- };
187
-
188
- const compositor = new TerminalSplitCompositor({
189
- tui,
190
- terminal,
191
- getShowHardwareCursor: () => false,
192
- renderCluster: (width) => ({
193
- lines: [`cluster:${width}`, ...compositor.renderHidden(hidden, width)],
194
- cursor: null,
195
- }),
196
- });
197
-
198
- compositor.hideRenderable(hidden);
199
- compositor.install();
200
-
201
- assert.deepEqual(hidden.render(40), []);
202
- assert.equal(terminal.rows, 10);
203
-
204
- tui.doRender();
205
-
206
- assert.equal(tui.rendered, 1);
207
- assert.equal(terminal.writes.length, 3);
208
- assert.ok(terminal.writes[0]?.includes("\x1b[?1049h"));
209
- assert.ok(terminal.writes[0]?.includes("\x1b[?1007l"));
210
- assert.ok(terminal.writes[0]?.includes("\x1b[?1002h"));
211
- assert.ok(terminal.writes[0]?.includes("\x1b[?1006h"));
212
- assert.ok(terminal.writes[1]?.includes("\x1b[1;10r\x1b[3;1Hbody"));
213
- assert.ok(terminal.writes[1]?.includes("cluster:40"));
214
- assert.ok(terminal.writes[1]?.includes("hidden:40"));
215
- assert.ok(terminal.writes[2]?.includes("cluster:40"));
216
-
217
- compositor.dispose();
218
-
219
- assert.deepEqual(hidden.render(8), ["hidden:8"]);
220
- assert.equal(terminal.rows, 12);
221
- assert.ok(terminal.writes.at(-1)?.includes("\x1b[r"));
222
- assert.ok(terminal.writes.at(-1)?.includes("\x1b[?1006l"));
223
- assert.ok(terminal.writes.at(-1)?.includes("\x1b[?1002l"));
224
- assert.ok(terminal.writes.at(-1)?.includes("\x1b[?1000l"));
225
- assert.ok(terminal.writes.at(-1)?.includes("\x1b[?1007h"));
226
- assert.ok(terminal.writes.at(-1)?.includes("\x1b[?1049l"));
227
- assert.ok(!terminal.writes.at(-1)?.includes("\x1b[<u"));
228
- assert.ok(!terminal.writes.at(-1)?.includes("\x1b[>4;0m"));
229
- });
230
-
231
- test("terminal split re-enables Kitty keyboard protocol in alternate screen", () => {
232
- const terminal = new FakeTerminal();
233
- Object.defineProperty(terminal, "kittyProtocolActive", { value: true });
234
- const compositor = new TerminalSplitCompositor({
235
- tui: { terminal },
236
- terminal,
237
- renderCluster: () => ({ lines: ["cluster"], cursor: null }),
238
- });
239
-
240
- compositor.install();
241
-
242
- const setup = terminal.writes[0] ?? "";
243
- assert.ok(setup.includes("\x1b[?1049h"));
244
- assert.ok(setup.includes("\x1b[>7u"));
245
- assert.ok(setup.indexOf("\x1b[?1049h") < setup.indexOf("\x1b[>7u"));
246
-
247
- compositor.dispose();
248
-
249
- const cleanup = terminal.writes.at(-1) ?? "";
250
- assert.ok(cleanup.includes("\x1b[<u"));
251
- assert.ok(cleanup.indexOf("\x1b[<u") < cleanup.indexOf("\x1b[?1049l"));
252
- assert.ok(!cleanup.includes("\x1b[<999u"));
253
- });
254
-
255
- test("terminal split re-enables modifyOtherKeys in alternate screen", () => {
256
- const terminal = new FakeTerminal();
257
- Reflect.set(terminal, "_modifyOtherKeysActive", true);
258
- const compositor = new TerminalSplitCompositor({
259
- tui: { terminal },
260
- terminal,
261
- renderCluster: () => ({ lines: ["cluster"], cursor: null }),
262
- });
263
-
264
- compositor.install();
265
-
266
- const setup = terminal.writes[0] ?? "";
267
- assert.ok(setup.includes("\x1b[?1049h"));
268
- assert.ok(setup.includes("\x1b[>4;2m"));
269
- assert.ok(setup.indexOf("\x1b[?1049h") < setup.indexOf("\x1b[>4;2m"));
270
-
271
- compositor.dispose();
272
-
273
- const cleanup = terminal.writes.at(-1) ?? "";
274
- assert.ok(cleanup.includes("\x1b[>4;0m"));
275
- assert.ok(cleanup.indexOf("\x1b[>4;0m") < cleanup.indexOf("\x1b[?1049l"));
276
- assert.ok(!cleanup.includes("\x1b[<999u"));
277
- });
278
-
279
- test("terminal split restores main screen mode when Kitty activates after install", () => {
280
- const terminal = new FakeTerminal();
281
- const compositor = new TerminalSplitCompositor({
282
- tui: { terminal },
283
- terminal,
284
- renderCluster: () => ({ lines: ["cluster"], cursor: null }),
285
- });
286
-
287
- compositor.install();
288
- terminal.kittyProtocolActive = true;
289
- compositor.dispose();
290
-
291
- const cleanup = terminal.writes.at(-1) ?? "";
292
- assert.ok(cleanup.includes("\x1b[<u"));
293
- assert.ok(cleanup.includes("\x1b[>7u"));
294
- assert.ok(cleanup.indexOf("\x1b[<u") < cleanup.indexOf("\x1b[?1049l"));
295
- assert.ok(cleanup.indexOf("\x1b[?1049l") < cleanup.indexOf("\x1b[>7u"));
296
- });
297
-
298
- test("terminal split restores main screen mode when modifyOtherKeys activates after install", () => {
299
- const terminal = new FakeTerminal();
300
- const compositor = new TerminalSplitCompositor({
301
- tui: { terminal },
302
- terminal,
303
- renderCluster: () => ({ lines: ["cluster"], cursor: null }),
304
- });
305
-
306
- compositor.install();
307
- Reflect.set(terminal, "_modifyOtherKeysActive", true);
308
- compositor.dispose();
309
-
310
- const cleanup = terminal.writes.at(-1) ?? "";
311
- assert.ok(cleanup.includes("\x1b[>4;0m"));
312
- assert.ok(cleanup.includes("\x1b[>4;2m"));
313
- assert.ok(cleanup.indexOf("\x1b[>4;0m") < cleanup.indexOf("\x1b[?1049l"));
314
- assert.ok(cleanup.indexOf("\x1b[?1049l") < cleanup.indexOf("\x1b[>4;2m"));
315
- });
316
-
317
- test("terminal split shutdown cleanup resets extended keyboard modes", () => {
318
- const terminal = new FakeTerminal();
319
- const compositor = new TerminalSplitCompositor({
320
- tui: { terminal },
321
- terminal,
322
- renderCluster: () => ({ lines: ["cluster"], cursor: null }),
323
- });
324
-
325
- compositor.install();
326
- compositor.dispose({ resetExtendedKeyboardModes: true });
327
-
328
- const cleanup = terminal.writes.at(-1) ?? "";
329
- assert.ok(cleanup.includes("\x1b[<999u"));
330
- assert.ok(cleanup.includes("\x1b[>4;0m"));
331
- assert.ok(cleanup.indexOf("\x1b[?1049l") < cleanup.indexOf("\x1b[<999u"));
332
- });
333
-
334
- test("terminal row reservation does not recurse when hidden editor render reads terminal rows", () => {
335
- const terminal = new FakeTerminal();
336
- const tui = { terminal };
337
- const hidden = {
338
- render() {
339
- return [`rows:${terminal.rows}`];
340
- },
341
- };
342
-
343
- const compositor = new TerminalSplitCompositor({
344
- tui,
345
- terminal,
346
- renderCluster: (width) => ({
347
- lines: compositor.renderHidden(hidden, width),
348
- cursor: null,
349
- }),
350
- });
351
-
352
- compositor.hideRenderable(hidden);
353
- compositor.install();
354
-
355
- assert.equal(terminal.rows, 11);
356
- compositor.requestRepaint();
357
- assert.ok(terminal.writes.at(-1)?.includes("rows:12"));
358
-
359
- compositor.dispose();
360
- });
361
-
362
- test("terminal split anchors diff writes to the visible viewport row", () => {
363
- const terminal = new FakeTerminal();
364
- const tui = {
365
- terminal,
366
- hardwareCursorRow: 100,
367
- cursorRow: 100,
368
- previousViewportTop: 95,
369
- };
370
-
371
- const compositor = new TerminalSplitCompositor({
372
- tui,
373
- terminal,
374
- renderCluster: () => ({ lines: ["cluster-a", "cluster-b"], cursor: null }),
375
- });
376
-
377
- compositor.install();
378
- terminal.write("diff");
379
-
380
- assert.ok(terminal.writes[1]?.includes("\x1b[1;10r\x1b[6;1Hdiff"));
381
-
382
- compositor.dispose();
383
- });
384
-
385
- test("terminal split does not repaint the fixed cluster over visible overlays", () => {
386
- const terminal = new FakeTerminal();
387
- const tui = {
388
- terminal,
389
- overlayStack: [{}],
390
- rendered: 0,
391
- doRender() {
392
- this.rendered += 1;
393
- this.terminal.write("overlay-frame");
394
- },
395
- };
396
-
397
- const compositor = new TerminalSplitCompositor({
398
- tui,
399
- terminal,
400
- renderCluster: () => ({ lines: ["cluster"], cursor: null }),
401
- });
402
-
403
- compositor.install();
404
- tui.doRender();
405
- compositor.requestRepaint();
406
-
407
- assert.deepEqual(terminal.writes, [
408
- "\x1b[?2026h\x1b[?1049h\x1b[?1007l\x1b[?1002h\x1b[?1006h\x1b[?2026l",
409
- "overlay-frame",
410
- ]);
411
-
412
- compositor.dispose();
413
- });
414
-
415
- test("terminal split strips OSC markers from root lines while overlays are visible", () => {
416
- const terminal = new FakeTerminal();
417
- terminal.columns = 20;
418
- const tui = {
419
- terminal,
420
- overlayStack: [{}],
421
- render() {
422
- return ["\x1b]133;B\x07" + "x".repeat(20) + "\x1b]133;C\x07"];
423
- },
424
- };
425
-
426
- const compositor = new TerminalSplitCompositor({
427
- tui,
428
- terminal,
429
- renderCluster: () => ({ lines: ["cluster"], cursor: null }),
430
- });
431
-
432
- compositor.install();
433
- const rendered = tui.render(20);
434
-
435
- assert.equal(rendered.length, 1);
436
- assert.equal(visibleWidth(rendered[0] ?? ""), 20);
437
- assert.doesNotMatch(rendered[0] ?? "", /\]133/);
438
-
439
- compositor.dispose();
440
- });
441
-
442
- test("terminal split keeps tabbed overlay composition within terminal width", () => {
443
- const terminal = new FakeTerminal();
444
- terminal.columns = 250;
445
- terminal.setRows(40);
446
- const tui = new TUI(terminal, false);
447
- const overlay = "\x1b[38;2;119;125;136m[grep]: render.ts-706- \treturn [...lines.slice(0, visibleLines), truncLine(theme.fg(\"dim\", hint), width)];\x1b[39m";
448
-
449
- const before = tui.compositeLineAt("Validation before " + " ".repeat(232), overlay, 20, 210, 250);
450
- assert.ok(visibleWidth(before) > 250);
451
-
452
- const compositor = new TerminalSplitCompositor({
453
- tui,
454
- terminal,
455
- renderCluster: () => ({ lines: ["cluster"], cursor: null }),
456
- });
457
-
458
- compositor.install();
459
- const after = tui.compositeLineAt("Validation before " + " ".repeat(232), overlay, 20, 210, 250);
460
-
461
- assert.ok(visibleWidth(after) <= 250);
462
- assert.doesNotMatch(after, /\t/);
463
-
464
- compositor.dispose();
465
- });
466
-
467
- test("terminal split renders chat through an app-owned scroll viewport", () => {
468
- const terminal = new FakeTerminal();
469
- let inputListener: ((data: string) => { consume?: boolean; data?: string } | undefined) | null = null;
470
- const renderRequests: Array<boolean | undefined> = [];
471
- let rootLines = Array.from({ length: 15 }, (_, index) => `line-${index}`);
472
- const tui = {
473
- terminal,
474
- addInputListener(listener: (data: string) => { consume?: boolean; data?: string } | undefined) {
475
- inputListener = listener;
476
- return () => {
477
- inputListener = null;
478
- };
479
- },
480
- requestRender(force?: boolean) {
481
- renderRequests.push(force);
482
- },
483
- render() {
484
- return rootLines;
485
- },
486
- };
487
-
488
- const compositor = new TerminalSplitCompositor({
489
- tui,
490
- terminal,
491
- renderCluster: () => ({ lines: ["cluster-a", "cluster-b"], cursor: null }),
492
- });
493
-
494
- compositor.install();
495
-
496
- assert.equal(terminal.rows, 10);
497
- assert.deepEqual(tui.render(40), [
498
- "line-5", "line-6", "line-7", "line-8", "line-9",
499
- "line-10", "line-11", "line-12", "line-13", "line-14",
500
- ]);
501
-
502
- assert.deepEqual(inputListener?.("\x1b[<2;1;1M"), { consume: true });
503
- assert.deepEqual(inputListener?.("\x1bOA"), undefined);
504
- assert.deepEqual(inputListener?.("\x1bOB"), undefined);
505
- assert.deepEqual(inputListener?.("\x1b[A"), undefined);
506
- assert.deepEqual(renderRequests, []);
507
- assert.deepEqual(inputListener?.("\x1b[<64;1;1M"), { consume: true });
508
- assert.deepEqual(renderRequests, [undefined]);
509
- assert.deepEqual(tui.render(40), [
510
- "line-2", "line-3", "line-4", "line-5", "line-6",
511
- "line-7", "line-8", "line-9", "line-10", "line-11",
512
- ]);
513
-
514
- rootLines = [...rootLines, "line-15"];
515
- assert.deepEqual(tui.render(40), [
516
- "line-2", "line-3", "line-4", "line-5", "line-6",
517
- "line-7", "line-8", "line-9", "line-10", "line-11",
518
- ]);
519
-
520
- assert.deepEqual(inputListener?.("\x1b[<65;1;1M"), { consume: true });
521
- assert.deepEqual(renderRequests, [undefined, undefined]);
522
- assert.deepEqual(tui.render(40), [
523
- "line-5", "line-6", "line-7", "line-8", "line-9",
524
- "line-10", "line-11", "line-12", "line-13", "line-14",
525
- ]);
526
-
527
- assert.deepEqual(inputListener?.("\x1b[5~"), { consume: true });
528
- assert.deepEqual(renderRequests, [undefined, undefined, undefined]);
529
- assert.deepEqual(tui.render(40), [
530
- "line-0", "line-1", "line-2", "line-3", "line-4",
531
- "line-5", "line-6", "line-7", "line-8", "line-9",
532
- ]);
533
-
534
- compositor.dispose();
535
- assert.equal(inputListener, null);
536
- });
537
-
538
- test("terminal split refreshes scroll bounds after fixed status rows appear", () => {
539
- const terminal = new FakeTerminal();
540
- let inputListener: ((data: string) => { consume?: boolean; data?: string } | undefined) | null = null;
541
- const renderRequests: Array<boolean | undefined> = [];
542
- const rootLines = Array.from({ length: 11 }, (_, index) => `line-${index}`);
543
- let statusVisible = false;
544
- const tui = {
545
- terminal,
546
- addInputListener(listener: (data: string) => { consume?: boolean; data?: string } | undefined) {
547
- inputListener = listener;
548
- return () => {
549
- inputListener = null;
550
- };
551
- },
552
- requestRender(force?: boolean) {
553
- renderRequests.push(force);
554
- },
555
- render() {
556
- return rootLines;
557
- },
558
- };
559
-
560
- const compositor = new TerminalSplitCompositor({
561
- tui,
562
- terminal,
563
- renderCluster: () => ({
564
- lines: statusVisible ? ["⠏ fixed status", "editor"] : ["editor"],
565
- cursor: null,
566
- }),
567
- });
568
-
569
- compositor.install();
570
-
571
- assert.deepEqual(tui.render(40), rootLines);
572
-
573
- statusVisible = true;
574
- compositor.requestRepaint();
575
-
576
- assert.deepEqual(inputListener?.("\x1b[<64;1;1M"), { consume: true });
577
- assert.deepEqual(renderRequests, [undefined]);
578
- assert.match(terminal.writes.at(-1) ?? "", /line-0/);
579
- assert.match(terminal.writes.at(-1) ?? "", /⠏ fixed status/);
580
- assert.deepEqual(tui.render(40), [
581
- "line-0", "line-1", "line-2", "line-3", "line-4",
582
- "line-5", "line-6", "line-7", "line-8", "line-9",
583
- ]);
584
-
585
- compositor.dispose();
586
- });
587
-
588
- test("terminal split handles modified SGR wheel packets", () => {
589
- const terminal = new FakeTerminal();
590
- let inputListener: ((data: string) => { consume?: boolean; data?: string } | undefined) | null = null;
591
- const renderRequests: Array<boolean | undefined> = [];
592
- const tui = {
593
- terminal,
594
- addInputListener(listener: (data: string) => { consume?: boolean; data?: string } | undefined) {
595
- inputListener = listener;
596
- return () => {
597
- inputListener = null;
598
- };
599
- },
600
- requestRender(force?: boolean) {
601
- renderRequests.push(force);
602
- },
603
- render() {
604
- return Array.from({ length: 15 }, (_, index) => `line-${index}`);
605
- },
606
- };
607
-
608
- const compositor = new TerminalSplitCompositor({
609
- tui,
610
- terminal,
611
- renderCluster: () => ({ lines: ["cluster-a", "cluster-b"], cursor: null }),
612
- });
613
-
614
- compositor.install();
615
- tui.render(40);
616
-
617
- assert.deepEqual(inputListener?.("\x1b[<68;1;1M"), { consume: true });
618
- assert.deepEqual(renderRequests, [undefined]);
619
- assert.deepEqual(tui.render(40), [
620
- "line-2", "line-3", "line-4", "line-5", "line-6",
621
- "line-7", "line-8", "line-9", "line-10", "line-11",
622
- ]);
623
-
624
- assert.deepEqual(inputListener?.("\x1b[<68;1;1M\x1b[<68;1;1M"), { consume: true });
625
- assert.deepEqual(renderRequests, [undefined, undefined]);
626
- assert.deepEqual(tui.render(40), [
627
- "line-0", "line-1", "line-2", "line-3", "line-4",
628
- "line-5", "line-6", "line-7", "line-8", "line-9",
629
- ]);
630
-
631
- assert.deepEqual(inputListener?.("\x1b[<69;1;1M"), { consume: true });
632
- assert.deepEqual(renderRequests, [undefined, undefined, undefined]);
633
-
634
- compositor.dispose();
635
- });
636
-
637
- test("terminal split pauses mouse reporting on right click for the terminal context menu", () => {
638
- const terminal = new FakeTerminal();
639
- let inputListener: ((data: string) => { consume?: boolean; data?: string } | undefined) | null = null;
640
- const renderRequests: Array<boolean | undefined> = [];
641
- const copied: string[] = [];
642
- const tui = {
643
- terminal,
644
- addInputListener(listener: (data: string) => { consume?: boolean; data?: string } | undefined) {
645
- inputListener = listener;
646
- return () => {
647
- inputListener = null;
648
- };
649
- },
650
- requestRender(force?: boolean) {
651
- renderRequests.push(force);
652
- },
653
- render() {
654
- return Array.from({ length: 20 }, (_, index) => `line-${index}`);
655
- },
656
- };
657
-
658
- const compositor = new TerminalSplitCompositor({
659
- tui,
660
- terminal,
661
- onCopySelection: (text) => copied.push(text),
662
- renderCluster: () => ({ lines: ["cluster-a", "cluster-b"], cursor: null }),
663
- });
664
-
665
- compositor.install();
666
- tui.render(40);
667
-
668
- assert.deepEqual(inputListener?.("\x1b[<0;5;5M"), { consume: true });
669
- assert.deepEqual(inputListener?.("\x1b[<0;5;5m"), { consume: true });
670
- assert.deepEqual(inputListener?.("\x1b[<2;5;5M"), { consume: true });
671
- assert.ok(terminal.writes.at(-1)?.includes("\x1b[?1006l\x1b[?1002l\x1b[?1000l"));
672
- assert.deepEqual(inputListener?.("\x1b[<0;5;5M"), { consume: true });
673
- assert.deepEqual(inputListener?.("\x1b[<0;5;5m"), { consume: true });
674
- assert.deepEqual(copied, []);
675
- assert.deepEqual(renderRequests, [undefined, undefined, undefined, undefined]);
676
-
677
- compositor.dispose();
678
- });
679
-
680
- test("terminal split selects visible chat text and copies it on drag release", () => {
681
- const terminal = new FakeTerminal();
682
- let inputListener: ((data: string) => { consume?: boolean; data?: string } | undefined) | null = null;
683
- const renderRequests: Array<boolean | undefined> = [];
684
- const copied: string[] = [];
685
- const rootLines = [
686
- "old-0", "old-1", "old-2", "old-3", "old-4",
687
- "alpha one", "bravo two", "charlie three", "delta four", "echo five",
688
- "foxtrot six", "golf seven", "hotel eight", "india nine", "juliet ten",
689
- ];
690
- const tui = {
691
- terminal,
692
- addInputListener(listener: (data: string) => { consume?: boolean; data?: string } | undefined) {
693
- inputListener = listener;
694
- return () => {
695
- inputListener = null;
696
- };
697
- },
698
- requestRender(force?: boolean) {
699
- renderRequests.push(force);
700
- },
701
- render() {
702
- return rootLines;
703
- },
704
- };
705
-
706
- const compositor = new TerminalSplitCompositor({
707
- tui,
708
- terminal,
709
- onCopySelection: (text) => copied.push(text),
710
- renderCluster: () => ({ lines: ["cluster-a", "cluster-b"], cursor: null }),
711
- });
712
-
713
- compositor.install();
714
- assert.deepEqual(tui.render(40), [
715
- "alpha one", "bravo two", "charlie three", "delta four", "echo five",
716
- "foxtrot six", "golf seven", "hotel eight", "india nine", "juliet ten",
717
- ]);
718
-
719
- assert.deepEqual(inputListener?.("\x1b[<0;2;2M"), { consume: true });
720
- assert.deepEqual(inputListener?.("\x1b[<32;7;4M"), { consume: true });
721
- assert.deepEqual(tui.render(40).slice(1, 4), [
722
- "b\x1b[7mravo two\x1b[27m",
723
- "\x1b[7mcharlie three\x1b[27m",
724
- "\x1b[7mdelta \x1b[27mfour",
725
- ]);
726
- assert.deepEqual(inputListener?.("\x1b[<0;7;4m"), { consume: true });
727
-
728
- assert.deepEqual(copied, ["ravo two\ncharlie three\ndelta"]);
729
- assert.ok(!terminal.writes.at(-1)?.includes("\x1b[?1006l\x1b[?1002l\x1b[?1000l"));
730
- assert.deepEqual(renderRequests, [undefined, undefined, undefined]);
731
-
732
- compositor.dispose();
733
- });
734
-
735
- test("terminal split restores app-owned selection after context menu copy", (t) => {
736
- t.mock.timers.enable({ apis: ["setTimeout"] });
737
-
738
- const terminal = new FakeTerminal();
739
- let inputListener: ((data: string) => { consume?: boolean; data?: string } | undefined) | null = null;
740
- let clipboard = "";
741
- const rootLines = [
742
- "old-0", "old-1", "old-2", "old-3", "old-4",
743
- "alpha one", "bravo two", "charlie three", "delta four", "echo five",
744
- "foxtrot six", "golf seven", "hotel eight", "india nine", "juliet ten",
745
- ];
746
- const tui = {
747
- terminal,
748
- addInputListener(listener: (data: string) => { consume?: boolean; data?: string } | undefined) {
749
- inputListener = listener;
750
- return () => {
751
- inputListener = null;
752
- };
753
- },
754
- requestRender() {},
755
- render(_width?: number) {
756
- return rootLines;
757
- },
758
- };
759
-
760
- const compositor = new TerminalSplitCompositor({
761
- tui,
762
- terminal,
763
- onCopySelection: (text) => {
764
- clipboard = text;
765
- },
766
- renderCluster: () => ({ lines: ["cluster-a", "cluster-b"], cursor: null }),
767
- });
768
-
769
- compositor.install();
770
- tui.render(40);
771
-
772
- assert.deepEqual(inputListener?.("\x1b[<0;2;2M"), { consume: true });
773
- assert.deepEqual(inputListener?.("\x1b[<32;7;4M"), { consume: true });
774
- assert.deepEqual(inputListener?.("\x1b[<0;7;4m"), { consume: true });
775
- assert.equal(clipboard, "ravo two\ncharlie three\ndelta");
776
-
777
- clipboard = "clicked-word";
778
- assert.deepEqual(inputListener?.("\x1b[<2;4;3M"), { consume: true });
779
- assert.equal(clipboard, "ravo two\ncharlie three\ndelta");
780
- assert.ok(tui.render(40)[1]?.includes("b\x1b[7mravo two\x1b[27m"));
781
- assert.ok(terminal.writes.at(-1)?.includes("\x1b[?1006l\x1b[?1002l\x1b[?1000l"));
782
-
783
- clipboard = "terminal-clicked-word-copy";
784
- t.mock.timers.tick(1200);
785
- assert.equal(clipboard, "ravo two\ncharlie three\ndelta");
786
- assert.ok(terminal.writes.at(-1)?.includes("\x1b[?1002h\x1b[?1006h"));
787
-
788
- clipboard = "late-terminal-clicked-word-copy";
789
- t.mock.timers.tick(100);
790
- assert.equal(clipboard, "ravo two\ncharlie three\ndelta");
791
-
792
- clipboard = "other-copy";
793
- assert.deepEqual(inputListener?.("\x1b[<2;5;5M"), { consume: true });
794
- assert.equal(clipboard, "other-copy");
795
- t.mock.timers.tick(1200);
796
- assert.equal(clipboard, "other-copy");
797
- assert.ok(!tui.render(40)[1]?.includes("\x1b[7mravo two\x1b[27m"));
798
-
799
- compositor.dispose();
800
- });
801
-
802
- test("terminal split selection does not expose OSC control sequences as text", () => {
803
- const terminal = new FakeTerminal();
804
- terminal.columns = 20;
805
- let inputListener: ((data: string) => { consume?: boolean; data?: string } | undefined) | null = null;
806
- const rootLines = [
807
- "old-0", "old-1", "old-2", "old-3", "old-4",
808
- "alpha", "bravo", "charlie", "delta", "echo",
809
- "foxtrot", "golf", "hotel", "india", "\x1b]133;B\x07\x1b]133;C\x07juliet",
810
- ];
811
- const tui = {
812
- terminal,
813
- addInputListener(listener: (data: string) => { consume?: boolean; data?: string } | undefined) {
814
- inputListener = listener;
815
- return () => {
816
- inputListener = null;
817
- };
818
- },
819
- requestRender() {},
820
- render() {
821
- return rootLines;
822
- },
823
- };
824
-
825
- const compositor = new TerminalSplitCompositor({
826
- tui,
827
- terminal,
828
- renderCluster: () => ({ lines: ["cluster-a", "cluster-b"], cursor: null }),
829
- });
830
-
831
- compositor.install();
832
- tui.render(20);
833
-
834
- assert.deepEqual(inputListener?.("\x1b[<0;1;10M"), { consume: true });
835
- assert.deepEqual(inputListener?.("\x1b[<32;6;10M"), { consume: true });
836
- const selectedLine = tui.render(20).at(-1) ?? "";
837
-
838
- assert.ok(visibleWidth(selectedLine) <= 20);
839
- assert.ok(!selectedLine.includes("]133"));
840
- assert.ok(selectedLine.includes("\x1b[7mjulie\x1b[27mt"));
841
-
842
- compositor.dispose();
843
- });
844
-
845
- test("terminal split selection highlighting does not duplicate wide glyphs", () => {
846
- for (const glyph of ["🪃", "👨‍👩‍👧‍👦"]) {
847
- const terminal = new FakeTerminal();
848
- terminal.columns = 30;
849
- let inputListener: ((data: string) => { consume?: boolean; data?: string } | undefined) | null = null;
850
- const prefix = "Done shows ";
851
- const line = `${prefix}${glyph} auto${" ".repeat(30 - visibleWidth(`${prefix}${glyph} auto`))}`;
852
- const tui = {
853
- terminal,
854
- addInputListener(listener: (data: string) => { consume?: boolean; data?: string } | undefined) {
855
- inputListener = listener;
856
- return () => {
857
- inputListener = null;
858
- };
859
- },
860
- requestRender() {},
861
- render() {
862
- return ["old-0", "old-1", "old-2", "old-3", "old-4", "old-5", "old-6", "old-7", "old-8", line];
863
- },
864
- };
865
-
866
- const compositor = new TerminalSplitCompositor({
867
- tui,
868
- terminal,
869
- renderCluster: () => ({ lines: ["cluster-a", "cluster-b"], cursor: null }),
870
- });
871
-
872
- compositor.install();
873
- tui.render(30);
874
-
875
- const glyphCol = visibleWidth(prefix);
876
- assert.deepEqual(inputListener?.(`\x1b[<0;${glyphCol + 1};10M`), { consume: true });
877
- assert.deepEqual(inputListener?.(`\x1b[<32;${glyphCol + 2};10M`), { consume: true });
878
- const selectedLine = tui.render(30).at(-1) ?? "";
879
-
880
- assert.ok(visibleWidth(selectedLine) <= 30);
881
- assert.equal(selectedLine.includes(glyph), true);
882
- assert.ok(selectedLine.includes(`\x1b[7m${glyph}\x1b[27m`));
883
-
884
- compositor.dispose();
885
- }
886
- });
887
-
888
- test("terminal split copies chat and fixed cluster selections", () => {
889
- const terminal = new FakeTerminal();
890
- let inputListener: ((data: string) => { consume?: boolean; data?: string } | undefined) | null = null;
891
- const copied: string[] = [];
892
- const rootLines = [
893
- "old-0", "old-1", "old-2", "old-3", "old-4",
894
- "alpha one", "bravo two", "charlie three", "delta four", "echo five",
895
- "foxtrot six", "golf seven", "hotel eight", "india nine", "juliet ten",
896
- ];
897
- const tui = {
898
- terminal,
899
- addInputListener(listener: (data: string) => { consume?: boolean; data?: string } | undefined) {
900
- inputListener = listener;
901
- return () => {
902
- inputListener = null;
903
- };
904
- },
905
- requestRender() {},
906
- render() {
907
- return rootLines;
908
- },
909
- };
910
-
911
- const compositor = new TerminalSplitCompositor({
912
- tui,
913
- terminal,
914
- onCopySelection: (text) => copied.push(text),
915
- renderCluster: () => ({ lines: ["cluster-a", " > hello world"], cursor: null }),
916
- });
917
-
918
- compositor.install();
919
- tui.render(40);
920
-
921
- assert.deepEqual(inputListener?.("\x1b[<0;1;9M"), { consume: true });
922
- assert.deepEqual(inputListener?.("\x1b[<0;5;11m"), { consume: true });
923
- assert.deepEqual(copied, ["india nine\njuli"]);
924
-
925
- assert.deepEqual(inputListener?.("\x1b[<0;5;12M"), { consume: true });
926
- assert.deepEqual(inputListener?.("\x1b[<32;10;12M"), { consume: true });
927
- compositor.requestRepaint();
928
- assert.ok(terminal.writes.at(-1)?.includes(" > \x1b[7mhello\x1b[27m world"));
929
- assert.deepEqual(inputListener?.("\x1b[<0;10;12m"), { consume: true });
930
- assert.deepEqual(copied, ["india nine\njuli", "hello"]);
931
-
932
- assert.deepEqual(inputListener?.("\x1b[<0;8;12M"), { consume: true });
933
- assert.deepEqual(inputListener?.("\x1b[<0;8;12m"), { consume: true });
934
- assert.deepEqual(copied, ["india nine\njuli", "hello"]);
935
-
936
- assert.deepEqual(inputListener?.("\x1b[<0;4;3M"), { consume: true });
937
- assert.deepEqual(inputListener?.("\x1b[<0;4;3m"), { consume: true });
938
- assert.deepEqual(inputListener?.("\x1b[<0;5;3M"), { consume: true });
939
- assert.ok(tui.render(40)[2]?.includes("\x1b[7mcharlie three\x1b[27m"));
940
- assert.deepEqual(inputListener?.("\x1b[<0;5;3m"), { consume: true });
941
- assert.deepEqual(copied, ["india nine\njuli", "hello", "charlie three"]);
942
-
943
- assert.deepEqual(inputListener?.("\x1b[<0;8;12M"), { consume: true });
944
- assert.deepEqual(inputListener?.("\x1b[<0;8;12m"), { consume: true });
945
- assert.deepEqual(inputListener?.("\x1b[<0;9;12M"), { consume: true });
946
- compositor.requestRepaint();
947
- assert.ok(terminal.writes.at(-1)?.includes("\x1b[7m > hello world\x1b[27m"));
948
- assert.deepEqual(inputListener?.("\x1b[<0;9;12m"), { consume: true });
949
- assert.deepEqual(copied, ["india nine\njuli", "hello", "charlie three", " > hello world"]);
950
-
951
- compositor.dispose();
952
- });
953
-
954
- test("terminal split selection scrolls when dragged to viewport edges", () => {
955
- const terminal = new FakeTerminal();
956
- let inputListener: ((data: string) => { consume?: boolean; data?: string } | undefined) | null = null;
957
- const copied: string[] = [];
958
- const tui = {
959
- terminal,
960
- addInputListener(listener: (data: string) => { consume?: boolean; data?: string } | undefined) {
961
- inputListener = listener;
962
- return () => {
963
- inputListener = null;
964
- };
965
- },
966
- requestRender() {},
967
- render() {
968
- return Array.from({ length: 30 }, (_, index) => `line-${index}`);
969
- },
970
- };
971
-
972
- const compositor = new TerminalSplitCompositor({
973
- tui,
974
- terminal,
975
- onCopySelection: (text) => copied.push(text),
976
- renderCluster: () => ({ lines: ["cluster-a", "cluster-b"], cursor: null }),
977
- });
978
-
979
- compositor.install();
980
- tui.render();
981
- inputListener?.("\x1b[5~");
982
- assert.equal(tui.render()[0], "line-10");
983
-
984
- assert.deepEqual(inputListener?.("\x1b[<0;1;9M"), { consume: true });
985
- assert.deepEqual(inputListener?.("\x1b[<32;5;12M"), { consume: true });
986
- assert.equal(tui.render()[0], "line-11");
987
- assert.ok(tui.render()[9]?.includes("\x1b[7mline\x1b[27m-20"));
988
- assert.deepEqual(inputListener?.("\x1b[<0;5;12m"), { consume: true });
989
- assert.deepEqual(copied, ["line-18\nline-19\nline"]);
990
-
991
- compositor.jumpToRootBottom();
992
- assert.equal(tui.render()[0], "line-20");
993
- assert.deepEqual(inputListener?.("\x1b[<0;1;2M"), { consume: true });
994
- assert.deepEqual(inputListener?.("\x1b[<32;5;1M"), { consume: true });
995
- assert.equal(tui.render()[0]?.replace(/\x1b\[[0-9;]*m/g, ""), "line-19");
996
- assert.ok(tui.render()[0]?.includes("line\x1b[7m-19\x1b[27m"));
997
- assert.deepEqual(inputListener?.("\x1b[<0;5;1m"), { consume: true });
998
- assert.deepEqual(copied, ["line-18\nline-19\nline", "-19\nline-20"]);
999
-
1000
- compositor.dispose();
1001
- });
1002
-
1003
- test("terminal split copies edge-scrolled selections without waiting for render", () => {
1004
- const terminal = new FakeTerminal();
1005
- let inputListener: ((data: string) => { consume?: boolean; data?: string } | undefined) | null = null;
1006
- const copied: string[] = [];
1007
- const tui = {
1008
- terminal,
1009
- addInputListener(listener: (data: string) => { consume?: boolean; data?: string } | undefined) {
1010
- inputListener = listener;
1011
- return () => {
1012
- inputListener = null;
1013
- };
1014
- },
1015
- requestRender() {},
1016
- render() {
1017
- return Array.from({ length: 30 }, (_, index) => `line-${index}`);
1018
- },
1019
- };
1020
-
1021
- const compositor = new TerminalSplitCompositor({
1022
- tui,
1023
- terminal,
1024
- onCopySelection: (text) => copied.push(text),
1025
- renderCluster: () => ({ lines: ["cluster-a", "cluster-b"], cursor: null }),
1026
- });
1027
-
1028
- compositor.install();
1029
- tui.render();
1030
- inputListener?.("\x1b[5~");
1031
- tui.render();
1032
-
1033
- inputListener?.("\x1b[<0;1;9M");
1034
- inputListener?.("\x1b[<32;5;12M");
1035
- inputListener?.("\x1b[<0;5;12m");
1036
- assert.deepEqual(copied, ["line-18\nline-19\nline"]);
1037
-
1038
- inputListener?.("\x1b[<0;1;9M");
1039
- for (let i = 0; i < 9; i++) {
1040
- inputListener?.("\x1b[<32;5;12M");
1041
- }
1042
- inputListener?.("\x1b[<0;5;12m");
1043
- assert.deepEqual(copied.at(-1), [
1044
- "line-19", "line-20", "line-21", "line-22", "line-23",
1045
- "line-24", "line-25", "line-26", "line-27", "line-28", "line",
1046
- ].join("\n"));
1047
-
1048
- compositor.dispose();
1049
- });
1050
-
1051
- test("terminal split maps post-edge-scroll drags against the updated viewport", () => {
1052
- const terminal = new FakeTerminal();
1053
- let inputListener: ((data: string) => { consume?: boolean; data?: string } | undefined) | null = null;
1054
- const copied: string[] = [];
1055
- const tui = {
1056
- terminal,
1057
- addInputListener(listener: (data: string) => { consume?: boolean; data?: string } | undefined) {
1058
- inputListener = listener;
1059
- return () => {
1060
- inputListener = null;
1061
- };
1062
- },
1063
- requestRender() {},
1064
- render() {
1065
- return Array.from({ length: 30 }, (_, index) => `line-${index}`);
1066
- },
1067
- };
1068
-
1069
- const compositor = new TerminalSplitCompositor({
1070
- tui,
1071
- terminal,
1072
- onCopySelection: (text) => copied.push(text),
1073
- renderCluster: () => ({ lines: ["cluster-a", "cluster-b"], cursor: null }),
1074
- });
1075
-
1076
- compositor.install();
1077
- tui.render();
1078
- inputListener?.("\x1b[5~");
1079
- tui.render();
1080
-
1081
- inputListener?.("\x1b[<0;1;9M");
1082
- inputListener?.("\x1b[<32;1;12M");
1083
- inputListener?.("\x1b[<32;1;3M");
1084
- inputListener?.("\x1b[<0;5;4m");
1085
- assert.deepEqual(copied, ["-14\nline-15\nline-16\nline-17"]);
1086
-
1087
- compositor.dispose();
1088
- });
1089
-
1090
- test("terminal split keyboard scroll supports Pi page aliases and preserves app shortcuts", () => {
1091
- const terminal = new FakeTerminal();
1092
- let inputListener: ((data: string) => { consume?: boolean; data?: string } | undefined) | null = null;
1093
- const tui = {
1094
- terminal,
1095
- addInputListener(listener: (data: string) => { consume?: boolean; data?: string } | undefined) {
1096
- inputListener = listener;
1097
- return () => {
1098
- inputListener = null;
1099
- };
1100
- },
1101
- requestRender() {},
1102
- render() {
1103
- return Array.from({ length: 30 }, (_, index) => `line-${index}`);
1104
- },
1105
- };
1106
-
1107
- const compositor = new TerminalSplitCompositor({
1108
- tui,
1109
- terminal,
1110
- renderCluster: () => ({ lines: ["cluster-a", "cluster-b"], cursor: null }),
1111
- });
1112
-
1113
- compositor.install();
1114
- tui.render();
1115
-
1116
- assert.deepEqual(inputListener?.("\x1b[5~"), { consume: true });
1117
- assert.deepEqual(inputListener?.("\x1b[5;9~"), { consume: true });
1118
- assert.deepEqual(inputListener?.("\x1b[57421;9u"), { consume: true });
1119
- assert.deepEqual(inputListener?.("\x1b[1;9A"), { consume: true });
1120
- assert.deepEqual(inputListener?.("\x1b[57419;9u"), { consume: true });
1121
- assert.deepEqual(inputListener?.("\x1b[1;9H"), { consume: true });
1122
- assert.deepEqual(inputListener?.("\x1b[57423;9u"), { consume: true });
1123
- assert.deepEqual(inputListener?.("\x1b[7;9~"), { consume: true });
1124
- assert.deepEqual(inputListener?.("\x1b[1;6A"), { consume: true });
1125
- assert.deepEqual(inputListener?.("\x1b[57419;6u"), { consume: true });
1126
- assert.deepEqual(inputListener?.("\x1b[6;9~"), { consume: true });
1127
- assert.deepEqual(inputListener?.("\x1b[57422;9u"), { consume: true });
1128
- assert.deepEqual(inputListener?.("\x1b[1;9B"), { consume: true });
1129
- assert.deepEqual(inputListener?.("\x1b[57420;9u"), { consume: true });
1130
- assert.deepEqual(inputListener?.("\x1b[1;9F"), { consume: true });
1131
- assert.deepEqual(inputListener?.("\x1b[57424;9u"), { consume: true });
1132
- assert.deepEqual(inputListener?.("\x1b[8;9~"), { consume: true });
1133
- assert.deepEqual(inputListener?.("\x1b[1;6B"), { consume: true });
1134
- assert.deepEqual(inputListener?.("\x1b[57420;6u"), { consume: true });
1135
- assert.equal(inputListener?.("\x1b[1;10A"), undefined);
1136
- assert.equal(inputListener?.("\x1b[57419;10u"), undefined);
1137
- assert.equal(inputListener?.("\x1b[1;10B"), undefined);
1138
- assert.equal(inputListener?.("\x1b[57420;10u"), undefined);
1139
- assert.equal(inputListener?.("\x1b[1;10:3A"), undefined);
1140
- assert.equal(inputListener?.("\x1b[57419;10:3u"), undefined);
1141
- assert.equal(inputListener?.("\x1bp"), undefined);
1142
- assert.equal(inputListener?.("\x1bn"), undefined);
1143
-
1144
- compositor.dispose();
1145
- });
1146
-
1147
- test("terminal split leaves PgUp/PgDn to a focused custom view", () => {
1148
- const terminal = new FakeTerminal();
1149
- let ownsKeyboardScroll = true;
1150
- let inputListener: ((data: string) => { consume?: boolean; data?: string } | undefined) | null = null;
1151
- const tui = {
1152
- terminal,
1153
- addInputListener(listener: (data: string) => { consume?: boolean; data?: string } | undefined) {
1154
- inputListener = listener;
1155
- return () => {
1156
- inputListener = null;
1157
- };
1158
- },
1159
- requestRender() {},
1160
- render() {
1161
- return Array.from({ length: 30 }, (_, index) => `line-${index}`);
1162
- },
1163
- };
1164
-
1165
- const compositor = new TerminalSplitCompositor({
1166
- tui,
1167
- terminal,
1168
- shouldCaptureKeyboardScroll: () => ownsKeyboardScroll,
1169
- renderCluster: () => ({ lines: ["cluster-a", "cluster-b"], cursor: null }),
1170
- });
1171
-
1172
- compositor.install();
1173
- tui.render();
1174
-
1175
- assert.deepEqual(inputListener?.("\x1b[5~"), { consume: true });
1176
- ownsKeyboardScroll = false;
1177
- assert.equal(inputListener?.("\x1b[5~"), undefined);
1178
- assert.equal(inputListener?.("\x1b[6~"), undefined);
1179
-
1180
- compositor.dispose();
1181
- });
1182
-
1183
- test("terminal split keyboard scroll accepts configured shortcuts", () => {
1184
- const terminal = new FakeTerminal();
1185
- const renderRequests: Array<boolean | undefined> = [];
1186
- let inputListener: ((data: string) => { consume?: boolean; data?: string } | undefined) | null = null;
1187
- const tui = {
1188
- terminal,
1189
- addInputListener(listener: (data: string) => { consume?: boolean; data?: string } | undefined) {
1190
- inputListener = listener;
1191
- return () => {
1192
- inputListener = null;
1193
- };
1194
- },
1195
- requestRender(force?: boolean) {
1196
- renderRequests.push(force);
1197
- },
1198
- render() {
1199
- return Array.from({ length: 30 }, (_, index) => `line-${index}`);
1200
- },
1201
- };
1202
-
1203
- const compositor = new TerminalSplitCompositor({
1204
- tui,
1205
- terminal,
1206
- keyboardScrollShortcuts: { up: "ctrl+shift+u", down: "ctrl+shift+d" },
1207
- renderCluster: () => ({ lines: ["cluster-a", "cluster-b"], cursor: null }),
1208
- });
1209
-
1210
- compositor.install();
1211
- tui.render();
1212
-
1213
- assert.equal(inputListener?.("\x1b[1;9A"), undefined);
1214
- assert.deepEqual(inputListener?.("\x1b[117;6u"), { consume: true });
1215
- assert.deepEqual(renderRequests, [undefined]);
1216
- assert.deepEqual(inputListener?.("\x1b[100;6u"), { consume: true });
1217
- assert.deepEqual(renderRequests, [undefined, undefined]);
1218
-
1219
- compositor.dispose();
1220
- });
1221
-
1222
- test("terminal split jumps to previous root target lines", () => {
1223
- const terminal = new FakeTerminal();
1224
- const renderRequests: Array<boolean | undefined> = [];
1225
- const tui = {
1226
- terminal,
1227
- requestRender(force?: boolean) {
1228
- renderRequests.push(force);
1229
- },
1230
- render() {
1231
- return Array.from({ length: 30 }, (_, index) => `line-${index}`);
1232
- },
1233
- };
1234
-
1235
- const compositor = new TerminalSplitCompositor({
1236
- tui,
1237
- terminal,
1238
- renderCluster: () => ({ lines: ["cluster-a", "cluster-b"], cursor: null }),
1239
- });
1240
-
1241
- compositor.install();
1242
- assert.deepEqual(tui.render(40), [
1243
- "line-20", "line-21", "line-22", "line-23", "line-24",
1244
- "line-25", "line-26", "line-27", "line-28", "line-29",
1245
- ]);
1246
-
1247
- assert.equal(compositor.jumpToPreviousRootTarget([6, 14, 24]), true);
1248
- assert.deepEqual(renderRequests, [undefined]);
1249
- assert.deepEqual(tui.render(40), [
1250
- "line-14", "line-15", "line-16", "line-17", "line-18",
1251
- "line-19", "line-20", "line-21", "line-22", "line-23",
1252
- ]);
1253
-
1254
- assert.equal(compositor.jumpToPreviousRootTarget([6, 14, 24]), true);
1255
- assert.deepEqual(renderRequests, [undefined, undefined]);
1256
- assert.deepEqual(tui.render(40), [
1257
- "line-6", "line-7", "line-8", "line-9", "line-10",
1258
- "line-11", "line-12", "line-13", "line-14", "line-15",
1259
- ]);
1260
-
1261
- assert.equal(compositor.jumpToPreviousRootTarget([6, 14, 24]), false);
1262
-
1263
- assert.equal(compositor.jumpToNextRootTarget([6, 14, 24]), true);
1264
- assert.deepEqual(tui.render(40), [
1265
- "line-14", "line-15", "line-16", "line-17", "line-18",
1266
- "line-19", "line-20", "line-21", "line-22", "line-23",
1267
- ]);
1268
-
1269
- assert.equal(compositor.jumpToNextRootTarget([6, 14, 24]), true);
1270
- assert.deepEqual(tui.render(40), [
1271
- "line-20", "line-21", "line-22", "line-23", "line-24",
1272
- "line-25", "line-26", "line-27", "line-28", "line-29",
1273
- ]);
1274
-
1275
- assert.equal(compositor.jumpToNextRootTarget([6, 14, 24]), false);
1276
-
1277
- assert.equal(compositor.jumpToPreviousRootTarget([6, 14, 24]), true);
1278
- assert.deepEqual(tui.render(40), [
1279
- "line-14", "line-15", "line-16", "line-17", "line-18",
1280
- "line-19", "line-20", "line-21", "line-22", "line-23",
1281
- ]);
1282
- assert.equal(compositor.jumpToRootBottom(), true);
1283
- assert.deepEqual(tui.render(40), [
1284
- "line-20", "line-21", "line-22", "line-23", "line-24",
1285
- "line-25", "line-26", "line-27", "line-28", "line-29",
1286
- ]);
1287
- assert.equal(compositor.jumpToRootBottom(), false);
1288
- compositor.dispose();
1289
- });
1290
-
1291
- test("terminal split previous root target only moves to older targets", () => {
1292
- const terminal = new FakeTerminal();
1293
- let inputListener: ((data: string) => { consume?: boolean; data?: string } | undefined) | null = null;
1294
- const tui = {
1295
- terminal,
1296
- addInputListener(listener: (data: string) => { consume?: boolean; data?: string } | undefined) {
1297
- inputListener = listener;
1298
- return () => {
1299
- inputListener = null;
1300
- };
1301
- },
1302
- requestRender() {},
1303
- render() {
1304
- return Array.from({ length: 30 }, (_, index) => `line-${index}`);
1305
- },
1306
- };
1307
-
1308
- const compositor = new TerminalSplitCompositor({
1309
- tui,
1310
- terminal,
1311
- renderCluster: () => ({ lines: ["cluster-a", "cluster-b"], cursor: null }),
1312
- });
1313
-
1314
- compositor.install();
1315
- assert.deepEqual(tui.render(), [
1316
- "line-20", "line-21", "line-22", "line-23", "line-24",
1317
- "line-25", "line-26", "line-27", "line-28", "line-29",
1318
- ]);
1319
- assert.deepEqual(inputListener?.("\x1b[5~"), { consume: true });
1320
- assert.deepEqual(tui.render(), [
1321
- "line-10", "line-11", "line-12", "line-13", "line-14",
1322
- "line-15", "line-16", "line-17", "line-18", "line-19",
1323
- ]);
1324
-
1325
- assert.equal(compositor.jumpToPreviousRootTarget([6, 14, 24]), true);
1326
- assert.deepEqual(tui.render(), [
1327
- "line-6", "line-7", "line-8", "line-9", "line-10",
1328
- "line-11", "line-12", "line-13", "line-14", "line-15",
1329
- ]);
1330
-
1331
- compositor.dispose();
1332
- });
1333
-
1334
- test("terminal split can disable mouse reporting for normal selection", () => {
1335
- const terminal = new FakeTerminal();
1336
- let inputListener: ((data: string) => { consume?: boolean; data?: string } | undefined) | null = null;
1337
- const renderRequests: Array<boolean | undefined> = [];
1338
- const tui = {
1339
- terminal,
1340
- addInputListener(listener: (data: string) => { consume?: boolean; data?: string } | undefined) {
1341
- inputListener = listener;
1342
- return () => {
1343
- inputListener = null;
1344
- };
1345
- },
1346
- requestRender(force?: boolean) {
1347
- renderRequests.push(force);
1348
- },
1349
- render() {
1350
- return Array.from({ length: 15 }, (_, index) => `line-${index}`);
1351
- },
1352
- };
1353
-
1354
- const compositor = new TerminalSplitCompositor({
1355
- tui,
1356
- terminal,
1357
- mouseScroll: false,
1358
- renderCluster: () => ({ lines: ["cluster-a", "cluster-b"], cursor: null }),
1359
- });
1360
-
1361
- compositor.install();
1362
- tui.render(40);
1363
-
1364
- assert.ok(!terminal.writes[0]?.includes("\x1b[?1002h"));
1365
- assert.ok(!terminal.writes[0]?.includes("\x1b[?1006h"));
1366
- assert.deepEqual(inputListener?.("\x1b[<64;1;1M"), undefined);
1367
- assert.deepEqual(inputListener?.("\x1b[A"), undefined);
1368
- assert.deepEqual(inputListener?.("\x1b[5~"), { consume: true });
1369
- assert.deepEqual(renderRequests, [undefined]);
1370
-
1371
- compositor.dispose();
1372
- assert.ok(!terminal.writes.at(-1)?.includes("\x1b[?1006l"));
1373
- assert.ok(!terminal.writes.at(-1)?.includes("\x1b[?1002l"));
1374
- });
1375
-
1376
- test("terminal split reuses the fixed cluster during one render pass", () => {
1377
- const terminal = new FakeTerminal();
1378
- let renderClusterCount = 0;
1379
- const tui = {
1380
- terminal,
1381
- hardwareCursorRow: 0,
1382
- cursorRow: 0,
1383
- previousViewportTop: 0,
1384
- render() {
1385
- return ["root-a", "root-b"];
1386
- },
1387
- doRender() {
1388
- void this.terminal.rows;
1389
- this.render(this.terminal.columns);
1390
- this.terminal.write("body");
1391
- },
1392
- };
1393
-
1394
- const compositor = new TerminalSplitCompositor({
1395
- tui,
1396
- terminal,
1397
- renderCluster: () => {
1398
- renderClusterCount += 1;
1399
- return { lines: ["cluster-a", "cluster-b"], cursor: null };
1400
- },
1401
- });
1402
-
1403
- compositor.install();
1404
- tui.doRender();
1405
-
1406
- assert.equal(renderClusterCount, 1);
1407
-
1408
- compositor.dispose();
1409
- });
1410
-
1411
- test("emergency terminal reset exits alternate screen before clearing keyboard modes", () => {
1412
- const cleanup = emergencyTerminalModeReset();
1413
- assert.ok(cleanup.includes("\x1b[?1049l"));
1414
- assert.ok(cleanup.includes("\x1b[<999u"));
1415
- assert.ok(cleanup.includes("\x1b[>4;0m"));
1416
- assert.ok(cleanup.indexOf("\x1b[?1049l") < cleanup.indexOf("\x1b[<999u"));
1417
- });
1418
-
1419
- test("terminal split emergency exit cleanup resets extended keyboard modes", () => {
1420
- const terminal = new FakeTerminal();
1421
- const compositor = new TerminalSplitCompositor({
1422
- tui: { terminal },
1423
- terminal,
1424
- renderCluster: () => ({ lines: ["cluster"], cursor: null }),
1425
- });
1426
-
1427
- compositor.install();
1428
- process.emit("exit", 0);
1429
-
1430
- const cleanup = terminal.writes.at(-1) ?? "";
1431
- assert.ok(cleanup.includes("\x1b[<999u"));
1432
- assert.ok(cleanup.includes("\x1b[>4;0m"));
1433
- assert.ok(cleanup.indexOf("\x1b[?1049l") < cleanup.indexOf("\x1b[<999u"));
1434
-
1435
- compositor.dispose();
1436
- });
1437
-
1438
- test("terminal split unregisters emergency exit cleanup on dispose", () => {
1439
- const terminal = new FakeTerminal();
1440
- const before = process.listenerCount("exit");
1441
- const compositor = new TerminalSplitCompositor({
1442
- tui: { terminal },
1443
- terminal,
1444
- renderCluster: () => ({ lines: ["cluster"], cursor: null }),
1445
- });
1446
-
1447
- compositor.install();
1448
- assert.equal(process.listenerCount("exit"), before + 1);
1449
-
1450
- compositor.dispose();
1451
- assert.equal(process.listenerCount("exit"), before);
1452
- });