aegiscode 6.0.0 → 6.1.1

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/src/render.js CHANGED
@@ -1,67 +1,150 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * The CLI's renderers: banner, transcript turns, per-turn meta line, status bar,
5
- * and the markdown-lite body treatment.
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.
6
10
  *
7
- * Every function returns an array of lines (strings with SGR escapes) rather
8
- * than writing to stdout, so the whole surface is assertable from a plain Node
9
- * test with no TTY and no child process — the same split the desktop uses for
10
- * its pure renderer modules.
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`.
11
17
  */
12
18
 
13
- const { RGB, BG, RESET, BOLD, DIM, ITALIC, UNDER, RESET_FG, RESET_BG, GLYPH, themeOf } = require('./theme.js');
14
- const { SIGIL, CORE, WORDMARK, TAGLINE, center } = require('./art.js');
15
- const { w, pad, padStart, wrapBlock, clip } = require('./screen.js');
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');
16
36
  const { fmtTokens, fmtEur, fmtElapsed } = require('./format.js');
17
37
 
18
- const fg = (t, c) => `${RGB(...c)}`;
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}`;
19
53
  const bg = (c) => BG(...c);
20
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
+
21
108
  /**
22
- * The start-up banner: sigil art, wordmark, tagline and a heavy-ruled identity
23
- * box (heavy corners, never rounded the rounded frame is the Claude Code
24
- * look this CLI exists to not be).
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.
25
113
  */
26
114
  function renderBanner(ctx, info = {}) {
27
115
  const t = themeOf(ctx);
28
- const width = Number(info.width) || 80;
116
+ const width = Math.max(20, Number(info.width) || 80);
29
117
  const lines = [];
30
118
 
31
- if (width >= 34) {
32
- const artRows = center(SIGIL, width);
33
- for (const [i, row] of artRows.entries()) {
34
- // `center()` only ever left-pads with spaces, so the offset is the width
35
- // the core cell moved by; shift the core columns to match.
36
- const offset = row.length - SIGIL[i].length;
37
- const coreCols = CORE.filter((c) => c.row === i).map((c) => c.col + offset);
38
- if (!coreCols.length) {
39
- lines.push(fg(t, t.plasma) + row + RESET);
40
- continue;
41
- }
42
- // Paint the core cell in the secondary colour so the mark reads as a
43
- // signal rather than a solid block.
44
- let painted = '';
45
- [...row].forEach((ch, c) => {
46
- painted += coreCols.includes(c) ? fg(t, t.beam) + ch + fg(t, t.plasma) : ch;
47
- });
48
- lines.push(fg(t, t.plasma) + painted + RESET);
49
- }
50
- lines.push('');
51
- lines.push(' '.repeat(Math.max(0, Math.floor((width - w(WORDMARK)) / 2))) + fg(t, t.plasma) + BOLD + WORDMARK + RESET);
52
- lines.push(' '.repeat(Math.max(0, Math.floor((width - w(TAGLINE)) / 2))) + fg(t, t.muted) + TAGLINE + RESET);
53
- lines.push('');
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));
54
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('');
55
138
 
56
139
  lines.push(...renderIdentityBox(ctx, info, width));
57
140
  return lines;
58
141
  }
59
142
 
