@causyne/agent 1.10.17 → 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.
Files changed (38) hide show
  1. package/docs/ARCHITECTURE.md +25 -8
  2. package/docs/SECURITY_REVIEW.md +4 -4
  3. package/docs/THIRD_PARTY_NOTICES.md +3 -0
  4. package/package.json +1 -1
  5. package/src/agentLoop.js +99 -19
  6. package/src/browser/{camofoxRuntime.js → browserRuntime.js} +10 -15
  7. package/src/browser/{camofoxRuntime.ts → browserRuntime.ts} +11 -16
  8. package/src/browser/causyneBrowser.js +6 -6
  9. package/src/browser/causyneBrowser.ts +9 -9
  10. package/src/cliMode.js +13 -1
  11. package/src/decisionPolicy.js +68 -0
  12. package/src/decisionPolicy.ts +106 -0
  13. package/src/implementationPlan.js +50 -0
  14. package/src/implementationPlan.ts +59 -0
  15. package/src/provisioning/capabilityProvisioner.js +1 -1
  16. package/src/taskShape.js +1 -1
  17. package/src/taskShape.ts +1 -1
  18. package/src/toolPacks.js +7 -5
  19. package/src/tools/browserAutomator.js +1 -1
  20. package/src/tools/browserAutomator.ts +1 -1
  21. package/src/tools/manageLocalAiDependencies.js +1 -1
  22. package/src/tools/registry.js +4 -3
  23. package/src/tools/requestDecision.js +87 -0
  24. package/src/tools/requestDecision.ts +107 -0
  25. package/src/tools/updatePlan.js +37 -84
  26. package/src/tools/updatePlan.ts +48 -0
  27. package/src/tools/webFetch.js +1 -1
  28. package/src/tools/webFetch.ts +1 -1
  29. package/src/tools/webSearch.js +1 -1
  30. package/src/tools/webSearch.ts +1 -1
  31. package/src/tui/ask.js +41 -1
  32. package/src/tui/banner.js +389 -190
  33. package/src/tui/banner.ts +407 -0
  34. package/src/tui/chatRenderer.js +29 -1
  35. package/src/tui/dialogs.js +91 -0
  36. package/src/tui/prompt.js +32 -3
  37. package/src/tui/toolDisplay.js +0 -1
  38. package/src/tools/todoWrite.js +0 -75
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
- * The banner rows.
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
- * `unicode` is overridable so both branches can actually be tested — under
69
- * `node --test` there is no TTY, so HAS_UNICODE is false and the block art would
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
- export function wordmark(width = 80, { unicode = HAS_UNICODE } = {}) {
73
- const rows = brandArt(width, unicode);
74
- rows.push('');
75
- rows.push(dim('Causyne Agent'));
76
- return rows;
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
- * Display the provider the way the user wrote it, normalised for casing only.
151
- *
152
- * Causyne is BYOK: the provider is whatever the user configured, and nothing here
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
- const raw = String(provider || '').trim();
158
- if (!raw) return '';
159
- const KNOWN = {
160
- openai: 'OpenAI', anthropic: 'Anthropic', gemini: 'Gemini', ollama: 'Ollama',
161
- deepseek: 'DeepSeek', dashscope: 'DashScope', 'dashscope-byok': 'DashScope',
162
- qwen: 'Qwen', xai: 'xAI', grok: 'xAI', moonshot: 'Moonshot', kimi: 'Moonshot',
163
- muse: 'Meta', meta: 'Meta',
164
- 'openai-compatible': 'OpenAI-compatible'
165
- };
166
- return KNOWN[raw.toLowerCase()] || raw;
167
- }
168
-
169
- // What to ask, for the person who has just opened this for the first time.
170
- //
171
- // `ask anything` is an invitation, not an example — it tells a new user nothing
172
- // about what this agent can actually be asked to do. Both references show a
173
- // concrete prompt instead (opencode seeds a random one per launch from
174
- // routes/home.tsx). One is picked per session so the hint does not churn while
175
- // the user is reading it.
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
- 'summarise what changed in this repo today',
178
- 'find every TODO left in src/ and group them by file',
179
- 'explain what this project does and how it is wired',
180
- 'run the tests and tell me what broke',
181
- 'review my last commit for anything risky'
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
- return EXAMPLES[Math.floor(Math.random() * EXAMPLES.length)];
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
- const separator = ` ${GLYPHS.dot} `;
198
- const kept = parts.slice();
199
- while (kept.length > 1 && visibleWidth(kept.join(separator)) > width) kept.pop();
200
- const line = kept.join(separator);
201
- // A narrow terminal must not get `try "review my last commit for anything r`.
202
- return visibleWidth(line) <= width ? line : fallback;
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
  }