@causyne/agent 1.10.18 → 1.10.20

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 (39) hide show
  1. package/docs/ARCHITECTURE.md +20 -0
  2. package/native-skills/media/creative-studio-pipeline/SKILL.md +52 -0
  3. package/native-skills/media/photo-studio-automation/SKILL.md +53 -0
  4. package/native-skills/media/video-timeline-assembly/SKILL.md +52 -0
  5. package/package.json +1 -1
  6. package/src/agentLoop.js +4 -0
  7. package/src/appChannel.js +7 -3
  8. package/src/cliMode.js +33 -67
  9. package/src/commands.js +56 -8
  10. package/src/compiledSkills.js +15 -0
  11. package/src/config.js +80 -11
  12. package/src/creative/apps.js +69 -0
  13. package/src/creative/apps.ts +58 -0
  14. package/src/creative/blenderBatch.js +233 -0
  15. package/src/creative/blenderBatch.ts +217 -0
  16. package/src/creative/pipeline.js +56 -0
  17. package/src/creative/pipeline.ts +82 -0
  18. package/src/creative/studioScripts.js +374 -0
  19. package/src/creative/studioScripts.ts +378 -0
  20. package/src/decisionPolicy.js +2 -0
  21. package/src/decisionPolicy.ts +1 -0
  22. package/src/dynamicCatalogue.js +270 -0
  23. package/src/dynamicCatalogue.ts +283 -0
  24. package/src/entrypoint.js +2 -1
  25. package/src/modelCatalogue.d.ts +5 -0
  26. package/src/modelCatalogue.js +49 -18
  27. package/src/providers/anthropic.js +4 -0
  28. package/src/sandbox.js +4 -0
  29. package/src/toolPacks.js +1 -0
  30. package/src/tools/creativeApps.js +106 -0
  31. package/src/tools/creativeApps.ts +99 -0
  32. package/src/tools/registry.js +2 -1
  33. package/src/tui/banner.js +391 -190
  34. package/src/tui/banner.ts +409 -0
  35. package/src/tui/chatRenderer.js +1 -1
  36. package/src/tui/dialogs.js +10 -0
  37. package/src/tui/home.js +29 -72
  38. package/src/tui/setup.js +104 -0
  39. package/src/tui/setup.ts +98 -0
