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.
- package/README.md +240 -0
- package/package.json +54 -0
- package/skills/build-app/SKILL.md +81 -0
- package/skills/code-review/SKILL.md +36 -0
- package/skills/debug/SKILL.md +47 -0
- package/skills/ui-ux/SKILL.md +237 -0
- package/skills/write-tests/SKILL.md +47 -0
- package/src/core/failure.js +70 -0
- package/src/core/history.js +278 -0
- package/src/core/loop.js +1146 -0
- package/src/core/provider.js +740 -0
- package/src/core/skills.js +165 -0
- package/src/core/window.js +127 -0
- package/src/tools/files.js +466 -0
- package/src/tools/index.js +394 -0
- package/src/tools/search.js +192 -0
- package/src/tools/shared.js +343 -0
- package/src/tools/shell.js +553 -0
- package/src/tools/web.js +96 -0
- package/src/ui/markdown.js +64 -0
- package/src/ui/plain.js +325 -0
- package/src/ui/screen.js +1067 -0
- package/src/ui/theme.js +256 -0
- package/ucode.js +118 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* markdown.js — turning a reply into styled terminal text.
|
|
3
|
+
*
|
|
4
|
+
* Each surface gets its own Marked instance. The shared singleton cannot be
|
|
5
|
+
* used: two markedTerminal extensions stacked on the same instance render
|
|
6
|
+
* everything twice, which shows up as stray asterisks around every bold word.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import chalk from 'chalk';
|
|
10
|
+
import { Marked } from 'marked';
|
|
11
|
+
import { markedTerminal } from 'marked-terminal';
|
|
12
|
+
import { blue, sky, dim } from './theme.js';
|
|
13
|
+
|
|
14
|
+
export function renderer(width = 80) {
|
|
15
|
+
const md = new Marked();
|
|
16
|
+
md.use(
|
|
17
|
+
markedTerminal(
|
|
18
|
+
{
|
|
19
|
+
code: chalk.reset, // cli-highlight colours the body itself
|
|
20
|
+
blockquote: dim.italic,
|
|
21
|
+
heading: blue.bold,
|
|
22
|
+
firstHeading: blue.bold,
|
|
23
|
+
strong: chalk.bold,
|
|
24
|
+
em: chalk.italic,
|
|
25
|
+
codespan: sky,
|
|
26
|
+
del: chalk.strikethrough,
|
|
27
|
+
link: blue.underline,
|
|
28
|
+
href: blue.underline,
|
|
29
|
+
hr: dim('─'.repeat(Math.max(10, Math.min(width, 100) - 2))),
|
|
30
|
+
tab: 2,
|
|
31
|
+
width: Math.max(20, Math.min(width - 2, 100)),
|
|
32
|
+
reflowText: false, // never rewrap code or tables
|
|
33
|
+
emoji: false,
|
|
34
|
+
},
|
|
35
|
+
{ ignoreIllegals: true }
|
|
36
|
+
)
|
|
37
|
+
);
|
|
38
|
+
return md;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Mop up what marked-terminal leaves behind.
|
|
43
|
+
*
|
|
44
|
+
* Inline markdown inside list items comes through untouched, so `code` and
|
|
45
|
+
* **bold** survive as literal punctuation. Bullet asterisks become real
|
|
46
|
+
* bullets at the same time.
|
|
47
|
+
*/
|
|
48
|
+
export function polish(s) {
|
|
49
|
+
return String(s)
|
|
50
|
+
// Leading whitespace can be interleaved with colour codes, so allow both.
|
|
51
|
+
.replace(/^((?:\s|\x1b\[[0-9;]*m)*)[*-] /gm, (_, lead) => `${lead}${blue('•')} `)
|
|
52
|
+
.replace(/\*\*([^*\n]+)\*\*/g, (_, t) => chalk.bold(t))
|
|
53
|
+
.replace(/`([^`\n]+)`/g, (_, t) => sky(t));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Render markdown, and never let a formatting bug swallow the answer. */
|
|
57
|
+
export function render(md, text) {
|
|
58
|
+
if (!text?.trim()) return '';
|
|
59
|
+
try {
|
|
60
|
+
return polish(String(md.parse(text))).replace(/\n{3,}/g, '\n\n').trimEnd();
|
|
61
|
+
} catch {
|
|
62
|
+
return polish(text).trimEnd();
|
|
63
|
+
}
|
|
64
|
+
}
|
package/src/ui/plain.js
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* plain.js — the interface for when there is no terminal to draw on.
|
|
3
|
+
*
|
|
4
|
+
* Piped input, CI, `echo "fix the test" | ucode`, a shell wrapper that hands
|
|
5
|
+
* over a pipe instead of a keyboard. There is no frame to repaint here, so
|
|
6
|
+
* output is simply printed in order and the prompt is one readline line.
|
|
7
|
+
*
|
|
8
|
+
* It carries the same method names as screen.js on purpose: the agent loop
|
|
9
|
+
* talks to one interface and never asks which one it got.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import readline from 'node:readline';
|
|
13
|
+
import chalk from 'chalk';
|
|
14
|
+
import {
|
|
15
|
+
theme, blue, sky, dim, boxTop, boxBottom, boxRow,
|
|
16
|
+
BANNER, BANNER_WIDTH, SPINNER, clip, shortenPath, asLabel, padVis, visLen,
|
|
17
|
+
} from './theme.js';
|
|
18
|
+
import { renderer, render } from './markdown.js';
|
|
19
|
+
|
|
20
|
+
const COMMANDS = [
|
|
21
|
+
'/help', '/model', '/models', '/session', '/sessions', '/resume',
|
|
22
|
+
'/new', '/skills', '/clear', '/search', '/copy', '/exit',
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
export class Plain {
|
|
26
|
+
constructor({ cwd, input = process.stdin, output = process.stdout } = {}) {
|
|
27
|
+
this.cwd = cwd;
|
|
28
|
+
this.output = output;
|
|
29
|
+
this.closed = false;
|
|
30
|
+
this.mode = 'build'; // no way to toggle without a keyboard; stays here
|
|
31
|
+
this.model = '';
|
|
32
|
+
this.timer = null;
|
|
33
|
+
this.frame = 0;
|
|
34
|
+
this.md = renderer(output.columns || 80);
|
|
35
|
+
|
|
36
|
+
this.rl = readline.createInterface({
|
|
37
|
+
input,
|
|
38
|
+
output,
|
|
39
|
+
historySize: 200,
|
|
40
|
+
completer(line) {
|
|
41
|
+
if (!line.startsWith('/')) return [[], line];
|
|
42
|
+
const hits = COMMANDS.filter((c) => c.startsWith(line));
|
|
43
|
+
return [hits.length ? hits : COMMANDS, line];
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Input is queued rather than read with rl.question(). When stdin is a
|
|
48
|
+
// pipe, readline emits every buffered line at once, so a question-per-turn
|
|
49
|
+
// loop would drop all but the first. Queueing behaves the same way
|
|
50
|
+
// interactively and makes piping work.
|
|
51
|
+
this.queue = [];
|
|
52
|
+
this.waiters = [];
|
|
53
|
+
|
|
54
|
+
this.rl.on('line', (line) => {
|
|
55
|
+
const clean = line.replace(/^/, ''); // strip a BOM on the first line
|
|
56
|
+
const waiter = this.waiters.shift();
|
|
57
|
+
if (waiter) waiter(clean);
|
|
58
|
+
else this.queue.push(clean);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
this.rl.on('close', () => {
|
|
62
|
+
this.closed = true;
|
|
63
|
+
while (this.waiters.length) this.waiters.shift()(null);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
width() {
|
|
68
|
+
return Math.max(30, this.output.columns || 80);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// -- output --------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
write(text = '') {
|
|
74
|
+
this.stopSpinner();
|
|
75
|
+
this.output.write(`${text}\n`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
blank() { this.write(''); }
|
|
79
|
+
note(text) { this.write(dim(` ${text}`)); }
|
|
80
|
+
|
|
81
|
+
clearScreen() {
|
|
82
|
+
this.stopSpinner();
|
|
83
|
+
this.output.write('\x1B[2J\x1B[3J\x1B[H');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
header({ cwd, model, used, limit, title }) {
|
|
87
|
+
this.stopSpinner();
|
|
88
|
+
this.model = model || this.model;
|
|
89
|
+
this.percent = limit > 0 ? Math.min(100, Math.round((used / limit) * 100)) : 0;
|
|
90
|
+
|
|
91
|
+
const width = this.width();
|
|
92
|
+
const room = Math.max(8, width - BANNER_WIDTH - 8);
|
|
93
|
+
|
|
94
|
+
const facts = [
|
|
95
|
+
['dir', shortenPath(cwd, room - 9)],
|
|
96
|
+
['keys', '/help'],
|
|
97
|
+
['', ''],
|
|
98
|
+
['', ''],
|
|
99
|
+
['', 'made with ❤️ by om dixit'],
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
// There is no input box to hang the status off here, so it goes on the
|
|
103
|
+
// last row inside the header box — still framed, still the same three
|
|
104
|
+
// facts, just attached to the only box this interface has.
|
|
105
|
+
const rows = width >= BANNER_WIDTH + 30
|
|
106
|
+
? BANNER.map((art, i) => {
|
|
107
|
+
const [label, value] = facts[i] ?? ['', ''];
|
|
108
|
+
const right = label
|
|
109
|
+
? `${dim(label.padEnd(9))}${chalk.white(clip(value, room - 9))}`
|
|
110
|
+
: (value ? dim(value) : '');
|
|
111
|
+
return ` ${blue(art)} ${right}`;
|
|
112
|
+
})
|
|
113
|
+
: [
|
|
114
|
+
` ${blue.bold('U C O D E')} ${dim('terminal coding agent')}`,
|
|
115
|
+
...facts
|
|
116
|
+
.filter(([label]) => label)
|
|
117
|
+
.map(([label, value]) => ` ${dim(label.padEnd(9))}${chalk.white(clip(value, width - 16))}`),
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
this.write('');
|
|
121
|
+
this.write(boxTop(width));
|
|
122
|
+
for (const row of rows) this.write(boxRow(row, width));
|
|
123
|
+
this.write(boxRow(this.statusRow(), width));
|
|
124
|
+
this.write(boxBottom(width));
|
|
125
|
+
this.write('');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
setFacts() { /* nothing to repaint without a frame */ }
|
|
129
|
+
|
|
130
|
+
/** The same three facts the full screen shows, on the row under the header. */
|
|
131
|
+
statusRow() {
|
|
132
|
+
const inner = this.width() - 2;
|
|
133
|
+
const chip = this.mode === 'plan' ? `${sky('◇')} ${sky('Plan')}` : `${blue('◆')} ${blue('Build')}`;
|
|
134
|
+
const left = ` ${chip} ${dim('·')} ${chalk.white(this.model || '—')}`;
|
|
135
|
+
const percent = Math.round(this.percent ?? 0);
|
|
136
|
+
const right = `${percent >= 75 ? theme.warn(`${percent}%`) : dim(`${percent}%`)} `;
|
|
137
|
+
const pad = Math.max(1, inner - visLen(left) - visLen(right));
|
|
138
|
+
return padVis(left + ' '.repeat(pad) + right, inner);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
toolCall(label) {
|
|
142
|
+
this.stopSpinner();
|
|
143
|
+
this.output.write(`${blue('●')} ${asLabel(label)}\n`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
toolResult(summary) {
|
|
147
|
+
this.stopSpinner();
|
|
148
|
+
this.output.write(dim(` └ ${summary}\n`));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
toolFailed(summary) {
|
|
152
|
+
this.stopSpinner();
|
|
153
|
+
this.output.write(`${dim(' └ ')}${theme.error(summary)}\n`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The change, with the same line-number gutter the full screen uses. */
|
|
157
|
+
diff(lines) {
|
|
158
|
+
this.stopSpinner();
|
|
159
|
+
for (const line of lines) {
|
|
160
|
+
if (line.startsWith('~')) {
|
|
161
|
+
this.output.write(` ${sky(line.slice(1))}\n`);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const added = line.startsWith('+');
|
|
165
|
+
const rest = line.slice(1);
|
|
166
|
+
const parsed = /^(\d+)\|\s?([\s\S]*)$/.exec(rest);
|
|
167
|
+
if (!parsed) {
|
|
168
|
+
this.output.write(` ${dim(rest)}\n`);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const [, number, body] = parsed;
|
|
172
|
+
const paint = added ? theme.ok : theme.error;
|
|
173
|
+
this.output.write(` ${dim(number.padStart(6))} ${paint(`${added ? '+' : '-'} ${body}`)}\n`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
commandOutput(lines) {
|
|
178
|
+
this.stopSpinner();
|
|
179
|
+
for (const line of lines) this.output.write(` ${dim(line)}\n`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
assistant(text) {
|
|
183
|
+
const out = render(this.md, text);
|
|
184
|
+
if (!out) return;
|
|
185
|
+
this.stopSpinner();
|
|
186
|
+
this.output.write(`\n${out}\n\n`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
narrate(text) {
|
|
190
|
+
const line = asLabel(text);
|
|
191
|
+
if (!line) return;
|
|
192
|
+
this.stopSpinner();
|
|
193
|
+
this.write(dim(` ⋮ ${line}`));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
progress(lines) {
|
|
197
|
+
const last = lines[lines.length - 1]?.trim();
|
|
198
|
+
if (last) this.updateSpinner(last);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Streaming has nowhere to repaint here, so the reply is printed whole when
|
|
202
|
+
// it is finished. The loop only streams into a real terminal anyway.
|
|
203
|
+
streamBegin() {}
|
|
204
|
+
streamDelta() {}
|
|
205
|
+
streamEnd() { return ''; }
|
|
206
|
+
thinkingDelta() {}
|
|
207
|
+
thinkingEnd() {}
|
|
208
|
+
|
|
209
|
+
// -- spinner -------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
startSpinner(text = 'thinking') {
|
|
212
|
+
this.stopSpinner();
|
|
213
|
+
if (!this.output.isTTY) return; // a pipe does not want animation frames
|
|
214
|
+
this.spinnerText = asLabel(text);
|
|
215
|
+
this.since = Date.now();
|
|
216
|
+
this.timer = setInterval(() => {
|
|
217
|
+
this.frame = (this.frame + 1) % SPINNER.length;
|
|
218
|
+
this.paintSpinner();
|
|
219
|
+
}, 100);
|
|
220
|
+
this.timer.unref?.();
|
|
221
|
+
this.paintSpinner();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
paintSpinner() {
|
|
225
|
+
const secs = Math.round((Date.now() - this.since) / 1000);
|
|
226
|
+
const line = ` ${blue(SPINNER[this.frame])} ${dim(this.spinnerText)}` +
|
|
227
|
+
(secs >= 2 ? dim(` ${secs}s`) : '');
|
|
228
|
+
this.output.write(`\r\x1b[K${padVis(line, this.width() - 1)}`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
updateSpinner(text) {
|
|
232
|
+
if (!this.timer) return;
|
|
233
|
+
this.spinnerText = asLabel(text);
|
|
234
|
+
this.paintSpinner();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
stopSpinner() {
|
|
238
|
+
if (!this.timer) return;
|
|
239
|
+
clearInterval(this.timer);
|
|
240
|
+
this.timer = null;
|
|
241
|
+
this.output.write('\r\x1b[K');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// -- input ---------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
nextLine() {
|
|
247
|
+
if (this.queue.length) return Promise.resolve(this.queue.shift());
|
|
248
|
+
if (this.closed) return Promise.resolve(null);
|
|
249
|
+
return new Promise((resolve) => this.waiters.push(resolve));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
promptWith(text) {
|
|
253
|
+
this.rl.setPrompt(text);
|
|
254
|
+
this.rl.prompt();
|
|
255
|
+
return this.nextLine();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
ask() {
|
|
259
|
+
this.stopSpinner();
|
|
260
|
+
return this.promptWith(`${blue('› ')}`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async confirm({ action, detail, risk }) {
|
|
264
|
+
this.stopSpinner();
|
|
265
|
+
const badge = risk === 'command' ? ' shell ' : ' outside project ';
|
|
266
|
+
this.output.write(`\n${chalk.inverse(theme.warn(badge))} ${chalk.white(action)}\n`);
|
|
267
|
+
for (const line of String(detail ?? '').split('\n')) {
|
|
268
|
+
if (line) this.output.write(dim(` ${line}\n`));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const answer = await this.promptWith(`${blue(' go ahead? ')}${dim('[y/N] ')}`);
|
|
272
|
+
// End of input is a no: never run something nobody approved.
|
|
273
|
+
const yes = /^(y|yes)$/i.test(String(answer ?? '').trim());
|
|
274
|
+
this.output.write(dim(yes ? ' approved\n\n' : ' declined\n\n'));
|
|
275
|
+
return yes;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async choose(prompt, items, { allowNone = true } = {}) {
|
|
279
|
+
this.stopSpinner();
|
|
280
|
+
items.forEach((item, i) => this.output.write(` ${blue(String(i + 1).padStart(2))}. ${item}\n`));
|
|
281
|
+
if (allowNone) this.output.write(dim(' 0. none — start fresh\n'));
|
|
282
|
+
this.output.write('\n');
|
|
283
|
+
|
|
284
|
+
const answer = await this.promptWith(`${blue('› ')}${dim(`${prompt} `)}`);
|
|
285
|
+
if (answer === null) return null;
|
|
286
|
+
|
|
287
|
+
const trimmed = String(answer).trim();
|
|
288
|
+
if (trimmed === '' || trimmed === '0') return null;
|
|
289
|
+
|
|
290
|
+
const index = Number(trimmed);
|
|
291
|
+
if (!Number.isInteger(index) || index < 1 || index > items.length) {
|
|
292
|
+
this.write(theme.warn(` "${trimmed}" is not one of 1-${items.length}. Starting fresh.`));
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
return index - 1;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
error(err, { debug = false } = {}) {
|
|
299
|
+
this.stopSpinner();
|
|
300
|
+
const known = err && typeof err === 'object' && err.attempted;
|
|
301
|
+
|
|
302
|
+
this.output.write('\n');
|
|
303
|
+
if (known) {
|
|
304
|
+
this.output.write(`${theme.error('✗')} ${chalk.white(`Failed while ${err.attempted}.`)}\n`);
|
|
305
|
+
this.output.write(` ${err.failed}\n`);
|
|
306
|
+
if (err.fix) this.output.write(` ${blue('→')} ${err.fix}\n`);
|
|
307
|
+
if (err.kind) this.output.write(dim(` (${err.kind})\n`));
|
|
308
|
+
} else {
|
|
309
|
+
this.output.write(`${theme.error('✗')} ${chalk.white('Something broke inside ucode.')}\n`);
|
|
310
|
+
this.output.write(` ${err?.message ?? String(err)}\n`);
|
|
311
|
+
this.output.write(` ${blue('→')} That is a bug in ucode rather than in your project. Re-run with --debug.\n`);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (debug) {
|
|
315
|
+
const stack = (known && err.cause?.stack) || err?.stack;
|
|
316
|
+
if (stack) this.output.write(dim(`\n${stack}\n`));
|
|
317
|
+
}
|
|
318
|
+
this.output.write('\n');
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
close() {
|
|
322
|
+
this.stopSpinner();
|
|
323
|
+
this.rl.close();
|
|
324
|
+
}
|
|
325
|
+
}
|