aegiscode 5.2.33 → 6.1.0

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.
@@ -0,0 +1,286 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Overlay renderers — the "/" command palette, the alt+p model picker, the
5
+ * /effort picker and the /resume session list. Ported from
6
+ * `aegiscodex-dev/src/commands.js` (which mirrors live captures of Claude Code
7
+ * 2.1.228: a gray `─` rule, a lavender `❯` cursor, a blue selected row, a
8
+ * `⎿ Usage: /x hint` hook under the focused command and a `·`-separated footer).
9
+ *
10
+ * Every renderer here is PURE. It receives the data it draws — a command list, a
11
+ * model list, an item list, a selection index, a width and a height — and
12
+ * returns an array of span lines (`{ t, s, w }`, see screen.js). It imports no
13
+ * registry, no session state and no key handling; the session loop owns those.
14
+ * The palette's command list is the array the concurrent workstream owns:
15
+ * `{ name, aliases?, help|desc, args?, category?, hint?, tool?|local?|unavailable? }`.
16
+ *
17
+ * Filtering reuses `./fuzzy.js` (fuzzyRankWithAliases + fuzzyMatchPositions).
18
+ * The require is guarded: if that module is absent the palette falls back to a
19
+ * substring filter so this file still loads and `node --check` still passes.
20
+ */
21
+
22
+ const { span, padLine } = require('./screen.js');
23
+ const { C, BOLD, BOLD_OFF, GLYPH } = require('./theme.js');
24
+
25
+ // ── fuzzy.js (guarded require — a concurrent workstream owns it) ─────────────
26
+ let fuzzy = null;
27
+ try {
28
+ // eslint-disable-next-line global-require
29
+ fuzzy = require('./fuzzy.js');
30
+ } catch {
31
+ fuzzy = null;
32
+ }
33
+
34
+ /** Rank palette commands by query (aliases count, like the reference). */
35
+ function rankCommands(query, commands) {
36
+ if (fuzzy && typeof fuzzy.fuzzyRankWithAliases === 'function') {
37
+ return fuzzy.fuzzyRankWithAliases(query, commands, (c) => c.name, (c) => c.aliases || []);
38
+ }
39
+ // Fallback: plain case-insensitive substring across name + aliases.
40
+ const q = String(query || '').toLowerCase();
41
+ if (!q) return commands;
42
+ return commands.filter((c) =>
43
+ `${c.name} ${(c.aliases || []).join(' ')}`.toLowerCase().includes(q)
44
+ );
45
+ }
46
+
47
+ /** The short description a registry entry carries (help or desc). */
48
+ const descOf = (c) => c.help != null ? c.help : (c.desc != null ? c.desc : '');
49
+ /** The usage hint shown on the selected row's `⎿` hook (hint, else args). */
50
+ const hintOf = (c) => (c.hint != null ? c.hint : (c.args != null ? c.args : ''));
51
+
52
+ /** Pad (and, if needed, truncate) every line to `width` cells so an overlay can
53
+ * never paint wider than the terminal. */
54
+ function fit(lines, width) {
55
+ return lines.map((l) => padLine(l, width));
56
+ }
57
+
58
+ /** A full-width blank row. */
59
+ const blank = (width) => [span('', ' '.repeat(Math.max(0, width)))];
60
+
61
+ // Phase 10b (2.1.228 port): split a command name into spans that bold the
62
+ // characters which matched the query; the selected row reads blue.
63
+ function boldMatched(query, name, colour) {
64
+ const pos = fuzzy && typeof fuzzy.fuzzyMatchPositions === 'function'
65
+ ? fuzzy.fuzzyMatchPositions(query, name)
66
+ : [];
67
+ if (!pos || !pos.length) return [span(colour, name)];
68
+ const segs = [];
69
+ let last = 0;
70
+ for (const p of pos) {
71
+ if (p > last) segs.push(span(colour, name.slice(last, p)));
72
+ segs.push(span(colour + BOLD, name[p]));
73
+ last = p + 1;
74
+ }
75
+ if (last < name.length) segs.push(span(colour, name.slice(last)));
76
+ return segs;
77
+ }
78
+
79
+ // ── Palette overlay (/) ──────────────────────────────────────────────────────
80
+
81
+ /**
82
+ * @param {Array|{commands:Array, query?:string, sel?:number}} commands
83
+ * the registry command list (or a state object carrying it as `.commands`)
84
+ * @param {{query?:string, sel?:number}} [state]
85
+ * @param {number} [width]
86
+ * @param {number} [height]
87
+ */
88
+ function renderPalette(commands, state = {}, width = 80, height = 24) {
89
+ // Accept either `renderPalette(list, {query, sel}, w, h)` or the reference's
90
+ // `renderPalette({commands, query, sel}, w, h)`.
91
+ if (!Array.isArray(commands) && commands && typeof commands === 'object') {
92
+ state = commands;
93
+ commands = Array.isArray(commands.commands) ? commands.commands : [];
94
+ }
95
+ const list = rankCommands(state.query || '', commands || []);
96
+ const sel = Number.isInteger(state.sel) ? state.sel : 0;
97
+ const total = list.length;
98
+ const per = Math.max(1, Math.min(12, height - 8));
99
+ const start = Math.max(0, Math.min(sel - Math.floor(per / 2), Math.max(0, total - per)));
100
+ const visible = list.slice(start, start + per);
101
+
102
+ const lines = [];
103
+ lines.push(blank(width));
104
+ lines.push([span(C.gray, ' ' + '─'.repeat(Math.max(10, width - 4)))]);
105
+ for (let i = 0; i < visible.length; i++) {
106
+ const c = visible[i];
107
+ const idx = start + i;
108
+ const active = idx === sel;
109
+ const left = active ? span(C.lavender, GLYPH.cursor) : span('', ' ');
110
+ const row = active ? C.blue : C.white;
111
+ const name = [span(row, '/'), ...boldMatched(state.query || '', c.name, row)];
112
+ const pad = ' '.repeat(Math.max(1, 22 - [...c.name].length));
113
+ lines.push([
114
+ span('', ' '),
115
+ left,
116
+ span('', ' '),
117
+ ...name,
118
+ span(c.unavailable ? C.dim : C.gray, pad + descOf(c)),
119
+ ]);
120
+ if (active && hintOf(c)) {
121
+ lines.push([span('', ' '), span(C.gray, `${GLYPH.hook} Usage: /${c.name} ${hintOf(c)}`)]);
122
+ }
123
+ }
124
+ while (lines.length < 4 + per * 2) lines.push(blank(width));
125
+ lines.push([span(C.gray, ' ' + '─'.repeat(Math.max(10, width - 4)))]);
126
+ lines.push([
127
+ span(
128
+ C.gray,
129
+ ` type to filter ${GLYPH.bullet} enter to run ${GLYPH.bullet} esc to close`
130
+ ),
131
+ ]);
132
+ return fit(lines, width);
133
+ }
134
+
135
+ // ── Model picker overlay (alt+p) ─────────────────────────────────────────────
136
+
137
+ /**
138
+ * @param {Array} models entries ({id, label?, name?, note?, model?})
139
+ * @param {number} sel selected index
140
+ * @param {number} width
141
+ * @param {number} height
142
+ * @param {string|null} current the currently-pinned model id
143
+ */
144
+ function renderModelPicker(models, sel = 0, width = 80, height = 24, current = null) {
145
+ const lines = [];
146
+ lines.push(blank(width));
147
+ lines.push([span('', ' '), span(C.white + BOLD, 'Select model'), span(BOLD_OFF, '')]);
148
+ lines.push([span('', ' '), span(C.gray, 'Switch between models. Your pick becomes the default for new sessions.')]);
149
+ lines.push([span('', ' '), span(C.gray, 'Manage the list with /model add|remove — /model <id> switches directly.')]);
150
+ lines.push(blank(width));
151
+ for (let i = 0; i < models.length; i++) {
152
+ const m = models[i] || {};
153
+ const label = m.label || m.name || m.id || '';
154
+ const active = i === sel;
155
+ const cur = current != null && m.id === current;
156
+ const left = active ? span(C.lavender, GLYPH.cursor) : span('', ' ');
157
+ const num = span(C.gray, `${i + 1}.`);
158
+ const nameSpan = active ? span(C.lavender, label) : span(C.white, label);
159
+ const mark = cur ? span(C.green, ' ' + GLYPH.check) : span('', '');
160
+ const pad = ' '.repeat(Math.max(1, 22 - [...label].length));
161
+ lines.push([
162
+ span('', ' '),
163
+ left,
164
+ span('', ' '),
165
+ num,
166
+ span('', ' '),
167
+ nameSpan,
168
+ mark,
169
+ span(C.gray, pad + (m.note || m.model || '')),
170
+ ]);
171
+ }
172
+ lines.push(blank(width));
173
+ lines.push([span('', ' '), span(C.gray, `arrow keys to navigate ${GLYPH.bullet} enter to select ${GLYPH.bullet} esc to cancel`)]);
174
+ return fit(lines, width);
175
+ }
176
+
177
+ // ── Effort picker overlay (/effort) ──────────────────────────────────────────
178
+
179
+ const EFFORT_LEVELS = [
180
+ { label: 'Low', note: 'Fastest, most efficient' },
181
+ { label: 'Medium', note: 'Balanced' },
182
+ { label: 'High', note: 'Highest quality, slowest' },
183
+ ];
184
+
185
+ /**
186
+ * @param {number} sel selected index
187
+ * @param {number} width
188
+ * @param {string|null} current the currently-selected effort level
189
+ * @param {Array} [levels] override the level table
190
+ */
191
+ function renderEffortPicker(sel = 0, width = 80, current = null, levels = EFFORT_LEVELS) {
192
+ const lines = [];
193
+ lines.push(blank(width));
194
+ lines.push([span('', ' '), span(C.white + BOLD, 'Select effort'), span(BOLD_OFF, '')]);
195
+ lines.push([span('', ' '), span(C.gray, 'Controls how much reasoning the model puts into each turn.')]);
196
+ lines.push(blank(width));
197
+ for (let i = 0; i < levels.length; i++) {
198
+ const lv = levels[i];
199
+ const active = i === sel;
200
+ const cur = current != null && String(current).toLowerCase() === String(lv.label).toLowerCase();
201
+ const left = active ? span(C.lavender, GLYPH.cursor) : span('', ' ');
202
+ const nameSpan = active ? span(C.lavender, lv.label) : span(C.white, lv.label);
203
+ const mark = cur ? span(C.green, ' ' + GLYPH.check) : span('', '');
204
+ const pad = ' '.repeat(Math.max(1, 12 - [...lv.label].length));
205
+ lines.push([span('', ' '), left, span('', ' '), nameSpan, mark, span(C.gray, pad + lv.note)]);
206
+ }
207
+ lines.push(blank(width));
208
+ lines.push([span('', ' '), span(C.gray, `arrow keys to navigate ${GLYPH.bullet} enter to select ${GLYPH.bullet} esc to cancel`)]);
209
+ return fit(lines, width);
210
+ }
211
+
212
+ // ── Resume-list overlay (/resume) ────────────────────────────────────────────
213
+
214
+ /**
215
+ * @param {Array} items entries ({own?, summary?, cwd?, time?})
216
+ * @param {number} sel selected index
217
+ * @param {number} width
218
+ * @param {number} height
219
+ */
220
+ function renderResumeList(items, sel = 0, width = 80, height = 24) {
221
+ const list = Array.isArray(items) ? items : [];
222
+ const lines = [];
223
+ lines.push(blank(width));
224
+ lines.push([span('', ' '), span(C.gray, '─'.repeat(Math.max(10, width - 4)))]);
225
+ lines.push([
226
+ span('', ' '),
227
+ span(C.white + BOLD, 'Resume session'),
228
+ span(C.gray, ` (${sel + 1} of ${Math.max(1, list.length)})`),
229
+ span(BOLD_OFF, ''),
230
+ ]);
231
+ // search box
232
+ const boxW = Math.max(10, width - 4);
233
+ lines.push([span('', ' '), span(C.gray, '╭' + '─'.repeat(Math.max(0, boxW - 2)) + '╮')]);
234
+ lines.push([
235
+ span('', ' '),
236
+ span(C.gray, '│'),
237
+ span(C.white, '⌕ Search…'),
238
+ span('', ' '.repeat(Math.max(0, boxW - 9))),
239
+ span(C.gray, '│'),
240
+ ]);
241
+ lines.push([span('', ' '), span(C.gray, '╰' + '─'.repeat(Math.max(0, boxW - 2)) + '╯')]);
242
+ lines.push(blank(width));
243
+ const per = Math.max(2, Math.min(6, height - 16));
244
+ const start = Math.max(0, Math.min(sel - Math.floor(per / 2), Math.max(0, list.length - per)));
245
+ const visible = list.slice(start, start + per);
246
+ for (let i = 0; i < visible.length; i++) {
247
+ const it = visible[i] || {};
248
+ const idx = start + i;
249
+ const active = idx === sel;
250
+ const left = active ? span(C.lavender, GLYPH.cursor) : span('', ' ');
251
+ const tag = it.own ? span(C.green, GLYPH.star + ' ') : span(C.gray, GLYPH.bullet + ' ');
252
+ const title = active
253
+ ? span(C.white + BOLD, it.summary || '(untitled)')
254
+ : span(C.white, it.summary || '(untitled)');
255
+ lines.push([span('', ' '), left, span('', ' '), tag, title]);
256
+ lines.push([span('', ' '), span(C.gray, `${it.cwd || '~'} ${GLYPH.bullet} ${ago(it.time)}`)]);
257
+ }
258
+ while (lines.length < 6 + per * 2) lines.push(blank(width));
259
+ lines.push([span('', ' '), span(C.gray, '─'.repeat(Math.max(10, width - 4)))]);
260
+ lines.push([
261
+ span('', ' '),
262
+ span(
263
+ C.gray,
264
+ `Ctrl+A all projects ${GLYPH.bullet} Space to preview ${GLYPH.bullet} Type to search ${GLYPH.bullet} Esc to cancel`
265
+ ),
266
+ ]);
267
+ return fit(lines, width);
268
+ }
269
+
270
+ function ago(ts) {
271
+ if (!ts) return '';
272
+ const ms = Date.now() - new Date(String(ts)).getTime();
273
+ if (!(ms >= 0) || Number.isNaN(ms)) return '';
274
+ if (ms < 60_000) return `${Math.max(1, Math.round(ms / 1000))}s ago`;
275
+ if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m ago`;
276
+ if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h ago`;
277
+ return `${Math.round(ms / 86_400_000)}d ago`;
278
+ }
279
+
280
+ module.exports = {
281
+ boldMatched,
282
+ renderPalette,
283
+ renderModelPicker,
284
+ renderEffortPicker,
285
+ renderResumeList,
286
+ };
package/src/render.js ADDED
@@ -0,0 +1,396 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The CLI's renderers — the start-up banner, transcript turns, the per-turn
5
+ * accounting line, the status bar, the live working line and the transcript's
6
+ * markdown body. The presentation layer is a fidelity port of
7
+ * `aegiscodex-dev`: gold `━`/`─` rules, the two-tone welcome mark (gold mascot,
8
+ * blue/lavender/dim whale), the coral-bold welcome title, the `❯` prompt, the
9
+ * `⎿` hook rows, the `●` answer/stream cursor and the `✻` done glyph.
10
+ *
11
+ * Every function returns lines (strings carrying SGR escapes) rather than
12
+ * writing to stdout, so the whole surface is assertable from a plain Node test
13
+ * with no TTY and no child process — the same split the desktop uses for its
14
+ * pure renderer modules. The markdown body is delegated to `./markdown.js`
15
+ * (returning span lines); the require is guarded so this file always loads even
16
+ * if that module is absent, falling back to the built-in `mdLines`.
17
+ */
18
+
19
+ const {
20
+ RGB,
21
+ BG,
22
+ RESET,
23
+ BOLD,
24
+ DIM,
25
+ ITALIC,
26
+ UNDER,
27
+ RESET_FG,
28
+ RESET_BG,
29
+ GLYPH,
30
+ VERBS,
31
+ DONE_VERBS,
32
+ themeOf,
33
+ } = require('./theme.js');
34
+ const { WELCOME_TITLE, WELCOME_BACK, TAGLINE, welcomeArtParts } = require('./art.js');
35
+ const { w, pad, padStart, wrapBlock, clip, span } = require('./screen.js');
36
+ const { fmtTokens, fmtEur, fmtElapsed } = require('./format.js');
37
+
38
+ // Guarded: a concurrent workstream owns ./markdown.js.
39
+ let markdown = null;
40
+ try {
41
+ // eslint-disable-next-line global-require
42
+ markdown = require('./markdown.js');
43
+ } catch {
44
+ markdown = null;
45
+ }
46
+
47
+ /** Error mark — the reference's failed-check glyph (theme.js has no GLYPH.err). */
48
+ const ERR = '✗';
49
+ /** Warning mark — the reference's ⚠ (theme.js has no GLYPH.warn). */
50
+ const WARN = '⚠';
51
+
52
+ const fg = (t, c) => `${c}`;
53
+ const bg = (c) => BG(...c);
54
+
55
+ /** Flatten a span line back to an ANSI string (what the transcript emits). */
56
+ const spansToString = (line) => line.map((sp) => (sp.s || '') + sp.t).join('') + RESET;
57
+
58
+ /** Centre plain `text` inside `width`, painted with `style` (never overflows). */
59
+ function centerStyled(text, width, style) {
60
+ const tt = clip(text, width);
61
+ const p = Math.max(0, Math.floor((width - w(tt)) / 2));
62
+ return ' '.repeat(p) + style + tt + RESET;
63
+ }
64
+
65
+ /** The right (moon + whale) half of the mark: ▓ blue, ▒ lavender, ░ dim,
66
+ * eye white, stars gold. */
67
+ function tintWhale(str, t) {
68
+ const map = { '▓': t.blue, '▒': t.lavender, '░': t.dim, '█': t.white, '✦': t.gold, '·': t.dim };
69
+ const spans = [];
70
+ let cur = null;
71
+ let style = '';
72
+ for (const ch of str) {
73
+ const s = map[ch] != null ? map[ch] : '';
74
+ if (cur !== null && s === style) {
75
+ cur += ch;
76
+ } else {
77
+ if (cur !== null) spans.push(span(style, cur));
78
+ cur = ch;
79
+ style = s;
80
+ }
81
+ }
82
+ if (cur !== null) spans.push(span(style, cur));
83
+ return spans;
84
+ }
85
+
86
+ /** One centred welcome-art row: gold mascot on the left, tinted whale right. */
87
+ function artRow(ctx, row, parts, leftPad) {
88
+ const t = themeOf(ctx);
89
+ const left = row[0] || '';
90
+ const right = row[1] || '';
91
+ const spans = [span('', ' '.repeat(leftPad))];
92
+ if (left && right) {
93
+ spans.push(span(t.gold, left));
94
+ spans.push(span('', ' '.repeat(parts.gutter)));
95
+ spans.push(...tintWhale(right, t));
96
+ } else if (left) {
97
+ spans.push(span(t.gold, left));
98
+ if (w(left) < parts.width) spans.push(span('', ' '.repeat(parts.width - w(left))));
99
+ } else if (right) {
100
+ spans.push(...tintWhale(right, t));
101
+ if (w(right) < parts.width) spans.push(span('', ' '.repeat(parts.width - w(right))));
102
+ } else {
103
+ spans.push(span('', ' '.repeat(parts.width)));
104
+ }
105
+ return spansToString(spans);
106
+ }
107
+
108
+ /**
109
+ * The start-up banner, matching aegiscodex-dev's welcome legs: a gold `━`+`─`
110
+ * rule across the full terminal, the centred welcome mark (mascot half gold,
111
+ * moon/whale half blue/lavender/dim), the coral-bold welcome title and a
112
+ * version line, then the compact identity block.
113
+ */
114
+ function renderBanner(ctx, info = {}) {
115
+ const t = themeOf(ctx);
116
+ const width = Math.max(20, Number(info.width) || 80);
117
+ const lines = [];
118
+
119
+ // Full-width gold header rule.
120
+ lines.push(`${t.gold}━${'─'.repeat(Math.max(0, width - 2))}━${RESET}`);
121
+ lines.push('');
122
+
123
+ const parts = welcomeArtParts(width);
124
+ if (width >= parts.width + 2) {
125
+ const leftPad = Math.max(0, Math.floor((width - parts.width) / 2));
126
+ for (const row of parts.rows) lines.push(artRow(ctx, row, parts, leftPad));
127
+ } else {
128
+ // Too narrow for the mark: a compact gold wordmark keeps every row in width.
129
+ lines.push(centerStyled('AEGIS CODE', width, t.gold + BOLD));
130
+ }
131
+ lines.push('');
132
+
133
+ const title = info.firstRun === false ? WELCOME_BACK : WELCOME_TITLE;
134
+ lines.push(centerStyled(title, width, t.coral + BOLD));
135
+ const verLine = [info.version ? `v${info.version}` : null, TAGLINE].filter(Boolean).join(` ${GLYPH.bullet} `);
136
+ lines.push(centerStyled(verLine, width, t.gray));
137
+ lines.push('');
138
+
139
+ lines.push(...renderIdentityBox(ctx, info, width));
140
+ return lines;
141
+ }
142
+
143
+ /** The identity panel: what you are talking to, and as what. Routed through
144
+ * renderHeading so it shares the CLI's ruled-section look (no rounded frame). */
145
+ function renderIdentityBox(ctx, info = {}, width = 80) {
146
+ const t = themeOf(ctx);
147
+ const lines = [renderHeading(ctx, 'aegiscode', width)];
148
+
149
+ const rows = [
150
+ ['version', info.version ? `v${info.version}` : null],
151
+ ['model', info.model || 'server default'],
152
+ ['base', info.base || ''],
153
+ ['key', info.key || 'not set'],
154
+ ['render', info.stream === false ? 'buffered' : 'streaming'],
155
+ ].filter(([, v]) => v);
156
+
157
+ for (const [k, v] of rows) {
158
+ const value = clip(String(v), Math.max(0, width - 12));
159
+ lines.push(` ${t.gray}${k.padEnd(8)}${RESET}${t.white}${value}${RESET}`);
160
+ }
161
+ const hint = clip(` type /help for commands ${GLYPH.bullet} /quit to exit`, width);
162
+ lines.push('');
163
+ lines.push(`${t.dim}${hint}${RESET}`);
164
+ return lines;
165
+ }
166
+
167
+ /** Markdown-lite body (fallback when ./markdown.js is unavailable): fenced
168
+ * code, bullets, headings, inline code/bold. */
169
+ function mdLines(text, width, ctx) {
170
+ const t = themeOf(ctx);
171
+ const out = [];
172
+ let inFence = false;
173
+ const bodyW = Math.max(20, width - 4);
174
+
175
+ for (const raw of String(text == null ? '' : text).split('\n')) {
176
+ const fence = /^\s*```/.test(raw);
177
+ if (fence) {
178
+ inFence = !inFence;
179
+ continue;
180
+ }
181
+ if (inFence) {
182
+ for (const l of wrapBlock(raw, bodyW)) out.push(`${t.gray} ${l}${RESET}`);
183
+ continue;
184
+ }
185
+ if (raw.trim() === '') {
186
+ out.push('');
187
+ continue;
188
+ }
189
+ const bullet = /^(\s*)[-*]\s+(.*)$/.exec(raw);
190
+ if (bullet) {
191
+ const indent = ' '.repeat(Math.min(6, bullet[1].length));
192
+ for (const l of wrapBlock(inline(bullet[2], ctx), bodyW - 2)) {
193
+ out.push(`${indent}${t.gray}${GLYPH.bullet}${RESET} ${l}`);
194
+ }
195
+ continue;
196
+ }
197
+ const heading = /^(#{1,6})\s+(.*)$/.exec(raw);
198
+ if (heading) {
199
+ out.push(`${t.gold}${BOLD}${inline(heading[2], ctx)}${RESET}`);
200
+ continue;
201
+ }
202
+ for (const l of wrapBlock(inline(raw, ctx), bodyW, '')) out.push(l);
203
+ }
204
+ return out;
205
+ }
206
+
207
+ /** Inline span treatment: `code` in green, **bold** in bold. */
208
+ function inline(text, ctx) {
209
+ const t = themeOf(ctx);
210
+ return String(text == null ? '' : text)
211
+ .replace(/`([^`]+)`/g, (_, code) => `${t.green}${code}${RESET}`)
212
+ .replace(/\*\*([^*]+)\*\*/g, (_, bold) => `${BOLD}${bold}${RESET}`);
213
+ }
214
+
215
+ /** Assistant body: ./markdown.js span lines flattened to ANSI strings, else the
216
+ * plain mdLines fallback. */
217
+ function assistantLines(ctx, text, width) {
218
+ if (markdown && typeof markdown.renderMarkdown === 'function') {
219
+ return markdown.renderMarkdown(String(text == null ? '' : text), width, ctx).map(spansToString);
220
+ }
221
+ return mdLines(text, width, ctx);
222
+ }
223
+
224
+ /**
225
+ * One transcript turn: a `❯`-prefixed user line, an assistant answer whose
226
+ * first line carries the `●` marker (and a `●` cursor while streaming), a tool
227
+ * line with its `⎿ $ …` hook row, and each turn's accounting line beneath.
228
+ */
229
+ function renderTurn(ctx, turn, width = 80) {
230
+ const t = themeOf(ctx);
231
+ const role = turn.role || 'assistant';
232
+ const text = String(turn.text == null ? '' : turn.text);
233
+ const lines = [];
234
+
235
+ if (role === 'user') {
236
+ const body = wrapBlock(text, Math.max(8, width - 2));
237
+ body.forEach((l, i) => {
238
+ if (i === 0) lines.push(`${t.gray}${GLYPH.cursor}${RESET} ${t.white}${l}${RESET}`);
239
+ else lines.push(` ${t.white}${l}${RESET}`);
240
+ });
241
+ } else if (role === 'assistant' || role === 'system') {
242
+ const body = assistantLines(ctx, text, Math.max(8, width - 2));
243
+ if (!body.length) body.push('');
244
+ body[0] = `${t.white}${GLYPH.block}${RESET} ` + body[0];
245
+ if (turn.streaming) body[body.length - 1] += `${t.white}${GLYPH.block}${RESET}`;
246
+ lines.push(...body);
247
+ } else if (role === 'tool') {
248
+ lines.push(`${t.white}${GLYPH.block}${RESET} ${t.gray}${turn.label || 'tool'}${RESET}`);
249
+ const args =
250
+ turn.args == null
251
+ ? ''
252
+ : typeof turn.args === 'string'
253
+ ? turn.args
254
+ : JSON.stringify(turn.args);
255
+ if (args) lines.push(` ${t.gray}${GLYPH.hook} $ ${clip(args, Math.max(0, width - 6))}${RESET}`);
256
+ } else if (role === 'error') {
257
+ lines.push(`${t.red}${ERR} ${text}${RESET}`);
258
+ } else {
259
+ for (const l of wrapBlock(text, Math.max(8, width - 2))) lines.push(`${t.white}${l}${RESET}`);
260
+ }
261
+
262
+ if (turn.meta) {
263
+ const m = renderMeta(ctx, turn.meta, width);
264
+ if (m) lines.push(m);
265
+ }
266
+ return lines;
267
+ }
268
+
269
+ /**
270
+ * The per-turn accounting line — tokens beside what they cost, in the `⎿` hook
271
+ * row style, e.g. `⎿ 1,562 tok · 1,250/312 · €0.0007 · 4.2s`.
272
+ */
273
+ function renderMeta(ctx, meta = {}, width = 80) {
274
+ const t = themeOf(ctx);
275
+ const bits = [];
276
+ if (meta.model) bits.push(`${t.blue}${meta.model}${RESET}`);
277
+ if (meta.tokens != null) bits.push(`${t.white}${fmtTokens(meta.tokens)} tok${RESET}`);
278
+ if (meta.usage) {
279
+ const { input, output } = meta.usage;
280
+ if (Number.isFinite(input) || Number.isFinite(output)) {
281
+ bits.push(`${t.gray}${fmtTokens(input || 0)}/${fmtTokens(output || 0)}${RESET}`);
282
+ }
283
+ }
284
+ if (meta.eur != null) bits.push(`${meta.eur > 0 ? t.coral : t.green}${fmtEur(meta.eur)}${RESET}`);
285
+ if (meta.ms != null) bits.push(`${t.gray}${fmtElapsed(meta.ms)}${RESET}`);
286
+ if (meta.calls > 1) bits.push(`${t.gray}${meta.calls} calls${RESET}`);
287
+ if (!bits.length) return '';
288
+ const sep = `${t.dim} ${GLYPH.bullet} ${RESET}`;
289
+ return `${t.dim}${GLYPH.hook} ${RESET}${bits.join(sep)}`;
290
+ }
291
+
292
+ /**
293
+ * The bottom bar: segments packed left, the hint right-aligned, always exactly
294
+ * `width` cells (clipped, never wrapped, so a narrow terminal degrades instead
295
+ * of corrupting the transcript). No background — the CLI stays pipeable.
296
+ */
297
+ function renderStatus(ctx, state = {}, width = 80) {
298
+ const t = themeOf(ctx);
299
+ const left = ['aegis'];
300
+ if (state.model) left.push(state.model);
301
+ if (state.tokens != null) left.push(`${fmtTokens(state.tokens)} tok`);
302
+ if (state.spend != null) left.push(fmtEur(state.spend));
303
+ if (state.mode) left.push(state.mode);
304
+
305
+ const right = state.hint || 'ctrl+c quit';
306
+ const body = ` ${left.join(` ${GLYPH.bullet} `)} `;
307
+ const gap = width - w(body) - w(right) - 1;
308
+ const content = gap > 0 ? body + ' '.repeat(gap) + right + ' ' : clip(body, width);
309
+ return `${t.gray}${pad(content, width)}${RESET}`;
310
+ }
311
+
312
+ /**
313
+ * The working line (drawn in the live region, then replaced by the turn):
314
+ * the `✻`-family spinner cycling with a VERBS entry. With `state.done`, the
315
+ * completion line `✻ <DoneVerb> for Ns` in gray (Churned after a chat turn,
316
+ * Worked after a turn that used tools).
317
+ */
318
+ function renderWorking(ctx, state = {}) {
319
+ const t = themeOf(ctx);
320
+ if (state.done) {
321
+ const verb = state.verb || DONE_VERBS[state.tools ? 1 : 0] || DONE_VERBS[0];
322
+ const secs =
323
+ state.secs != null ? state.secs : Math.max(1, Math.round((state.elapsedMs || 0) / 1000));
324
+ return `${t.gray}${GLYPH.bloom} ${verb} for ${secs}s${RESET}`;
325
+ }
326
+ const frame = GLYPH.spin[Math.abs(Math.floor(state.tick || 0)) % GLYPH.spin.length];
327
+ const verb = state.verb || VERBS[0];
328
+ const tail = [];
329
+ if (state.elapsedMs != null) tail.push(fmtElapsed(state.elapsedMs));
330
+ if (state.streamed != null) tail.push(`↓${fmtTokens(state.streamed)} tokens`);
331
+ const tailStr = tail.length ? ` ${t.gray}(${tail.join(` ${GLYPH.bullet} `)})${RESET}` : '';
332
+ return (
333
+ `${t.coral}${frame}${RESET} ${t.white}${verb}${RESET}${t.white}…${RESET}${tailStr}` +
334
+ `${t.dim} esc to interrupt${RESET}`
335
+ );
336
+ }
337
+
338
+ /** A ruled section header used by command output (`/help`, `/cost`). */
339
+ function renderHeading(ctx, text, width = 80) {
340
+ const t = themeOf(ctx);
341
+ const label = ` ${text} `;
342
+ const fill = Math.max(0, width - w(label));
343
+ const left = Math.floor(fill / 2);
344
+ return (
345
+ `${t.gray}${'─'.repeat(left)}${RESET}${t.gold}${BOLD}${label}${RESET}` +
346
+ `${t.gray}${'─'.repeat(Math.max(0, fill - left))}${RESET}`
347
+ );
348
+ }
349
+
350
+ /** Render a tool result body: heading + indented lines, no role gutter. */
351
+ function renderToolResult(ctx, name, text, width = 80) {
352
+ const t = themeOf(ctx);
353
+ const lines = [renderHeading(ctx, name, width)];
354
+ for (const l of wrapBlock(String(text == null ? '' : text), Math.max(8, width - 2))) {
355
+ lines.push(l ? ` ${t.white}${l}${RESET}` : '');
356
+ }
357
+ return lines;
358
+ }
359
+
360
+ /** A transient notice. Kinds map gray / coral / red / green with the matching
361
+ * glyph: info `·`, warn `⚠`, error `✗`, ok `✔`. */
362
+ const NOTICE = {
363
+ info: ['gray', GLYPH.bullet],
364
+ warn: ['coral', WARN],
365
+ error: ['red', ERR],
366
+ ok: ['green', GLYPH.check],
367
+ };
368
+
369
+ function renderNotice(ctx, kind, text) {
370
+ const t = themeOf(ctx);
371
+ const [token, mark] = NOTICE[kind] || NOTICE.info;
372
+ return `${t[token]}${mark} ${text}${RESET}`;
373
+ }
374
+
375
+ module.exports = {
376
+ renderBanner,
377
+ renderIdentityBox,
378
+ renderTurn,
379
+ renderMeta,
380
+ renderStatus,
381
+ renderWorking,
382
+ renderHeading,
383
+ renderToolResult,
384
+ renderNotice,
385
+ mdLines,
386
+ inline,
387
+ // Style helpers kept for legacy consumers.
388
+ fg,
389
+ bg,
390
+ UNDER,
391
+ ITALIC,
392
+ RESET_FG,
393
+ RESET_BG,
394
+ pad,
395
+ padStart,
396
+ };