@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,993 @@
1
+ /**
2
+ * ChatView — full-screen chat widget for terminal REPLs.
3
+ *
4
+ * Layout (top to bottom):
5
+ *
6
+ * ┌─ banner ──────────────────────────────┐
7
+ * │ customizable multi-line header text │
8
+ * ├───────────────────────────────────────-┤
9
+ * │ │
10
+ * │ scrollable feed area │
11
+ * │ (messages, agent output, etc.) │
12
+ * │ │
13
+ * ├───────────────────────────────────────-┤
14
+ * │ progress message (optional) │
15
+ * │ ❯ input box │
16
+ * │ ┌─ dropdown ───────────────────────┐ │
17
+ * │ │ /command1 description │ │
18
+ * │ │ /command2 description │ │
19
+ * │ └──────────────────────────────────-┘ │
20
+ * └────────────────────────────────────────┘
21
+ *
22
+ * The feed is the terminal's own scrollback: new content is appended
23
+ * as Text children to the feed Column. Everything is double-buffered
24
+ * through Consolonia's PixelBuffer so resizing redraws cleanly.
25
+ *
26
+ * Events emitted:
27
+ * "submit" (text: string) — user pressed Enter
28
+ * "change" (text: string) — input value changed
29
+ * "cancel" () — user pressed Escape
30
+ * "tab" () — user pressed Tab (for autocomplete)
31
+ */
32
+ import { Control } from "../layout/control.js";
33
+ import { StyledText } from "./styled-text.js";
34
+ import { Text } from "./text.js";
35
+ import { TextInput, } from "./text-input.js";
36
+ // ── URL detection ──────────────────────────────────────────────────
37
+ const URL_REGEX = /https?:\/\/[^\s)>\]]+/g;
38
+ // ── ChatView ───────────────────────────────────────────────────────
39
+ export class ChatView extends Control {
40
+ // ── Child controls ─────────────────────────────────────────────
41
+ _banner;
42
+ _topSeparator;
43
+ _feedLines = [];
44
+ /** Maps feed line index → action(s) for clickable lines. */
45
+ _feedActions = new Map();
46
+ /** Feed line index currently hovered (-1 if none). */
47
+ _hoveredAction = -1;
48
+ /** Maps screen Y → feed line index (rebuilt each render). */
49
+ _screenToFeedLine = new Map();
50
+ /** Maps screen Y → row offset within the feed line (for multi-row wrapped lines). */
51
+ _screenToFeedRow = new Map();
52
+ _bottomSeparator;
53
+ _progressText;
54
+ _input;
55
+ _inputSeparator;
56
+ _footer;
57
+ _dropdownItems = [];
58
+ _dropdownIndex = -1;
59
+ // ── Configuration ──────────────────────────────────────────────
60
+ _feedStyle;
61
+ _progressStyle;
62
+ _separatorStyle;
63
+ _separatorChar;
64
+ _dropdownHighlightStyle;
65
+ _dropdownStyle;
66
+ _footerStyle;
67
+ _maxInputH;
68
+ _feedScrollOffset = 0;
69
+ // ── Feed geometry (cached from last render for hit-testing) ──
70
+ _feedX = 0;
71
+ _contentWidth = 0;
72
+ // ── Scrollbar state ───────────────────────────────────────────
73
+ /** Cached from last render for hit-testing. */
74
+ _scrollbarX = -1;
75
+ _feedY = 0;
76
+ _feedH = 0;
77
+ _thumbPos = 0;
78
+ _thumbSize = 0;
79
+ _maxScroll = 0;
80
+ _scrollbarVisible = false;
81
+ /** True while the user is dragging the scrollbar thumb. */
82
+ _dragging = false;
83
+ /** The Y offset within the thumb where the drag started. */
84
+ _dragOffsetY = 0;
85
+ /** Optional widget that replaces the input area (e.g. Interview). */
86
+ _inputOverride = null;
87
+ constructor(options = {}) {
88
+ super();
89
+ this._feedStyle = options.feedStyle ?? {};
90
+ this._progressStyle = options.progressStyle ?? { italic: true };
91
+ this._separatorStyle = options.separatorStyle ?? {};
92
+ this._separatorChar = options.separatorChar ?? "─";
93
+ this._dropdownHighlightStyle = options.dropdownHighlightStyle ?? {
94
+ bold: true,
95
+ };
96
+ this._dropdownStyle = options.dropdownStyle ?? {};
97
+ this._footerStyle = options.footerStyle ?? {};
98
+ this._maxInputH = options.maxInputHeight ?? 1;
99
+ // Banner — use custom widget if provided, otherwise fall back to Text
100
+ if (options.bannerWidget) {
101
+ this._banner = options.bannerWidget;
102
+ }
103
+ else {
104
+ this._banner = new Text({
105
+ text: options.banner ?? "",
106
+ style: options.bannerStyle ?? {},
107
+ wrap: true,
108
+ });
109
+ }
110
+ this.addChild(this._banner);
111
+ // Top separator (between banner and feed)
112
+ this._topSeparator = new _Separator(this._separatorChar, this._separatorStyle);
113
+ this.addChild(this._topSeparator);
114
+ // Bottom separator (between feed and input area)
115
+ this._bottomSeparator = new _Separator(this._separatorChar, this._separatorStyle);
116
+ this.addChild(this._bottomSeparator);
117
+ // Progress text (above separator, fixed)
118
+ this._progressText = new StyledText({
119
+ lines: [],
120
+ defaultStyle: this._progressStyle,
121
+ wrap: false,
122
+ });
123
+ this._progressText.visible = false;
124
+ this.addChild(this._progressText);
125
+ // Input
126
+ this._input = new TextInput({
127
+ prompt: options.prompt ?? "❯ ",
128
+ promptStyle: options.promptStyle ?? {},
129
+ style: options.inputStyle ?? {},
130
+ cursorStyle: options.cursorStyle ?? {},
131
+ placeholder: options.placeholder ?? "",
132
+ placeholderStyle: options.placeholderStyle ?? { italic: true },
133
+ history: options.history,
134
+ colorize: options.inputColorize,
135
+ deleteSize: options.inputDeleteSize,
136
+ hint: options.inputHint,
137
+ hintStyle: options.inputHintStyle,
138
+ });
139
+ this._input.focusable = true;
140
+ this._input.onFocus();
141
+ this.addChild(this._input);
142
+ // Separator between input and footer
143
+ this._inputSeparator = new _Separator(this._separatorChar, this._separatorStyle);
144
+ this.addChild(this._inputSeparator);
145
+ // Footer (below input separator / dropdown, always 1 row)
146
+ const footerLine = options.footer ?? "";
147
+ this._footer = new StyledText({
148
+ lines: [footerLine],
149
+ defaultStyle: this._footerStyle,
150
+ wrap: false,
151
+ });
152
+ this.addChild(this._footer);
153
+ // Wire input events to ChatView events
154
+ this._input.on("submit", (text) => this.emit("submit", text));
155
+ this._input.on("change", (text) => this.emit("change", text));
156
+ this._input.on("paste", (text) => this.emit("paste", text));
157
+ this._input.on("cancel", () => {
158
+ if (this._dropdownItems.length > 0) {
159
+ this.hideDropdown();
160
+ }
161
+ else {
162
+ this.emit("cancel");
163
+ }
164
+ });
165
+ this._input.on("tab", () => {
166
+ if (this._dropdownItems.length > 0 && this._dropdownIndex >= 0) {
167
+ this.acceptDropdownItem();
168
+ }
169
+ else {
170
+ this.emit("tab");
171
+ }
172
+ });
173
+ }
174
+ // ── Public API: Banner ─────────────────────────────────────────
175
+ /** Get the banner text (only works when using the built-in Text banner). */
176
+ get banner() {
177
+ return this._banner instanceof Text ? this._banner.text : "";
178
+ }
179
+ /** Set the banner text (only works when using the built-in Text banner). */
180
+ set banner(text) {
181
+ if (this._banner instanceof Text) {
182
+ this._banner.text = text;
183
+ this._banner.visible = text.length > 0;
184
+ this.invalidate();
185
+ }
186
+ }
187
+ /** Get the banner style (only works when using the built-in Text banner). */
188
+ get bannerStyle() {
189
+ return this._banner instanceof Text ? this._banner.style : {};
190
+ }
191
+ /** Set the banner style (only works when using the built-in Text banner). */
192
+ set bannerStyle(style) {
193
+ if (this._banner instanceof Text) {
194
+ this._banner.style = style;
195
+ }
196
+ }
197
+ /** Replace the banner with a custom widget. */
198
+ set bannerWidget(widget) {
199
+ this.removeChild(this._banner);
200
+ this._banner = widget;
201
+ // Insert as first child so it stays at the top
202
+ this.children.unshift(widget);
203
+ widget.parent = this;
204
+ this.invalidate();
205
+ }
206
+ /** Get the current banner widget. */
207
+ get bannerWidget() {
208
+ return this._banner;
209
+ }
210
+ // ── Public API: Footer ─────────────────────────────────────────
211
+ /** Set footer content (plain string or StyledSpan for mixed colors). */
212
+ setFooter(content) {
213
+ this._footer.lines = [content];
214
+ this.invalidate();
215
+ }
216
+ // ── Public API: Feed ───────────────────────────────────────────
217
+ /** Append a line of plain text to the feed. Auto-scrolls to bottom. */
218
+ appendToFeed(text, style) {
219
+ const line = new StyledText({
220
+ lines: [text],
221
+ defaultStyle: style ?? this._feedStyle,
222
+ wrap: true,
223
+ });
224
+ this._feedLines.push(line);
225
+ this._autoScrollToBottom();
226
+ this.invalidate();
227
+ }
228
+ /** Append a styled line (StyledSpan) to the feed. */
229
+ appendStyledToFeed(styledLine) {
230
+ const line = new StyledText({
231
+ lines: [styledLine],
232
+ defaultStyle: this._feedStyle,
233
+ wrap: true,
234
+ });
235
+ this._feedLines.push(line);
236
+ this._autoScrollToBottom();
237
+ this.invalidate();
238
+ }
239
+ /** Append a clickable action line to the feed. Emits "action" on click. */
240
+ appendAction(id, normalContent, hoverContent) {
241
+ const line = new StyledText({
242
+ lines: [normalContent],
243
+ defaultStyle: this._feedStyle,
244
+ wrap: false,
245
+ });
246
+ const idx = this._feedLines.length;
247
+ this._feedLines.push(line);
248
+ this._feedActions.set(idx, {
249
+ items: [{ id, normalStyle: normalContent, hoverStyle: hoverContent }],
250
+ normalStyle: normalContent,
251
+ });
252
+ this._autoScrollToBottom();
253
+ this.invalidate();
254
+ }
255
+ /** Append a line with multiple side-by-side clickable actions. */
256
+ appendActionList(actions) {
257
+ if (actions.length === 0)
258
+ return;
259
+ const combined = this._concatSpans(actions.map((a) => a.normalStyle));
260
+ const line = new StyledText({
261
+ lines: [combined],
262
+ defaultStyle: this._feedStyle,
263
+ wrap: false,
264
+ });
265
+ const idx = this._feedLines.length;
266
+ this._feedLines.push(line);
267
+ this._feedActions.set(idx, { items: actions, normalStyle: combined });
268
+ this._autoScrollToBottom();
269
+ this.invalidate();
270
+ }
271
+ /** Concatenate multiple StyledLine arrays into one. */
272
+ _concatSpans(spans) {
273
+ const result = [];
274
+ for (const s of spans) {
275
+ if (Array.isArray(s))
276
+ result.push(...s);
277
+ else
278
+ result.push(s);
279
+ }
280
+ return result;
281
+ }
282
+ /** Append multiple plain lines to the feed. */
283
+ appendLines(lines, style) {
284
+ for (const text of lines) {
285
+ const line = new StyledText({
286
+ lines: [text],
287
+ defaultStyle: style ?? this._feedStyle,
288
+ wrap: true,
289
+ });
290
+ this._feedLines.push(line);
291
+ }
292
+ this._autoScrollToBottom();
293
+ this.invalidate();
294
+ }
295
+ /** Clear everything between the banner and the input box. */
296
+ clear() {
297
+ this._feedLines = [];
298
+ this._feedActions.clear();
299
+ this._hoveredAction = -1;
300
+ this._feedScrollOffset = 0;
301
+ this.invalidate();
302
+ }
303
+ /** Total number of feed lines. */
304
+ get feedLineCount() {
305
+ return this._feedLines.length;
306
+ }
307
+ /** Update the content of an existing feed line by index. Also removes its action if any. */
308
+ updateFeedLine(index, content) {
309
+ if (index < 0 || index >= this._feedLines.length)
310
+ return;
311
+ this._feedLines[index].lines = [content];
312
+ this._feedActions.delete(index);
313
+ if (this._hoveredAction === index)
314
+ this._hoveredAction = -1;
315
+ this.invalidate();
316
+ }
317
+ /** Scroll the feed to the bottom. */
318
+ scrollToBottom() {
319
+ this._autoScrollToBottom();
320
+ this.invalidate();
321
+ }
322
+ /** Scroll the feed by a delta (positive = down, negative = up). */
323
+ scrollFeed(delta) {
324
+ this._feedScrollOffset = Math.max(0, this._feedScrollOffset + delta);
325
+ this.invalidate();
326
+ }
327
+ // ── Public API: Input ──────────────────────────────────────────
328
+ /** Get current input value. */
329
+ get inputValue() {
330
+ return this._input.value;
331
+ }
332
+ /** Set the input value and move cursor to end. */
333
+ set inputValue(text) {
334
+ this._input.setValue(text);
335
+ }
336
+ /** Get the underlying TextInput for advanced use. */
337
+ get input() {
338
+ return this._input;
339
+ }
340
+ /** Get input history. */
341
+ get history() {
342
+ return this._input.history;
343
+ }
344
+ /** Set the input prompt text. */
345
+ set prompt(text) {
346
+ this._input.prompt = text;
347
+ }
348
+ get prompt() {
349
+ return this._input.prompt;
350
+ }
351
+ // ── Public API: Progress ───────────────────────────────────────
352
+ /** Show a progress/status message just above the separator. */
353
+ setProgress(content) {
354
+ if (content === null ||
355
+ (typeof content === "string" && content.length === 0)) {
356
+ this._progressText.lines = [];
357
+ this._progressText.visible = false;
358
+ }
359
+ else {
360
+ this._progressText.lines = [content];
361
+ this._progressText.visible = true;
362
+ }
363
+ this.invalidate();
364
+ }
365
+ // ── Public API: Dropdown ───────────────────────────────────────
366
+ /** Show dropdown items below the input box. */
367
+ showDropdown(items) {
368
+ this._dropdownItems = items;
369
+ this._dropdownIndex = items.length > 0 ? 0 : -1;
370
+ this.invalidate();
371
+ }
372
+ /** Hide the dropdown. */
373
+ hideDropdown() {
374
+ this._dropdownItems = [];
375
+ this._dropdownIndex = -1;
376
+ this.invalidate();
377
+ }
378
+ /** Move dropdown selection down. */
379
+ dropdownDown() {
380
+ if (this._dropdownItems.length === 0)
381
+ return false;
382
+ this._dropdownIndex = Math.min(this._dropdownIndex + 1, this._dropdownItems.length - 1);
383
+ this.invalidate();
384
+ return true;
385
+ }
386
+ /** Move dropdown selection up. */
387
+ dropdownUp() {
388
+ if (this._dropdownItems.length === 0)
389
+ return false;
390
+ this._dropdownIndex = Math.max(this._dropdownIndex - 1, 0);
391
+ this.invalidate();
392
+ return true;
393
+ }
394
+ /** Accept the currently highlighted dropdown item. Returns it, or null. */
395
+ acceptDropdownItem() {
396
+ if (this._dropdownIndex < 0 ||
397
+ this._dropdownIndex >= this._dropdownItems.length) {
398
+ return null;
399
+ }
400
+ const item = this._dropdownItems[this._dropdownIndex];
401
+ this._input.setValue(item.completion);
402
+ this.hideDropdown();
403
+ this.emit("change", item.completion);
404
+ return item;
405
+ }
406
+ /** Get current dropdown items. */
407
+ get dropdownItems() {
408
+ return this._dropdownItems;
409
+ }
410
+ /** Get current dropdown selection index. */
411
+ get dropdownIndex() {
412
+ return this._dropdownIndex;
413
+ }
414
+ // ── Public API: Input Override ─────────────────────────────────
415
+ /**
416
+ * Replace the normal input/footer area with a custom widget
417
+ * (e.g. an Interview). While an override is active the normal
418
+ * input, separator, footer and dropdown are hidden and input
419
+ * events are routed to the override widget.
420
+ *
421
+ * Pass `null` to remove the override and restore normal input.
422
+ */
423
+ setInputOverride(widget) {
424
+ // Remove previous override
425
+ if (this._inputOverride) {
426
+ this.removeChild(this._inputOverride);
427
+ }
428
+ this._inputOverride = widget;
429
+ if (widget) {
430
+ this.addChild(widget);
431
+ // Hide normal input chrome
432
+ this._input.visible = false;
433
+ this._input.focusable = false;
434
+ this._inputSeparator.visible = false;
435
+ this._footer.visible = false;
436
+ }
437
+ else {
438
+ // Restore normal input chrome
439
+ this._input.visible = true;
440
+ this._input.focusable = true;
441
+ this._input.onFocus();
442
+ this._inputSeparator.visible = true;
443
+ this._footer.visible = true;
444
+ }
445
+ this.invalidate();
446
+ }
447
+ /** Get the current input override widget, or null. */
448
+ get inputOverride() {
449
+ return this._inputOverride;
450
+ }
451
+ // ── Input handling ─────────────────────────────────────────────
452
+ handleInput(event) {
453
+ if (event.type === "key") {
454
+ const ke = event.event;
455
+ // Ctrl+C → emit for the app to handle
456
+ if (ke.key === "c" && ke.ctrl && !ke.alt && !ke.shift) {
457
+ this.emit("ctrlc");
458
+ return true;
459
+ }
460
+ // Dropdown navigation
461
+ if (this._dropdownItems.length > 0) {
462
+ if (ke.key === "up")
463
+ return this.dropdownUp();
464
+ if (ke.key === "down")
465
+ return this.dropdownDown();
466
+ if (ke.key === "enter" && this._dropdownIndex >= 0) {
467
+ // Only consume Enter if the highlighted item has a completion value
468
+ // AND the input doesn't already match the completion (otherwise submit).
469
+ const item = this._dropdownItems[this._dropdownIndex];
470
+ if (item?.completion) {
471
+ const currentVal = this._input.value.trim();
472
+ if (currentVal !== item.completion.trim()) {
473
+ this.acceptDropdownItem();
474
+ return true;
475
+ }
476
+ // Input already matches — hide dropdown and let Enter fall through to submit
477
+ this.hideDropdown();
478
+ }
479
+ }
480
+ if (ke.key === "escape") {
481
+ this.hideDropdown();
482
+ return true;
483
+ }
484
+ }
485
+ // Mouse wheel scrolling for feed
486
+ if (ke.key === "pageup") {
487
+ this.scrollFeed(-10);
488
+ return true;
489
+ }
490
+ if (ke.key === "pagedown") {
491
+ this.scrollFeed(10);
492
+ return true;
493
+ }
494
+ }
495
+ // Mouse events: wheel scrolling + scrollbar drag
496
+ if (event.type === "mouse") {
497
+ const me = event.event;
498
+ if (me.type === "wheelup") {
499
+ this.scrollFeed(-3);
500
+ return true;
501
+ }
502
+ if (me.type === "wheeldown") {
503
+ this.scrollFeed(3);
504
+ return true;
505
+ }
506
+ // Scrollbar drag
507
+ if (this._scrollbarVisible) {
508
+ const onScrollbar = me.x === this._scrollbarX &&
509
+ me.y >= this._feedY &&
510
+ me.y < this._feedY + this._feedH;
511
+ if (me.type === "press" && me.button === "left" && onScrollbar) {
512
+ const relY = me.y - this._feedY;
513
+ if (relY >= this._thumbPos &&
514
+ relY < this._thumbPos + this._thumbSize) {
515
+ // Clicked on thumb — start dragging
516
+ this._dragging = true;
517
+ this._dragOffsetY = relY - this._thumbPos;
518
+ }
519
+ else {
520
+ // Clicked on track — jump to that position
521
+ const ratio = relY / this._feedH;
522
+ this._feedScrollOffset = Math.round(ratio * this._maxScroll);
523
+ this._feedScrollOffset = Math.max(0, Math.min(this._feedScrollOffset, this._maxScroll));
524
+ this.invalidate();
525
+ }
526
+ return true;
527
+ }
528
+ if (me.type === "move" && this._dragging) {
529
+ const relY = me.y - this._feedY;
530
+ const newThumbPos = relY - this._dragOffsetY;
531
+ const maxThumbPos = this._feedH - this._thumbSize;
532
+ const clampedPos = Math.max(0, Math.min(newThumbPos, maxThumbPos));
533
+ const ratio = maxThumbPos > 0 ? clampedPos / maxThumbPos : 0;
534
+ this._feedScrollOffset = Math.round(ratio * this._maxScroll);
535
+ this.invalidate();
536
+ return true;
537
+ }
538
+ if (me.type === "release" && this._dragging) {
539
+ this._dragging = false;
540
+ return true;
541
+ }
542
+ }
543
+ // Ctrl+click to open URLs in browser
544
+ if (me.type === "press" && me.button === "left" && me.ctrl) {
545
+ const feedLineIdx = this._screenToFeedLine.get(me.y) ?? -1;
546
+ if (feedLineIdx >= 0) {
547
+ const text = this._extractFeedLineText(feedLineIdx);
548
+ URL_REGEX.lastIndex = 0;
549
+ const urls = [...text.matchAll(URL_REGEX)];
550
+ if (urls.length === 1) {
551
+ this.emit("link", urls[0][0]);
552
+ return true;
553
+ }
554
+ if (urls.length > 1) {
555
+ // Try to resolve which URL based on click position
556
+ const row = this._screenToFeedRow.get(me.y) ?? 0;
557
+ const col = me.x - this._feedX;
558
+ const charOffset = row * this._contentWidth + col;
559
+ const hit = this._findUrlAtOffset(text, charOffset);
560
+ this.emit("link", hit ?? urls[0][0]);
561
+ return true;
562
+ }
563
+ }
564
+ }
565
+ // Action hover/click in feed area
566
+ if (this._feedActions.size > 0) {
567
+ const feedLineIdx = this._screenToFeedLine.get(me.y) ?? -1;
568
+ const entry = feedLineIdx >= 0 ? this._feedActions.get(feedLineIdx) : undefined;
569
+ if (me.type === "move") {
570
+ const newHover = entry ? feedLineIdx : -1;
571
+ if (newHover !== this._hoveredAction ||
572
+ (entry && entry.items.length > 1)) {
573
+ // Restore previous hover
574
+ if (this._hoveredAction >= 0) {
575
+ const prev = this._feedActions.get(this._hoveredAction);
576
+ if (prev) {
577
+ this._feedLines[this._hoveredAction].lines = [prev.normalStyle];
578
+ }
579
+ }
580
+ // Apply new hover — highlight only the hovered action item
581
+ if (entry && newHover >= 0) {
582
+ const hitItem = this._resolveActionItem(entry, me.x);
583
+ const hoverLine = this._buildHoverLine(entry, hitItem);
584
+ this._feedLines[newHover].lines = [hoverLine];
585
+ }
586
+ this._hoveredAction = newHover;
587
+ this.invalidate();
588
+ }
589
+ // Don't consume — let other handlers run too
590
+ }
591
+ if (me.type === "press" && me.button === "left" && entry) {
592
+ const hitItem = this._resolveActionItem(entry, me.x);
593
+ if (hitItem)
594
+ this.emit("action", hitItem.id);
595
+ return true;
596
+ }
597
+ }
598
+ }
599
+ // Delegate to override widget or normal input
600
+ if (this._inputOverride) {
601
+ return this._inputOverride.handleInput(event);
602
+ }
603
+ return this._input.handleInput(event);
604
+ }
605
+ /** Extract the plain text content of a feed line. */
606
+ _extractFeedLineText(idx) {
607
+ const styledText = this._feedLines[idx];
608
+ if (!styledText)
609
+ return "";
610
+ return styledText.lines
611
+ .map((line) => {
612
+ if (typeof line === "string")
613
+ return line;
614
+ return line.map((seg) => seg.text).join("");
615
+ })
616
+ .join("\n");
617
+ }
618
+ /** Find the URL at the given character offset, if any. */
619
+ _findUrlAtOffset(text, charOffset) {
620
+ URL_REGEX.lastIndex = 0;
621
+ let match;
622
+ while ((match = URL_REGEX.exec(text)) !== null) {
623
+ if (charOffset >= match.index &&
624
+ charOffset < match.index + match[0].length) {
625
+ return match[0];
626
+ }
627
+ }
628
+ return null;
629
+ }
630
+ /** Resolve which action item the mouse x-position falls on. */
631
+ _resolveActionItem(entry, x) {
632
+ if (entry.items.length === 1)
633
+ return entry.items[0];
634
+ // Calculate text length of each item's normal style to find boundaries
635
+ let col = 0;
636
+ for (const item of entry.items) {
637
+ const len = this._spanTextLength(item.normalStyle);
638
+ if (x < col + len)
639
+ return item;
640
+ col += len;
641
+ }
642
+ return entry.items[entry.items.length - 1];
643
+ }
644
+ /** Build a hover line: highlight only the target item, keep others normal. */
645
+ _buildHoverLine(entry, target) {
646
+ if (entry.items.length === 1 && target)
647
+ return target.hoverStyle;
648
+ const parts = entry.items.map((item) => item === target ? item.hoverStyle : item.normalStyle);
649
+ return this._concatSpans(parts);
650
+ }
651
+ /** Get the plain text length of a StyledLine. */
652
+ _spanTextLength(span) {
653
+ if (typeof span === "string")
654
+ return span.length;
655
+ const segments = span;
656
+ if (!Array.isArray(segments))
657
+ return 0;
658
+ let len = 0;
659
+ for (const seg of segments) {
660
+ if (typeof seg === "string")
661
+ len += seg.length;
662
+ else if (seg && typeof seg === "object" && "text" in seg)
663
+ len += seg.text.length;
664
+ }
665
+ return len;
666
+ }
667
+ // ── Layout ─────────────────────────────────────────────────────
668
+ measure(constraint) {
669
+ // ChatView always fills the full available space
670
+ const size = {
671
+ width: constraint.maxWidth,
672
+ height: constraint.maxHeight,
673
+ };
674
+ this.desiredSize = size;
675
+ return size;
676
+ }
677
+ arrange(rect) {
678
+ this.bounds = rect;
679
+ // Actual child arrangement happens in render() because we need
680
+ // to know the computed heights of variable-height elements.
681
+ }
682
+ // ── Render ─────────────────────────────────────────────────────
683
+ render(ctx) {
684
+ const b = this.bounds;
685
+ if (!b || b.width < 1 || b.height < 3)
686
+ return;
687
+ const W = b.width;
688
+ const H = b.height;
689
+ // ── Measure fixed-height sections ────────────────────────
690
+ // Progress text height (always 1 row when visible)
691
+ let progressH = 0;
692
+ if (this._progressText.visible && this._progressText.lines.length > 0) {
693
+ progressH = 1;
694
+ }
695
+ // Bottom separator: 1 row
696
+ const botSepH = 1;
697
+ // When an input override is active, it replaces input + inputSep + footer
698
+ if (this._inputOverride) {
699
+ // Measure the override widget
700
+ const overrideSize = this._inputOverride.measure({
701
+ minWidth: 0,
702
+ maxWidth: W,
703
+ minHeight: 0,
704
+ maxHeight: Math.max(1, Math.floor(H / 2)), // up to half the screen
705
+ });
706
+ const overrideH = overrideSize.height;
707
+ const chromeH = botSepH + progressH + overrideH;
708
+ const feedH = Math.max(0, H - chromeH);
709
+ let y = b.y;
710
+ // 1. Feed area
711
+ if (feedH > 0) {
712
+ this._renderFeed(ctx, b.x, y, W, feedH);
713
+ y += feedH;
714
+ }
715
+ // 2. Progress text
716
+ if (progressH > 0) {
717
+ this._progressText.measure({
718
+ minWidth: 0,
719
+ maxWidth: W,
720
+ minHeight: 0,
721
+ maxHeight: 1,
722
+ });
723
+ this._progressText.arrange({
724
+ x: b.x,
725
+ y,
726
+ width: W,
727
+ height: progressH,
728
+ });
729
+ this._progressText.render(ctx);
730
+ y += progressH;
731
+ }
732
+ // 3. Bottom separator
733
+ this._bottomSeparator.arrange({ x: b.x, y, width: W, height: 1 });
734
+ this._bottomSeparator.render(ctx);
735
+ y += 1;
736
+ // 4. Override widget (replaces input + inputSep + footer)
737
+ this._inputOverride.arrange({
738
+ x: b.x,
739
+ y,
740
+ width: W,
741
+ height: overrideH,
742
+ });
743
+ this._inputOverride.render(ctx);
744
+ return;
745
+ }
746
+ // ── Normal input mode ────────────────────────────────────
747
+ // Input: measure to get wrapped height (up to maxInputH rows)
748
+ const inputSize = this._input.measure({
749
+ minWidth: 0,
750
+ maxWidth: W,
751
+ minHeight: 0,
752
+ maxHeight: this._maxInputH,
753
+ });
754
+ const inputH = inputSize.height;
755
+ // Input separator: 1 row (between input and footer/dropdown)
756
+ const inputSepH = 1;
757
+ // Footer: always 1 row (shows footer text or first row of dropdown)
758
+ const footerH = 1;
759
+ // Dropdown height — when active, replaces the footer row and can grow.
760
+ const chromeH = botSepH + progressH + inputH + inputSepH + footerH;
761
+ const hasDropdown = this._dropdownItems.length > 0;
762
+ const maxDropdownH = Math.max(0, H - chromeH);
763
+ const dropdownExtraH = hasDropdown
764
+ ? Math.min(this._dropdownItems.length - 1, maxDropdownH)
765
+ : 0;
766
+ // Feed gets remaining space (banner + separator scroll within it)
767
+ const fixedH = chromeH + dropdownExtraH;
768
+ const feedH = Math.max(0, H - fixedH);
769
+ // ── Arrange and render each section ──────────────────────
770
+ let y = b.y;
771
+ // 1. Feed area (banner + separator + feed lines all scroll together)
772
+ if (feedH > 0) {
773
+ this._renderFeed(ctx, b.x, y, W, feedH);
774
+ y += feedH;
775
+ }
776
+ // 2. Progress text (above separator, fixed — not part of scrollable feed)
777
+ if (progressH > 0) {
778
+ this._progressText.measure({
779
+ minWidth: 0,
780
+ maxWidth: W,
781
+ minHeight: 0,
782
+ maxHeight: 1,
783
+ });
784
+ this._progressText.arrange({ x: b.x, y, width: W, height: progressH });
785
+ this._progressText.render(ctx);
786
+ y += progressH;
787
+ }
788
+ // 3. Bottom separator
789
+ this._bottomSeparator.arrange({ x: b.x, y, width: W, height: 1 });
790
+ this._bottomSeparator.render(ctx);
791
+ y += 1;
792
+ // 4. Input
793
+ this._input.arrange({ x: b.x, y, width: W, height: inputH });
794
+ this._input.render(ctx);
795
+ y += inputH;
796
+ // 5. Input separator
797
+ this._inputSeparator.arrange({ x: b.x, y, width: W, height: 1 });
798
+ this._inputSeparator.render(ctx);
799
+ y += inputSepH;
800
+ // 6. Dropdown or footer
801
+ if (hasDropdown) {
802
+ const totalDropdownH = dropdownExtraH + 1;
803
+ this._renderDropdown(ctx, b.x, y, W, totalDropdownH);
804
+ }
805
+ else {
806
+ this._footer.measure({
807
+ minWidth: 0,
808
+ maxWidth: W,
809
+ minHeight: 0,
810
+ maxHeight: 1,
811
+ });
812
+ this._footer.arrange({ x: b.x, y, width: W, height: footerH });
813
+ this._footer.render(ctx);
814
+ }
815
+ }
816
+ // ── Feed rendering ─────────────────────────────────────────────
817
+ _renderFeed(ctx, x, y, width, height) {
818
+ // Build the list of scrollable items: banner + separator + feed lines
819
+ // Each item is { control, height } measured against content width.
820
+ const contentWidth = width - 1; // reserve 1 col for scrollbar
821
+ this._feedX = x;
822
+ this._contentWidth = contentWidth;
823
+ const items = [];
824
+ // Banner (if visible)
825
+ if (this._banner.visible) {
826
+ const bannerSize = this._banner.measure({
827
+ minWidth: 0,
828
+ maxWidth: contentWidth,
829
+ minHeight: 0,
830
+ maxHeight: Infinity,
831
+ });
832
+ const bh = Math.max(1, bannerSize.height);
833
+ items.push({
834
+ height: bh,
835
+ feedLineIdx: -1,
836
+ render: (cx, cy, cw, ch) => {
837
+ this._banner.arrange({ x: cx, y: cy, width: cw, height: ch });
838
+ this._banner.render(ctx);
839
+ },
840
+ });
841
+ // Top separator after banner
842
+ items.push({
843
+ height: 1,
844
+ feedLineIdx: -1,
845
+ render: (cx, cy, cw, _ch) => {
846
+ this._topSeparator.arrange({ x: cx, y: cy, width: cw, height: 1 });
847
+ this._topSeparator.render(ctx);
848
+ },
849
+ });
850
+ }
851
+ // Feed lines
852
+ for (let fi = 0; fi < this._feedLines.length; fi++) {
853
+ const line = this._feedLines[fi];
854
+ const lineSize = line.measure({
855
+ minWidth: 0,
856
+ maxWidth: contentWidth,
857
+ minHeight: 0,
858
+ maxHeight: Infinity,
859
+ });
860
+ const h = Math.max(1, lineSize.height);
861
+ items.push({
862
+ height: h,
863
+ feedLineIdx: fi,
864
+ render: (cx, cy, cw, ch) => {
865
+ line.arrange({ x: cx, y: cy, width: cw, height: ch });
866
+ line.render(ctx);
867
+ },
868
+ });
869
+ }
870
+ // Calculate total content height
871
+ let totalContentH = 0;
872
+ for (const item of items) {
873
+ totalContentH += item.height;
874
+ }
875
+ // Clamp scroll offset
876
+ const maxScroll = Math.max(0, totalContentH - height);
877
+ this._feedScrollOffset = Math.max(0, Math.min(this._feedScrollOffset, maxScroll));
878
+ // Clip feed area
879
+ ctx.pushClip({ x, y, width, height });
880
+ // Find the first visible item
881
+ let skippedRows = 0;
882
+ let startIdx = 0;
883
+ for (let i = 0; i < items.length; i++) {
884
+ if (skippedRows + items[i].height > this._feedScrollOffset)
885
+ break;
886
+ skippedRows += items[i].height;
887
+ startIdx = i + 1;
888
+ }
889
+ // Render visible items and build screen→feedLine map
890
+ this._screenToFeedLine.clear();
891
+ this._screenToFeedRow.clear();
892
+ let cy = y - (this._feedScrollOffset - skippedRows);
893
+ for (let i = startIdx; i < items.length && cy < y + height; i++) {
894
+ const item = items[i];
895
+ item.render(x, cy, contentWidth, item.height);
896
+ // Map screen rows to feed line index + row offset for hit-testing
897
+ if (item.feedLineIdx >= 0) {
898
+ for (let row = 0; row < item.height; row++) {
899
+ const screenY = cy + row;
900
+ if (screenY >= y && screenY < y + height) {
901
+ this._screenToFeedLine.set(screenY, item.feedLineIdx);
902
+ this._screenToFeedRow.set(screenY, row);
903
+ }
904
+ }
905
+ }
906
+ cy += item.height;
907
+ }
908
+ // Render scrollbar and cache geometry for hit-testing
909
+ if (height > 0 && totalContentH > height) {
910
+ const scrollX = x + width - 1;
911
+ const thumbSize = Math.max(1, Math.round((height / totalContentH) * height));
912
+ const thumbPos = maxScroll > 0
913
+ ? Math.round((this._feedScrollOffset / maxScroll) * (height - thumbSize))
914
+ : 0;
915
+ const trackStyle = this._separatorStyle;
916
+ const thumbStyle = this._feedStyle;
917
+ // Cache for mouse interaction
918
+ this._scrollbarX = scrollX;
919
+ this._feedY = y;
920
+ this._feedH = height;
921
+ this._thumbPos = thumbPos;
922
+ this._thumbSize = thumbSize;
923
+ this._maxScroll = maxScroll;
924
+ this._scrollbarVisible = true;
925
+ for (let row = 0; row < height; row++) {
926
+ const inThumb = row >= thumbPos && row < thumbPos + thumbSize;
927
+ ctx.drawChar(scrollX, y + row, inThumb ? "┃" : "│", inThumb ? thumbStyle : trackStyle);
928
+ }
929
+ }
930
+ else {
931
+ this._scrollbarVisible = false;
932
+ }
933
+ ctx.popClip();
934
+ }
935
+ // ── Dropdown rendering ─────────────────────────────────────────
936
+ _renderDropdown(ctx, x, y, width, height) {
937
+ for (let i = 0; i < this._dropdownItems.length && i < height; i++) {
938
+ const item = this._dropdownItems[i];
939
+ const isHighlighted = i === this._dropdownIndex;
940
+ const style = isHighlighted
941
+ ? this._dropdownHighlightStyle
942
+ : this._dropdownStyle;
943
+ const prefix = isHighlighted ? "▸ " : " ";
944
+ const labelPad = item.label.padEnd(16);
945
+ const text = prefix + labelPad + item.description;
946
+ const truncated = text.length > width ? text.slice(0, width) : text;
947
+ ctx.drawText(x, y + i, truncated, style);
948
+ }
949
+ }
950
+ // ── Auto-scroll ────────────────────────────────────────────────
951
+ _autoScrollToBottom() {
952
+ // Set scroll to a very large value; it will be clamped during render
953
+ this._feedScrollOffset = Number.MAX_SAFE_INTEGER;
954
+ }
955
+ }
956
+ // ── Internal: Separator line control ─────────────────────────────
957
+ /**
958
+ * Thin separator line that fills its width with a repeated character.
959
+ * Used for the horizontal rules between banner/feed/input.
960
+ */
961
+ class _Separator extends Control {
962
+ _char;
963
+ _style;
964
+ constructor(char, style) {
965
+ super();
966
+ this._char = char;
967
+ this._style = style;
968
+ }
969
+ get separatorChar() {
970
+ return this._char;
971
+ }
972
+ set separatorChar(c) {
973
+ this._char = c;
974
+ this.invalidate();
975
+ }
976
+ get style() {
977
+ return this._style;
978
+ }
979
+ set style(s) {
980
+ this._style = s;
981
+ this.invalidate();
982
+ }
983
+ measure(_constraint) {
984
+ return { width: _constraint.maxWidth, height: 1 };
985
+ }
986
+ render(ctx) {
987
+ const b = this.bounds;
988
+ if (!b)
989
+ return;
990
+ const line = this._char.repeat(b.width);
991
+ ctx.drawText(b.x, b.y, line, this._style);
992
+ }
993
+ }