@teammates/consolonia 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +48 -0
  2. package/dist/__tests__/ansi.test.d.ts +1 -0
  3. package/dist/__tests__/ansi.test.js +520 -0
  4. package/dist/__tests__/chat-view.test.d.ts +4 -0
  5. package/dist/__tests__/chat-view.test.js +480 -0
  6. package/dist/__tests__/drawing.test.d.ts +4 -0
  7. package/dist/__tests__/drawing.test.js +426 -0
  8. package/dist/__tests__/input.test.d.ts +5 -0
  9. package/dist/__tests__/input.test.js +911 -0
  10. package/dist/__tests__/layout.test.d.ts +4 -0
  11. package/dist/__tests__/layout.test.js +689 -0
  12. package/dist/__tests__/pixel.test.d.ts +1 -0
  13. package/dist/__tests__/pixel.test.js +674 -0
  14. package/dist/__tests__/render.test.d.ts +1 -0
  15. package/dist/__tests__/render.test.js +400 -0
  16. package/dist/__tests__/styled.test.d.ts +4 -0
  17. package/dist/__tests__/styled.test.js +149 -0
  18. package/dist/__tests__/widgets.test.d.ts +5 -0
  19. package/dist/__tests__/widgets.test.js +924 -0
  20. package/dist/ansi/esc.d.ts +61 -0
  21. package/dist/ansi/esc.js +85 -0
  22. package/dist/ansi/output.d.ts +66 -0
  23. package/dist/ansi/output.js +192 -0
  24. package/dist/ansi/strip.d.ts +16 -0
  25. package/dist/ansi/strip.js +74 -0
  26. package/dist/app.d.ts +68 -0
  27. package/dist/app.js +297 -0
  28. package/dist/drawing/clip.d.ts +23 -0
  29. package/dist/drawing/clip.js +67 -0
  30. package/dist/drawing/context.d.ts +77 -0
  31. package/dist/drawing/context.js +275 -0
  32. package/dist/index.d.ts +48 -0
  33. package/dist/index.js +63 -0
  34. package/dist/input/escape-matcher.d.ts +27 -0
  35. package/dist/input/escape-matcher.js +253 -0
  36. package/dist/input/events.d.ts +49 -0
  37. package/dist/input/events.js +17 -0
  38. package/dist/input/index.d.ts +15 -0
  39. package/dist/input/index.js +14 -0
  40. package/dist/input/matcher.d.ts +23 -0
  41. package/dist/input/matcher.js +14 -0
  42. package/dist/input/mouse-matcher.d.ts +27 -0
  43. package/dist/input/mouse-matcher.js +142 -0
  44. package/dist/input/paste-matcher.d.ts +23 -0
  45. package/dist/input/paste-matcher.js +104 -0
  46. package/dist/input/processor.d.ts +51 -0
  47. package/dist/input/processor.js +145 -0
  48. package/dist/input/raw-mode.d.ts +13 -0
  49. package/dist/input/raw-mode.js +24 -0
  50. package/dist/input/text-matcher.d.ts +14 -0
  51. package/dist/input/text-matcher.js +32 -0
  52. package/dist/layout/box.d.ts +33 -0
  53. package/dist/layout/box.js +92 -0
  54. package/dist/layout/column.d.ts +21 -0
  55. package/dist/layout/column.js +90 -0
  56. package/dist/layout/control.d.ts +73 -0
  57. package/dist/layout/control.js +215 -0
  58. package/dist/layout/row.d.ts +21 -0
  59. package/dist/layout/row.js +95 -0
  60. package/dist/layout/stack.d.ts +18 -0
  61. package/dist/layout/stack.js +64 -0
  62. package/dist/layout/types.d.ts +27 -0
  63. package/dist/layout/types.js +4 -0
  64. package/dist/pixel/background.d.ts +16 -0
  65. package/dist/pixel/background.js +16 -0
  66. package/dist/pixel/box-pattern.d.ts +38 -0
  67. package/dist/pixel/box-pattern.js +57 -0
  68. package/dist/pixel/buffer.d.ts +25 -0
  69. package/dist/pixel/buffer.js +51 -0
  70. package/dist/pixel/color.d.ts +48 -0
  71. package/dist/pixel/color.js +92 -0
  72. package/dist/pixel/foreground.d.ts +31 -0
  73. package/dist/pixel/foreground.js +64 -0
  74. package/dist/pixel/pixel.d.ts +21 -0
  75. package/dist/pixel/pixel.js +38 -0
  76. package/dist/pixel/symbol.d.ts +38 -0
  77. package/dist/pixel/symbol.js +192 -0
  78. package/dist/render/regions.d.ts +54 -0
  79. package/dist/render/regions.js +102 -0
  80. package/dist/render/render-target.d.ts +42 -0
  81. package/dist/render/render-target.js +118 -0
  82. package/dist/styled.d.ts +113 -0
  83. package/dist/styled.js +176 -0
  84. package/dist/widgets/border.d.ts +34 -0
  85. package/dist/widgets/border.js +121 -0
  86. package/dist/widgets/chat-view.d.ts +239 -0
  87. package/dist/widgets/chat-view.js +993 -0
  88. package/dist/widgets/interview.d.ts +87 -0
  89. package/dist/widgets/interview.js +187 -0
  90. package/dist/widgets/markdown.d.ts +87 -0
  91. package/dist/widgets/markdown.js +611 -0
  92. package/dist/widgets/panel.d.ts +19 -0
  93. package/dist/widgets/panel.js +35 -0
  94. package/dist/widgets/scroll-view.d.ts +43 -0
  95. package/dist/widgets/scroll-view.js +182 -0
  96. package/dist/widgets/styled-text.d.ts +38 -0
  97. package/dist/widgets/styled-text.js +183 -0
  98. package/dist/widgets/syntax.d.ts +37 -0
  99. package/dist/widgets/syntax.js +670 -0
  100. package/dist/widgets/text-input.d.ts +121 -0
  101. package/dist/widgets/text-input.js +618 -0
  102. package/dist/widgets/text.d.ts +34 -0
  103. package/dist/widgets/text.js +168 -0
  104. package/package.json +45 -0