60
- /** The `┏━ aegiscode ━┓` identity panel: what you are talking to, and as what. */
61
- function renderIdentityBox(ctx, info, width) {
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) {
62
146
  const t = themeOf(ctx);
63
- const boxW = Math.max(24, Math.min(width, 64));
64
- const inner = boxW - 2;
147
+ const lines = [renderHeading(ctx, 'aegiscode', width)];
65
148
 
66
149
  const rows = [
67
150
  ['version', info.version ? `v${info.version}` : null],
@@ -71,30 +154,18 @@ function renderIdentityBox(ctx, info, width) {
71
154
  ['render', info.stream === false ? 'buffered' : 'streaming'],
72
155
  ].filter(([, v]) => v);
73
156
 
74
- const title = ' aegiscode ';
75
- const headFill = Math.max(0, inner - w(title) - 1);
76
- const out = [
77
- fg(t, t.plasma) + GLYPH.box.tl + GLYPH.box.h + BOLD + title + RESET + fg(t, t.plasma) + GLYPH.box.h.repeat(headFill) + GLYPH.box.tr + RESET,
78
- ];
79
157
  for (const [k, v] of rows) {
80
- const label = fg(t, t.muted) + k.padEnd(8) + RESET;
81
- // Row budget: 1 border + 1 space + 8 label + 1 space + value + fill + 1 space
82
- // + 1 border = boxW, so the value gets `inner - 11` cells.
83
- const value = clip(String(v), Math.max(0, inner - 11));
84
- out.push(
85
- fg(t, t.plasma) + GLYPH.box.v + RESET + ' ' + label + ' ' + fg(t, t.text) + value + RESET +
86
- ' '.repeat(Math.max(0, inner - 11 - w(value))) + ' ' + fg(t, t.plasma) + GLYPH.box.v + RESET
87
- );
158
+ const value = clip(String(v), Math.max(0, width - 12));
159
+ lines.push(` ${t.gray}${k.padEnd(8)}${RESET}${t.white}${value}${RESET}`);
88
160
  }
89
- out.push(fg(t, t.plasma) + GLYPH.box.bl + GLYPH.box.h.repeat(inner) + GLYPH.box.br + RESET);
90
- // Clip the hint as plain text *before* styling — clipping an already-styled
91
- // line would cut through an escape sequence and bleed colour.
92
161
  const hint = clip(` type /help for commands ${GLYPH.bullet} /quit to exit`, width);
93
- out.push(fg(t, t.dim) + hint + RESET);
94
- return out;
162
+ lines.push('');
163
+ lines.push(`${t.dim}${hint}${RESET}`);
164
+ return lines;
95
165
  }
96
166
 
97
- /** Markdown-lite body: fenced code, bullets, headings, inline code/bold. */
167
+ /** Markdown-lite body (fallback when ./markdown.js is unavailable): fenced
168
+ * code, bullets, headings, inline code/bold. */
98
169
  function mdLines(text, width, ctx) {
99
170
  const t = themeOf(ctx);
100
171
  const out = [];
@@ -108,7 +179,7 @@ function mdLines(text, width, ctx) {
108
179
  continue;
109
180
  }
110
181
  if (inFence) {
111
- for (const l of wrapBlock(raw, bodyW)) out.push(fg(t, t.beam) + ' ' + l + RESET);
182
+ for (const l of wrapBlock(raw, bodyW)) out.push(`${t.gray} ${l}${RESET}`);
112
183
  continue;
113
184
  }
114
185
  if (raw.trim() === '') {
@@ -119,13 +190,13 @@ function mdLines(text, width, ctx) {
119
190
  if (bullet) {
120
191
  const indent = ' '.repeat(Math.min(6, bullet[1].length));
121
192
  for (const l of wrapBlock(inline(bullet[2], ctx), bodyW - 2)) {
122
- out.push(`${indent}${fg(t, t.dim)}${GLYPH.bullet}${RESET} ${l}`);
193
+ out.push(`${indent}${t.gray}${GLYPH.bullet}${RESET} ${l}`);
123
194
  }
124
195
  continue;
125
196
  }
126
197
  const heading = /^(#{1,6})\s+(.*)$/.exec(raw);
127
198
  if (heading) {
128
- out.push(fg(t, t.plasma) + BOLD + inline(heading[2], ctx) + RESET);
199
+ out.push(`${t.gold}${BOLD}${inline(heading[2], ctx)}${RESET}`);
129
200
  continue;
130
201
  }
131
202
  for (const l of wrapBlock(inline(raw, ctx), bodyW, '')) out.push(l);
@@ -133,111 +204,146 @@ function mdLines(text, width, ctx) {
133
204
  return out;
134
205
  }
135
206
 
136
- /** Inline span treatment: `code` in beam, **bold** in bold. */
207
+ /** Inline span treatment: `code` in green, **bold** in bold. */
137
208
  function inline(text, ctx) {
138
209
  const t = themeOf(ctx);
139
- return String(text)
140
- .replace(/`([^`]+)`/g, (_, code) => fg(t, t.beam) + code + RESET)
141
- .replace(/\*\*([^*]+)\*\*/g, (_, bold) => BOLD + bold + RESET);
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);
142
222
  }
143
223
 
144
224
  /**
145
- * One transcript turn. Roles get a heavy gutter (┃) and their own sigil, so a
146
- * turn is scannable without the boxed-bubble or `>`-prefix look.
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.
147
228
  */
148
229
  function renderTurn(ctx, turn, width = 80) {
149
230
  const t = themeOf(ctx);
150
231
  const role = turn.role || 'assistant';
151
- const rail = fg(t, t.dim) + GLYPH.rail + RESET;
232
+ const text = String(turn.text == null ? '' : turn.text);
152
233
  const lines = [];
153
234
 
154
- const head = {
155
- user: { mark: GLYPH.prompt, label: 'you', colour: t.text },
156
- assistant: { mark: GLYPH.sigil, label: 'aegis', colour: t.plasma },
157
- tool: { mark: GLYPH.pointer, label: turn.label || 'tool', colour: t.beam },
158
- system: { mark: GLYPH.warn, label: 'aegis', colour: t.muted },
159
- error: { mark: GLYPH.err, label: 'error', colour: t.fault },
160
- }[role] || { mark: GLYPH.bullet, label: role, colour: t.muted };
161
-
162
- lines.push(`${rail}${fg(t, head.colour)}${head.mark} ${BOLD}${head.label}${RESET}`);
163
-
164
- const body = role === 'assistant' || role === 'system' ? mdLines(turn.text, width - 2, ctx) : wrapBlock(turn.text, width - 4);
165
- for (const l of body) lines.push(`${rail} ${l}`);
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
+ }
166
261
 
167
- if (turn.meta) lines.push(renderMeta(ctx, turn.meta, width));
262
+ if (turn.meta) {
263
+ const m = renderMeta(ctx, turn.meta, width);
264
+ if (m) lines.push(m);
265
+ }
168
266
  return lines;
169
267
  }
170
268
 
171
269
  /**
172
- * The per-turn accounting line — tokens consumed beside what they cost, which
173
- * is the readout this whole surface exists to make possible.
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`.
174
272
  */
175
- function renderMeta(ctx, meta, width = 80) {
273
+ function renderMeta(ctx, meta = {}, width = 80) {
176
274
  const t = themeOf(ctx);
177
275
  const bits = [];
178
- if (meta.model) bits.push(fg(t, t.beam) + meta.model + RESET);
179
- const tokens = meta.tokens == null ? null : `${fmtTokens(meta.tokens)} tok`;
180
- if (tokens) bits.push(fg(t, t.text) + tokens + RESET);
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}`);
181
278
  if (meta.usage) {
182
279
  const { input, output } = meta.usage;
183
280
  if (Number.isFinite(input) || Number.isFinite(output)) {
184
- bits.push(fg(t, t.muted) + `${fmtTokens(input || 0)}/${fmtTokens(output || 0)}` + RESET);
281
+ bits.push(`${t.gray}${fmtTokens(input || 0)}/${fmtTokens(output || 0)}${RESET}`);
185
282
  }
186
283
  }
187
- if (meta.eur != null) bits.push(fg(t, meta.eur > 0 ? t.alert : t.pulse) + fmtEur(meta.eur) + RESET);
188
- if (meta.ms != null) bits.push(fg(t, t.muted) + fmtElapsed(meta.ms) + RESET);
189
- if (meta.calls > 1) bits.push(fg(t, t.muted) + `${meta.calls} calls` + RESET);
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}`);
190
287
  if (!bits.length) return '';
191
- return `${fg(t, t.dim)}${GLYPH.railEnd}${GLYPH.divider} ${RESET}${GLYPH.spend} ${bits.join(fg(t, t.dim) + ' ' + GLYPH.bullet + ' ' + RESET)}`;
288
+ const sep = `${t.dim} ${GLYPH.bullet} ${RESET}`;
289
+ return `${t.dim}${GLYPH.hook} ${RESET}${bits.join(sep)}`;
192
290
  }
193
291
 
194
292
  /**
195
- * The bottom bar: a full-width inverted strip. Segments are packed left and the
196
- * hint is right-aligned; the bar is clipped (never wrapped) so a narrow
197
- * terminal degrades instead of corrupting the transcript.
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.
198
296
  */
199
297
  function renderStatus(ctx, state = {}, width = 80) {
200
298
  const t = themeOf(ctx);
201
- const left = [];
202
- left.push(`${GLYPH.sigil} aegis`);
299
+ const left = ['aegis'];
203
300
  if (state.model) left.push(state.model);
204
301
  if (state.tokens != null) left.push(`${fmtTokens(state.tokens)} tok`);
205
302
  if (state.spend != null) left.push(fmtEur(state.spend));
206
303
  if (state.mode) left.push(state.mode);
207
304
 
208
305
  const right = state.hint || 'ctrl+c quit';
209
- const body = ` ${left.join(' ' + GLYPH.bullet + ' ')} `;
306
+ const body = ` ${left.join(` ${GLYPH.bullet} `)} `;
210
307
  const gap = width - w(body) - w(right) - 1;
211
- // Always exactly `width` cells: the bar is a background, so a short line
212
- // would leave the terminal's own background showing through the strip.
213
308
  const content = gap > 0 ? body + ' '.repeat(gap) + right + ' ' : clip(body, width);
214
- return bg(t.ink) + fg(t, t.plasma) + pad(content, width) + RESET + RESET_BG;
309
+ return `${t.gray}${pad(content, width)}${RESET}`;
215
310
  }
216
311
 
217
- /** The working line (drawn in the live region, then replaced by the turn). */
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
+ */
218
318
  function renderWorking(ctx, state = {}) {
219
319
  const t = themeOf(ctx);
220
- const frame = GLYPH.spin[Math.floor((state.tick || 0)) % GLYPH.spin.length];
221
- const verb = state.verb || 'Working';
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];
222
328
  const tail = [];
223
329
  if (state.elapsedMs != null) tail.push(fmtElapsed(state.elapsedMs));
224
- if (state.streamed) tail.push(`${fmtTokens(state.streamed)} char`);
330
+ if (state.streamed != null) tail.push(`↓${fmtTokens(state.streamed)} tokens`);
331
+ const tailStr = tail.length ? ` ${t.gray}(${tail.join(` ${GLYPH.bullet} `)})${RESET}` : '';
225
332
  return (
226
- fg(t, t.plasma) + frame + RESET + ' ' + fg(t, t.text) + verb + RESET +
227
- fg(t, t.muted) + '…' + (tail.length ? ` ${tail.join(' ' + GLYPH.bullet + ' ')}` : '') + RESET +
228
- fg(t, t.dim) + ' esc to interrupt' + RESET
333
+ `${t.coral}${frame}${RESET} ${t.white}${verb}${RESET}${t.white}…${RESET}${tailStr}` +
334
+ `${t.dim} esc to interrupt${RESET}`
229
335
  );
230
336
  }
231
337
 
232
- /** A ruled section header used by command output (`/balance`, `/models`). */
338
+ /** A ruled section header used by command output (`/help`, `/cost`). */
233
339
  function renderHeading(ctx, text, width = 80) {
234
340
  const t = themeOf(ctx);
235
341
  const label = ` ${text} `;
236
- const fill = Math.max(0, width - w(label) - 2);
342
+ const fill = Math.max(0, width - w(label));
237
343
  const left = Math.floor(fill / 2);
238
344
  return (
239
- fg(t, t.dim) + GLYPH.rule.repeat(left) + RESET + fg(t, t.plasma) + BOLD + label + RESET +
240
- fg(t, t.dim) + GLYPH.rule.repeat(fill - left) + RESET
345
+ `${t.gray}${'─'.repeat(left)}${RESET}${t.gold}${BOLD}${label}${RESET}` +
346
+ `${t.gray}${'─'.repeat(Math.max(0, fill - left))}${RESET}`
241
347
  );
242
348
  }
243
349
 
@@ -245,18 +351,25 @@ function renderHeading(ctx, text, width = 80) {
245
351
  function renderToolResult(ctx, name, text, width = 80) {
246
352
  const t = themeOf(ctx);
247
353
  const lines = [renderHeading(ctx, name, width)];
248
- for (const l of wrapBlock(String(text == null ? '' : text), width - 2)) {
249
- lines.push(' ' + (l ? fg(t, t.text) + l + RESET : ''));
354
+ for (const l of wrapBlock(String(text == null ? '' : text), Math.max(8, width - 2))) {
355
+ lines.push(l ? ` ${t.white}${l}${RESET}` : '');
250
356
  }
251
357
  return lines;
252
358
  }
253
359
 
254
- /** The transient "you didn't finish a stream" note. */
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
+
255
369
  function renderNotice(ctx, kind, text) {
256
370
  const t = themeOf(ctx);
257
- const colour = kind === 'error' ? t.fault : kind === 'warn' ? t.alert : t.muted;
258
- const mark = kind === 'error' ? GLYPH.err : kind === 'warn' ? GLYPH.warn : GLYPH.ok;
259
- return fg(t, colour) + `${mark} ${text}` + RESET;
371
+ const [token, mark] = NOTICE[kind] || NOTICE.info;
372
+ return `${t[token]}${mark} ${text}${RESET}`;
260
373
  }
261
374
 
262
375
  module.exports = {
@@ -271,11 +384,13 @@ module.exports = {
271
384
  renderNotice,
272
385
  mdLines,
273
386
  inline,
387
+ // Style helpers kept for legacy consumers.
274
388
  fg,
275
389
  bg,
276
390
  UNDER,
277
391
  ITALIC,
278
392
  RESET_FG,
393
+ RESET_BG,
279
394
  pad,
280
395
  padStart,
281
396
  };