@zenera/cli 1.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.
- package/LICENSE +21 -0
- package/README.md +239 -0
- package/dist/args.d.ts +40 -0
- package/dist/args.js +99 -0
- package/dist/audit.d.ts +53 -0
- package/dist/audit.js +144 -0
- package/dist/banner.d.ts +13 -0
- package/dist/banner.js +103 -0
- package/dist/command.d.ts +14 -0
- package/dist/command.js +12 -0
- package/dist/commands/check.d.ts +3 -0
- package/dist/commands/check.js +287 -0
- package/dist/commands/index.d.ts +22 -0
- package/dist/commands/index.js +56 -0
- package/dist/commands/init.d.ts +3 -0
- package/dist/commands/init.js +157 -0
- package/dist/commands/inspect.d.ts +3 -0
- package/dist/commands/inspect.js +158 -0
- package/dist/commands/key.d.ts +3 -0
- package/dist/commands/key.js +335 -0
- package/dist/commands/list.d.ts +3 -0
- package/dist/commands/list.js +101 -0
- package/dist/commands/models.d.ts +9 -0
- package/dist/commands/models.js +120 -0
- package/dist/commands/open.d.ts +9 -0
- package/dist/commands/open.js +270 -0
- package/dist/commands/run.d.ts +3 -0
- package/dist/commands/run.js +167 -0
- package/dist/commands/sandbox.d.ts +3 -0
- package/dist/commands/sandbox.js +112 -0
- package/dist/commands/version.d.ts +6 -0
- package/dist/commands/version.js +39 -0
- package/dist/engine.d.ts +49 -0
- package/dist/engine.js +208 -0
- package/dist/external.d.ts +10 -0
- package/dist/external.js +56 -0
- package/dist/home.d.ts +31 -0
- package/dist/home.js +108 -0
- package/dist/ids.d.ts +12 -0
- package/dist/ids.js +44 -0
- package/dist/keys.d.ts +124 -0
- package/dist/keys.js +309 -0
- package/dist/lib.d.ts +9 -0
- package/dist/lib.js +31 -0
- package/dist/liveness.d.ts +23 -0
- package/dist/liveness.js +221 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.js +155 -0
- package/dist/narrate.d.ts +19 -0
- package/dist/narrate.js +124 -0
- package/dist/podman.d.ts +46 -0
- package/dist/podman.js +254 -0
- package/dist/projects.d.ts +70 -0
- package/dist/projects.js +232 -0
- package/dist/resolve.d.ts +27 -0
- package/dist/resolve.js +138 -0
- package/dist/sandbox.d.ts +36 -0
- package/dist/sandbox.js +104 -0
- package/dist/scaffold.d.ts +29 -0
- package/dist/scaffold.js +220 -0
- package/dist/session.d.ts +77 -0
- package/dist/session.js +156 -0
- package/dist/term.d.ts +69 -0
- package/dist/term.js +242 -0
- package/dist/tui/app.d.ts +8 -0
- package/dist/tui/app.js +257 -0
- package/dist/tui/theme.d.ts +23 -0
- package/dist/tui/theme.js +134 -0
- package/dist/tui/wrap.d.ts +12 -0
- package/dist/tui/wrap.js +62 -0
- package/dist/validate.d.ts +145 -0
- package/dist/validate.js +959 -0
- package/package.json +76 -0
- package/templates/.github/copilot-instructions.md +1579 -0
- package/templates/.github/prompts/new-agent.prompt.md +38 -0
- package/templates/.github/prompts/new-skill.prompt.md +37 -0
- package/templates/.github/prompts/review-project.prompt.md +31 -0
- package/templates/.github/skills/zen-cli/SKILL.md +110 -0
package/dist/tui/app.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { TextInput } from '@inkjs/ui';
|
|
3
|
+
import { Box, Static, Text, useApp, useInput, useStdout } from 'ink';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import React, { useCallback, useContext, useRef, useState } from 'react';
|
|
6
|
+
import { isCheckpoint, turns, zeroUsage } from '@zenera/neo';
|
|
7
|
+
import * as Engine from "../engine.js";
|
|
8
|
+
import { format } from "../narrate.js";
|
|
9
|
+
import { display } from "../session.js";
|
|
10
|
+
import { CliError } from "../term.js";
|
|
11
|
+
import { resolveTheme, THEMES } from "./theme.js";
|
|
12
|
+
import { windowOf } from "./wrap.js";
|
|
13
|
+
const BANNER = { key: 'banner' };
|
|
14
|
+
const isBanner = (item) => item.key === 'banner';
|
|
15
|
+
const MARK = {
|
|
16
|
+
you: '›',
|
|
17
|
+
agent: ' ',
|
|
18
|
+
tool: '·',
|
|
19
|
+
note: ' ',
|
|
20
|
+
error: '!',
|
|
21
|
+
};
|
|
22
|
+
// The theme is decided once, before the first frame, and never changes while
|
|
23
|
+
// the app is up — a terminal does not repaint its own scheme underneath us.
|
|
24
|
+
// A context rather than props only because every part of the view wants it.
|
|
25
|
+
const ThemeContext = React.createContext(THEMES.dark);
|
|
26
|
+
const useTheme = () => useContext(ThemeContext);
|
|
27
|
+
function Row({ line }) {
|
|
28
|
+
const style = useTheme().line[line.kind];
|
|
29
|
+
return (_jsxs(Box, { flexDirection: "row", marginTop: line.kind === 'you' ? 1 : 0, children: [_jsxs(Text, { color: style.color, dimColor: style.dim, children: [MARK[line.kind], ' '] }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: style.color, dimColor: style.dim, bold: line.kind === 'you', children: line.text }), line.detail ? _jsx(Text, { dimColor: true, children: line.detail }) : null] })] }));
|
|
30
|
+
}
|
|
31
|
+
function App({ engine, options, theme }) {
|
|
32
|
+
const { exit } = useApp();
|
|
33
|
+
const { stdout } = useStdout();
|
|
34
|
+
const [lines, setLines] = useState([]);
|
|
35
|
+
const [live, setLive] = useState('');
|
|
36
|
+
const [thinking, setThinking] = useState('');
|
|
37
|
+
const [busy, setBusy] = useState(false);
|
|
38
|
+
const [agent, setAgent] = useState(engine.state?.agentName ?? engine.project.entry);
|
|
39
|
+
const [stats, setStats] = useState({
|
|
40
|
+
session: engine.state?.usage ?? zeroUsage(),
|
|
41
|
+
calls: engine.state ? turns(engine.state) : 0,
|
|
42
|
+
});
|
|
43
|
+
const [tool, setTool] = useState(undefined);
|
|
44
|
+
const stopping = useRef(undefined);
|
|
45
|
+
const seq = useRef(0);
|
|
46
|
+
// Read during render so a resize, which re-renders the root, resizes the
|
|
47
|
+
// windows below with it. The two streaming blocks share one budget: what
|
|
48
|
+
// is left of the terminal once the chrome has had its rows.
|
|
49
|
+
const rows = stdout?.rows ?? 24;
|
|
50
|
+
const columns = stdout?.columns ?? 80;
|
|
51
|
+
const budget = Math.max(2, rows - CHROME_ROWS);
|
|
52
|
+
const thinkingRows = thinking ? Math.min(THINKING_ROWS, Math.max(1, budget - 2)) : 0;
|
|
53
|
+
const liveRows = Math.max(1, budget - thinkingRows);
|
|
54
|
+
const push = useCallback((kind, text, detail) => {
|
|
55
|
+
setLines((prev) => [...prev, { key: `${seq.current++}`, kind, text, detail }]);
|
|
56
|
+
}, []);
|
|
57
|
+
// Deltas arrive far faster than a terminal can usefully redraw, so text is
|
|
58
|
+
// accumulated in one string and React coalesces the repaints. The finished
|
|
59
|
+
// answer replaces it in one piece when the turn lands.
|
|
60
|
+
//
|
|
61
|
+
// Reasoning is accumulated the same way but never enters `lines`: it is a
|
|
62
|
+
// progress indicator, not part of the conversation. The full chain is in
|
|
63
|
+
// the trajectory (`LlmCallNode.thinking`) and the run's report, so nothing
|
|
64
|
+
// is lost when it is cleared at the start of the next model call.
|
|
65
|
+
const onEvent = useCallback((event) => {
|
|
66
|
+
if (!isCheckpoint(event)) {
|
|
67
|
+
if (event.type === 'text_delta') {
|
|
68
|
+
setLive((prev) => prev + event.delta);
|
|
69
|
+
}
|
|
70
|
+
else if (event.type === 'thinking_delta') {
|
|
71
|
+
setThinking((prev) => prev + event.delta);
|
|
72
|
+
}
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
switch (event.type) {
|
|
76
|
+
case 'before_llm_call':
|
|
77
|
+
setThinking('');
|
|
78
|
+
break;
|
|
79
|
+
case 'before_tool_call':
|
|
80
|
+
setTool(event.call.name);
|
|
81
|
+
break;
|
|
82
|
+
case 'after_tool_call':
|
|
83
|
+
setTool(undefined);
|
|
84
|
+
push('tool', event.node.name, event.node.isError ? 'failed' : durationOf(event.node.durationMs));
|
|
85
|
+
break;
|
|
86
|
+
case 'handoff':
|
|
87
|
+
setAgent(event.to);
|
|
88
|
+
push('note', `→ ${event.to}`, `handed off from ${event.from}`);
|
|
89
|
+
break;
|
|
90
|
+
case 'before_fork':
|
|
91
|
+
push('note', `⑂ ${event.node.branches.map((b) => b.name).join(', ')}`);
|
|
92
|
+
break;
|
|
93
|
+
case 'branch_finished':
|
|
94
|
+
push('tool', `⑂ ${event.child.name}`, event.status);
|
|
95
|
+
break;
|
|
96
|
+
default:
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}, [push]);
|
|
100
|
+
const submit = useCallback((value) => {
|
|
101
|
+
const text = value.trim();
|
|
102
|
+
if (!text || busy) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (text === '/exit' || text === '/quit') {
|
|
106
|
+
exit();
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (text === '/clear') {
|
|
110
|
+
setLines([]);
|
|
111
|
+
stdout?.write('\u001b[2J\u001b[H');
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
push('you', text);
|
|
115
|
+
setBusy(true);
|
|
116
|
+
setLive('');
|
|
117
|
+
setThinking('');
|
|
118
|
+
const controller = new AbortController();
|
|
119
|
+
stopping.current = controller;
|
|
120
|
+
void (async () => {
|
|
121
|
+
try {
|
|
122
|
+
const outcome = await Engine.run(engine, text, onEvent, controller.signal);
|
|
123
|
+
push('agent', outcome.text);
|
|
124
|
+
// The previous total is the only thing that can say what
|
|
125
|
+
// this turn cost, and reading it out of the updater is what
|
|
126
|
+
// makes that true regardless of when the turn lands.
|
|
127
|
+
setStats((prev) => ({
|
|
128
|
+
session: outcome.result.usage,
|
|
129
|
+
turn: since(prev.session, outcome.result.usage),
|
|
130
|
+
calls: turns(outcome.result.state),
|
|
131
|
+
durationMs: outcome.durationMs,
|
|
132
|
+
}));
|
|
133
|
+
setAgent(outcome.result.agent);
|
|
134
|
+
if (outcome.result.stopReason === 'aborted') {
|
|
135
|
+
push('note', 'stopped');
|
|
136
|
+
}
|
|
137
|
+
if (outcome.report) {
|
|
138
|
+
push('note', `↗ report ${pathToFileURL(outcome.report).href}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
const hint = err instanceof CliError ? err.hint : undefined;
|
|
143
|
+
push('error', err instanceof Error ? err.message : String(err), hint);
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
setBusy(false);
|
|
147
|
+
setLive('');
|
|
148
|
+
setThinking('');
|
|
149
|
+
setTool(undefined);
|
|
150
|
+
stopping.current = undefined;
|
|
151
|
+
}
|
|
152
|
+
})();
|
|
153
|
+
}, [busy, engine, exit, onEvent, push, stdout]);
|
|
154
|
+
// Escape stops the turn; ctrl-c leaves. They are different things, and a
|
|
155
|
+
// run that is asked to stop still writes its state, so the session survives
|
|
156
|
+
// either one.
|
|
157
|
+
useInput((_input, keys) => {
|
|
158
|
+
if (keys.escape && stopping.current) {
|
|
159
|
+
stopping.current.abort();
|
|
160
|
+
}
|
|
161
|
+
if (keys.ctrl && _input === 'c') {
|
|
162
|
+
stopping.current?.abort();
|
|
163
|
+
exit();
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
return (_jsx(ThemeContext.Provider, { value: theme, children: _jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: [BANNER, ...lines], children: (item) => isBanner(item) ? (_jsx(Header, { engine: engine, readOnly: options.readOnly }, item.key)) : (_jsx(Row, { line: item }, item.key)) }), thinking ? (_jsx(Thinking, { text: thinking, columns: columns, rows: thinkingRows })) : null, live ? _jsx(Live, { text: live, columns: columns, rows: liveRows }) : null, _jsx(Footer, { agent: agent, busy: busy, tool: tool, stats: stats, thinking: Boolean(thinking) }), busy ? null : (_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, children: "\u203A " }), _jsx(TextInput, { placeholder: "Ask something\u2026 (/exit to leave)", onSubmit: submit })] }))] }) }));
|
|
167
|
+
}
|
|
168
|
+
function Header({ engine, readOnly, }) {
|
|
169
|
+
const theme = useTheme();
|
|
170
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: engine.name }), _jsxs(Text, { dimColor: true, children: [" ", engine.session.id] }), readOnly ? _jsx(Text, { color: theme.warn, children: " read-only" }) : null] }), _jsx(Text, { dimColor: true, children: display(engine.workspace) })] }));
|
|
171
|
+
}
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// The repainting frame
|
|
174
|
+
//
|
|
175
|
+
// Everything below `Static` is redrawn on every event, and it has one hard
|
|
176
|
+
// constraint: **it must never be taller than the terminal.** Ink erases the
|
|
177
|
+
// previous frame by moving the cursor up over it, which only works while that
|
|
178
|
+
// frame is still on screen. A frame that outgrows the viewport scrolls its own
|
|
179
|
+
// top away, the erase falls short, and every repaint strands another copy of
|
|
180
|
+
// its first line in the scrollback — the same line, over and over, with the
|
|
181
|
+
// text creeping sideways as the stream advances.
|
|
182
|
+
//
|
|
183
|
+
// The unit that matters here is the **row the terminal draws**, not the line
|
|
184
|
+
// the model wrote. A reasoning stream is one enormous paragraph with almost no
|
|
185
|
+
// newlines in it, so counting `\n` says "six lines" while the terminal draws
|
|
186
|
+
// sixty. So `windowOf` wraps the text itself, to a width it knows, and takes
|
|
187
|
+
// the last N wrapped rows — and then the same number is given again as an
|
|
188
|
+
// explicit `height` with `overflow="hidden"`, so a miscount clips instead of
|
|
189
|
+
// corrupting.
|
|
190
|
+
//
|
|
191
|
+
// Nothing is lost by any of it: the finished answer lands in `Static` whole,
|
|
192
|
+
// and the full reasoning chain is in the trajectory and the run's report.
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
/** How much of the reasoning stream is worth showing. It is a progress bar. */
|
|
195
|
+
const THINKING_ROWS = 6;
|
|
196
|
+
/** The two footer rows, its margin, the prompt, and a row in hand. */
|
|
197
|
+
const CHROME_ROWS = 6;
|
|
198
|
+
/** The gutter every streaming block is indented behind. */
|
|
199
|
+
const GUTTER = 2;
|
|
200
|
+
function Thinking({ text, columns, rows }) {
|
|
201
|
+
const theme = useTheme();
|
|
202
|
+
const shown = windowOf(text, columns - GUTTER, rows);
|
|
203
|
+
return (_jsxs(Box, { flexDirection: "row", height: shown.length, overflow: "hidden", children: [_jsx(Box, { flexDirection: "column", width: GUTTER, children: shown.map((_, i) => (_jsx(Text, { color: theme.rule, dimColor: true, children: i === 0 ? '◇ ' : ' ' }, i))) }), _jsx(Box, { flexDirection: "column", children: shown.map((row, i) => (_jsx(Text, { dimColor: true, italic: true, wrap: "truncate-end", children: row }, i))) })] }));
|
|
204
|
+
}
|
|
205
|
+
function Live({ text, columns, rows }) {
|
|
206
|
+
const shown = windowOf(text, columns - GUTTER, rows);
|
|
207
|
+
return (_jsx(Box, { flexDirection: "column", paddingLeft: GUTTER, height: shown.length, overflow: "hidden", children: shown.map((row, i) => (_jsx(Text, { wrap: "truncate-end", children: row }, i))) }));
|
|
208
|
+
}
|
|
209
|
+
function Footer({ agent, busy, tool, stats, thinking, }) {
|
|
210
|
+
const what = tool ? `running ${tool}` : thinking ? 'reasoning' : 'thinking';
|
|
211
|
+
const theme = useTheme();
|
|
212
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [busy ? _jsxs(Text, { color: theme.warn, children: [what, "\u2026 "] }) : null, _jsx(Text, { color: theme.accent, dimColor: true, children: agent }), stats.turn ? (_jsxs(Text, { dimColor: true, children: [' turn ', tokens(stats.turn), stats.durationMs === undefined
|
|
213
|
+
? ''
|
|
214
|
+
: ` · ${durationOf(stats.durationMs) ?? ''}`] })) : null, busy ? _jsx(Text, { dimColor: true, children: ' esc to stop' }) : null] }), _jsxs(Text, { dimColor: true, children: ['session ', tokens(stats.session), stats.calls ? ` · ${stats.calls} ${stats.calls === 1 ? 'call' : 'calls'}` : ''] })] }));
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Cache and reasoning are subsets of the numbers beside them, not additions to
|
|
218
|
+
* them, and they are only worth the width when a provider actually reports one
|
|
219
|
+
* — most do not, and a row of zeroes teaches nobody anything.
|
|
220
|
+
*/
|
|
221
|
+
function tokens(usage) {
|
|
222
|
+
const parts = [`${format(usage.inputTokens)} in`];
|
|
223
|
+
if (usage.cachedInputTokens) {
|
|
224
|
+
parts.push(`${format(usage.cachedInputTokens)} cached`);
|
|
225
|
+
}
|
|
226
|
+
parts.push(`${format(usage.outputTokens)} out`);
|
|
227
|
+
if (usage.reasoningTokens) {
|
|
228
|
+
parts.push(`${format(usage.reasoningTokens)} thinking`);
|
|
229
|
+
}
|
|
230
|
+
return parts.join(' · ');
|
|
231
|
+
}
|
|
232
|
+
/** What the last turn added. Usage only ever grows, so a subtraction is safe. */
|
|
233
|
+
function since(before, after) {
|
|
234
|
+
return {
|
|
235
|
+
inputTokens: after.inputTokens - before.inputTokens,
|
|
236
|
+
cachedInputTokens: after.cachedInputTokens - before.cachedInputTokens,
|
|
237
|
+
outputTokens: after.outputTokens - before.outputTokens,
|
|
238
|
+
reasoningTokens: after.reasoningTokens - before.reasoningTokens,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
function durationOf(ms) {
|
|
242
|
+
return ms === undefined ? undefined : ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
|
|
243
|
+
}
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
export async function start(engine, options) {
|
|
246
|
+
// Asked before Ink takes the terminal: the query talks to stdin directly,
|
|
247
|
+
// and there is exactly one moment when nothing else is holding it.
|
|
248
|
+
const theme = await resolveTheme(options.theme);
|
|
249
|
+
const { render } = await import('ink');
|
|
250
|
+
// Ctrl-C is handled above so an in-flight turn can be aborted and recorded
|
|
251
|
+
// rather than the process simply vanishing mid-write.
|
|
252
|
+
const instance = render(_jsx(App, { engine: engine, options: options, theme: theme }), {
|
|
253
|
+
exitOnCtrlC: false,
|
|
254
|
+
});
|
|
255
|
+
await instance.waitUntilExit();
|
|
256
|
+
}
|
|
257
|
+
//# sourceMappingURL=app.js.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type Appearance = 'dark' | 'light';
|
|
2
|
+
/** The roles a line can play in the transcript. */
|
|
3
|
+
export type Kind = 'you' | 'agent' | 'tool' | 'note' | 'error';
|
|
4
|
+
export interface LineStyle {
|
|
5
|
+
/** `undefined` means the terminal's own foreground. */
|
|
6
|
+
readonly color?: string;
|
|
7
|
+
readonly dim?: boolean;
|
|
8
|
+
}
|
|
9
|
+
export interface Theme {
|
|
10
|
+
readonly appearance: Appearance;
|
|
11
|
+
readonly line: Record<Kind, LineStyle>;
|
|
12
|
+
/** Agent name, prompt caret — the one colour the eye is trained to find. */
|
|
13
|
+
readonly accent: string;
|
|
14
|
+
/** Read-only badge, busy label. */
|
|
15
|
+
readonly warn: string;
|
|
16
|
+
/** Gutters and marks: structure, not content. Always drawn dim. */
|
|
17
|
+
readonly rule?: string;
|
|
18
|
+
}
|
|
19
|
+
export declare const THEMES: Record<Appearance, Theme>;
|
|
20
|
+
export type ThemeChoice = Appearance | 'auto';
|
|
21
|
+
export declare function parseChoice(value: string | undefined): ThemeChoice | undefined;
|
|
22
|
+
export declare function resolveTheme(choice?: string): Promise<Theme>;
|
|
23
|
+
//# sourceMappingURL=theme.d.ts.map
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Light and dark
|
|
3
|
+
//
|
|
4
|
+
// A terminal already has a colour scheme, and it is not ours to replace. The
|
|
5
|
+
// rule here is to name as few colours as possible and to name them by role:
|
|
6
|
+
// the answer is drawn in the terminal's *own* foreground (no colour at all),
|
|
7
|
+
// asides are drawn dim, and only the few things that must stand out —
|
|
8
|
+
// the person's own turn, the agent name, a warning, an error — take a colour.
|
|
9
|
+
//
|
|
10
|
+
// That alone fixes most of it. `white` was the bug: it is legible on exactly
|
|
11
|
+
// one kind of background, and half the world runs the other kind. What is left
|
|
12
|
+
// is the handful of accents that ANSI *does* let a light theme get wrong —
|
|
13
|
+
// `cyan` on paper, `gray` on paper — so those swap.
|
|
14
|
+
//
|
|
15
|
+
// Note what is *not* here: no hex, no 256-colour ramps, no attempt at a brand.
|
|
16
|
+
// A palette that ignores the user's scheme is worse on both schemes than one
|
|
17
|
+
// that mostly defers to it.
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
const DARK = {
|
|
20
|
+
appearance: 'dark',
|
|
21
|
+
line: {
|
|
22
|
+
you: { color: 'cyan' },
|
|
23
|
+
agent: {},
|
|
24
|
+
tool: { color: 'gray', dim: true },
|
|
25
|
+
note: { color: 'cyan', dim: true },
|
|
26
|
+
error: { color: 'red' },
|
|
27
|
+
},
|
|
28
|
+
accent: 'cyan',
|
|
29
|
+
warn: 'yellow',
|
|
30
|
+
rule: 'gray',
|
|
31
|
+
};
|
|
32
|
+
// On a light background `gray` is bright black — pale grey on white — and
|
|
33
|
+
// `cyan` and `yellow` are barely darker than the paper. Dimmed default
|
|
34
|
+
// foreground and `blue`/`magenta` are the same information, still legible.
|
|
35
|
+
const LIGHT = {
|
|
36
|
+
appearance: 'light',
|
|
37
|
+
line: {
|
|
38
|
+
you: { color: 'blue' },
|
|
39
|
+
agent: {},
|
|
40
|
+
tool: { dim: true },
|
|
41
|
+
note: { color: 'blue', dim: true },
|
|
42
|
+
error: { color: 'red' },
|
|
43
|
+
},
|
|
44
|
+
accent: 'blue',
|
|
45
|
+
warn: 'magenta',
|
|
46
|
+
};
|
|
47
|
+
export const THEMES = { dark: DARK, light: LIGHT };
|
|
48
|
+
export function parseChoice(value) {
|
|
49
|
+
const v = value?.trim().toLowerCase();
|
|
50
|
+
return v === 'dark' || v === 'light' || v === 'auto' ? v : undefined;
|
|
51
|
+
}
|
|
52
|
+
export async function resolveTheme(choice) {
|
|
53
|
+
const asked = parseChoice(choice) ?? parseChoice(process.env['ZENERA_THEME']) ?? 'auto';
|
|
54
|
+
if (asked !== 'auto') {
|
|
55
|
+
return THEMES[asked];
|
|
56
|
+
}
|
|
57
|
+
return THEMES[(await queryBackground()) ?? fromColorFgBg() ?? 'dark'];
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* `COLORFGBG` is `fg;bg` or `fg;<something>;bg`; the background is the last
|
|
61
|
+
* field. Anything non-numeric (notably `default`) tells us nothing.
|
|
62
|
+
*/
|
|
63
|
+
function fromColorFgBg() {
|
|
64
|
+
const parts = process.env['COLORFGBG']?.split(';');
|
|
65
|
+
const bg = parts?.[parts.length - 1];
|
|
66
|
+
if (bg === undefined || !/^\d+$/.test(bg)) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
const n = Number(bg);
|
|
70
|
+
return n === 7 || n >= 9 ? 'light' : 'dark';
|
|
71
|
+
}
|
|
72
|
+
const QUERY = '\u001b]11;?\u0007';
|
|
73
|
+
const REPLY = /\u001b\]11;rgb:([\da-f]{1,4})\/([\da-f]{1,4})\/([\da-f]{1,4})/i;
|
|
74
|
+
/**
|
|
75
|
+
* Ask the terminal for its background colour and read the answer off stdin.
|
|
76
|
+
*
|
|
77
|
+
* This is a conversation with a program that may not be listening, so it is
|
|
78
|
+
* bounded on every axis: raw mode is taken and given back, the listener is
|
|
79
|
+
* removed either way, and a terminal that does not answer costs one timeout
|
|
80
|
+
* and nothing else. Anything the user typed in that window is put back, so
|
|
81
|
+
* the first keystroke of a fast start is not eaten by the handshake.
|
|
82
|
+
*/
|
|
83
|
+
async function queryBackground(timeoutMs = 120) {
|
|
84
|
+
const { stdin, stdout } = process;
|
|
85
|
+
if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== 'function') {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
const wasRaw = stdin.isRaw;
|
|
89
|
+
let timer;
|
|
90
|
+
let settled = false;
|
|
91
|
+
let seen = '';
|
|
92
|
+
return await new Promise((resolve) => {
|
|
93
|
+
const finish = (result) => {
|
|
94
|
+
if (settled) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
settled = true;
|
|
98
|
+
clearTimeout(timer);
|
|
99
|
+
stdin.off('data', onData);
|
|
100
|
+
stdin.setRawMode(wasRaw);
|
|
101
|
+
if (!wasRaw) {
|
|
102
|
+
stdin.pause();
|
|
103
|
+
}
|
|
104
|
+
const typed = seen.replace(REPLY, '').replace(/\u001b\\|\u0007/g, '');
|
|
105
|
+
if (typed) {
|
|
106
|
+
stdin.unshift(Buffer.from(typed, 'latin1'));
|
|
107
|
+
}
|
|
108
|
+
resolve(result);
|
|
109
|
+
};
|
|
110
|
+
const onData = (chunk) => {
|
|
111
|
+
seen += chunk.toString('latin1');
|
|
112
|
+
const m = REPLY.exec(seen);
|
|
113
|
+
if (m) {
|
|
114
|
+
finish(appearanceOf(m[1], m[2], m[3]));
|
|
115
|
+
}
|
|
116
|
+
else if (seen.length > 256) {
|
|
117
|
+
finish(undefined);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
stdin.setRawMode(true);
|
|
121
|
+
stdin.resume();
|
|
122
|
+
stdin.on('data', onData);
|
|
123
|
+
timer = setTimeout(() => finish(undefined), timeoutMs);
|
|
124
|
+
timer.unref?.();
|
|
125
|
+
stdout.write(QUERY);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
/** Components come back as 1–4 hex digits, so each is scaled by its own width. */
|
|
129
|
+
function appearanceOf(r, g, b) {
|
|
130
|
+
const channel = (hex) => parseInt(hex, 16) / (16 ** hex.length - 1);
|
|
131
|
+
const luminance = 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
|
132
|
+
return luminance > 0.5 ? 'light' : 'dark';
|
|
133
|
+
}
|
|
134
|
+
//# sourceMappingURL=theme.js.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The last `rows` rows of `text` once wrapped to `width`, each one short enough
|
|
3
|
+
* that the terminal will not wrap it again.
|
|
4
|
+
*
|
|
5
|
+
* Only the tail is ever wanted, so only the tail is wrapped: `width * rows * 2`
|
|
6
|
+
* characters is more than enough to fill the window whatever the wrapping does,
|
|
7
|
+
* and bounds the work per keystroke on a stream that never stops growing.
|
|
8
|
+
*/
|
|
9
|
+
export declare function windowOf(text: string, width: number, rows: number): string[];
|
|
10
|
+
/** Word wrap. Every returned row is at most `width` columns wide. */
|
|
11
|
+
export declare function wrap(text: string, width: number): string[];
|
|
12
|
+
//# sourceMappingURL=wrap.d.ts.map
|
package/dist/tui/wrap.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Measuring in rows
|
|
3
|
+
//
|
|
4
|
+
// The unit that matters in a repainting terminal frame is the **row the
|
|
5
|
+
// terminal draws**, not the line the model wrote. A reasoning stream is one
|
|
6
|
+
// enormous paragraph with almost no newlines in it, so counting `\n` says
|
|
7
|
+
// "six lines" while the terminal draws sixty — and a frame that outgrows the
|
|
8
|
+
// viewport is the one thing Ink cannot erase (see tui/app.tsx).
|
|
9
|
+
//
|
|
10
|
+
// So the text is wrapped here, to a width we know, and measured in what comes
|
|
11
|
+
// out. Plain text only: nothing that reaches these two functions has style
|
|
12
|
+
// codes in it, so there is no need to carry the machinery for counting around
|
|
13
|
+
// them.
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
/**
|
|
16
|
+
* The last `rows` rows of `text` once wrapped to `width`, each one short enough
|
|
17
|
+
* that the terminal will not wrap it again.
|
|
18
|
+
*
|
|
19
|
+
* Only the tail is ever wanted, so only the tail is wrapped: `width * rows * 2`
|
|
20
|
+
* characters is more than enough to fill the window whatever the wrapping does,
|
|
21
|
+
* and bounds the work per keystroke on a stream that never stops growing.
|
|
22
|
+
*/
|
|
23
|
+
export function windowOf(text, width, rows) {
|
|
24
|
+
const w = Math.max(8, width);
|
|
25
|
+
const n = Math.max(1, rows);
|
|
26
|
+
const wrapped = wrap(text.slice(-w * n * 2).replace(/\n{2,}/g, '\n'), w);
|
|
27
|
+
return wrapped.slice(-n);
|
|
28
|
+
}
|
|
29
|
+
/** Word wrap. Every returned row is at most `width` columns wide. */
|
|
30
|
+
export function wrap(text, width) {
|
|
31
|
+
const w = Math.max(1, width);
|
|
32
|
+
const out = [];
|
|
33
|
+
for (const paragraph of text.split('\n')) {
|
|
34
|
+
let line = '';
|
|
35
|
+
for (const word of paragraph.split(' ')) {
|
|
36
|
+
// A word wider than the terminal has to be broken somewhere, and
|
|
37
|
+
// anywhere is as good as anywhere else.
|
|
38
|
+
let rest = word;
|
|
39
|
+
while (rest.length > w) {
|
|
40
|
+
if (line) {
|
|
41
|
+
out.push(line);
|
|
42
|
+
line = '';
|
|
43
|
+
}
|
|
44
|
+
out.push(rest.slice(0, w));
|
|
45
|
+
rest = rest.slice(w);
|
|
46
|
+
}
|
|
47
|
+
if (!line) {
|
|
48
|
+
line = rest;
|
|
49
|
+
}
|
|
50
|
+
else if (line.length + 1 + rest.length <= w) {
|
|
51
|
+
line += ` ${rest}`;
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
out.push(line);
|
|
55
|
+
line = rest;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
out.push(line);
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=wrap.js.map
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { type AnyTool, type ProjectConfig } from '@zenera/neo';
|
|
2
|
+
import { type DeclaredRole } from './audit.ts';
|
|
3
|
+
import { type KeyStore } from './keys.ts';
|
|
4
|
+
/**
|
|
5
|
+
* `error` — the project will not load, or will not run.
|
|
6
|
+
* `warning` — it loads, and something about it is probably not what was meant.
|
|
7
|
+
* `note` — worth knowing, wrong in no sense at all.
|
|
8
|
+
*/
|
|
9
|
+
export type Severity = 'error' | 'warning' | 'note';
|
|
10
|
+
export interface Finding {
|
|
11
|
+
severity: Severity;
|
|
12
|
+
/** stable identifier, e.g. `prompt.missing` — safe to match on */
|
|
13
|
+
code: string;
|
|
14
|
+
/** the config key or path this is about, e.g. `agents.triage.system` */
|
|
15
|
+
where: string;
|
|
16
|
+
message: string;
|
|
17
|
+
/** what to do about it, naming a file or a command */
|
|
18
|
+
fix?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface FileCheck {
|
|
21
|
+
/** relative to the project root */
|
|
22
|
+
path: string;
|
|
23
|
+
/** what the file is for, in words */
|
|
24
|
+
role: string;
|
|
25
|
+
kind: 'file' | 'directory';
|
|
26
|
+
exists: boolean;
|
|
27
|
+
/** the project does not load without it */
|
|
28
|
+
required: boolean;
|
|
29
|
+
bytes?: number;
|
|
30
|
+
/** the config key that named it, when a key did rather than a convention */
|
|
31
|
+
from?: string;
|
|
32
|
+
}
|
|
33
|
+
export interface AgentReport {
|
|
34
|
+
name: string;
|
|
35
|
+
/** the agent a bare `zen run` starts on */
|
|
36
|
+
entry: boolean;
|
|
37
|
+
description?: string;
|
|
38
|
+
/** the model as written, before aliases are resolved */
|
|
39
|
+
model?: string;
|
|
40
|
+
/** where that value came from */
|
|
41
|
+
modelSource: 'agent' | 'project' | 'none';
|
|
42
|
+
/** prompt files, in the order they are concatenated */
|
|
43
|
+
instructions: string[];
|
|
44
|
+
/** `tools:` as written */
|
|
45
|
+
toolSelectors: string[];
|
|
46
|
+
/** what those selectors resolve to */
|
|
47
|
+
tools: string[];
|
|
48
|
+
handoffs: string[];
|
|
49
|
+
skills?: {
|
|
50
|
+
provider: string;
|
|
51
|
+
discovery: string;
|
|
52
|
+
allow?: string[];
|
|
53
|
+
preload?: string[];
|
|
54
|
+
};
|
|
55
|
+
fork?: {
|
|
56
|
+
agents?: string[];
|
|
57
|
+
maxBranches?: number;
|
|
58
|
+
};
|
|
59
|
+
/** true when the agent overrides the project's container */
|
|
60
|
+
ownSandbox: boolean;
|
|
61
|
+
}
|
|
62
|
+
export interface SkillReport {
|
|
63
|
+
name: string;
|
|
64
|
+
description: string;
|
|
65
|
+
/** the SKILL.md, relative to the project root */
|
|
66
|
+
path: string;
|
|
67
|
+
tools?: string[];
|
|
68
|
+
/** other files in the skill folder, which the agent gets as resources */
|
|
69
|
+
resources?: string[];
|
|
70
|
+
/** agents whose binding can see it */
|
|
71
|
+
usedBy: string[];
|
|
72
|
+
}
|
|
73
|
+
export interface ModelReport {
|
|
74
|
+
/** the alias it is declared under, or the reference itself */
|
|
75
|
+
name: string;
|
|
76
|
+
/** what the config declared it for */
|
|
77
|
+
role: DeclaredRole;
|
|
78
|
+
/** the provider it resolves to */
|
|
79
|
+
provider?: string;
|
|
80
|
+
kind?: string;
|
|
81
|
+
/** the variable that would carry the credential */
|
|
82
|
+
env?: string;
|
|
83
|
+
credential: 'present' | 'missing' | 'rejected' | 'unknown';
|
|
84
|
+
detail?: string;
|
|
85
|
+
/** agents that would use it */
|
|
86
|
+
usedBy: string[];
|
|
87
|
+
}
|
|
88
|
+
export interface Report {
|
|
89
|
+
/** no errors — the project loads */
|
|
90
|
+
ok: boolean;
|
|
91
|
+
project: {
|
|
92
|
+
name: string | null;
|
|
93
|
+
root: string;
|
|
94
|
+
/** whether the registry knows this path, i.e. whether `zen list` shows it */
|
|
95
|
+
registered: boolean;
|
|
96
|
+
/** the config file that wins, relative to the root */
|
|
97
|
+
config: string | null;
|
|
98
|
+
/** other config files present, which the loader will ignore */
|
|
99
|
+
shadowed: string[];
|
|
100
|
+
version: number | null;
|
|
101
|
+
entry: string | null;
|
|
102
|
+
};
|
|
103
|
+
files: FileCheck[];
|
|
104
|
+
agents: AgentReport[];
|
|
105
|
+
skills: {
|
|
106
|
+
/** the directories the catalog is built from */
|
|
107
|
+
dirs: string[];
|
|
108
|
+
entries: SkillReport[];
|
|
109
|
+
};
|
|
110
|
+
providers: string[];
|
|
111
|
+
models: ModelReport[];
|
|
112
|
+
sandbox: {
|
|
113
|
+
image: string | null;
|
|
114
|
+
declared: boolean;
|
|
115
|
+
used: boolean;
|
|
116
|
+
};
|
|
117
|
+
findings: Finding[];
|
|
118
|
+
counts: {
|
|
119
|
+
errors: number;
|
|
120
|
+
warnings: number;
|
|
121
|
+
notes: number;
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
export interface ValidateOptions {
|
|
125
|
+
/** the project directory */
|
|
126
|
+
dir: string;
|
|
127
|
+
/** the registered name, when there is one */
|
|
128
|
+
name?: string;
|
|
129
|
+
/**
|
|
130
|
+
* Whether the registry knows this path. Passed in rather than looked up,
|
|
131
|
+
* because nothing in this check may depend on `$HOME` being readable: the
|
|
132
|
+
* machine that is not set up yet is the one that most needs the report.
|
|
133
|
+
*/
|
|
134
|
+
registered?: boolean;
|
|
135
|
+
/**
|
|
136
|
+
* The keyring, materialised. Given, every model the project names is
|
|
137
|
+
* checked for a credential; omitted, that section says `unknown` and no
|
|
138
|
+
* finding is raised — a project is not invalid because this laptop cannot
|
|
139
|
+
* pay for it.
|
|
140
|
+
*/
|
|
141
|
+
keys?: KeyStore;
|
|
142
|
+
}
|
|
143
|
+
export declare function validateProject(opts: ValidateOptions): Promise<Report>;
|
|
144
|
+
export declare function availableTools(root: string, config: ProjectConfig): AnyTool<unknown>[];
|
|
145
|
+
//# sourceMappingURL=validate.d.ts.map
|