novac 2.0.1 → 2.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 (161) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +1574 -597
  3. package/bin/novac +468 -171
  4. package/bin/nvc +522 -0
  5. package/bin/nvml +78 -17
  6. package/demo.nv +0 -0
  7. package/demo_builtins.nv +0 -0
  8. package/demo_http.nv +0 -0
  9. package/examples/bf.nv +69 -0
  10. package/examples/math.nv +21 -0
  11. package/kits/birdAPI/kitdef.js +954 -0
  12. package/kits/kitRNG/kitdef.js +740 -0
  13. package/kits/kitSSH/kitdef.js +1272 -0
  14. package/kits/kitadb/kitdef.js +606 -0
  15. package/kits/kitai/kitdef.js +2185 -0
  16. package/kits/kitansi/kitdef.js +1402 -0
  17. package/kits/kitcanvas/kitdef.js +914 -0
  18. package/kits/kitclippy/kitdef.js +925 -0
  19. package/kits/kitformat/kitdef.js +1485 -0
  20. package/kits/kitgps/kitdef.js +1862 -0
  21. package/kits/kitlibproc/kitdef.js +3 -2
  22. package/kits/kitmatrix/ex.js +19 -0
  23. package/kits/kitmatrix/kitdef.js +960 -0
  24. package/kits/kitmorse/kitdef.js +229 -0
  25. package/kits/kitmpatch/kitdef.js +906 -0
  26. package/kits/kitnet/kitdef.js +1401 -0
  27. package/kits/kitnovacweb/README.md +1416 -143
  28. package/kits/kitnovacweb/kitdef.js +92 -2
  29. package/kits/kitnovacweb/nvml/executor.js +578 -176
  30. package/kits/kitnovacweb/nvml/index.js +2 -2
  31. package/kits/kitnovacweb/nvml/lexer.js +72 -69
  32. package/kits/kitnovacweb/nvml/parser.js +328 -159
  33. package/kits/kitnovacweb/nvml/renderer.js +770 -270
  34. package/kits/kitparse/kitdef.js +1688 -0
  35. package/kits/kitproto/kitdef.js +613 -0
  36. package/kits/kitqr/kitdef.js +637 -0
  37. package/kits/kitregex++/kitdef.js +1353 -0
  38. package/kits/kitrequire/kitdef.js +1599 -0
  39. package/kits/kitx11/kitdef.js +1 -0
  40. package/kits/kitx11/kitx11.js +2472 -0
  41. package/kits/kitx11/kitx11_conn.js +948 -0
  42. package/kits/kitx11/kitx11_worker.js +121 -0
  43. package/kits/libtea/kitdef.js +2691 -0
  44. package/kits/libterm/ex.js +285 -0
  45. package/kits/libterm/kitdef.js +1927 -0
  46. package/novac/LICENSE +21 -0
  47. package/novac/README.md +1823 -0
  48. package/novac/bin/novac +950 -0
  49. package/novac/bin/nvc +522 -0
  50. package/novac/bin/nvml +542 -0
  51. package/novac/demo.nv +245 -0
  52. package/novac/demo_builtins.nv +209 -0
  53. package/novac/demo_http.nv +62 -0
  54. package/novac/examples/bf.nv +69 -0
  55. package/novac/examples/math.nv +21 -0
  56. package/novac/kits/kitai/kitdef.js +2185 -0
  57. package/novac/kits/kitansi/kitdef.js +1402 -0
  58. package/novac/kits/kitformat/kitdef.js +1485 -0
  59. package/novac/kits/kitgps/kitdef.js +1862 -0
  60. package/novac/kits/kitlibfs/kitdef.js +231 -0
  61. package/{examples/example-project/nova_modules → novac/kits}/kitlibproc/kitdef.js +3 -2
  62. package/novac/kits/kitmatrix/ex.js +19 -0
  63. package/novac/kits/kitmatrix/kitdef.js +960 -0
  64. package/novac/kits/kitmpatch/kitdef.js +906 -0
  65. package/novac/kits/kitnovacweb/README.md +1572 -0
  66. package/novac/kits/kitnovacweb/demo.nv +12 -0
  67. package/novac/kits/kitnovacweb/demo.nvml +71 -0
  68. package/novac/kits/kitnovacweb/index.nova +12 -0
  69. package/novac/kits/kitnovacweb/kitdef.js +692 -0
  70. package/novac/kits/kitnovacweb/nova.kit.json +8 -0
  71. package/novac/kits/kitnovacweb/nvml/executor.js +739 -0
  72. package/novac/kits/kitnovacweb/nvml/index.js +67 -0
  73. package/novac/kits/kitnovacweb/nvml/lexer.js +263 -0
  74. package/novac/kits/kitnovacweb/nvml/parser.js +508 -0
  75. package/novac/kits/kitnovacweb/nvml/renderer.js +924 -0
  76. package/novac/kits/kitparse/kitdef.js +1688 -0
  77. package/novac/kits/kitregex++/kitdef.js +1353 -0
  78. package/novac/kits/kitrequire/kitdef.js +1599 -0
  79. package/novac/kits/kitx11/kitdef.js +1 -0
  80. package/novac/kits/kitx11/kitx11.js +2472 -0
  81. package/novac/kits/kitx11/kitx11_conn.js +948 -0
  82. package/novac/kits/kitx11/kitx11_worker.js +121 -0
  83. package/novac/kits/libtea/tf.js +2691 -0
  84. package/novac/kits/libterm/ex.js +285 -0
  85. package/novac/kits/libterm/kitdef.js +1927 -0
  86. package/novac/node_modules/chalk/license +9 -0
  87. package/novac/node_modules/chalk/package.json +83 -0
  88. package/novac/node_modules/chalk/readme.md +297 -0
  89. package/novac/node_modules/chalk/source/index.d.ts +325 -0
  90. package/novac/node_modules/chalk/source/index.js +225 -0
  91. package/novac/node_modules/chalk/source/utilities.js +33 -0
  92. package/novac/node_modules/chalk/source/vendor/ansi-styles/index.d.ts +236 -0
  93. package/novac/node_modules/chalk/source/vendor/ansi-styles/index.js +223 -0
  94. package/novac/node_modules/chalk/source/vendor/supports-color/browser.d.ts +1 -0
  95. package/novac/node_modules/chalk/source/vendor/supports-color/browser.js +34 -0
  96. package/novac/node_modules/chalk/source/vendor/supports-color/index.d.ts +55 -0
  97. package/novac/node_modules/chalk/source/vendor/supports-color/index.js +190 -0
  98. package/novac/node_modules/commander/LICENSE +22 -0
  99. package/novac/node_modules/commander/Readme.md +1176 -0
  100. package/novac/node_modules/commander/esm.mjs +16 -0
  101. package/novac/node_modules/commander/index.js +24 -0
  102. package/novac/node_modules/commander/lib/argument.js +150 -0
  103. package/novac/node_modules/commander/lib/command.js +2777 -0
  104. package/novac/node_modules/commander/lib/error.js +39 -0
  105. package/novac/node_modules/commander/lib/help.js +747 -0
  106. package/novac/node_modules/commander/lib/option.js +380 -0
  107. package/novac/node_modules/commander/lib/suggestSimilar.js +101 -0
  108. package/novac/node_modules/commander/package-support.json +19 -0
  109. package/novac/node_modules/commander/package.json +82 -0
  110. package/novac/node_modules/commander/typings/esm.d.mts +3 -0
  111. package/novac/node_modules/commander/typings/index.d.ts +1113 -0
  112. package/novac/node_modules/node-addon-api/LICENSE.md +9 -0
  113. package/novac/node_modules/node-addon-api/README.md +95 -0
  114. package/novac/node_modules/node-addon-api/common.gypi +21 -0
  115. package/novac/node_modules/node-addon-api/except.gypi +25 -0
  116. package/novac/node_modules/node-addon-api/index.js +14 -0
  117. package/novac/node_modules/node-addon-api/napi-inl.deprecated.h +186 -0
  118. package/novac/node_modules/node-addon-api/napi-inl.h +7165 -0
  119. package/novac/node_modules/node-addon-api/napi.h +3364 -0
  120. package/novac/node_modules/node-addon-api/node_addon_api.gyp +42 -0
  121. package/novac/node_modules/node-addon-api/node_api.gyp +9 -0
  122. package/novac/node_modules/node-addon-api/noexcept.gypi +26 -0
  123. package/novac/node_modules/node-addon-api/package-support.json +21 -0
  124. package/novac/node_modules/node-addon-api/package.json +480 -0
  125. package/novac/node_modules/node-addon-api/tools/README.md +73 -0
  126. package/novac/node_modules/node-addon-api/tools/check-napi.js +99 -0
  127. package/novac/node_modules/node-addon-api/tools/clang-format.js +71 -0
  128. package/novac/node_modules/node-addon-api/tools/conversion.js +301 -0
  129. package/novac/node_modules/serialize-javascript/LICENSE +27 -0
  130. package/novac/node_modules/serialize-javascript/README.md +149 -0
  131. package/novac/node_modules/serialize-javascript/index.js +297 -0
  132. package/novac/node_modules/serialize-javascript/package.json +33 -0
  133. package/novac/package.json +27 -0
  134. package/novac/scripts/update-bin.js +24 -0
  135. package/novac/src/core/bstd.js +1035 -0
  136. package/novac/src/core/config.js +155 -0
  137. package/novac/src/core/describe.js +187 -0
  138. package/novac/src/core/emitter.js +499 -0
  139. package/novac/src/core/error.js +86 -0
  140. package/novac/src/core/executor.js +5606 -0
  141. package/novac/src/core/formatter.js +686 -0
  142. package/novac/src/core/lexer.js +1026 -0
  143. package/novac/src/core/nova_builtins.js +717 -0
  144. package/novac/src/core/nova_thread_worker.js +166 -0
  145. package/novac/src/core/parser.js +2181 -0
  146. package/novac/src/core/types.js +112 -0
  147. package/novac/src/index.js +28 -0
  148. package/novac/src/runtime/stdlib.js +244 -0
  149. package/package.json +6 -3
  150. package/scripts/update-bin.js +0 -0
  151. package/src/core/bstd.js +838 -362
  152. package/src/core/executor.js +2578 -170
  153. package/src/core/lexer.js +502 -54
  154. package/src/core/nova_builtins.js +21 -3
  155. package/src/core/parser.js +413 -72
  156. package/src/core/types.js +30 -2
  157. package/src/index.js +0 -0
  158. package/examples/example-project/README.md +0 -3
  159. package/examples/example-project/src/main.nova +0 -3
  160. package/src/core/environment.js +0 -0
  161. /package/{examples/example-project/bin/example-project.nv → novac/node_modules/node-addon-api/nothing.c} +0 -0
