ucode-agent 1.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.
@@ -0,0 +1,256 @@
1
+ /**
2
+ * theme.js — colour, boxes, and the string maths that keeps a terminal frame
3
+ * from tearing.
4
+ *
5
+ * Everything visual comes from here so the whole interface can be re-tinted by
6
+ * editing one block. ucode is blue: a single hue, three steps of it, and
7
+ * nothing else decorative. Red, amber and green are reserved — they mean
8
+ * failed, careful, and done, and they never appear for any other reason.
9
+ */
10
+
11
+ import chalk from 'chalk';
12
+
13
+ // One hue, three weights. Anything that needs a fourth is asking for emphasis
14
+ // it has not earned.
15
+ export const blue = chalk.hex('#4d8dff'); // structure: borders, the caret, the wordmark
16
+ export const sky = chalk.hex('#8fbcff'); // secondary: labels that still matter
17
+ export const deep = chalk.hex('#2f6fe0'); // pressed, quiet, behind
18
+ export const dim = chalk.dim;
19
+
20
+ /**
21
+ * The input box's own edge: the same blue, drawn bold.
22
+ *
23
+ * The input is the one thing on screen you act on, so it is the one box that
24
+ * gets the heavier line. Bold box-drawing renders brighter, and in most
25
+ * terminal fonts visibly thicker, which is enough to separate "where you type"
26
+ * from "what you are reading" without a second colour.
27
+ */
28
+ export const edge = chalk.hex('#4d8dff').bold;
29
+
30
+ /**
31
+ * The colour level to use for a stream, or null to leave chalk's guess alone.
32
+ *
33
+ * chalk decides from the environment, and some environments lie: TERM=dumb
34
+ * from an embedding shell, or a wrapper that strips COLORTERM. The result is a
35
+ * UI with every colour silently gone — a grey box where a blue one was drawn.
36
+ *
37
+ * The full-screen interface already depends on a terminal that understands VT
38
+ * sequences — it switches to the alternate screen and moves the cursor — and
39
+ * any terminal that handles those handles colour. So when that interface is
40
+ * running, the guess is overruled. NO_COLOR is still honoured, because that
41
+ * one is a person's explicit choice rather than an environment's accident.
42
+ */
43
+ export function colourLevel(stream, env = process.env, current = chalk.level) {
44
+ if ('NO_COLOR' in env) return null;
45
+ if (!stream?.isTTY) return null;
46
+ if (current >= 2) return null;
47
+ return env.COLORTERM === 'truecolor' || env.COLORTERM === '24bit' || process.platform === 'win32' ? 3 : 2;
48
+ }
49
+
50
+ export function ensureColour(stream) {
51
+ const level = colourLevel(stream);
52
+ if (level !== null) chalk.level = level;
53
+ }
54
+
55
+ export const theme = {
56
+ blue,
57
+ sky,
58
+ deep,
59
+ dim,
60
+ text: chalk.white,
61
+ error: chalk.red,
62
+ warn: chalk.hex('#e0a030'),
63
+ ok: chalk.hex('#3fb950'),
64
+ };
65
+
66
+ /** Tints for a diff: enough colour to scan, dim enough to read code through. */
67
+ export const ADDED = chalk.bgHex('#0e2a1a').hex('#7ee2a8');
68
+ export const REMOVED = chalk.bgHex('#331319').hex('#f2939c');
69
+
70
+ export const BANNER = [
71
+ '██╗ ██╗ ██████╗ ██████╗ ██████╗ ███████╗',
72
+ '██║ ██║██╔════╝██╔═══██╗██╔══██╗██╔════╝',
73
+ '██║ ██║██║ ██║ ██║██║ ██║█████╗ ',
74
+ '██║ ██║██║ ██║ ██║██║ ██║██╔══╝ ',
75
+ '╚██████╔╝╚██████╗╚██████╔╝██████╔╝███████╗',
76
+ ' ╚═════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝',
77
+ ];
78
+
79
+ export const BANNER_WIDTH = Math.max(...BANNER.map((r) => r.length));
80
+
81
+ /** The spinner. Braille dots, because they animate in place without jitter. */
82
+ export const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Boxes
86
+ // ---------------------------------------------------------------------------
87
+
88
+ export const BOX = {
89
+ topLeft: '╭', topRight: '╮', bottomLeft: '╰', bottomRight: '╯',
90
+ h: '─', v: '│',
91
+ };
92
+
93
+ export const boxTop = (width, paint = blue) =>
94
+ paint(BOX.topLeft + BOX.h.repeat(Math.max(0, width - 2)) + BOX.topRight);
95
+
96
+ export const boxBottom = (width, paint = blue) =>
97
+ paint(BOX.bottomLeft + BOX.h.repeat(Math.max(0, width - 2)) + BOX.bottomRight);
98
+
99
+ /** One row inside a box, padded so the right border lands in the same column. */
100
+ export const boxRow = (content, width, paint = blue) =>
101
+ paint(BOX.v) + padVis(content, Math.max(0, width - 2)) + paint(BOX.v);
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Widths, with escape codes discounted
105
+ // ---------------------------------------------------------------------------
106
+
107
+ /** The string with its colour codes stripped — what the terminal actually shows. */
108
+ export const bare = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, '');
109
+ export const visLen = (s) => bare(s).length;
110
+
111
+ /** The first `width` visible characters, with escape sequences left intact. */
112
+ export function sliceVis(s, width) {
113
+ let out = '';
114
+ let seen = 0;
115
+ for (let i = 0; i < s.length; i++) {
116
+ if (s[i] === '\x1b') {
117
+ const m = /^\x1b\[[0-9;]*m/.exec(s.slice(i));
118
+ if (m) { out += m[0]; i += m[0].length - 1; continue; }
119
+ }
120
+ if (seen >= width) break;
121
+ out += s[i];
122
+ seen++;
123
+ }
124
+ return out;
125
+ }
126
+
127
+ /** Pad or hard-cut a possibly-coloured string to an exact visible width. */
128
+ export function padVis(s, width) {
129
+ const len = visLen(s);
130
+ if (len === width) return s;
131
+ if (len < width) return s + ' '.repeat(width - len);
132
+ return `${sliceVis(s, width)}\x1b[0m`;
133
+ }
134
+
135
+ export function clip(text, max) {
136
+ const s = String(text ?? '');
137
+ if (max <= 1) return '';
138
+ return s.length > max ? `${s.slice(0, max - 1)}…` : s;
139
+ }
140
+
141
+ /**
142
+ * Word-wrap text that may already be coloured.
143
+ *
144
+ * Escape sequences have no width, and whichever styles are open at a break get
145
+ * reopened on the next line — otherwise a wrapped sentence loses its colour
146
+ * halfway through.
147
+ */
148
+ export function wrapAnsi(text, width) {
149
+ if (width < 4) return [text];
150
+
151
+ const lines = [];
152
+ let line = '';
153
+ let seen = 0;
154
+ let open = '';
155
+ let lastSpace = -1;
156
+ let lastSpaceSeen = 0;
157
+
158
+ const flush = (upto = null) => {
159
+ if (upto === null) {
160
+ lines.push(line);
161
+ line = open;
162
+ seen = 0;
163
+ } else {
164
+ lines.push(line.slice(0, upto));
165
+ const carry = line.slice(upto).replace(/^ +/, '');
166
+ line = open + carry;
167
+ seen = visLen(carry);
168
+ }
169
+ lastSpace = -1;
170
+ };
171
+
172
+ for (let i = 0; i < text.length; i++) {
173
+ if (text[i] === '\x1b') {
174
+ const m = /^\x1b\[[0-9;]*m/.exec(text.slice(i));
175
+ if (m) {
176
+ line += m[0];
177
+ open = m[0] === '\x1b[0m' ? '' : open + m[0];
178
+ i += m[0].length - 1;
179
+ continue;
180
+ }
181
+ }
182
+ if (text[i] === ' ') { lastSpace = line.length; lastSpaceSeen = seen; }
183
+ line += text[i];
184
+ seen++;
185
+ if (seen >= width) {
186
+ // Break at a word boundary unless that would leave a stub behind.
187
+ if (lastSpace > 0 && lastSpaceSeen > width * 0.4) flush(lastSpace);
188
+ else flush();
189
+ }
190
+ }
191
+
192
+ if (visLen(line)) lines.push(line);
193
+ return lines.length ? lines : [''];
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // Small formatters
198
+ // ---------------------------------------------------------------------------
199
+
200
+ export function formatTokens(n) {
201
+ if (!n) return '0';
202
+ if (n < 1000) return String(n);
203
+ if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`;
204
+ return `${(n / 1_000_000).toFixed(1)}M`;
205
+ }
206
+
207
+ export function today() {
208
+ return new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
209
+ }
210
+
211
+ /** Shorten a path for display: home becomes ~, a long middle collapses. */
212
+ export function shortenPath(p, max = 40) {
213
+ let out = String(p);
214
+ const home = process.env.USERPROFILE || process.env.HOME || '';
215
+ if (home && out.startsWith(home)) out = `~${out.slice(home.length)}`;
216
+ if (out.length <= max) return out;
217
+
218
+ const parts = out.split(/[\\/]/);
219
+ if (parts.length <= 3) return `…${out.slice(-(max - 1))}`;
220
+ const sep = out.includes('\\') ? '\\' : '/';
221
+ return `${parts[0]}${sep}…${sep}${parts.slice(-2).join(sep)}`;
222
+ }
223
+
224
+ export function relativeTime(iso) {
225
+ if (!iso) return 'unknown';
226
+ const then = new Date(iso).getTime();
227
+ if (Number.isNaN(then)) return 'unknown';
228
+
229
+ const secs = Math.max(0, Math.round((Date.now() - then) / 1000));
230
+ if (secs < 60) return 'just now';
231
+ const mins = Math.round(secs / 60);
232
+ if (mins < 60) return `${mins}m ago`;
233
+ const hours = Math.round(mins / 60);
234
+ if (hours < 24) return `${hours}h ago`;
235
+ const days = Math.round(hours / 24);
236
+ return days < 30 ? `${days}d ago` : new Date(iso).toISOString().slice(0, 10);
237
+ }
238
+
239
+ /**
240
+ * Trim a trailing full stop off a live status line.
241
+ *
242
+ * "Listing src" is a label on work in progress. "Listing src." is a sentence,
243
+ * and a sentence that ends while the thing it describes is still happening
244
+ * reads as finished when it is not. Models add the full stop by habit; this
245
+ * takes it back off.
246
+ *
247
+ * Only after a word, though. A dot that follows a space is the whole point of
248
+ * the line — "Listing ." names the current directory — and trimming that turns
249
+ * a label into a fragment.
250
+ */
251
+ export function asLabel(text) {
252
+ return String(text ?? '')
253
+ .trim()
254
+ .replace(/\s+/g, ' ')
255
+ .replace(/(?<=[\w)\]"'`])[.。]+$/, '');
256
+ }
package/ucode.js ADDED
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ucode.js — the command.
4
+ *
5
+ * Parses the arguments, builds an Agent, and gets out of the way. Everything
6
+ * of substance is under src/.
7
+ */
8
+
9
+ import path from 'node:path';
10
+ import process from 'node:process';
11
+ import { readFile } from 'node:fs/promises';
12
+ import { realpathSync } from 'node:fs';
13
+ import { pathToFileURL, fileURLToPath } from 'node:url';
14
+
15
+ import { Agent } from './src/core/loop.js';
16
+ import { setModel, modelName, MODELS, DEFAULT_MODEL, ENV_FILE } from './src/core/provider.js';
17
+ import { Plain } from './src/ui/plain.js';
18
+ import { blue, dim, sky } from './src/ui/theme.js';
19
+
20
+ function parseArgs(argv) {
21
+ const args = { debug: false, model: null, cwd: process.cwd(), help: false, plan: false, version: false };
22
+
23
+ for (let i = 0; i < argv.length; i++) {
24
+ const a = argv[i];
25
+ if (a === '--debug') args.debug = true;
26
+ else if (a === '--plan') args.plan = true;
27
+ else if (a === '--model' || a === '-m') args.model = argv[++i];
28
+ else if (a === '--cwd' || a === '-C') args.cwd = path.resolve(argv[++i]);
29
+ else if (a === '--help' || a === '-h') args.help = true;
30
+ else if (a === '--version' || a === '-v') args.version = true;
31
+ }
32
+
33
+ return args;
34
+ }
35
+
36
+ function usage() {
37
+ const entries = Object.entries(MODELS);
38
+ const width = Math.max(...entries.map(([, m]) => m.name.length));
39
+ const models = entries
40
+ .map(([id, m]) => ` ${m.name.padEnd(width)} ${dim(id)}`)
41
+ .join('\n');
42
+
43
+ process.stdout.write(
44
+ `\n ${blue('ucode')} — a terminal coding agent\n\n` +
45
+ ' ucode [options]\n\n' +
46
+ ` -m, --model <id> which model to use (default: ${modelName(DEFAULT_MODEL)})\n` +
47
+ ' -C, --cwd <dir> work in another directory\n' +
48
+ ' --plan start in plan mode: read and research, change nothing\n' +
49
+ ' --debug print stack traces when something breaks\n' +
50
+ ' -v, --version print the version\n' +
51
+ ' -h, --help this message\n\n' +
52
+ ` ${sky('Models')}\n${models}\n\n` +
53
+ ` Needs OPENROUTER_API_KEY in the environment or in ${ENV_FILE}\n` +
54
+ ' Free keys: https://openrouter.ai/keys\n\n'
55
+ );
56
+ }
57
+
58
+ async function main() {
59
+ const args = parseArgs(process.argv.slice(2));
60
+
61
+ if (args.help) return usage();
62
+
63
+ if (args.version) {
64
+ const here = path.dirname(fileURLToPath(import.meta.url));
65
+ const pkg = JSON.parse(await readFile(path.join(here, 'package.json'), 'utf8'));
66
+ process.stdout.write(`${pkg.version}\n`);
67
+ return;
68
+ }
69
+
70
+ if (args.model) {
71
+ try {
72
+ setModel(args.model);
73
+ } catch (err) {
74
+ new Plain({ cwd: args.cwd }).error(err);
75
+ process.exitCode = 1;
76
+ return;
77
+ }
78
+ }
79
+
80
+ const agent = new Agent({ cwd: args.cwd, debug: args.debug });
81
+ if (args.plan) agent.ui.mode = 'plan';
82
+
83
+ try {
84
+ await agent.start();
85
+ } catch (err) {
86
+ agent.ui.error(err, { debug: args.debug });
87
+ await agent.persist().catch(() => {});
88
+ agent.ui.close();
89
+ process.exitCode = 1;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Was this file launched, or imported?
95
+ *
96
+ * Importing it — which another front end would do to reuse Agent — must not
97
+ * open a terminal session. The obvious check is comparing `import.meta.url`
98
+ * with argv[1], and it is wrong: after `npm link`, argv[1] arrives as the path
99
+ * through the symlink in the global node_modules while `import.meta.url` has
100
+ * already been resolved to the real file. The two never match, so the command
101
+ * starts, matches nothing, and exits successfully having done absolutely
102
+ * nothing — which is a great deal harder to diagnose than a crash.
103
+ *
104
+ * Comparing real paths is what actually answers the question.
105
+ */
106
+ function launchedDirectly() {
107
+ const entry = process.argv[1];
108
+ if (!entry) return false;
109
+
110
+ const self = fileURLToPath(import.meta.url);
111
+ try {
112
+ return realpathSync(entry) === realpathSync(self);
113
+ } catch {
114
+ return pathToFileURL(entry).href === import.meta.url;
115
+ }
116
+ }
117
+
118
+ if (launchedDirectly()) main();