@@ -0,0 +1,409 @@
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 providerModel = [info.provider, info.model].filter(Boolean).join('/');
244
+ const providerPart = providerModel || 'select provider';
245
+ const parts = [info.version ? 'v' + String(info.version).replace(/^v/, '') : '', providerPart, info.mode ?? '', info.cwd ?? ''].filter(Boolean);
246
+ const fits = (): boolean => parts.join(sep).length <= maxw;
247
+ if (!fits() && info.cwd) {
248
+ const others = parts.slice(0, -1).join(sep).length + (parts.length > 1 ? sep.length : 0);
249
+ parts[parts.length - 1] = shortPath(info.cwd, Math.max(8, maxw - others));
250
+ }
251
+ while (!fits() && parts.length > 1) parts.pop();
252
+ return parts.join(sep);
253
+ }
254
+
255
+ // ---- Layouts --------------------------------------------------------------------------------------------
256
+ function layoutFull(caps: Caps, info: BannerInfo, active: number): string[] {
257
+ const R = LOGO_L.length / 2, logo = cells(pixLogo(LOGO_L, active), caps), gap = ' ';
258
+ const right = blank(48, R * 2);
259
+ blit(right, pixText('CAUSYNE', F7, 7, 1, 'white'), 0, 2); // rows 1-4
260
+ blit(right, pixText('AGENT', F5, 5, 2, 'lime'), 0, 12); // rows 6-8
261
+ // Keep the metadata inside the wordmark's 48-pixel measure. Letting a long
262
+ // workspace consume every spare terminal column made the mark look detached
263
+ // from its own status line and prevented the prompt from widening once a
264
+ // session began.
265
+ const maxw = Math.min(48, caps.width - 2 - (LOGO_L[0]?.length ?? 0) - gap.length);
266
+ return cells(right, caps).map((tail, r) => {
267
+ if (r === R - 2) tail = txt(TAGLINE, 'dim', caps, maxw);
268
+ if (r === R - 1) tail = txt(statusLine(info, maxw, caps.unicode), 'dim', caps, maxw);
269
+ return ' ' + (logo[r] ?? '') + gap + tail;
270
+ });
271
+ }
272
+ function layoutCompact(caps: Caps, info: BannerInfo, active: number): string[] {
273
+ const logo = cells(pixLogo(LOGO_S, active), caps), gap = ' ';
274
+ const maxw = Math.max(10, caps.width - 2 - (LOGO_S[0]?.length ?? 0) - gap.length), wide = maxw >= 24;
275
+ return logo.map((l, r) => {
276
+ let tail = '';
277
+ 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);
278
+ else if (r === 4) tail = txt(TAGLINE, 'dim', caps, maxw);
279
+ else if (r === 6) tail = txt(statusLine(info, maxw, caps.unicode), 'dim', caps, maxw);
280
+ return ' ' + l + (tail ? gap + tail : '');
281
+ });
282
+ }
283
+ function layoutMini(caps: Caps, info: BannerInfo): string[] {
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) line += txt(ver, 'dim', caps);
287
+ return [' ' + line];
288
+ }
289
+
290
+ // ---- Public API -----------------------------------------------------------------------------------------
291
+ /** Returns the banner as a string (no trailing newline). */
292
+ export function renderBanner(o: BannerOptions = {}, stream: TermStream = process.stdout): string {
293
+ const caps = detect(o, stream), active = o.active ?? 0;
294
+ if (caps.variant === 'full' && !caps.unicode) caps.variant = 'compact';
295
+ const lines = caps.variant === 'mini' ? layoutMini(caps, o) : caps.variant === 'compact' ? layoutCompact(caps, o, active) : layoutFull(caps, o, active);
296
+ return lines.join('\n');
297
+ }
298
+
299
+ /**
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.
303
+ */
304
+ export function wordmark(width = 80, options: Pick<BannerOptions, 'unicode' | 'color' | 'height'> = {}): string[] {
305
+ return renderBanner({ ...options, width, height: options.height ?? 24 }, process.stdout).split('\n');
306
+ }
307
+
308
+ /** A single display spelling for configured providers. */
309
+ export function providerLabel(provider: unknown): string {
310
+ const raw = String(provider ?? '').trim();
311
+ if (!raw) return '';
312
+ const known: Record<string, string> = {
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
+
321
+ function sessionInfo(info: BannerInfo): BannerInfo {
322
+ const provider = providerLabel(info.provider);
323
+ const modeParts = [
324
+ info.autonomyMode ? `mode ${info.autonomyMode}` : info.mode,
325
+ info.tier ? `tier ${info.tier}` : undefined
326
+ ].filter((part): part is string => Boolean(part));
327
+ return {
328
+ ...(info.version ? { version: info.version } : {}),
329
+ ...(provider ? { provider } : {}),
330
+ ...(info.model ? { model: info.model } : {}),
331
+ ...(modeParts.length ? { mode: modeParts.join(' / ') } : {}),
332
+ ...(info.workspace || info.cwd ? { cwd: info.workspace ?? info.cwd } : {})
333
+ };
334
+ }
335
+
336
+ /** Render the adaptive session identity block used by ChatRenderer. */
337
+ export function sessionBanner(
338
+ info: BannerInfo = {},
339
+ width = 80,
340
+ options: Pick<BannerOptions, 'unicode' | 'color' | 'height'> = {}
341
+ ): string[] {
342
+ const height = options.height ?? process.stdout.rows ?? 24;
343
+ // The chat surface also needs room for its input band and hint row. The
344
+ // standalone renderer keeps the source design's 80x24 full-banner threshold,
345
+ // while the integrated session header chooses the same artwork at a size that
346
+ // cannot push the first prompt off short terminals.
347
+ const variant: Variant = height < 22 || width < 44
348
+ ? 'mini'
349
+ : width < 96 || height < 28 || options.unicode === false
350
+ ? 'compact'
351
+ : 'full';
352
+ return renderBanner({
353
+ ...sessionInfo(info),
354
+ ...options,
355
+ variant,
356
+ width,
357
+ height
358
+ }, process.stdout).split('\n');
359
+ }
360
+
361
+ /** Prints the banner. Returns false (prints nothing) when stdout is not a TTY, unless `force` is set. */
362
+ export function printBanner(o: BannerOptions = {}, stream: TermStream = process.stdout): boolean {
363
+ if (!stream.isTTY && !o.force) return false;
364
+ stream.write(renderBanner(o, stream) + '\n');
365
+ return true;
366
+ }
367
+ const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
368
+
369
+ /** Optional standalone boot animation. The main chat TUI deliberately writes once to preserve scrollback. */
370
+ export async function animateBanner(o: BannerOptions = {}, stream: TermStream = process.stdout): Promise<boolean> {
371
+ const caps = detect(o, stream), env = process.env;
372
+ 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);
373
+ const frames = [0, 1, 2, 3, 4, 5, 0].map((active) => renderBanner({ ...o, active }, stream).split('\n'));
374
+ const first = frames[0] ?? [], count = first.length, show = (): void => { stream.write('\x1b[?25h'); };
375
+ process.once('exit', show);
376
+ try {
377
+ stream.write('\x1b[?25l' + first.join('\n') + '\n');
378
+ for (const frame of frames.slice(1)) {
379
+ await sleep(o.frameMs ?? 70);
380
+ stream.write(`\x1b[${count}A` + frame.map((line) => line + '\x1b[K').join('\n') + '\n');
381
+ }
382
+ } finally {
383
+ show();
384
+ process.removeListener('exit', show);
385
+ }
386
+ return true;
387
+ }
388
+
389
+ export const EXAMPLES = [
390
+ 'summarise what changed in this repo today',
391
+ 'find every TODO left in src/ and group them by file',
392
+ 'explain what this project does and how it is wired',
393
+ 'run the tests and tell me what broke',
394
+ 'review my last commit for anything risky'
395
+ ] as const;
396
+
397
+ /** One example, chosen once per session rather than once per keystroke. */
398
+ export function pickExample(): string {
399
+ return EXAMPLES[Math.floor(Math.random() * EXAMPLES.length)] ?? EXAMPLES[0];
400
+ }
401
+
402
+ /** Drop complete hint fragments until the remaining line fits. */
403
+ export function fitHints(parts: string[], width: number, fallback: string): string {
404
+ const separator = ' · ';
405
+ const kept = parts.slice();
406
+ while (kept.length > 1 && kept.join(separator).length > width) kept.pop();
407
+ const line = kept.join(separator);
408
+ return line.length <= width ? line : fallback;
409
+ }
@@ -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 = [
@@ -38,9 +38,17 @@ export class SelectLayer extends Layer {
38
38
  emptyText: options.emptyText
39
39
  });
40
40
  this.onPick = options.onPick || null;
41
+ this.settled = false;
41
42
  this.footer = options.footer || dialogHint();
42
43
  }