package/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # @teammates/consolonia
2
+
3
+ > Part of the [teammates](https://github.com/Stevenic/teammates) monorepo.
4
+
5
+ Terminal UI rendering engine inspired by Consolonia. Pixel-level compositing with ANSI output.
6
+
7
+ ## Quick Start
8
+
9
+ ```bash
10
+ npm install @teammates/consolonia
11
+ ```
12
+
13
+ ## Features
14
+
15
+ - Pixel-level terminal buffer with foreground/background compositing
16
+ - Box-drawing character merging (single, double, mixed)
17
+ - Layout system: Box, Row, Column, Stack with constraint-based sizing
18
+ - Clipping regions for nested drawing contexts
19
+ - Widget library: Border, Panel, Text, TextInput, ScrollView, ChatView, Markdown, Syntax
20
+ - Styled text with inline markup (`*bold*`, `_italic_`, `` `code` ``, `~dim~`)
21
+ - Input processing: keyboard, mouse, paste detection, escape sequences
22
+ - ANSI output with dirty-region rendering (only redraws what changed)
23
+
24
+ ## Architecture
25
+
26
+ ```
27
+ src/
28
+ pixel/ # Buffer, Pixel, Color, Symbol, Foreground, Background
29
+ layout/ # Box, Row, Column, Stack, Control (constraint solver)
30
+ drawing/ # DrawingContext, Clip
31
+ render/ # RenderTarget, Regions (dirty tracking)
32
+ widgets/ # Border, Panel, Text, TextInput, ScrollView, ChatView, Markdown, Syntax
33
+ input/ # Processor, Matchers (text, escape, mouse, paste)
34
+ ansi/ # Escape codes, Output, Strip
35
+ styled.ts # Inline markup parser
36
+ app.ts # Application lifecycle
37
+ index.ts # Public API
38
+ ```
39
+
40
+ ## Testing
41
+
42
+ ```bash
43
+ npm test
44
+ ```
45
+
46
+ ## Requirements
47
+
48
+ - Node.js >= 20
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,520 @@
1
+ import { Writable } from "node:stream";
2
+ import { beforeEach, describe, expect, it } from "vitest";
3
+ import * as esc from "../ansi/esc.js";
4
+ import { AnsiOutput } from "../ansi/output.js";
5
+ import { stripAnsi, truncateAnsi, visibleLength } from "../ansi/strip.js";
6
+ // ── Helpers ────────────────────────────────────────────────────────
7
+ const ESC = "\x1b[";
8
+ /** Build a mock writable stream that accumulates output to a string. */
9
+ function mockStream() {
10
+ const stream = new Writable({
11
+ write(chunk, _encoding, callback) {
12
+ stream.output += chunk.toString();
13
+ callback();
14
+ },
15
+ });
16
+ stream.output = "";
17
+ return stream;
18
+ }
19
+ // ═══════════════════════════════════════════════════════════════════
20
+ // esc.ts
21
+ // ═══════════════════════════════════════════════════════════════════
22
+ describe("esc", () => {
23
+ describe("text style sequences", () => {
24
+ it("bold produces SGR 1", () => {
25
+ expect(esc.bold).toBe(`${ESC}1m`);
26
+ });
27
+ it("dim produces SGR 2", () => {
28
+ expect(esc.dim).toBe(`${ESC}2m`);
29
+ });
30
+ it("italic produces SGR 3", () => {
31
+ expect(esc.italic).toBe(`${ESC}3m`);
32
+ });
33
+ it("underline produces SGR 4", () => {
34
+ expect(esc.underline).toBe(`${ESC}4m`);
35
+ });
36
+ it("strikethrough produces SGR 9", () => {
37
+ expect(esc.strikethrough).toBe(`${ESC}9m`);
38
+ });
39
+ it("reset produces SGR 0", () => {
40
+ expect(esc.reset).toBe(`${ESC}0m`);
41
+ });
42
+ it("boldOff and dimOff both produce SGR 22", () => {
43
+ expect(esc.boldOff).toBe(`${ESC}22m`);
44
+ expect(esc.dimOff).toBe(`${ESC}22m`);
45
+ });
46
+ it("italicOff produces SGR 23", () => {
47
+ expect(esc.italicOff).toBe(`${ESC}23m`);
48
+ });
49
+ it("underlineOff produces SGR 24", () => {
50
+ expect(esc.underlineOff).toBe(`${ESC}24m`);
51
+ });
52
+ it("strikethroughOff produces SGR 29", () => {
53
+ expect(esc.strikethroughOff).toBe(`${ESC}29m`);
54
+ });
55
+ });
56
+ describe("color sequences", () => {
57
+ it("fg(r,g,b) produces 24-bit foreground escape", () => {
58
+ expect(esc.fg(255, 128, 0)).toBe(`${ESC}38;2;255;128;0m`);
59
+ });
60
+ it("bg(r,g,b) produces 24-bit background escape", () => {
61
+ expect(esc.bg(0, 0, 0)).toBe(`${ESC}48;2;0;0;0m`);
62
+ });
63
+ it("fg with full white", () => {
64
+ expect(esc.fg(255, 255, 255)).toBe(`${ESC}38;2;255;255;255m`);
65
+ });
66
+ it("bg with arbitrary values", () => {
67
+ expect(esc.bg(12, 34, 56)).toBe(`${ESC}48;2;12;34;56m`);
68
+ });
69
+ it("fgDefault resets foreground", () => {
70
+ expect(esc.fgDefault).toBe(`${ESC}39m`);
71
+ });
72
+ it("bgDefault resets background", () => {
73
+ expect(esc.bgDefault).toBe(`${ESC}49m`);
74
+ });
75
+ });
76
+ describe("cursor movement", () => {
77
+ it("moveTo converts 0-based to 1-based coordinates", () => {
78
+ expect(esc.moveTo(0, 0)).toBe(`${ESC}1;1H`);
79
+ expect(esc.moveTo(9, 4)).toBe(`${ESC}5;10H`);
80
+ });
81
+ it("moveUp defaults to 1", () => {
82
+ expect(esc.moveUp()).toBe(`${ESC}1A`);
83
+ expect(esc.moveUp(5)).toBe(`${ESC}5A`);
84
+ });
85
+ it("moveDown defaults to 1", () => {
86
+ expect(esc.moveDown()).toBe(`${ESC}1B`);
87
+ expect(esc.moveDown(3)).toBe(`${ESC}3B`);
88
+ });
89
+ it("moveRight defaults to 1", () => {
90
+ expect(esc.moveRight()).toBe(`${ESC}1C`);
91
+ expect(esc.moveRight(10)).toBe(`${ESC}10C`);
92
+ });
93
+ it("moveLeft defaults to 1", () => {
94
+ expect(esc.moveLeft()).toBe(`${ESC}1D`);
95
+ expect(esc.moveLeft(2)).toBe(`${ESC}2D`);
96
+ });
97
+ it("saveCursor and restoreCursor", () => {
98
+ expect(esc.saveCursor).toBe(`${ESC}s`);
99
+ expect(esc.restoreCursor).toBe(`${ESC}u`);
100
+ });
101
+ it("hideCursor and showCursor", () => {
102
+ expect(esc.hideCursor).toBe(`${ESC}?25l`);
103
+ expect(esc.showCursor).toBe(`${ESC}?25h`);
104
+ });
105
+ });
106
+ describe("screen control", () => {
107
+ it("clearScreen clears entire display", () => {
108
+ expect(esc.clearScreen).toBe(`${ESC}2J`);
109
+ });
110
+ it("eraseLine clears entire line", () => {
111
+ expect(esc.eraseLine).toBe(`${ESC}2K`);
112
+ });
113
+ it("eraseDown erases from cursor to end of display", () => {
114
+ expect(esc.eraseDown).toBe(`${ESC}0J`);
115
+ });
116
+ });
117
+ describe("mode toggles", () => {
118
+ it("alternateScreenOn/Off", () => {
119
+ expect(esc.alternateScreenOn).toBe(`${ESC}?1049h`);
120
+ expect(esc.alternateScreenOff).toBe(`${ESC}?1049l`);
121
+ });
122
+ it("bracketedPasteOn/Off", () => {
123
+ expect(esc.bracketedPasteOn).toBe(`${ESC}?2004h`);
124
+ expect(esc.bracketedPasteOff).toBe(`${ESC}?2004l`);
125
+ });
126
+ it("mouseTrackingOn enables button-event tracking and SGR mode", () => {
127
+ expect(esc.mouseTrackingOn).toBe(`${ESC}?1003h${ESC}?1006h`);
128
+ });
129
+ it("mouseTrackingOff disables SGR mode and button-event tracking", () => {
130
+ expect(esc.mouseTrackingOff).toBe(`${ESC}?1006l${ESC}?1003l`);
131
+ });
132
+ });
133
+ describe("setTitle", () => {
134
+ it("wraps title in OSC 0 sequence", () => {
135
+ expect(esc.setTitle("Hello")).toBe(`\x1b]0;Hello\x07`);
136
+ });
137
+ });
138
+ });
139
+ // ═══════════════════════════════════════════════════════════════════
140
+ // strip.ts
141
+ // ═══════════════════════════════════════════════════════════════════
142
+ describe("strip", () => {
143
+ describe("stripAnsi", () => {
144
+ it("returns plain text unchanged", () => {
145
+ expect(stripAnsi("hello")).toBe("hello");
146
+ });
147
+ it("removes SGR sequences", () => {
148
+ expect(stripAnsi(`${ESC}1mhello${ESC}0m`)).toBe("hello");
149
+ });
150
+ it("removes cursor movement sequences", () => {
151
+ expect(stripAnsi(`${ESC}5;10Hworld`)).toBe("world");
152
+ });
153
+ it("removes multiple sequences", () => {
154
+ const styled = `${ESC}1m${ESC}38;2;255;0;0mhello${ESC}0m world`;
155
+ expect(stripAnsi(styled)).toBe("hello world");
156
+ });
157
+ it("removes OSC sequences", () => {
158
+ expect(stripAnsi(`\x1b]0;Title\x07text`)).toBe("text");
159
+ });
160
+ it("handles empty string", () => {
161
+ expect(stripAnsi("")).toBe("");
162
+ });
163
+ it("handles string that is only escape codes", () => {
164
+ expect(stripAnsi(`${ESC}1m${ESC}0m`)).toBe("");
165
+ });
166
+ });
167
+ describe("visibleLength", () => {
168
+ it("returns correct length for plain text", () => {
169
+ expect(visibleLength("hello")).toBe(5);
170
+ });
171
+ it("ignores ANSI codes in length calculation", () => {
172
+ expect(visibleLength(`${ESC}1mhello${ESC}0m`)).toBe(5);
173
+ });
174
+ it("returns 0 for string with only escape codes", () => {
175
+ expect(visibleLength(`${ESC}38;2;255;0;0m`)).toBe(0);
176
+ });
177
+ it("handles mixed content correctly", () => {
178
+ const s = `${ESC}1mbold${ESC}0m and ${ESC}3mitalic${ESC}0m`;
179
+ expect(visibleLength(s)).toBe("bold and italic".length);
180
+ });
181
+ });
182
+ describe("truncateAnsi", () => {
183
+ it("returns empty string for maxWidth 0", () => {
184
+ expect(truncateAnsi("hello", 0)).toBe("");
185
+ });
186
+ it("returns empty string for negative maxWidth", () => {
187
+ expect(truncateAnsi("hello", -1)).toBe("");
188
+ });
189
+ it("truncates plain text to maxWidth", () => {
190
+ expect(truncateAnsi("hello world", 5)).toBe("hello");
191
+ });
192
+ it("returns full string if shorter than maxWidth", () => {
193
+ expect(truncateAnsi("hi", 10)).toBe("hi");
194
+ });
195
+ it("preserves ANSI codes up to the visible cut-off", () => {
196
+ const s = `${ESC}1mhello${ESC}0m world`;
197
+ const result = truncateAnsi(s, 5);
198
+ // Should contain all 5 visible chars "hello" plus the bold escape
199
+ expect(stripAnsi(result)).toBe("hello");
200
+ expect(result).toContain(`${ESC}1m`);
201
+ });
202
+ it("includes ANSI codes before any visible character", () => {
203
+ const s = `${ESC}38;2;255;0;0m${ESC}1mAB`;
204
+ const result = truncateAnsi(s, 1);
205
+ expect(stripAnsi(result)).toBe("A");
206
+ // Both escape sequences should be preserved since they precede visible chars
207
+ expect(result).toContain(`${ESC}38;2;255;0;0m`);
208
+ expect(result).toContain(`${ESC}1m`);
209
+ });
210
+ it("handles string with only escape codes", () => {
211
+ const s = `${ESC}1m${ESC}0m`;
212
+ const result = truncateAnsi(s, 5);
213
+ expect(result).toBe(s);
214
+ });
215
+ it("handles OSC sequences in truncation", () => {
216
+ const s = `\x1b]0;Title\x07hello`;
217
+ const result = truncateAnsi(s, 3);
218
+ expect(stripAnsi(result)).toBe("hel");
219
+ });
220
+ });
221
+ });
222
+ // ═══════════════════════════════════════════════════════════════════
223
+ // output.ts
224
+ // ═══════════════════════════════════════════════════════════════════
225
+ describe("AnsiOutput", () => {
226
+ let stream;
227
+ let output;
228
+ /** Create a minimal Pixel for testing. */
229
+ function makePixel(char, fg, bg, opts = {}) {
230
+ return {
231
+ foreground: {
232
+ symbol: { text: char, width: 1, pattern: 0 },
233
+ color: fg,
234
+ bold: opts.bold ?? false,
235
+ italic: opts.italic ?? false,
236
+ underline: opts.underline ?? false,
237
+ strikethrough: opts.strikethrough ?? false,
238
+ },
239
+ background: {
240
+ color: bg,
241
+ },
242
+ };
243
+ }
244
+ beforeEach(() => {
245
+ stream = mockStream();
246
+ output = new AnsiOutput(stream);
247
+ });
248
+ describe("buffering and flush", () => {
249
+ it("does not write to stream until flush is called", () => {
250
+ output.hideCursor();
251
+ expect(stream.output).toBe("");
252
+ });
253
+ it("flush writes accumulated buffer to stream", () => {
254
+ output.hideCursor();
255
+ output.flush();
256
+ expect(stream.output).toBe(esc.hideCursor);
257
+ });
258
+ it("flush clears the buffer so second flush is a no-op", () => {
259
+ output.hideCursor();
260
+ output.flush();
261
+ const first = stream.output;
262
+ output.flush();
263
+ expect(stream.output).toBe(first);
264
+ });
265
+ it("empty flush does not write to stream", () => {
266
+ output.flush();
267
+ expect(stream.output).toBe("");
268
+ });
269
+ });
270
+ describe("setCursor", () => {
271
+ it("emits moveTo on first call", () => {
272
+ output.setCursor(5, 3);
273
+ output.flush();
274
+ expect(stream.output).toBe(esc.moveTo(5, 3));
275
+ });
276
+ it("skips moveTo when position is already correct", () => {
277
+ output.setCursor(5, 3);
278
+ output.flush();
279
+ stream.output = "";
280
+ output.setCursor(5, 3);
281
+ output.flush();
282
+ expect(stream.output).toBe("");
283
+ });
284
+ it("emits moveTo when position changes", () => {
285
+ output.setCursor(0, 0);
286
+ output.flush();
287
+ stream.output = "";
288
+ output.setCursor(10, 5);
289
+ output.flush();
290
+ expect(stream.output).toBe(esc.moveTo(10, 5));
291
+ });
292
+ });
293
+ describe("writePixel", () => {
294
+ it("emits cursor position, colors, and character", () => {
295
+ const px = makePixel("A", { r: 255, g: 0, b: 0, a: 255 }, { r: 0, g: 0, b: 0, a: 255 });
296
+ output.writePixel(0, 0, px);
297
+ output.flush();
298
+ expect(stream.output).toContain(esc.moveTo(0, 0));
299
+ expect(stream.output).toContain(esc.fg(255, 0, 0));
300
+ expect(stream.output).toContain(esc.bg(0, 0, 0));
301
+ expect(stream.output).toContain("A");
302
+ });
303
+ it("skips cursor move for adjacent horizontal pixels", () => {
304
+ const px1 = makePixel("A", { r: 255, g: 0, b: 0, a: 255 }, { r: 0, g: 0, b: 0, a: 255 });
305
+ const px2 = makePixel("B", { r: 255, g: 0, b: 0, a: 255 }, { r: 0, g: 0, b: 0, a: 255 });
306
+ output.writePixel(0, 0, px1);
307
+ output.writePixel(1, 0, px2);
308
+ output.flush();
309
+ // Should have moveTo for (0,0) but not for (1,0) since cursor advances
310
+ const moveToCount = (stream.output.match(/\x1b\[\d+;\d+H/g) || []).length;
311
+ expect(moveToCount).toBe(1);
312
+ expect(stream.output).toContain("AB");
313
+ });
314
+ it("emits cursor move when pixels are not adjacent", () => {
315
+ const px1 = makePixel("A", { r: 255, g: 0, b: 0, a: 255 }, { r: 0, g: 0, b: 0, a: 255 });
316
+ const px2 = makePixel("B", { r: 255, g: 0, b: 0, a: 255 }, { r: 0, g: 0, b: 0, a: 255 });
317
+ output.writePixel(0, 0, px1);
318
+ output.writePixel(5, 0, px2); // gap at x=5
319
+ output.flush();
320
+ const moveToCount = (stream.output.match(/\x1b\[\d+;\d+H/g) || []).length;
321
+ expect(moveToCount).toBe(2);
322
+ });
323
+ it("does not re-emit foreground color when unchanged", () => {
324
+ const fgColor = { r: 100, g: 200, b: 50, a: 255 };
325
+ const bgColor = { r: 0, g: 0, b: 0, a: 255 };
326
+ const px1 = makePixel("A", fgColor, bgColor);
327
+ const px2 = makePixel("B", fgColor, bgColor);
328
+ output.writePixel(0, 0, px1);
329
+ output.writePixel(1, 0, px2);
330
+ output.flush();
331
+ // fg color should appear only once
332
+ const fgSeq = esc.fg(100, 200, 50);
333
+ const fgCount = stream.output.split(fgSeq).length - 1;
334
+ expect(fgCount).toBe(1);
335
+ });
336
+ it("does not re-emit background color when unchanged", () => {
337
+ const fgColor = { r: 255, g: 255, b: 255, a: 255 };
338
+ const bgColor = { r: 30, g: 30, b: 30, a: 255 };
339
+ const px1 = makePixel("X", fgColor, bgColor);
340
+ const px2 = makePixel("Y", fgColor, bgColor);
341
+ output.writePixel(0, 0, px1);
342
+ output.writePixel(1, 0, px2);
343
+ output.flush();
344
+ const bgSeq = esc.bg(30, 30, 30);
345
+ const bgCount = stream.output.split(bgSeq).length - 1;
346
+ expect(bgCount).toBe(1);
347
+ });
348
+ it("emits new color when foreground changes", () => {
349
+ const bg = { r: 0, g: 0, b: 0, a: 255 };
350
+ const px1 = makePixel("A", { r: 255, g: 0, b: 0, a: 255 }, bg);
351
+ const px2 = makePixel("B", { r: 0, g: 255, b: 0, a: 255 }, bg);
352
+ output.writePixel(0, 0, px1);
353
+ output.writePixel(1, 0, px2);
354
+ output.flush();
355
+ expect(stream.output).toContain(esc.fg(255, 0, 0));
356
+ expect(stream.output).toContain(esc.fg(0, 255, 0));
357
+ });
358
+ it("emits bold on and bold off on toggle", () => {
359
+ const fg = { r: 255, g: 255, b: 255, a: 255 };
360
+ const bg = { r: 0, g: 0, b: 0, a: 255 };
361
+ const px1 = makePixel("A", fg, bg, { bold: true });
362
+ const px2 = makePixel("B", fg, bg, { bold: false });
363
+ output.writePixel(0, 0, px1);
364
+ output.writePixel(1, 0, px2);
365
+ output.flush();
366
+ expect(stream.output).toContain(esc.bold);
367
+ expect(stream.output).toContain(esc.boldOff);
368
+ });
369
+ it("emits italic on and off on toggle", () => {
370
+ const fg = { r: 255, g: 255, b: 255, a: 255 };
371
+ const bg = { r: 0, g: 0, b: 0, a: 255 };
372
+ const px1 = makePixel("A", fg, bg, { italic: true });
373
+ const px2 = makePixel("B", fg, bg, { italic: false });
374
+ output.writePixel(0, 0, px1);
375
+ output.writePixel(1, 0, px2);
376
+ output.flush();
377
+ expect(stream.output).toContain(esc.italic);
378
+ expect(stream.output).toContain(esc.italicOff);
379
+ });
380
+ it("emits underline on and off on toggle", () => {
381
+ const fg = { r: 255, g: 255, b: 255, a: 255 };
382
+ const bg = { r: 0, g: 0, b: 0, a: 255 };
383
+ const px1 = makePixel("A", fg, bg, { underline: true });
384
+ const px2 = makePixel("B", fg, bg, { underline: false });
385
+ output.writePixel(0, 0, px1);
386
+ output.writePixel(1, 0, px2);
387
+ output.flush();
388
+ expect(stream.output).toContain(esc.underline);
389
+ expect(stream.output).toContain(esc.underlineOff);
390
+ });
391
+ it("emits strikethrough on and off on toggle", () => {
392
+ const fg = { r: 255, g: 255, b: 255, a: 255 };
393
+ const bg = { r: 0, g: 0, b: 0, a: 255 };
394
+ const px1 = makePixel("A", fg, bg, { strikethrough: true });
395
+ const px2 = makePixel("B", fg, bg, { strikethrough: false });
396
+ output.writePixel(0, 0, px1);
397
+ output.writePixel(1, 0, px2);
398
+ output.flush();
399
+ expect(stream.output).toContain(esc.strikethrough);
400
+ expect(stream.output).toContain(esc.strikethroughOff);
401
+ });
402
+ it("does not re-emit style when consecutive pixels have the same style", () => {
403
+ const fg = { r: 255, g: 255, b: 255, a: 255 };
404
+ const bg = { r: 0, g: 0, b: 0, a: 255 };
405
+ const px1 = makePixel("A", fg, bg, { bold: true, italic: true });
406
+ const px2 = makePixel("B", fg, bg, { bold: true, italic: true });
407
+ output.writePixel(0, 0, px1);
408
+ output.writePixel(1, 0, px2);
409
+ output.flush();
410
+ // bold and italic should each appear exactly once
411
+ const boldCount = stream.output.split(esc.bold).length - 1;
412
+ const italicCount = stream.output.split(esc.italic).length - 1;
413
+ expect(boldCount).toBe(1);
414
+ expect(italicCount).toBe(1);
415
+ });
416
+ it("emits fgDefault when foreground alpha drops to 0", () => {
417
+ const bg = { r: 0, g: 0, b: 0, a: 255 };
418
+ const px1 = makePixel("A", { r: 255, g: 0, b: 0, a: 255 }, bg);
419
+ const px2 = makePixel("B", { r: 0, g: 0, b: 0, a: 0 }, bg);
420
+ output.writePixel(0, 0, px1);
421
+ output.writePixel(1, 0, px2);
422
+ output.flush();
423
+ expect(stream.output).toContain(esc.fgDefault);
424
+ });
425
+ it("emits bgDefault when background alpha drops to 0", () => {
426
+ const fg = { r: 255, g: 255, b: 255, a: 255 };
427
+ const px1 = makePixel("A", fg, { r: 0, g: 0, b: 0, a: 255 });
428
+ const px2 = makePixel("B", fg, { r: 0, g: 0, b: 0, a: 0 });
429
+ output.writePixel(0, 0, px1);
430
+ output.writePixel(1, 0, px2);
431
+ output.flush();
432
+ expect(stream.output).toContain(esc.bgDefault);
433
+ });
434
+ });
435
+ describe("writeText", () => {
436
+ it("writes text at specified position", () => {
437
+ output.writeText(3, 2, "hello");
438
+ output.flush();
439
+ expect(stream.output).toContain(esc.moveTo(3, 2));
440
+ expect(stream.output).toContain("hello");
441
+ });
442
+ it("applies style overrides", () => {
443
+ output.writeText(0, 0, "bold", {
444
+ bold: true,
445
+ fgColor: { r: 255, g: 0, b: 0, a: 255 },
446
+ });
447
+ output.flush();
448
+ expect(stream.output).toContain(esc.bold);
449
+ expect(stream.output).toContain(esc.fg(255, 0, 0));
450
+ expect(stream.output).toContain("bold");
451
+ });
452
+ it("advances cursor x by text length", () => {
453
+ output.writeText(0, 0, "abc");
454
+ // Now cursor should be at x=3, y=0. Writing at (3, 0) should not emit moveTo.
455
+ output.setCursor(3, 0);
456
+ output.flush();
457
+ // Only one moveTo for the initial (0,0)
458
+ const moveToCount = (stream.output.match(/\x1b\[\d+;\d+H/g) || []).length;
459
+ expect(moveToCount).toBe(1);
460
+ });
461
+ });
462
+ describe("prepareTerminal", () => {
463
+ it("emits setup sequences and flushes immediately", () => {
464
+ output.prepareTerminal();
465
+ expect(stream.output).toContain(esc.alternateScreenOn);
466
+ expect(stream.output).toContain(esc.hideCursor);
467
+ expect(stream.output).toContain(esc.bracketedPasteOn);
468
+ expect(stream.output).toContain(esc.mouseTrackingOn);
469
+ expect(stream.output).toContain(esc.clearScreen);
470
+ });
471
+ });
472
+ describe("restoreTerminal", () => {
473
+ it("emits teardown sequences and flushes immediately", () => {
474
+ output.restoreTerminal();
475
+ expect(stream.output).toContain(esc.reset);
476
+ expect(stream.output).toContain(esc.mouseTrackingOff);
477
+ expect(stream.output).toContain(esc.bracketedPasteOff);
478
+ expect(stream.output).toContain(esc.showCursor);
479
+ expect(stream.output).toContain(esc.alternateScreenOff);
480
+ });
481
+ });
482
+ describe("state reset after prepare/restore", () => {
483
+ it("re-emits cursor position after prepareTerminal", () => {
484
+ output.setCursor(5, 5);
485
+ output.flush();
486
+ stream.output = "";
487
+ output.prepareTerminal();
488
+ stream.output = "";
489
+ // After prepare, state is reset, so setCursor to (5,5) should emit moveTo again
490
+ output.setCursor(5, 5);
491
+ output.flush();
492
+ expect(stream.output).toContain(esc.moveTo(5, 5));
493
+ });
494
+ it("re-emits colors after restoreTerminal", () => {
495
+ const fg = { r: 100, g: 100, b: 100, a: 255 };
496
+ const bg = { r: 50, g: 50, b: 50, a: 255 };
497
+ const px = {
498
+ foreground: {
499
+ symbol: { text: "A", width: 1, pattern: 0 },
500
+ color: fg,
501
+ bold: false,
502
+ italic: false,
503
+ underline: false,
504
+ strikethrough: false,
505
+ },
506
+ background: { color: bg },
507
+ };
508
+ output.writePixel(0, 0, px);
509
+ output.flush();
510
+ stream.output = "";
511
+ output.restoreTerminal();
512
+ stream.output = "";
513
+ // After restore, tracked colors are reset so the same color is re-emitted
514
+ output.writePixel(0, 0, px);
515
+ output.flush();
516
+ expect(stream.output).toContain(esc.fg(100, 100, 100));
517
+ expect(stream.output).toContain(esc.bg(50, 50, 50));
518
+ });
519
+ });
520
+ });
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Unit tests for the ChatView widget.
3
+ */
4
+ export {};