ucode-agent 1.63.0 → 1.65.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/core/loop.js CHANGED
@@ -29,10 +29,11 @@ import { spawn } from 'node:child_process';
29
29
  import {
30
30
  ask, model, setModel, modelName, modelList, contextLimit, rateLimits,
31
31
  estimateConversation, MODELS, DEFAULT_MODEL, PROVIDER, fallbackFor, backupFor, transcribe,
32
+ requestCount, customProvider,
32
33
  } from './provider.js';
33
34
  import {
34
35
  tools, runTool, describe, setRoot, setConfirm, setRequest,
35
- PARALLEL_SAFE, WRITES, FILE_WRITES,
36
+ PARALLEL_SAFE, WRITES, FILE_WRITES, MUTATING,
36
37
  } from '../tools/index.js';
37
38
  import { projectMap, loadMemory, hasCode, remember, MEMORY_FILE } from './context.js';
38
39
  import { autoUpdate } from './updater.js';
@@ -52,13 +53,22 @@ import { serversReadySince } from '../tools/shell.js';
52
53
  import { formatDuration } from '../ui/activity.js';
53
54
  import { MAX_FILE_OUTPUT } from '../tools/shared.js';
54
55
  import { openInBrowser } from './opener.js';
55
- import { withScope, isNoise } from './scope.js';
56
+ import { withScope, isNoise, asksToBuild, asksToChange } from './scope.js';
56
57
  import { chooseFile, projectFiles, loadAttachments } from './attach.js';
57
58
  import { startRecording, isSilent, cleanTranscript, MAX_RECORD_MS } from './voice.js';
58
59
  import { runDoctor } from './doctor.js';
59
60
  import { JS_LOGIC } from './jslogic.js';
60
61
  import { deploy } from '../tools/deploy.js';
61
62
  import { normaliseFiles } from '../tools/scaffold.js';
63
+ import { setPolicy, allowedNow, confirm } from '../tools/shared.js';
64
+ import { genericLook, genericMessage } from './genericcheck.js';
65
+ import { noteMistakes, lessonsText } from './lessons.js';
66
+ import { Snapshots } from './snapshot.js';
67
+ import { loadSettings, addAllow, isTrusted, trust, runHook } from './settings.js';
68
+ import { McpHub, readServers, USER_MCP, projectMcpFile } from './mcp.js';
69
+ import { loadCommands, expandCommand } from './commands.js';
70
+ import { SPINNERS, saveLook, look, normaliseColour } from '../ui/theme.js';
71
+ import { customizeTerminal, resetTerminal, detectTerminal, TERMINAL_NAMES } from './terminal.js';
62
72
 
63
73
  /**
64
74
  * Tool calls allowed in one turn.
@@ -124,7 +134,10 @@ const LOOKUP_NUDGE = 5;
124
134
  /** A file with a page in it — something a browser can be pointed at. */
125
135
  const PAGE = /\.(?:html?|tsx|jsx)$/i;
126
136
 
127
- const CHECKABLE = /\.(?:[cm]?[jt]sx?|py|html?)$/i;
137
+ const CHECKABLE = /\.(?:[cm]?[jt]sx?|py|html?|go|rs)$/i;
138
+
139
+ /** The model that is asked, with the user's say-so, when Flash-Lite cannot fix something. */
140
+ const STRONGER = 'gemini-3.5-flash';
128
141
 
129
142
  /** Where TypeScript keeps what it learned, so the next check is a quick one. */
130
143
  export const TSBUILDINFO = 'node_modules/.cache/ucode/types.tsbuildinfo';