@@ -0,0 +1,1927 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * ╔═══════════════════════════════════════════════════════════════════════════╗
5
+ * ║ kitdef.js v1.0 ║
6
+ * ║ ║
7
+ * ║ A complete terminal application kit in a single CJS module. ║
8
+ * ║ ║
9
+ * ║ Subsystems ║
10
+ * ║ ─────────────────────────────────────────────────────────────────────── ║
11
+ * ║ tview TVPixel, TView, InputManager — pixel-grid rendering engine ║
12
+ * ║ tshell TShell — spawn / pipe / interact with child processes ║
13
+ * ║ tenv TEnv — typed env-var config with validation & defaults ║
14
+ * ║ tlog TLog — levelled logger that streams into a TView ║
15
+ * ║ ttheme TTheme — named colour/style token system with dark/light ║
16
+ * ║ twidget TWidget — higher-level widgets (menu, list, dialog, input…) ║
17
+ * ║ trouter TRouter — keyboard-driven "screen router" (push/pop/replace) ║
18
+ * ║ ║
19
+ * ║ Top-level export ║
20
+ * ║ ─────────────────────────────────────────────────────────────────────── ║
21
+ * ║ Terminal — one object that boots everything and wires it together ║
22
+ * ║ ║
23
+ * ║ Usage ║
24
+ * ║ const { kitdef } = require('./kitdef'); ║
25
+ * ║ const { Terminal, TView, TShell, TEnv, TLog, ║
26
+ * ║ TTheme, TWidget, TRouter } = kitdef; ║
27
+ * ╚═══════════════════════════════════════════════════════════════════════════╝
28
+ */
29
+
30
+ const { EventEmitter } = require('events');
31
+ const { spawn, spawnSync } = require('child_process');
32
+ const fs = require('fs');
33
+ const path = require('path');
34
+ const os = require('os');
35
+
36
+ // ═════════════════════════════════════════════════════════════════════════════
37
+ // §1 ANSI / colour primitives
38
+ // ═════════════════════════════════════════════════════════════════════════════
39
+
40
+ const ESC = '\x1b';
41
+
42
+ const ansi = {
43
+ reset: `${ESC}[0m`,
44
+ clear: `${ESC}[2J`,
45
+ clearLine: `${ESC}[2K`,
46
+ altScreenOn: `${ESC}[?1049h`,
47
+ altScreenOff: `${ESC}[?1049l`,
48
+ hideCursor: `${ESC}[?25l`,
49
+ showCursor: `${ESC}[?25h`,
50
+ cursorHome: `${ESC}[H`,
51
+ saveCursor: `${ESC}[s`,
52
+ restoreCursor: `${ESC}[u`,
53
+ mouseOn: `${ESC}[?1000h${ESC}[?1002h${ESC}[?1006h`,
54
+ mouseOff: `${ESC}[?1006l${ESC}[?1002l${ESC}[?1000l`,
55
+ moveTo: (r, c) => `${ESC}[${r};${c}H`,
56
+ fg256: n => `${ESC}[38;5;${n}m`,
57
+ bg256: n => `${ESC}[48;5;${n}m`,
58
+ fg: c => _colorSeq(c, false),
59
+ bg: c => _colorSeq(c, true),
60
+ bold: `${ESC}[1m`,
61
+ dim: `${ESC}[2m`,
62
+ italic: `${ESC}[3m`,
63
+ underline: `${ESC}[4m`,
64
+ blink: `${ESC}[5m`,
65
+ reverse: `${ESC}[7m`,
66
+ strikethrough: `${ESC}[9m`,
67
+ };
68
+
69
+ const NAMED_COLORS = {
70
+ black:0, red:1, green:2, yellow:3, blue:4, magenta:5,
71
+ cyan:6, white:7, gray:8, grey:8,
72
+ brightred:9, brightgreen:10, brightyellow:11, brightblue:12,
73
+ brightmagenta:13, brightcyan:14, brightwhite:15,
74
+ };
75
+
76
+ function _colorSeq(color, isBg) {
77
+ if (color == null) return '';
78
+ const p = isBg ? '48' : '38';
79
+ if (typeof color === 'string') {
80
+ const l = color.toLowerCase();
81
+ if (l === 'default' || l === 'reset') return isBg ? `${ESC}[49m` : `${ESC}[39m`;
82
+ if (l in NAMED_COLORS) return `${ESC}[${p};5;${NAMED_COLORS[l]}m`;
83
+ if (/^#[0-9a-f]{6}$/i.test(l)) {
84
+ const r = parseInt(l.slice(1,3),16), g = parseInt(l.slice(3,5),16), b = parseInt(l.slice(5,7),16);
85
+ return `${ESC}[${p};2;${r};${g};${b}m`;
86
+ }
87
+ }
88
+ if (Array.isArray(color)) {
89
+ if (color.length === 3) return `${ESC}[${p};2;${color[0]};${color[1]};${color[2]}m`;
90
+ if (color.length === 1) return `${ESC}[${p};5;${color[0]}m`;
91
+ }
92
+ if (typeof color === 'number') return `${ESC}[${p};5;${color}m`;
93
+ return '';
94
+ }
95
+
96
+ // ═════════════════════════════════════════════════════════════════════════════
97
+ // §2 KEY_MAP (escape sequence → named key)
98
+ // ═════════════════════════════════════════════════════════════════════════════
99
+
100
+ const KEY_MAP = {
101
+ '\x1b[A':{name:'up'}, '\x1b[B':{name:'down'},
102
+ '\x1b[C':{name:'right'}, '\x1b[D':{name:'left'},
103
+ '\x1b[1;2A':{name:'up',shift:true}, '\x1b[1;2B':{name:'down',shift:true},
104
+ '\x1b[1;2C':{name:'right',shift:true},'\x1b[1;2D':{name:'left',shift:true},
105
+ '\x1b[1;5A':{name:'up',ctrl:true}, '\x1b[1;5B':{name:'down',ctrl:true},
106
+ '\x1b[1;5C':{name:'right',ctrl:true}, '\x1b[1;5D':{name:'left',ctrl:true},
107
+ '\x1b[1;3A':{name:'up',alt:true}, '\x1b[1;3B':{name:'down',alt:true},
108
+ '\x1b[1;3C':{name:'right',alt:true}, '\x1b[1;3D':{name:'left',alt:true},
109
+ '\x1bOP':{name:'f1'},'\x1bOQ':{name:'f2'},'\x1bOR':{name:'f3'},'\x1bOS':{name:'f4'},
110
+ '\x1b[15~':{name:'f5'},'\x1b[17~':{name:'f6'},'\x1b[18~':{name:'f7'},
111
+ '\x1b[19~':{name:'f8'},'\x1b[20~':{name:'f9'},'\x1b[21~':{name:'f10'},
112
+ '\x1b[23~':{name:'f11'},'\x1b[24~':{name:'f12'},
113
+ '\x1b[H':{name:'home'},'\x1b[F':{name:'end'},
114
+ '\x1b[5~':{name:'pageup'},'\x1b[6~':{name:'pagedown'},
115
+ '\x1b[2~':{name:'insert'},'\x1b[3~':{name:'delete'},
116
+ '\x1b[Z':{name:'tab',shift:true},
117
+ '\r':{name:'enter'},'\n':{name:'enter'},'\t':{name:'tab'},
118
+ '\x7f':{name:'backspace'},'\x08':{name:'backspace'},'\x1b':{name:'escape'},
119
+ '\x01':{name:'a',ctrl:true},'\x02':{name:'b',ctrl:true},'\x03':{name:'c',ctrl:true},
120
+ '\x04':{name:'d',ctrl:true},'\x05':{name:'e',ctrl:true},'\x06':{name:'f',ctrl:true},
121
+ '\x07':{name:'g',ctrl:true},'\x0b':{name:'k',ctrl:true},'\x0c':{name:'l',ctrl:true},
122
+ '\x0e':{name:'n',ctrl:true},'\x0f':{name:'o',ctrl:true},'\x10':{name:'p',ctrl:true},
123
+ '\x11':{name:'q',ctrl:true},'\x12':{name:'r',ctrl:true},'\x13':{name:'s',ctrl:true},
124
+ '\x14':{name:'t',ctrl:true},'\x15':{name:'u',ctrl:true},'\x16':{name:'v',ctrl:true},
125
+ '\x17':{name:'w',ctrl:true},'\x18':{name:'x',ctrl:true},'\x19':{name:'y',ctrl:true},
126
+ '\x1a':{name:'z',ctrl:true},
127
+ };
128
+
129
+ // ═════════════════════════════════════════════════════════════════════════════
130
+ // §3 BORDER_STYLES
131
+ // ═════════════════════════════════════════════════════════════════════════════
132
+
133
+ const BORDER_STYLES = {
134
+ single: {tl:'┌',tr:'┐',bl:'└',br:'┘',h:'─',v:'│'},
135
+ double: {tl:'╔',tr:'╗',bl:'╚',br:'╝',h:'═',v:'║'},
136
+ rounded: {tl:'╭',tr:'╮',bl:'╰',br:'╯',h:'─',v:'│'},
137
+ heavy: {tl:'┏',tr:'┓',bl:'┗',br:'┛',h:'━',v:'┃'},
138
+ ascii: {tl:'+',tr:'+',bl:'+',br:'+',h:'-',v:'|'},
139
+ none: {tl:' ',tr:' ',bl:' ',br:' ',h:' ',v:' '},
140
+ };
141
+
142
+ // ═════════════════════════════════════════════════════════════════════════════
143
+ // §4 tview — TVPixel · TView · InputManager
144
+ // ═════════════════════════════════════════════════════════════════════════════
145
+
146
+ // ── TVPixel ───────────────────────────────────────────────────────────────────
147
+
148
+ class TVPixel {
149
+ /**
150
+ * A single rendered cell.
151
+ * @param {object} o
152
+ * @param {string} [o.char=' '] display character
153
+ * @param {boolean} [o.isStatic=false] writes ignored when true
154
+ * @param {*} [o.color] [fg,bg] pair or single fg
155
+ * @param {string} [o.style] bold|dim|italic|underline|blink|reverse|strike
156
+ */
157
+ constructor({ char=' ', isStatic=false, color=[null,null], style=null } = {}) {
158
+ this.char = String(char).slice(0,1) || ' ';
159
+ this.isStatic = Boolean(isStatic);
160
+ if (Array.isArray(color) && color.length === 2) { this.fg=color[0]; this.bg=color[1]; }
161
+ else { this.fg=color; this.bg=null; }
162
+ this.style = style;
163
+ }
164
+ render() {
165
+ let o = '';
166
+ if (this.fg != null) o += ansi.fg(this.fg);
167
+ if (this.bg != null) o += ansi.bg(this.bg);
168
+ if (this.style) o += ({bold:ansi.bold,dim:ansi.dim,italic:ansi.italic,
169
+ underline:ansi.underline,blink:ansi.blink,reverse:ansi.reverse,
170
+ strike:ansi.strikethrough,strikethrough:ansi.strikethrough})[this.style]||'';
171
+ return o + this.char + ansi.reset;
172
+ }
173
+ clone() { return new TVPixel({char:this.char,isStatic:this.isStatic,color:[this.fg,this.bg],style:this.style}); }
174
+ set(char) { if (!this.isStatic) this.char = String(char).slice(0,1)||' '; return this; }
175
+ }
176
+
177
+ // ── InputManager ─────────────────────────────────────────────────────────────
178
+
179
+ class InputManager extends EventEmitter {
180
+ constructor() {
181
+ super();
182
+ this._views = [];
183
+ this._focused = null;
184
+ this._mouseOn = false;
185
+ this._running = false;
186
+ this._buf = '';
187
+ this._flushTimer = null;
188
+ this._lastClick = {btn:null,row:0,col:0,t:0};
189
+ this._drag = {active:false,btn:null,startRow:0,startCol:0,view:null};
190
+ }
191
+
192
+ start(opts = {}) {
193
+ if (this._running) return this;
194
+ this._running = true;
195
+ const stdin = process.stdin;
196
+ stdin.setEncoding('utf8');
197
+ if (stdin.isTTY) stdin.setRawMode(true);
198
+ stdin.resume();
199
+ stdin.on('data', this._onData.bind(this));
200
+ process.stdout.on('resize', this._onTermResize.bind(this));
201
+ if (opts.mouse !== false) this.enableMouse();
202
+ return this;
203
+ }
204
+
205
+ stop() {
206
+ if (!this._running) return this;
207
+ this._running = false;
208
+ process.stdin.removeAllListeners('data');
209
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
210
+ process.stdin.pause();
211
+ process.stdout.removeAllListeners('resize');
212
+ if (this._mouseOn) this.disableMouse();
213
+ return this;
214
+ }
215
+
216
+ enableMouse() { process.stdout.write(ansi.mouseOn); this._mouseOn=true; return this; }
217
+ disableMouse() { process.stdout.write(ansi.mouseOff); this._mouseOn=false; return this; }
218
+
219
+ register(view) {
220
+ if (!this._views.includes(view)) {
221
+ this._views.push(view);
222
+ if (!this._focused) this._focused = view;
223
+ }
224
+ return this;
225
+ }
226
+
227
+ unregister(view) {
228
+ this._views = this._views.filter(v => v !== view);
229
+ if (this._focused === view) this._focused = this._views[this._views.length-1] ?? null;
230
+ return this;
231
+ }
232
+
233
+ focus(view) {
234
+ if (this._focused && this._focused !== view) {
235
+ this._focused._hasFocus = false;
236
+ this._focused.emit('blur');
237
+ this._focused.markDirty?.();
238
+ }
239
+ this._focused = view;
240
+ view._hasFocus = true;
241
+ view.emit('focus');
242
+ view.markDirty?.();
243
+ return this;
244
+ }
245
+
246
+ _onData(raw) {
247
+ this._buf += raw;
248
+ clearTimeout(this._flushTimer);
249
+ if (!this._buf.startsWith(ESC) || this._buf.length > 1) { this._flush(); }
250
+ else { this._flushTimer = setTimeout(() => this._flush(), 20); }
251
+ }
252
+
253
+ _flush() {
254
+ while (this._buf.length > 0) {
255
+ const n = this._parseOne(this._buf);
256
+ if (n === 0) break;
257
+ this._buf = this._buf.slice(n);
258
+ }
259
+ }
260
+
261
+ _parseOne(buf) {
262
+ // SGR mouse
263
+ const sgr = buf.match(/^\x1b\[<(\d+);(\d+);(\d+)([Mm])/);
264
+ if (sgr) { this._dispatchMouse({btn:+sgr[1],col:+sgr[2]-1,row:+sgr[3]-1,release:sgr[4]==='m'}); return sgr[0].length; }
265
+ // X10 mouse
266
+ if (buf.startsWith(`${ESC}[M`) && buf.length >= 6) {
267
+ this._dispatchMouse({btn:buf.charCodeAt(3)-32,col:buf.charCodeAt(4)-33,row:buf.charCodeAt(5)-33,release:(buf.charCodeAt(3)-32&3)===3});
268
+ return 6;
269
+ }
270
+ // Escape sequences / keys
271
+ if (buf.startsWith(ESC)) {
272
+ for (let len = Math.min(buf.length,8); len >= 1; len--) {
273
+ const seq = buf.slice(0,len);
274
+ if (KEY_MAP[seq]) { this._dispatchKey({sequence:seq,...KEY_MAP[seq]}); return len; }
275
+ }
276
+ if (buf.length === 1) { this._dispatchKey({sequence:ESC,name:'escape'}); return 1; }
277
+ if (buf.length >= 2) return 2;
278
+ return 0;
279
+ }
280
+ const ch = buf[0], def = KEY_MAP[ch];
281
+ this._dispatchKey(def ? {sequence:ch,...def} : {sequence:ch,name:ch,char:ch});
282
+ return 1;
283
+ }
284
+
285
+ _dispatchKey(key) {
286
+ if (key.name === 'c' && key.ctrl) {
287
+ this.emit('sigint', key);
288
+ if (this.listenerCount('sigint') <= 1) {
289
+ if (this._mouseOn) this.disableMouse();
290
+ process.stdout.write(ansi.showCursor + '\n');
291
+ process.exit(0);
292
+ }
293
+ return;
294
+ }
295
+ this.emit('key', key);
296
+ const f = this._focused;
297
+ if (!f) return;
298
+ f.emit('key', key);
299
+ f.emit(`key:${key.name}`, key);
300
+ if (key.ctrl) f.emit(`key:ctrl+${key.name}`, key);
301
+ if (key.shift) f.emit(`key:shift+${key.name}`, key);
302
+ if (key.alt) f.emit(`key:alt+${key.name}`, key);
303
+ if (['up','down','left','right'].includes(key.name)) {
304
+ f.emit('arrow', key); f.emit(`arrow:${key.name}`, key);
305
+ }
306
+ }
307
+
308
+ _dispatchMouse(ev) {
309
+ const {btn,col,row,release} = ev;
310
+ const button=btn&3, shift=!!(btn&4), alt=!!(btn&8), ctrl=!!(btn&16);
311
+ const motion=!!(btn&32), scroll=btn>=64;
312
+ const base = {col,row,button,shift,alt,ctrl};
313
+ this.emit('mouse',{...base,type:'raw',btn});
314
+
315
+ if (scroll) {
316
+ const dir = (btn&1)?'down':'up', target = this._hitTest(row,col);
317
+ const sEv = {...base,type:'scroll',direction:dir};
318
+ this.emit('scroll',sEv);
319
+ if (target) { const l=this._toLocal(target,row,col); target.emit('scroll',{...sEv,...l}); target.emit(`scroll:${dir}`,{...sEv,...l}); }
320
+ return;
321
+ }
322
+ if (motion && !release) {
323
+ const d = this._drag;
324
+ if (d.active) {
325
+ const dEv={...base,type:'drag',startRow:d.startRow,startCol:d.startCol,deltaRow:row-d.startRow,deltaCol:col-d.startCol};
326
+ this.emit('drag',dEv);
327
+ if (d.view) { const l=this._toLocal(d.view,row,col); d.view.emit('drag',{...dEv,...l}); }
328
+ } else {
329
+ const target=this._hitTest(row,col), mEv={...base,type:'mousemove'};
330
+ this.emit('mousemove',mEv);
331
+ if (target) { const l=this._toLocal(target,row,col); target.emit('mousemove',{...mEv,...l}); }
332
+ }
333
+ return;
334
+ }
335
+ if (release) {
336
+ const d=this._drag;
337
+ if (d.active) {
338
+ const dEv={...base,type:'dragend',startRow:d.startRow,startCol:d.startCol,deltaRow:row-d.startRow,deltaCol:col-d.startCol};
339
+ this.emit('dragend',dEv);
340
+ if (d.view) { const l=this._toLocal(d.view,row,col); d.view.emit('dragend',{...dEv,...l}); }
341
+ this._drag.active=false; this._drag.view=null;
342
+ }
343
+ const target=this._hitTest(row,col), rEv={...base,type:'mouseup'};
344
+ this.emit('mouseup',rEv);
345
+ if (target) { const l=this._toLocal(target,row,col); target.emit('mouseup',{...rEv,...l}); }
346
+ return;
347
+ }
348
+ const target=this._hitTest(row,col);
349
+ if (target && target !== this._focused) this.focus(target);
350
+ const now=Date.now(), lc=this._lastClick;
351
+ const isDbl=(lc.btn===button&&lc.row===row&&lc.col===col&&now-lc.t<400);
352
+ this._lastClick={btn:button,row,col,t:now};
353
+ this._drag={active:true,btn:button,startRow:row,startCol:col,view:target};
354
+ const local=target?this._toLocal(target,row,col):{localRow:row,localCol:col};
355
+ const type=isDbl?'dblclick':'click';
356
+ const cEv={...base,type,view:target,...local};
357
+ this.emit('click',cEv); this.emit(type,cEv);
358
+ if (target) {
359
+ target.emit('click',cEv);
360
+ ['click:left','click:middle','click:right'][button] && target.emit(['click:left','click:middle','click:right'][button],cEv);
361
+ if (isDbl) target.emit('dblclick',cEv);
362
+ if (button===2) target.emit('contextmenu',cEv);
363
+ const pixelObj=target.getPixel?.(local.localRow,local.localCol);
364
+ if (pixelObj) target.emit('click:pixel',{...cEv,pixel:pixelObj});
365
+ }
366
+ }
367
+
368
+ _onTermResize() {
369
+ const cols=process.stdout.columns||80, rows=process.stdout.rows||24;
370
+ const ev={cols,rows,type:'resize'};
371
+ this.emit('resize',ev);
372
+ for (const v of this._views) v.emit('termresize',ev);
373
+ }
374
+
375
+ _hitTest(termRow,termCol) {
376
+ for (let i=this._views.length-1;i>=0;i--) {
377
+ const v=this._views[i]; if (v._hidden) continue;
378
+ const r0=v.offsetRow-1,r1=r0+v.rows-1,c0=v.offsetCol-1,c1=c0+v.cols-1;
379
+ if (termRow>=r0&&termRow<=r1&&termCol>=c0&&termCol<=c1) return v;
380
+ }
381
+ return null;
382
+ }
383
+
384
+ _toLocal(view,row,col) { return {localRow:row-(view.offsetRow-1),localCol:col-(view.offsetCol-1)}; }
385
+ }
386
+
387
+ const inputManager = new InputManager();
388
+
389
+ // ── TView ─────────────────────────────────────────────────────────────────────
390
+
391
+ class TView extends EventEmitter {
392
+ /**
393
+ * @param {object} opts
394
+ * @param {number} [opts.rows]
395
+ * @param {number} [opts.cols]
396
+ * @param {number} [opts.offsetRow=1]
397
+ * @param {number} [opts.offsetCol=1]
398
+ * @param {number} [opts.fps=30]
399
+ * @param {string} [opts.title]
400
+ * @param {*} [opts.defaultFg]
401
+ * @param {*} [opts.defaultBg]
402
+ * @param {boolean} [opts.border=false]
403
+ * @param {string} [opts.borderStyle='single']
404
+ * @param {boolean} [opts.scrollable=false]
405
+ * @param {boolean} [opts.autoResize=false]
406
+ * @param {boolean} [opts.autoRegister=true]
407
+ */
408
+ constructor(opts = {}) {
409
+ super();
410
+ const ts = process.stdout;
411
+ this.rows = opts.rows ?? (ts.rows || 24);
412
+ this.cols = opts.cols ?? (ts.columns || 80);
413
+ this.offsetRow = opts.offsetRow ?? 1;
414
+ this.offsetCol = opts.offsetCol ?? 1;
415
+ this.fps = opts.fps ?? 30;
416
+ this.title = opts.title ?? '';
417
+ this.defaultBg = opts.defaultBg ?? null;
418
+ this.defaultFg = opts.defaultFg ?? null;
419
+ this.border = opts.border ?? false;
420
+ this.borderStyle = opts.borderStyle ?? 'single';
421
+ this.scrollable = opts.scrollable ?? false;
422
+ this.autoResize = opts.autoResize ?? false;
423
+ this._scrollOffset = 0;
424
+ this._altScreen = false;
425
+ this._dirty = true;
426
+ this._timer = null;
427
+ this._hidden = false;
428
+ this._hasFocus = false;
429
+ this._promptActive = false;
430
+ this._overlay = null;
431
+ this._animations = [];
432
+ this._dataBindings = [];
433
+ this.grid = this._makeGrid(this.rows, this.cols);
434
+ if (this.autoResize) this.on('termresize',({rows,cols}) => this.resize(rows,cols));
435
+ if (this.fps > 0) this._startRenderLoop();
436
+ if (opts.autoRegister !== false) inputManager.register(this);
437
+ }
438
+
439
+ // ── Handler shortcuts ──────────────────────────────────────────────────────
440
+ onKey(keys, fn) {
441
+ const pairs = [].concat(keys).map(k => { const ev=`key:${k}`; this.on(ev,fn); return [ev,fn]; });
442
+ return () => pairs.forEach(([ev,h]) => this.removeListener(ev,h));
443
+ }
444
+ onArrow(dir, fn) {
445
+ const ev = dir==='*'?'arrow':`arrow:${dir}`; this.on(ev,fn); return ()=>this.removeListener(ev,fn);
446
+ }
447
+ onClick(fn) { this.on('click',fn); return ()=>this.removeListener('click',fn); }
448
+ onDblClick(fn) { this.on('dblclick',fn); return ()=>this.removeListener('dblclick',fn); }
449
+ onRightClick(fn) { this.on('contextmenu',fn); return ()=>this.removeListener('contextmenu',fn); }
450
+ onMouseUp(fn) { this.on('mouseup',fn); return ()=>this.removeListener('mouseup',fn); }
451
+ onMouseMove(fn) { this.on('mousemove',fn); return ()=>this.removeListener('mousemove',fn); }
452
+ onDrag(fn) { this.on('drag',fn); return ()=>this.removeListener('drag',fn); }
453
+ onDragEnd(fn) { this.on('dragend',fn); return ()=>this.removeListener('dragend',fn); }
454
+ onScroll(dir, fn) {
455
+ const ev=dir==='*'?'scroll':`scroll:${dir}`; this.on(ev,fn); return ()=>this.removeListener(ev,fn);
456
+ }
457
+ onResize(fn) { this.on('termresize',fn); return ()=>this.removeListener('termresize',fn); }
458
+ onFocus(fn) { this.on('focus',fn); return ()=>this.removeListener('focus',fn); }
459
+ onBlur(fn) { this.on('blur',fn); return ()=>this.removeListener('blur',fn); }
460
+
461
+ focus() { inputManager.focus(this); return this; }
462
+ get focused() { return this._hasFocus; }
463
+
464
+ // ── Data binding ───────────────────────────────────────────────────────────
465
+ bindData(source, event, handler) {
466
+ const wrapped = data => { handler(data,this); this.markDirty(); };
467
+ source.on(event, wrapped);
468
+ this._dataBindings.push({source,event,handler:wrapped});
469
+ return () => { source.removeListener(event,wrapped); this._dataBindings=this._dataBindings.filter(b=>b.handler!==wrapped); };
470
+ }
471
+
472
+ pipeStream(stream, pixelOpts = {}) {
473
+ let buf = '';
474
+ const stop = this.bindData(stream,'data',chunk => {
475
+ buf += String(chunk);
476
+ const lines = buf.split('\n'); buf = lines.pop();
477
+ for (const line of lines) {
478
+ const row = this.grid.length;
479
+ this.grid.push(Array.from({length:this.cols},()=>new TVPixel({char:' ',color:[this.defaultFg,this.defaultBg]})));
480
+ const display = line.replace(/\x1b\[[^m]*m/g,'').slice(0,this.cols);
481
+ for (let c=0;c<display.length;c++) this.grid[row][c]=new TVPixel({char:display[c],...pixelOpts});
482
+ if (this.scrollable) this.scrollToBottom();
483
+ }
484
+ });
485
+ stream.once('end',stop); stream.once('close',stop);
486
+ return stop;
487
+ }
488
+
489
+ // ── Grid ───────────────────────────────────────────────────────────────────
490
+ _makeGrid(rows,cols) {
491
+ return Array.from({length:rows},()=>Array.from({length:cols},()=>new TVPixel({char:' ',color:[this.defaultFg,this.defaultBg]})));
492
+ }
493
+ _startRenderLoop() {
494
+ this._timer = setInterval(()=>{ if(this._dirty&&!this._hidden) this.render(); }, Math.round(1000/this.fps));
495
+ this._timer.unref?.();
496
+ }
497
+ _stopRenderLoop() { if(this._timer){clearInterval(this._timer);this._timer=null;} }
498
+
499
+ // ── Rendering ──────────────────────────────────────────────────────────────
500
+ render() {
501
+ if (this._hidden) return this;
502
+ const out=[ansi.saveCursor,ansi.hideCursor];
503
+ const iRS=this.border?1:0, iCS=this.border?1:0;
504
+ const iR=this.border?this.rows-2:this.rows, iC=this.border?this.cols-2:this.cols;
505
+ if (this.border) {
506
+ const bs=BORDER_STYLES[this.borderStyle]||BORDER_STYLES.single;
507
+ const fc=this._hasFocus?ansi.fg('brightcyan'):ansi.fg('gray');
508
+ const pad=this.cols-2-this.title.length;
509
+ const top=bs.tl+(this.title?bs.h.repeat(Math.max(0,Math.floor(pad/2)))+this.title+bs.h.repeat(Math.max(0,Math.ceil(pad/2))):bs.h.repeat(this.cols-2))+bs.tr;
510
+ out.push(ansi.moveTo(this.offsetRow,this.offsetCol)+fc+top+ansi.reset);
511
+ for (let r=1;r<this.rows-1;r++) {
512
+ out.push(ansi.moveTo(this.offsetRow+r,this.offsetCol)+fc+bs.v+ansi.reset);
513
+ out.push(ansi.moveTo(this.offsetRow+r,this.offsetCol+this.cols-1)+fc+bs.v+ansi.reset);
514
+ }
515
+ out.push(ansi.moveTo(this.offsetRow+this.rows-1,this.offsetCol)+fc+bs.bl+bs.h.repeat(this.cols-2)+bs.br+ansi.reset);
516
+ }
517
+ for (let r=0;r<iR;r++) {
518
+ const gr=r+this._scrollOffset; if(gr>=this.grid.length) break;
519
+ out.push(ansi.moveTo(this.offsetRow+iRS+r,this.offsetCol+iCS));
520
+ for (let c=0;c<iC;c++) { const p=this.grid[gr]?.[c]; out.push(p?p.render():ansi.reset+' '); }
521
+ }
522
+ if (this._overlay) {
523
+ const ov=this._overlay;
524
+ for (let r=0;r<ov.rows;r++) {
525
+ out.push(ansi.moveTo(this.offsetRow+ov.row+r,this.offsetCol+ov.col));
526
+ for (let c=0;c<ov.cols;c++) { const p=ov.pixels[r]?.[c]; out.push(p?p.render():ansi.reset+' '); }
527
+ }
528
+ }
529
+ out.push(ansi.reset,ansi.restoreCursor,ansi.showCursor);
530
+ process.stdout.write(out.join(''));
531
+ this._dirty=false; this.emit('render');
532
+ return this;
533
+ }
534
+ markDirty() { this._dirty=true; return this; }
535
+
536
+ // ── Pixel/text ─────────────────────────────────────────────────────────────
537
+ setPixel(row,col,pixel) {
538
+ if(row<0||row>=this.grid.length)return this;
539
+ if(col<0||col>=(this.grid[row]?.length||0))return this;
540
+ if(this.grid[row][col].isStatic)return this;
541
+ this.grid[row][col]=pixel instanceof TVPixel?pixel:new TVPixel(pixel);
542
+ this._dirty=true; return this;
543
+ }
544
+ getPixel(row,col) { return this.grid[row]?.[col]??null; }
545
+ writeText(row,col,text,opts={}) {
546
+ return new Promise(res => {
547
+ for(let i=0;i<text.length;i++) this.setPixel(row,col+i,new TVPixel({char:text[i],...opts}));
548
+ return this;
549
+ })
550
+ }
551
+ writeCentered(row,text,opts={}) { return this.writeText(row,Math.max(0,Math.floor((this.cols-text.length)/2)),text,opts); }
552
+ writeRight(row,text,opts={}) { return this.writeText(row,Math.max(0,this.cols-text.length),text,opts); }
553
+ fill(row,col,rows,cols,opts={}) {
554
+ for(let r=row;r<row+rows;r++) for(let c=col;c<col+cols;c++) this.setPixel(r,c,new TVPixel(opts));
555
+ return this;
556
+ }
557
+ clear() { this.grid=this._makeGrid(this.rows,this.cols); this._dirty=true; return this; }
558
+ clearRow(r) {
559
+ if(r>=0&&r<this.grid.length){ this.grid[r]=Array.from({length:this.cols},()=>new TVPixel({char:' ',color:[this.defaultFg,this.defaultBg]})); this._dirty=true; }
560
+ return this;
561
+ }
562
+
563
+ // ── Drawing ────────────────────────────────────────────────────────────────
564
+ drawBox(row,col,rows,cols,opts={},style='single') {
565
+ const bs=BORDER_STYLES[style]||BORDER_STYLES.single;
566
+ this.setPixel(row,col,new TVPixel({char:bs.tl,...opts}));
567
+ this.setPixel(row,col+cols-1,new TVPixel({char:bs.tr,...opts}));
568
+ this.setPixel(row+rows-1,col,new TVPixel({char:bs.bl,...opts}));
569
+ this.setPixel(row+rows-1,col+cols-1,new TVPixel({char:bs.br,...opts}));
570
+ for(let c=col+1;c<col+cols-1;c++){this.setPixel(row,c,new TVPixel({char:bs.h,...opts}));this.setPixel(row+rows-1,c,new TVPixel({char:bs.h,...opts}));}
571
+ for(let r=row+1;r<row+rows-1;r++){this.setPixel(r,col,new TVPixel({char:bs.v,...opts}));this.setPixel(r,col+cols-1,new TVPixel({char:bs.v,...opts}));}
572
+ return this;
573
+ }
574
+ drawHLine(row,col,len,opts={},style='single'){const h=(BORDER_STYLES[style]||BORDER_STYLES.single).h;for(let c=col;c<col+len;c++)this.setPixel(row,c,new TVPixel({char:h,...opts}));return this;}
575
+ drawVLine(row,col,len,opts={},style='single'){const v=(BORDER_STYLES[style]||BORDER_STYLES.single).v;for(let r=row;r<row+len;r++)this.setPixel(r,col,new TVPixel({char:v,...opts}));return this;}
576
+
577
+ // ── Overlay ────────────────────────────────────────────────────────────────
578
+ setOverlay(row,col,pixels){this._overlay={row,col,rows:pixels.length,cols:pixels[0]?.length??0,pixels};this._dirty=true;return this;}
579
+ clearOverlay(){this._overlay=null;this._dirty=true;return this;}
580
+
581
+ // ── Scroll ─────────────────────────────────────────────────────────────────
582
+ scrollDown(n=1){this._scrollOffset=Math.min(this._scrollOffset+n,Math.max(0,this.grid.length-this.rows));this._dirty=true;return this;}
583
+ scrollUp(n=1) {this._scrollOffset=Math.max(0,this._scrollOffset-n);this._dirty=true;return this;}
584
+ scrollTo(r) {this._scrollOffset=Math.max(0,Math.min(r,this.grid.length-this.rows));this._dirty=true;return this;}
585
+ scrollToBottom(){return this.scrollTo(Math.max(0,this.grid.length-this.rows));}
586
+ pushRow(pixels){this.grid.push(pixels);if(this.scrollable)this.scrollToBottom();this._dirty=true;return this;}
587
+
588
+ // ── Alt screen ─────────────────────────────────────────────────────────────
589
+ renderInAltScreen(){if(!this._altScreen){process.stdout.write(ansi.altScreenOn+ansi.clear+ansi.cursorHome);this._altScreen=true;}return this.render();}
590
+ leaveAltScreen(){if(this._altScreen){process.stdout.write(ansi.altScreenOff);this._altScreen=false;}return this;}
591
+
592
+ hide() {this._hidden=true;return this;}
593
+ show() {this._hidden=false;this._dirty=true;return this;}
594
+ toggle() {return this._hidden?this.show():this.hide();}
595
+
596
+ resize(rows,cols) {
597
+ const g=this._makeGrid(rows,cols);
598
+ for(let r=0;r<Math.min(rows,this.grid.length);r++) for(let c=0;c<Math.min(cols,(this.grid[r]||[]).length);c++) g[r][c]=this.grid[r][c];
599
+ this.rows=rows;this.cols=cols;this.grid=g;this._dirty=true;this.emit('resize',{rows,cols});return this;
600
+ }
601
+
602
+ // ── Prompt ─────────────────────────────────────────────────────────────────
603
+ prompt(promptStr = '> ') {
604
+ return new Promise(resolve => {
605
+ this._promptActive=true;
606
+ const pRow=this.rows-1; let inputStr='', inputCol=promptStr.length;
607
+ this.clearRow(pRow);
608
+ this.writeText(pRow,0,promptStr,{color:['cyan',null]});
609
+ this.render();
610
+ process.stdout.write(ansi.moveTo(this.offsetRow+pRow,this.offsetCol+inputCol)+ansi.showCursor);
611
+ this.focus();
612
+ const onKey = key => {
613
+ if (key.name==='enter') {
614
+ this.removeListener('key',onKey); this._promptActive=false; this.clearRow(pRow); this.markDirty(); resolve(inputStr);
615
+ } else if (key.name==='backspace') {
616
+ if(inputStr.length>0){inputStr=inputStr.slice(0,-1);inputCol--;this.setPixel(pRow,inputCol,new TVPixel({char:' '}));this.render();process.stdout.write(ansi.moveTo(this.offsetRow+pRow,this.offsetCol+inputCol));}
617
+ } else if (key.name==='escape') {
618
+ this.removeListener('key',onKey);this._promptActive=false;this.clearRow(pRow);this.markDirty();resolve(null);
619
+ } else if (key.char&&key.char>=' '&&!key.ctrl&&inputCol<this.cols) {
620
+ this.setPixel(pRow,inputCol,new TVPixel({char:key.char,color:['white',null]}));inputStr+=key.char;inputCol++;this.render();process.stdout.write(ansi.moveTo(this.offsetRow+pRow,this.offsetCol+inputCol));
621
+ }
622
+ };
623
+ this.on('key',onKey);
624
+ });
625
+ }
626
+
627
+ // ── Cut/split ──────────────────────────────────────────────────────────────
628
+ cut(n) {
629
+ if(n<1)throw new RangeError('cut: n>=1'); this._stopRenderLoop();
630
+ const sh=Math.floor(this.rows/n);
631
+ return Array.from({length:n},(_,i)=>{
632
+ const rows=(i===n-1)?this.rows-sh*(n-1):sh;
633
+ return new TView({rows,cols:this.cols,offsetRow:this.offsetRow+i*sh,offsetCol:this.offsetCol,fps:this.fps,defaultBg:this.defaultBg,defaultFg:this.defaultFg});
634
+ });
635
+ }
636
+ cutHorizontal(n) {
637
+ if(n<1)throw new RangeError('cutHorizontal: n>=1'); this._stopRenderLoop();
638
+ const sw=Math.floor(this.cols/n);
639
+ return Array.from({length:n},(_,i)=>{
640
+ const cols=(i===n-1)?this.cols-sw*(n-1):sw;
641
+ return new TView({rows:this.rows,cols,offsetRow:this.offsetRow,offsetCol:this.offsetCol+i*sw,fps:this.fps,defaultBg:this.defaultBg,defaultFg:this.defaultFg});
642
+ });
643
+ }
644
+ cutGrid(gr,gc) { return this.cut(gr).map(rv=>rv.cutHorizontal(gc)); }
645
+ cutWeighted(weights) {
646
+ const total=weights.reduce((a,b)=>a+b,0), isPct=total<=1.01&&total>=0.99;
647
+ let cursor=0; this._stopRenderLoop();
648
+ return weights.map((w,i)=>{
649
+ const rows=(i===weights.length-1)?this.rows-cursor:Math.floor(isPct?w*this.rows:w);
650
+ const v=new TView({rows,cols:this.cols,offsetRow:this.offsetRow+cursor,offsetCol:this.offsetCol,fps:this.fps,defaultBg:this.defaultBg,defaultFg:this.defaultFg});
651
+ cursor+=rows; return v;
652
+ });
653
+ }
654
+
655
+ // ── Animation ──────────────────────────────────────────────────────────────
656
+ animatePixel(row,col,frames,interval=100,opts={}) {
657
+ let i=0; const t=setInterval(()=>this.setPixel(row,col,new TVPixel({char:frames[i++%frames.length],...opts})),interval);
658
+ const stop=()=>clearInterval(t); this._animations.push(stop); return stop;
659
+ }
660
+ marquee(row,text,interval=80,opts={}) {
661
+ const padded=text+' '; let offset=0;
662
+ const t=setInterval(()=>{this.writeText(row,0,(padded.slice(offset%padded.length)+padded).slice(0,this.cols).padEnd(this.cols),opts);offset++;},interval);
663
+ const stop=()=>clearInterval(t); this._animations.push(stop); return stop;
664
+ }
665
+ spinner(row,col,style='dots',interval=80,opts={}) {
666
+ const S={dots:['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'],line:['-','\\','|','/'],arc:['◜','◠','◝','◞','◡','◟'],bounce:['⠁','⠂','⠄','⠂'],pulse:['·','●','◉','●'],arrows:['←','↖','↑','↗','→','↘','↓','↙'],clock:['🕐','🕑','🕒','🕓','🕔','🕕'],bar:['▏','▎','▍','▌','▋','▊','▉','█','▉','▊','▋','▌','▍','▎']};
667
+ return this.animatePixel(row,col,S[style]||S.dots,interval,opts);
668
+ }
669
+ flash(row,col,rows,cols,color='yellow',times=6,interval=150) {
670
+ let count=0,on=true;
671
+ const orig=this.grid.slice(row,row+rows).map(r=>r.slice(col,col+cols).map(p=>p.clone()));
672
+ const t=setInterval(()=>{
673
+ if(on)this.fill(row,col,rows,cols,{color:[null,color]});
674
+ else{for(let r=0;r<rows;r++)for(let c=0;c<cols;c++)this.grid[row+r][col+c]=orig[r][c].clone();this._dirty=true;}
675
+ on=!on;count++;if(count>=times*2)clearInterval(t);
676
+ },interval);
677
+ return ()=>clearInterval(t);
678
+ }
679
+
680
+ // ── Widgets ────────────────────────────────────────────────────────────────
681
+ progressBar(row,col,width,pct,opts={}) {
682
+ const{filledColor='green',emptyColor='gray',filledChar='█',emptyChar='░',showLabel=false}=opts;
683
+ const filled=Math.round(Math.min(1,Math.max(0,pct))*width);
684
+ for(let c=0;c<width;c++){const f=c<filled;this.setPixel(row,col+c,new TVPixel({char:f?filledChar:emptyChar,color:[f?filledColor:emptyColor,null]}));}
685
+ if(showLabel){const lbl=`${Math.round(pct*100)}%`;this.writeText(row,col+Math.floor((width-lbl.length)/2),lbl,{color:['white',null]});}
686
+ this._dirty=true; return this;
687
+ }
688
+ statusBar(left,right='',opts={color:['black','white']}) {
689
+ const spacer=' '.repeat(Math.max(0,this.cols-left.length-right.length));
690
+ this.writeText(this.rows-1,0,(left+spacer+right).slice(0,this.cols),opts); return this;
691
+ }
692
+ table(row,col,data,opts={}) {
693
+ const{headerColor=['black','cyan'],cellColor=['white',null],colWidths}=opts;
694
+ if(!data.length)return this;
695
+ const widths=colWidths??data[0].map((_,ci)=>Math.max(...data.map(r=>(r[ci]??'').length))+2);
696
+ let r=row;
697
+ for(let ri=0;ri<data.length;ri++){let c=col;const isH=ri===0;for(let ci=0;ci<data[ri].length;ci++){const cell=(' '+(data[ri][ci]??'')+' ').padEnd(widths[ci]).slice(0,widths[ci]);this.writeText(r,c,cell,isH?{color:headerColor}:{color:cellColor});c+=widths[ci];}r++;}
698
+ return this;
699
+ }
700
+
701
+ // ── Snapshot/copy ──────────────────────────────────────────────────────────
702
+ snapshot() { return this.grid.map(r=>r.map(p=>p.clone())); }
703
+ restoreSnapshot(snap) {
704
+ for(let r=0;r<snap.length&&r<this.grid.length;r++) for(let c=0;c<snap[r].length&&c<this.grid[r].length;c++) if(!this.grid[r][c].isStatic)this.grid[r][c]=snap[r][c].clone();
705
+ this._dirty=true; return this;
706
+ }
707
+ copyRegion(row,col,rows,cols) { return Array.from({length:rows},(_,r)=>Array.from({length:cols},(_,c)=>this.grid[row+r]?.[col+c]?.clone()??new TVPixel())); }
708
+ pasteRegion(row,col,pixels) { for(let r=0;r<pixels.length;r++)for(let c=0;c<pixels[r].length;c++)this.setPixel(row+r,col+c,pixels[r][c]);return this; }
709
+
710
+ // ── Destroy ────────────────────────────────────────────────────────────────
711
+ destroy() {
712
+ this._stopRenderLoop();
713
+ this._animations.forEach(s=>s()); this._animations=[];
714
+ this._dataBindings.forEach(({source,event,handler})=>source.removeListener(event,handler)); this._dataBindings=[];
715
+ inputManager.unregister(this);
716
+ if(this._altScreen)this.leaveAltScreen();
717
+ this.emit('destroy'); this.removeAllListeners();
718
+ }
719
+
720
+ toString() { return this.grid.map(r=>r.map(p=>p.char).join('')).join('\n'); }
721
+ toAnsiString() { return this.grid.map(r=>r.map(p=>p.render()).join('')+ansi.reset).join('\n'); }
722
+ }
723
+
724
+ function createFullscreen(opts={}) {
725
+ return new TView({rows:process.stdout.rows||24,cols:process.stdout.columns||80,...opts});
726
+ }
727
+ function px(char,fg=null,bg=null,isStatic=false) { return new TVPixel({char,color:[fg,bg],isStatic}); }
728
+
729
+ // ═════════════════════════════════════════════════════════════════════════════
730
+ // §5 tshell — child-process runner / interactive shell
731
+ // ═════════════════════════════════════════════════════════════════════════════
732
+
733
+ /**
734
+ * TShell — spawn programs, pipe their output into TViews, send input,
735
+ * collect results, and run shell pipelines.
736
+ *
737
+ * @example
738
+ * const sh = new TShell({ view: myView });
739
+ * const { stdout } = await sh.run('ls', ['-la']);
740
+ * sh.spawn('tail', ['-f', '/var/log/syslog']); // live stream
741
+ * sh.write('hello\n'); // send stdin
742
+ */
743
+ class TShell extends EventEmitter {
744
+ /**
745
+ * @param {object} [opts]
746
+ * @param {TView} [opts.view] — pipe stdout into this view
747
+ * @param {object} [opts.env] — extra env vars (merged over process.env)
748
+ * @param {string} [opts.cwd] — working directory
749
+ * @param {*} [opts.pixelOpts] — color/style for piped text
750
+ * @param {string} [opts.shell] — shell binary for .shell() (default: /bin/sh)
751
+ */
752
+ constructor(opts = {}) {
753
+ super();
754
+ this._view = opts.view ?? null;
755
+ this._env = opts.env ? { ...process.env, ...opts.env } : process.env;
756
+ this._cwd = opts.cwd ?? process.cwd();
757
+ this._pixelOpts = opts.pixelOpts ?? { color: ['brightgreen', null] };
758
+ this._shell = opts.shell ?? (process.platform === 'win32' ? 'cmd.exe' : '/bin/sh');
759
+ this._proc = null; // currently spawned process
760
+ this._history = []; // command history
761
+ }
762
+
763
+ /** Change the view that output is piped into. */
764
+ setView(view) { this._view = view; return this; }
765
+
766
+ /**
767
+ * Run a command and collect its output (like exec but streaming).
768
+ * Resolves when the process exits.
769
+ * @param {string} cmd
770
+ * @param {string[]} [args=[]]
771
+ * @param {object} [spawnOpts]
772
+ * @returns {Promise<{code, stdout, stderr}>}
773
+ */
774
+ run(cmd, args = [], spawnOpts = {}) {
775
+ return new Promise((resolve, reject) => {
776
+ const proc = spawn(cmd, args, {
777
+ env: this._env, cwd: this._cwd,
778
+ ...spawnOpts,
779
+ });
780
+ let stdout = '', stderr = '';
781
+ proc.stdout?.on('data', d => {
782
+ const s = String(d); stdout += s;
783
+ this._toView(s); this.emit('data', s);
784
+ });
785
+ proc.stderr?.on('data', d => {
786
+ const s = String(d); stderr += s;
787
+ this._toView(s, { color: ['brightyellow', null] }); this.emit('stderr', s);
788
+ });
789
+ proc.on('error', reject);
790
+ proc.on('close', code => {
791
+ this._history.push({ cmd, args, code, ts: Date.now() });
792
+ this.emit('exit', { cmd, args, code });
793
+ resolve({ code, stdout, stderr });
794
+ });
795
+ this._proc = proc;
796
+ });
797
+ }
798
+
799
+ /**
800
+ * Spawn a long-running process (don't await its exit).
801
+ * Stdout is continuously piped to the bound view.
802
+ * @returns {ChildProcess}
803
+ */
804
+ spawn(cmd, args = [], spawnOpts = {}) {
805
+ const proc = spawn(cmd, args, { env: this._env, cwd: this._cwd, ...spawnOpts });
806
+ proc.stdout?.on('data', d => { const s=String(d); this._toView(s); this.emit('data',s); });
807
+ proc.stderr?.on('data', d => { const s=String(d); this._toView(s,{color:['brightyellow',null]}); this.emit('stderr',s); });
808
+ proc.on('close', code => { this._history.push({cmd,args,code,ts:Date.now()}); this.emit('exit',{cmd,args,code}); });
809
+ proc.on('error', err => this.emit('error', err));
810
+ this._proc = proc;
811
+ return proc;
812
+ }
813
+
814
+ /**
815
+ * Execute a shell command string (passed to /bin/sh -c).
816
+ * @param {string} cmdStr — e.g. 'ls -la | head -5'
817
+ * @returns {Promise<{code, stdout, stderr}>}
818
+ */
819
+ shell(cmdStr) {
820
+ return this.run(this._shell, ['-c', cmdStr]);
821
+ }
822
+
823
+ /**
824
+ * Run a command synchronously (blocks the event loop).
825
+ * @returns {{ code, stdout, stderr }}
826
+ */
827
+ runSync(cmd, args = [], opts = {}) {
828
+ const result = spawnSync(cmd, args, {
829
+ env: this._env, cwd: this._cwd, encoding: 'utf8', ...opts,
830
+ });
831
+ this._history.push({ cmd, args, code: result.status, ts: Date.now() });
832
+ return { code: result.status, stdout: result.stdout, stderr: result.stderr };
833
+ }
834
+
835
+ /** Send text to the running process's stdin. */
836
+ write(data) {
837
+ if (this._proc?.stdin) { this._proc.stdin.write(data); }
838
+ return this;
839
+ }
840
+
841
+ /** Send SIGTERM (or another signal) to the running process. */
842
+ kill(signal = 'SIGTERM') {
843
+ this._proc?.kill(signal); return this;
844
+ }
845
+
846
+ /** Change working directory (affects subsequent runs). */
847
+ cd(dir) {
848
+ const resolved = path.resolve(this._cwd, dir);
849
+ if (fs.existsSync(resolved)) { this._cwd = resolved; this.emit('cwd', resolved); }
850
+ else throw new Error(`TShell.cd: directory not found: ${resolved}`);
851
+ return this;
852
+ }
853
+
854
+ /** Set/get an environment variable for subsequent runs. */
855
+ setEnv(key, value) { this._env = { ...this._env, [key]: String(value) }; return this; }
856
+ getEnv(key) { return this._env[key]; }
857
+
858
+ /** Returns a copy of the command history array. */
859
+ getHistory() { return [...this._history]; }
860
+
861
+ /**
862
+ * Run a series of commands in sequence, stopping on first non-zero exit.
863
+ * @param {Array<{cmd, args}>} pipeline
864
+ * @returns {Promise<Array<result>>}
865
+ */
866
+ async sequence(pipeline) {
867
+ const results = [];
868
+ for (const { cmd, args = [] } of pipeline) {
869
+ const r = await this.run(cmd, args);
870
+ results.push(r);
871
+ if (r.code !== 0) break;
872
+ }
873
+ return results;
874
+ }
875
+
876
+ /**
877
+ * Run commands in parallel, resolve when all complete.
878
+ * @param {Array<{cmd, args}>} commands
879
+ */
880
+ parallel(commands) {
881
+ return Promise.all(commands.map(({ cmd, args = [] }) => this.run(cmd, args)));
882
+ }
883
+
884
+ _toView(str, overrideOpts) {
885
+ if (!this._view) return;
886
+ const opts = overrideOpts ?? this._pixelOpts;
887
+ // Append each line to the view grid
888
+ const lines = str.split('\n');
889
+ for (let i = 0; i < lines.length; i++) {
890
+ const line = lines[i].replace(/\x1b\[[^m]*m/g, '');
891
+ if (!line && i === lines.length - 1) continue;
892
+ const row = this._view.grid.length;
893
+ this._view.grid.push(Array.from({length:this._view.cols},()=>new TVPixel({char:' '})));
894
+ const display = line.slice(0, this._view.cols);
895
+ for (let c = 0; c < display.length; c++)
896
+ this._view.grid[row][c] = new TVPixel({ char: display[c], ...opts });
897
+ if (this._view.scrollable) this._view.scrollToBottom();
898
+ }
899
+ this._view.markDirty();
900
+ }
901
+
902
+ get cwd() { return this._cwd; }
903
+ get pid() { return this._proc?.pid ?? null; }
904
+ get alive(){ return !!(this._proc && !this._proc.exitCode && !this._proc.killed); }
905
+ }
906
+
907
+ // ═════════════════════════════════════════════════════════════════════════════
908
+ // §6 tenv — typed, validated environment config
909
+ // ═════════════════════════════════════════════════════════════════════════════
910
+
911
+ /**
912
+ * TEnv — typed env-var system with defaults, validation, transforms,
913
+ * .env file loading, and change-watching.
914
+ *
915
+ * @example
916
+ * const env = new TEnv();
917
+ * env.define('PORT', { type: 'int', default: 3000 });
918
+ * env.define('DEBUG', { type: 'bool', default: false });
919
+ * env.define('LOG_LEVEL', { type: 'enum', values: ['debug','info','warn','error'], default: 'info' });
920
+ * env.define('API_KEY', { type: 'string', required: true });
921
+ * env.load();
922
+ *
923
+ * const port = env.get('PORT'); // → 3000 (number)
924
+ * env.set('DEBUG', true); // programmatic override
925
+ * env.watch('./config.env', 500); // reload on file change
926
+ */
927
+ class TEnv extends EventEmitter {
928
+ constructor(opts = {}) {
929
+ super();
930
+ this._source = opts.source ?? process.env; // raw string map
931
+ this._schema = new Map(); // name → schema entry
932
+ this._values = new Map(); // name → parsed value
933
+ this._watchers = [];
934
+ this._strict = opts.strict ?? false; // throw on undefined vars
935
+ }
936
+
937
+ /**
938
+ * Define a variable.
939
+ * @param {string} name
940
+ * @param {object} schema
941
+ * @param {string} schema.type — 'string'|'int'|'float'|'bool'|'json'|'list'|'enum'|'path'|'url'
942
+ * @param {*} [schema.default] — value when var is absent
943
+ * @param {boolean} [schema.required] — throw if absent and no default
944
+ * @param {string[]} [schema.values] — allowed values (for enum)
945
+ * @param {function} [schema.validate] — custom (val)=>true|string(error)
946
+ * @param {function} [schema.transform]— (raw)=>transformed
947
+ * @param {string} [schema.desc] — human description
948
+ */
949
+ define(name, schema = {}) {
950
+ this._schema.set(name, { type: 'string', ...schema });
951
+ return this;
952
+ }
953
+
954
+ /**
955
+ * Load and parse all defined variables from the source.
956
+ * @param {string} [dotenvPath] — optional .env file to read first
957
+ */
958
+ load(dotenvPath) {
959
+ if (dotenvPath) this._parseDotenv(dotenvPath);
960
+ for (const [name, schema] of this._schema) {
961
+ this._resolve(name, schema);
962
+ }
963
+ this.emit('load', Object.fromEntries(this._values));
964
+ return this;
965
+ }
966
+
967
+ /** Get a parsed value. Throws if not loaded and strict mode is on. */
968
+ get(name) {
969
+ if (!this._values.has(name)) {
970
+ const schema = this._schema.get(name);
971
+ if (schema) this._resolve(name, schema);
972
+ else if (this._strict) throw new Error(`TEnv: unknown variable "${name}"`);
973
+ else return this._source[name];
974
+ }
975
+ return this._values.get(name);
976
+ }
977
+
978
+ /** Programmatically override a value (bypasses env but not validate). */
979
+ set(name, value) {
980
+ const schema = this._schema.get(name) ?? { type: typeof value === 'number' ? 'float' : 'string' };
981
+ const parsed = this._parse(String(value), schema);
982
+ this._values.set(name, parsed);
983
+ this.emit('change', { name, value: parsed });
984
+ return this;
985
+ }
986
+
987
+ /** Return all resolved values as a plain object. */
988
+ all() { return Object.fromEntries(this._values); }
989
+
990
+ /** Return a flat diagnostic table (useful for TView.table()). */
991
+ describe() {
992
+ const rows = [['Variable', 'Type', 'Value', 'Default', 'Required', 'Description']];
993
+ for (const [name, schema] of this._schema) {
994
+ rows.push([
995
+ name,
996
+ schema.type ?? 'string',
997
+ String(this._values.has(name) ? this._values.get(name) : '—'),
998
+ String(schema.default ?? '—'),
999
+ schema.required ? 'yes' : 'no',
1000
+ schema.desc ?? '',
1001
+ ]);
1002
+ }
1003
+ return rows;
1004
+ }
1005
+
1006
+ /**
1007
+ * Watch a .env file for changes and reload automatically.
1008
+ * @param {string} filePath
1009
+ * @param {number} [interval=1000] — poll interval ms (fs.watchFile)
1010
+ */
1011
+ watch(filePath, interval = 1000) {
1012
+ fs.watchFile(filePath, { interval }, () => {
1013
+ this._parseDotenv(filePath);
1014
+ for (const [name, schema] of this._schema) this._resolve(name, schema);
1015
+ this.emit('reload', Object.fromEntries(this._values));
1016
+ });
1017
+ this._watchers.push(filePath);
1018
+ return this;
1019
+ }
1020
+
1021
+ unwatch() { this._watchers.forEach(f => fs.unwatchFile(f)); this._watchers = []; return this; }
1022
+
1023
+ // ── internal ──────────────────────────────────────────────────────────────
1024
+
1025
+ _resolve(name, schema) {
1026
+ const raw = this._source[name];
1027
+ if (raw == null || raw === '') {
1028
+ if (schema.required && schema.default == null)
1029
+ throw new Error(`TEnv: required variable "${name}" is not set`);
1030
+ this._values.set(name, schema.default ?? null);
1031
+ return;
1032
+ }
1033
+ let value = schema.transform ? schema.transform(raw) : this._parse(raw, schema);
1034
+ if (schema.validate) {
1035
+ const verdict = schema.validate(value);
1036
+ if (verdict !== true && verdict != null)
1037
+ throw new Error(`TEnv: "${name}" failed validation: ${verdict}`);
1038
+ }
1039
+ this._values.set(name, value);
1040
+ }
1041
+
1042
+ _parse(raw, schema) {
1043
+ switch (schema.type) {
1044
+ case 'int': return parseInt(raw, 10);
1045
+ case 'float': return parseFloat(raw);
1046
+ case 'bool': return /^(true|1|yes|on)$/i.test(raw);
1047
+ case 'json': return JSON.parse(raw);
1048
+ case 'list': return raw.split(schema.separator ?? ',').map(s => s.trim());
1049
+ case 'path': return path.resolve(raw);
1050
+ case 'url': return new URL(raw).toString();
1051
+ case 'enum': {
1052
+ const vals = schema.values ?? [];
1053
+ if (!vals.includes(raw))
1054
+ throw new Error(`TEnv: "${raw}" not in allowed values [${vals.join(', ')}]`);
1055
+ return raw;
1056
+ }
1057
+ default: return raw;
1058
+ }
1059
+ }
1060
+
1061
+ _parseDotenv(filePath) {
1062
+ if (!fs.existsSync(filePath)) return;
1063
+ const lines = fs.readFileSync(filePath, 'utf8').split('\n');
1064
+ for (const line of lines) {
1065
+ const trimmed = line.trim();
1066
+ if (!trimmed || trimmed.startsWith('#')) continue;
1067
+ const eq = trimmed.indexOf('=');
1068
+ if (eq < 0) continue;
1069
+ const key = trimmed.slice(0, eq).trim();
1070
+ let val = trimmed.slice(eq + 1).trim();
1071
+ if ((val.startsWith('"') && val.endsWith('"')) ||
1072
+ (val.startsWith("'") && val.endsWith("'"))) val = val.slice(1, -1);
1073
+ this._source[key] = val;
1074
+ }
1075
+ }
1076
+ }
1077
+
1078
+ // ═════════════════════════════════════════════════════════════════════════════
1079
+ // §7 tlog — levelled logger with TView integration
1080
+ // ═════════════════════════════════════════════════════════════════════════════
1081
+
1082
+ /**
1083
+ * TLog — structured levelled logger.
1084
+ * Can write to a TView, to a file, or both.
1085
+ *
1086
+ * @example
1087
+ * const log = new TLog({ view: myView, level: 'debug' });
1088
+ * log.info('Server started', { port: 3000 });
1089
+ * log.error('Connection failed', err);
1090
+ * log.time('init'); // start timer
1091
+ * log.timeEnd('init'); // log elapsed
1092
+ */
1093
+ class TLog extends EventEmitter {
1094
+ static LEVELS = { trace:0, debug:1, info:2, warn:3, error:4, fatal:5, silent:99 };
1095
+ static COLORS = { trace:'gray', debug:'brightcyan', info:'brightgreen', warn:'brightyellow', error:'brightred', fatal:'magenta' };
1096
+ static ICONS = { trace:'·', debug:'◌', info:'ℹ', warn:'⚠', error:'✖', fatal:'☠' };
1097
+
1098
+ /**
1099
+ * @param {object} [opts]
1100
+ * @param {TView} [opts.view] — render logs here
1101
+ * @param {string} [opts.level] — minimum level (default 'info')
1102
+ * @param {string} [opts.file] — path to append log file
1103
+ * @param {boolean} [opts.timestamp] — include ISO timestamp (default true)
1104
+ * @param {boolean} [opts.json] — write JSON to file instead of pretty
1105
+ * @param {number} [opts.maxLines] — max lines kept in view grid (default 500)
1106
+ * @param {string} [opts.prefix] — static prefix for all messages
1107
+ */
1108
+ constructor(opts = {}) {
1109
+ super();
1110
+ this._view = opts.view ?? null;
1111
+ this._level = TLog.LEVELS[opts.level ?? 'info'] ?? TLog.LEVELS.info;
1112
+ this._file = opts.file ?? null;
1113
+ this._timestamp = opts.timestamp ?? true;
1114
+ this._json = opts.json ?? false;
1115
+ this._maxLines = opts.maxLines ?? 500;
1116
+ this._prefix = opts.prefix ?? '';
1117
+ this._timers = new Map();
1118
+ this._fileStream = this._file ? fs.createWriteStream(this._file, { flags: 'a' }) : null;
1119
+ this._lineCount = 0;
1120
+ }
1121
+
1122
+ trace(msg, meta) { return this._log('trace', msg, meta); }
1123
+ debug(msg, meta) { return this._log('debug', msg, meta); }
1124
+ info (msg, meta) { return this._log('info', msg, meta); }
1125
+ warn (msg, meta) { return this._log('warn', msg, meta); }
1126
+ error(msg, meta) { return this._log('error', msg, meta); }
1127
+ fatal(msg, meta) { return this._log('fatal', msg, meta); }
1128
+
1129
+ /** Set log level dynamically. */
1130
+ setLevel(level) { this._level = TLog.LEVELS[level] ?? this._level; return this; }
1131
+
1132
+ /** Bind to a TView. */
1133
+ setView(view) { this._view = view; return this; }
1134
+
1135
+ /** Start a named timer. */
1136
+ time(label) { this._timers.set(label, process.hrtime.bigint()); return this; }
1137
+
1138
+ /** Stop a named timer and log the elapsed time. */
1139
+ timeEnd(label) {
1140
+ const start = this._timers.get(label);
1141
+ if (!start) { this.warn(`timeEnd: no timer "${label}"`); return this; }
1142
+ const ms = Number(process.hrtime.bigint() - start) / 1e6;
1143
+ this._timers.delete(label);
1144
+ return this.info(`${label}: ${ms.toFixed(2)}ms`);
1145
+ }
1146
+
1147
+ /** Log a group header (indents subsequent lines visually). */
1148
+ group(label) { return this._log('info', `▼ ${label}`); }
1149
+ groupEnd() { return this._log('info', '▲'); }
1150
+
1151
+ /** Dump an object as formatted JSON to the log. */
1152
+ dir(obj, label = '') {
1153
+ const str = JSON.stringify(obj, null, 2);
1154
+ for (const line of (`${label ? label + ': ' : ''}${str}`).split('\n'))
1155
+ this._log('debug', line);
1156
+ return this;
1157
+ }
1158
+
1159
+ /** Clear the log view. */
1160
+ clearView() {
1161
+ if (this._view) { this._view.clear(); this._lineCount = 0; }
1162
+ return this;
1163
+ }
1164
+
1165
+ _log(level, msg, meta) {
1166
+ if (TLog.LEVELS[level] < this._level) return this;
1167
+ const ts = this._timestamp ? new Date().toISOString().slice(11,23) : '';
1168
+ const icon = TLog.ICONS[level] || ' ';
1169
+ const color= TLog.COLORS[level] || 'white';
1170
+ const metaStr = meta != null
1171
+ ? (typeof meta === 'string' ? ' ' + meta : ' ' + JSON.stringify(meta))
1172
+ : '';
1173
+ const prefix = this._prefix ? `[${this._prefix}] ` : '';
1174
+ const line = `${ts ? ts + ' ' : ''}${icon} ${prefix}${msg}${metaStr}`;
1175
+
1176
+ // View output
1177
+ if (this._view) {
1178
+ if (this._lineCount >= this._maxLines) {
1179
+ this._view.grid.shift(); this._lineCount--;
1180
+ }
1181
+ const row = this._view.grid.length;
1182
+ this._view.grid.push(Array.from({length:this._view.cols},()=>new TVPixel({char:' '})));
1183
+ const display = line.slice(0, this._view.cols);
1184
+ for (let c = 0; c < display.length; c++)
1185
+ this._view.grid[row][c] = new TVPixel({ char: display[c], color: [color, null] });
1186
+ if (this._view.scrollable) this._view.scrollToBottom();
1187
+ this._view.markDirty();
1188
+ this._lineCount++;
1189
+ }
1190
+
1191
+ // File output
1192
+ if (this._fileStream) {
1193
+ const fileLine = this._json
1194
+ ? JSON.stringify({ ts: new Date().toISOString(), level, msg, meta }) + '\n'
1195
+ : line + '\n';
1196
+ this._fileStream.write(fileLine);
1197
+ }
1198
+
1199
+ const entry = { ts: new Date().toISOString(), level, msg, meta };
1200
+ this.emit('log', entry);
1201
+ this.emit(level, entry);
1202
+ return this;
1203
+ }
1204
+
1205
+ close() { this._fileStream?.end(); return this; }
1206
+ }
1207
+
1208
+ // ═════════════════════════════════════════════════════════════════════════════
1209
+ // §8 ttheme — named colour/style token system
1210
+ // ═════════════════════════════════════════════════════════════════════════════
1211
+
1212
+ /**
1213
+ * TTheme — token-based colour system. Define named tokens, bundle them into
1214
+ * themes, switch themes at runtime. All TView/TVPixel colour calls
1215
+ * accept raw values, so you just pass theme.get('primary') anywhere.
1216
+ *
1217
+ * @example
1218
+ * const theme = new TTheme();
1219
+ * theme.define('dark', { primary:'brightcyan', bg:'#0d1117', fg:'white', error:'brightred', muted:'gray' });
1220
+ * theme.define('light',{ primary:'blue', bg:'white', fg:'black', error:'red', muted:'gray' });
1221
+ * theme.use('dark');
1222
+ *
1223
+ * view.writeText(0, 0, 'Hello', { color: [theme.get('primary'), theme.get('bg')] });
1224
+ * theme.on('change', name => view.markDirty());
1225
+ */
1226
+ class TTheme extends EventEmitter {
1227
+ /** Built-in themes */
1228
+ static DARK = {
1229
+ primary:'brightcyan', secondary:'brightmagenta', accent:'brightyellow',
1230
+ success:'brightgreen', warning:'brightyellow', error:'brightred', info:'brightblue',
1231
+ fg:'brightwhite', fgMuted:'gray', bg:'#0d1117', bgAlt:'#161b22',
1232
+ bgHighlight:'#21262d', border:'#30363d', selection:'#1f6feb',
1233
+ };
1234
+ static LIGHT = {
1235
+ primary:'blue', secondary:'magenta', accent:'yellow',
1236
+ success:'green', warning:'yellow', error:'red', info:'blue',
1237
+ fg:'black', fgMuted:'gray', bg:'white', bgAlt:'#f6f8fa',
1238
+ bgHighlight:'#e1e4e8', border:'#d0d7de', selection:'#0969da',
1239
+ };
1240
+ static MONOKAI = {
1241
+ primary:'#a6e22e', secondary:'#fd971f', accent:'#e6db74',
1242
+ success:'#a6e22e', warning:'#fd971f', error:'#f92672', info:'#66d9ef',
1243
+ fg:'#f8f8f2', fgMuted:'#75715e', bg:'#272822', bgAlt:'#3e3d32',
1244
+ bgHighlight:'#49483e', border:'#75715e', selection:'#49483e',
1245
+ };
1246
+
1247
+ constructor(initial = 'dark') {
1248
+ super();
1249
+ this._themes = new Map();
1250
+ this._current = null;
1251
+ this._name = null;
1252
+ // Register built-ins
1253
+ this.define('dark', TTheme.DARK);
1254
+ this.define('light', TTheme.LIGHT);
1255
+ this.define('monokai', TTheme.MONOKAI);
1256
+ this.use(initial);
1257
+ }
1258
+
1259
+ /** Register a named theme (merges with dark base for missing tokens). */
1260
+ define(name, tokens) {
1261
+ this._themes.set(name, { ...TTheme.DARK, ...tokens });
1262
+ return this;
1263
+ }
1264
+
1265
+ /** Switch to a named theme. */
1266
+ use(name) {
1267
+ if (!this._themes.has(name)) throw new Error(`TTheme: unknown theme "${name}"`);
1268
+ this._name = name;
1269
+ this._current = this._themes.get(name);
1270
+ this.emit('change', name, this._current);
1271
+ return this;
1272
+ }
1273
+
1274
+ /** Get a token value from the active theme. */
1275
+ get(token, fallback = null) {
1276
+ return this._current?.[token] ?? fallback;
1277
+ }
1278
+
1279
+ /** Get fg/bg pair as a color array suitable for TVPixel. */
1280
+ pair(fg, bg) { return [this.get(fg), this.get(bg)]; }
1281
+
1282
+ /** All token names of the active theme. */
1283
+ tokens() { return this._current ? Object.keys(this._current) : []; }
1284
+
1285
+ /** All registered theme names. */
1286
+ names() { return [...this._themes.keys()]; }
1287
+
1288
+ /** Active theme name. */
1289
+ get name() { return this._name; }
1290
+
1291
+ /** Active theme raw object. */
1292
+ get theme() { return { ...this._current }; }
1293
+
1294
+ /**
1295
+ * Build a TVPixel pixelOpts object from semantic token names.
1296
+ * @param {string} fgToken — e.g. 'primary'
1297
+ * @param {string} bgToken — e.g. 'bg'
1298
+ * @param {string} [style]
1299
+ */
1300
+ style(fgToken, bgToken = null, style = null) {
1301
+ return { color: [this.get(fgToken), bgToken ? this.get(bgToken) : null], style };
1302
+ }
1303
+ }
1304
+
1305
+ // ═════════════════════════════════════════════════════════════════════════════
1306
+ // §9 twidget — higher-level interactive widgets
1307
+ // ═════════════════════════════════════════════════════════════════════════════
1308
+
1309
+ /**
1310
+ * TWidget — collection of interactive terminal widgets rendered into TViews.
1311
+ *
1312
+ * Widgets:
1313
+ * TWidget.Menu — keyboard/mouse navigable menu
1314
+ * TWidget.List — scrollable multi-select list
1315
+ * TWidget.Dialog — modal confirmation/info dialog
1316
+ * TWidget.TextInput — single-line text input component
1317
+ * TWidget.NumberInput — number spinner
1318
+ * TWidget.Checkbox — toggleable check item
1319
+ * TWidget.Tabs — tab bar + content switching
1320
+ * TWidget.Gauge — animated gauge/meter
1321
+ * TWidget.Notification — timed toast notification
1322
+ */
1323
+ class TWidget {
1324
+
1325
+ // ── Menu ───────────────────────────────────────────────────────────────────
1326
+ /**
1327
+ * Render a navigable menu into a TView.
1328
+ * Returns a Promise that resolves to the selected item (or null on Escape).
1329
+ *
1330
+ * @param {TView} view
1331
+ * @param {string[]} items
1332
+ * @param {object} [opts]
1333
+ * @param {number} [opts.initialIndex=0]
1334
+ * @param {*} [opts.selectedColor='brightcyan']
1335
+ * @param {*} [opts.normalColor='white']
1336
+ * @param {string} [opts.prefix=' '] — prefix for non-selected items
1337
+ * @param {string} [opts.selPrefix='▶ ']— prefix for selected item
1338
+ */
1339
+ static menu(view, items, opts = {}) {
1340
+ return new Promise(resolve => {
1341
+ const {
1342
+ initialIndex = 0,
1343
+ selectedColor = 'brightcyan',
1344
+ normalColor = 'white',
1345
+ prefix = ' ',
1346
+ selPrefix = '▶ ',
1347
+ } = opts;
1348
+ let idx = Math.max(0, Math.min(initialIndex, items.length - 1));
1349
+
1350
+ const draw = () => {
1351
+ for (let i = 0; i < items.length && i < view.rows; i++) {
1352
+ const sel = i === idx;
1353
+ const pre = sel ? selPrefix : prefix;
1354
+ const text = (pre + items[i]).padEnd(view.cols).slice(0, view.cols);
1355
+ view.writeText(i, 0, text, {
1356
+ color: [sel ? selectedColor : normalColor, null],
1357
+ style: sel ? 'bold' : null,
1358
+ });
1359
+ }
1360
+ view.markDirty();
1361
+ };
1362
+
1363
+ draw();
1364
+ view.focus();
1365
+
1366
+ const offUp = view.onArrow('up', () => { idx = (idx - 1 + items.length) % items.length; draw(); });
1367
+ const offDown = view.onArrow('down', () => { idx = (idx + 1) % items.length; draw(); });
1368
+ const offEnter = view.onKey('enter', () => done(items[idx]));
1369
+ const offEsc = view.onKey('escape', () => done(null));
1370
+ const offClick = view.onClick(({ localRow }) => {
1371
+ if (localRow >= 0 && localRow < items.length) { idx = localRow; draw(); done(items[idx]); }
1372
+ });
1373
+
1374
+ function done(value) {
1375
+ offUp(); offDown(); offEnter(); offEsc(); offClick();
1376
+ resolve(value);
1377
+ }
1378
+ });
1379
+ }
1380
+
1381
+ // ── List ───────────────────────────────────────────────────────────────────
1382
+ /**
1383
+ * Multi-select scrollable list.
1384
+ * Returns a Promise that resolves to an array of selected item strings.
1385
+ *
1386
+ * @param {TView} view
1387
+ * @param {string[]} items
1388
+ * @param {object} [opts]
1389
+ * @param {boolean} [opts.multiSelect=true]
1390
+ * @param {number[]} [opts.preSelected=[]]
1391
+ */
1392
+ static list(view, items, opts = {}) {
1393
+ return new Promise(resolve => {
1394
+ const { multiSelect = true, preSelected = [], selectedColor = 'brightcyan', normalColor = 'white' } = opts;
1395
+ let cursor = 0, scroll = 0;
1396
+ const selected = new Set(preSelected);
1397
+ const visRows = view.rows;
1398
+
1399
+ const draw = () => {
1400
+ for (let i = 0; i < visRows; i++) {
1401
+ const idx = i + scroll;
1402
+ if (idx >= items.length) { view.clearRow(i); continue; }
1403
+ const isCursor = idx === cursor;
1404
+ const isSelected = selected.has(idx);
1405
+ const mark = isSelected ? '[✓] ' : '[ ] ';
1406
+ const color = isCursor ? selectedColor : (isSelected ? 'brightgreen' : normalColor);
1407
+ view.writeText(i, 0, (mark + items[idx]).padEnd(view.cols).slice(0, view.cols), { color:[color,null], style: isCursor?'bold':null });
1408
+ }
1409
+ view.markDirty();
1410
+ };
1411
+
1412
+ draw();
1413
+ view.focus();
1414
+
1415
+ const offUp = view.onArrow('up', () => { if(cursor>0){cursor--;if(cursor<scroll)scroll--;} draw(); });
1416
+ const offDown = view.onArrow('down', () => { if(cursor<items.length-1){cursor++;if(cursor>=scroll+visRows)scroll++;} draw(); });
1417
+ const offSpace= view.onKey('space', () => { multiSelect?(selected.has(cursor)?selected.delete(cursor):selected.add(cursor)):(selected.clear(),selected.add(cursor)); draw(); });
1418
+ const offEnter= view.onKey('enter', () => done([...selected].map(i=>items[i])));
1419
+ const offEsc = view.onKey('escape', () => done([]));
1420
+
1421
+ function done(values) { offUp();offDown();offSpace();offEnter();offEsc(); resolve(values); }
1422
+ });
1423
+ }
1424
+
1425
+ // ── Dialog ─────────────────────────────────────────────────────────────────
1426
+ /**
1427
+ * Modal dialog over a view.
1428
+ * @param {TView} view — the view to draw the dialog on
1429
+ * @param {string} title
1430
+ * @param {string} message
1431
+ * @param {string[]} [buttons] — default: ['OK', 'Cancel']
1432
+ * @returns {Promise<string>} — the label of the clicked/selected button
1433
+ */
1434
+ static dialog(view, title, message, buttons = ['OK', 'Cancel']) {
1435
+ return new Promise(resolve => {
1436
+ const snap = view.snapshot();
1437
+ const dw = Math.min(view.cols - 4, Math.max(title.length + 4, message.length + 4, 30));
1438
+ const dh = 6;
1439
+ const dr = Math.floor((view.rows - dh) / 2);
1440
+ const dc = Math.floor((view.cols - dw) / 2);
1441
+ let btnIdx = 0;
1442
+
1443
+ const draw = () => {
1444
+ view.drawBox(dr, dc, dh, dw, { color: ['brightcyan', null] }, 'double');
1445
+ view.writeText(dr, dc + Math.floor((dw - title.length) / 2), title, { color: ['brightwhite', null], style: 'bold' });
1446
+ view.writeText(dr + 2, dc + Math.floor((dw - message.length) / 2), message, { color: ['white', null] });
1447
+ let btnCol = dc + Math.floor((dw - buttons.reduce((a,b)=>a+b.length+4,0)) / 2);
1448
+ buttons.forEach((btn, i) => {
1449
+ const sel = i === btnIdx;
1450
+ const text = ` [ ${btn} ] `;
1451
+ view.writeText(dr + 4, btnCol, text, { color: [sel ? 'black' : 'white', sel ? 'brightcyan' : null] });
1452
+ btnCol += text.length;
1453
+ });
1454
+ view.markDirty();
1455
+ };
1456
+
1457
+ draw();
1458
+ view.focus();
1459
+
1460
+ const offLeft = view.onArrow('left', () => { btnIdx=(btnIdx-1+buttons.length)%buttons.length; draw(); });
1461
+ const offRight = view.onArrow('right', () => { btnIdx=(btnIdx+1)%buttons.length; draw(); });
1462
+ const offEnter = view.onKey('enter', () => done(buttons[btnIdx]));
1463
+ const offEsc = view.onKey('escape', () => done(buttons[buttons.length-1]));
1464
+
1465
+ function done(label) {
1466
+ offLeft();offRight();offEnter();offEsc();
1467
+ view.restoreSnapshot(snap);
1468
+ resolve(label);
1469
+ }
1470
+ });
1471
+ }
1472
+
1473
+ // ── TextInput ──────────────────────────────────────────────────────────────
1474
+ /**
1475
+ * Inline text-input rendered at (row, col) in a view.
1476
+ * Returns a Promise<string|null>.
1477
+ *
1478
+ * @param {TView} view
1479
+ * @param {number} row
1480
+ * @param {number} col
1481
+ * @param {number} width
1482
+ * @param {object} [opts]
1483
+ * @param {string} [opts.placeholder]
1484
+ * @param {string} [opts.initial]
1485
+ * @param {string} [opts.mask] — char to show instead of actual input (e.g. '*' for passwords)
1486
+ */
1487
+ static textInput(view, row, col, width, opts = {}) {
1488
+ return new Promise(resolve => {
1489
+ const { placeholder = '', initial = '', mask = null } = opts;
1490
+ let value = initial;
1491
+ let cursor = value.length;
1492
+
1493
+ const draw = () => {
1494
+ const display = mask ? mask.repeat(value.length) : value;
1495
+ const visible = display.slice(Math.max(0, cursor - width + 1));
1496
+ const text = (visible || placeholder).padEnd(width).slice(0, width);
1497
+ const color = value.length ? 'white' : 'gray';
1498
+ view.writeText(row, col, text, { color: [color, '#1a1a2e'] });
1499
+ view.markDirty();
1500
+ process.stdout.write(ansi.moveTo(view.offsetRow + row, view.offsetCol + col + Math.min(cursor, width - 1)));
1501
+ };
1502
+
1503
+ draw(); view.focus();
1504
+
1505
+ const onKey = key => {
1506
+ if (key.name === 'enter') { cleanup(); resolve(value); }
1507
+ else if (key.name === 'escape'){ cleanup(); resolve(null); }
1508
+ else if (key.name === 'backspace' && cursor > 0) { value=value.slice(0,cursor-1)+value.slice(cursor); cursor--; draw(); }
1509
+ else if (key.name === 'delete' && cursor < value.length) { value=value.slice(0,cursor)+value.slice(cursor+1); draw(); }
1510
+ else if (key.name === 'left' && cursor > 0) { cursor--; draw(); }
1511
+ else if (key.name === 'right' && cursor < value.length) { cursor++; draw(); }
1512
+ else if (key.name === 'home') { cursor = 0; draw(); }
1513
+ else if (key.name === 'end') { cursor = value.length; draw(); }
1514
+ else if (key.char && key.char >= ' ' && !key.ctrl) { value=value.slice(0,cursor)+key.char+value.slice(cursor); cursor++; draw(); }
1515
+ };
1516
+ view.on('key', onKey);
1517
+ function cleanup() { view.removeListener('key', onKey); }
1518
+ });
1519
+ }
1520
+
1521
+ // ── Checkbox ───────────────────────────────────────────────────────────────
1522
+ /**
1523
+ * Toggle-able checkbox at (row, col).
1524
+ * @param {TView} view
1525
+ * @param {number} row
1526
+ * @param {number} col
1527
+ * @param {string} label
1528
+ * @param {boolean} [initial=false]
1529
+ * @returns {{ get: ()=>boolean, set: (v)=>void, off: ()=>void }}
1530
+ */
1531
+ static checkbox(view, row, col, label, initial = false) {
1532
+ let value = initial;
1533
+ const draw = () => {
1534
+ const box = value ? '[✓]' : '[ ]';
1535
+ view.writeText(row, col, `${box} ${label}`, { color: [value ? 'brightgreen' : 'white', null] });
1536
+ view.markDirty();
1537
+ };
1538
+ draw();
1539
+ const offClick = view.onClick(({ localRow, localCol }) => {
1540
+ if (localRow === row && localCol >= col && localCol < col + 3 + label.length + 1) {
1541
+ value = !value; draw();
1542
+ }
1543
+ });
1544
+ view.focus();
1545
+ return { get: () => value, set: v => { value = Boolean(v); draw(); }, off: offClick };
1546
+ }
1547
+
1548
+ // ── Tabs ───────────────────────────────────────────────────────────────────
1549
+ /**
1550
+ * Tab bar rendered at (row) in a view.
1551
+ * Returns a controller { active, setActive, onSwitch }.
1552
+ *
1553
+ * @param {TView} view
1554
+ * @param {number} row — row to draw the tab bar on
1555
+ * @param {string[]} tabNames
1556
+ */
1557
+ static tabs(view, row, tabNames) {
1558
+ let active = 0;
1559
+ const handlers = [];
1560
+
1561
+ const draw = () => {
1562
+ let col = 0;
1563
+ tabNames.forEach((name, i) => {
1564
+ const isActive = i === active;
1565
+ const text = ` ${name} `;
1566
+ view.writeText(row, col, text, {
1567
+ color: [isActive ? 'black' : 'gray', isActive ? 'brightcyan' : null],
1568
+ style: isActive ? 'bold' : null,
1569
+ });
1570
+ col += text.length + 1;
1571
+ });
1572
+ view.markDirty();
1573
+ };
1574
+ draw();
1575
+
1576
+ const offClick = view.onClick(({ localRow, localCol }) => {
1577
+ if (localRow !== row) return;
1578
+ let col = 0;
1579
+ tabNames.forEach((name, i) => {
1580
+ const w = name.length + 2;
1581
+ if (localCol >= col && localCol < col + w) { active = i; draw(); handlers.forEach(h=>h(i,name)); }
1582
+ col += w + 1;
1583
+ });
1584
+ });
1585
+
1586
+ return {
1587
+ get active() { return active; },
1588
+ setActive(i) { active=i; draw(); },
1589
+ onSwitch(fn) { handlers.push(fn); return ()=>{ const idx=handlers.indexOf(fn); if(idx>-1)handlers.splice(idx,1); }; },
1590
+ destroy() { offClick(); },
1591
+ };
1592
+ }
1593
+
1594
+ // ── Notification ───────────────────────────────────────────────────────────
1595
+ /**
1596
+ * Show a timed toast notification over a view.
1597
+ *
1598
+ * @param {TView} view
1599
+ * @param {string} message
1600
+ * @param {object} [opts]
1601
+ * @param {number} [opts.duration=2500] — ms before auto-dismiss
1602
+ * @param {string} [opts.type] — 'info'|'success'|'warn'|'error'
1603
+ * @param {string} [opts.position] — 'top'|'bottom' (default 'top')
1604
+ */
1605
+ static notify(view, message, opts = {}) {
1606
+ const { duration=2500, type='info', position='top' } = opts;
1607
+ const colors = { info:'brightcyan', success:'brightgreen', warn:'brightyellow', error:'brightred' };
1608
+ const icons = { info:'ℹ', success:'✔', warn:'⚠', error:'✖' };
1609
+ const color = colors[type] || 'brightcyan';
1610
+ const icon = icons[type] || 'ℹ';
1611
+ const snap = view.snapshot();
1612
+ const text = ` ${icon} ${message} `;
1613
+ const row = position === 'bottom' ? view.rows - 2 : 1;
1614
+ const col = Math.max(0, Math.floor((view.cols - text.length) / 2));
1615
+ view.writeText(row, col, text.slice(0,view.cols), { color: ['black', color] });
1616
+ view.markDirty();
1617
+ const timer = setTimeout(() => { view.restoreSnapshot(snap); view.markDirty(); }, duration);
1618
+ return () => { clearTimeout(timer); view.restoreSnapshot(snap); view.markDirty(); };
1619
+ }
1620
+
1621
+ // ── Gauge ──────────────────────────────────────────────────────────────────
1622
+ /**
1623
+ * Animated radial-style (ASCII) gauge rendered into a region.
1624
+ * @param {TView} view
1625
+ * @param {number} row
1626
+ * @param {number} col
1627
+ * @param {number} [size=5] — radius in chars (width = size*4+1)
1628
+ */
1629
+ static gauge(view, row, col, size = 5) {
1630
+ let _value = 0;
1631
+ const ARC = ['▁','▂','▃','▄','▅','▆','▇','█'];
1632
+
1633
+ const draw = () => {
1634
+ const w = size * 4 + 1;
1635
+ const filled = Math.round(_value * w);
1636
+ const bar = ARC[7].repeat(filled).padEnd(w, '░').slice(0, w);
1637
+ view.writeText(row, col, bar, { color: [_value>0.7?'brightred':_value>0.4?'brightyellow':'brightgreen', null] });
1638
+ const pct = `${Math.round(_value*100)}%`.padStart(4);
1639
+ view.writeText(row+1, col + Math.floor((w-pct.length)/2), pct, { color: ['white', null] });
1640
+ view.markDirty();
1641
+ };
1642
+ draw();
1643
+ return {
1644
+ set value(v) { _value = Math.max(0, Math.min(1, v)); draw(); },
1645
+ get value() { return _value; },
1646
+ };
1647
+ }
1648
+ }
1649
+
1650
+ // ═════════════════════════════════════════════════════════════════════════════
1651
+ // §10 trouter — keyboard-driven screen router
1652
+ // ═════════════════════════════════════════════════════════════════════════════
1653
+
1654
+ /**
1655
+ * TRouter — push/pop/replace stack-based screen router.
1656
+ *
1657
+ * Each "screen" is a named factory function: (view, router, params) => cleanup.
1658
+ * The cleanup function is called when the screen is popped or replaced.
1659
+ *
1660
+ * @example
1661
+ * const router = new TRouter(screen, inputManager);
1662
+ *
1663
+ * router.register('home', (view, router) => {
1664
+ * view.writeCentered(2, 'Press ENTER to go to settings');
1665
+ * view.onKey('enter', () => router.push('settings'));
1666
+ * return () => view.clear(); // cleanup
1667
+ * });
1668
+ *
1669
+ * router.register('settings', (view, router) => {
1670
+ * view.writeCentered(2, 'Settings — press Backspace to go back');
1671
+ * view.onKey('backspace', () => router.pop());
1672
+ * return () => view.clear();
1673
+ * });
1674
+ *
1675
+ * router.push('home');
1676
+ */
1677
+ class TRouter extends EventEmitter {
1678
+ /**
1679
+ * @param {TView} rootView — the single view screens render into
1680
+ * @param {InputManager} [im] — defaults to the module singleton
1681
+ */
1682
+ constructor(rootView, im = inputManager) {
1683
+ super();
1684
+ this._view = rootView;
1685
+ this._im = im;
1686
+ this._screens = new Map(); // name → factory(view, router, params) => cleanup
1687
+ this._stack = []; // [{ name, params, cleanup }]
1688
+ this._current = null;
1689
+ }
1690
+
1691
+ /**
1692
+ * Register a screen factory.
1693
+ * @param {string} name
1694
+ * @param {function} factory — (view, router, params) => (cleanup | void)
1695
+ */
1696
+ register(name, factory) {
1697
+ this._screens.set(name, factory);
1698
+ return this;
1699
+ }
1700
+
1701
+ /** Push a new screen onto the stack (the previous screen is paused). */
1702
+ push(name, params = {}) {
1703
+ const factory = this._screens.get(name);
1704
+ if (!factory) throw new Error(`TRouter: unknown screen "${name}"`);
1705
+ this._suspend();
1706
+ this._view.clear();
1707
+ const _cr = factory(this._view, this, params);
1708
+ const cleanup = typeof _cr === 'function' ? _cr : () => {};
1709
+ this._stack.push({ name, params, cleanup });
1710
+ this._current = name;
1711
+ this._view.focus();
1712
+ this.emit('push', { name, params, depth: this._stack.length });
1713
+ this.emit('navigate', { name, params, action: 'push' });
1714
+ return this;
1715
+ }
1716
+
1717
+ /** Pop the current screen and resume the previous one. */
1718
+ pop(result) {
1719
+ if (this._stack.length <= 1) return this;
1720
+ const top = this._stack.pop();
1721
+ top.cleanup?.();
1722
+ this._view.clear();
1723
+ const prev = this._stack[this._stack.length - 1];
1724
+ this._current = prev.name;
1725
+ // Re-run the previous screen's factory (resume)
1726
+ const _resumed = this._screens.get(prev.name)?.(this._view, this, prev.params);
1727
+ prev.cleanup = typeof _resumed === 'function' ? _resumed : () => {};
1728
+ this._view.focus();
1729
+ this.emit('pop', { from: top.name, to: prev.name, result });
1730
+ this.emit('navigate', { name: prev.name, params: prev.params, action: 'pop', result });
1731
+ return this;
1732
+ }
1733
+
1734
+ /** Replace the current screen without growing the stack. */
1735
+ replace(name, params = {}) {
1736
+ const factory = this._screens.get(name);
1737
+ if (!factory) throw new Error(`TRouter: unknown screen "${name}"`);
1738
+ if (this._stack.length > 0) { const top = this._stack.pop(); top.cleanup?.(); }
1739
+ this._view.clear();
1740
+ const _rr = factory(this._view, this, params);
1741
+ const cleanup = typeof _rr === 'function' ? _rr : () => {};
1742
+ this._stack.push({ name, params, cleanup });
1743
+ this._current = name;
1744
+ this._view.focus();
1745
+ this.emit('replace', { name, params });
1746
+ this.emit('navigate', { name, params, action: 'replace' });
1747
+ return this;
1748
+ }
1749
+
1750
+ /** Go to a specific depth in the stack (0 = root). */
1751
+ go(delta) {
1752
+ if (delta < 0) { for (let i=0;i<Math.abs(delta)&&this._stack.length>1;i++) this.pop(); }
1753
+ return this;
1754
+ }
1755
+
1756
+ /** Reset to root and push a new screen. */
1757
+ reset(name, params = {}) {
1758
+ while (this._stack.length > 0) { const top=this._stack.pop(); top.cleanup?.(); }
1759
+ this._view.clear();
1760
+ return this.push(name, params);
1761
+ }
1762
+
1763
+ get current() { return this._current; }
1764
+ get stackDepth() { return this._stack.length; }
1765
+ get canPop() { return this._stack.length > 1; }
1766
+
1767
+ _suspend() {
1768
+ // Run cleanup of current top without re-rendering previous
1769
+ if (this._stack.length > 0) {
1770
+ const top = this._stack[this._stack.length - 1];
1771
+ top.cleanup?.();
1772
+ }
1773
+ }
1774
+ }
1775
+
1776
+ // ═════════════════════════════════════════════════════════════════════════════
1777
+ // §11 Terminal — top-level bootstrap object
1778
+ // ═════════════════════════════════════════════════════════════════════════════
1779
+
1780
+ /**
1781
+ * Terminal — boots the entire kit in one call.
1782
+ *
1783
+ * @example
1784
+ * const { kitdef } = require('./kitdef');
1785
+ * const { Terminal } = kitdef;
1786
+ *
1787
+ * const term = new Terminal({ theme: 'dark', mouse: true });
1788
+ * term.on('ready', () => {
1789
+ * const [top, main, status] = term.screen.cutWeighted([0.08, 0.84, 0.08]);
1790
+ * term.log.setView(main);
1791
+ * term.log.info('Terminal ready', { cols: term.cols, rows: term.rows });
1792
+ * });
1793
+ */
1794
+ class Terminal extends EventEmitter {
1795
+ /**
1796
+ * @param {object} [opts]
1797
+ * @param {boolean} [opts.altScreen=true] — enter alternate screen buffer
1798
+ * @param {boolean} [opts.mouse=true] — enable mouse tracking
1799
+ * @param {string} [opts.theme='dark'] — initial TTheme
1800
+ * @param {string} [opts.logLevel='info'] — TLog level
1801
+ * @param {string} [opts.logFile] — optional log file path
1802
+ * @param {object} [opts.env] — extra env vars for TEnv / TShell
1803
+ * @param {boolean} [opts.exitOnCtrlC=true] — graceful Ctrl-C handler
1804
+ */
1805
+ constructor(opts = {}) {
1806
+ super();
1807
+ const {
1808
+ altScreen = true,
1809
+ mouse = true,
1810
+ theme = 'dark',
1811
+ logLevel = 'info',
1812
+ logFile,
1813
+ env = {},
1814
+ exitOnCtrlC = true,
1815
+ } = opts;
1816
+
1817
+ // ── Core singletons ────────────────────────────────────────────────────
1818
+ this.input = inputManager;
1819
+ this.theme = new TTheme(theme);
1820
+ this.env = new TEnv({ source: { ...process.env, ...env } });
1821
+ this.log = new TLog({ level: logLevel, file: logFile });
1822
+ this.shell = new TShell({ env });
1823
+
1824
+ // ── Full-screen root view ──────────────────────────────────────────────
1825
+ this.screen = createFullscreen({ autoRegister: false, fps: 30 });
1826
+ this.input.register(this.screen);
1827
+
1828
+ // ── Router (uses root screen) ──────────────────────────────────────────
1829
+ this.router = new TRouter(this.screen, this.input);
1830
+
1831
+ // ── Start input ───────────────────────────────────────────────────────
1832
+ this.input.start({ mouse });
1833
+ if (altScreen) {
1834
+ process.stdout.write(ansi.altScreenOn + ansi.clear + ansi.cursorHome);
1835
+ this._altScreen = true;
1836
+ }
1837
+
1838
+ // ── Terminal resize → screen resize ───────────────────────────────────
1839
+ this.input.on('resize', ({ rows, cols }) => {
1840
+ this.screen.resize(rows, cols);
1841
+ this.emit('resize', { rows, cols });
1842
+ });
1843
+
1844
+ // ── Graceful exit ─────────────────────────────────────────────────────
1845
+ if (exitOnCtrlC) {
1846
+ this.input.on('sigint', () => this.exit(0));
1847
+ }
1848
+
1849
+ // Expose dimensions as getters
1850
+ Object.defineProperty(this, 'rows', { get: () => process.stdout.rows || 24 });
1851
+ Object.defineProperty(this, 'cols', { get: () => process.stdout.columns || 80 });
1852
+
1853
+ setImmediate(() => this.emit('ready', this));
1854
+ }
1855
+
1856
+ /**
1857
+ * Gracefully shut down everything and exit.
1858
+ * @param {number} [code=0]
1859
+ */
1860
+ exit(code = 0) {
1861
+ this.emit('exit', code);
1862
+ this.input.disableMouse();
1863
+ this.input.stop();
1864
+ this.log.close();
1865
+ if (this._altScreen) process.stdout.write(ansi.altScreenOff);
1866
+ process.stdout.write(ansi.showCursor + '\n');
1867
+ // Give one tick for final writes to flush
1868
+ setImmediate(() => process.exit(code));
1869
+ }
1870
+
1871
+ /**
1872
+ * Create a TView pre-wired to the active theme's colors.
1873
+ * @param {object} opts — passed to TView constructor
1874
+ */
1875
+ createView(opts = {}) {
1876
+ return new TView({
1877
+ defaultFg: this.theme.get('fg'),
1878
+ defaultBg: this.theme.get('bg'),
1879
+ ...opts,
1880
+ });
1881
+ }
1882
+
1883
+ /**
1884
+ * Render the whole screen (useful after layout changes).
1885
+ */
1886
+ render() { this.screen.render(); return this; }
1887
+ }
1888
+
1889
+ // ═════════════════════════════════════════════════════════════════════════════
1890
+ // §12 module.exports
1891
+ // ═════════════════════════════════════════════════════════════════════════════
1892
+
1893
+ const kitdef = {
1894
+ // ── tview ─────────────────────────────────────────────────────────────────
1895
+ TVPixel,
1896
+ TView,
1897
+ InputManager,
1898
+ inputManager,
1899
+ createFullscreen,
1900
+ px,
1901
+ ansi,
1902
+ BORDER_STYLES,
1903
+ KEY_MAP,
1904
+
1905
+ // ── tshell ────────────────────────────────────────────────────────────────
1906
+ TShell,
1907
+
1908
+ // ── tenv ──────────────────────────────────────────────────────────────────
1909
+ TEnv,
1910
+
1911
+ // ── tlog ──────────────────────────────────────────────────────────────────
1912
+ TLog,
1913
+
1914
+ // ── ttheme ────────────────────────────────────────────────────────────────
1915
+ TTheme,
1916
+
1917
+ // ── twidget ───────────────────────────────────────────────────────────────
1918
+ TWidget,
1919
+
1920
+ // ── trouter ───────────────────────────────────────────────────────────────
1921
+ TRouter,
1922
+
1923
+ // ── Terminal bootstrap ────────────────────────────────────────────────────
1924
+ Terminal,
1925
+ };
1926
+
1927
+ module.exports = { kitdef };