ucode-agent 1.62.8 → 1.64.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/src/ui/plain.js CHANGED
@@ -24,6 +24,9 @@ const COMMANDS = [
24
24
  '/new', '/remember', '/skills', '/clear', '/search', '/copy', '/exit',
25
25
  ];
26
26
 
27
+ /** The tag on an approval prompt. */
28
+ export const badge = (risk) => ({ command: ' shell ', mcp: ' tool ', trust: ' project ' })[risk] ?? ' outside project ';
29
+
27
30
  export class Plain {
28
31
  constructor({ cwd, input = process.stdin, output = process.stdout } = {}) {
29
32
  this.cwd = cwd;
@@ -293,18 +296,18 @@ export class Plain {
293
296
  return this.promptWith(`${blue('› ')}`);
294
297
  }
295
298
 
296
- async confirm({ action, detail, risk }) {
299
+ async confirm({ action, detail, risk, always }) {
297
300
  this.stopSpinner();
298
- const badge = risk === 'command' ? ' shell ' : ' outside project ';
299
- this.output.write(`\n${chalk.inverse(theme.warn(badge))} ${chalk.white(action)}\n`);
301
+ this.output.write(`\n${chalk.inverse(theme.warn(badge(risk)))} ${chalk.white(action)}\n`);
300
302
  for (const line of String(detail ?? '').split('\n')) {
301
303
  if (line) this.output.write(dim(` ${line}\n`));
302
304
  }
303
305
 
304
- const answer = await this.promptWith(`${blue(' go ahead? ')}${dim('[y/N] ')}`);
306
+ const answer = await this.promptWith(`${blue(' go ahead? ')}${dim(always ? `[y/N, a = always allow ${always}] ` : '[y/N] ')}`);
305
307
  // End of input is a no: never run something nobody approved.
306
- const yes = /^(y|yes)$/i.test(String(answer ?? '').trim());
307
- this.output.write(dim(yes ? ' approved\n\n' : ' declined\n\n'));
308
+ const said = String(answer ?? '').trim();
309
+ const yes = always && /^(a|always)$/i.test(said) ? 'always' : /^(y|yes)$/i.test(said);
310
+ this.output.write(dim(yes === 'always' ? ` approved — ${always} is always allowed here now\n\n` : yes ? ' approved\n\n' : ' declined\n\n'));
308
311
  return yes;
309
312
  }
310
313
 
package/src/ui/screen.js CHANGED
@@ -40,10 +40,11 @@ import {
40
40
  theme, blue, sky, deep, dim, edge, ADDED, REMOVED, BANNER, BANNER_WIDTH, SPINNER,
41
41
  boxTop, boxBottom, boxRow, visLen, padVis, clip, wrapAnsi,
42
42
  shortenPath, asLabel, ensureColour, planLine, bare, narration, narrationMark, groupKind, groupLabel, groupTarget, runLine, planRows, tidyReply, trimAnswer,
43
- bannerPaint, RAIL, modeChip, ADD_CHIP, asNarrationLine } from './theme.js';
43
+ bannerPaint, RAIL, modeChip, ADD_CHIP, micChip, asNarrationLine } from './theme.js';
44
44
  import { FRAME_MS, spinnerGlyph, formatDuration, doneLine, workingLine, bannerSweep, SWEEP_MS } from './activity.js';
45
45
  import { gitBranch } from '../core/git.js';
46
46
  import { renderer, render } from './markdown.js';
47
+ import { badge } from './plain.js';
47
48
  import { VERSION } from '../core/version.js';
48
49
 
49
50
  /**
@@ -60,7 +61,7 @@ export function isLabel(text) {
60
61
  export const COMMANDS = [
61
62
  '/help', '/model', '/models', '/session', '/sessions', '/resume',
62
63
  '/new', '/remember', '/skills', '/clear', '/search', '/copy', '/exit',
63
- '/stats', '/doctor', '/deploy', '/look', '/undo',
64
+ '/stats', '/doctor', '/deploy', '/look', '/undo', '/mic',
64
65
  ];
65
66
 
66
67
  // ANSI ----------------------------------------------------------------------
@@ -215,6 +216,12 @@ export class Screen {
215
216
  this.plusTo = 0;
216
217
  this.attachments = [];
217
218
  this.onAttach = null;
219
+ // The mic button: null when idle, else 'starting' | 'listening' | 'writing'.
220
+ // Ctrl+T starts and stops it; while it listens, enter stops and sends.
221
+ this.micFrom = 0;
222
+ this.micTo = 0;
223
+ this.listening = null;
224
+ this.onMic = null;
218
225
  this.onInterrupt = null;
219
226
  this.onModeChange = null;
220
227
  this.spinTimer = null;
@@ -808,7 +815,7 @@ export class Screen {
808
815
  ['dir', value(shortenPath(this.facts.cwd ?? this.cwd, room - 10))],
809
816
  branch && ['branch', value(branch)],
810
817
  VERSION && ['version', value(this.facts.update ? `${VERSION} → ${this.facts.update} next start` : VERSION)],
811
- ['keys', value('/help · esc interrupts · ctrl+b plan · ctrl+o file')],
818
+ ['keys', value('/help · ctrl+t mic · ctrl+o file · ctrl+b plan · esc stops')],
812
819
  ].filter(Boolean).slice(0, BANNER.length - 1);
813
820
 
814
821
  while (facts.length < BANNER.length - 1) facts.push(['', '']);
@@ -986,7 +993,8 @@ export class Screen {
986
993
  statusRow(width = this.width()) {
987
994
  const inner = width - 2; // the space between the two borders
988
995
  const chip = this.modeChip();
989
- const left = ` ${chip} ${ADD_CHIP} ${chalk.white(this.model || '—')}`;
996
+ const mic = micChip(this.listening);
997
+ const buttons = ` ${chip} ${ADD_CHIP} ${mic}`;
990
998
 
991
999
  // How long the turn has taken, back in the box beside the other two facts
992
1000
  // about the session. It is not on the live line: that line says what is
@@ -996,16 +1004,24 @@ export class Screen {
996
1004
  const since = this.activity?.start ?? this.status.since ?? 0;
997
1005
  const running = this.busy() && since && now - since >= 1000;
998
1006
  const right = `${running ? `${dim(formatDuration(now - since))} ` : ''}${this.percentChip()} `;
1007
+ // On a narrow terminal the model name goes before any button does.
1008
+ const withModel = `${buttons} ${chalk.white(this.model || '—')}`;
1009
+ const left = visLen(withModel) + visLen(right) < inner ? withModel : buttons;
999
1010
 
1000
1011
  // Where a click on the bottom row still counts as hitting the mode chip.
1001
1012
  this.chipTo = 2 + visLen(chip);
1002
1013
  this.plusFrom = this.chipTo + 3; // after the two spaces
1003
1014
  this.plusTo = this.plusFrom + visLen(ADD_CHIP) - 1;
1015
+ this.micFrom = this.plusTo + 3;
1016
+ this.micTo = this.micFrom + visLen(mic) - 1;
1004
1017
 
1005
1018
  const between = Math.max(1, inner - visLen(left) - visLen(right));
1006
1019
  const files = this.attachments.map((f) => path.basename(f)).join(', ');
1007
- const middle = this.flashText ? dim(clip(this.flashText, between - 2))
1008
- : files ? sky(clip(`+ ${files}`, between - 2)) : '';
1020
+ // Room for the three spaces after it and at least one before.
1021
+ const room = between - 4;
1022
+ const middle = this.listening === 'listening' ? sky(clip('speak now · enter sends · ctrl+t stops · esc cancels', room))
1023
+ : this.flashText ? dim(clip(this.flashText, room))
1024
+ : files ? sky(clip(`+ ${files}`, room)) : '';
1009
1025
 
1010
1026
  const tail = middle ? `${middle} ` : '';
1011
1027
  const pad = Math.max(1, inner - visLen(left) - visLen(tail) - visLen(right));
@@ -1188,22 +1204,23 @@ export class Screen {
1188
1204
  }
1189
1205
 
1190
1206
  /** y/n, answered on the input line. */
1191
- confirm({ action, detail, risk }) {
1207
+ confirm({ action, detail, risk, always }) {
1192
1208
  this.push('');
1193
- this.push(`${chalk.inverse(theme.warn(risk === 'command' ? ' shell ' : ' outside project '))} ${chalk.white(action)}`);
1209
+ this.push(`${chalk.inverse(theme.warn(badge(risk)))} ${chalk.white(action)}`);
1194
1210
  for (const line of String(detail ?? '').split('\n')) {
1195
1211
  if (line) this.push(dim(` ${line}`));
1196
1212
  }
1197
1213
 
1198
- this.pendingPrompt = 'go ahead? [y/N]';
1214
+ this.pendingPrompt = always ? `go ahead? [y/N, a = always allow ${always}]` : 'go ahead? [y/N]';
1199
1215
  this.scroll = 0; // the question has to be on screen to be answered
1200
1216
  this.render();
1201
1217
 
1202
1218
  return this.nextLine().then((answer) => {
1203
1219
  this.pendingPrompt = null;
1204
1220
  // End of input counts as no. Never run something nobody approved.
1205
- const yes = /^(y|yes)$/i.test(String(answer ?? '').trim());
1206
- this.push(dim(yes ? ' approved' : ' declined'));
1221
+ const said = String(answer ?? '').trim();
1222
+ const yes = always && /^(a|always)$/i.test(said) ? 'always' : /^(y|yes)$/i.test(said);
1223
+ this.push(dim(yes === 'always' ? ` approved — ${always} is always allowed here now` : yes ? ' approved' : ' declined'));
1207
1224
  this.push('');
1208
1225
  return yes;
1209
1226
  });
@@ -1430,12 +1447,30 @@ export class Screen {
1430
1447
  if (row !== statusRow) return;
1431
1448
  if (col > g.left + 1 && col <= g.left + this.chipTo) this.toggleMode();
1432
1449
  else if (col >= g.left + this.plusFrom && col <= g.left + this.plusTo) this.onAttach?.();
1450
+ else if (col >= g.left + this.micFrom && col <= g.left + this.micTo) this.onMic?.('toggle');
1433
1451
  return;
1434
1452
  }
1435
- // The mode chip and the "+ file" button, at the left of the bottom row.
1453
+ // The mode chip, "+ file" and mic buttons, at the left of the bottom row.
1436
1454
  if (row !== this.rows - 1) return;
1437
1455
  if (col >= 2 && col <= this.chipTo) this.toggleMode();
1438
1456
  else if (col >= this.plusFrom && col <= this.plusTo) this.onAttach?.();
1457
+ else if (col >= this.micFrom && col <= this.micTo) this.onMic?.('toggle');
1458
+ }
1459
+
1460
+ /** The mic's state, for the chip: null, 'starting', 'listening' or 'writing'. */
1461
+ setListening(state) {
1462
+ this.listening = state;
1463
+ this.render();
1464
+ }
1465
+
1466
+ /** Words from the mic, into the box at the cursor; `send` presses enter for them. */
1467
+ insertText(text, { send = false } = {}) {
1468
+ const before = this.buffer.slice(0, this.cursor);
1469
+ const joined = before && !before.endsWith(' ') ? `${before} ${text}` : before + text;
1470
+ this.buffer = joined + this.buffer.slice(this.cursor);
1471
+ this.cursor = joined.length;
1472
+ if (send) this.onKey('\r');
1473
+ else this.render();
1439
1474
  }
1440
1475
 
1441
1476
  /** A file chosen with "+ file", to go with the next message. */
@@ -1470,6 +1505,12 @@ export class Screen {
1470
1505
  return;
1471
1506
  }
1472
1507
 
1508
+ // While the mic listens, enter means "done, send it" and esc means "never mind".
1509
+ if (this.listening === 'listening') {
1510
+ if (key === '\r' || key === '\n') { this.onMic?.('send'); return; }
1511
+ if (key === ESC || key === '\x03') { this.onMic?.('cancel'); return; }
1512
+ }
1513
+
1473
1514
  switch (key) {
1474
1515
  case '\r':
1475
1516
  case '\n': {
@@ -1516,6 +1557,10 @@ export class Screen {
1516
1557
  this.onAttach?.();
1517
1558
  return;
1518
1559
 
1560
+ case '\x14': // ctrl+t — the mic button: start listening, or stop and put the words in the box
1561
+ this.onMic?.('toggle');
1562
+ return;
1563
+
1519
1564
  case '\x15': // ctrl+u — clear the line; on an empty line, the attached files
1520
1565
  if (!this.buffer) this.attachments = [];
1521
1566
  this.buffer = this.buffer.slice(this.cursor);
package/src/ui/theme.js CHANGED
@@ -130,6 +130,13 @@ export const modeChip = (mode) =>
130
130
  /** The "+ file" button beside the mode chip, in the quieter of the two chip styles. */
131
131
  export const ADD_CHIP = PLAN_CHIP(' + file ');
132
132
 
133
+ /** The mic button beside it: quiet when idle, red while it is listening. */
134
+ const LISTEN_CHIP = chalk.bgHex('#e5484d').hex('#ffffff').bold;
135
+ export const micChip = (state) =>
136
+ state === 'listening' ? LISTEN_CHIP(' ● listening ')
137
+ : state ? PLAN_CHIP(` ${state === 'starting' ? 'starting' : 'writing'}… `)
138
+ : PLAN_CHIP(' mic ');
139
+
133
140
  /** The spinner. Braille dots, because they animate in place without jitter. */
134
141
  export const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
135
142
 
@@ -787,8 +794,30 @@ const CAVEAT = /\b(?:however|failed|couldn't|could not|cannot|can't|didn't|did n
787
794
  const ACTIONABLE = /\b(?:open|run|serve|visit|try|start|npm|npx|node|pnpm|yarn)\b|https?:\/\/|\.(?:html?|css|jsx?|tsx?|md|json|py|rs|go)\b/i;
788
795
  const BULLET = /^\s*(?:[-*•>]|\d+[.)]|[✓✔✅☑])\s+/;
789
796
 
797
+ /**
798
+ * Words that say nothing about this app, because they are said about every
799
+ * app: "a sleek, modern and intuitive interface for a seamless experience".
800
+ */
801
+ export const STOCK = /\b(?:seamless(?:ly)?|sleek|modern and clean|clean and modern|robust|intuitive|user-friendly|cutting-edge|state-of-the-art|elevate[sd]?|effortless(?:ly)?|stunning|delightful|vibrant|happy coding|feel free to|i hope (?:this|you)|let me know if)\b/i;
802
+
803
+ /**
804
+ * The closing message without its stock sentences. A sentence goes only when
805
+ * it is pure filler: one that says how to try the app, or admits something is
806
+ * unfinished, stays whatever words it uses. Never returns nothing.
807
+ */
808
+ export function withoutStock(text) {
809
+ const lines = String(text ?? '').replace(/\r/g, '').split('\n');
810
+ const out = [];
811
+ for (const line of lines) {
812
+ if (!STOCK.test(line)) { out.push(line); continue; }
813
+ const kept = line.split(/(?<=[.!?])\s+/).filter((s) => !STOCK.test(s) || ACTIONABLE.test(s) || CAVEAT.test(s));
814
+ if (kept.length) out.push(kept.join(' '));
815
+ }
816
+ return out.join('\n').trim() || lines.join('\n').trim();
817
+ }
818
+
790
819
  export function trimAnswer(text, max = ANSWER_LINES) {
791
- const all = String(text ?? '').replace(/\r/g, '').split('\n');
820
+ const all = withoutStock(text).split('\n');
792
821
 
793
822
  let start = 0;
794
823
  while (start < all.length && (!all[start].trim() || RESTATED.test(all[start].trim()))) start++;
package/ucode.js CHANGED
@@ -39,6 +39,9 @@ function parseArgs(argv) {
39
39
  else if (a === '--cwd' || a === '-C') args.cwd = path.resolve(argv[++i]);
40
40
  else if (a === '--help' || a === '-h') args.help = true;
41
41
  else if (a === '--version' || a === '-v') args.version = true;
42
+ else if (a === '--print' || a === '-p') args.print = argv[i + 1] && !argv[i + 1].startsWith('-') ? argv[++i] : '-';
43
+ else if (a === '--json') args.json = true;
44
+ else if (a === '--yes' || a === '-y') args.yes = true;
42
45
  }
43
46
 
44
47
  return args;
@@ -59,10 +62,16 @@ async function usage() {
59
62
  ' -C, --cwd <dir> work in another directory\n' +
60
63
  ' --plan start in plan mode: read and research, change nothing\n' +
61
64
  ' --debug print stack traces when something breaks\n' +
65
+ ' -p, --print <task> do one task with no keyboard, print the answer, exit\n' +
66
+ ' --json with -p: print one JSON object about the run instead\n' +
67
+ ' -y, --yes with -p: say yes to anything that would be asked\n' +
62
68
  ' -v, --version print the version\n' +
63
69
  ' -h, --help this message\n' +
64
70
  ' doctor check that everything ucode needs is working\n' +
65
- ' login <key> save your key for every folder on this machine\n\n' +
71
+ ' login <key> save your key for every folder on this machine\n' +
72
+ ' mcp add <name> <command> [args...] connect an MCP server (--url <url> for a remote one)\n' +
73
+ ' mcp list | mcp remove <name>\n\n' +
74
+ ` ${sky('Other models')} UCODE_BASE_URL=http://localhost:11434/v1 UCODE_MODEL=<model> ucode (Ollama, offline)\n\n` +
66
75
  ` ${sky('Models')}\n${models}\n\n` +
67
76
  ` Needs GEMINI_API_KEY in the environment or in ${ENV_FILE}\n` +
68
77
  ' Free keys: https://aistudio.google.com/apikey\n\n'
@@ -92,6 +101,33 @@ async function main() {
92
101
  return;
93
102
  }
94
103
 
104
+ if (process.argv[2] === 'mcp') {
105
+ const { mcpCommand } = await import('./src/core/mcpcli.js');
106
+ process.stdout.write(`${await mcpCommand(process.argv.slice(3))}\n`);
107
+ return;
108
+ }
109
+
110
+ if (args.print !== undefined) {
111
+ let prompt = args.print;
112
+ if (prompt === '-') {
113
+ const chunks = [];
114
+ for await (const chunk of process.stdin) chunks.push(chunk);
115
+ prompt = Buffer.concat(chunks).toString('utf8').trim();
116
+ }
117
+ if (!prompt) {
118
+ process.stderr.write('ucode -p needs a task: ucode -p "fix the failing test"\n');
119
+ process.exitCode = 2;
120
+ return;
121
+ }
122
+ const [, { setModel }] = await heavy();
123
+ if (args.model) setModel(args.model);
124
+ const { runHeadless } = await import('./src/core/headless.js');
125
+ const code = await runHeadless({ cwd: args.cwd, prompt, json: args.json, yes: args.yes, plan: args.plan });
126
+ // A one-shot run ends here, whatever handle something left open.
127
+ process.stdout.write('', () => process.exit(code));
128
+ return;
129
+ }
130
+
95
131
  // Update before the session starts, not after it ends.
96
132
  //
97
133
  // Installing in the background and taking effect "next time" means the run