dsh-neotui 0.0.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.
package/src/screen.js ADDED
@@ -0,0 +1,215 @@
1
+ // screen.js — Cell-grid framebuffer with ANSI diff rendering.
2
+ import { wcwidth } from "./text.js";
3
+
4
+ export const ATTR = { BOLD: 1, DIM: 2, ITALIC: 4, UNDERLINE: 8, REVERSE: 16, STRIKE: 32 };
5
+
6
+ let CELL_COUNTER = 0;
7
+ function blank() {
8
+ CELL_COUNTER++;
9
+ return { ch: " ", fg: -1, bg: -1, attrs: 0, wide: false, link: "" };
10
+ }
11
+
12
+ export class Screen {
13
+ constructor(w, h) {
14
+ this.w = w; this.h = h;
15
+ this.prev = null;
16
+ this.cells = [];
17
+ this.resize(w, h);
18
+ }
19
+
20
+ resize(w, h) {
21
+ this.w = w; this.h = h;
22
+ this.cells = new Array(h);
23
+ for (let y = 0; y < h; y++) {
24
+ const row = new Array(w);
25
+ for (let x = 0; x < w; x++) row[x] = blank();
26
+ this.cells[y] = row;
27
+ }
28
+ this.prev = null;
29
+ }
30
+
31
+ clear(fg = -1, bg = -1) {
32
+ for (let y = 0; y < this.h; y++)
33
+ for (let x = 0; x < this.w; x++) {
34
+ const c = this.cells[y][x];
35
+ c.ch = " "; c.fg = fg; c.bg = bg; c.attrs = 0; c.wide = false; c.link = "";
36
+ }
37
+ }
38
+
39
+ /** Set one cell. Wide chars consume two columns; x+1 becomes a continuation. */
40
+ put(x, y, ch = " ", { fg = -1, bg = -1, attrs = 0, link = "" } = {}) {
41
+ if (y < 0 || y >= this.h || x < 0 || x >= this.w) return;
42
+ // A glyph drawn over a wide char's continuation cell clips that wide char
43
+ // (nvim-style): the overlay wins, and the wide char's left half is cleared
44
+ // so the terminal never renders a half-glyph that "eats" the border.
45
+ if (x > 0) {
46
+ const left = this.cells[y][x - 1];
47
+ if (left.wide) {
48
+ left.ch = " ";
49
+ left.wide = false;
50
+ left.link = "";
51
+ }
52
+ }
53
+ const cell = this.cells[y][x];
54
+ const wide = wcwidth(ch.codePointAt(0)) === 2;
55
+ if (wide && x + 1 >= this.w) ch = " "; // clip wide char at the right edge (terminal wrap corruption)
56
+ cell.ch = ch; cell.fg = fg; cell.bg = bg; cell.attrs = attrs; cell.link = link;
57
+ cell.wide = wide && x + 1 < this.w;
58
+ if (cell.wide) {
59
+ const cont = this.cells[y][x + 1];
60
+ // If x+1 was itself a wide char (spanning x+1..x+2), clear its right half.
61
+ if (cont.wide && x + 2 < this.w) {
62
+ const next = this.cells[y][x + 2];
63
+ next.ch = " "; next.wide = false; next.link = "";
64
+ }
65
+ cont.ch = ""; cont.fg = fg; cont.bg = bg; cont.attrs = attrs; cont.link = link; cont.wide = false;
66
+ }
67
+ }
68
+
69
+ /** Write text; wide-aware; clips at right edge. Returns final x. */
70
+ text(x, y, s, style = {}) {
71
+ let px = x;
72
+ for (const ch of s) {
73
+ const cw = wcwidth(ch.codePointAt(0));
74
+ if (cw === 0) continue;
75
+ if (px >= this.w) break;
76
+ if (cw === 2 && px + 1 >= this.w) break; // wide char needs both columns
77
+ this.put(px, y, ch, style);
78
+ px += cw;
79
+ }
80
+ return px;
81
+ }
82
+
83
+ fillRect(x0, y0, x1, y1, ch = " ", style = {}) {
84
+ for (let y = y0; y <= y1; y++)
85
+ for (let x = x0; x <= x1; x++) this.put(x, y, ch, style);
86
+ }
87
+
88
+ box(x0, y0, x1, y1, style = {}, title = "") {
89
+ const { fg, bg = -1 } = style;
90
+ const s = { fg, bg };
91
+ for (let x = x0; x <= x1; x++) {
92
+ this.put(x, y0, "─", s);
93
+ this.put(x, y1, "─", s);
94
+ }
95
+ for (let y = y0; y <= y1; y++) {
96
+ this.put(x0, y, "│", s);
97
+ this.put(x1, y, "│", s);
98
+ }
99
+ this.put(x0, y0, "╭", s); this.put(x1, y0, "╮", s);
100
+ this.put(x0, y1, "╰", s); this.put(x1, y1, "╯", s);
101
+ if (title) this.text(x0 + 2, y0, " " + title + " ", s);
102
+ }
103
+
104
+ hline(x0, x1, y, ch = "─", style = {}) {
105
+ for (let x = x0; x <= x1; x++) this.put(x, y, ch, style);
106
+ }
107
+
108
+ /** Apply reverse-video over a rect (drag-selection highlight). */
109
+ invertRect(x0, y0, x1, y1) {
110
+ for (let y = Math.max(0, y0); y <= Math.min(this.h - 1, y1); y++)
111
+ for (let x = Math.max(0, x0); x <= Math.min(this.w - 1, x1); x++) {
112
+ const c = this.cells[y][x];
113
+ if (c.ch !== " " && c.ch !== "") c.attrs |= ATTR.REVERSE;
114
+ }
115
+ }
116
+
117
+ vline(x, y0, y1, ch = "│", style = {}) {
118
+ for (let y = y0; y <= y1; y++) this.put(x, y, ch, style);
119
+ }
120
+
121
+ // ---- ANSI diff rendering ----
122
+
123
+ sgr(fg, bg, attrs) {
124
+ const parts = [];
125
+ if (attrs !== 0) {
126
+ const bold = attrs & ATTR.BOLD ? ";1" : "";
127
+ const dim = attrs & ATTR.DIM ? ";2" : "";
128
+ const ital = attrs & ATTR.ITALIC ? ";3" : "";
129
+ const ul = attrs & ATTR.UNDERLINE ? ";4" : "";
130
+ const rev = attrs & ATTR.REVERSE ? ";7" : "";
131
+ const str = attrs & ATTR.STRIKE ? ";9" : "";
132
+ parts.push(`\x1b[0${bold}${dim}${ital}${ul}${rev}${str}m`);
133
+ }
134
+ if (fg >= 0) parts.push(`\x1b[38;2;${(fg >> 16) & 255};${(fg >> 8) & 255};${fg & 255}m`);
135
+ if (bg >= 0) parts.push(`\x1b[48;2;${(bg >> 16) & 255};${(bg >> 8) & 255};${bg & 255}m`);
136
+ return parts.join("");
137
+ }
138
+
139
+ /** Render diff versus previous frame. Returns ANSI string (no final flush). */
140
+ render() {
141
+ const prev = this.prev;
142
+ const out = [];
143
+ let curFg = -1, curBg = -1, curAttrs = 0, curLink = "";
144
+ const ensureStyle = (fg, bg, attrs) => {
145
+ if (fg === curFg && bg === curBg && attrs === curAttrs) return;
146
+ // Any component returning to default while another stays styled needs a
147
+ // full reset first, or the terminal keeps the previous background
148
+ // (the classic scroll-smear bug).
149
+ const needReset = (bg === -1 && curBg !== -1) || (fg === -1 && curFg !== -1) || (attrs === 0 && curAttrs !== 0);
150
+ curFg = fg; curBg = bg; curAttrs = attrs;
151
+ if (fg === -1 && bg === -1 && attrs === 0) {
152
+ out.push("\x1b[0m");
153
+ return;
154
+ }
155
+ if (needReset) out.push("\x1b[0m");
156
+ out.push(this.sgr(fg, bg, attrs));
157
+ };
158
+ const ensureLink = (link) => {
159
+ if (link !== curLink) {
160
+ if (curLink) out.push("\x1b]8;;\x1b\\");
161
+ if (link) out.push(`\x1b]8;;${link}\x1b\\`);
162
+ curLink = link;
163
+ }
164
+ };
165
+ for (let y = 0; y < this.h; y++) {
166
+ const row = this.cells[y];
167
+ const prow = prev ? prev[y] : null;
168
+ let x = 0;
169
+ while (x < this.w) {
170
+ const c = row[x];
171
+ const p = prow ? prow[x] : null;
172
+ let dirty = !p || p.ch !== c.ch || p.fg !== c.fg || p.bg !== c.bg || p.attrs !== c.attrs || p.link !== c.link || p.wide !== c.wide;
173
+ // Wide-char atomicity: if the wide glyph spans x,x+1 and its right half
174
+ // was clobbered in the previous frame (e.g. an overlay border overwrote
175
+ // the continuation cell), redraw the whole glyph so the char reappears.
176
+ if (!dirty && c.wide && prow && x + 1 < this.w) {
177
+ const p2 = prow[x + 1];
178
+ if (p2 && p2.ch !== "") dirty = true;
179
+ }
180
+ if (!dirty) { x++; continue; }
181
+ out.push(`\x1b[${y + 1};${x + 1}H`);
182
+ ensureStyle(c.fg, c.bg, c.attrs);
183
+ ensureLink(c.link);
184
+ out.push(c.ch === "" ? " " : c.ch);
185
+ if (c.wide) x += 2; else x++;
186
+ }
187
+ }
188
+ if (curLink) out.push("\x1b]8;;\x1b\\");
189
+ out.push(`\x1b[${this.h};1H`);
190
+ this.prev = this.cells;
191
+ this.cells = new Array(this.h);
192
+ for (let y = 0; y < this.h; y++) {
193
+ const row = new Array(this.w);
194
+ for (let x = 0; x < this.w; x++) row[x] = blank();
195
+ this.cells[y] = row;
196
+ }
197
+ return out.join("");
198
+ }
199
+
200
+ /** Plain-text dump (no ANSI) for scripted tests. Reads the last rendered frame. */
201
+ toPlain() {
202
+ const cells = this.prev ?? this.cells;
203
+ const lines = [];
204
+ for (let y = 0; y < this.h; y++) {
205
+ let s = "";
206
+ const row = cells[y] ?? [];
207
+ for (let x = 0; x < this.w; x++) {
208
+ const c = row[x];
209
+ s += c && c.ch !== "" ? c.ch : " ";
210
+ }
211
+ lines.push(s.replace(/\s+$/, ""));
212
+ }
213
+ return lines.join("\n");
214
+ }
215
+ }
package/src/term.js ADDED
@@ -0,0 +1,270 @@
1
+ // term.js — Raw-mode terminal: alternate screen, SGR mouse (1000/1002/1003/1006),
2
+ // bracketed paste, kitty keyboard (best-effort), key + mouse event decoding.
3
+ import { StringDecoder } from "node:string_decoder";
4
+
5
+ export function detectKitty(env = process.env) {
6
+ return Boolean(env.KITTY_WINDOW_ID || env.TERM_PROGRAM === "WezTerm" || /kitty/i.test(env.TERM ?? ""));
7
+ }
8
+
9
+ const KEY_NAMES = {
10
+ A: "up", B: "down", C: "right", D: "left", H: "home", F: "end", P: "f1", Q: "f2",
11
+ R: "f3", S: "f4",
12
+ };
13
+ const TILDE_NAMES = {
14
+ 1: "home", 2: "insert", 3: "delete", 4: "end", 5: "pgup", 6: "pgdn",
15
+ 7: "home", 8: "end", 11: "f1", 12: "f2", 13: "f3", 14: "f4", 15: "f5",
16
+ 17: "f6", 18: "f7", 19: "f8", 20: "f9", 21: "f10", 23: "f11", 24: "f12",
17
+ };
18
+ const KITTY_KEY_NAMES = {
19
+ 13: "enter", 9: "tab", 27: "escape", 127: "backspace",
20
+ 57344: "f1", 57345: "f2", 57346: "f3", 57347: "f4", 57348: "f5", 57349: "f6",
21
+ 57350: "f7", 57351: "f8", 57352: "f9", 57353: "f10", 57354: "f11", 57355: "f12",
22
+ 57356: "insert", 57357: "delete", 57358: "home", 57359: "end", 57360: "pgup", 57361: "pgdn",
23
+ 57362: "up", 57363: "down", 57364: "right", 57365: "left",
24
+ };
25
+
26
+ export class Term {
27
+ constructor({ input = process.stdin, output = process.stdout, onEvent, onResize, kitty = false } = {}) {
28
+ this.input = input; this.output = output;
29
+ this.onEvent = onEvent ?? (() => {});
30
+ this.onResize = onResize ?? (() => {});
31
+ this.kitty = kitty;
32
+ this.decoder = new StringDecoder("utf8");
33
+ this.buf = "";
34
+ this.started = false;
35
+ this.pasteBuf = null;
36
+ this.w = (output.columns || 80); this.h = (output.rows || 24);
37
+ }
38
+
39
+ start() {
40
+ if (this.started) return;
41
+ this.started = true;
42
+ if (typeof this.input.setRawMode === "function") this.input.setRawMode(true);
43
+ this.input.resume();
44
+ this.input.on("data", (chunk) => this.#feed(this.decoder.write(chunk)));
45
+ const o = this.output;
46
+ o.write("\x1b[?1049h"); // alt screen
47
+ o.write("\x1b[?25l"); // hide cursor
48
+ o.write("\x1b[?1000h"); // mouse: clicks
49
+ o.write("\x1b[?1002h"); // mouse: drag
50
+ o.write("\x1b[?1003h"); // mouse: all motion
51
+ o.write("\x1b[?1006h"); // SGR extended coordinates
52
+ o.write("\x1b[?2004h"); // bracketed paste
53
+ o.write("\x1b[?7l"); // no autowrap (we clip ourselves)
54
+ if (this.kitty) o.write("\x1b[>1u"); // kitty: disambiguate escape codes
55
+ this.resizeHandler = () => this.#resize();
56
+ process.on("SIGWINCH", this.resizeHandler);
57
+ this.#resize();
58
+ }
59
+
60
+ stop() {
61
+ if (!this.started) return;
62
+ this.started = false;
63
+ const o = this.output;
64
+ o.write("\x1b[?7h\x1b[?2004l\x1b[?1006l\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?25h\x1b[?1049l");
65
+ if (this.kitty) o.write("\x1b[<u");
66
+ process.off("SIGWINCH", this.resizeHandler);
67
+ this.input.pause();
68
+ if (typeof this.input.setRawMode === "function") this.input.setRawMode(false);
69
+ }
70
+
71
+ #resize() {
72
+ const w = this.output.columns || process.stdout.columns || 80;
73
+ const h = this.output.rows || process.stdout.rows || 24;
74
+ if (w !== this.w || h !== this.h) {
75
+ this.w = w; this.h = h;
76
+ this.onResize(w, h);
77
+ }
78
+ }
79
+
80
+ #emit(ev) { this.onEvent(ev); }
81
+
82
+ #feed(s) {
83
+ if (!s) return;
84
+ this.buf += s;
85
+ this.#parse();
86
+ }
87
+
88
+ #parse() {
89
+ const buf = this.buf;
90
+ let i = 0;
91
+ while (i < buf.length) {
92
+ const ch = buf[i];
93
+ if (ch === "\x1b") {
94
+ // escape sequence
95
+ const next = buf[i + 1];
96
+ if (next === "[") {
97
+ const r = this.#parseCsi(buf, i + 2);
98
+ if (r === null) { i = buf.length; break; } // incomplete
99
+ i = r.next;
100
+ } else if (next === "O") {
101
+ const fin = buf[i + 2];
102
+ if (fin === undefined) { i = buf.length; break; }
103
+ const name = KEY_NAMES[fin];
104
+ if (name) this.#emit({ type: "key", name, ctrl: false, alt: false, shift: false });
105
+ i += 3;
106
+ } else if (next === "]") {
107
+ // OSC: skip to BEL or ST
108
+ let j = i + 2;
109
+ while (j < buf.length) {
110
+ if (buf[j] === "\x07") { j++; break; }
111
+ if (buf[j] === "\x1b" && buf[j + 1] === "\\") { j += 2; break; }
112
+ j++;
113
+ }
114
+ if (j > buf.length && buf[buf.length - 1] !== "\x07") { i = buf.length; break; }
115
+ i = j;
116
+ } else if (next === "P" || next === "X" || next === "^" || next === "_") {
117
+ // DCS / SOS / PM / APC: skip to ST (or BEL)
118
+ let j = i + 2;
119
+ while (j < buf.length) {
120
+ if (buf[j] === "\x07") { j++; break; }
121
+ if (buf[j] === "\x1b" && buf[j + 1] === "\\") { j += 2; break; }
122
+ j++;
123
+ }
124
+ i = j;
125
+ } else if (next !== undefined) {
126
+ // ESC + char = alt key
127
+ const cp = next.codePointAt(0);
128
+ if (cp === 13) this.#emit({ type: "key", name: "enter", ctrl: false, alt: true, shift: false });
129
+ else this.#emit({ type: "key", name: "char", key: next.toLowerCase(), text: next, ctrl: false, alt: true, shift: false });
130
+ i += 2;
131
+ } else { i = buf.length; break; }
132
+ } else if (ch === "\r") {
133
+ this.#emit({ type: "key", name: "enter", ctrl: false, alt: false, shift: false });
134
+ i++;
135
+ } else if (ch === "\n") {
136
+ // LF in raw mode = Ctrl+J (insert newline), distinct from Enter (CR).
137
+ this.#emit({ type: "key", name: "char", key: "j", text: "j", ctrl: true, alt: false, shift: false });
138
+ i++;
139
+ } else if (ch === "\t") {
140
+ this.#emit({ type: "key", name: "tab", ctrl: false, alt: false, shift: false });
141
+ i++;
142
+ } else if (ch === "\x7f") {
143
+ this.#emit({ type: "key", name: "backspace", ctrl: false, alt: false, shift: false });
144
+ i++;
145
+ } else {
146
+ const cp = ch.codePointAt(0);
147
+ if (cp < 32) {
148
+ if (cp === 0) {
149
+ // NUL: legacy Ctrl+Space encoding (xterm/WezTerm without kitty protocol)
150
+ this.#emit({ type: "key", name: "char", key: " ", text: " ", ctrl: true, alt: false, shift: false });
151
+ } else {
152
+ // ctrl+letter
153
+ const key = String.fromCharCode(cp + 96);
154
+ this.#emit({ type: "key", name: "char", key, text: key, ctrl: true, alt: false, shift: false });
155
+ }
156
+ i++;
157
+ } else {
158
+ // text run: collect until next ESC/control
159
+ let j = i;
160
+ while (j < buf.length) {
161
+ const c = buf.codePointAt(j);
162
+ if (c === 27 || c < 32) break;
163
+ j += c > 0xffff ? 2 : 1;
164
+ }
165
+ this.#emit({ type: "text", text: buf.slice(i, j) });
166
+ i = j;
167
+ }
168
+ }
169
+ }
170
+ this.buf = buf.slice(i);
171
+ }
172
+
173
+ /** Parse CSI starting after "\x1b[". Returns {next} or null when incomplete. */
174
+ #parseCsi(buf, start) {
175
+ let i = start;
176
+ let prefix = "";
177
+ const first = buf[i];
178
+ if (first === "<" || first === ">" || first === "?" || first === "=") { prefix = first; i++; }
179
+ let params = "";
180
+ while (i < buf.length) {
181
+ const c = buf[i];
182
+ const cc = c.charCodeAt(0);
183
+ if ((cc >= 0x30 && cc <= 0x39) || c === ";" || c === ":" || c === " ") { params += c; i++; continue; }
184
+ if (cc >= 0x40 && cc <= 0x7e) {
185
+ this.#dispatchCsi(prefix, params, c);
186
+ return { next: i + 1 };
187
+ }
188
+ if (cc === 0x1b || cc < 32) {
189
+ // Malformed; drop and resync from here
190
+ return { next: i };
191
+ }
192
+ return null; // incomplete (wait for final byte)
193
+ }
194
+ return null;
195
+ }
196
+
197
+ #dispatchCsi(prefix, params, final) {
198
+ if (prefix === "<") {
199
+ this.#mouse(params, final);
200
+ return;
201
+ }
202
+ if (prefix === "?") return; // private responses (cursor pos, kitty flags) — ignored
203
+ if (prefix === ">") return;
204
+ if (final === "Z") { // Shift+Tab (backtab)
205
+ this.#emit({ type: "key", name: "backtab", ctrl: false, alt: false, shift: true });
206
+ return;
207
+ }
208
+ if (final === "u") {
209
+ this.#kittyKey(params);
210
+ return;
211
+ }
212
+ const nums = params.split(";").filter((s) => s !== "").map(Number);
213
+ if (final === "~") {
214
+ // xterm modifyOtherKeys: CSI 27;mod;code~ → the code with modifiers
215
+ if (nums.length >= 3 && nums[0] === 27) {
216
+ const code = nums[2] ?? 0;
217
+ const mod = nums[1] ?? 1;
218
+ const ctrl = !!(mod - 1 & 4), alt = !!(mod - 1 & 2), shift = !!(mod - 1 & 1);
219
+ let text = "";
220
+ try { text = String.fromCodePoint(code); } catch { return; }
221
+ this.#emit({ type: "key", name: "char", key: text.toLowerCase(), text, ctrl, alt, shift });
222
+ return;
223
+ }
224
+ const [n = 0, mod = 1] = nums;
225
+ const name = TILDE_NAMES[n];
226
+ if (!name) return;
227
+ const ctrl = !!(mod - 1 & 4), alt = !!(mod - 1 & 2), shift = !!(mod - 1 & 1);
228
+ this.#emit({ type: "key", name, ctrl, alt, shift });
229
+ return;
230
+ }
231
+ const name = KEY_NAMES[final];
232
+ if (!name) return;
233
+ // modifier is the last param before the final byte (e.g. CSI 1;5A)
234
+ const mod = nums.length > 1 ? nums[nums.length - 1] : 1;
235
+ const ctrl = !!(mod - 1 & 4), alt = !!(mod - 1 & 2), shift = !!(mod - 1 & 1);
236
+ this.#emit({ type: "key", name, ctrl, alt, shift });
237
+ }
238
+
239
+ #kittyKey(params) {
240
+ const [cp = 0, mod = 1] = params.split(";").filter((s) => s !== "").map(Number);
241
+ const ctrl = !!(mod & 4), alt = !!(mod & 2), shift = !!(mod & 1);
242
+ let name = KITTY_KEY_NAMES[cp];
243
+ if (name === "tab" && shift) name = "backtab";
244
+ if (name) {
245
+ this.#emit({ type: "key", name, ctrl, alt, shift });
246
+ return;
247
+ }
248
+ let text;
249
+ try { text = String.fromCodePoint(cp); } catch { return; }
250
+ if (ctrl && /^[a-zA-Z]$/.test(text)) text = text.toLowerCase();
251
+ this.#emit({ type: "key", name: "char", key: text.toLowerCase(), text, ctrl, alt, shift });
252
+ }
253
+
254
+ #mouse(params, final) {
255
+ const [b = 0, x = 0, y = 0] = params.split(";").filter((s) => s !== "").map(Number);
256
+ const kind = final === "M" ? "press" : "release";
257
+ const motion = !!(b & 32);
258
+ const wheel = !!(b & 64);
259
+ const button = b & 3; // 0 left, 1 middle, 2 right
260
+ let ev;
261
+ if (wheel) {
262
+ ev = { type: "mouse", kind: button === 0 ? "wheel-up" : "wheel-down", button: button === 0 ? 4 : 5, x: x - 1, y: y - 1, ctrl: !!(b & 16), shift: !!(b & 4), alt: !!(b & 8), motion: false };
263
+ } else if (motion) {
264
+ ev = { type: "mouse", kind: button === 3 ? "release" : "drag", button: button === 3 ? 0 : button, x: x - 1, y: y - 1, ctrl: !!(b & 16), shift: !!(b & 4), alt: !!(b & 8), motion: true };
265
+ } else {
266
+ ev = { type: "mouse", kind, button, x: x - 1, y: y - 1, ctrl: !!(b & 16), shift: !!(b & 4), alt: !!(b & 8), motion: false };
267
+ }
268
+ this.#emit(ev);
269
+ }
270
+ }
package/src/text.js ADDED
@@ -0,0 +1,110 @@
1
+ // text.js — Unicode width, truncation, braille bitmaps, color helpers.
2
+ // Zero external dependencies.
3
+
4
+ /** Display width of one code point (approximate wcwidth). */
5
+ export function wcwidth(cp) {
6
+ if (cp === 0) return 0;
7
+ // Combining marks and zero-width formatting
8
+ if (
9
+ (cp >= 0x0300 && cp <= 0x036f) || (cp >= 0x1ab0 && cp <= 0x1aff) ||
10
+ (cp >= 0x1dc0 && cp <= 0x1dff) || (cp >= 0x20d0 && cp <= 0x20ff) ||
11
+ (cp >= 0xfe00 && cp <= 0xfe0f) || (cp >= 0xfe20 && cp <= 0xfe2f) ||
12
+ (cp >= 0x200b && cp <= 0x200f) || cp === 0x2060 || cp === 0x00ad
13
+ ) return 0;
14
+ // Controls (rendered as placeholders by the screen layer)
15
+ if (cp < 32 || (cp >= 0x7f && cp < 0xa0)) return 0;
16
+ // Wide (CJK + fullwidth + emoji ranges)
17
+ if (
18
+ (cp >= 0x1100 && cp <= 0x115f) || cp === 0x2329 || cp === 0x232a ||
19
+ (cp >= 0x2e80 && cp <= 0xa4cf && cp !== 0x303f) ||
20
+ (cp >= 0xac00 && cp <= 0xd7a3) || (cp >= 0xf900 && cp <= 0xfaff) ||
21
+ (cp >= 0xfe10 && cp <= 0xfe19) || (cp >= 0xfe30 && cp <= 0xfe6f) ||
22
+ (cp >= 0xff00 && cp <= 0xff60) || (cp >= 0xffe0 && cp <= 0xffe6) ||
23
+ (cp >= 0x1f300 && cp <= 0x1faff) || (cp >= 0x20000 && cp <= 0x3fffd)
24
+ ) return 2;
25
+ return 1;
26
+ }
27
+
28
+ export function strWidth(s) {
29
+ let w = 0;
30
+ for (const ch of s) w += wcwidth(ch.codePointAt(0));
31
+ return w;
32
+ }
33
+
34
+ /** Truncate to display width; appends '…' when cut. */
35
+ export function truncate(s, w) {
36
+ if (w <= 0) return "";
37
+ const ell = "…";
38
+ if (strWidth(s) <= w) return s;
39
+ let out = "", used = 0;
40
+ for (const ch of s) {
41
+ const cw = wcwidth(ch.codePointAt(0));
42
+ if (used + cw > w - 1) break;
43
+ out += ch; used += cw;
44
+ }
45
+ return out + ell;
46
+ }
47
+
48
+ /** Pad with spaces to exact display width (assumes strWidth(s) <= w). */
49
+ export function pad(s, w, align = "left") {
50
+ const gap = w - strWidth(s);
51
+ if (gap <= 0) return s;
52
+ const sp = " ".repeat(gap);
53
+ return align === "right" ? sp + s : s + sp;
54
+ }
55
+
56
+ export function hexRgb(hex) {
57
+ const m = /^#?([0-9a-f]{6})$/i.exec(String(hex).trim());
58
+ if (!m) return null;
59
+ const v = parseInt(m[1], 16);
60
+ return [(v >> 16) & 255, (v >> 8) & 255, v & 255];
61
+ }
62
+
63
+ /** Blend rgb → 256-color palette index (for degradable terminals). */
64
+ export function rgb256(r, g, b) {
65
+ if (r === g && g === b) {
66
+ if (r < 8) return 16;
67
+ if (r > 248) return 231;
68
+ return Math.round((r - 8) / 10) + 232;
69
+ }
70
+ const ri = Math.round((r / 255) * 5), gi = Math.round((g / 255) * 5), bi = Math.round((b / 255) * 5);
71
+ return 16 + 36 * ri + 6 * gi + bi;
72
+ }
73
+
74
+ // ---- Braille (2 cols × 4 rows per character cell) ----
75
+ // col: array of up to 4 booleans, index 0 = top dot.
76
+
77
+ const BRAILLE_BASE = 0x2800;
78
+
79
+ export function brailleCell(left, right) {
80
+ let bits = 0;
81
+ for (let y = 0; y < 4; y++) {
82
+ if (left[y]) bits |= 1 << y; // dots 1..4 (left column)
83
+ if (right[y]) bits |= 1 << (y + 3); // dots 4..8 (right column)
84
+ }
85
+ return String.fromCodePoint(BRAILLE_BASE + bits);
86
+ }
87
+
88
+ /** cols: array of columns (each an array of booleans, 0=top). Returns braille string. */
89
+ export function brailleLine(cols) {
90
+ let out = "";
91
+ for (let i = 0; i < cols.length; i += 2) {
92
+ out += brailleCell(cols[i] ?? [], cols[i + 1] ?? []);
93
+ }
94
+ return out;
95
+ }
96
+
97
+ /** Bars: given values 0..1 and width, produce block-glyph column string. */
98
+ export function bars(values, width, { min = 0, max = 1 } = {}) {
99
+ const out = [];
100
+ for (let i = 0; i < width; i++) {
101
+ const t = values[i] ?? 0;
102
+ const v = Math.max(0, Math.min(1, (t - min) / (max - min)));
103
+ let eighths = Math.round(v * 8);
104
+ if (v > 0 && eighths <= 0) eighths = 1; // visible sliver for tiny fractions
105
+ if (eighths <= 0) out.push(" ");
106
+ else if (eighths < 8) out.push(String.fromCodePoint(0x2581 + eighths - 1));
107
+ else out.push("█");
108
+ }
109
+ return out.join("");
110
+ }
package/src/theme.js ADDED
@@ -0,0 +1,90 @@
1
+ // theme.js — Named terminal palettes. All UI code reads through the live
2
+ // proxy T; switching themes takes effect on the next frame render.
3
+ export const THEMES = {
4
+ dark: {
5
+ name: "dark",
6
+ BG: 0x12151a, BG2: 0x161a20, PANEL: 0x1c2128, STATUSBG: 0x1f242b, CARD: 0x181d24,
7
+ USERBG: 0x22262e, THINKBG: 0x181b20, TOOLBG: 0x1e1e2e, TOOLOK: 0x1e2e1e, TOOLERR: 0x2e1e1e,
8
+ BORDER: 0x2a323c, BORDER2: 0x3a424c,
9
+ TXT: 0xd4d8dd, DIM: 0x8b939e, FAINT: 0x5c6670, BOLD: 0xffffff,
10
+ ACCENT: 0x67b7ff, ACCENT2: 0x4d9fff, HEADING: 0x7cc7ff,
11
+ LINK: 0x67b7ff, CODE: 0x9ce5ed, CODEBG: 0x1c2128,
12
+ OK: 0x7dde86, WARN: 0xf5c96b, ERR: 0xff7a7a,
13
+ PURPLE: 0x9f86ff, RED: 0xff8a8a, GREEN: 0x8adf95, PINK: 0xffb3b3, GREENG: 0xb3e6b8,
14
+ KEYWORD: 0xc792ea, STRING: 0x98c379, NUMBER: 0xd19a66,
15
+ TABLEHEAD: 0xf2f4f6, TABLESEP: 0x3a424c, QUOTE: 0x8b949e, QUOTEFG: 0xc7ccd1,
16
+ SELBG: 0x3a4a5c, SELFG: 0xffffff, CURSORBG: 0x3a4a5c, CURSORFG: 0xffffff,
17
+ MENUBG: 0x1c2128, MENUSEL: 0x3a4a5c, SCROLLTHUMB: 0x67b7ff, SCROLLTRACK: 0x2a323c,
18
+ },
19
+ light: {
20
+ name: "light",
21
+ BG: 0xf6f6f6, BG2: 0xf0f0f0, PANEL: 0xffffff, STATUSBG: 0xe8e8e8, CARD: 0xffffff,
22
+ USERBG: 0xececec, THINKBG: 0xf2f2f2, TOOLBG: 0xe9edf7, TOOLOK: 0xe9f4e9, TOOLERR: 0xf7e9e9,
23
+ BORDER: 0xd4d4d4, BORDER2: 0xc0c0c0,
24
+ TXT: 0x2a2a2a, DIM: 0x666666, FAINT: 0x999999, BOLD: 0x000000,
25
+ ACCENT: 0x0a5fd7, ACCENT2: 0x0a5fd7, HEADING: 0x0a5fd7,
26
+ LINK: 0x0a5fd7, CODE: 0x9a2b6e, CODEBG: 0xf0f0f0,
27
+ OK: 0x1f8a3d, WARN: 0xa86a00, ERR: 0xd02222,
28
+ PURPLE: 0x6a3fd0, RED: 0xd02222, GREEN: 0x1f8a3d, PINK: 0xc05060, GREENG: 0x2a8a4a,
29
+ KEYWORD: 0x7c2fc0, STRING: 0x1f6f3d, NUMBER: 0xa05a00,
30
+ TABLEHEAD: 0x111111, TABLESEP: 0xc0c0c0, QUOTE: 0x777777, QUOTEFG: 0x444444,
31
+ SELBG: 0xcfe4ff, SELFG: 0x000000, CURSORBG: 0xcfe4ff, CURSORFG: 0x000000,
32
+ MENUBG: 0xffffff, MENUSEL: 0xcfe4ff, SCROLLTHUMB: 0x0a5fd7, SCROLLTRACK: 0xd4d4d4,
33
+ },
34
+ gruvbox: {
35
+ name: "gruvbox",
36
+ BG: 0x282828, BG2: 0x242424, PANEL: 0x32302f, STATUSBG: 0x32302f, CARD: 0x32302f,
37
+ USERBG: 0x3c3836, THINKBG: 0x2e2b28, TOOLBG: 0x2f3a3c, TOOLOK: 0x333c33, TOOLERR: 0x3c3232,
38
+ BORDER: 0x504945, BORDER2: 0x665c54,
39
+ TXT: 0xebdbb2, DIM: 0xa89984, FAINT: 0x7c6f64, BOLD: 0xfbf1c7,
40
+ ACCENT: 0x83a598, ACCENT2: 0x8ec07c, HEADING: 0x8ec07c,
41
+ LINK: 0x83a598, CODE: 0x8ec07c, CODEBG: 0x3c3836,
42
+ OK: 0xb8bb26, WARN: 0xfabd2f, ERR: 0xfb4934,
43
+ PURPLE: 0xd3869b, RED: 0xfb4934, GREEN: 0xb8bb26, PINK: 0xd3869b, GREENG: 0xb8bb26,
44
+ KEYWORD: 0xd3869b, STRING: 0xb8bb26, NUMBER: 0xd65d0e,
45
+ TABLEHEAD: 0xfbf1c7, TABLESEP: 0x665c54, QUOTE: 0xa89984, QUOTEFG: 0xebdbb2,
46
+ SELBG: 0x504945, SELFG: 0xfbf1c7, CURSORBG: 0x665c54, CURSORFG: 0xfbf1c7,
47
+ MENUBG: 0x32302f, MENUSEL: 0x504945, SCROLLTHUMB: 0x83a598, SCROLLTRACK: 0x504945,
48
+ },
49
+ };
50
+
51
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
52
+ import { dirname, join } from "node:path";
53
+
54
+ function themeFile() {
55
+ const base = process.env.DSH_HOME ?? (process.env.XDG_CONFIG_HOME ?? join(process.env.HOME ?? ".", ".config"));
56
+ return join(base, "tui-theme.txt");
57
+ }
58
+
59
+ let current = "dark";
60
+ const ORDER = ["dark", "light", "gruvbox"];
61
+
62
+ try {
63
+ const saved = readFileSync(themeFile(), "utf8").trim();
64
+ if (THEMES[saved]) current = saved;
65
+ } catch { /* first run */ }
66
+
67
+ /** Live theme accessor: T.ACCENT etc. reads the active palette. */
68
+ export const T = new Proxy({}, {
69
+ get(_t, key) { return THEMES[current][key]; },
70
+ });
71
+
72
+ function persist() {
73
+ try {
74
+ mkdirSync(dirname(themeFile()), { recursive: true });
75
+ writeFileSync(themeFile(), current + "\n");
76
+ } catch {}
77
+ }
78
+
79
+ export function setTheme(name) {
80
+ if (THEMES[name]) { current = name; persist(); return true; }
81
+ return false;
82
+ }
83
+
84
+ export function cycleTheme() {
85
+ current = ORDER[(ORDER.indexOf(current) + 1) % ORDER.length];
86
+ persist();
87
+ return current;
88
+ }
89
+
90
+ export function themeName() { return current; }