@@ -338,7 +351,7 @@ function trace(event) {
338
351
  function newStats() {
339
352
  return {
340
353
  started: Date.now(), workMs: 0, turns: 0, steps: 0, tokensIn: 0, tokensOut: 0,
341
- tools: {}, failed: 0, written: 0, edited: 0, commands: 0, builds: 0, stuck: 0,
354
+ tools: {}, failed: 0, written: 0, edited: 0, commands: 0, builds: 0, stuck: 0, requests: 0,
342
355
  };
343
356
  }
344
357
 
@@ -368,6 +381,7 @@ export function statsLines(st, messages) {
368
381
  const rows = [
369
382
  ['Session', `${formatDuration(Date.now() - st.started)} open · ${formatDuration(st.workMs)} working · ${plural(st.turns, 'request')}`],
370
383
  ['Model', `${n(st.steps)} steps · ${formatTokens(st.tokensIn)} in · ${formatTokens(st.tokensOut)} out`],
384
+ ...(st.requests ? [['Requests', `${n(st.requests)} sent — what the free daily limit counts`]] : []),
371
385
  ['Tools', top],
372
386
  ['Files', `${st.written} written · ${st.edited} edited`],
373
387
  ['Commands', `${st.commands} run · ${plural(st.builds, 'build')}${st.failed ? ` · ${plural(st.failed, 'tool call')} failed` : ''}`],
@@ -557,6 +571,53 @@ export class Writing {
557
571
  /** Tools that only mean anything once there is code in the folder. */
558
572
  const LOOKUP_TOOLS = new Set(['find_symbol', 'outline', 'rename_symbol', 'type_of']);
559
573
 
574
+ /**
575
+ * A request about ucode's own look, or the terminal's - not an app's. Only
576
+ * then do the two tools below go out: "make it purple" about an app must not
577
+ * repaint ucode instead.
578
+ */
579
+ const WANTS_LOOK = /\b(?:ucode|terminal|yourself|your (?:own )?(?:colou?rs?|look|theme|style|banner|spinner|font|byline)|this (?:terminal|window))\b/i;
580
+
581
+ const lookTool = {
582
+ name: 'change_look',
583
+ description:
584
+ 'Change how ucode itself looks in the terminal - only when the user asks about ucode, not about an app. ' +
585
+ 'Sets the accent colour (borders, wordmark, chips; a lighter and a deeper step are worked out from it), ' +
586
+ 'the spinner, and the line under the logo. Saved, so it stays after a restart. reset: true goes back to ucode\'s own blue.',
587
+ parameters: {
588
+ type: 'object',
589
+ properties: {
590
+ accent: { type: 'string', description: 'A hex colour like "#ff8c2b", or a name like "orange", "teal", "pink".' },
591
+ light: { type: 'string', description: 'Optional: the lighter step (labels, the top of the wordmark).' },
592
+ deep: { type: 'string', description: 'Optional: the deeper step (the bottom of the wordmark).' },
593
+ spinner: { type: 'string', enum: Object.keys(SPINNERS), description: 'The spinner shape.' },
594
+ byline: { type: 'string', description: 'The short line beside the logo, up to 40 characters.' },
595
+ reset: { type: 'boolean', description: 'Go back to the default look.' },
596
+ },
597
+ },
598
+ };
599
+
600
+ const terminalTool = {
601
+ name: 'change_terminal',
602
+ description:
603
+ 'Change the terminal window ucode is running in - its background, text colour, cursor, font, font size, or ' +
604
+ 'see-through opacity - only when the user asks about their terminal. Windows Terminal keeps the change; ' +
605
+ 'Terminal.app changes this window; other terminals change colours for this session. The user approves it ' +
606
+ 'first. reset: true puts the terminal back.',
607
+ parameters: {
608
+ type: 'object',
609
+ properties: {
610
+ background: { type: 'string', description: 'Hex like "#1e1e2e" or a name like "navy".' },
611
+ foreground: { type: 'string', description: 'The text colour.' },
612
+ cursor: { type: 'string', description: 'The cursor colour.' },
613
+ font: { type: 'string', description: 'A font installed on this computer, e.g. "Cascadia Code" or "Menlo".' },
614
+ font_size: { type: 'integer', description: 'Font size, 6 to 48.' },
615
+ opacity: { type: 'integer', description: 'How solid the window is, 20 to 100 (Windows Terminal only).' },
616
+ reset: { type: 'boolean', description: 'Put the terminal back as it was.' },
617
+ },
618
+ },
619
+ };
620
+
560
621
  /** A request that plainly wants the internet keeps web_search in a new project. */
561
622
  const WANTS_WEB = /\b(?:search|google|web|online|internet|latest|current|news|docs|documentation|api reference|look up|find out about)\b/i;
562
623
 
@@ -576,7 +637,7 @@ const loadSkillTool = {
576
637
  },
577
638
  };
578
639
 
579
- function systemPrompt({ cwd, skills, mode, check, map, memory }) {
640
+ function systemPrompt({ cwd, skills, mode, check, map, memory, lessons }) {
580
641
  const list = catalogue(skills);
581
642
 
582
643
  return [
@@ -592,6 +653,14 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
592
653
  '',
593
654
  memory,
594
655
  ] : []),
656
+ ...(lessons ? [
657
+ '',
658
+ '## Mistakes ucode has caught in earlier builds',
659
+ '',
660
+ 'These keep coming up. Avoid them from the start - each one caught later costs a fix round.',
661
+ '',
662
+ lessons,
663
+ ] : []),
595
664
  '',
596
665
  '## How to work',
597
666
  '',
@@ -599,6 +668,8 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
599
668
  'A greeting, a question, a single word, or anything unclear gets a short plain',
600
669
  'answer - or one question back about what they would like - never an app. "What',
601
670
  'can you do?" is answered in words; "make a quiz app" is built.',
671
+ 'A question about this project or its code is answered from the files, never from a guess:',
672
+ 'read the README and the files involved first (one read_files), then answer.',
602
673
  '',
603
674
  'AN APP THAT IS AWKWARD TO USE IS NOT FINISHED. Before you call it done, look at',
604
675
  'what you built as someone using it for the first time: is the button inside its',
@@ -866,6 +937,8 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
866
937
  '- Point at code as path:line so the user can jump straight to it.',
867
938
  '- Report honestly. If a command failed or you skipped something, say so.',
868
939
  '- Length tracks the question: a one-line question gets a one-line answer.',
940
+ '- Never call your work sleek, seamless, modern, intuitive, robust or stunning. Those',
941
+ ' words are said about every app; say what this one does instead.',
869
942
  '- Brevity is about your prose and never about your work. What you build is',
870
943
  ' finished: every control wired, every state handled, no TODO left behind.',
871
944
  ...(list ? [
@@ -1012,6 +1085,82 @@ export class Agent {
1012
1085
  });
1013
1086
  this.skills = await loadSkills({ cwd: this.cwd });
1014
1087
  await this.detectCheck();
1088
+ await this.loadSettings();
1089
+ this.snaps = new Snapshots(this.cwd);
1090
+ this.undoStack = [];
1091
+ this.commands = await loadCommands(this.cwd);
1092
+ }
1093
+
1094
+ /** settings.json, both of them: what runs without asking, and the hooks. */
1095
+ async loadSettings() {
1096
+ this.settings = await loadSettings(this.cwd);
1097
+ setPolicy({
1098
+ askCommands: this.settings.commands === 'ask',
1099
+ allow: this.settings.allow,
1100
+ onAlways: (entry) => addAllow(this.cwd, entry),
1101
+ });
1102
+ }
1103
+
1104
+ /**
1105
+ * MCP servers: yours start at once, in the background, so the first prompt
1106
+ * never waits on one; this project's wait until you have approved them, the
1107
+ * first time a turn runs (see trustProject). Never throws.
1108
+ */
1109
+ startMcp({ project = false } = {}) {
1110
+ const run = async () => {
1111
+ const specs = project
1112
+ ? await readServers(projectMcpFile(this.cwd))
1113
+ : await readServers(USER_MCP);
1114
+ const fresh = specs.filter((s) => !this.mcp?.servers.has(s.name));
1115
+ if (!fresh.length) return;
1116
+ this.mcp ??= new McpHub();
1117
+ const status = await this.mcp.start(fresh, { cwd: this.cwd });
1118
+ for (const s of status.filter((x) => fresh.some((f) => f.name === x.name))) {
1119
+ this.ui.note(s.ok
1120
+ ? `MCP: ${s.name} connected · ${s.tools} tool${s.tools === 1 ? '' : 's'}`
1121
+ : `MCP: ${s.name} did not start — ${s.error}`);
1122
+ }
1123
+ };
1124
+ const started = run().catch((err) => this.ui.note(`MCP: ${err.message}`));
1125
+ this.mcpStarting = Promise.all([this.mcpStarting, started]);
1126
+ return started;
1127
+ }
1128
+
1129
+ /**
1130
+ * A project's own hooks and MCP servers run commands on this computer, so
1131
+ * they wait for a yes - once, and again whenever they change.
1132
+ */
1133
+ async trustProject() {
1134
+ if (this.trustChecked) return;
1135
+ this.trustChecked = true;
1136
+ const hooks = this.settings?.projectHooks ?? { afterEdit: [], beforeCommand: [] };
1137
+ const servers = await readServers(projectMcpFile(this.cwd));
1138
+ const wanted = { hooks, servers };
1139
+ const asks = [
1140
+ ...hooks.afterEdit.map((h) => `after each edit: ${h}`),
1141
+ ...hooks.beforeCommand.map((h) => `before each command: ${h}`),
1142
+ ...servers.map((s) => `MCP server ${s.name}: ${s.url ?? [s.command, ...(s.args ?? [])].join(' ')}`),
1143
+ ];
1144
+ if (!asks.length) return;
1145
+ let yes = await isTrusted(this.cwd, 'project', wanted);
1146
+ if (!yes) {
1147
+ yes = await Promise.resolve(this.ui.confirm({
1148
+ action: 'this project wants to run its own commands',
1149
+ detail: `${asks.join('\n')}\nFrom .ucode/ in this folder. Only say yes if you trust where it came from.`,
1150
+ risk: 'trust',
1151
+ })).catch(() => false);
1152
+ if (yes) await trust(this.cwd, 'project', wanted).catch(() => {});
1153
+ }
1154
+ this.projectTrusted = Boolean(yes);
1155
+ if (yes && servers.length) this.startMcp({ project: true });
1156
+ }
1157
+
1158
+ /** The hooks that may run: yours, and the project's once approved. */
1159
+ hooks(kind) {
1160
+ return [
1161
+ ...(this.settings?.hooks?.[kind] ?? []),
1162
+ ...(this.projectTrusted ? this.settings?.projectHooks?.[kind] ?? [] : []),
1163
+ ];
1015
1164
  }
1016
1165
 
1017
1166
  async start() {
@@ -1038,6 +1187,7 @@ export class Agent {
1038
1187
 
1039
1188
  this.showHeader();
1040
1189
  this.installSignals();
1190
+ this.startMcp();
1041
1191
 
1042
1192
  // Checked in the background; nothing here waits on it.
1043
1193
  autoUpdate({
@@ -1168,6 +1318,7 @@ export class Agent {
1168
1318
 
1169
1319
  async shutdown() {
1170
1320
  await closeBrowser().catch(() => {});
1321
+ this.mcp?.close();
1171
1322
  this.ui.stopSpinner();
1172
1323
  if (this.session.messages.length) {
1173
1324
  await this.persist();
@@ -1247,6 +1398,20 @@ export class Agent {
1247
1398
  this.declines = 0;
1248
1399
  this.apps = [];
1249
1400
  this.wrote = new Map();
1401
+ this.genericChecked = false;
1402
+ this.escalated = false;
1403
+ this.lastProblems = null;
1404
+ this.planItems = [];
1405
+ this.askedAboutPlan = false;
1406
+ this.mutated = false;
1407
+ this.building = asksToBuild(input);
1408
+ this.changing = asksToChange(input);
1409
+ const sentBefore = requestCount();
1410
+ // The whole project as it is now, for /undo — taken while the model
1411
+ // thinks, so nothing waits on it; the first write waits for it (dispatch).
1412
+ this.snapshotting = (this.snaps?.take(`before: ${titleFrom(input)}`) ?? Promise.resolve(null))
1413
+ .then((id) => (id ? { id, input } : null), () => null);
1414
+ if (this.snaps) await this.trustProject().catch(() => {});
1250
1415
  forgetReviews(); // a new request: its apps get a fresh design review
1251
1416
  setRequest(input); // create_app checks this before choosing a starter
1252
1417
  const images = await this.attachImages(input);
@@ -1268,15 +1433,23 @@ export class Agent {
1268
1433
  this.session.title = titleFrom(input);
1269
1434
  }
1270
1435
  // What the model is told about the project, fresh for this turn.
1271
- [this.map, this.memory] = await Promise.all([
1436
+ [this.map, this.memory, this.lessons] = await Promise.all([
1272
1437
  projectMap(this.cwd).catch(() => ''),
1273
1438
  loadMemory(this.cwd).catch(() => ''),
1439
+ lessonsText().catch(() => ''),
1274
1440
  ]);
1275
1441
  // Nothing to look up in an empty folder, so those tools do not go out with
1276
1442
  // the request. Decided per turn: the moment there is code, they are back.
1277
1443
  this.fresh = !hasCode(this.map);
1278
- request.content = withScope(input) + added.text;
1444
+ request.content = withScope(input, { hasCode: !this.fresh }) + added.text;
1279
1445
  this.wantsWeb = WANTS_WEB.test(input);
1446
+ this.wantsLook = WANTS_LOOK.test(input) || this.lookTurn === true;
1447
+ // Offering the tools was not enough: Flash-Lite said "Changing the look"
1448
+ // and stopped. Said on the request itself, it calls them.
1449
+ if (this.wantsLook) {
1450
+ request.content += '\n\n(From ucode: to change how ucode itself looks, call change_look; to change the ' +
1451
+ 'terminal window, call change_terminal. Call them now - do not just say you will - then say in one line what changed.)';
1452
+ }
1280
1453
  await this.persist();
1281
1454
 
1282
1455
  // A busy model was swapped for a fallback earlier; after a few minutes the
@@ -1315,6 +1488,10 @@ export class Agent {
1315
1488
  trace({ kind: 'turn', ms: Date.now() - turnStarted });
1316
1489
  this.stats.workMs += Date.now() - turnStarted;
1317
1490
  this.stats.turns++;
1491
+ this.stats.requests += requestCount() - sentBefore;
1492
+ // A turn that changed nothing has nothing for /undo to take back.
1493
+ const before = await this.snapshotting;
1494
+ if (before && this.mutated) this.undoStack.push(before);
1318
1495
  if (!finished) this.closeInterrupted();
1319
1496
  const ok = finished && !this.endedSilently;
1320
1497
  this.busy = false;
@@ -1342,12 +1519,61 @@ export class Agent {
1342
1519
  * wrong turns available to a model deciding what to do next.
1343
1520
  */
1344
1521
  toolsNow() {
1345
- const all = [...tools, loadSkillTool, planTool, delegateTool];
1522
+ const all = [...tools, loadSkillTool, planTool, delegateTool, ...(this.wantsLook ? [lookTool, terminalTool] : [])];
1346
1523
  const live = this.fresh
1347
1524
  ? all.filter((t) => !LOOKUP_TOOLS.has(t.name) && !(t.name === 'web_search' && !this.wantsWeb))
1348
1525
  : all;
1349
- if (this.ui.mode !== 'plan') return live;
1350
- return live.filter((t) => !WRITES.has(t.name));
1526
+ // MCP tools can do anything their server does, so a read-only turn gets none.
1527
+ if (this.ui.mode !== 'plan' && !this.readOnly) return [...live, ...(this.mcp?.tools() ?? [])];
1528
+ return live.filter((t) => !WRITES.has(t.name) && t !== lookTool && t !== terminalTool);
1529
+ }
1530
+
1531
+ /**
1532
+ * How hard the model thinks on this step (see provider.js for the numbers).
1533
+ * The first step of a build or a change is where the plan and the design
1534
+ * get decided, so it gets more; a fix that has already failed once gets the
1535
+ * most; everything else gets the cheap level that is still real thinking.
1536
+ */
1537
+ effortFor(step, fixRounds) {
1538
+ if (this.fixing && fixRounds >= 2) return 'high';
1539
+ if (step === 0 && (this.building || this.changing)) return 'medium';
1540
+ return 'low';
1541
+ }
1542
+
1543
+ /**
1544
+ * Everything that goes with sending problems back to the model: counting
1545
+ * them for next time, noticing when the last fix did not work, and — once,
1546
+ * with the user's say-so — moving a stuck Flash-Lite to the stronger model.
1547
+ * Returns text to add to the fix-round message.
1548
+ */
1549
+ async beforeFixRound(problems, round) {
1550
+ noteMistakes(problems).catch(() => {});
1551
+
1552
+ const keys = new Set(String(problems).split('\n').map((l) => l.trim()).filter((l) => l.length > 12));
1553
+ const repeated = this.lastProblems && [...keys].some((k) => this.lastProblems.has(k));
1554
+ this.lastProblems = keys;
1555
+ let extra = repeated
1556
+ ? '\n\nThe same problem came back after your last fix, so that fix did not work. Do not repeat ' +
1557
+ 'it: read the failing code again and take a different approach.'
1558
+ : '';
1559
+
1560
+ // Only when a fix has already failed: a new problem is not being stuck.
1561
+ if (repeated && round >= 2 && !this.escalated && /flash-lite/.test(model()) && MODELS[STRONGER] && !customProvider()) {
1562
+ this.escalated = true;
1563
+ const yes = await Promise.resolve(this.ui.confirm({
1564
+ action: `${modelName()} is stuck on this. Use ${modelName(STRONGER)} for the rest of this job?`,
1565
+ detail: `${modelName(STRONGER)} is smarter, but has only about 20 free requests a day.`,
1566
+ risk: 'model',
1567
+ })).catch(() => false);
1568
+ if (yes) {
1569
+ setModel(STRONGER);
1570
+ this.cooldownUntil = 0; // the next turn goes back to the model you chose
1571
+ this.ui.note(`switched to ${modelName(STRONGER)} for this job`);
1572
+ if (this.full) this.showHeader({ clear: false });
1573
+ extra += '\n\nA stronger model has taken over for this fix.';
1574
+ }
1575
+ }
1576
+ return extra;
1351
1577
  }
1352
1578
 
1353
1579
  /** Model, tools, model, until it answers with prose. */
@@ -1381,6 +1607,7 @@ export class Agent {
1381
1607
  const opts = {
1382
1608
  signal: this.abort.signal,
1383
1609
  onWait: (text) => this.ui.updateSpinner(text),
1610
+ effort: this.effortFor(step, fixRounds),
1384
1611
  };
1385
1612
  // Only a real terminal has somewhere to stream into.
1386
1613
  if (this.full) {
@@ -1421,10 +1648,11 @@ export class Agent {
1421
1648
  content: systemPrompt({
1422
1649
  cwd: this.cwd,
1423
1650
  skills: this.skills,
1424
- mode: this.ui.mode,
1651
+ mode: this.readOnly ? 'plan' : this.ui.mode,
1425
1652
  check: this.check,
1426
1653
  map: this.map,
1427
1654
  memory: this.memory,
1655
+ lessons: this.lessons,
1428
1656
  }),
1429
1657
  },
1430
1658
  ...dedupe(lean(this.working)),
@@ -1532,6 +1760,7 @@ export class Agent {
1532
1760
  if (problems) {
1533
1761
  fixRounds++;
1534
1762
  this.fixing = true;
1763
+ const extra = await this.beforeFixRound(problems, fixRounds);
1535
1764
  if (reply.text) this.push({ role: 'assistant', content: reply.text });
1536
1765
  this.push({
1537
1766
  role: 'user',
@@ -1539,12 +1768,29 @@ export class Agent {
1539
1768
  `ucode checked the files you changed and found errors (round ${fixRounds} of ` +
1540
1769
  `${MAX_FIX_ROUNDS}). Fix all of them with the smallest edits that do it, then ` +
1541
1770
  'finish. Change nothing else - no restyling, no rewrites of code that works.' +
1542
- `\n\n${problems}`,
1771
+ `\n\n${problems}${extra}`,
1543
1772
  });
1544
1773
  continue;
1545
1774
  }
1546
1775
  }
1547
1776
 
1777
+ // The plan it wrote, checked against what it ticked. Items left open
1778
+ // at the end are the commonest way part of a request goes missing.
1779
+ // Once a turn, and only when the turn actually changed something.
1780
+ const open = (this.planItems ?? []).filter((i) => !i.done).map((i) => String(i.text).trim());
1781
+ if (open.length && this.touched.size && !this.askedAboutPlan) {
1782
+ this.askedAboutPlan = true;
1783
+ if (reply.text) this.push({ role: 'assistant', content: reply.text });
1784
+ this.push({
1785
+ role: 'user',
1786
+ content:
1787
+ `Your plan still has ${open.length === 1 ? 'this item' : 'these items'} open: ${open.join('; ')}. ` +
1788
+ 'Finish them now. If they are already done, tick them with update_plan and finish; if one ' +
1789
+ 'cannot be done, say which and why.',
1790
+ });
1791
+ continue;
1792
+ }
1793
+
1548
1794
  // It changed code and never ran anything. Send it back once — but
1549
1795
  // only if this project's check would actually exercise what changed.
1550
1796
  // A page and a stylesheet in a repo that happens to have `npm test`
@@ -1618,12 +1864,13 @@ export class Agent {
1618
1864
  if (handed?.problems && fixRounds < MAX_FIX_ROUNDS) {
1619
1865
  fixRounds++;
1620
1866
  this.fixing = true;
1867
+ const extra = await this.beforeFixRound(handed.problems, fixRounds);
1621
1868
  this.push({
1622
1869
  role: 'user',
1623
1870
  content:
1624
1871
  `ucode opened the app and found errors (round ${fixRounds} of ${MAX_FIX_ROUNDS}). Fix all ` +
1625
1872
  'of them with the smallest edits that do it. Change nothing else. As soon as it works, ' +
1626
- `ucode hands it to the user.\n\n${handed.problems}`,
1873
+ `ucode hands it to the user.\n\n${handed.problems}${extra}`,
1627
1874
  });
1628
1875
  await this.persist();
1629
1876
  continue;
@@ -2256,12 +2503,138 @@ export class Agent {
2256
2503
  if (call.name === 'load_skill') return this.loadSkill(call.args?.name);
2257
2504
  if (call.name === 'update_plan') return this.updatePlan(call.args?.items);
2258
2505
  if (call.name === 'delegate') return this.delegate(call.args?.tasks);
2506
+ if (call.name === 'change_look') return this.changeLook(call.args ?? {});
2507
+ if (call.name === 'change_terminal') return this.changeTerminal(call.args ?? {});
2508
+ if (this.mcp?.has(call.name)) return this.callMcp(call);
2509
+
2510
+ // The snapshot /undo goes back to has to be on disk before anything changes.
2511
+ if (MUTATING.has(call.name)) {
2512
+ this.mutated = true;
2513
+ await this.snapshotting;
2514
+ }
2515
+ if (call.name === 'run_command' || call.name === 'run_commands') await this.beforeCommand(call);
2259
2516
 
2260
2517
  // Output reaches the screen as the command produces it, so a slow build is
2261
2518
  // something you watch rather than something you sit out in silence.
2262
- return runTool(call.name, call.args ?? {}, {
2519
+ const out = await runTool(call.name, call.args ?? {}, {
2263
2520
  onOutput: (lines) => this.ui.progress(lines),
2264
2521
  });
2522
+ if (FILE_WRITES.has(call.name)) await this.afterEdit(call, out);
2523
+ return out;
2524
+ }
2525
+
2526
+ /** change_look: ucode's own colours, spinner and byline, saved and shown at once. */
2527
+ changeLook(args) {
2528
+ const changes = {};
2529
+ for (const key of ['accent', 'light', 'deep', 'spinner', 'byline']) {
2530
+ if (args[key] !== undefined) changes[key] = args[key];
2531
+ }
2532
+ if (!args.reset && !Object.keys(changes).length) {
2533
+ throw new ToolFailure({
2534
+ kind: 'bad_args', attempted: 'changing how ucode looks',
2535
+ failed: 'Nothing to change was given.', fix: 'Pass accent, spinner or byline - or reset: true.',
2536
+ });
2537
+ }
2538
+ for (const key of ['accent', 'light', 'deep']) {
2539
+ if (changes[key] && !normaliseColour(changes[key])) {
2540
+ throw new ToolFailure({
2541
+ kind: 'bad_args', attempted: 'changing ucode\'s colour',
2542
+ failed: `"${changes[key]}" is not a colour ucode can read.`, fix: 'Use a hex like "#ff8c2b" or a name like "orange".',
2543
+ });
2544
+ }
2545
+ }
2546
+ const now = saveLook(changes, { reset: Boolean(args.reset) });
2547
+ this.showHeader({ clear: false });
2548
+ this.ui.render?.();
2549
+ return {
2550
+ content: `ucode now looks like this: accent ${now.accent}, spinner ${now.spinner}, byline "${now.byline}". It is saved and already on screen.`,
2551
+ summary: `accent ${now.accent} · ${now.spinner}`,
2552
+ };
2553
+ }
2554
+
2555
+ /** change_terminal: the terminal window itself, after the user says yes. */
2556
+ async changeTerminal(args) {
2557
+ const name = TERMINAL_NAMES[detectTerminal()];
2558
+ if (args.reset) {
2559
+ await confirm(`put ${name} back the way it was`, 'Undoes the changes ucode made to it.', 'write');
2560
+ const done = await resetTerminal();
2561
+ return { content: done.message, summary: done.ok ? 'terminal reset' : 'nothing to reset' };
2562
+ }
2563
+ const wanted = Object.entries(args).filter(([, v]) => v !== undefined && v !== null && v !== '').map(([k, v]) => `${k} ${v}`);
2564
+ await confirm(`change ${name}: ${wanted.join(', ')}`, 'ucode keeps a backup; "/theme terminal reset" puts it back.', 'write');
2565
+ let done;
2566
+ try {
2567
+ done = await customizeTerminal(args);
2568
+ } catch (err) {
2569
+ throw new ToolFailure({ kind: 'bad_args', attempted: `changing ${name}`, failed: err.message, fix: 'Correct the values and try again.' });
2570
+ }
2571
+ if (!done.ok) {
2572
+ throw new ToolFailure({ kind: 'not_supported', attempted: `changing ${name}`, failed: done.message, fix: 'Tell the user what this terminal allows instead.' });
2573
+ }
2574
+ return { content: done.message, summary: 'terminal changed' };
2575
+ }
2576
+
2577
+ /** A tool from an MCP server: asked about first, unless always allowed. */
2578
+ async callMcp(call) {
2579
+ const subject = `mcp:${call.name}`;
2580
+ if (!allowedNow(subject)) {
2581
+ await confirm(`use ${call.name}`, clip(JSON.stringify(call.args ?? {}), 300), 'mcp', subject);
2582
+ }
2583
+ let answer;
2584
+ try {
2585
+ answer = await this.mcp.call(call.name, call.args ?? {});
2586
+ } catch (err) {
2587
+ throw new ToolFailure({
2588
+ kind: 'mcp_failed',
2589
+ attempted: `calling ${call.name}`,
2590
+ failed: err.message,
2591
+ fix: 'Try once more if it looks temporary; otherwise carry on without it and say so.',
2592
+ });
2593
+ }
2594
+ if (answer.isError) {
2595
+ throw new ToolFailure({
2596
+ kind: 'tool_error',
2597
+ attempted: `calling ${call.name}`,
2598
+ failed: clip(answer.text, 2000),
2599
+ fix: 'Correct the arguments and call it again, or carry on without it.',
2600
+ });
2601
+ }
2602
+ return { content: answer.text, summary: `${this.mcp.serverOf(call.name)} answered` };
2603
+ }
2604
+
2605
+ /** beforeCommand hooks: any that exits non-zero stops the command. */
2606
+ async beforeCommand(call) {
2607
+ const hooks = this.hooks('beforeCommand');
2608
+ if (!hooks.length) return;
2609
+ const commands = call.name === 'run_commands'
2610
+ ? (Array.isArray(call.args?.commands) ? call.args.commands : []).map((c) => c?.command)
2611
+ : [call.args?.command];
2612
+ for (const command of commands.filter(Boolean)) {
2613
+ for (const hook of hooks) {
2614
+ const { code, output } = await runHook(hook, { cwd: this.cwd, env: { UCODE_COMMAND: String(command) } });
2615
+ if (code !== 0) {
2616
+ throw new ToolFailure({
2617
+ kind: 'blocked_by_hook',
2618
+ attempted: `running ${clip(String(command), 80)}`,
2619
+ failed: `A beforeCommand hook stopped it (${hook}): ${clip(output || `exit ${code}`, 500)}`,
2620
+ fix: 'Do it another way, or ask the user to change the hook in .ucode/settings.json.',
2621
+ });
2622
+ }
2623
+ }
2624
+ }
2625
+ }
2626
+
2627
+ /** afterEdit hooks (a formatter, say) on the files a write just changed. */
2628
+ async afterEdit(call, out) {
2629
+ const hooks = this.hooks('afterEdit');
2630
+ const files = pathsOf(call);
2631
+ if (!hooks.length || !files.length) return;
2632
+ for (const hook of hooks) {
2633
+ const { code, output } = await runHook(hook, { cwd: this.cwd, files });
2634
+ if (code !== 0 && out && typeof out.content === 'string') {
2635
+ out.content += `\n\n(The afterEdit hook \`${hook}\` failed: ${clip(output || `exit ${code}`, 400)})`;
2636
+ }
2637
+ }
2265
2638
  }
2266
2639
 
2267
2640
  /**
@@ -2320,6 +2693,7 @@ export class Agent {
2320
2693
  const list = (Array.isArray(items) ? items : [])
2321
2694
  .filter((i) => i && String(i.text ?? '').trim())
2322
2695
  .slice(0, 6);
2696
+ this.planItems = list;
2323
2697
  this.ui.plan(list);
2324
2698
  const done = list.filter((i) => i.done).length;
2325
2699
  return { content: `Plan updated: ${done} of ${list.length} done.`, summary: `${done}/${list.length}` };
@@ -2393,7 +2767,7 @@ export class Agent {
2393
2767
  { role: 'system', content: workerPrompt({ cwd: this.cwd, name, memory: this.memory, skills, map: this.map }) },
2394
2768
  { role: 'user', content: String(task.instructions) },
2395
2769
  ];
2396
- const available = this.toolsNow().filter((t) => !WORKER_EXCLUDED.has(t.name));
2770
+ const available = this.toolsNow().filter((t) => !WORKER_EXCLUDED.has(t.name) && !this.mcp?.has(t.name));
2397
2771
  const offered = new Set(available.map((t) => t.name));
2398
2772
  const wanted = process.env.UCODE_WORKER_MODEL;
2399
2773
  let workerModel = wanted && MODELS[wanted] ? wanted : model();
@@ -2416,7 +2790,7 @@ export class Agent {
2416
2790
  // again costs far less than carrying the whole history.
2417
2791
  thinWorker(messages, contextLimit(workerModel) * 0.6);
2418
2792
  try {
2419
- reply = await ask(messages, available, { signal: this.abort?.signal, model: workerModel });
2793
+ reply = await ask(messages, available, { signal: this.abort?.signal, model: workerModel, effort: 'low' });
2420
2794
  } catch (err) {
2421
2795
  // Same rule as the lead: a busy model is swapped, not a reason to stop.
2422
2796
  if (passing(err) && failovers < 6 && !this.abort?.signal.aborted) {
@@ -2664,6 +3038,13 @@ export class Agent {
2664
3038
  if (failed) problems.push(failed);
2665
3039
  }
2666
3040
 
3041
+ // A Next.js app is not handed over by handOver, so its look is checked here.
3042
+ const app = this.apps?.at(-1);
3043
+ if (app && this.appTemplate === 'next-shadcn') {
3044
+ const generic = await this.genericOnce(app);
3045
+ if (generic) problems.push(generic);
3046
+ }
3047
+
2667
3048
  const live = await this.liveErrors();
2668
3049
  if (live) problems.push(live);
2669
3050
 
@@ -2769,6 +3150,15 @@ export class Agent {
2769
3150
  return { problems: `I opened the app and looked at it:\n\n${content}` };
2770
3151
  }
2771
3152
 
3153
+ // It works; now whether it looks like every other generated app. Once a
3154
+ // turn, milliseconds of reading files, and a fix round only on a hit.
3155
+ const generic = await this.genericOnce(app);
3156
+ if (generic) {
3157
+ this.handOverPending = true;
3158
+ this.ui.runStat?.('looks generic');
3159
+ return { problems: generic };
3160
+ }
3161
+
2772
3162
  this.handOverPending = false;
2773
3163
  // "Tip Calculator is done", with the page as a link the terminal can open.
2774
3164
  const made = calls.find((c) => c.name === 'create_app' && c.args?.name)?.args.name ?? this.appName;
@@ -2787,6 +3177,14 @@ export class Agent {
2787
3177
  return { done: true };
2788
3178
  }
2789
3179
 
3180
+ /** The generated-look check (genericcheck.js) on the app just built, once a turn. */
3181
+ async genericOnce(app) {
3182
+ if (this.genericChecked || process.env.UCODE_DESIGN_CHECK === '0') return null;
3183
+ this.genericChecked = true;
3184
+ const found = await genericLook(app, this.appTemplate ?? 'plain-html').catch(() => []);
3185
+ return found.length ? genericMessage(found) : null;
3186
+ }
3187
+
2790
3188
  async lookOnceThisTurn(root, changed) {
2791
3189
  // A look that found problems is taken again after the fix round, even if
2792
3190
  // the fix only touched the page's script. Looking once a turn let a live
@@ -3012,22 +3410,192 @@ export class Agent {
3012
3410
  case '/search': return this.cmdSearch(arg);
3013
3411
  case '/copy': return this.cmdCopy();
3014
3412
  case '/stats': return this.cmdStats();
3015
- case '/undo': return this.cmdUndo();
3413
+ case '/undo': return this.cmdUndo(arg);
3016
3414
  case '/doctor': return this.cmdDoctor();
3017
3415
  case '/look': return this.cmdLook(arg);
3018
3416
  case '/deploy': return this.cmdDeploy(arg);
3417
+ case '/init': return this.cmdInit();
3418
+ case '/diff': return this.cmdDiff();
3419
+ case '/commit': return this.cmdCommit(arg);
3420
+ case '/review': return this.cmdReview();
3421
+ case '/mcp': return this.cmdMcp();
3422
+ case '/permissions': return this.cmdPermissions(arg);
3423
+ case '/theme': return this.cmdTheme(arg);
3019
3424
  case '/mic':
3020
3425
  if (this.ui.onMic) return this.mic('toggle');
3021
3426
  return this.ui.note('the mic works in the full-screen view — start ucode in a terminal window');
3022
3427
  case '/exit':
3023
3428
  case '/quit': return 'exit';
3024
3429
 
3025
- default:
3430
+ default: {
3431
+ // One of the user's own, from .ucode/commands/<name>.md.
3432
+ this.commands = await loadCommands(this.cwd);
3433
+ const own = this.commands.get(name.slice(1).toLowerCase());
3434
+ if (own) return this.turn(expandCommand(own.body, arg));
3026
3435
  this.ui.write(theme.warn(` no such command: ${name}`));
3027
3436
  this.ui.note('/help lists them.');
3437
+ }
3028
3438
  }
3029
3439
  }
3030
3440
 
3441
+ /** Run git in the project; resolves to { code, out }. */
3442
+ git(args) {
3443
+ return new Promise((resolve) => {
3444
+ let out = '';
3445
+ const child = spawn('git', args, { cwd: this.cwd, windowsHide: true });
3446
+ child.stdout.on('data', (d) => { out += d; });
3447
+ child.stderr.on('data', (d) => { out += d; });
3448
+ child.on('error', (err) => resolve({ code: -1, out: err.message }));
3449
+ child.on('close', (code) => resolve({ code, out: out.trim() }));
3450
+ });
3451
+ }
3452
+
3453
+ /** /init: read the project and write what ucode should know into UCODE.md. */
3454
+ cmdInit() {
3455
+ return this.turn(
3456
+ `Read this project - its package files, README, config and the main source folders - and write ${MEMORY_FILE} ` +
3457
+ 'in the project root for a coding agent that has never seen it: what it is, the stack, the commands to ' +
3458
+ 'install, run, test and build, the folder layout, and the conventions the code follows. Under 60 lines, ' +
3459
+ `facts only. If ${MEMORY_FILE} exists, update it and keep anything the user wrote. Do not change any other file.`,
3460
+ );
3461
+ }
3462
+
3463
+ /** /diff: what changed — since the session started, or uncommitted in git. */
3464
+ async cmdDiff() {
3465
+ const first = this.undoStack?.[0]?.id;
3466
+ if (first && (await this.snaps?.ready())) {
3467
+ const changes = await this.snaps.changedSince(first);
3468
+ if (!changes.length) { this.ui.note('nothing has changed this session'); return; }
3469
+ const word = { A: 'added', M: 'changed', D: 'deleted' };
3470
+ this.ui.blank();
3471
+ for (const c of changes) this.ui.write(` ${dim((word[c.status] ?? c.status).padEnd(8))} ${c.file}`);
3472
+ this.ui.blank();
3473
+ return;
3474
+ }
3475
+ const { code, out } = await this.git(['status', '--short']);
3476
+ if (code !== 0) { this.ui.note('nothing to compare yet — this folder is not a git repository and no turn has changed it'); return; }
3477
+ this.ui.write(out ? out.split('\n').map((l) => ` ${l}`).join('\n') : dim(' no uncommitted changes'));
3478
+ }
3479
+
3480
+ /** /commit [message]: commit everything, with a message written from the diff when none is given. */
3481
+ async cmdCommit(message) {
3482
+ const status = await this.git(['status', '--porcelain']);
3483
+ if (status.code !== 0) { this.ui.note('this folder is not a git repository'); return; }
3484
+ if (!status.out) { this.ui.note('nothing to commit'); return; }
3485
+ let text = message;
3486
+ if (!text) {
3487
+ this.ui.startSpinner('writing a commit message');
3488
+ const diff = (await this.git(['diff', 'HEAD'])).out || status.out;
3489
+ try {
3490
+ const reply = await ask([
3491
+ { role: 'system', content: 'Write a git commit message for this diff: a subject line under 70 characters in the imperative, then a blank line and up to three short lines on why. Reply with the message only.' },
3492
+ { role: 'user', content: diff.slice(0, 30_000) },
3493
+ ], [], { effort: 'low' });
3494
+ text = reply.text.replace(/^```\w*\n?|```$/g, '').trim();
3495
+ } finally {
3496
+ this.ui.stopSpinner();
3497
+ }
3498
+ }
3499
+ if (!text) { this.ui.note('no message — /commit <message> to give one'); return; }
3500
+ this.ui.write(`\n${text.split('\n').map((l) => ` ${l}`).join('\n')}\n`);
3501
+ const yes = await this.ui.confirm({ action: 'commit everything with this message', detail: `${status.out.split('\n').length} files`, risk: 'command' });
3502
+ if (!yes) { this.ui.note('not committed'); return; }
3503
+ await this.git(['add', '-A']);
3504
+ const done = await this.git(['commit', '-m', text]);
3505
+ this.ui.write(done.code === 0 ? ` ${theme.ok('✓')} committed` : theme.error(` ${done.out.split('\n')[0]}`));
3506
+ }
3507
+
3508
+ /** /review: read the uncommitted changes for bugs, without changing anything. */
3509
+ async cmdReview() {
3510
+ const diff = await this.git(['diff', 'HEAD']);
3511
+ if (diff.code !== 0 || !diff.out) { this.ui.note('no uncommitted changes to review (this needs git)'); return; }
3512
+ this.readOnly = true;
3513
+ try {
3514
+ await this.turn(
3515
+ 'Review these uncommitted changes for bugs: wrong logic, missed edge cases, broken error handling, ' +
3516
+ 'security holes. Read the surrounding code where you need it. List each real problem as file:line, ' +
3517
+ `what goes wrong and the fix - most serious first. If there are none, say so in one line.\n\n\`\`\`diff\n${diff.out.slice(0, 40_000)}\n\`\`\``,
3518
+ );
3519
+ } finally {
3520
+ this.readOnly = false;
3521
+ }
3522
+ }
3523
+
3524
+ /** /mcp: which servers are connected, and how to add one. */
3525
+ async cmdMcp() {
3526
+ await this.mcpStarting;
3527
+ const status = this.mcp?.status() ?? [];
3528
+ this.ui.blank();
3529
+ if (!status.length) this.ui.write(dim(' no MCP servers yet'));
3530
+ for (const s of status) {
3531
+ this.ui.write(` ${s.ok ? theme.ok('●') : theme.error('●')} ${blue(s.name)} ${dim(s.ok ? `${s.tools} tools` : s.error)}`);
3532
+ }
3533
+ this.ui.blank();
3534
+ this.ui.write(dim(' add one: ucode mcp add <name> <command> [args...] or ucode mcp add <name> --url <url>'));
3535
+ this.ui.write(dim(` or edit ${USER_MCP} (every project) or .ucode/mcp.json (this one)`));
3536
+ this.ui.blank();
3537
+ }
3538
+
3539
+ /**
3540
+ * /theme how ucode looks now, and what can change
3541
+ * /theme reset ucode's own blue again
3542
+ * /theme terminal reset the terminal back as it was
3543
+ * /theme orange | #ff8c2b a new accent, at once
3544
+ * /theme <anything else> asked for in words: "/theme make the terminal navy with a bigger font"
3545
+ */
3546
+ async cmdTheme(arg) {
3547
+ const said = arg.trim();
3548
+ if (!said) {
3549
+ this.ui.blank();
3550
+ this.ui.write(` ${blue('accent')} ${look.accent} ${dim('light')} ${look.light} ${dim('deep')} ${look.deep}`);
3551
+ this.ui.write(` ${blue('spinner')} ${look.spinner} ${dim(`(${Object.keys(SPINNERS).join(', ')})`)}`);
3552
+ this.ui.write(` ${blue('byline')} ${look.byline}`);
3553
+ this.ui.write(` ${blue('terminal')} ${TERMINAL_NAMES[detectTerminal()]}`);
3554
+ this.ui.blank();
3555
+ this.ui.write(dim(' /theme orange · /theme reset · /theme terminal reset · or just ask: "make ucode green and my terminal navy"'));
3556
+ this.ui.blank();
3557
+ return;
3558
+ }
3559
+ if (/^reset$/i.test(said)) {
3560
+ saveLook({}, { reset: true });
3561
+ this.showHeader({ clear: false });
3562
+ this.ui.note('ucode is back to its own blue');
3563
+ return;
3564
+ }
3565
+ if (/^terminal\s+reset$/i.test(said)) {
3566
+ const done = await resetTerminal();
3567
+ this.ui.note(done.message);
3568
+ return;
3569
+ }
3570
+ if (/^\S+$/.test(said) && normaliseColour(said)) {
3571
+ saveLook({ accent: said });
3572
+ this.showHeader({ clear: false });
3573
+ this.ui.note(`ucode is ${look.accent} now — saved for next time too`);
3574
+ return;
3575
+ }
3576
+ this.lookTurn = true;
3577
+ try {
3578
+ await this.turn(`Change how ucode or this terminal looks: ${said}`);
3579
+ } finally {
3580
+ this.lookTurn = false;
3581
+ }
3582
+ }
3583
+
3584
+ /** /permissions [ask|auto]: whether commands are asked about, and what is always allowed. */
3585
+ async cmdPermissions(arg) {
3586
+ const want = arg.trim().toLowerCase();
3587
+ if (want === 'ask' || want === 'auto') {
3588
+ const { projectSettingsFile, readJson, writeJson } = await import('./settings.js');
3589
+ const file = projectSettingsFile(this.cwd);
3590
+ await writeJson(file, { ...(await readJson(file)), commands: want });
3591
+ await this.loadSettings();
3592
+ }
3593
+ this.ui.blank();
3594
+ this.ui.write(` commands ${blue(this.settings.commands === 'ask' ? 'ask first' : 'run without asking')} ${dim('/permissions ask · /permissions auto')}`);
3595
+ this.ui.write(` allowed ${this.settings.allow.length ? this.settings.allow.join(' · ') : dim('nothing yet — answer "a" at a prompt to add')}`);
3596
+ this.ui.blank();
3597
+ }
3598
+
3031
3599
  /**
3032
3600
  * Look at the running app, because the user asked to.
3033
3601
  *
@@ -3066,7 +3634,21 @@ ${out.content}` });
3066
3634
  * one that just happened — and it says how many files it touched rather than
3067
3635
  * listing them, the way everything else here reports work.
3068
3636
  */
3069
- async cmdUndo() {
3637
+ async cmdUndo(arg = '') {
3638
+ // Snapshots first: they cover what commands changed, and go back further.
3639
+ const n = Math.max(1, Number.parseInt(arg, 10) || 1);
3640
+ if (this.undoStack?.length) {
3641
+ const take = Math.min(n, this.undoStack.length);
3642
+ const target = this.undoStack[this.undoStack.length - take];
3643
+ const { restored, removed, failed } = await this.snaps.restore(target.id);
3644
+ this.undoStack.splice(this.undoStack.length - take, take);
3645
+ const parts = [];
3646
+ if (restored.length) parts.push(`${restored.length} file${restored.length === 1 ? '' : 's'} put back`);
3647
+ if (removed.length) parts.push(`${removed.length} removed`);
3648
+ this.ui.write(` ${theme.ok('✓')} ${parts.join(', ') || 'nothing to do'} — back to before "${clip(target.input, 50)}"`);
3649
+ for (const f of failed) this.ui.write(theme.error(` could not undo ${f}`));
3650
+ return;
3651
+ }
3070
3652
  const count = changedCount();
3071
3653
  if (!count) {
3072
3654
  this.ui.note('nothing to undo — the last turn changed no files.');
@@ -3084,7 +3666,14 @@ ${out.content}` });
3084
3666
  cmdHelp() {
3085
3667
  const rows = [
3086
3668
  ['/help', 'this list'],
3087
- ['/undo', 'put back every file the last turn changed'],
3669
+ ['/undo [n]', 'put the project back as it was before the last turn (or n turns)'],
3670
+ ['/diff', 'what has changed this session'],
3671
+ ['/commit [msg]', 'commit the changes, with a message written for you'],
3672
+ ['/review', 'check the uncommitted changes for bugs, changing nothing'],
3673
+ ['/init', `read the project and write ${MEMORY_FILE} for it`],
3674
+ ['/mcp', 'connected MCP servers and their tools'],
3675
+ ['/permissions', 'ask before commands, or run them; what is always allowed'],
3676
+ ['/theme [what]', 'change how ucode or your terminal looks — or just ask in words'],
3088
3677
  ['/stats', 'time, steps and tokens this session'],
3089
3678
  ['/doctor', 'check that everything ucode needs is working'],
3090
3679
  ['/look [url]', 'open the running app and report what is on the page'],
@@ -3101,6 +3690,8 @@ ${out.content}` });
3101
3690
  ['/exit', 'save and quit'],
3102
3691
  ];
3103
3692
 
3693
+ for (const c of this.commands?.values() ?? []) rows.push([`/${c.name}`, `yours: ${c.description}`]);
3694
+
3104
3695
  this.ui.blank();
3105
3696
  for (const [command, what] of rows) {
3106
3697
  this.ui.write(` ${blue(command.padEnd(18))} ${dim(what)}`);