@causyne/agent 1.10.18 → 1.10.19
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/package.json +1 -1
- package/src/tui/banner.js +389 -190
- package/src/tui/banner.ts +407 -0
- package/src/tui/chatRenderer.js +1 -1
package/package.json
CHANGED
package/src/tui/banner.js
CHANGED
|
@@ -1,203 +1,402 @@
|
|
|
1
|
-
// The banner: the wordmark, and the first thing a new user is invited to ask.
|
|
2
|
-
//
|
|
3
|
-
// This lives in its own module because it is written ONCE, straight into the
|
|
4
|
-
// user's real terminal history, before anything else runs — the same shape
|
|
5
|
-
// openclaw uses (src/cli/banner.ts emitCliBanner: art first, metadata line
|
|
6
|
-
// underneath) and opencode's entry splash, whose own comment calls these
|
|
7
|
-
// "scrollback snapshots ... immutable terminal history once committed".
|
|
8
|
-
//
|
|
9
|
-
// Nothing here repaints. Nothing here clears. See UX-01 in .agents/rules.json.
|
|
10
|
-
//
|
|
11
|
-
// Pure Node over theme.js and text.js. No dependency, no shell.
|
|
12
|
-
|
|
13
|
-
import { paint, bold, dim, GLYPHS, HAS_UNICODE } from './theme.js';
|
|
14
|
-
import { visibleWidth, truncate, padEnd, shortenHome } from './text.js';
|
|
15
|
-
|
|
16
|
-
// Painted through the theme rather than with a hardcoded \x1b[1m\x1b[36m, so it
|
|
17
|
-
// still obeys NO_COLOR and still degrades on a terminal without box-drawing.
|
|
18
|
-
const WORDMARK = [
|
|
19
|
-
' ████ ███ █ █ ████ █ █ █ █ █████',
|
|
20
|
-
'█ █ █ █ █ █ █ █ ██ █ █ ',
|
|
21
|
-
'█ █████ █ █ ███ █ █ █ █ ████ ',
|
|
22
|
-
'█ █ █ █ █ █ █ █ ██ █ ',
|
|
23
|
-
' ████ █ █ ███ ████ █ █ █ █████'
|
|
24
|
-
];
|
|
25
|
-
|
|
26
|
-
// A compact terminal rendering of the six-part Causyne aperture. The shape is
|
|
27
|
-
// sampled from the canonical SVG rather than invented as a second logo. Its
|
|
28
|
-
// upper-right blade carries the signal green; the other blades use the neutral
|
|
29
|
-
// brand ink. Each row remains ordinary terminal text and degrades to #/+ ASCII.
|
|
30
|
-
const LOGO_SEGMENTS = [
|
|
31
|
-
[['ink', ' ███▓']],
|
|
32
|
-
[['ink', ' ▓███'], ['accent', '███'], ['ink', '▓']],
|
|
33
|
-
[['ink', '▓███ ▓▓'], ['accent', '██'], ['ink', '▓']],
|
|
34
|
-
[['ink', '▓██ ▓██▓']],
|
|
35
|
-
[['ink', '▓███▓ ███▓']],
|
|
36
|
-
[['ink', ' ███████▓']],
|
|
37
|
-
[['ink', ' ▓███']]
|
|
38
|
-
];
|
|
39
|
-
|
|
40
|
-
function logoRows(unicode) {
|
|
41
|
-
return LOGO_SEGMENTS.map((segments) => segments.map(([role, text]) => {
|
|
42
|
-
const rendered = unicode
|
|
43
|
-
? text
|
|
44
|
-
: text.replace(/[█▓]/g, role === 'accent' ? '+' : '#');
|
|
45
|
-
return paint(role, rendered);
|
|
46
|
-
}).join(''));
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function brandArt(width, unicode) {
|
|
50
|
-
if (!unicode || width < 64) {
|
|
51
|
-
const logo = logoRows(false);
|
|
52
|
-
logo[2] = `${padEnd(logo[2], 12)}${bold(paint('ink', 'CAUSYNE AGENT'))}`;
|
|
53
|
-
return logo;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const logo = logoRows(true);
|
|
57
|
-
return logo.map((line, index) => {
|
|
58
|
-
const word = WORDMARK[index - 1];
|
|
59
|
-
return word === undefined
|
|
60
|
-
? line
|
|
61
|
-
: `${padEnd(line, 10)} ${paint('ink', word)}`;
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
|
|
65
1
|
/**
|
|
66
|
-
*
|
|
2
|
+
* Canonical Causyne Agent terminal identity: aperture, pixel wordmark, status,
|
|
3
|
+
* and adaptive fallbacks. TypeScript is the source; the repository build emits
|
|
4
|
+
* the readable ESM file imported by the CLI.
|
|
67
5
|
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
* never be exercised at all.
|
|
6
|
+
* Environment: NO_COLOR, CAUSYNE_NO_COLOR, FORCE_COLOR=1|2|3,
|
|
7
|
+
* CAUSYNE_ASCII=1 (7-bit only), and CAUSYNE_NO_ANIM=1.
|
|
71
8
|
*/
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
export const TAGLINE = 'From intention to meaningful progress.';
|
|
11
|
+
// ---- Logo -----------------------------------------------------------------------------------------------
|
|
12
|
+
// One character = one "pixel". A terminal cell holds two stacked pixels (drawn with the half blocks ▀ ▄ █).
|
|
13
|
+
// '.' is empty, 0-5 is the blade id. Blade 0 is the lime one (top-right); ids run clockwise.
|
|
14
|
+
const LOGO_L = [
|
|
15
|
+
'......................',
|
|
16
|
+
'.........5555.........',
|
|
17
|
+
'.......555555.........',
|
|
18
|
+
'.....5555555.0000.....',
|
|
19
|
+
'....5555555.0000000...',
|
|
20
|
+
'...5555555.000000000..',
|
|
21
|
+
'....55555.00000000000.',
|
|
22
|
+
'.44.5555.......000000.',
|
|
23
|
+
'.444.55...........000.',
|
|
24
|
+
'.444..5........111..0.',
|
|
25
|
+
'.4444..........11111..',
|
|
26
|
+
'.4444...........11111.',
|
|
27
|
+
'.44444...........1111.',
|
|
28
|
+
'.444444..........1111.',
|
|
29
|
+
'..44444........2..111.',
|
|
30
|
+
'......4........22.111.',
|
|
31
|
+
'.3333.........222..11.',
|
|
32
|
+
'.33333333333.22222.11.',
|
|
33
|
+
'..333333333.2222222...',
|
|
34
|
+
'....333333.2222222....',
|
|
35
|
+
'.....3333.2222222.....',
|
|
36
|
+
'.........222222.......',
|
|
37
|
+
'.........2222.........',
|
|
38
|
+
'......................',
|
|
39
|
+
];
|
|
40
|
+
const LOGO_S = [
|
|
41
|
+
'..................',
|
|
42
|
+
'.......555........',
|
|
43
|
+
'.....55555..0.....',
|
|
44
|
+
'...555555.00000...',
|
|
45
|
+
'...55555.0000000..',
|
|
46
|
+
'.4.5555..00000000.',
|
|
47
|
+
'.44.55.......0000.',
|
|
48
|
+
'.44.55......1...0.',
|
|
49
|
+
'.444........1111..',
|
|
50
|
+
'.444.........1111.',
|
|
51
|
+
'.4444.........111.',
|
|
52
|
+
'.44444........111.',
|
|
53
|
+
'...444......22.11.',
|
|
54
|
+
'.33.........22.11.',
|
|
55
|
+
'.333333333.2222.1.',
|
|
56
|
+
'..3333333.22222...',
|
|
57
|
+
'...33333.222222...',
|
|
58
|
+
'.....33.22222.....',
|
|
59
|
+
'........222.......',
|
|
60
|
+
'..................',
|
|
61
|
+
];
|
|
62
|
+
// ---- Wordmark: pixel fonts ('#' = lit pixel). CAUSYNE is 6x7, AGENT is 5x5. The A has no crossbar, like the brand wordmark.
|
|
63
|
+
const F7 = {
|
|
64
|
+
C: ['.####.', '#....#', '#.....', '#.....', '#.....', '#....#', '.####.'],
|
|
65
|
+
A: ['..##..', '.#..#.', '.#..#.', '#....#', '#....#', '#....#', '#....#'],
|
|
66
|
+
U: ['#....#', '#....#', '#....#', '#....#', '#....#', '#....#', '.####.'],
|
|
67
|
+
S: ['.#####', '#.....', '#.....', '.####.', '.....#', '.....#', '#####.'],
|
|
68
|
+
Y: ['#....#', '.#..#.', '..##..', '..##..', '..##..', '..##..', '..##..'],
|
|
69
|
+
N: ['#....#', '##...#', '#.#..#', '#..#.#', '#...##', '#....#', '#....#'],
|
|
70
|
+
E: ['######', '#.....', '#.....', '####..', '#.....', '#.....', '######'],
|
|
71
|
+
};
|
|
72
|
+
const F5 = {
|
|
73
|
+
A: ['..#..', '.#.#.', '.#.#.', '#...#', '#...#'],
|
|
74
|
+
G: ['.####', '#....', '#.###', '#...#', '.###.'],
|
|
75
|
+
E: ['#####', '#....', '###..', '#....', '#####'],
|
|
76
|
+
N: ['#...#', '##..#', '#.#.#', '#..##', '#...#'],
|
|
77
|
+
T: ['#####', '..#..', '..#..', '..#..', '..#..'],
|
|
78
|
+
};
|
|
79
|
+
// ---- Colour ---------------------------------------------------------------------------------------------
|
|
80
|
+
const ESC = '\x1b[';
|
|
81
|
+
const RESET = ESC + '0m';
|
|
82
|
+
const PAL = { lime: [182, 224, 111], gray: [95, 102, 114], white: [244, 246, 248], dim: [140, 148, 160] };
|
|
83
|
+
const C16 = { lime: 92, gray: 90, white: 97, dim: 90 };
|
|
84
|
+
const STRONG = { lime: true, white: true, gray: false, dim: false };
|
|
85
|
+
const LV = [0, 95, 135, 175, 215, 255];
|
|
86
|
+
const FORCE = { '0': 'none', false: 'none', '1': '16', true: '16', '2': '256', '3': 'truecolor' };
|
|
87
|
+
function to256(c) {
|
|
88
|
+
const [r, g, b] = c;
|
|
89
|
+
const near = (v) => LV.reduce((bi, x, i) => (Math.abs(x - v) < Math.abs((LV[bi] ?? 0) - v) ? i : bi), 0);
|
|
90
|
+
const ri = near(r), gi = near(g), bi = near(b), cube = 16 + 36 * ri + 6 * gi + bi;
|
|
91
|
+
const gl = Math.max(0, Math.min(23, Math.round(((r + g + b) / 3 - 8) / 10))), gv = 8 + 10 * gl;
|
|
92
|
+
const dc = (r - (LV[ri] ?? 0)) ** 2 + (g - (LV[gi] ?? 0)) ** 2 + (b - (LV[bi] ?? 0)) ** 2;
|
|
93
|
+
const dg = (r - gv) ** 2 + (g - gv) ** 2 + (b - gv) ** 2;
|
|
94
|
+
return dg < dc ? 232 + gl : cube;
|
|
95
|
+
}
|
|
96
|
+
function fgCode(k, d) {
|
|
97
|
+
const rgb = PAL[k];
|
|
98
|
+
return d === 'truecolor' ? `38;2;${rgb.join(';')}` : d === '256' ? `38;5;${to256(rgb)}` : String(C16[k]);
|
|
99
|
+
}
|
|
100
|
+
function bgCode(k, d) {
|
|
101
|
+
const rgb = PAL[k];
|
|
102
|
+
return d === 'truecolor' ? `48;2;${rgb.join(';')}` : d === '256' ? `48;5;${to256(rgb)}` : String(C16[k] + 10);
|
|
103
|
+
}
|
|
104
|
+
// ---- Capability detection -------------------------------------------------------------------------------
|
|
105
|
+
function pickVariant(c) {
|
|
106
|
+
if (c.width < 44 || c.height < 14)
|
|
107
|
+
return 'mini';
|
|
108
|
+
if (c.width < 80 || c.height < 24 || !c.unicode)
|
|
109
|
+
return 'compact';
|
|
110
|
+
return 'full';
|
|
111
|
+
}
|
|
112
|
+
export function detect(o = {}, stream = process.stdout) {
|
|
113
|
+
const env = process.env, tty = Boolean(stream.isTTY);
|
|
114
|
+
const width = o.width || stream.columns || 80, height = o.height || stream.rows || 24;
|
|
115
|
+
const want = o.color;
|
|
116
|
+
let color;
|
|
117
|
+
if (want === true)
|
|
118
|
+
color = 'truecolor';
|
|
119
|
+
else if (want === false)
|
|
120
|
+
color = 'none';
|
|
121
|
+
else if (want !== undefined && want !== 'auto')
|
|
122
|
+
color = want;
|
|
123
|
+
else {
|
|
124
|
+
const f = env['FORCE_COLOR'];
|
|
125
|
+
if (f !== undefined && f !== '')
|
|
126
|
+
color = FORCE[f] ?? '16';
|
|
127
|
+
else if (env['NO_COLOR'] || env['CAUSYNE_NO_COLOR'] === '1')
|
|
128
|
+
color = 'none';
|
|
129
|
+
else if (!tty || env['TERM'] === 'dumb')
|
|
130
|
+
color = 'none';
|
|
131
|
+
else {
|
|
132
|
+
const d = stream.getColorDepth?.() ?? 4;
|
|
133
|
+
color = /truecolor|24bit/i.test(env['COLORTERM'] ?? '') || d >= 24 ? 'truecolor' : d >= 8 ? '256' : d >= 4 ? '16' : 'none';
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
let unicode = o.unicode;
|
|
137
|
+
if (unicode === undefined) {
|
|
138
|
+
const loc = env['LC_ALL'] || env['LC_CTYPE'] || env['LANG'] || '', ascii = env['CAUSYNE_ASCII'];
|
|
139
|
+
unicode = !(ascii && ascii !== '0') && !(process.platform !== 'win32' && loc !== '' && !/utf-?8/i.test(loc));
|
|
140
|
+
}
|
|
141
|
+
const caps = { tty, width, height, color, unicode, variant: 'full' };
|
|
142
|
+
caps.variant = o.variant && o.variant !== 'auto' ? o.variant : pickVariant(caps);
|
|
143
|
+
return caps;
|
|
144
|
+
}
|
|
145
|
+
// ---- Pixel canvas -> terminal cells ---------------------------------------------------------------------
|
|
146
|
+
const blank = (w, h) => Array.from({ length: h }, () => Array(w).fill(null));
|
|
147
|
+
function blit(dst, src, x, y) {
|
|
148
|
+
src.forEach((row, j) => row.forEach((k, i) => { const d = dst[y + j]; if (d && k)
|
|
149
|
+
d[x + i] = k; }));
|
|
150
|
+
}
|
|
151
|
+
const pixLogo = (g, active) => g.map((r) => [...r].map((ch) => (ch === '.' ? null : Number(ch) === active ? 'lime' : 'gray')));
|
|
152
|
+
function pixText(str, font, h, gap, key) {
|
|
153
|
+
const rows = Array.from({ length: h }, () => []);
|
|
154
|
+
[...str].forEach((ch, i) => {
|
|
155
|
+
const glyph = font[ch];
|
|
156
|
+
if (!glyph)
|
|
157
|
+
return;
|
|
158
|
+
rows.forEach((row, y) => {
|
|
159
|
+
if (i)
|
|
160
|
+
for (let k = 0; k < gap; k++)
|
|
161
|
+
row.push(null);
|
|
162
|
+
for (const c of glyph[y] ?? '')
|
|
163
|
+
row.push(c === '#' ? key : null);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
return rows;
|
|
167
|
+
}
|
|
168
|
+
function cells(px, caps) {
|
|
169
|
+
const { color, unicode } = caps, lines = [];
|
|
170
|
+
for (let y = 0; y < px.length; y += 2) {
|
|
171
|
+
const top = px[y] ?? [], bot = px[y + 1] ?? [], w = Math.max(top.length, bot.length);
|
|
172
|
+
let s = '', fg = null, bg = null;
|
|
173
|
+
for (let x = 0; x < w; x++) {
|
|
174
|
+
const t = top[x] ?? null, b = bot[x] ?? null;
|
|
175
|
+
let ch = ' ', f = null, g = null;
|
|
176
|
+
if (t || b) {
|
|
177
|
+
if (unicode) {
|
|
178
|
+
if (color === 'none')
|
|
179
|
+
ch = t && b ? ((t && STRONG[t]) || (b && STRONG[b]) ? '█' : '▒') : t ? '▀' : '▄';
|
|
180
|
+
else if (t === b) {
|
|
181
|
+
ch = '█';
|
|
182
|
+
f = t;
|
|
183
|
+
}
|
|
184
|
+
else if (!b) {
|
|
185
|
+
ch = '▀';
|
|
186
|
+
f = t;
|
|
187
|
+
}
|
|
188
|
+
else if (!t) {
|
|
189
|
+
ch = '▄';
|
|
190
|
+
f = b;
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
ch = '▀';
|
|
194
|
+
f = t;
|
|
195
|
+
g = b;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
const k = (t && STRONG[t] ? t : null) || (b && STRONG[b] ? b : null) || t || b, st = k ? STRONG[k] : false;
|
|
200
|
+
ch = t && b ? (st ? '@' : '%') : t ? (st ? "'" : '"') : st ? '.' : ',';
|
|
201
|
+
f = color === 'none' ? null : k;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (color !== 'none') {
|
|
205
|
+
if (g !== bg) {
|
|
206
|
+
s += ESC + (g ? bgCode(g, color) : '49') + 'm';
|
|
207
|
+
bg = g;
|
|
208
|
+
}
|
|
209
|
+
if (f && f !== fg) {
|
|
210
|
+
s += ESC + fgCode(f, color) + 'm';
|
|
211
|
+
fg = f;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
s += ch;
|
|
215
|
+
}
|
|
216
|
+
if (fg || bg)
|
|
217
|
+
s += RESET;
|
|
218
|
+
lines.push(s);
|
|
219
|
+
}
|
|
220
|
+
return lines;
|
|
221
|
+
}
|
|
222
|
+
function txt(str, key, caps, maxw, bold) {
|
|
223
|
+
if (maxw !== undefined && str.length > maxw)
|
|
224
|
+
str = str.slice(0, Math.max(0, maxw - 1)) + (caps.unicode ? '…' : '~');
|
|
225
|
+
if (caps.color === 'none')
|
|
226
|
+
return str;
|
|
227
|
+
return ESC + (bold ? '1;' : '') + fgCode(key, caps.color) + 'm' + str + RESET;
|
|
228
|
+
}
|
|
229
|
+
// ---- Status line ----------------------------------------------------------------------------------------
|
|
230
|
+
function shortPath(p, maxw) {
|
|
231
|
+
const home = homedir();
|
|
232
|
+
if (home && (p === home || p.startsWith(home + '/') || p.startsWith(home + '\\')))
|
|
233
|
+
p = '~' + p.slice(home.length);
|
|
234
|
+
if (p.length <= maxw)
|
|
235
|
+
return p;
|
|
236
|
+
return (maxw > 4 ? '...' : '') + p.slice(p.length - Math.max(1, maxw - 3));
|
|
237
|
+
}
|
|
238
|
+
function statusLine(info, maxw, unicode) {
|
|
239
|
+
const sep = unicode ? ' · ' : ' | ';
|
|
240
|
+
const parts = [info.version ? 'v' + String(info.version).replace(/^v/, '') : '', [info.provider, info.model].filter(Boolean).join('/'), info.mode ?? '', info.cwd ?? ''].filter(Boolean);
|
|
241
|
+
const fits = () => parts.join(sep).length <= maxw;
|
|
242
|
+
if (!fits() && info.cwd) {
|
|
243
|
+
const others = parts.slice(0, -1).join(sep).length + (parts.length > 1 ? sep.length : 0);
|
|
244
|
+
parts[parts.length - 1] = shortPath(info.cwd, Math.max(8, maxw - others));
|
|
245
|
+
}
|
|
246
|
+
while (!fits() && parts.length > 1)
|
|
247
|
+
parts.pop();
|
|
248
|
+
return parts.join(sep);
|
|
249
|
+
}
|
|
250
|
+
// ---- Layouts --------------------------------------------------------------------------------------------
|
|
251
|
+
function layoutFull(caps, info, active) {
|
|
252
|
+
const R = LOGO_L.length / 2, logo = cells(pixLogo(LOGO_L, active), caps), gap = ' ';
|
|
253
|
+
const right = blank(48, R * 2);
|
|
254
|
+
blit(right, pixText('CAUSYNE', F7, 7, 1, 'white'), 0, 2); // rows 1-4
|
|
255
|
+
blit(right, pixText('AGENT', F5, 5, 2, 'lime'), 0, 12); // rows 6-8
|
|
256
|
+
// Keep the metadata inside the wordmark's 48-pixel measure. Letting a long
|
|
257
|
+
// workspace consume every spare terminal column made the mark look detached
|
|
258
|
+
// from its own status line and prevented the prompt from widening once a
|
|
259
|
+
// session began.
|
|
260
|
+
const maxw = Math.min(48, caps.width - 2 - (LOGO_L[0]?.length ?? 0) - gap.length);
|
|
261
|
+
return cells(right, caps).map((tail, r) => {
|
|
262
|
+
if (r === R - 2)
|
|
263
|
+
tail = txt(TAGLINE, 'dim', caps, maxw);
|
|
264
|
+
if (r === R - 1)
|
|
265
|
+
tail = txt(statusLine(info, maxw, caps.unicode), 'dim', caps, maxw);
|
|
266
|
+
return ' ' + (logo[r] ?? '') + gap + tail;
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
function layoutCompact(caps, info, active) {
|
|
270
|
+
const logo = cells(pixLogo(LOGO_S, active), caps), gap = ' ';
|
|
271
|
+
const maxw = Math.max(10, caps.width - 2 - (LOGO_S[0]?.length ?? 0) - gap.length), wide = maxw >= 24;
|
|
272
|
+
return logo.map((l, r) => {
|
|
273
|
+
let tail = '';
|
|
274
|
+
if (r === 3)
|
|
275
|
+
tail = txt(wide ? 'C A U S Y N E' : 'CAUSYNE', 'white', caps, undefined, true) + txt(wide ? ' A G E N T' : ' AGENT', 'lime', caps, undefined, true);
|
|
276
|
+
else if (r === 4)
|
|
277
|
+
tail = txt(TAGLINE, 'dim', caps, maxw);
|
|
278
|
+
else if (r === 6)
|
|
279
|
+
tail = txt(statusLine(info, maxw, caps.unicode), 'dim', caps, maxw);
|
|
280
|
+
return ' ' + l + (tail ? gap + tail : '');
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
function layoutMini(caps, info) {
|
|
284
|
+
const ver = info.version ? ' v' + String(info.version).replace(/^v/, '') : '';
|
|
285
|
+
let line = txt(caps.unicode ? '■' : '#', 'lime', caps) + ' ' + txt('CAUSYNE', 'white', caps, undefined, true) + txt(' AGENT', 'lime', caps, undefined, true);
|
|
286
|
+
if (ver && 'x CAUSYNE AGENT'.length + ver.length <= caps.width - 2)
|
|
287
|
+
line += txt(ver, 'dim', caps);
|
|
288
|
+
return [' ' + line];
|
|
289
|
+
}
|
|
290
|
+
// ---- Public API -----------------------------------------------------------------------------------------
|
|
291
|
+
/** Returns the banner as a string (no trailing newline). */
|
|
292
|
+
export function renderBanner(o = {}, stream = process.stdout) {
|
|
293
|
+
const caps = detect(o, stream), active = o.active ?? 0;
|
|
294
|
+
if (caps.variant === 'full' && !caps.unicode)
|
|
295
|
+
caps.variant = 'compact';
|
|
296
|
+
const lines = caps.variant === 'mini' ? layoutMini(caps, o) : caps.variant === 'compact' ? layoutCompact(caps, o, active) : layoutFull(caps, o, active);
|
|
297
|
+
return lines.join('\n');
|
|
77
298
|
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* The session header: the wordmark and the session facts, SIDE BY SIDE.
|
|
81
|
-
*
|
|
82
|
-
* This used to be three stacked blocks — six rows of art, then a blank, then a
|
|
83
|
-
* bordered panel of four label/value rows, then two separate hint lines. Nineteen
|
|
84
|
-
* rows of chrome before the user could type a character, on a surface whose whole
|
|
85
|
-
* job is to get out of the way.
|
|
86
|
-
*
|
|
87
|
-
* Neither reference does that. claude-code sets its art in the left column with
|
|
88
|
-
* the version, model, plan and directory stacked beside it — three rows, one
|
|
89
|
-
* block. opencode puts the logo alone and the facts in the prompt box. The
|
|
90
|
-
* common property is that the identity and the facts are ONE object, not a
|
|
91
|
-
* sequence of them.
|
|
92
|
-
*
|
|
93
|
-
* Falls back to stacking when the terminal cannot hold both columns, because a
|
|
94
|
-
* header that wraps is worse than a header that is tall.
|
|
95
|
-
*/
|
|
96
|
-
export function sessionBanner(info = {}, width = 80, { unicode = HAS_UNICODE } = {}) {
|
|
97
|
-
let art = brandArt(width, unicode);
|
|
98
|
-
let artWidth = Math.max(...art.map(visibleWidth));
|
|
99
|
-
|
|
100
|
-
const facts = [];
|
|
101
|
-
if (info.version) facts.push([bold(paint('accent', 'Causyne')), dim(info.version)]);
|
|
102
|
-
const model = info.model || 'no model configured';
|
|
103
|
-
facts.push([paint('ink', model), info.provider ? dim(providerLabel(info.provider)) : '']);
|
|
104
|
-
const mode = [info.autonomyMode ? `mode ${info.autonomyMode}` : '', info.tier ? `tier ${info.tier}` : '']
|
|
105
|
-
.filter(Boolean).join(` ${GLYPHS.dot} `);
|
|
106
|
-
if (mode) facts.push([dim(mode), '']);
|
|
107
|
-
if (info.workspace) facts.push([dim(shortenHome(info.workspace)), '']);
|
|
108
|
-
|
|
109
|
-
const GAP = 4;
|
|
110
|
-
// The spare column is the same one the stacked branch below already reserves
|
|
111
|
-
// (`width - 1`). Without it a full-width fact row lands in the terminal's
|
|
112
|
-
// deferred-wrap state: the cursor sits past the last column, and the next
|
|
113
|
-
// write decides whether that becomes a wrap. Measured on an 80-column pty,
|
|
114
|
-
// the workspace row came back at exactly 80 while every other row was 79.
|
|
115
|
-
let room = width - artWidth - GAP - 1;
|
|
116
|
-
|
|
117
|
-
// A coloured 80-column terminal can render the full block wordmark, but it
|
|
118
|
-
// leaves too little room for the session facts beside it. Stacking those
|
|
119
|
-
// facts made startup 16 rows tall and overflowed short terminals. Prefer the
|
|
120
|
-
// compact ASCII aperture in that case: identity and facts remain one object,
|
|
121
|
-
// and the first prompt still fits without scrolling the banner away.
|
|
122
|
-
if (room < 24 && unicode && width >= 64) {
|
|
123
|
-
art = brandArt(width, false);
|
|
124
|
-
artWidth = Math.max(...art.map(visibleWidth));
|
|
125
|
-
room = width - artWidth - GAP - 1;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// Not enough width for two columns: stack, rather than wrap either one.
|
|
129
|
-
if (room < 24) {
|
|
130
|
-
return art
|
|
131
|
-
.concat([''], facts.map(([left, right]) =>
|
|
132
|
-
truncate(right ? `${left} ${right}` : left, width - 1, GLYPHS.ellipsis)));
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const rows = [];
|
|
136
|
-
const height = Math.max(art.length, facts.length);
|
|
137
|
-
for (let index = 0; index < height; index++) {
|
|
138
|
-
const artLine = art[index] === undefined ? '' : art[index];
|
|
139
|
-
const fact = facts[index];
|
|
140
|
-
const left = padEnd(artLine, artWidth) + ' '.repeat(GAP);
|
|
141
|
-
if (!fact) { rows.push(artLine); continue; }
|
|
142
|
-
const [primary, secondary] = fact;
|
|
143
|
-
const text = secondary ? `${primary} ${secondary}` : primary;
|
|
144
|
-
rows.push(left + truncate(text, room, GLYPHS.ellipsis));
|
|
145
|
-
}
|
|
146
|
-
return rows;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
299
|
/**
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
* may assume or default to one. A config saying `Gemini` and one saying `gemini`
|
|
154
|
-
* are the same provider and must read the same.
|
|
300
|
+
* Preserve the TUI's established banner API while rendering the canonical
|
|
301
|
+
* aperture and wordmark above. The returned rows are ordinary terminal history;
|
|
302
|
+
* callers decide when to write them and never need to repaint the screen.
|
|
155
303
|
*/
|
|
304
|
+
export function wordmark(width = 80, options = {}) {
|
|
305
|
+
return renderBanner({ ...options, width, height: options.height ?? 24 }, process.stdout).split('\n');
|
|
306
|
+
}
|
|
307
|
+
/** A single display spelling for configured providers. */
|
|
156
308
|
export function providerLabel(provider) {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
309
|
+
const raw = String(provider ?? '').trim();
|
|
310
|
+
if (!raw)
|
|
311
|
+
return '';
|
|
312
|
+
const known = {
|
|
313
|
+
openai: 'OpenAI', anthropic: 'Anthropic', gemini: 'Gemini', ollama: 'Ollama',
|
|
314
|
+
deepseek: 'DeepSeek', dashscope: 'DashScope', 'dashscope-byok': 'DashScope',
|
|
315
|
+
qwen: 'Qwen', xai: 'xAI', grok: 'xAI', moonshot: 'Moonshot', kimi: 'Moonshot',
|
|
316
|
+
muse: 'Meta', meta: 'Meta', 'openai-compatible': 'OpenAI-compatible'
|
|
317
|
+
};
|
|
318
|
+
return known[raw.toLowerCase()] ?? raw;
|
|
319
|
+
}
|
|
320
|
+
function sessionInfo(info) {
|
|
321
|
+
const provider = providerLabel(info.provider);
|
|
322
|
+
const modeParts = [
|
|
323
|
+
info.autonomyMode ? `mode ${info.autonomyMode}` : info.mode,
|
|
324
|
+
info.tier ? `tier ${info.tier}` : undefined
|
|
325
|
+
].filter((part) => Boolean(part));
|
|
326
|
+
return {
|
|
327
|
+
...(info.version ? { version: info.version } : {}),
|
|
328
|
+
...(provider ? { provider } : {}),
|
|
329
|
+
...(info.model ? { model: info.model } : {}),
|
|
330
|
+
...(modeParts.length ? { mode: modeParts.join(' / ') } : {}),
|
|
331
|
+
...(info.workspace || info.cwd ? { cwd: info.workspace ?? info.cwd } : {})
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
/** Render the adaptive session identity block used by ChatRenderer. */
|
|
335
|
+
export function sessionBanner(info = {}, width = 80, options = {}) {
|
|
336
|
+
const height = options.height ?? process.stdout.rows ?? 24;
|
|
337
|
+
// The chat surface also needs room for its input band and hint row. The
|
|
338
|
+
// standalone renderer keeps the source design's 80x24 full-banner threshold,
|
|
339
|
+
// while the integrated session header chooses the same artwork at a size that
|
|
340
|
+
// cannot push the first prompt off short terminals.
|
|
341
|
+
const variant = height < 22 || width < 44
|
|
342
|
+
? 'mini'
|
|
343
|
+
: width < 96 || height < 28 || options.unicode === false
|
|
344
|
+
? 'compact'
|
|
345
|
+
: 'full';
|
|
346
|
+
return renderBanner({
|
|
347
|
+
...sessionInfo(info),
|
|
348
|
+
...options,
|
|
349
|
+
variant,
|
|
350
|
+
width,
|
|
351
|
+
height
|
|
352
|
+
}, process.stdout).split('\n');
|
|
353
|
+
}
|
|
354
|
+
/** Prints the banner. Returns false (prints nothing) when stdout is not a TTY, unless `force` is set. */
|
|
355
|
+
export function printBanner(o = {}, stream = process.stdout) {
|
|
356
|
+
if (!stream.isTTY && !o.force)
|
|
357
|
+
return false;
|
|
358
|
+
stream.write(renderBanner(o, stream) + '\n');
|
|
359
|
+
return true;
|
|
360
|
+
}
|
|
361
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
362
|
+
/** Optional standalone boot animation. The main chat TUI deliberately writes once to preserve scrollback. */
|
|
363
|
+
export async function animateBanner(o = {}, stream = process.stdout) {
|
|
364
|
+
const caps = detect(o, stream), env = process.env;
|
|
365
|
+
if (!stream.isTTY || caps.color === 'none' || !caps.unicode || caps.variant === 'mini' || o.animate === false || env['CI'] || env['CAUSYNE_NO_ANIM'] || env['TERM'] === 'dumb')
|
|
366
|
+
return printBanner(o, stream);
|
|
367
|
+
const frames = [0, 1, 2, 3, 4, 5, 0].map((active) => renderBanner({ ...o, active }, stream).split('\n'));
|
|
368
|
+
const first = frames[0] ?? [], count = first.length, show = () => { stream.write('\x1b[?25h'); };
|
|
369
|
+
process.once('exit', show);
|
|
370
|
+
try {
|
|
371
|
+
stream.write('\x1b[?25l' + first.join('\n') + '\n');
|
|
372
|
+
for (const frame of frames.slice(1)) {
|
|
373
|
+
await sleep(o.frameMs ?? 70);
|
|
374
|
+
stream.write(`\x1b[${count}A` + frame.map((line) => line + '\x1b[K').join('\n') + '\n');
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
finally {
|
|
378
|
+
show();
|
|
379
|
+
process.removeListener('exit', show);
|
|
380
|
+
}
|
|
381
|
+
return true;
|
|
382
|
+
}
|
|
176
383
|
export const EXAMPLES = [
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
384
|
+
'summarise what changed in this repo today',
|
|
385
|
+
'find every TODO left in src/ and group them by file',
|
|
386
|
+
'explain what this project does and how it is wired',
|
|
387
|
+
'run the tests and tell me what broke',
|
|
388
|
+
'review my last commit for anything risky'
|
|
182
389
|
];
|
|
183
|
-
|
|
184
|
-
/** One example, chosen per session rather than per keystroke. */
|
|
390
|
+
/** One example, chosen once per session rather than once per keystroke. */
|
|
185
391
|
export function pickExample() {
|
|
186
|
-
|
|
392
|
+
return EXAMPLES[Math.floor(Math.random() * EXAMPLES.length)] ?? EXAMPLES[0];
|
|
187
393
|
}
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Join hint fragments, dropping the least important until the line fits.
|
|
191
|
-
*
|
|
192
|
-
* Same policy as the footer: a hint clipped mid-word reads as a bug, so drop
|
|
193
|
-
* whole fragments rather than truncate one, and fall back entirely when even a
|
|
194
|
-
* single fragment will not fit.
|
|
195
|
-
*/
|
|
394
|
+
/** Drop complete hint fragments until the remaining line fits. */
|
|
196
395
|
export function fitHints(parts, width, fallback) {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
396
|
+
const separator = ' · ';
|
|
397
|
+
const kept = parts.slice();
|
|
398
|
+
while (kept.length > 1 && kept.join(separator).length > width)
|
|
399
|
+
kept.pop();
|
|
400
|
+
const line = kept.join(separator);
|
|
401
|
+
return line.length <= width ? line : fallback;
|
|
203
402
|
}
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical Causyne Agent terminal identity: aperture, pixel wordmark, status,
|
|
3
|
+
* and adaptive fallbacks. TypeScript is the source; the repository build emits
|
|
4
|
+
* the readable ESM file imported by the CLI.
|
|
5
|
+
*
|
|
6
|
+
* Environment: NO_COLOR, CAUSYNE_NO_COLOR, FORCE_COLOR=1|2|3,
|
|
7
|
+
* CAUSYNE_ASCII=1 (7-bit only), and CAUSYNE_NO_ANIM=1.
|
|
8
|
+
*/
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
|
|
11
|
+
export type ColorMode = 'truecolor' | '256' | '16' | 'none';
|
|
12
|
+
export type Variant = 'full' | 'compact' | 'mini';
|
|
13
|
+
|
|
14
|
+
export interface BannerInfo {
|
|
15
|
+
version?: string;
|
|
16
|
+
provider?: string;
|
|
17
|
+
model?: string;
|
|
18
|
+
mode?: string;
|
|
19
|
+
cwd?: string;
|
|
20
|
+
autonomyMode?: string;
|
|
21
|
+
tier?: string;
|
|
22
|
+
workspace?: string;
|
|
23
|
+
}
|
|
24
|
+
export interface BannerOptions extends BannerInfo {
|
|
25
|
+
/** 'auto' picks from the terminal size: full >= 80x24, compact >= 44 cols, otherwise mini. */
|
|
26
|
+
variant?: Variant | 'auto';
|
|
27
|
+
/** 'auto' honours NO_COLOR / FORCE_COLOR / TTY / COLORTERM. */
|
|
28
|
+
color?: ColorMode | 'auto' | boolean;
|
|
29
|
+
unicode?: boolean; width?: number; height?: number;
|
|
30
|
+
/** Which blade is lime (0-5, clockwise from top-right). Used by the boot animation. */
|
|
31
|
+
active?: number;
|
|
32
|
+
animate?: boolean; frameMs?: number;
|
|
33
|
+
/** Print even when stdout is not a TTY. */
|
|
34
|
+
force?: boolean;
|
|
35
|
+
}
|
|
36
|
+
export interface TermStream { isTTY?: boolean; columns?: number; rows?: number; getColorDepth?: () => number; write(chunk: string): unknown }
|
|
37
|
+
export interface Caps { tty: boolean; width: number; height: number; color: ColorMode; unicode: boolean; variant: Variant }
|
|
38
|
+
|
|
39
|
+
type Key = 'lime' | 'gray' | 'white' | 'dim';
|
|
40
|
+
type Px = Key | null;
|
|
41
|
+
type Canvas = Px[][];
|
|
42
|
+
type Font = Record<string, readonly string[]>;
|
|
43
|
+
type RGB = readonly [number, number, number];
|
|
44
|
+
|
|
45
|
+
export const TAGLINE = 'From intention to meaningful progress.';
|
|
46
|
+
|
|
47
|
+
// ---- Logo -----------------------------------------------------------------------------------------------
|
|
48
|
+
// One character = one "pixel". A terminal cell holds two stacked pixels (drawn with the half blocks ▀ ▄ █).
|
|
49
|
+
// '.' is empty, 0-5 is the blade id. Blade 0 is the lime one (top-right); ids run clockwise.
|
|
50
|
+
const LOGO_L: readonly string[] = [
|
|
51
|
+
'......................',
|
|
52
|
+
'.........5555.........',
|
|
53
|
+
'.......555555.........',
|
|
54
|
+
'.....5555555.0000.....',
|
|
55
|
+
'....5555555.0000000...',
|
|
56
|
+
'...5555555.000000000..',
|
|
57
|
+
'....55555.00000000000.',
|
|
58
|
+
'.44.5555.......000000.',
|
|
59
|
+
'.444.55...........000.',
|
|
60
|
+
'.444..5........111..0.',
|
|
61
|
+
'.4444..........11111..',
|
|
62
|
+
'.4444...........11111.',
|
|
63
|
+
'.44444...........1111.',
|
|
64
|
+
'.444444..........1111.',
|
|
65
|
+
'..44444........2..111.',
|
|
66
|
+
'......4........22.111.',
|
|
67
|
+
'.3333.........222..11.',
|
|
68
|
+
'.33333333333.22222.11.',
|
|
69
|
+
'..333333333.2222222...',
|
|
70
|
+
'....333333.2222222....',
|
|
71
|
+
'.....3333.2222222.....',
|
|
72
|
+
'.........222222.......',
|
|
73
|
+
'.........2222.........',
|
|
74
|
+
'......................',
|
|
75
|
+
];
|
|
76
|
+
const LOGO_S: readonly string[] = [
|
|
77
|
+
'..................',
|
|
78
|
+
'.......555........',
|
|
79
|
+
'.....55555..0.....',
|
|
80
|
+
'...555555.00000...',
|
|
81
|
+
'...55555.0000000..',
|
|
82
|
+
'.4.5555..00000000.',
|
|
83
|
+
'.44.55.......0000.',
|
|
84
|
+
'.44.55......1...0.',
|
|
85
|
+
'.444........1111..',
|
|
86
|
+
'.444.........1111.',
|
|
87
|
+
'.4444.........111.',
|
|
88
|
+
'.44444........111.',
|
|
89
|
+
'...444......22.11.',
|
|
90
|
+
'.33.........22.11.',
|
|
91
|
+
'.333333333.2222.1.',
|
|
92
|
+
'..3333333.22222...',
|
|
93
|
+
'...33333.222222...',
|
|
94
|
+
'.....33.22222.....',
|
|
95
|
+
'........222.......',
|
|
96
|
+
'..................',
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
// ---- Wordmark: pixel fonts ('#' = lit pixel). CAUSYNE is 6x7, AGENT is 5x5. The A has no crossbar, like the brand wordmark.
|
|
100
|
+
const F7: Font = {
|
|
101
|
+
C: ['.####.', '#....#', '#.....', '#.....', '#.....', '#....#', '.####.'],
|
|
102
|
+
A: ['..##..', '.#..#.', '.#..#.', '#....#', '#....#', '#....#', '#....#'],
|
|
103
|
+
U: ['#....#', '#....#', '#....#', '#....#', '#....#', '#....#', '.####.'],
|
|
104
|
+
S: ['.#####', '#.....', '#.....', '.####.', '.....#', '.....#', '#####.'],
|
|
105
|
+
Y: ['#....#', '.#..#.', '..##..', '..##..', '..##..', '..##..', '..##..'],
|
|
106
|
+
N: ['#....#', '##...#', '#.#..#', '#..#.#', '#...##', '#....#', '#....#'],
|
|
107
|
+
E: ['######', '#.....', '#.....', '####..', '#.....', '#.....', '######'],
|
|
108
|
+
};
|
|
109
|
+
const F5: Font = {
|
|
110
|
+
A: ['..#..', '.#.#.', '.#.#.', '#...#', '#...#'],
|
|
111
|
+
G: ['.####', '#....', '#.###', '#...#', '.###.'],
|
|
112
|
+
E: ['#####', '#....', '###..', '#....', '#####'],
|
|
113
|
+
N: ['#...#', '##..#', '#.#.#', '#..##', '#...#'],
|
|
114
|
+
T: ['#####', '..#..', '..#..', '..#..', '..#..'],
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// ---- Colour ---------------------------------------------------------------------------------------------
|
|
118
|
+
const ESC = '\x1b[';
|
|
119
|
+
const RESET = ESC + '0m';
|
|
120
|
+
const PAL: Record<Key, RGB> = { lime: [182, 224, 111], gray: [95, 102, 114], white: [244, 246, 248], dim: [140, 148, 160] };
|
|
121
|
+
const C16: Record<Key, number> = { lime: 92, gray: 90, white: 97, dim: 90 };
|
|
122
|
+
const STRONG: Record<Key, boolean> = { lime: true, white: true, gray: false, dim: false };
|
|
123
|
+
const LV = [0, 95, 135, 175, 215, 255] as const;
|
|
124
|
+
const FORCE: Record<string, ColorMode> = { '0': 'none', false: 'none', '1': '16', true: '16', '2': '256', '3': 'truecolor' };
|
|
125
|
+
|
|
126
|
+
function to256(c: RGB): number {
|
|
127
|
+
const [r, g, b] = c;
|
|
128
|
+
const near = (v: number): number => LV.reduce<number>((bi, x, i) => (Math.abs(x - v) < Math.abs((LV[bi] ?? 0) - v) ? i : bi), 0);
|
|
129
|
+
const ri = near(r), gi = near(g), bi = near(b), cube = 16 + 36 * ri + 6 * gi + bi;
|
|
130
|
+
const gl = Math.max(0, Math.min(23, Math.round(((r + g + b) / 3 - 8) / 10))), gv = 8 + 10 * gl;
|
|
131
|
+
const dc = (r - (LV[ri] ?? 0)) ** 2 + (g - (LV[gi] ?? 0)) ** 2 + (b - (LV[bi] ?? 0)) ** 2;
|
|
132
|
+
const dg = (r - gv) ** 2 + (g - gv) ** 2 + (b - gv) ** 2;
|
|
133
|
+
return dg < dc ? 232 + gl : cube;
|
|
134
|
+
}
|
|
135
|
+
function fgCode(k: Key, d: ColorMode): string {
|
|
136
|
+
const rgb = PAL[k];
|
|
137
|
+
return d === 'truecolor' ? `38;2;${rgb.join(';')}` : d === '256' ? `38;5;${to256(rgb)}` : String(C16[k]);
|
|
138
|
+
}
|
|
139
|
+
function bgCode(k: Key, d: ColorMode): string {
|
|
140
|
+
const rgb = PAL[k];
|
|
141
|
+
return d === 'truecolor' ? `48;2;${rgb.join(';')}` : d === '256' ? `48;5;${to256(rgb)}` : String(C16[k] + 10);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---- Capability detection -------------------------------------------------------------------------------
|
|
145
|
+
function pickVariant(c: Pick<Caps, 'width' | 'height' | 'unicode'>): Variant {
|
|
146
|
+
if (c.width < 44 || c.height < 14) return 'mini';
|
|
147
|
+
if (c.width < 80 || c.height < 24 || !c.unicode) return 'compact';
|
|
148
|
+
return 'full';
|
|
149
|
+
}
|
|
150
|
+
export function detect(o: BannerOptions = {}, stream: TermStream = process.stdout): Caps {
|
|
151
|
+
const env = process.env, tty = Boolean(stream.isTTY);
|
|
152
|
+
const width = o.width || stream.columns || 80, height = o.height || stream.rows || 24;
|
|
153
|
+
const want = o.color;
|
|
154
|
+
let color: ColorMode;
|
|
155
|
+
if (want === true) color = 'truecolor';
|
|
156
|
+
else if (want === false) color = 'none';
|
|
157
|
+
else if (want !== undefined && want !== 'auto') color = want;
|
|
158
|
+
else {
|
|
159
|
+
const f = env['FORCE_COLOR'];
|
|
160
|
+
if (f !== undefined && f !== '') color = FORCE[f] ?? '16';
|
|
161
|
+
else if (env['NO_COLOR'] || env['CAUSYNE_NO_COLOR'] === '1') color = 'none';
|
|
162
|
+
else if (!tty || env['TERM'] === 'dumb') color = 'none';
|
|
163
|
+
else {
|
|
164
|
+
const d = stream.getColorDepth?.() ?? 4;
|
|
165
|
+
color = /truecolor|24bit/i.test(env['COLORTERM'] ?? '') || d >= 24 ? 'truecolor' : d >= 8 ? '256' : d >= 4 ? '16' : 'none';
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
let unicode = o.unicode;
|
|
169
|
+
if (unicode === undefined) {
|
|
170
|
+
const loc = env['LC_ALL'] || env['LC_CTYPE'] || env['LANG'] || '', ascii = env['CAUSYNE_ASCII'];
|
|
171
|
+
unicode = !(ascii && ascii !== '0') && !(process.platform !== 'win32' && loc !== '' && !/utf-?8/i.test(loc));
|
|
172
|
+
}
|
|
173
|
+
const caps: Caps = { tty, width, height, color, unicode, variant: 'full' };
|
|
174
|
+
caps.variant = o.variant && o.variant !== 'auto' ? o.variant : pickVariant(caps);
|
|
175
|
+
return caps;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ---- Pixel canvas -> terminal cells ---------------------------------------------------------------------
|
|
179
|
+
const blank = (w: number, h: number): Canvas => Array.from({ length: h }, () => Array<Px>(w).fill(null));
|
|
180
|
+
function blit(dst: Canvas, src: Canvas, x: number, y: number): void {
|
|
181
|
+
src.forEach((row, j) => row.forEach((k, i) => { const d = dst[y + j]; if (d && k) d[x + i] = k; }));
|
|
182
|
+
}
|
|
183
|
+
const pixLogo = (g: readonly string[], active: number): Canvas => g.map((r) => [...r].map((ch): Px => (ch === '.' ? null : Number(ch) === active ? 'lime' : 'gray')));
|
|
184
|
+
function pixText(str: string, font: Font, h: number, gap: number, key: Key): Canvas {
|
|
185
|
+
const rows: Canvas = Array.from({ length: h }, () => []);
|
|
186
|
+
[...str].forEach((ch, i) => {
|
|
187
|
+
const glyph = font[ch];
|
|
188
|
+
if (!glyph) return;
|
|
189
|
+
rows.forEach((row, y) => {
|
|
190
|
+
if (i) for (let k = 0; k < gap; k++) row.push(null);
|
|
191
|
+
for (const c of glyph[y] ?? '') row.push(c === '#' ? key : null);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
return rows;
|
|
195
|
+
}
|
|
196
|
+
function cells(px: Canvas, caps: Caps): string[] {
|
|
197
|
+
const { color, unicode } = caps, lines: string[] = [];
|
|
198
|
+
for (let y = 0; y < px.length; y += 2) {
|
|
199
|
+
const top = px[y] ?? [], bot = px[y + 1] ?? [], w = Math.max(top.length, bot.length);
|
|
200
|
+
let s = '', fg: Key | null = null, bg: Key | null = null;
|
|
201
|
+
for (let x = 0; x < w; x++) {
|
|
202
|
+
const t = top[x] ?? null, b = bot[x] ?? null;
|
|
203
|
+
let ch = ' ', f: Key | null = null, g: Key | null = null;
|
|
204
|
+
if (t || b) {
|
|
205
|
+
if (unicode) {
|
|
206
|
+
if (color === 'none') ch = t && b ? ((t && STRONG[t]) || (b && STRONG[b]) ? '█' : '▒') : t ? '▀' : '▄';
|
|
207
|
+
else if (t === b) { ch = '█'; f = t; }
|
|
208
|
+
else if (!b) { ch = '▀'; f = t; }
|
|
209
|
+
else if (!t) { ch = '▄'; f = b; }
|
|
210
|
+
else { ch = '▀'; f = t; g = b; }
|
|
211
|
+
} else {
|
|
212
|
+
const k: Key | null = (t && STRONG[t] ? t : null) || (b && STRONG[b] ? b : null) || t || b, st = k ? STRONG[k] : false;
|
|
213
|
+
ch = t && b ? (st ? '@' : '%') : t ? (st ? "'" : '"') : st ? '.' : ',';
|
|
214
|
+
f = color === 'none' ? null : k;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (color !== 'none') {
|
|
218
|
+
if (g !== bg) { s += ESC + (g ? bgCode(g, color) : '49') + 'm'; bg = g; }
|
|
219
|
+
if (f && f !== fg) { s += ESC + fgCode(f, color) + 'm'; fg = f; }
|
|
220
|
+
}
|
|
221
|
+
s += ch;
|
|
222
|
+
}
|
|
223
|
+
if (fg || bg) s += RESET;
|
|
224
|
+
lines.push(s);
|
|
225
|
+
}
|
|
226
|
+
return lines;
|
|
227
|
+
}
|
|
228
|
+
function txt(str: string, key: Key, caps: Caps, maxw?: number, bold?: boolean): string {
|
|
229
|
+
if (maxw !== undefined && str.length > maxw) str = str.slice(0, Math.max(0, maxw - 1)) + (caps.unicode ? '…' : '~');
|
|
230
|
+
if (caps.color === 'none') return str;
|
|
231
|
+
return ESC + (bold ? '1;' : '') + fgCode(key, caps.color) + 'm' + str + RESET;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ---- Status line ----------------------------------------------------------------------------------------
|
|
235
|
+
function shortPath(p: string, maxw: number): string {
|
|
236
|
+
const home = homedir();
|
|
237
|
+
if (home && (p === home || p.startsWith(home + '/') || p.startsWith(home + '\\'))) p = '~' + p.slice(home.length);
|
|
238
|
+
if (p.length <= maxw) return p;
|
|
239
|
+
return (maxw > 4 ? '...' : '') + p.slice(p.length - Math.max(1, maxw - 3));
|
|
240
|
+
}
|
|
241
|
+
function statusLine(info: BannerInfo, maxw: number, unicode: boolean): string {
|
|
242
|
+
const sep = unicode ? ' · ' : ' | ';
|
|
243
|
+
const parts = [info.version ? 'v' + String(info.version).replace(/^v/, '') : '', [info.provider, info.model].filter(Boolean).join('/'), info.mode ?? '', info.cwd ?? ''].filter(Boolean);
|
|
244
|
+
const fits = (): boolean => parts.join(sep).length <= maxw;
|
|
245
|
+
if (!fits() && info.cwd) {
|
|
246
|
+
const others = parts.slice(0, -1).join(sep).length + (parts.length > 1 ? sep.length : 0);
|
|
247
|
+
parts[parts.length - 1] = shortPath(info.cwd, Math.max(8, maxw - others));
|
|
248
|
+
}
|
|
249
|
+
while (!fits() && parts.length > 1) parts.pop();
|
|
250
|
+
return parts.join(sep);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ---- Layouts --------------------------------------------------------------------------------------------
|
|
254
|
+
function layoutFull(caps: Caps, info: BannerInfo, active: number): string[] {
|
|
255
|
+
const R = LOGO_L.length / 2, logo = cells(pixLogo(LOGO_L, active), caps), gap = ' ';
|
|
256
|
+
const right = blank(48, R * 2);
|
|
257
|
+
blit(right, pixText('CAUSYNE', F7, 7, 1, 'white'), 0, 2); // rows 1-4
|
|
258
|
+
blit(right, pixText('AGENT', F5, 5, 2, 'lime'), 0, 12); // rows 6-8
|
|
259
|
+
// Keep the metadata inside the wordmark's 48-pixel measure. Letting a long
|
|
260
|
+
// workspace consume every spare terminal column made the mark look detached
|
|
261
|
+
// from its own status line and prevented the prompt from widening once a
|
|
262
|
+
// session began.
|
|
263
|
+
const maxw = Math.min(48, caps.width - 2 - (LOGO_L[0]?.length ?? 0) - gap.length);
|
|
264
|
+
return cells(right, caps).map((tail, r) => {
|
|
265
|
+
if (r === R - 2) tail = txt(TAGLINE, 'dim', caps, maxw);
|
|
266
|
+
if (r === R - 1) tail = txt(statusLine(info, maxw, caps.unicode), 'dim', caps, maxw);
|
|
267
|
+
return ' ' + (logo[r] ?? '') + gap + tail;
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
function layoutCompact(caps: Caps, info: BannerInfo, active: number): string[] {
|
|
271
|
+
const logo = cells(pixLogo(LOGO_S, active), caps), gap = ' ';
|
|
272
|
+
const maxw = Math.max(10, caps.width - 2 - (LOGO_S[0]?.length ?? 0) - gap.length), wide = maxw >= 24;
|
|
273
|
+
return logo.map((l, r) => {
|
|
274
|
+
let tail = '';
|
|
275
|
+
if (r === 3) tail = txt(wide ? 'C A U S Y N E' : 'CAUSYNE', 'white', caps, undefined, true) + txt(wide ? ' A G E N T' : ' AGENT', 'lime', caps, undefined, true);
|
|
276
|
+
else if (r === 4) tail = txt(TAGLINE, 'dim', caps, maxw);
|
|
277
|
+
else if (r === 6) tail = txt(statusLine(info, maxw, caps.unicode), 'dim', caps, maxw);
|
|
278
|
+
return ' ' + l + (tail ? gap + tail : '');
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
function layoutMini(caps: Caps, info: BannerInfo): string[] {
|
|
282
|
+
const ver = info.version ? ' v' + String(info.version).replace(/^v/, '') : '';
|
|
283
|
+
let line = txt(caps.unicode ? '■' : '#', 'lime', caps) + ' ' + txt('CAUSYNE', 'white', caps, undefined, true) + txt(' AGENT', 'lime', caps, undefined, true);
|
|
284
|
+
if (ver && 'x CAUSYNE AGENT'.length + ver.length <= caps.width - 2) line += txt(ver, 'dim', caps);
|
|
285
|
+
return [' ' + line];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ---- Public API -----------------------------------------------------------------------------------------
|
|
289
|
+
/** Returns the banner as a string (no trailing newline). */
|
|
290
|
+
export function renderBanner(o: BannerOptions = {}, stream: TermStream = process.stdout): string {
|
|
291
|
+
const caps = detect(o, stream), active = o.active ?? 0;
|
|
292
|
+
if (caps.variant === 'full' && !caps.unicode) caps.variant = 'compact';
|
|
293
|
+
const lines = caps.variant === 'mini' ? layoutMini(caps, o) : caps.variant === 'compact' ? layoutCompact(caps, o, active) : layoutFull(caps, o, active);
|
|
294
|
+
return lines.join('\n');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Preserve the TUI's established banner API while rendering the canonical
|
|
299
|
+
* aperture and wordmark above. The returned rows are ordinary terminal history;
|
|
300
|
+
* callers decide when to write them and never need to repaint the screen.
|
|
301
|
+
*/
|
|
302
|
+
export function wordmark(width = 80, options: Pick<BannerOptions, 'unicode' | 'color' | 'height'> = {}): string[] {
|
|
303
|
+
return renderBanner({ ...options, width, height: options.height ?? 24 }, process.stdout).split('\n');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** A single display spelling for configured providers. */
|
|
307
|
+
export function providerLabel(provider: unknown): string {
|
|
308
|
+
const raw = String(provider ?? '').trim();
|
|
309
|
+
if (!raw) return '';
|
|
310
|
+
const known: Record<string, string> = {
|
|
311
|
+
openai: 'OpenAI', anthropic: 'Anthropic', gemini: 'Gemini', ollama: 'Ollama',
|
|
312
|
+
deepseek: 'DeepSeek', dashscope: 'DashScope', 'dashscope-byok': 'DashScope',
|
|
313
|
+
qwen: 'Qwen', xai: 'xAI', grok: 'xAI', moonshot: 'Moonshot', kimi: 'Moonshot',
|
|
314
|
+
muse: 'Meta', meta: 'Meta', 'openai-compatible': 'OpenAI-compatible'
|
|
315
|
+
};
|
|
316
|
+
return known[raw.toLowerCase()] ?? raw;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function sessionInfo(info: BannerInfo): BannerInfo {
|
|
320
|
+
const provider = providerLabel(info.provider);
|
|
321
|
+
const modeParts = [
|
|
322
|
+
info.autonomyMode ? `mode ${info.autonomyMode}` : info.mode,
|
|
323
|
+
info.tier ? `tier ${info.tier}` : undefined
|
|
324
|
+
].filter((part): part is string => Boolean(part));
|
|
325
|
+
return {
|
|
326
|
+
...(info.version ? { version: info.version } : {}),
|
|
327
|
+
...(provider ? { provider } : {}),
|
|
328
|
+
...(info.model ? { model: info.model } : {}),
|
|
329
|
+
...(modeParts.length ? { mode: modeParts.join(' / ') } : {}),
|
|
330
|
+
...(info.workspace || info.cwd ? { cwd: info.workspace ?? info.cwd } : {})
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Render the adaptive session identity block used by ChatRenderer. */
|
|
335
|
+
export function sessionBanner(
|
|
336
|
+
info: BannerInfo = {},
|
|
337
|
+
width = 80,
|
|
338
|
+
options: Pick<BannerOptions, 'unicode' | 'color' | 'height'> = {}
|
|
339
|
+
): string[] {
|
|
340
|
+
const height = options.height ?? process.stdout.rows ?? 24;
|
|
341
|
+
// The chat surface also needs room for its input band and hint row. The
|
|
342
|
+
// standalone renderer keeps the source design's 80x24 full-banner threshold,
|
|
343
|
+
// while the integrated session header chooses the same artwork at a size that
|
|
344
|
+
// cannot push the first prompt off short terminals.
|
|
345
|
+
const variant: Variant = height < 22 || width < 44
|
|
346
|
+
? 'mini'
|
|
347
|
+
: width < 96 || height < 28 || options.unicode === false
|
|
348
|
+
? 'compact'
|
|
349
|
+
: 'full';
|
|
350
|
+
return renderBanner({
|
|
351
|
+
...sessionInfo(info),
|
|
352
|
+
...options,
|
|
353
|
+
variant,
|
|
354
|
+
width,
|
|
355
|
+
height
|
|
356
|
+
}, process.stdout).split('\n');
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Prints the banner. Returns false (prints nothing) when stdout is not a TTY, unless `force` is set. */
|
|
360
|
+
export function printBanner(o: BannerOptions = {}, stream: TermStream = process.stdout): boolean {
|
|
361
|
+
if (!stream.isTTY && !o.force) return false;
|
|
362
|
+
stream.write(renderBanner(o, stream) + '\n');
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
|
366
|
+
|
|
367
|
+
/** Optional standalone boot animation. The main chat TUI deliberately writes once to preserve scrollback. */
|
|
368
|
+
export async function animateBanner(o: BannerOptions = {}, stream: TermStream = process.stdout): Promise<boolean> {
|
|
369
|
+
const caps = detect(o, stream), env = process.env;
|
|
370
|
+
if (!stream.isTTY || caps.color === 'none' || !caps.unicode || caps.variant === 'mini' || o.animate === false || env['CI'] || env['CAUSYNE_NO_ANIM'] || env['TERM'] === 'dumb') return printBanner(o, stream);
|
|
371
|
+
const frames = [0, 1, 2, 3, 4, 5, 0].map((active) => renderBanner({ ...o, active }, stream).split('\n'));
|
|
372
|
+
const first = frames[0] ?? [], count = first.length, show = (): void => { stream.write('\x1b[?25h'); };
|
|
373
|
+
process.once('exit', show);
|
|
374
|
+
try {
|
|
375
|
+
stream.write('\x1b[?25l' + first.join('\n') + '\n');
|
|
376
|
+
for (const frame of frames.slice(1)) {
|
|
377
|
+
await sleep(o.frameMs ?? 70);
|
|
378
|
+
stream.write(`\x1b[${count}A` + frame.map((line) => line + '\x1b[K').join('\n') + '\n');
|
|
379
|
+
}
|
|
380
|
+
} finally {
|
|
381
|
+
show();
|
|
382
|
+
process.removeListener('exit', show);
|
|
383
|
+
}
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export const EXAMPLES = [
|
|
388
|
+
'summarise what changed in this repo today',
|
|
389
|
+
'find every TODO left in src/ and group them by file',
|
|
390
|
+
'explain what this project does and how it is wired',
|
|
391
|
+
'run the tests and tell me what broke',
|
|
392
|
+
'review my last commit for anything risky'
|
|
393
|
+
] as const;
|
|
394
|
+
|
|
395
|
+
/** One example, chosen once per session rather than once per keystroke. */
|
|
396
|
+
export function pickExample(): string {
|
|
397
|
+
return EXAMPLES[Math.floor(Math.random() * EXAMPLES.length)] ?? EXAMPLES[0];
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Drop complete hint fragments until the remaining line fits. */
|
|
401
|
+
export function fitHints(parts: string[], width: number, fallback: string): string {
|
|
402
|
+
const separator = ' · ';
|
|
403
|
+
const kept = parts.slice();
|
|
404
|
+
while (kept.length > 1 && kept.join(separator).length > width) kept.pop();
|
|
405
|
+
const line = kept.join(separator);
|
|
406
|
+
return line.length <= width ? line : fallback;
|
|
407
|
+
}
|
package/src/tui/chatRenderer.js
CHANGED
|
@@ -384,7 +384,7 @@ export class ChatRenderer {
|
|
|
384
384
|
// nineteen rows of chrome before the user could type. Both references open
|
|
385
385
|
// in eight or fewer, with the identity and the facts as a single object.
|
|
386
386
|
this.write();
|
|
387
|
-
for (const row of sessionBanner(info, width)) this.write(row);
|
|
387
|
+
for (const row of sessionBanner(info, width, { height: this.out?.rows })) this.write(row);
|
|
388
388
|
this.write();
|
|
389
389
|
|
|
390
390
|
const hints = [
|