@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.
- package/docs/ARCHITECTURE.md +25 -8
- package/docs/SECURITY_REVIEW.md +4 -4
- package/docs/THIRD_PARTY_NOTICES.md +3 -0
- package/package.json +1 -1
- package/src/agentLoop.js +99 -19
- package/src/browser/{camofoxRuntime.js → browserRuntime.js} +10 -15
- package/src/browser/{camofoxRuntime.ts → browserRuntime.ts} +11 -16
- package/src/browser/causyneBrowser.js +6 -6
- package/src/browser/causyneBrowser.ts +9 -9
- package/src/cliMode.js +13 -1
- package/src/decisionPolicy.js +68 -0
- package/src/decisionPolicy.ts +106 -0
- package/src/implementationPlan.js +50 -0
- package/src/implementationPlan.ts +59 -0
- package/src/provisioning/capabilityProvisioner.js +1 -1
- package/src/taskShape.js +1 -1
- package/src/taskShape.ts +1 -1
- package/src/toolPacks.js +7 -5
- package/src/tools/browserAutomator.js +1 -1
- package/src/tools/browserAutomator.ts +1 -1
- package/src/tools/manageLocalAiDependencies.js +1 -1
- package/src/tools/registry.js +4 -3
- package/src/tools/requestDecision.js +87 -0
- package/src/tools/requestDecision.ts +107 -0
- package/src/tools/updatePlan.js +37 -84
- package/src/tools/updatePlan.ts +48 -0
- package/src/tools/webFetch.js +1 -1
- package/src/tools/webFetch.ts +1 -1
- package/src/tools/webSearch.js +1 -1
- package/src/tools/webSearch.ts +1 -1
- package/src/tui/ask.js +41 -1
- package/src/tui/banner.js +389 -190
- package/src/tui/banner.ts +407 -0
- package/src/tui/chatRenderer.js +29 -1
- package/src/tui/dialogs.js +91 -0
- package/src/tui/prompt.js +32 -3
- package/src/tui/toolDisplay.js +0 -1
- package/src/tools/todoWrite.js +0 -75
|
@@ -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
|
@@ -211,6 +211,7 @@ export class ChatRenderer {
|
|
|
211
211
|
this.spinnerIndex = 0;
|
|
212
212
|
this.currentStatus = '';
|
|
213
213
|
this.pendingApprovals = new Map();
|
|
214
|
+
this.latestPlan = [];
|
|
214
215
|
this.status = new StatusLine();
|
|
215
216
|
this.turnStartedAt = null;
|
|
216
217
|
|
|
@@ -383,7 +384,7 @@ export class ChatRenderer {
|
|
|
383
384
|
// nineteen rows of chrome before the user could type. Both references open
|
|
384
385
|
// in eight or fewer, with the identity and the facts as a single object.
|
|
385
386
|
this.write();
|
|
386
|
-
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);
|
|
387
388
|
this.write();
|
|
388
389
|
|
|
389
390
|
const hints = [
|
|
@@ -548,9 +549,34 @@ export class ChatRenderer {
|
|
|
548
549
|
onAgentPlan(msg) {
|
|
549
550
|
if (!Array.isArray(msg.plan) || msg.plan.length === 0) return;
|
|
550
551
|
const plan = msg.plan.slice();
|
|
552
|
+
this.latestPlan = plan;
|
|
553
|
+
if (this.onPlanChange) this.onPlanChange(plan);
|
|
551
554
|
this.frame('plan', () => this.renderPlan(plan), { items: plan.length });
|
|
552
555
|
}
|
|
553
556
|
|
|
557
|
+
onDecisionRequest(msg) {
|
|
558
|
+
if (this.dialogWillAsk) return;
|
|
559
|
+
const request = msg.request || {};
|
|
560
|
+
this.frame('approval', () => {
|
|
561
|
+
this.write();
|
|
562
|
+
this.write(`${paint('accent', GLYPHS.arrow)} ${bold(safeDisplay(request.header || 'decision required'))}`);
|
|
563
|
+
for (const row of wrapBlock(safeDisplay(request.question || ''), Math.max(20, this.width() - 4))) this.write(` ${row}`);
|
|
564
|
+
this.write();
|
|
565
|
+
}, { kind: request.kind || 'other' });
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
onDecisionResult(msg) {
|
|
569
|
+
const request = msg.request || {};
|
|
570
|
+
const answer = msg.answer;
|
|
571
|
+
this.frame('approval', () => {
|
|
572
|
+
if (!answer) {
|
|
573
|
+
this.write(` ${paint('muted', 'decision dismissed')} ${safeDisplay(request.header || '')}`);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
this.write(` ${paint('success', GLYPHS.check)} ${bold(safeDisplay(answer.label || ''))}${answer.custom ? dim(' custom choice') : ''}`);
|
|
577
|
+
}, { kind: request.kind || 'other', answered: Boolean(answer) });
|
|
578
|
+
}
|
|
579
|
+
|
|
554
580
|
renderPlan(plan) {
|
|
555
581
|
const width = this.width();
|
|
556
582
|
this.write();
|
|
@@ -940,6 +966,8 @@ export class ChatRenderer {
|
|
|
940
966
|
case 'step_error': this.onStepError(msg); return true;
|
|
941
967
|
case 'checkpoint': this.onCheckpoint(msg); return true;
|
|
942
968
|
case 'approval_request': this.onApprovalRequest(msg); return true;
|
|
969
|
+
case 'decision_request': this.onDecisionRequest(msg); return true;
|
|
970
|
+
case 'decision_result': this.onDecisionResult(msg); return true;
|
|
943
971
|
case 'error': this.onError(msg); return true;
|
|
944
972
|
case 'budget_warning': this.onBudgetWarning(msg); return true;
|
|
945
973
|
case 'notice': this.onNotice(msg); return true;
|
package/src/tui/dialogs.js
CHANGED
|
@@ -489,6 +489,97 @@ export class ApprovalLayer extends Layer {
|
|
|
489
489
|
}
|
|
490
490
|
}
|
|
491
491
|
|
|
492
|
+
/** A structured product or architecture choice, separate from permissions. */
|
|
493
|
+
export class DecisionLayer extends Layer {
|
|
494
|
+
constructor(options = {}) {
|
|
495
|
+
super({ width: options.width || 82, height: options.height || 24, ...options });
|
|
496
|
+
this.request = options.request || {};
|
|
497
|
+
this.onAnswer = options.onAnswer || null;
|
|
498
|
+
this.options = Array.isArray(this.request.options) ? this.request.options.slice(0, 4) : [];
|
|
499
|
+
this.selected = 0;
|
|
500
|
+
this.customIndex = this.options.length;
|
|
501
|
+
this.customMode = false;
|
|
502
|
+
this.editor = new LineEditor();
|
|
503
|
+
this.answered = false;
|
|
504
|
+
this.title = safeDisplay(this.request.header || 'decision required');
|
|
505
|
+
this.footer = `↑↓ choose ${GLYPHS.dot} enter select ${GLYPHS.dot} esc cancel`;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
preferredHeight() {
|
|
509
|
+
const questionRows = wrap(safeDisplay(this.request.question || ''), Math.max(20, this.width - 6)).length;
|
|
510
|
+
const reasonRows = wrap(safeDisplay(this.request.reason || ''), Math.max(20, this.width - 6)).length;
|
|
511
|
+
return Math.min(this.height, questionRows + reasonRows + (this.options.length * 2) + 7);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
body(width, height) {
|
|
515
|
+
const rows = [];
|
|
516
|
+
rows.push(...wrap(bold(safeDisplay(this.request.question || 'Choose a direction')), width).slice(0, 4));
|
|
517
|
+
if (this.request.reason) rows.push(...wrap(dim(safeDisplay(this.request.reason)), width).slice(0, 3));
|
|
518
|
+
rows.push('');
|
|
519
|
+
|
|
520
|
+
for (const [index, option] of this.options.entries()) {
|
|
521
|
+
const selected = index === this.selected;
|
|
522
|
+
const marker = selected ? paint('accent', GLYPHS.arrow) : paint('muted', GLYPHS.dot);
|
|
523
|
+
rows.push(`${marker} ${selected ? bold(safeDisplay(option.label || '')) : safeDisplay(option.label || '')}`);
|
|
524
|
+
if (option.description) rows.push(` ${dim(truncate(safeDisplay(option.description), Math.max(1, width - 2), GLYPHS.ellipsis))}`);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const customSelected = this.selected === this.customIndex;
|
|
528
|
+
rows.push(`${customSelected ? paint('accent', GLYPHS.arrow) : paint('muted', GLYPHS.dot)} ${customSelected ? bold('Type your own answer') : 'Type your own answer'}`);
|
|
529
|
+
if (this.customMode) {
|
|
530
|
+
const value = this.editor.getText();
|
|
531
|
+
rows.push(`${paint('accent', `${GLYPHS.arrow} `)}${value || dim('Describe the direction you want')}`);
|
|
532
|
+
this.cursor = { x: 2 + visibleWidth(value.slice(0, this.editor.col)), y: rows.length - 1 };
|
|
533
|
+
this.footer = `enter submit ${GLYPHS.dot} esc choices`;
|
|
534
|
+
} else {
|
|
535
|
+
this.cursor = null;
|
|
536
|
+
this.footer = `↑↓ choose ${GLYPHS.dot} enter select ${GLYPHS.dot} esc cancel`;
|
|
537
|
+
}
|
|
538
|
+
return rows.slice(0, height);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
key(event) {
|
|
542
|
+
if (!event) return null;
|
|
543
|
+
if (this.customMode) {
|
|
544
|
+
if (event.name === 'escape') { this.customMode = false; return 'handled'; }
|
|
545
|
+
if (event.name === 'enter' || event.name === 'return') {
|
|
546
|
+
const label = this.editor.getText().replace(/\s+/g, ' ').trim();
|
|
547
|
+
if (!label) return 'handled';
|
|
548
|
+
this.answer({ requestId: this.request.id, optionId: null, label, custom: true });
|
|
549
|
+
return 'close';
|
|
550
|
+
}
|
|
551
|
+
if (event.name === 'backspace') { this.editor.backspace(); return 'handled'; }
|
|
552
|
+
if (event.name === 'delete') { this.editor.deleteForward(); return 'handled'; }
|
|
553
|
+
if (event.name === 'left') { this.editor.moveLeft(); return 'handled'; }
|
|
554
|
+
if (event.name === 'right') { this.editor.moveRight(); return 'handled'; }
|
|
555
|
+
if (event.name === 'home') { this.editor.moveHome(); return 'handled'; }
|
|
556
|
+
if (event.name === 'end') { this.editor.moveEnd(); return 'handled'; }
|
|
557
|
+
if (event.name === 'paste') { this.editor.insert(event.text); return 'handled'; }
|
|
558
|
+
if (event.name === 'char') { this.editor.insert(event.text); return 'handled'; }
|
|
559
|
+
return 'handled';
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (event.name === 'escape') { this.answer(null); return 'close'; }
|
|
563
|
+
const count = this.options.length + 1;
|
|
564
|
+
if (event.name === 'up') { this.selected = (this.selected - 1 + count) % count; return 'handled'; }
|
|
565
|
+
if (event.name === 'down') { this.selected = (this.selected + 1) % count; return 'handled'; }
|
|
566
|
+
if (event.name === 'enter' || event.name === 'return') {
|
|
567
|
+
if (this.selected === this.customIndex) { this.customMode = true; return 'handled'; }
|
|
568
|
+
const option = this.options[this.selected];
|
|
569
|
+
if (!option) return 'handled';
|
|
570
|
+
this.answer({ requestId: this.request.id, optionId: option.id, label: option.label, custom: false });
|
|
571
|
+
return 'close';
|
|
572
|
+
}
|
|
573
|
+
return 'handled';
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
answer(value = null) {
|
|
577
|
+
if (this.answered) return;
|
|
578
|
+
this.answered = true;
|
|
579
|
+
if (this.onAnswer) this.onAnswer(value);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
492
583
|
/** A yes/no confirm, for destructive commands that are not tool approvals. */
|
|
493
584
|
export class ConfirmLayer extends Layer {
|
|
494
585
|
constructor(options = {}) {
|
package/src/tui/prompt.js
CHANGED
|
@@ -26,7 +26,7 @@ import { barBlock, GUTTER } from './block.js';
|
|
|
26
26
|
import { visibleWidth, truncate, wrapBlock, shortenHome, safeDisplay } from './text.js';
|
|
27
27
|
import { pickExample, fitHints } from './banner.js';
|
|
28
28
|
import { panelRows } from './overlay.js';
|
|
29
|
-
import { commandPalette, skillPicker, ApprovalLayer, ConfirmLayer, HomeLayer, TextLayer } from './dialogs.js';
|
|
29
|
+
import { commandPalette, skillPicker, ApprovalLayer, DecisionLayer, ConfirmLayer, HomeLayer, TextLayer } from './dialogs.js';
|
|
30
30
|
import { describeTool } from './toolDisplay.js';
|
|
31
31
|
|
|
32
32
|
const CTRL_C_WINDOW_MS = 2000;
|
|
@@ -264,6 +264,7 @@ export class ChatPrompt {
|
|
|
264
264
|
this.statusMessage = message;
|
|
265
265
|
this.scheduleDraw();
|
|
266
266
|
};
|
|
267
|
+
renderer.onPlanChange = () => this.scheduleDraw();
|
|
267
268
|
}
|
|
268
269
|
|
|
269
270
|
/**
|
|
@@ -321,6 +322,26 @@ export class ChatPrompt {
|
|
|
321
322
|
return visibleWidth(full) > width ? truncate(full, width, GLYPHS.ellipsis) : full;
|
|
322
323
|
}
|
|
323
324
|
|
|
325
|
+
/** Persistent checklist shown outside the chat transcript. */
|
|
326
|
+
planRows(width) {
|
|
327
|
+
const plan = this.renderer && Array.isArray(this.renderer.latestPlan) ? this.renderer.latestPlan : [];
|
|
328
|
+
if (plan.length === 0) return [];
|
|
329
|
+
const completed = plan.filter(item => item.status === 'completed').length;
|
|
330
|
+
const rows = [`${bold(paint('accent', 'implementation plan'))} ${dim(`${completed}/${plan.length}`)}`];
|
|
331
|
+
const limit = Math.min(plan.length, 5);
|
|
332
|
+
for (const item of plan.slice(0, limit)) {
|
|
333
|
+
const mark = item.status === 'completed'
|
|
334
|
+
? paint('success', GLYPHS.check)
|
|
335
|
+
: item.status === 'in_progress'
|
|
336
|
+
? paint('accent', GLYPHS.arrow)
|
|
337
|
+
: paint('muted', GLYPHS.dot);
|
|
338
|
+
rows.push(` ${mark} ${truncate(safeDisplay(item.text || item.step || ''), Math.max(8, width - 5), GLYPHS.ellipsis)}`);
|
|
339
|
+
}
|
|
340
|
+
if (plan.length > limit) rows.push(` ${dim(`${GLYPHS.ellipsis} ${plan.length - limit} more`)}`);
|
|
341
|
+
rows.push('');
|
|
342
|
+
return rows;
|
|
343
|
+
}
|
|
344
|
+
|
|
324
345
|
startTick() {
|
|
325
346
|
if (this.tickTimer) return;
|
|
326
347
|
this.tickTimer = setInterval(() => {
|
|
@@ -456,6 +477,13 @@ export class ChatPrompt {
|
|
|
456
477
|
});
|
|
457
478
|
}
|
|
458
479
|
|
|
480
|
+
/** Ask for a durable implementation choice, with a free-form option. */
|
|
481
|
+
decide(request = {}) {
|
|
482
|
+
return new Promise((resolve) => {
|
|
483
|
+
this.openLayer(new DecisionLayer({ request, onAnswer: resolve }));
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
459
487
|
/** A plain yes/no, for the questions that are not tool approvals. */
|
|
460
488
|
confirm(message, options = {}) {
|
|
461
489
|
return new Promise((resolve) => {
|
|
@@ -1136,12 +1164,13 @@ export class ChatPrompt {
|
|
|
1136
1164
|
const status = this.statusRow(width);
|
|
1137
1165
|
const gap = status.length > 0 ? new Array(FOOTER_GAP).fill('') : [];
|
|
1138
1166
|
|
|
1167
|
+
const planRows = this.planRows(width);
|
|
1139
1168
|
const all = this.clampRows(
|
|
1140
|
-
|
|
1169
|
+
planRows.concat(boxRows, pickerRows, noticeRows, gap, status), width
|
|
1141
1170
|
);
|
|
1142
1171
|
|
|
1143
1172
|
// Park the cursor where the user is typing, not at the end of the picker.
|
|
1144
|
-
this.present(all, caretRow + padTop, layout.cursorCol + inset);
|
|
1173
|
+
this.present(all, planRows.length + caretRow + padTop, layout.cursorCol + inset);
|
|
1145
1174
|
}
|
|
1146
1175
|
|
|
1147
1176
|
/**
|
package/src/tui/toolDisplay.js
CHANGED
|
@@ -82,7 +82,6 @@ const TOOL_DISPLAY = new Map([
|
|
|
82
82
|
['code_verify_syntax', (a) => ({ verb: 'verify', detail: pathDetail(firstString(a, ['path', 'file_path'])) })],
|
|
83
83
|
|
|
84
84
|
['memory_write', (a) => ({ verb: 'remember', detail: firstString(a, ['fact', 'key', 'project']) })],
|
|
85
|
-
['TodoWrite', (a) => ({ verb: 'plan', detail: countDetail(a.todos, 'item') })],
|
|
86
85
|
['update_plan', (a) => ({ verb: 'plan', detail: countDetail(a.plan, 'step') })],
|
|
87
86
|
['tool_search', (a) => ({ verb: 'discover', detail: firstString(a, ['query', 'description']) })],
|
|
88
87
|
['fs_read_skill', (a) => ({ verb: 'skill', detail: firstString(a, ['id', 'skillId', 'domain']) })],
|