@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
@@ -0,0 +1,480 @@
1
+ /**
2
+ * Unit tests for the ChatView widget.
3
+ */
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import { DrawingContext } from "../drawing/context.js";
6
+ import { keyEvent, mouseEvent } from "../input/events.js";
7
+ import { PixelBuffer } from "../pixel/buffer.js";
8
+ import { ChatView } from "../widgets/chat-view.js";
9
+ // ── Helpers ──────────────────────────────────────────────────────────
10
+ function createRenderTarget(width = 60, height = 20) {
11
+ const buffer = new PixelBuffer(width, height);
12
+ const ctx = new DrawingContext(buffer);
13
+ return { buffer, ctx };
14
+ }
15
+ function rowText(buffer, y, x0 = 0, x1) {
16
+ const end = x1 ?? buffer.width;
17
+ let s = "";
18
+ for (let x = x0; x < end; x++) {
19
+ s += buffer.get(x, y).foreground.symbol.text;
20
+ }
21
+ return s;
22
+ }
23
+ /** Full layout + render a ChatView at the given dimensions. */
24
+ function layoutAndRender(chat, width = 60, height = 20) {
25
+ const { buffer, ctx } = createRenderTarget(width, height);
26
+ const constraint = {
27
+ minWidth: 0,
28
+ minHeight: 0,
29
+ maxWidth: width,
30
+ maxHeight: height,
31
+ };
32
+ chat.measure(constraint);
33
+ chat.arrange({ x: 0, y: 0, width, height });
34
+ chat.render(ctx);
35
+ return { buffer, ctx };
36
+ }
37
+ /** Create a key event for a printable character. */
38
+ function charKey(ch) {
39
+ return keyEvent(ch, ch);
40
+ }
41
+ /** Create a key event for a special key (non-printable). */
42
+ function specialKey(key) {
43
+ return keyEvent(key);
44
+ }
45
+ // ── Tests ────────────────────────────────────────────────────────────
46
+ describe("ChatView", () => {
47
+ describe("construction", () => {
48
+ it("creates with default options", () => {
49
+ const chat = new ChatView();
50
+ expect(chat).toBeDefined();
51
+ expect(chat.inputValue).toBe("");
52
+ expect(chat.feedLineCount).toBe(0);
53
+ expect(chat.banner).toBe("");
54
+ });
55
+ it("creates with custom options", () => {
56
+ const chat = new ChatView({
57
+ banner: "Welcome!",
58
+ prompt: "> ",
59
+ placeholder: "Type here...",
60
+ });
61
+ expect(chat.banner).toBe("Welcome!");
62
+ expect(chat.prompt).toBe("> ");
63
+ });
64
+ });
65
+ describe("banner", () => {
66
+ it("renders banner text at top", () => {
67
+ const chat = new ChatView({ banner: "Hello" });
68
+ const { buffer } = layoutAndRender(chat, 40, 10);
69
+ const row0 = rowText(buffer, 0, 0, 5);
70
+ expect(row0).toBe("Hello");
71
+ });
72
+ it("hides banner when empty", () => {
73
+ const chat = new ChatView({ banner: "" });
74
+ layoutAndRender(chat, 40, 10);
75
+ expect(chat.banner).toBe("");
76
+ });
77
+ it("can update banner text", () => {
78
+ const chat = new ChatView({ banner: "Old" });
79
+ chat.banner = "New Title";
80
+ expect(chat.banner).toBe("New Title");
81
+ });
82
+ });
83
+ describe("feed", () => {
84
+ it("starts empty", () => {
85
+ const chat = new ChatView();
86
+ expect(chat.feedLineCount).toBe(0);
87
+ });
88
+ it("appends lines", () => {
89
+ const chat = new ChatView();
90
+ chat.appendToFeed("line 1");
91
+ chat.appendToFeed("line 2");
92
+ expect(chat.feedLineCount).toBe(2);
93
+ });
94
+ it("appends multiple lines at once", () => {
95
+ const chat = new ChatView();
96
+ chat.appendLines(["a", "b", "c"]);
97
+ expect(chat.feedLineCount).toBe(3);
98
+ });
99
+ it("renders feed lines in the feed area", () => {
100
+ const chat = new ChatView({ banner: "B" });
101
+ chat.appendToFeed("Hello feed");
102
+ const { buffer } = layoutAndRender(chat, 40, 10);
103
+ // Row 0: banner "B"
104
+ // Row 1: separator
105
+ // Row 2+: feed area — should contain "Hello feed"
106
+ const feedRow = rowText(buffer, 2, 0, 10);
107
+ expect(feedRow).toBe("Hello feed");
108
+ });
109
+ it("clears feed lines", () => {
110
+ const chat = new ChatView();
111
+ chat.appendToFeed("msg1");
112
+ chat.appendToFeed("msg2");
113
+ expect(chat.feedLineCount).toBe(2);
114
+ chat.clear();
115
+ expect(chat.feedLineCount).toBe(0);
116
+ });
117
+ it("scrolling adjusts feed offset", () => {
118
+ const chat = new ChatView();
119
+ for (let i = 0; i < 30; i++) {
120
+ chat.appendToFeed(`line ${i}`);
121
+ }
122
+ chat.scrollFeed(-5);
123
+ // Should not crash
124
+ layoutAndRender(chat, 40, 10);
125
+ });
126
+ });
127
+ describe("input", () => {
128
+ it("handles key input", () => {
129
+ const chat = new ChatView();
130
+ const submitted = vi.fn();
131
+ chat.on("submit", submitted);
132
+ // Type "hello"
133
+ for (const ch of "hello") {
134
+ chat.handleInput(charKey(ch));
135
+ }
136
+ expect(chat.inputValue).toBe("hello");
137
+ // Press enter
138
+ chat.handleInput(specialKey("enter"));
139
+ expect(submitted).toHaveBeenCalledWith("hello");
140
+ });
141
+ it("emits change on input", () => {
142
+ const chat = new ChatView();
143
+ const changed = vi.fn();
144
+ chat.on("change", changed);
145
+ chat.handleInput(charKey("a"));
146
+ expect(changed).toHaveBeenCalled();
147
+ });
148
+ it("can set input value programmatically", () => {
149
+ const chat = new ChatView();
150
+ chat.inputValue = "/help";
151
+ expect(chat.inputValue).toBe("/help");
152
+ });
153
+ it("renders the input line above separator and footer", () => {
154
+ const chat = new ChatView({ prompt: "> " });
155
+ chat.inputValue = "test";
156
+ const { buffer } = layoutAndRender(chat, 40, 10);
157
+ // Input at row 7, separator at row 8, footer at row 9
158
+ const inputRow = rowText(buffer, 7, 0, 6);
159
+ expect(inputRow).toBe("> test");
160
+ });
161
+ });
162
+ describe("progress", () => {
163
+ it("shows progress message above input", () => {
164
+ const chat = new ChatView();
165
+ chat.setProgress("Loading...");
166
+ const { buffer } = layoutAndRender(chat, 40, 10);
167
+ // Find "Loading..." somewhere in the buffer
168
+ let found = false;
169
+ for (let y = 0; y < 10; y++) {
170
+ const row = rowText(buffer, y, 0, 10);
171
+ if (row.startsWith("Loading...")) {
172
+ found = true;
173
+ break;
174
+ }
175
+ }
176
+ expect(found).toBe(true);
177
+ });
178
+ it("hides progress when set to null", () => {
179
+ const chat = new ChatView();
180
+ chat.setProgress("Loading...");
181
+ chat.setProgress(null);
182
+ // Should not crash during render
183
+ layoutAndRender(chat, 40, 10);
184
+ });
185
+ });
186
+ describe("dropdown", () => {
187
+ it("starts with no dropdown", () => {
188
+ const chat = new ChatView();
189
+ expect(chat.dropdownItems).toHaveLength(0);
190
+ expect(chat.dropdownIndex).toBe(-1);
191
+ });
192
+ it("shows dropdown items", () => {
193
+ const chat = new ChatView();
194
+ chat.showDropdown([
195
+ { label: "/help", description: "Show help", completion: "/help " },
196
+ {
197
+ label: "/status",
198
+ description: "Show status",
199
+ completion: "/status ",
200
+ },
201
+ ]);
202
+ expect(chat.dropdownItems).toHaveLength(2);
203
+ expect(chat.dropdownIndex).toBe(0);
204
+ });
205
+ it("navigates dropdown with up/down", () => {
206
+ const chat = new ChatView();
207
+ chat.showDropdown([
208
+ { label: "/help", description: "desc", completion: "/help " },
209
+ { label: "/status", description: "desc", completion: "/status " },
210
+ { label: "/quit", description: "desc", completion: "/quit " },
211
+ ]);
212
+ expect(chat.dropdownIndex).toBe(0);
213
+ chat.dropdownDown();
214
+ expect(chat.dropdownIndex).toBe(1);
215
+ chat.dropdownDown();
216
+ expect(chat.dropdownIndex).toBe(2);
217
+ chat.dropdownDown(); // at end, stays
218
+ expect(chat.dropdownIndex).toBe(2);
219
+ chat.dropdownUp();
220
+ expect(chat.dropdownIndex).toBe(1);
221
+ chat.dropdownUp();
222
+ expect(chat.dropdownIndex).toBe(0);
223
+ chat.dropdownUp(); // at top, stays
224
+ expect(chat.dropdownIndex).toBe(0);
225
+ });
226
+ it("accepts dropdown item", () => {
227
+ const chat = new ChatView();
228
+ chat.showDropdown([
229
+ { label: "/help", description: "desc", completion: "/help " },
230
+ ]);
231
+ const item = chat.acceptDropdownItem();
232
+ expect(item).not.toBeNull();
233
+ expect(item.label).toBe("/help");
234
+ expect(chat.inputValue).toBe("/help ");
235
+ expect(chat.dropdownItems).toHaveLength(0);
236
+ });
237
+ it("hides dropdown on escape", () => {
238
+ const chat = new ChatView();
239
+ chat.showDropdown([
240
+ { label: "/help", description: "desc", completion: "/help " },
241
+ ]);
242
+ chat.handleInput(specialKey("escape"));
243
+ expect(chat.dropdownItems).toHaveLength(0);
244
+ });
245
+ it("accepts dropdown on enter when item selected", () => {
246
+ const chat = new ChatView();
247
+ chat.showDropdown([
248
+ { label: "/help", description: "desc", completion: "/help " },
249
+ ]);
250
+ chat.handleInput(specialKey("enter"));
251
+ expect(chat.inputValue).toBe("/help ");
252
+ });
253
+ it("navigates dropdown via keyboard events", () => {
254
+ const chat = new ChatView();
255
+ chat.showDropdown([
256
+ { label: "/a", description: "", completion: "/a " },
257
+ { label: "/b", description: "", completion: "/b " },
258
+ ]);
259
+ chat.handleInput(specialKey("down"));
260
+ expect(chat.dropdownIndex).toBe(1);
261
+ chat.handleInput(specialKey("up"));
262
+ expect(chat.dropdownIndex).toBe(0);
263
+ });
264
+ it("renders dropdown items below input", () => {
265
+ const chat = new ChatView();
266
+ chat.showDropdown([
267
+ { label: "/help", description: "Show help", completion: "/help " },
268
+ ]);
269
+ const { buffer } = layoutAndRender(chat, 40, 12);
270
+ // Find dropdown text — check the last row (should be right after input)
271
+ let foundRow = -1;
272
+ for (let y = 0; y < 12; y++) {
273
+ const row = rowText(buffer, y, 0, 30);
274
+ if (row.includes("/help")) {
275
+ foundRow = y;
276
+ break;
277
+ }
278
+ }
279
+ expect(foundRow).toBeGreaterThanOrEqual(0);
280
+ });
281
+ it("renders dropdown items at correct position with banner", () => {
282
+ const chat = new ChatView({ banner: "Banner\nLine 2" });
283
+ chat.appendToFeed("msg1");
284
+ chat.showDropdown([
285
+ { label: "/help", description: "Show help", completion: "/help " },
286
+ { label: "/status", description: "Status", completion: "/status " },
287
+ ]);
288
+ const H = 15;
289
+ const { buffer } = layoutAndRender(chat, 50, H);
290
+ // Dump all rows for debugging
291
+ const rows = [];
292
+ for (let y = 0; y < H; y++) {
293
+ rows.push(rowText(buffer, y, 0, 50).replace(/ +$/, ""));
294
+ }
295
+ // Input should be at H - 3 (2 dropdown rows below)
296
+ // Dropdown should be at H - 2 and H - 1
297
+ const helpRow = rows.findIndex((r) => r.includes("/help"));
298
+ const statusRow = rows.findIndex((r) => r.includes("/status"));
299
+ expect(helpRow).toBeGreaterThanOrEqual(0);
300
+ expect(statusRow).toBeGreaterThanOrEqual(0);
301
+ // Dropdown should be after input (last 2 rows)
302
+ expect(helpRow).toBeGreaterThan(rows.findIndex((r) => r.includes("❯") || r.includes("> ")));
303
+ });
304
+ });
305
+ describe("mouse scrolling", () => {
306
+ it("scrolls feed on wheel up", () => {
307
+ const chat = new ChatView();
308
+ for (let i = 0; i < 30; i++)
309
+ chat.appendToFeed(`line ${i}`);
310
+ const result = chat.handleInput(mouseEvent(0, 0, "none", "wheelup"));
311
+ expect(result).toBe(true);
312
+ });
313
+ it("scrolls feed on wheel down", () => {
314
+ const chat = new ChatView();
315
+ for (let i = 0; i < 30; i++)
316
+ chat.appendToFeed(`line ${i}`);
317
+ const result = chat.handleInput(mouseEvent(0, 0, "none", "wheeldown"));
318
+ expect(result).toBe(true);
319
+ });
320
+ });
321
+ describe("page scrolling", () => {
322
+ it("scrolls feed on pageup", () => {
323
+ const chat = new ChatView();
324
+ for (let i = 0; i < 30; i++)
325
+ chat.appendToFeed(`line ${i}`);
326
+ const result = chat.handleInput(specialKey("pageup"));
327
+ expect(result).toBe(true);
328
+ });
329
+ it("scrolls feed on pagedown", () => {
330
+ const chat = new ChatView();
331
+ for (let i = 0; i < 30; i++)
332
+ chat.appendToFeed(`line ${i}`);
333
+ const result = chat.handleInput(specialKey("pagedown"));
334
+ expect(result).toBe(true);
335
+ });
336
+ });
337
+ describe("resize / double buffer", () => {
338
+ it("renders cleanly at different sizes", () => {
339
+ const chat = new ChatView({ banner: "Banner" });
340
+ chat.appendToFeed("message 1");
341
+ chat.appendToFeed("message 2");
342
+ // Render at one size
343
+ layoutAndRender(chat, 60, 20);
344
+ // Re-render at a different size (simulates resize)
345
+ const { buffer } = layoutAndRender(chat, 40, 15);
346
+ // Should still render banner
347
+ const row0 = rowText(buffer, 0, 0, 6);
348
+ expect(row0).toBe("Banner");
349
+ });
350
+ it("renders at minimum viable size", () => {
351
+ const chat = new ChatView();
352
+ // Even at 10x3, should not crash
353
+ layoutAndRender(chat, 10, 3);
354
+ });
355
+ it("handles very small terminal gracefully", () => {
356
+ const chat = new ChatView();
357
+ const { ctx } = createRenderTarget(2, 2);
358
+ chat.measure({ minWidth: 0, minHeight: 0, maxWidth: 2, maxHeight: 2 });
359
+ chat.arrange({ x: 0, y: 0, width: 2, height: 2 });
360
+ // Should not throw
361
+ chat.render(ctx);
362
+ });
363
+ });
364
+ describe("dropdown after external showDropdown + re-render", () => {
365
+ it("dropdown persists across multiple render passes", () => {
366
+ const chat = new ChatView({ banner: "B", prompt: "> " });
367
+ const W = 50, H = 15;
368
+ // First render — no dropdown
369
+ let { buffer, ctx } = layoutAndRender(chat, W, H);
370
+ // Simulate: user types, CLI shows dropdown, then forces refresh
371
+ chat.showDropdown([
372
+ { label: "/help", description: "Show help", completion: "/help " },
373
+ { label: "/quit", description: "Quit", completion: "/quit " },
374
+ ]);
375
+ // Second render (simulates app.refresh())
376
+ ({ buffer, ctx } = layoutAndRender(chat, W, H));
377
+ // Dropdown should be in the last 2 rows
378
+ const helpRow = rowText(buffer, H - 2, 0, 30);
379
+ const quitRow = rowText(buffer, H - 1, 0, 30);
380
+ expect(helpRow).toContain("/help");
381
+ expect(quitRow).toContain("/quit");
382
+ });
383
+ it("dropdown appears when set during change event handler", () => {
384
+ const chat = new ChatView({ banner: "B", prompt: "> " });
385
+ const W = 50, H = 15;
386
+ // Wire up a change handler that shows dropdown (like the CLI does)
387
+ chat.on("change", (text) => {
388
+ if (text.startsWith("/") && text.length >= 2) {
389
+ chat.showDropdown([
390
+ { label: "/help", description: "Show help", completion: "/help " },
391
+ ]);
392
+ }
393
+ else {
394
+ chat.hideDropdown();
395
+ }
396
+ });
397
+ // Do initial render
398
+ let { buffer, ctx } = layoutAndRender(chat, W, H);
399
+ // Simulate typing "/" then "h" — exactly as App would
400
+ chat.handleInput(charKey("/"));
401
+ // After "/", no dropdown (length < 2)
402
+ ({ buffer } = layoutAndRender(chat, W, H));
403
+ let hasDropdown = false;
404
+ for (let y = 0; y < H; y++) {
405
+ if (rowText(buffer, y, 0, 30).includes("/help")) {
406
+ hasDropdown = true;
407
+ break;
408
+ }
409
+ }
410
+ expect(hasDropdown).toBe(false);
411
+ // Type "h" — should trigger dropdown
412
+ chat.handleInput(charKey("h"));
413
+ // Re-render (simulates App's scheduled render after input)
414
+ ({ buffer } = layoutAndRender(chat, W, H));
415
+ hasDropdown = false;
416
+ let dropdownRow = -1;
417
+ for (let y = 0; y < H; y++) {
418
+ const row = rowText(buffer, y, 0, 40);
419
+ if (row.includes("/help")) {
420
+ hasDropdown = true;
421
+ dropdownRow = y;
422
+ break;
423
+ }
424
+ }
425
+ expect(hasDropdown).toBe(true);
426
+ // Dropdown should be after the input line (near bottom)
427
+ expect(dropdownRow).toBeGreaterThanOrEqual(H - 3);
428
+ });
429
+ it("dropdown survives re-render after invalidation", () => {
430
+ const chat = new ChatView({ prompt: "> " });
431
+ const W = 40, H = 10;
432
+ // Show dropdown
433
+ chat.showDropdown([
434
+ { label: "/test", description: "Test cmd", completion: "/test " },
435
+ ]);
436
+ // Render once
437
+ layoutAndRender(chat, W, H);
438
+ // Simulate TextInput invalidation (what happens after insert)
439
+ chat.input.setValue("x");
440
+ // Re-render (like App._renderFrame after setImmediate)
441
+ const { buffer } = layoutAndRender(chat, W, H);
442
+ // Dropdown should still be there
443
+ let found = false;
444
+ for (let y = 0; y < H; y++) {
445
+ if (rowText(buffer, y, 0, 30).includes("/test")) {
446
+ found = true;
447
+ break;
448
+ }
449
+ }
450
+ expect(found).toBe(true);
451
+ });
452
+ });
453
+ describe("full render cycle", () => {
454
+ it("renders banner + separator + feed + separator + input", () => {
455
+ const chat = new ChatView({
456
+ banner: "Test Chat",
457
+ prompt: "> ",
458
+ });
459
+ chat.appendToFeed("Hello world");
460
+ const { buffer } = layoutAndRender(chat, 40, 8);
461
+ // Row 0: "Test Chat"
462
+ expect(rowText(buffer, 0, 0, 9)).toBe("Test Chat");
463
+ // Row 1: separator (repeated char)
464
+ const sep = rowText(buffer, 1, 0, 3);
465
+ expect(sep).toBe("───");
466
+ // Feed should contain "Hello world" somewhere in rows 2-5
467
+ let feedFound = false;
468
+ for (let y = 2; y < 6; y++) {
469
+ if (rowText(buffer, y, 0, 11) === "Hello world") {
470
+ feedFound = true;
471
+ break;
472
+ }
473
+ }
474
+ expect(feedFound).toBe(true);
475
+ // Input at row 5, separator at row 6, footer at row 7
476
+ const inputRow = rowText(buffer, 5, 0, 2);
477
+ expect(inputRow).toBe("> ");
478
+ });
479
+ });
480
+ });
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Tests for Phase 5: Drawing Context (ClipStack + DrawingContext).
3
+ */
4
+ export {};