43
44
 
45
+ /** Dismissal must resolve pending picker flows, just like text inputs. */
46
+ answer() {
47
+ if (this.settled) return;
48
+ this.settled = true;
49
+ if (this.onPick) this.onPick(null);
50
+ }
51
+
44
52
  preferredHeight() {
45
53
  // filter row + rule + one row per item (+ its category header) + frame.
46
54
  const categories = new Set(this.list.filtered.map((entry) => entry.item.category).filter(Boolean));
@@ -66,7 +74,9 @@ export class SelectLayer extends Layer {
66
74
 
67
75
  if (action === 'select') {
68
76
  const item = this.list.current;
77
+ this.settled = true;
69
78
  const result = this.onPick ? this.onPick(item) : null;
79
+ if (result === 'keep') this.settled = false;
70
80
  return result === 'keep' ? 'handled' : 'close';
71
81
  }
72
82
  if (action === 'cancel') return 'close';
package/src/tui/home.js CHANGED
@@ -2,15 +2,21 @@
2
2
 
3
3
  import { SelectLayer, InputLayer } from './dialogs.js';
4
4
  import { paint, dim, bold, badge, GLYPHS } from './theme.js';
5
+ import { runSetup } from './setup.js';
5
6
  import { DEFAULT_MODEL_BY_PROVIDER } from '../modelCatalogue.js';
6
7
 
7
8
  /** Local runtime and provider summary. */
8
9
  export function homeStatusLine({ config = {} } = {}) {
9
- const model = config.model ? `${config.provider || 'default'}/${config.model}` : (config.provider || 'no provider');
10
- const configured = config.apiKey || config.provider === 'ollama';
10
+ const hasProvider = Boolean(config.provider && config.provider !== 'unconfigured');
11
+ const model = hasProvider
12
+ ? (config.model ? `${config.provider}/${config.model}` : config.provider)
13
+ : 'no provider configured';
14
+ const configured = Boolean((hasProvider && config.apiKey) || config.provider === 'ollama');
11
15
  return ' ' + [
12
16
  paint('success', `${GLYPHS.bullet} local desktop`),
13
- configured ? dim(model) : paint('warn', `${model} — no key`)
17
+ configured
18
+ ? dim(model)
19
+ : (hasProvider ? paint('warn', `${model} — no key`) : paint('warn', 'no provider configured — run /setup'))
14
20
  ].join(dim(` ${GLYPHS.dot} `));
15
21
  }
16
22
 
@@ -41,7 +47,7 @@ export const PRIMARY_ACTIONS = [
41
47
  kind: 'stay',
42
48
  run: async () => (await import('../reporting.js')).buildUsageText()
43
49
  },
44
- { id: 'config', label: 'config', hint: 'provider, model, API key', kind: 'config' },
50
+ { id: 'config', label: 'setup', hint: 'provider · endpoint · model · permissions', kind: 'config' },
45
51
  { id: 'more', label: 'more…', hint: 'sessions · model · approvals · tools', kind: 'more' }
46
52
  ];
47
53
 
@@ -70,6 +76,13 @@ export const MORE_ACTIONS = [
70
76
  // be the one path around the gate.
71
77
  command: '/undo'
72
78
  },
79
+ {
80
+ id: 'creative', label: 'creative apps', category: 'tools', hint: 'check installed apps and adapter readiness', kind: 'stay',
81
+ run: async () => {
82
+ const { creativeAppStatus } = await import('../creative/apps.js');
83
+ return (await creativeAppStatus()).map(app => `${app.label}: ${app.available ? 'available' : 'not connected'}\n ${app.detail}`).join('\n');
84
+ }
85
+ },
73
86
  { id: 'mcp', label: 'mcp', category: 'tools', hint: 'manage MCP servers', kind: 'mcp' },
74
87
  { id: 'help', label: 'commands', category: 'tools', hint: 'show every slash command', kind: 'command', command: '/help' },
75
88
  {
@@ -111,16 +124,8 @@ export function homeHint() {
111
124
  export { badge };
112
125
 
113
126
  // ---------------------------------------------------------------------------
114
- // The two flows that need a keyboard
127
+ // Provider setup and MCP navigation
115
128
  //
116
- // Both of these used inquirer, which takes stdin while ChatPrompt is already
117
- // holding it in raw mode — the two contend and the question lands on a
118
- // half-drawn prompt. Inside home there IS a live region and a modal stack, so
119
- // they can be asked properly. `causyne config` invoked on its own still uses the
120
- // inquirer wizard, because standalone there is no live region to draw into and
121
- // building a second one is how the deleted alt-screen shell started.
122
- // ---------------------------------------------------------------------------
123
-
124
129
  /** Provider choices for local CLI configuration. */
125
130
  // Suggested models come from src/modelCatalogue.js rather than being repeated
126
131
  // here. This list had drifted to `gpt-4o` and `gemini-2.5-pro` — two
@@ -140,65 +145,17 @@ export const PROVIDERS = [
140
145
  { id: 'ollama', label: 'Ollama', hint: 'local / offline' }
141
146
  ].map((entry) => ({ ...entry, model: DEFAULT_MODEL_BY_PROVIDER[entry.id] ?? '' }));
142
147
 
143
- /**
144
- * Ask for provider, then model, then key — each in the prompt's own panel.
145
- *
146
- * Resolves when the last answer is in, or when any step is dismissed. Dismissing
147
- * cancels the whole flow rather than saving a half-configuration, because a
148
- * provider saved without its key leaves the agent in a state that fails on the
149
- * next turn with an error about credentials rather than about the cancel.
150
- */
151
- export function configureInPrompt(prompt, { loadConfig, saveConfig }) {
152
- return new Promise((resolve) => {
153
- const config = loadConfig();
154
-
155
- prompt.openLayer(new SelectLayer({
156
- title: 'provider',
157
- items: PROVIDERS.map((p) => ({ id: p.id, label: p.label, description: p.hint, value: p })),
158
- onPick: (item) => {
159
- if (!item) { resolve(null); return; }
160
- const provider = item.value;
161
- askModel(provider);
162
- }
163
- }));
164
-
165
- function askModel(provider) {
166
- const suggested = (config.provider === provider.id && config.model) ? config.model : provider.model;
167
- prompt.openLayer(new InputLayer({
168
- title: `model for ${provider.label}`,
169
- lines: [
170
- 'Model ids change often. Type the one you want, or accept the suggestion.'
171
- ],
172
- value: suggested,
173
- placeholder: 'model id',
174
- onSubmit: (model) => askKey(provider, (model || '').trim() || suggested),
175
- onCancel: () => resolve(null)
176
- }));
177
- }
178
-
179
- function askKey(provider, model) {
180
- prompt.openLayer(new InputLayer({
181
- title: 'API key',
182
- lines: [
183
- `${provider.label} ${GLYPHS.arrow} ${model}`,
184
- '',
185
- 'Stored encrypted in ~/.causyne/config.json. It never leaves this machine.'
186
- ],
187
- mask: true,
188
- value: '',
189
- placeholder: 'paste your key (blank keeps the current one)',
190
- onSubmit: (apiKey) => {
191
- const next = loadConfig();
192
- next.provider = provider.id;
193
- next.model = model;
194
- if (apiKey) next.apiKey = apiKey;
195
- saveConfig(next);
196
- resolve({ provider: provider.id, model });
197
- },
198
- onCancel: () => resolve(null)
199
- }));
200
- }
201
- });
148
+ /** Run the shared setup transaction inside the existing prompt. */
149
+ export function configureInPrompt(prompt, store) {
150
+ return runSetup({
151
+ select: (options) => new Promise((resolve) => {
152
+ const layer = new SelectLayer({ ...options, onPick: resolve });
153
+ prompt.openLayer(layer);
154
+ }),
155
+ text: (options) => new Promise((resolve) => {
156
+ prompt.openLayer(new InputLayer({ ...options, onSubmit: resolve, onCancel: () => resolve(null) }));
157
+ })
158
+ }, PROVIDERS, store);
202
159
  }
203
160
 
204
161
  /** The MCP server list, as something you can act on rather than read. */