@lardexx/arenacode 0.0.0-stage → 2.5.2
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/LICENSE +21 -0
- package/README.md +288 -2
- package/bin/arenacode.js +19 -0
- package/docs/ANALYSIS.md +201 -0
- package/docs/PUBLISHING.md +65 -0
- package/docs/TUI.md +114 -0
- package/docs/tui-approve.html +28 -0
- package/docs/tui-approve.png +0 -0
- package/docs/tui-home.html +28 -0
- package/docs/tui-home.png +0 -0
- package/docs/tui-palette.html +28 -0
- package/docs/tui-palette.png +0 -0
- package/docs/tui-plan.html +28 -0
- package/docs/tui-plan.png +0 -0
- package/docs/tui-preview.html +28 -0
- package/docs/tui-session.png +0 -0
- package/docs/tui-slash.html +28 -0
- package/package.json +32 -4
- package/src/bridge.js +135 -0
- package/src/cli.js +720 -0
- package/src/config.js +141 -0
- package/src/local.js +507 -0
- package/src/meta.js +143 -0
- package/src/prompts/agent-prompt.md +237 -0
- package/src/tui/app.js +1368 -0
- package/src/tui/dialogs.js +136 -0
- package/src/tui/keys.js +134 -0
- package/src/tui/logo.js +54 -0
- package/src/tui/render.js +256 -0
- package/src/tui/term.js +93 -0
- package/src/tui/theme.js +246 -0
- package/src/tui/widgets.js +374 -0
- package/src/turn.js +213 -0
- package/src/ui.js +130 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Dialog look copied from opencode's `ui/dialog.tsx` + `ui/dialog-select.tsx`:
|
|
5
|
+
* a panel-background rectangle, centered horizontally, starting a quarter of the way
|
|
6
|
+
* down, title in bold on the left, `esc` on the right, a filter line, then the list
|
|
7
|
+
* with right-aligned shortcuts and the selected row painted in the primary colour.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const R = require('./render');
|
|
11
|
+
const { bgSeq, fgSeq, RESET } = require('./theme');
|
|
12
|
+
const { bgWrap, spaces } = require('./widgets');
|
|
13
|
+
|
|
14
|
+
const SIZES = { medium: 60, large: 88, xlarge: 116 };
|
|
15
|
+
|
|
16
|
+
function frameWidth(size, W) {
|
|
17
|
+
return Math.max(24, Math.min(SIZES[size || 'medium'] || 60, W - 8));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** one dialog row: full width, panel background, optional painters */
|
|
21
|
+
function row(t, width, left, right = '') {
|
|
22
|
+
const bg = bgSeq(t.c.backgroundPanel);
|
|
23
|
+
const content = spaces(4) + left + spaces(Math.max(1, width - 8 - R.vlen(left) - R.vlen(right))) + right + spaces(4);
|
|
24
|
+
return bgWrap(bg, content, width);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function blankRow(t, width) {
|
|
28
|
+
return bgWrap(bgSeq(t.c.backgroundPanel), '', width);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Generic list dialog.
|
|
33
|
+
* items: [{ label, hint, group, active }] — `group` starts a new section header
|
|
34
|
+
*/
|
|
35
|
+
function listDialog(t, opts) {
|
|
36
|
+
const { W, size = 'medium', title, filter, items, index = 0, empty = 'No results found', footer } = opts;
|
|
37
|
+
const width = frameWidth(size, W);
|
|
38
|
+
const lines = [];
|
|
39
|
+
const bg = bgSeq(t.c.backgroundPanel);
|
|
40
|
+
|
|
41
|
+
lines.push(row(t, width, `${fgSeq(t.c.text)}\x1b[1m${title}${RESET}`, `${fgSeq(t.c.textMuted)}esc${RESET}`));
|
|
42
|
+
lines.push(blankRow(t, width));
|
|
43
|
+
lines.push(row(t, width, filter ? `${fgSeq(t.c.text)}${filter}${RESET}` : `${fgSeq(t.c.textMuted)}Search${RESET}`));
|
|
44
|
+
lines.push(blankRow(t, width));
|
|
45
|
+
|
|
46
|
+
if (!items.length) {
|
|
47
|
+
lines.push(row(t, width, `${fgSeq(t.c.textMuted)}${empty}${RESET}`));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let index2 = 0;
|
|
51
|
+
for (const item of items) {
|
|
52
|
+
if (item.group) {
|
|
53
|
+
lines.push(row(t, width, `${fgSeq(t.c.textMuted)}\x1b[1m${item.group}${RESET}`));
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const active = index2 === index;
|
|
57
|
+
const pad = active ? spaces(1) : spaces(3);
|
|
58
|
+
const hint = item.hint ? `${fgSeq(active ? t.c.background : t.c.textMuted)}${item.hint}${RESET}` : '';
|
|
59
|
+
const left = item.grouped ? pad : spaces(3);
|
|
60
|
+
const label = `${fgSeq(active ? t.c.background : item.dim ? t.c.textMuted : t.c.text)}${item.label}${RESET}`;
|
|
61
|
+
const content = left + label;
|
|
62
|
+
const full = spaces(4) + content + spaces(Math.max(1, width - 8 - R.vlen(content) - R.vlen(hint))) + hint + spaces(4);
|
|
63
|
+
if (active) {
|
|
64
|
+
lines.push(`${bgSeq(t.c.primary)}${full.replace(/\x1b\[0m/g, '\x1b[0m' + bgSeq(t.c.primary))}${RESET}`);
|
|
65
|
+
} else {
|
|
66
|
+
lines.push(bgWrap(bg, full, width));
|
|
67
|
+
}
|
|
68
|
+
index2++;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
lines.push(blankRow(t, width));
|
|
72
|
+
const hints = footer || [];
|
|
73
|
+
const hintText = hints.map(([k, label]) => `${fgSeq(t.c.text)}${k}${RESET} ${fgSeq(t.c.textMuted)}${label}${RESET}`).join(' ');
|
|
74
|
+
lines.push(row(t, width, hintText));
|
|
75
|
+
lines.push(blankRow(t, width));
|
|
76
|
+
return { lines, width, x: Math.max(0, Math.floor((W - width) / 2)) };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** the confirm card used for terminal / file / plan requests */
|
|
80
|
+
function confirmDialog(t, opts) {
|
|
81
|
+
const { W, title, subject, note = '', options = [], index = 0, footer = [] } = opts;
|
|
82
|
+
const width = frameWidth('medium', W);
|
|
83
|
+
const lines = [];
|
|
84
|
+
const bg = bgSeq(t.c.backgroundPanel);
|
|
85
|
+
|
|
86
|
+
lines.push(row(t, width, `${fgSeq(t.c.text)}\x1b[1m${title}${RESET}`, `${fgSeq(t.c.textMuted)}esc${RESET}`));
|
|
87
|
+
lines.push(blankRow(t, width));
|
|
88
|
+
if (subject) {
|
|
89
|
+
const subjectLines = String(subject).split('\n').flatMap((line) => R.wrap(line, width - 10));
|
|
90
|
+
for (const line of subjectLines.slice(0, 12)) {
|
|
91
|
+
lines.push(row(t, width, `${fgSeq(t.c.text)}${line}${RESET}`));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (note) {
|
|
95
|
+
for (const line of R.wrap(note, width - 10)) lines.push(row(t, width, `${fgSeq(t.c.textMuted)}${line}${RESET}`));
|
|
96
|
+
}
|
|
97
|
+
lines.push(blankRow(t, width));
|
|
98
|
+
options.forEach((opt, i) => {
|
|
99
|
+
const active = i === index;
|
|
100
|
+
const hint = opt.hint ? `${fgSeq(active ? t.c.background : t.c.textMuted)}${opt.hint}${RESET}` : '';
|
|
101
|
+
const content = (active ? spaces(1) : spaces(3)) + `${fgSeq(active ? t.c.background : t.c.text)}${opt.label}${RESET}`;
|
|
102
|
+
const full = spaces(4) + content + spaces(Math.max(1, width - 8 - R.vlen(content) - R.vlen(hint))) + hint + spaces(4);
|
|
103
|
+
lines.push(
|
|
104
|
+
active
|
|
105
|
+
? `${bgSeq(t.c.primary)}${full.replace(/\x1b\[0m/g, '\x1b[0m' + bgSeq(t.c.primary))}${RESET}`
|
|
106
|
+
: bgWrap(bg, full, width),
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
lines.push(blankRow(t, width));
|
|
110
|
+
const hintText = footer.map(([k, label]) => `${fgSeq(t.c.text)}${k}${RESET} ${fgSeq(t.c.textMuted)}${label}${RESET}`).join(' ');
|
|
111
|
+
lines.push(row(t, width, hintText));
|
|
112
|
+
lines.push(blankRow(t, width));
|
|
113
|
+
return { lines, width, x: Math.max(0, Math.floor((W - width) / 2)) };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** plain informational dialog (toasts, errors) */
|
|
117
|
+
function alertDialog(t, opts) {
|
|
118
|
+
const { W, title, body = [], footer = [] } = opts;
|
|
119
|
+
const width = frameWidth('medium', W);
|
|
120
|
+
const lines = [];
|
|
121
|
+
lines.push(row(t, width, `${fgSeq(t.c.text)}\x1b[1m${title}${RESET}`, `${fgSeq(t.c.textMuted)}esc${RESET}`));
|
|
122
|
+
lines.push(blankRow(t, width));
|
|
123
|
+
for (const line of body) lines.push(row(t, width, `${fgSeq(t.c.textMuted)}${R.vclip(line, width - 10)}${RESET}`));
|
|
124
|
+
lines.push(blankRow(t, width));
|
|
125
|
+
const hintText = footer.map(([k, label]) => `${fgSeq(t.c.text)}${k}${RESET} ${fgSeq(t.c.textMuted)}${label}${RESET}`).join(' ');
|
|
126
|
+
if (hintText) lines.push(row(t, width, hintText));
|
|
127
|
+
lines.push(blankRow(t, width));
|
|
128
|
+
return { lines, width, x: Math.max(0, Math.floor((W - width) / 2)) };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function dialogY(H, height) {
|
|
132
|
+
const quarter = Math.floor(H / 4);
|
|
133
|
+
return Math.max(0, Math.min(quarter, H - height - 1));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = { listDialog, confirmDialog, alertDialog, frameWidth, dialogY, SIZES };
|
package/src/tui/keys.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Key parser: raw stdin chunks -> key events.
|
|
5
|
+
*
|
|
6
|
+
* { name: 'a', ctrl: false, alt: false, shift: false, seq: 'a' }
|
|
7
|
+
* { name: 'enter' | 'tab' | 'backspace' | 'escape' | 'up' | 'down' | ... }
|
|
8
|
+
*
|
|
9
|
+
* A lone ESC is only reported after a short timeout, so escape sequences
|
|
10
|
+
* (arrows, function keys) are not mistaken for the Esc key.
|
|
11
|
+
*/
|
|
12
|
+
const CSI_KEYS = {
|
|
13
|
+
A: 'up',
|
|
14
|
+
B: 'down',
|
|
15
|
+
C: 'right',
|
|
16
|
+
D: 'left',
|
|
17
|
+
H: 'home',
|
|
18
|
+
F: 'end',
|
|
19
|
+
Z: 'shift+tab',
|
|
20
|
+
'1~': 'home',
|
|
21
|
+
'2~': 'insert',
|
|
22
|
+
'3~': 'delete',
|
|
23
|
+
'4~': 'end',
|
|
24
|
+
'5~': 'pageup',
|
|
25
|
+
'6~': 'pagedown',
|
|
26
|
+
'7~': 'home',
|
|
27
|
+
'8~': 'end',
|
|
28
|
+
'11~': 'f1',
|
|
29
|
+
'12~': 'f2',
|
|
30
|
+
'13~': 'f3',
|
|
31
|
+
'14~': 'f4',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
class KeyParser {
|
|
35
|
+
constructor(onKey) {
|
|
36
|
+
this.onKey = onKey;
|
|
37
|
+
this.buffer = '';
|
|
38
|
+
this.escTimer = null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
feed(chunk) {
|
|
42
|
+
this.buffer += chunk;
|
|
43
|
+
this._drain();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
_emit(key) {
|
|
47
|
+
if (this.escTimer) {
|
|
48
|
+
clearTimeout(this.escTimer);
|
|
49
|
+
this.escTimer = null;
|
|
50
|
+
}
|
|
51
|
+
this.onKey(key);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
_drain() {
|
|
55
|
+
while (this.buffer.length) {
|
|
56
|
+
const seq = this._next();
|
|
57
|
+
if (seq === null || seq === '') {
|
|
58
|
+
// incomplete escape sequence: wait for more bytes
|
|
59
|
+
if (this.escTimer) clearTimeout(this.escTimer);
|
|
60
|
+
this.escTimer = setTimeout(() => {
|
|
61
|
+
if (this.buffer === '\x1b') {
|
|
62
|
+
this.buffer = '';
|
|
63
|
+
this._emit({ name: 'escape', ctrl: false, alt: false, shift: false, seq: '\x1b' });
|
|
64
|
+
}
|
|
65
|
+
}, 40);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
this.buffer = this.buffer.slice(seq.length);
|
|
69
|
+
const key = this._decode(seq);
|
|
70
|
+
if (key) this._emit(key);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
_next() {
|
|
75
|
+
const b = this.buffer;
|
|
76
|
+
if (b[0] !== '\x1b') return b[0];
|
|
77
|
+
if (b.length === 1) return null;
|
|
78
|
+
if (b[1] === '[') {
|
|
79
|
+
// CSI: ESC [ params final
|
|
80
|
+
let i = 2;
|
|
81
|
+
while (i < b.length && !/[A-Za-z~]/.test(b[i])) i += 1;
|
|
82
|
+
if (i >= b.length) return null;
|
|
83
|
+
return b.slice(0, i + 1);
|
|
84
|
+
}
|
|
85
|
+
if (b[1] === 'O') {
|
|
86
|
+
if (b.length < 3) return null;
|
|
87
|
+
return b.slice(0, 3);
|
|
88
|
+
}
|
|
89
|
+
// Alt + char
|
|
90
|
+
return b.slice(0, 2);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
_decode(seq) {
|
|
94
|
+
const mk = (name, extra = {}) => ({ name, ctrl: false, alt: false, shift: false, seq, ...extra });
|
|
95
|
+
|
|
96
|
+
if (seq.length === 1) {
|
|
97
|
+
const code = seq.charCodeAt(0);
|
|
98
|
+
if (code === 13) return mk('enter');
|
|
99
|
+
if (code === 10) return mk('enter');
|
|
100
|
+
if (code === 9) return mk('tab');
|
|
101
|
+
if (code === 127) return mk('backspace');
|
|
102
|
+
if (code === 27) return mk('escape');
|
|
103
|
+
if (code === 3) return mk('c', { ctrl: true });
|
|
104
|
+
if (code === 4) return mk('d', { ctrl: true });
|
|
105
|
+
if (code < 27) return mk(String.fromCharCode(code + 96), { ctrl: true });
|
|
106
|
+
return mk(seq);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (seq[0] === '\x1b' && seq[1] === '[') {
|
|
110
|
+
const body = seq.slice(2, -1);
|
|
111
|
+
const final = seq[seq.length - 1];
|
|
112
|
+
if (final === '~') {
|
|
113
|
+
const name = CSI_KEYS[`${body}~`];
|
|
114
|
+
return name ? mk(name) : null;
|
|
115
|
+
}
|
|
116
|
+
if (final === 'Z') return mk('tab', { shift: true });
|
|
117
|
+
const name = CSI_KEYS[final];
|
|
118
|
+
if (name) return mk(name, { shift: /;2$/.test(body) });
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (seq[0] === '\x1b' && seq[1] === 'O') {
|
|
123
|
+
const name = CSI_KEYS[seq[2]];
|
|
124
|
+
return name ? mk(name) : null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ESC + char = alt+char
|
|
128
|
+
const ch = seq.slice(1);
|
|
129
|
+
if (ch === '\r') return mk('enter', { alt: true });
|
|
130
|
+
return mk(ch, { alt: true });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = { KeyParser };
|
package/src/tui/logo.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* arenacode wordmark, drawn the way opencode draws its own (packages/tui/src/logo.tsx):
|
|
5
|
+
* two-tone block letters where `_` is a shadow-filled cell, `^` a half block on the
|
|
6
|
+
* shadow background, `~`/`,` half blocks drawn in the shadow colour.
|
|
7
|
+
*
|
|
8
|
+
* Left half is muted and regular, right half bold — the same trick opencode uses for
|
|
9
|
+
* "open" + "code".
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const { fgSeq, bgSeq, RESET, tint } = require('./theme');
|
|
13
|
+
|
|
14
|
+
const LOGO = {
|
|
15
|
+
left: [' ', '█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀█', '█▀▀█ █▀▀▄ █^^^ █__█ █▀▀█', '▀▀▀▀ ▀~~▀ ▀▀▀▀ ▀~~▀ ▀▀▀▀'],
|
|
16
|
+
right: [' ▄ ', '█▀▀▀ █▀▀█ █▀▀█ █▀▀█', '█___ █__█ █__█ █^^^', '▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀'],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const LOGO_WIDTH = LOGO.left[1].length + 1 + LOGO.right[1].length;
|
|
20
|
+
|
|
21
|
+
function fgPaint(color, text, bold) {
|
|
22
|
+
return `${fgSeq(color)}${bold ? '\x1b[1m' : ''}${text}${RESET}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function bgFill(bgColor, text, fgColor, bold) {
|
|
26
|
+
return `${fgSeq(fgColor)}${bgSeq(bgColor)}${bold ? '\x1b[1m' : ''}${text}${RESET}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function paintLine(line, color, shadow, bold) {
|
|
30
|
+
let out = '';
|
|
31
|
+
for (const ch of line) {
|
|
32
|
+
if (ch === '_') out += bgFill(shadow, ' ', color, bold);
|
|
33
|
+
else if (ch === '^') out += bgFill(shadow, '▀', color, bold);
|
|
34
|
+
else if (ch === '~') out += fgPaint(shadow, '▀', bold);
|
|
35
|
+
else if (ch === ',') out += fgPaint(shadow, '▄', bold);
|
|
36
|
+
else out += fgPaint(color, ch, bold);
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Four styled lines, in opencode's two-tone style. */
|
|
42
|
+
function logoLines(t) {
|
|
43
|
+
const shadowLeft = tint(t.c.background, t.c.textMuted, 0.22);
|
|
44
|
+
const shadowRight = tint(t.c.background, t.c.text, 0.22);
|
|
45
|
+
const lines = [];
|
|
46
|
+
for (let i = 0; i < LOGO.left.length; i++) {
|
|
47
|
+
// opencode keeps the left half muted; at this size a slightly brighter grey reads better
|
|
48
|
+
const leftColor = tint(t.c.background, t.c.textMuted, 1.6);
|
|
49
|
+
lines.push(paintLine(LOGO.left[i], leftColor, shadowLeft, true) + ' ' + paintLine(LOGO.right[i], t.c.text, shadowRight, true));
|
|
50
|
+
}
|
|
51
|
+
return lines;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { LOGO, LOGO_WIDTH, logoLines };
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/** ANSI-aware text helpers: everything measures and clips by *visible* width. */
|
|
4
|
+
|
|
5
|
+
const ANSI_RE = /\x1b\[[0-9;?]*[A-Za-z]/g;
|
|
6
|
+
|
|
7
|
+
function strip(s) {
|
|
8
|
+
return String(s).replace(ANSI_RE, '');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function charWidth(ch) {
|
|
12
|
+
const cp = ch.codePointAt(0);
|
|
13
|
+
if (cp === 0x200d) return 0;
|
|
14
|
+
if (cp >= 0x0300 && cp <= 0x036f) return 0;
|
|
15
|
+
if (
|
|
16
|
+
cp > 0x1100 &&
|
|
17
|
+
(cp <= 0x115f ||
|
|
18
|
+
(cp >= 0x2e80 && cp <= 0xa4cf) ||
|
|
19
|
+
(cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
20
|
+
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
21
|
+
(cp >= 0xfe30 && cp <= 0xfe6f) ||
|
|
22
|
+
(cp >= 0xff00 && cp <= 0xff60) ||
|
|
23
|
+
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
24
|
+
(cp >= 0x1f300 && cp <= 0x1faff))
|
|
25
|
+
) {
|
|
26
|
+
return 2;
|
|
27
|
+
}
|
|
28
|
+
return 1;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function vlen(s) {
|
|
32
|
+
let w = 0;
|
|
33
|
+
for (const ch of strip(s)) w += charWidth(ch);
|
|
34
|
+
return w;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Clips to `max` visible cells, keeping escape sequences and closing styles. */
|
|
38
|
+
function vclip(s, max) {
|
|
39
|
+
const text = String(s);
|
|
40
|
+
if (vlen(text) <= max) return text;
|
|
41
|
+
let out = '';
|
|
42
|
+
let w = 0;
|
|
43
|
+
let i = 0;
|
|
44
|
+
while (i < text.length) {
|
|
45
|
+
if (text[i] === '\x1b') {
|
|
46
|
+
const match = /^\x1b\[[0-9;?]*[A-Za-z]/.exec(text.slice(i));
|
|
47
|
+
if (match) {
|
|
48
|
+
out += match[0];
|
|
49
|
+
i += match[0].length;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const ch = String.fromCodePoint(text.codePointAt(i));
|
|
54
|
+
const cw = charWidth(ch);
|
|
55
|
+
if (w + cw > max - 1) {
|
|
56
|
+
out += '…';
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
out += ch;
|
|
60
|
+
w += cw;
|
|
61
|
+
i += ch.length;
|
|
62
|
+
}
|
|
63
|
+
return out + '\x1b[0m';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function vpad(s, width) {
|
|
67
|
+
const diff = width - vlen(s);
|
|
68
|
+
return diff > 0 ? String(s) + ' '.repeat(diff) : String(s);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Word wrap without breaking ANSI codes (styles are expected per line). */
|
|
72
|
+
function wrap(text, width) {
|
|
73
|
+
const out = [];
|
|
74
|
+
if (width <= 0) return [''];
|
|
75
|
+
for (const rawLine of String(text).split('\n')) {
|
|
76
|
+
if (rawLine === '') {
|
|
77
|
+
out.push('');
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const words = rawLine.split(/(\s+)/);
|
|
81
|
+
let line = '';
|
|
82
|
+
for (const word of words) {
|
|
83
|
+
if (vlen(line) + vlen(word) <= width) {
|
|
84
|
+
line += word;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (vlen(word) > width) {
|
|
88
|
+
// long token: hard break
|
|
89
|
+
let rest = word;
|
|
90
|
+
while (vlen(rest) > width) {
|
|
91
|
+
out.push(vclip(line + rest, width));
|
|
92
|
+
const consumed = strip(vclip(rest, width)).length;
|
|
93
|
+
rest = rest.slice(consumed);
|
|
94
|
+
line = '';
|
|
95
|
+
}
|
|
96
|
+
line += rest;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (line.trim() !== '') out.push(line.replace(/\s+$/, ''));
|
|
100
|
+
line = word.replace(/^\s+/, '');
|
|
101
|
+
}
|
|
102
|
+
out.push(line.replace(/\s+$/, ''));
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const BOX = {
|
|
108
|
+
tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│',
|
|
109
|
+
ltee: '├', rtee: '┤', cross: '┼',
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A bordered panel. `lines` are already coloured; they are clipped and padded to
|
|
114
|
+
* the inner width. Returns exactly `height` lines, each `width` cells wide.
|
|
115
|
+
*/
|
|
116
|
+
function box({ width, height, title = '', lines = [], theme, active = false, footer = '' }) {
|
|
117
|
+
if (width < 4 || height < 3) return [];
|
|
118
|
+
const innerW = width - 2;
|
|
119
|
+
const borderColor = active ? theme.borderActive : theme.border;
|
|
120
|
+
const b = (s) => borderColor(s);
|
|
121
|
+
const out = [];
|
|
122
|
+
|
|
123
|
+
const head = title ? ` ${title} ` : '';
|
|
124
|
+
const headW = vlen(head);
|
|
125
|
+
const fill = Math.max(0, innerW - headW - 1);
|
|
126
|
+
out.push(b(BOX.tl) + (title ? b(BOX.h) + theme.title(head) + b(BOX.h.repeat(fill)) : b(BOX.h.repeat(innerW))) + b(BOX.tr));
|
|
127
|
+
|
|
128
|
+
const bodyRows = height - 2;
|
|
129
|
+
for (let i = 0; i < bodyRows; i += 1) {
|
|
130
|
+
let content = lines[i] === undefined ? '' : lines[i];
|
|
131
|
+
let suffix = '';
|
|
132
|
+
if (i === bodyRows - 1 && footer) {
|
|
133
|
+
content = footer;
|
|
134
|
+
suffix = '';
|
|
135
|
+
}
|
|
136
|
+
out.push(b(BOX.v) + vpad(vclip(content, innerW), innerW) + b(BOX.v) + suffix);
|
|
137
|
+
}
|
|
138
|
+
out.push(b(BOX.bl) + b(BOX.h.repeat(innerW)) + b(BOX.br));
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Side-by-side composition: every column is a list of equal-height lines. */
|
|
143
|
+
function columns(parts, gap = 1, theme = null) {
|
|
144
|
+
const height = Math.max(...parts.map((p) => p.lines.length), 0);
|
|
145
|
+
const out = [];
|
|
146
|
+
const sep = theme ? theme.border(BOX.v) : '│';
|
|
147
|
+
for (let row = 0; row < height; row += 1) {
|
|
148
|
+
out.push(
|
|
149
|
+
parts
|
|
150
|
+
.map((part) => {
|
|
151
|
+
const line = part.lines[row] === undefined ? ' '.repeat(part.width) : part.lines[row];
|
|
152
|
+
return vpad(vclip(line, part.width), part.width);
|
|
153
|
+
})
|
|
154
|
+
.join(' '.repeat(gap) + sep + ' '.repeat(gap)),
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function hline(width, theme, label = '') {
|
|
161
|
+
const color = theme ? theme.border : (s) => s;
|
|
162
|
+
if (!label) return color(BOX.h.repeat(Math.max(0, width)));
|
|
163
|
+
const head = ` ${label} `;
|
|
164
|
+
const rest = Math.max(0, width - vlen(head) - 2);
|
|
165
|
+
return color(BOX.h.repeat(2)) + (theme ? theme.dim(head) : head) + color(BOX.h.repeat(rest));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
/** Visible-cell slice [start, end): keeps the escape sequences that fall inside. */
|
|
170
|
+
function vslice(s, start, end) {
|
|
171
|
+
const text = String(s);
|
|
172
|
+
let out = '';
|
|
173
|
+
let cell = 0;
|
|
174
|
+
let i = 0;
|
|
175
|
+
while (i < text.length) {
|
|
176
|
+
if (text[i] === '\x1b') {
|
|
177
|
+
const match = /^\x1b\[[0-9;?]*[A-Za-z]/.exec(text.slice(i));
|
|
178
|
+
if (match) {
|
|
179
|
+
if (cell >= start && cell < end) out += match[0];
|
|
180
|
+
i += match[0].length;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const ch = String.fromCodePoint(text.codePointAt(i));
|
|
185
|
+
const cw = charWidth(ch);
|
|
186
|
+
if (cell >= start && cell < end) out += ch;
|
|
187
|
+
cell += cw;
|
|
188
|
+
i += ch.length;
|
|
189
|
+
if (cell >= end) break;
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Draws overlay lines onto a base frame at (x, y). */
|
|
195
|
+
function overlay(base, boxLines, x, y) {
|
|
196
|
+
const out = base.slice();
|
|
197
|
+
for (let i = 0; i < boxLines.length; i += 1) {
|
|
198
|
+
const row = y + i;
|
|
199
|
+
if (row < 0 || row >= out.length) continue;
|
|
200
|
+
const line = out[row] || '';
|
|
201
|
+
const w = boxLines[i].length ? vlen(boxLines[i]) : 0;
|
|
202
|
+
const left = vpad(vslice(line, 0, x), x);
|
|
203
|
+
const right = vpad(vslice(line, x + w, vlen(line)), vlen(line) - x - w);
|
|
204
|
+
out[row] = left + boxLines[i] + right;
|
|
205
|
+
}
|
|
206
|
+
return out;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* opencode puts rgba(0,0,0,150) over everything behind a dialog. A terminal has no alpha,
|
|
211
|
+
* so every colour inside the string is scaled towards black instead — the same 40% look,
|
|
212
|
+
* and the structure of what is behind the dialog stays readable.
|
|
213
|
+
*/
|
|
214
|
+
function dimAnsi(s, factor = 0.4) {
|
|
215
|
+
const text = String(s);
|
|
216
|
+
return text.replace(/\x1b\[([0-9;]*)m/g, (whole, codes) => {
|
|
217
|
+
const parts = codes.split(';').map(Number);
|
|
218
|
+
const out = [];
|
|
219
|
+
for (let i = 0; i < parts.length; i += 1) {
|
|
220
|
+
const c = parts[i];
|
|
221
|
+
if (c === 1 || c === 2 || c === 4 || c === 7) {
|
|
222
|
+
out.push(0);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if ((c === 38 || c === 48) && parts[i + 1] === 2) {
|
|
226
|
+
const [r, g, b] = [parts[i + 2], parts[i + 3], parts[i + 4]].map((v) => Math.round(v * factor));
|
|
227
|
+
out.push(c, 2, r, g, b);
|
|
228
|
+
i += 4;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
out.push(c);
|
|
232
|
+
}
|
|
233
|
+
return `\x1b[${out.join(';')}m`;
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** overlay + dimmed backdrop */
|
|
238
|
+
function overlayDim(base, boxLines, x, y, paint) {
|
|
239
|
+
const out = base.slice();
|
|
240
|
+
for (let i = 0; i < boxLines.length; i += 1) {
|
|
241
|
+
const row = y + i;
|
|
242
|
+
if (row < 0 || row >= out.length) continue;
|
|
243
|
+
const line = out[row] || '';
|
|
244
|
+
const w = vlen(boxLines[i]);
|
|
245
|
+
const left = vpad(vslice(line, 0, x), x);
|
|
246
|
+
const right = vpad(vslice(line, x + w, vlen(line)), vlen(line) - x - w);
|
|
247
|
+
out[row] = (paint ? paint(left) : dimAnsi(left)) + boxLines[i] + (paint ? paint(right) : dimAnsi(right));
|
|
248
|
+
}
|
|
249
|
+
return out;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function spaces(n) {
|
|
253
|
+
return ' '.repeat(Math.max(0, n));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
module.exports = { strip, vlen, vclip, vpad, wrap, box, columns, hline, vslice, overlay, overlayDim, dimAnsi, BOX, charWidth, spaces };
|
package/src/tui/term.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { stdin, stdout } = require('node:process');
|
|
4
|
+
|
|
5
|
+
const ENTER_ALT = '\x1b[?1049h';
|
|
6
|
+
const EXIT_ALT = '\x1b[?1049l';
|
|
7
|
+
const HIDE_CURSOR = '\x1b[?25l';
|
|
8
|
+
const SHOW_CURSOR = '\x1b[?25h';
|
|
9
|
+
const SYNC_ON = '\x1b[?2026h';
|
|
10
|
+
const SYNC_OFF = '\x1b[?2026l';
|
|
11
|
+
const CLEAR = '\x1b[2J\x1b[H';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Thin wrapper over the tty: alternate screen, raw mode, resize events and
|
|
15
|
+
* flicker-free frame writes (synchronised output where the terminal supports it).
|
|
16
|
+
*/
|
|
17
|
+
class Term {
|
|
18
|
+
constructor({ altScreen = true } = {}) {
|
|
19
|
+
this.altScreen = altScreen;
|
|
20
|
+
this.entered = false;
|
|
21
|
+
this.listeners = { resize: [], key: [], paste: [] };
|
|
22
|
+
this._onData = this._onData.bind(this);
|
|
23
|
+
this._onResize = this._onResize.bind(this);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
get width() {
|
|
27
|
+
return stdout.columns || 80;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
get height() {
|
|
31
|
+
return stdout.rows || 24;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
enter() {
|
|
35
|
+
if (this.entered) return;
|
|
36
|
+
this.entered = true;
|
|
37
|
+
if (this.altScreen) stdout.write(ENTER_ALT);
|
|
38
|
+
stdout.write(CLEAR + HIDE_CURSOR);
|
|
39
|
+
if (stdin.isTTY) stdin.setRawMode(true);
|
|
40
|
+
stdin.resume();
|
|
41
|
+
stdin.setEncoding('utf8');
|
|
42
|
+
stdin.on('data', this._onData);
|
|
43
|
+
stdout.on('resize', this._onResize);
|
|
44
|
+
process.on('exit', () => this.exit());
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
exit() {
|
|
48
|
+
if (!this.entered) return;
|
|
49
|
+
this.entered = false;
|
|
50
|
+
stdin.removeListener('data', this._onData);
|
|
51
|
+
stdout.removeListener('resize', this._onResize);
|
|
52
|
+
if (stdin.isTTY) stdin.setRawMode(false);
|
|
53
|
+
stdin.pause();
|
|
54
|
+
stdout.write(SHOW_CURSOR + (this.altScreen ? EXIT_ALT : '\n'));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
on(event, fn) {
|
|
58
|
+
this.listeners[event].push(fn);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
_onResize() {
|
|
62
|
+
for (const fn of this.listeners.resize) fn(this.width, this.height);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
_onData(chunk) {
|
|
66
|
+
for (const fn of this.listeners.key) fn(chunk);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Writes a full frame: home the cursor and overwrite every line. */
|
|
70
|
+
draw(lines) {
|
|
71
|
+
const height = this.height;
|
|
72
|
+
const out = [SYNC_ON];
|
|
73
|
+
out.push('\x1b[H');
|
|
74
|
+
for (let i = 0; i < height; i += 1) {
|
|
75
|
+
out.push('\x1b[0m');
|
|
76
|
+
out.push(lines[i] || '');
|
|
77
|
+
out.push('\x1b[0m');
|
|
78
|
+
if (i < height - 1) out.push('\r\n');
|
|
79
|
+
}
|
|
80
|
+
out.push(SYNC_OFF);
|
|
81
|
+
stdout.write(out.join(''));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
placeCursor(row, col) {
|
|
85
|
+
stdout.write(`\x1b[${Math.max(1, row)};${Math.max(1, col)}H${SHOW_CURSOR}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
hideCursor() {
|
|
89
|
+
stdout.write(HIDE_CURSOR);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = { Term };
|