aegiscode 5.2.32 → 6.0.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.
- package/README.md +123 -392
- package/bin/aegiscode.js +197 -0
- package/package.json +30 -83
- package/scripts/demo.mjs +92 -0
- package/scripts/predist.mjs +86 -0
- package/src/app.js +543 -0
- package/src/art.js +45 -0
- package/src/commands.js +163 -0
- package/src/deps.js +65 -0
- package/src/format.js +54 -0
- package/src/render.js +281 -0
- package/src/screen.js +177 -0
- package/src/theme.js +120 -0
- package/vendor/client/aegis.js +809 -0
- package/vendor/client/foreign-memory.js +666 -0
- package/vendor/desktop/renderer/usage.js +42 -0
- package/vendor/mcp/tools.js +356 -0
- package/LICENSE +0 -21
- package/bin/cli.js +0 -2362
- package/scripts/download-binary.mjs +0 -155
- package/scripts/empty-stub.mjs +0 -1
- package/scripts/ensure-node-version.mjs +0 -27
- package/scripts/install-alacritty.ps1 +0 -149
- package/scripts/install-alacritty.sh +0 -164
- package/scripts/install.sh +0 -121
- package/scripts/make-bin.mjs +0 -17
- package/scripts/release-local.sh +0 -68
- package/scripts/repro-compact.ts +0 -59
- package/scripts/repro-tokencount.ts +0 -45
- package/scripts/sharp-stub/index.js +0 -9
- package/scripts/sharp-stub/package.json +0 -5
package/src/commands.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Slash commands — the CLI's face on the shared tool registry.
|
|
5
|
+
*
|
|
6
|
+
* Every command that talks to AEGIS names a tool from `mcp/tools.js`, and
|
|
7
|
+
* `test/cli-tools.test.mjs` asserts both directions: no command points at a
|
|
8
|
+
* tool that does not exist, and no tool is unreachable from the prompt. A new
|
|
9
|
+
* capability added to the registry therefore shows up here or fails the build,
|
|
10
|
+
* which is the failure mode we want (the alternative is a capability that
|
|
11
|
+
* exists in the MCP host and is invisible in the terminal).
|
|
12
|
+
*
|
|
13
|
+
* Plain text (no leading `/`) is a prompt: it goes to the pooled brain.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const COMMANDS = [
|
|
17
|
+
{
|
|
18
|
+
name: 'ask',
|
|
19
|
+
args: '<prompt>',
|
|
20
|
+
help: 'Send a prompt to the AEGIS pool (same as typing it plainly).',
|
|
21
|
+
tool: 'aegis_ask',
|
|
22
|
+
build: (arg) => ({ prompt: arg }),
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: 'status',
|
|
26
|
+
help: 'Validate the API key; show plan, account and memory state.',
|
|
27
|
+
tool: 'aegis_status',
|
|
28
|
+
build: () => ({}),
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: 'models',
|
|
32
|
+
aliases: ['model-list'],
|
|
33
|
+
help: 'List the model ids you can pin with /model.',
|
|
34
|
+
tool: 'aegis_list_models',
|
|
35
|
+
build: () => ({}),
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: 'balance',
|
|
39
|
+
aliases: ['spend'], // not `cost` — that name is the local session tally below
|
|
40
|
+
help: 'Token-bank balance and recent spend (tokens beside €).',
|
|
41
|
+
tool: 'aegis_balance',
|
|
42
|
+
build: () => ({}),
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'recall',
|
|
46
|
+
args: '<query>',
|
|
47
|
+
help: 'Search your AEGIS cloud memory.',
|
|
48
|
+
tool: 'aegis_memory_search',
|
|
49
|
+
build: (arg) => ({ query: arg }),
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: 'remember',
|
|
53
|
+
args: '<text>',
|
|
54
|
+
help: 'Save a durable note to cloud memory.',
|
|
55
|
+
tool: 'aegis_memory_save',
|
|
56
|
+
build: (arg) => ({ content: arg }),
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: 'memory',
|
|
60
|
+
aliases: ['memories'],
|
|
61
|
+
help: 'List the most recent cloud-memory entries.',
|
|
62
|
+
tool: 'aegis_memory_list',
|
|
63
|
+
build: () => ({}),
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: 'import',
|
|
67
|
+
args: '[--confirm]',
|
|
68
|
+
help: 'Import memory from other tools on this machine (dry run unless --confirm).',
|
|
69
|
+
tool: 'aegis_memory_import',
|
|
70
|
+
build: (arg) => ({ confirm: /--confirm\b/.test(arg) }),
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: 'byok',
|
|
74
|
+
help: 'Show which providers have your own key configured.',
|
|
75
|
+
tool: 'aegis_byok_status',
|
|
76
|
+
build: () => ({}),
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: 'byok-set',
|
|
80
|
+
args: '<provider>',
|
|
81
|
+
help: 'Set YOUR provider key (prompted, never echoed, never in history).',
|
|
82
|
+
tool: 'aegis_byok_set',
|
|
83
|
+
secret: 'key',
|
|
84
|
+
build: (arg) => ({ provider: arg.trim() }),
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: 'byok-rm',
|
|
88
|
+
args: '<provider>',
|
|
89
|
+
help: 'Remove a stored provider key.',
|
|
90
|
+
tool: 'aegis_byok_set',
|
|
91
|
+
build: (arg) => ({ provider: arg.trim() }),
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: 'tool',
|
|
95
|
+
args: '<name> [json]',
|
|
96
|
+
help: 'Call any registry tool directly (escape hatch for new tools).',
|
|
97
|
+
generic: true,
|
|
98
|
+
build: (arg) => {
|
|
99
|
+
const sp = arg.indexOf(' ');
|
|
100
|
+
const name = (sp === -1 ? arg : arg.slice(0, sp)).trim();
|
|
101
|
+
const rest = sp === -1 ? '' : arg.slice(sp + 1).trim();
|
|
102
|
+
let args = {};
|
|
103
|
+
if (rest) {
|
|
104
|
+
try {
|
|
105
|
+
args = JSON.parse(rest);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
throw new Error(`/tool: arguments must be JSON — ${err.message}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { tool: name, args };
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
// --- local commands: no server call, handled by app.js --------------------
|
|
115
|
+
{
|
|
116
|
+
name: 'model',
|
|
117
|
+
args: '[id]',
|
|
118
|
+
help: 'Pin a model id (no argument shows the current pin and clears it with `-`).',
|
|
119
|
+
local: 'model',
|
|
120
|
+
},
|
|
121
|
+
{ name: 'stream', args: '[on|off]', help: 'Toggle streaming output.', local: 'stream' },
|
|
122
|
+
{ name: 'theme', args: '[dark|light]', help: 'Switch the colour theme.', local: 'theme' },
|
|
123
|
+
{ name: 'cost', aliases: ['usage'], help: 'This session: tokens, spend and call count.', local: 'cost' },
|
|
124
|
+
{ name: 'clear', aliases: ['cls'], help: 'Clear the screen.', local: 'clear' },
|
|
125
|
+
{ name: 'help', aliases: ['?', 'h'], help: 'Show this list.', local: 'help' },
|
|
126
|
+
{ name: 'quit', aliases: ['exit', 'q'], help: 'Exit.', local: 'quit' },
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
const BY_NAME = new Map();
|
|
130
|
+
for (const c of COMMANDS) {
|
|
131
|
+
BY_NAME.set(c.name, c);
|
|
132
|
+
for (const a of c.aliases || []) BY_NAME.set(a, c);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function findCommand(name) {
|
|
136
|
+
return BY_NAME.get(String(name || '').toLowerCase()) || null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Classify one input line.
|
|
141
|
+
* @returns {{kind:'empty'}|{kind:'command',command:object,arg:string}|{kind:'prompt',text:string}}
|
|
142
|
+
*/
|
|
143
|
+
function parseLine(line) {
|
|
144
|
+
const raw = String(line == null ? '' : line);
|
|
145
|
+
const trimmed = raw.trim();
|
|
146
|
+
if (!trimmed) return { kind: 'empty' };
|
|
147
|
+
if (trimmed[0] !== '/') return { kind: 'prompt', text: trimmed };
|
|
148
|
+
const sp = trimmed.indexOf(' ');
|
|
149
|
+
const name = (sp === -1 ? trimmed.slice(1) : trimmed.slice(1, sp)).trim();
|
|
150
|
+
const arg = sp === -1 ? '' : trimmed.slice(sp + 1).trim();
|
|
151
|
+
const command = findCommand(name);
|
|
152
|
+
if (!command) {
|
|
153
|
+
return { kind: 'unknown', name, text: trimmed };
|
|
154
|
+
}
|
|
155
|
+
return { kind: 'command', command, arg };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Commands that map to a registry tool, for the coverage test. */
|
|
159
|
+
function toolBackedCommands() {
|
|
160
|
+
return COMMANDS.filter((c) => c.tool);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = { COMMANDS, findCommand, parseLine, toolBackedCommands };
|
package/src/deps.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where the CLI finds the modules it shares with the rest of the repo.
|
|
5
|
+
*
|
|
6
|
+
* Two layouts must work:
|
|
7
|
+
*
|
|
8
|
+
* in-repo <repo>/cli/src/deps.js -> <repo>/client/aegis.js, <repo>/mcp/tools.js
|
|
9
|
+
* npm <pkg>/src/deps.js -> <pkg>/vendor/{client,mcp,desktop}/...
|
|
10
|
+
*
|
|
11
|
+
* The published package is staged by `scripts/predist.mjs` into `cli/vendor/`
|
|
12
|
+
* keeping the repo's own relative shape, so nothing here (or in the copied
|
|
13
|
+
* files) needs a path rewrite — `vendor/mcp/tools.js` requiring
|
|
14
|
+
* `../client/foreign-memory.js` resolves inside the vendor tree by construction.
|
|
15
|
+
*
|
|
16
|
+
* Resolution is by existence, and a missing module is a loud error rather than
|
|
17
|
+
* a silent fallback to a second copy: duplicate transports are how the two
|
|
18
|
+
* hosts would drift.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const fs = require('node:fs');
|
|
22
|
+
const path = require('node:path');
|
|
23
|
+
|
|
24
|
+
function roots() {
|
|
25
|
+
const srcDir = __dirname; // <root>/cli/src
|
|
26
|
+
const cliDir = path.join(srcDir, '..');
|
|
27
|
+
return [
|
|
28
|
+
path.join(srcDir, '..', '..'), // repo root (in-repo layout)
|
|
29
|
+
path.join(cliDir, 'vendor'), // staged tree (published layout)
|
|
30
|
+
];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Resolve a repo-relative module path against whichever root has it. */
|
|
34
|
+
function resolveShared(relPath) {
|
|
35
|
+
for (const root of roots()) {
|
|
36
|
+
const candidate = path.join(root, relPath);
|
|
37
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
38
|
+
}
|
|
39
|
+
throw new Error(
|
|
40
|
+
`aegiscode: cannot find ${relPath}. Expected it beside cli/ (in the repo) or ` +
|
|
41
|
+
'under cli/vendor/ (installed package). Reinstall the package, or run ' +
|
|
42
|
+
'`node scripts/predist.mjs` from cli/ if this is a source checkout.'
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const clientPath = resolveShared(path.join('client', 'aegis.js'));
|
|
47
|
+
const toolsPath = resolveShared(path.join('mcp', 'tools.js'));
|
|
48
|
+
// The desktop's pure usage module is the single source of truth for turning a
|
|
49
|
+
// provider's usage object into a display number. The CLI reuses the same file
|
|
50
|
+
// instead of re-implementing the mapping, so the terminal and the GUI can never
|
|
51
|
+
// disagree about what a call consumed (test/cli-tools.test.mjs asserts the
|
|
52
|
+
// function identity).
|
|
53
|
+
const usagePath = resolveShared(path.join('desktop', 'renderer', 'usage.js'));
|
|
54
|
+
|
|
55
|
+
const { createClient } = require(clientPath);
|
|
56
|
+
const { createTools } = require(toolsPath);
|
|
57
|
+
const { usageTokens } = require(usagePath);
|
|
58
|
+
|
|
59
|
+
module.exports = {
|
|
60
|
+
createClient,
|
|
61
|
+
createTools,
|
|
62
|
+
usageTokens,
|
|
63
|
+
paths: { client: clientPath, tools: toolsPath, usage: usagePath },
|
|
64
|
+
roots,
|
|
65
|
+
};
|
package/src/format.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Number/text formatting for the CLI — one place, so the transcript, the status
|
|
5
|
+
* bar and the headless `--print` output always agree.
|
|
6
|
+
*
|
|
7
|
+
* The sub-cent rule mirrors the server-side ledger: AEGIS pooled calls settle
|
|
8
|
+
* around €0.0007, so a 2dp rendering shows a real charge as "€0.00" next to its
|
|
9
|
+
* token count and reads as free usage.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Group digits: 1562 -> "1,562". */
|
|
13
|
+
function fmtTokens(n) {
|
|
14
|
+
const v = Number(n);
|
|
15
|
+
if (!Number.isFinite(v) || v <= 0) return '0';
|
|
16
|
+
return Math.round(v)
|
|
17
|
+
.toString()
|
|
18
|
+
.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** € amount, 4dp below a cent (per-call spend) and 2dp above it (top-ups). */
|
|
22
|
+
function fmtEur(n) {
|
|
23
|
+
const v = Number(n);
|
|
24
|
+
if (!Number.isFinite(v)) return '€?';
|
|
25
|
+
const abs = Math.abs(v);
|
|
26
|
+
return `€${abs < 0.01 ? abs.toFixed(4) : abs.toFixed(2)}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Signed € amount, from the user's side: -€0.0007 spent, +€5.00 added. */
|
|
30
|
+
function fmtEurSigned(n) {
|
|
31
|
+
const v = Number(n);
|
|
32
|
+
if (!Number.isFinite(v)) return '€?';
|
|
33
|
+
return `${v < 0 ? '-' : '+'}${fmtEur(Math.abs(v))}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Compact duration: 850 -> "0.9s", 65000 -> "1m05s". */
|
|
37
|
+
function fmtElapsed(ms) {
|
|
38
|
+
const s = Math.max(0, Number(ms) || 0) / 1000;
|
|
39
|
+
if (s < 10) return `${s.toFixed(1)}s`;
|
|
40
|
+
if (s < 60) return `${Math.round(s)}s`;
|
|
41
|
+
const m = Math.floor(s / 60);
|
|
42
|
+
const rest = Math.round(s - m * 60);
|
|
43
|
+
return `${m}m${String(rest).padStart(2, '0')}s`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Mask an API key for display: never echo enough to be useful if leaked. */
|
|
47
|
+
function maskKey(key) {
|
|
48
|
+
const s = String(key || '');
|
|
49
|
+
if (!s) return 'not set';
|
|
50
|
+
if (s.length <= 8) return '•'.repeat(s.length);
|
|
51
|
+
return `${s.slice(0, 6)}${'•'.repeat(4)}${s.slice(-4)}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { fmtTokens, fmtEur, fmtEurSigned, fmtElapsed, maskKey };
|
package/src/render.js
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The CLI's renderers: banner, transcript turns, per-turn meta line, status bar,
|
|
5
|
+
* and the markdown-lite body treatment.
|
|
6
|
+
*
|
|
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
|
+
*/
|
|
12
|
+
|
|
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');
|
|
16
|
+
const { fmtTokens, fmtEur, fmtElapsed } = require('./format.js');
|
|
17
|
+
|
|
18
|
+
const fg = (t, c) => `${RGB(...c)}`;
|
|
19
|
+
const bg = (c) => BG(...c);
|
|
20
|
+
|
|
21
|
+
/**
|
|
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).
|
|
25
|
+
*/
|
|
26
|
+
function renderBanner(ctx, info = {}) {
|
|
27
|
+
const t = themeOf(ctx);
|
|
28
|
+
const width = Number(info.width) || 80;
|
|
29
|
+
const lines = [];
|
|
30
|
+
|
|
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('');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
lines.push(...renderIdentityBox(ctx, info, width));
|
|
57
|
+
return lines;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The `┏━ aegiscode ━┓` identity panel: what you are talking to, and as what. */
|
|
61
|
+
function renderIdentityBox(ctx, info, width) {
|
|
62
|
+
const t = themeOf(ctx);
|
|
63
|
+
const boxW = Math.max(24, Math.min(width, 64));
|
|
64
|
+
const inner = boxW - 2;
|
|
65
|
+
|
|
66
|
+
const rows = [
|
|
67
|
+
['version', info.version ? `v${info.version}` : null],
|
|
68
|
+
['model', info.model || 'server default'],
|
|
69
|
+
['base', info.base || ''],
|
|
70
|
+
['key', info.key || 'not set'],
|
|
71
|
+
['render', info.stream === false ? 'buffered' : 'streaming'],
|
|
72
|
+
].filter(([, v]) => v);
|
|
73
|
+
|
|
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
|
+
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
|
+
);
|
|
88
|
+
}
|
|
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
|
+
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;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Markdown-lite body: fenced code, bullets, headings, inline code/bold. */
|
|
98
|
+
function mdLines(text, width, ctx) {
|
|
99
|
+
const t = themeOf(ctx);
|
|
100
|
+
const out = [];
|
|
101
|
+
let inFence = false;
|
|
102
|
+
const bodyW = Math.max(20, width - 4);
|
|
103
|
+
|
|
104
|
+
for (const raw of String(text == null ? '' : text).split('\n')) {
|
|
105
|
+
const fence = /^\s*```/.test(raw);
|
|
106
|
+
if (fence) {
|
|
107
|
+
inFence = !inFence;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (inFence) {
|
|
111
|
+
for (const l of wrapBlock(raw, bodyW)) out.push(fg(t, t.beam) + ' ' + l + RESET);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (raw.trim() === '') {
|
|
115
|
+
out.push('');
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const bullet = /^(\s*)[-*]\s+(.*)$/.exec(raw);
|
|
119
|
+
if (bullet) {
|
|
120
|
+
const indent = ' '.repeat(Math.min(6, bullet[1].length));
|
|
121
|
+
for (const l of wrapBlock(inline(bullet[2], ctx), bodyW - 2)) {
|
|
122
|
+
out.push(`${indent}${fg(t, t.dim)}${GLYPH.bullet}${RESET} ${l}`);
|
|
123
|
+
}
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const heading = /^(#{1,6})\s+(.*)$/.exec(raw);
|
|
127
|
+
if (heading) {
|
|
128
|
+
out.push(fg(t, t.plasma) + BOLD + inline(heading[2], ctx) + RESET);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
for (const l of wrapBlock(inline(raw, ctx), bodyW, '')) out.push(l);
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Inline span treatment: `code` in beam, **bold** in bold. */
|
|
137
|
+
function inline(text, ctx) {
|
|
138
|
+
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);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
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.
|
|
147
|
+
*/
|
|
148
|
+
function renderTurn(ctx, turn, width = 80) {
|
|
149
|
+
const t = themeOf(ctx);
|
|
150
|
+
const role = turn.role || 'assistant';
|
|
151
|
+
const rail = fg(t, t.dim) + GLYPH.rail + RESET;
|
|
152
|
+
const lines = [];
|
|
153
|
+
|
|
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}`);
|
|
166
|
+
|
|
167
|
+
if (turn.meta) lines.push(renderMeta(ctx, turn.meta, width));
|
|
168
|
+
return lines;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The per-turn accounting line — tokens consumed beside what they cost, which
|
|
173
|
+
* is the readout this whole surface exists to make possible.
|
|
174
|
+
*/
|
|
175
|
+
function renderMeta(ctx, meta, width = 80) {
|
|
176
|
+
const t = themeOf(ctx);
|
|
177
|
+
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);
|
|
181
|
+
if (meta.usage) {
|
|
182
|
+
const { input, output } = meta.usage;
|
|
183
|
+
if (Number.isFinite(input) || Number.isFinite(output)) {
|
|
184
|
+
bits.push(fg(t, t.muted) + `${fmtTokens(input || 0)}/${fmtTokens(output || 0)}` + RESET);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
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);
|
|
190
|
+
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)}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
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.
|
|
198
|
+
*/
|
|
199
|
+
function renderStatus(ctx, state = {}, width = 80) {
|
|
200
|
+
const t = themeOf(ctx);
|
|
201
|
+
const left = [];
|
|
202
|
+
left.push(`${GLYPH.sigil} aegis`);
|
|
203
|
+
if (state.model) left.push(state.model);
|
|
204
|
+
if (state.tokens != null) left.push(`${fmtTokens(state.tokens)} tok`);
|
|
205
|
+
if (state.spend != null) left.push(fmtEur(state.spend));
|
|
206
|
+
if (state.mode) left.push(state.mode);
|
|
207
|
+
|
|
208
|
+
const right = state.hint || 'ctrl+c quit';
|
|
209
|
+
const body = ` ${left.join(' ' + GLYPH.bullet + ' ')} `;
|
|
210
|
+
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
|
+
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;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The working line (drawn in the live region, then replaced by the turn). */
|
|
218
|
+
function renderWorking(ctx, state = {}) {
|
|
219
|
+
const t = themeOf(ctx);
|
|
220
|
+
const frame = GLYPH.spin[Math.floor((state.tick || 0)) % GLYPH.spin.length];
|
|
221
|
+
const verb = state.verb || 'Working';
|
|
222
|
+
const tail = [];
|
|
223
|
+
if (state.elapsedMs != null) tail.push(fmtElapsed(state.elapsedMs));
|
|
224
|
+
if (state.streamed) tail.push(`${fmtTokens(state.streamed)} char`);
|
|
225
|
+
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
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** A ruled section header used by command output (`/balance`, `/models`). */
|
|
233
|
+
function renderHeading(ctx, text, width = 80) {
|
|
234
|
+
const t = themeOf(ctx);
|
|
235
|
+
const label = ` ${text} `;
|
|
236
|
+
const fill = Math.max(0, width - w(label) - 2);
|
|
237
|
+
const left = Math.floor(fill / 2);
|
|
238
|
+
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
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Render a tool result body: heading + indented lines, no role gutter. */
|
|
245
|
+
function renderToolResult(ctx, name, text, width = 80) {
|
|
246
|
+
const t = themeOf(ctx);
|
|
247
|
+
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 : ''));
|
|
250
|
+
}
|
|
251
|
+
return lines;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** The transient "you didn't finish a stream" note. */
|
|
255
|
+
function renderNotice(ctx, kind, text) {
|
|
256
|
+
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;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
module.exports = {
|
|
263
|
+
renderBanner,
|
|
264
|
+
renderIdentityBox,
|
|
265
|
+
renderTurn,
|
|
266
|
+
renderMeta,
|
|
267
|
+
renderStatus,
|
|
268
|
+
renderWorking,
|
|
269
|
+
renderHeading,
|
|
270
|
+
renderToolResult,
|
|
271
|
+
renderNotice,
|
|
272
|
+
mdLines,
|
|
273
|
+
inline,
|
|
274
|
+
fg,
|
|
275
|
+
bg,
|
|
276
|
+
UNDER,
|
|
277
|
+
ITALIC,
|
|
278
|
+
RESET_FG,
|
|
279
|
+
pad,
|
|
280
|
+
padStart,
|
|
281
|
+
};
|