ucode-agent 1.3.0 → 1.4.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 CHANGED
@@ -19,7 +19,7 @@ corner:
19
19
  ╭──────────────────────────────────────────────────────────────────────────────╮
20
20
  │ › Ask anything… │
21
21
  │ │
22
- │ ◆ Build · Nemotron 3 Ultra 0% │
22
+ │ ◆ Build · North Mini Code 0% │
23
23
  ╰──────────────────────────────────────────────────────────────────────────────╯
24
24
 
25
25
 
@@ -44,7 +44,7 @@ the input, so your own words are easy to find in a long session:
44
44
  ╭──────────────────────────────────────────────────────────────────────────────────╮
45
45
  │ › now add a dark mode toggle │
46
46
  │ │
47
- │ ◆ Build · Nemotron 3 Ultra 4% │
47
+ │ ◆ Build · North Mini Code 4% │
48
48
  ╰──────────────────────────────────────────────────────────────────────────────────╯
49
49
  ```
50
50
 
@@ -88,24 +88,32 @@ properly, which is the thing an agent actually depends on.
88
88
 
89
89
  | Model | Context | For |
90
90
  | --- | --- | --- |
91
- | **Nemotron 3 Ultra** | 1M | the default — deepest reasoning, slowest to first token |
91
+ | Nemotron 3 Ultra | 1M | deepest reasoning, slowest to first token |
92
92
  | Nemotron 3.5 Lightning | 1M | the same enormous window, answers much sooner |
93
93
  | Nemotron 3 Super | 262k | strong all-rounder, quick to start |
94
94
  | Nemotron 3 Nano Omni | 256k | small, fast, reasoning tuned |
95
- | **North Mini Code** ★ | 256k | code and UI specialist reach for it on frontend work |
95
+ | **North Mini Code** ★ | 256k | the defaultbuilt for code and interface work, quick to answer |
96
96
 
97
97
  `/model` shows them and switches. `ucode -m cohere/north-mini-code:free`
98
98
  starts on one.
99
99
 
100
- Ultra is the default because the work this is for read a codebase, hold it in
101
- mind, change several files consistently is what a million-token window and a
102
- long think are for. When the wait stops being worth it, switch.
100
+ North Mini Code is the default: it is built for code and interfaces, which is most
101
+ of what ucode is asked to do, and it answers far sooner than the big reasoning
102
+ models. Switch to Ultra when a problem needs the million-token window more than
103
+ the speed.
104
+
105
+ **A busy model never stops a build.** Free endpoints are shared, and "too many
106
+ requests" is routine. ucode waits it out with growing pauses, and if the model
107
+ stays busy it carries on with the next one — North Mini Code, then Nemotron 3.5
108
+ Lightning, Super, Ultra — from exactly where it was, and tells you it switched.
109
+ If every model is busy at once it waits a minute and goes round again. Your
110
+ chosen model gets another go a few minutes later.
103
111
 
104
112
  ## What it does
105
113
 
106
- **Thirteen tools.** `read_file`, `read_files`, `write_file`, `batch_write`,
114
+ **Fourteen tools.** `read_file`, `read_files`, `write_file`, `batch_write`,
107
115
  `edit_file`, `multi_edit`, `edit_files`, `list_dir`, `glob`, `grep`,
108
- `run_command`, `run_commands`, `web_search`. Read-only calls run in parallel,
116
+ `run_command`, `run_commands`, `look_at_app`, `web_search`. Read-only calls run in parallel,
109
117
  and start the moment the model finishes writing them — while the rest of its
110
118
  reply is still arriving. Anything that writes runs on its own, in order.
111
119
 
@@ -119,6 +127,14 @@ written, its install starts in the background while the rest of the app is
119
127
  still being written. An install the model asks for later waits for that one
120
128
  instead of running twice, and anything run in that folder waits for it too.
121
129
 
130
+ **It looks at what it built.** `look_at_app` opens the running app in a real
131
+ browser — the Edge or Chrome already on your machine, so there is nothing extra
132
+ to download — at 375px and 1440px. It reports console errors, failed requests,
133
+ content that spills off a phone screen, broken images and unlabeled controls,
134
+ saves screenshots to `.ucode/screenshots`, and has Nemotron Nano Omni review them
135
+ the way a designer would. The model fixes what it finds before calling the app
136
+ done.
137
+
122
138
  **Errors fixed before you see them.** When the model says it is done, ucode
123
139
  type-checks every file it changed — `tsc --noEmit` for TypeScript projects,
124
140
  a syntax check for JavaScript and Python — and hands any errors back to fix,
@@ -154,7 +170,12 @@ them; the chip inside the input box says which is live.
154
170
 
155
171
  **Sessions.** Everything is on disk under `~/.ucode/sessions`, saved after every
156
172
  step. `/resume` lists them with what each one was actually about, the ones from
157
- this folder first.
173
+ this folder first. Press `d` twice on one to delete it — the list stays open, so
174
+ clearing out several is quick — or `/session delete 2,5`.
175
+
176
+ **It updates itself.** Each launch checks npm in the background and, if there is
177
+ a newer version, installs it while you work. The next launch is the new one.
178
+ Set `UCODE_NO_UPDATE=1` to turn that off.
158
179
 
159
180
  **A context window that folds rather than forgets.** Past 75% the oldest turns
160
181
  are summarised instead of dropped, never cutting between a tool call and its
@@ -207,6 +228,7 @@ Everything after the frontmatter is the instruction.
207
228
  | `/help` | the list |
208
229
  | `/model` | show the models and switch — `/models` does the same |
209
230
  | `/resume` | pick up an earlier conversation — `/session`, `/sessions` too |
231
+ | `/session delete 2,5` | delete saved conversations by number (or `d d` in the list) |
210
232
  | `/new` | save this one and start fresh |
211
233
  | `/remember <note>` | add a standing note to this project's `UCODE.md` |
212
234
  | `/skills` | what it knows how to do, and what is loaded |
@@ -245,7 +267,7 @@ ucode [options]
245
267
  Environment overrides: `UCODE_MODEL`, `UCODE_WORKER_MODEL` (a faster model for
246
268
  parallel workers), `UCODE_WORKER_STEPS`, `UCODE_MAX_CONTEXT_TOKENS`,
247
269
  `UCODE_MAX_STEPS`, `UCODE_MAX_TOOL_OUTPUT`, `UCODE_REQUEST_TIMEOUT_MS`,
248
- `UCODE_BASE_URL`.
270
+ `UCODE_BASE_URL`, `UCODE_NO_UPDATE`.
249
271
 
250
272
  Web search needs a Tavily key — free, 1000 searches a month, no card. Without
251
273
  one, ucode answers from what it knows and says that it could not check.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucode-agent",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "ucode - a terminal coding agent that reads, edits and runs your code, on NVIDIA and Cohere models.",
5
5
  "type": "module",
6
6
  "main": "ucode.js",
@@ -48,6 +48,7 @@
48
48
  "dotenv": "^17.4.2",
49
49
  "marked": "^15.0.12",
50
50
  "marked-terminal": "^7.3.0",
51
- "openai": "^7.4.0"
51
+ "openai": "^7.4.0",
52
+ "playwright-core": "^1.63.0"
52
53
  }
53
54
  }
@@ -141,8 +141,10 @@ Use `batch_write` for the skeleton — one call, every file.
141
141
  - `npm run build` — it type-checks and lints; a build that fails is not done.
142
142
  - Start it: `npm run dev` goes to the background on its own and comes back with
143
143
  the URL once ready. Do not start it twice.
144
- - Exercise it: `curl` the API route with real input, load the page, check the
145
- core loop end to end. A clean build proves it compiles, not that it works.
144
+ - Exercise it: `curl` the API route with real input, then `look_at_app` on every
145
+ page it loads them in a real browser at phone and desktop width and reports
146
+ errors, overflow and a visual review. A clean build proves it compiles, not
147
+ that it works.
146
148
  - Fix what you find and check again.
147
149
 
148
150
  ## 7. Definition of done
@@ -283,7 +283,11 @@ because it was first to hand means you were not deciding.
283
283
 
284
284
  ## 9. Look at it, then report
285
285
 
286
- Run it (`npm run dev` starts in the background and returns the URL) and check:
286
+ Run it (`npm run dev` starts in the background and returns the URL), then call
287
+ `look_at_app` with that URL. It opens the app in a real browser at 375px and
288
+ 1440px, reports console errors, failed requests, overflow, broken images and
289
+ unlabeled controls, and returns a designer's review of the screenshots. Fix what
290
+ it finds and look again. Then check:
287
291
 
288
292
  1. **375px wide** — no horizontal scroll, nothing overlapping or clipped. If you
289
293
  wrote no responsive rules at all, you have not done this.
package/src/core/loop.js CHANGED
@@ -17,14 +17,16 @@ import { spawn } from 'node:child_process';
17
17
 
18
18
  import {
19
19
  ask, model, setModel, modelName, modelList, contextLimit, rateLimits,
20
- MODELS, DEFAULT_MODEL, PROVIDER,
20
+ MODELS, DEFAULT_MODEL, PROVIDER, fallbackFor,
21
21
  } from './provider.js';
22
22
  import {
23
23
  tools, runTool, describe, setRoot, setConfirm, PARALLEL_SAFE, WRITES, FILE_WRITES,
24
24
  } from '../tools/index.js';
25
25
  import { projectMap, loadMemory, remember, MEMORY_FILE } from './context.js';
26
+ import { autoUpdate } from './updater.js';
27
+ import { closeBrowser } from '../tools/browser.js';
26
28
  import {
27
- newSession, save, load, list, removeAll, titleFrom,
29
+ newSession, save, load, list, remove, removeAll, titleFrom,
28
30
  } from './history.js';
29
31
  import { fold, usage, tooBig, SUMMARY_PROMPT, forSummary } from './window.js';
30
32
  import { loadSkills, catalogue, findSkill, skillMessage, autoLoadFor } from './skills.js';
@@ -64,6 +66,17 @@ const MAX_FIX_ROUNDS = 3;
64
66
  /** Files worth checking after they change. */
65
67
  const CHECKABLE = /\.(?:[cm]?[jt]sx?|py)$/i;
66
68
 
69
+ /**
70
+ * Failures that are the provider's and not the model's: busy, slow, down, or
71
+ * unreachable. None of them should end a build — the turn moves to another
72
+ * model and carries on from exactly where it was.
73
+ */
74
+ const TRANSIENT = new Set(['rate_limit', 'timeout', 'server', 'network', 'no_content']);
75
+ const MAX_FAILOVERS = 8;
76
+ const COOLDOWN = 5 * 60_000;
77
+
78
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
79
+
67
80
  /** Parallel workers at once, and how many steps each may take. */
68
81
  const MAX_WORKERS = 3;
69
82
  const WORKER_STEPS = Number(process.env.UCODE_WORKER_STEPS) || 60;
@@ -129,7 +142,7 @@ const delegateTool = {
129
142
  };
130
143
 
131
144
  /** The instructions a parallel worker starts with. */
132
- function workerPrompt({ cwd, name, memory, skills }) {
145
+ function workerPrompt({ cwd, name, memory, skills, map }) {
133
146
  return [
134
147
  `You are a ucode worker called "${name}" - one of several building parts of the same project at the same time.`,
135
148
  '',
@@ -145,7 +158,11 @@ function workerPrompt({ cwd, name, memory, skills }) {
145
158
  '- Finish what you build: real content, every state handled, no TODOs.',
146
159
  '- When done, reply with two or three sentences: what you built, in which files, and',
147
160
  ' anything the lead has to wire up.',
161
+ '- The files you own may not exist yet - create them. Do not go looking for them',
162
+ ' first. The project map below shows what does exist; read only the files whose',
163
+ ' interfaces you must match, then start writing within two or three steps.',
148
164
  ...(memory ? ['', '## Project memory', '', memory] : []),
165
+ ...(map ? ['', '## Project map', '', map] : []),
149
166
  ...(skills ? ['', '## Instructions in force', '', skills] : []),
150
167
  ].join('\n');
151
168
  }
@@ -234,6 +251,9 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
234
251
  ' writing files. Running the install yourself afterwards just waits for that one.',
235
252
  '- When you finish, ucode type-checks what you changed and hands you the errors, so',
236
253
  ' there is no need to run tsc yourself.',
254
+ '- Once the dev server is ready, run look_at_app on the pages you built and fix what',
255
+ ' it reports - errors, layout that overflows a phone, and the visual review - then',
256
+ ' look again. Do not call an interface finished before it has been looked at.',
237
257
  '- Nothing you run has a keyboard. Pass the non-interactive flag to anything that',
238
258
  ' would ask a question, or it fails instead of waiting: create-next-app --yes,',
239
259
  ' npx shadcn@latest init -d -y, npx shadcn@latest add <names> -y, npm init -y.',
@@ -373,6 +393,14 @@ export class Agent {
373
393
 
374
394
  this.showHeader();
375
395
  this.installSignals();
396
+
397
+ // Checked in the background; nothing here waits on it.
398
+ autoUpdate({
399
+ onUpdated: (version) => {
400
+ this.ui.setFacts?.({ update: version });
401
+ if (!this.ui.welcoming?.()) this.ui.note(`updated to v${version} — it takes over the next time you start ucode`);
402
+ },
403
+ });
376
404
  await this.repl();
377
405
  }
378
406
 
@@ -485,6 +513,7 @@ export class Agent {
485
513
  }
486
514
 
487
515
  async shutdown() {
516
+ await closeBrowser().catch(() => {});
488
517
  this.ui.stopSpinner();
489
518
  if (this.session.messages.length) {
490
519
  await this.persist();
@@ -559,6 +588,14 @@ export class Agent {
559
588
  ]);
560
589
  await this.persist();
561
590
 
591
+ // A busy model was swapped for a fallback earlier; after a few minutes the
592
+ // one the user chose gets another go.
593
+ this.preferred ??= model();
594
+ if (model() !== this.preferred && Date.now() > (this.cooldownUntil ?? 0)) {
595
+ setModel(this.preferred);
596
+ if (this.full) this.showHeader({ clear: false });
597
+ }
598
+
562
599
  this.busy = true;
563
600
  this.abort = new AbortController();
564
601
 
@@ -591,6 +628,8 @@ export class Agent {
591
628
  let askedToVerify = false;
592
629
  let askedToSpeak = false;
593
630
  let fixRounds = 0;
631
+ this.failovers = 0;
632
+ this.tried = new Set([model()]);
594
633
 
595
634
  this.touched = new Set();
596
635
  this.sinceCheck = new Set();
@@ -668,6 +707,10 @@ export class Agent {
668
707
  });
669
708
  continue;
670
709
  }
710
+
711
+ // Busy, slow or down: move to the next model and carry on, rather
712
+ // than ending a half-built app with an error.
713
+ if (TRANSIENT.has(err.kind) && !this.abort.signal.aborted && (await this.failover(err))) continue;
671
714
  throw err;
672
715
  }
673
716
 
@@ -917,6 +960,38 @@ export class Agent {
917
960
  });
918
961
  }
919
962
 
963
+ /**
964
+ * Switch to the next model after a provider failure. Returns false once
965
+ * there is nothing sensible left to try. When every model is busy at once,
966
+ * it waits a minute and goes round again rather than giving up.
967
+ */
968
+ async failover(err) {
969
+ if (++this.failovers > MAX_FAILOVERS) return false;
970
+ const from = model();
971
+ let next = fallbackFor(from, this.tried);
972
+
973
+ if (!next) {
974
+ const until = Date.now() + 60_000;
975
+ this.ui.startSpinner('every model is busy');
976
+ while (Date.now() < until && !this.abort?.signal.aborted) {
977
+ this.ui.updateSpinner(`every model is busy — trying again in ${Math.ceil((until - Date.now()) / 1000)}s`);
978
+ await wait(1000);
979
+ }
980
+ this.ui.stopSpinner();
981
+ if (this.abort?.signal.aborted) return false;
982
+ this.tried = new Set();
983
+ next = this.preferred && this.preferred !== from ? this.preferred : fallbackFor(from, this.tried) ?? from;
984
+ }
985
+
986
+ this.tried.add(next);
987
+ setModel(next);
988
+ this.cooldownUntil = Date.now() + COOLDOWN;
989
+ const why = err.kind === 'rate_limit' ? 'busy' : err.kind === 'timeout' ? 'too slow to answer' : 'not answering';
990
+ this.ui.note(`${modelName(from)} is ${why} — carrying on with ${modelName(next)}`);
991
+ if (this.full) this.showHeader({ clear: false });
992
+ return true;
993
+ }
994
+
920
995
  /** Run a call and settle to { out } or { err } — never throws. */
921
996
  execute(call) {
922
997
  return this.dispatch(call).then((out) => ({ out }), (err) => ({ err }));
@@ -964,10 +1039,18 @@ export class Agent {
964
1039
 
965
1040
  for (const r of results) for (const f of r.touched) { this.touched.add(f); this.sinceCheck.add(f); }
966
1041
 
1042
+ // A worker that wrote nothing has not done its part, whatever it said.
1043
+ // The lead builds those itself rather than leaving holes in the app.
1044
+ const empty = results.filter((r) => !r.touched.length).map((r) => r.name);
1045
+
967
1046
  return {
968
1047
  content: results
969
1048
  .map((r) => `## ${r.name}\n${r.summary}\nFiles changed: ${r.touched.join(', ') || 'none'}`)
970
- .join('\n\n'),
1049
+ .join('\n\n') +
1050
+ (empty.length
1051
+ ? `\n\n${empty.join(', ')} wrote no files. Build ${empty.length === 1 ? 'that part' : 'those parts'} ` +
1052
+ 'yourself now, directly - do not delegate them again.'
1053
+ : ''),
971
1054
  summary: results.map((r) => `${r.name} · ${r.touched.length} file${r.touched.length === 1 ? '' : 's'}`).join(' '),
972
1055
  };
973
1056
  }
@@ -980,16 +1063,39 @@ export class Agent {
980
1063
  .map((s) => `--- ${s.name} ---\n${s.body}`)
981
1064
  .join('\n\n');
982
1065
  const messages = [
983
- { role: 'system', content: workerPrompt({ cwd: this.cwd, name, memory: this.memory, skills }) },
1066
+ { role: 'system', content: workerPrompt({ cwd: this.cwd, name, memory: this.memory, skills, map: this.map }) },
984
1067
  { role: 'user', content: String(task.instructions) },
985
1068
  ];
986
1069
  const available = this.toolsNow().filter((t) => !WORKER_EXCLUDED.has(t.name));
987
1070
  const wanted = process.env.UCODE_WORKER_MODEL;
988
- const workerModel = wanted && MODELS[wanted] ? wanted : model();
1071
+ let workerModel = wanted && MODELS[wanted] ? wanted : model();
1072
+ const tried = new Set([workerModel]);
1073
+ let failovers = 0;
1074
+
1075
+ // Start a moment apart. Three requests in the same instant is exactly what
1076
+ // trips a free endpoint's rate limit, and the stagger costs a second or two.
1077
+ if (index) await wait(index * 1500);
989
1078
 
990
1079
  for (let step = 0; step < WORKER_STEPS; step++) {
991
1080
  if (this.abort?.signal.aborted) break;
992
- const reply = await ask(messages, available, { signal: this.abort?.signal, model: workerModel });
1081
+
1082
+ let reply;
1083
+ try {
1084
+ reply = await ask(messages, available, { signal: this.abort?.signal, model: workerModel });
1085
+ } catch (err) {
1086
+ // Same rule as the lead: a busy model is swapped, not a reason to stop.
1087
+ if (TRANSIENT.has(err.kind) && failovers < 6 && !this.abort?.signal.aborted) {
1088
+ failovers++;
1089
+ let next = fallbackFor(workerModel, tried);
1090
+ if (!next) { tried.clear(); await wait(20_000); next = fallbackFor(workerModel, tried) ?? workerModel; }
1091
+ tried.add(next);
1092
+ this.ui.note(`${name}: ${modelName(workerModel)} is busy — switching to ${modelName(next)}`);
1093
+ workerModel = next;
1094
+ step--;
1095
+ continue;
1096
+ }
1097
+ throw err;
1098
+ }
993
1099
  this.record(reply.usage);
994
1100
 
995
1101
  if (!reply.toolCalls.length) {
@@ -1220,6 +1326,7 @@ export class Agent {
1220
1326
  if (arg) {
1221
1327
  try {
1222
1328
  setModel(arg);
1329
+ this.preferred = model();
1223
1330
  } catch (err) {
1224
1331
  this.ui.error(err, { debug: this.debug });
1225
1332
  return;
@@ -1247,6 +1354,7 @@ export class Agent {
1247
1354
  if (chosen === null) return;
1248
1355
 
1249
1356
  setModel(all[chosen].id);
1357
+ this.preferred = model();
1250
1358
  this.session.model = model();
1251
1359
  this.ui.note(`now using ${modelName()}`);
1252
1360
  this.showHeader({ clear: false });
@@ -1285,7 +1393,7 @@ export class Agent {
1285
1393
  * and how far it got, and the ones from this folder are marked, because that
1286
1394
  * is nearly always the one being looked for.
1287
1395
  */
1288
- describeSession(s, width) {
1396
+ describeSession(s, width, i) {
1289
1397
  const room = Math.max(24, Math.min(46, width - 34));
1290
1398
  const mark = s.mine ? blue('●') : dim('○');
1291
1399
  const when = relativeTime(s.updatedAt).padEnd(9);
@@ -1293,7 +1401,8 @@ export class Agent {
1293
1401
  const where = s.mine ? 'here' : shortenPath(s.cwd, 26);
1294
1402
 
1295
1403
  return {
1296
- label: `${mark} ${clip(s.title, room).padEnd(room)} ${dim(when)}${dim(turns)}${dim(where)}`,
1404
+ // Numbered in the picker, so /session delete 3 has something to point at.
1405
+ label: `${i === undefined ? '' : `${dim(String(i + 1).padStart(2))} `}${mark} ${clip(s.title, room).padEnd(room)} ${dim(when)}${dim(turns)}${dim(where)}`,
1297
1406
  sub: s.preview ? dim(` ${clip(s.preview, width - 10)}`) : '',
1298
1407
  };
1299
1408
  }
@@ -1314,6 +1423,10 @@ export class Agent {
1314
1423
  return;
1315
1424
  }
1316
1425
 
1426
+ // /session delete 3 or /session delete 2,5,7
1427
+ const del = /^(?:delete|del|rm|remove)\b\s*(.*)$/i.exec(arg ?? '');
1428
+ if (del) return this.deleteSessions(del[1]);
1429
+
1317
1430
  const sessions = await list({ cwd: this.cwd });
1318
1431
  if (!sessions.length) {
1319
1432
  this.ui.note('no saved conversations yet');
@@ -1336,22 +1449,48 @@ export class Agent {
1336
1449
  }
1337
1450
  index = n - 1;
1338
1451
  } else if (this.ui.pick) {
1339
- const here = shown.filter((s) => s.mine).length;
1340
- index = await this.ui.pick(
1341
- shown.map((s) => this.describeSession(s, width)),
1342
- {
1343
- hint:
1344
- `↑↓ move · enter to continue · esc to cancel` +
1345
- (here ? ` — ${here} from this folder` : '') +
1346
- (sessions.length > shown.length ? ` · ${sessions.length - shown.length} older not shown` : ''),
1452
+ // The picker stays open while you delete, so clearing out several old
1453
+ // conversations is d d, d d, d d — then Enter on the one you want.
1454
+ let active = 0;
1455
+ for (;;) {
1456
+ const here = shown.filter((s) => s.mine).length;
1457
+ const picked = await this.ui.pick(
1458
+ shown.map((s, i) => this.describeSession(s, width, i)),
1459
+ {
1460
+ active,
1461
+ deletable: true,
1462
+ hint:
1463
+ `↑↓ move · enter to continue · d twice to delete · esc to cancel` +
1464
+ (here ? ` — ${here} from this folder` : ''),
1465
+ }
1466
+ );
1467
+ if (picked === null) return;
1468
+ if (typeof picked === 'object' && picked.delete !== undefined) {
1469
+ const doomed = shown[picked.delete];
1470
+ active = picked.delete;
1471
+ if (doomed.id === this.session.id) {
1472
+ this.ui.flash?.('that is the conversation you are in — /new first, then delete it');
1473
+ continue;
1474
+ }
1475
+ await remove(doomed.id);
1476
+ shown.splice(picked.delete, 1);
1477
+ this.ui.flash?.(`deleted · ${clip(doomed.title, 50)}`);
1478
+ if (!shown.length) {
1479
+ this.ui.note('no saved conversations left');
1480
+ return;
1481
+ }
1482
+ active = Math.min(active, shown.length - 1);
1483
+ continue;
1347
1484
  }
1348
- );
1349
- if (index === null) return;
1485
+ index = picked;
1486
+ break;
1487
+ }
1350
1488
  } else {
1351
1489
  this.ui.blank();
1490
+ this.ui.note('/session delete <number> removes one, or several: /session delete 2,5');
1352
1491
  index = await this.ui.choose(
1353
1492
  'continue which?',
1354
- shown.map((s) => this.describeSession(s, width).label)
1493
+ shown.map((s, i) => this.describeSession(s, width, i).label)
1355
1494
  );
1356
1495
  if (index === null) return;
1357
1496
  }
@@ -1363,6 +1502,26 @@ export class Agent {
1363
1502
  }
1364
1503
  }
1365
1504
 
1505
+ /** /session delete 3, or 2,5,7 — numbers as the session list shows them. */
1506
+ async deleteSessions(spec) {
1507
+ const sessions = (await list({ cwd: this.cwd })).slice(0, 25);
1508
+ const numbers = [...new Set(String(spec).split(/[\s,]+/).filter(Boolean).map(Number))];
1509
+ const bad = numbers.filter((n) => !Number.isInteger(n) || n < 1 || n > sessions.length);
1510
+ if (!numbers.length || bad.length) {
1511
+ this.ui.write(theme.warn(` usage: /session delete <number>[,<number>…] — numbers from 1 to ${sessions.length}`));
1512
+ return;
1513
+ }
1514
+ for (const n of numbers) {
1515
+ const s = sessions[n - 1];
1516
+ if (s.id === this.session.id) {
1517
+ this.ui.note(`skipped ${n} — that is the conversation you are in`);
1518
+ continue;
1519
+ }
1520
+ await remove(s.id);
1521
+ this.ui.note(`deleted ${n} · ${s.title}`);
1522
+ }
1523
+ }
1524
+
1366
1525
  async resume(id) {
1367
1526
  try {
1368
1527
  const loaded = await load(id);
@@ -58,7 +58,7 @@ export const MODELS = {
58
58
  name: 'Nemotron 3 Ultra',
59
59
  context: 1_000_000,
60
60
  star: true,
61
- note: 'deepest reasoning, 1M context — the default',
61
+ note: 'deepest reasoning, 1M context — slowest to answer',
62
62
  },
63
63
  'nvidia/nemotron-3.5-lightning:free': {
64
64
  name: 'Nemotron 3.5 Lightning',
@@ -79,18 +79,43 @@ export const MODELS = {
79
79
  name: 'North Mini Code',
80
80
  context: 256_000,
81
81
  star: true,
82
- note: 'code and UI specialist reach for it on frontend work',
82
+ note: 'the defaultbuilt for code and interface work, quick to answer',
83
83
  },
84
84
  };
85
85
 
86
86
  /**
87
- * Nemotron 3 Ultra is the default because the work ucode is for read a
88
- * codebase, hold it in mind, change several files consistently is exactly
89
- * what a million-token window and a long think buy you. It is slower to the
90
- * first token than the others and that is the trade being made. /model swaps
91
- * to Lightning or North Mini Code when the wait stops being worth it.
87
+ * North Mini Code is the default: it is built for code and interface work,
88
+ * which is what ucode is mostly asked to do, and it answers far sooner than
89
+ * the big reasoning models. /model moves to Ultra when a problem needs the
90
+ * million-token window and the long think more than it needs the speed.
92
91
  */
93
- export const DEFAULT_MODEL = 'nvidia/nemotron-3-ultra-550b-a55b:free';
92
+ export const DEFAULT_MODEL = 'cohere/north-mini-code:free';
93
+
94
+ /**
95
+ * Where to go when a model is busy, in order of preference. Each is served by
96
+ * a different upstream, so a rate limit on one rarely means a limit on the
97
+ * next — which is what lets a long build keep going instead of stopping at
98
+ * the first "too many requests".
99
+ */
100
+ export const FALLBACKS = [
101
+ 'cohere/north-mini-code:free',
102
+ 'nvidia/nemotron-3.5-lightning:free',
103
+ 'nvidia/nemotron-3-super-120b-a12b:free',
104
+ 'nvidia/nemotron-3-ultra-550b-a55b:free',
105
+ ];
106
+
107
+ /** The next model to try after `id`, skipping any already tried this round. */
108
+ export function fallbackFor(id, tried = new Set()) {
109
+ const start = Math.max(0, FALLBACKS.indexOf(id));
110
+ for (let i = 1; i <= FALLBACKS.length; i++) {
111
+ const next = FALLBACKS[(start + i) % FALLBACKS.length];
112
+ if (next !== id && !tried.has(next)) return next;
113
+ }
114
+ return null;
115
+ }
116
+
117
+ /** Seconds to wait on successive rate limits that come with no retry-after. */
118
+ const RATE_LIMIT_BACKOFF = [5, 10, 20];
94
119
 
95
120
  let current = process.env.UCODE_MODEL || DEFAULT_MODEL;
96
121
  let client = null;
@@ -578,12 +603,15 @@ export async function ask(messages, tools = [], opts = {}) {
578
603
  problem = explain(err, id);
579
604
 
580
605
  // A per-minute limit is a wait, not a failure. Sit it out rather than
581
- // making the user retype their message.
582
- const wait = problem.detail?.retryAfter;
606
+ // making the user retype their message. Free endpoints often refuse
607
+ // without saying how long to wait, so when there is no retry-after the
608
+ // pauses grow on their own — 5s, 10s, 20s — and only then does the
609
+ // error go up to the loop, which moves to another model.
610
+ const told = problem.detail?.retryAfter;
611
+ const wait = Number.isFinite(told) && told > 0 && told <= 90 ? told : RATE_LIMIT_BACKOFF[attempt - 1];
583
612
  if (
584
613
  problem.kind === 'rate_limit' && !problem.detail?.daily &&
585
- Number.isFinite(wait) && wait > 0 && wait <= 90 &&
586
- attempt < attempts && !opts.signal?.aborted
614
+ wait && attempt < attempts && printed === 0 && !opts.signal?.aborted
587
615
  ) {
588
616
  const until = Date.now() + wait * 1000;
589
617
  while (Date.now() < until && !opts.signal?.aborted) {
@@ -0,0 +1,95 @@
1
+ /**
2
+ * updater.js — staying current without anyone running npm by hand.
3
+ *
4
+ * On every launch ucode asks the registry, in the background, whether a newer
5
+ * version exists. If one does, it installs it globally, detached, while you
6
+ * work. The version you are running carries on untouched; the next launch is
7
+ * the new one. Nothing about starting ucode waits on any of this.
8
+ *
9
+ * It stays out of the way in three cases: a development checkout (updating
10
+ * would overwrite the `npm link` that points at your working copy), when
11
+ * UCODE_NO_UPDATE is set, and when another ucode is already updating.
12
+ */
13
+
14
+ import { spawn } from 'node:child_process';
15
+ import { existsSync, promises as fs } from 'node:fs';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { VERSION } from './version.js';
20
+
21
+ const PACKAGE = 'ucode-agent';
22
+ const PACKAGE_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
23
+ const HOME = path.join(os.homedir(), '.ucode');
24
+ const LOCK = path.join(HOME, 'update.lock');
25
+ const LOG = path.join(HOME, 'update.log');
26
+ const LOCK_TTL = 10 * 60_000;
27
+
28
+ /** "1.10.0" > "1.9.3" — numeric, part by part. */
29
+ export function newer(a, b) {
30
+ const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0);
31
+ const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0);
32
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
33
+ if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0);
34
+ }
35
+ return false;
36
+ }
37
+
38
+ function isDevCheckout() {
39
+ return existsSync(path.join(PACKAGE_ROOT, '.git'));
40
+ }
41
+
42
+ async function latestVersion() {
43
+ const res = await fetch(`https://registry.npmjs.org/${PACKAGE}/latest`, {
44
+ signal: AbortSignal.timeout(5_000),
45
+ headers: { accept: 'application/json' },
46
+ });
47
+ if (!res.ok) return null;
48
+ return (await res.json())?.version ?? null;
49
+ }
50
+
51
+ async function takeLock() {
52
+ try {
53
+ const stat = await fs.stat(LOCK);
54
+ if (Date.now() - stat.mtimeMs < LOCK_TTL) return false; // someone else is on it
55
+ } catch { /* no lock — good */ }
56
+ await fs.mkdir(HOME, { recursive: true });
57
+ await fs.writeFile(LOCK, String(process.pid));
58
+ return true;
59
+ }
60
+
61
+ /**
62
+ * Check, and install if there is something newer.
63
+ *
64
+ * @param {object} o
65
+ * @param {(v: string) => void} [o.onUpdated] called when the install finishes
66
+ */
67
+ export async function autoUpdate({ onUpdated } = {}) {
68
+ try {
69
+ if (process.env.UCODE_NO_UPDATE || isDevCheckout() || !VERSION) return;
70
+ const latest = await latestVersion();
71
+ if (!latest || !newer(latest, VERSION)) return;
72
+ if (!(await takeLock())) return;
73
+
74
+ const log = await fs.open(LOG, 'w');
75
+ const child = spawn(`npm install -g ${PACKAGE}@${latest} --no-audit --no-fund`, {
76
+ shell: true,
77
+ detached: true,
78
+ windowsHide: true,
79
+ stdio: ['ignore', log.fd, log.fd],
80
+ });
81
+ await log.close();
82
+ child.unref();
83
+
84
+ child.on('exit', async (code) => {
85
+ await fs.rm(LOCK, { force: true }).catch(() => {});
86
+ if (code === 0) onUpdated?.(latest);
87
+ });
88
+ child.on('error', async () => {
89
+ await fs.rm(LOCK, { force: true }).catch(() => {});
90
+ });
91
+ } catch {
92
+ // An update check must never be the reason ucode misbehaves. Offline,
93
+ // registry down, no permission to install globally: all silently skipped.
94
+ }
95
+ }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * browser.js — looking at the app the way a person would.
3
+ *
4
+ * A build that passes and a page that works are different claims. This opens
5
+ * the running app in a real browser at a phone width and a desktop width, and
6
+ * reports what a person would run into: errors in the console, requests that
7
+ * failed, a layout that spills off the side of a phone, broken images,
8
+ * controls with no name. It saves a screenshot of each, and has the one model
9
+ * in the set that can see — Nemotron Nano Omni — review them as a designer
10
+ * would. The model building the app then has something concrete to fix.
11
+ *
12
+ * It drives the browser already on the machine (Edge or Chrome) through
13
+ * playwright-core, so there is no separate 150 MB browser download.
14
+ */
15
+
16
+ import { promises as fs } from 'node:fs';
17
+ import path from 'node:path';
18
+ import { ToolFailure } from '../core/failure.js';
19
+ import { ask } from '../core/provider.js';
20
+ import { getRoot, result } from './shared.js';
21
+
22
+ const VISION_MODEL = 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free';
23
+ const WIDTHS = [
24
+ { name: 'phone', width: 375, height: 812 },
25
+ { name: 'desktop', width: 1440, height: 900 },
26
+ ];
27
+ const LOCAL = /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?(?:\/|$)/i;
28
+
29
+ let browserPromise = null;
30
+
31
+ /**
32
+ * One browser for the whole session, started on first use. The installed
33
+ * Edge or Chrome is tried first; Playwright's own Chromium only if it happens
34
+ * to be installed.
35
+ */
36
+ async function browser() {
37
+ if (browserPromise) return browserPromise;
38
+ browserPromise = (async () => {
39
+ let chromium;
40
+ try {
41
+ ({ chromium } = await import('playwright-core'));
42
+ } catch (err) {
43
+ throw new ToolFailure({
44
+ kind: 'no_playwright',
45
+ attempted: 'starting a browser',
46
+ failed: `playwright-core could not be loaded: ${err.message}`,
47
+ fix: 'Reinstall ucode (npm install -g ucode-agent). Carry on without looking at the app, and say so.',
48
+ });
49
+ }
50
+ const tried = [];
51
+ for (const channel of ['msedge', 'chrome', undefined]) {
52
+ try {
53
+ return await chromium.launch({ channel, headless: true });
54
+ } catch (err) {
55
+ tried.push(`${channel ?? 'bundled chromium'}: ${String(err.message).split('\n')[0]}`);
56
+ }
57
+ }
58
+ throw new ToolFailure({
59
+ kind: 'no_browser',
60
+ attempted: 'starting a browser',
61
+ failed: `No browser could be started. Tried ${tried.join('; ')}.`,
62
+ fix: 'Install Google Chrome or Microsoft Edge. Carry on without looking at the app, and say so.',
63
+ });
64
+ })();
65
+ browserPromise.catch(() => { browserPromise = null; });
66
+ return browserPromise;
67
+ }
68
+
69
+ /** Close the shared browser, if one was started. Called when ucode exits. */
70
+ export async function closeBrowser() {
71
+ if (!browserPromise) return;
72
+ try { await (await browserPromise).close(); } catch { /* already gone */ }
73
+ browserPromise = null;
74
+ }
75
+
76
+ /** Layout and accessibility checks run inside the page. */
77
+ function inspect() {
78
+ const vw = window.innerWidth;
79
+ const describeEl = (el) => {
80
+ const id = el.id ? `#${el.id}` : '';
81
+ const cls = typeof el.className === 'string' && el.className.trim()
82
+ ? `.${el.className.trim().split(/\s+/).slice(0, 2).join('.')}` : '';
83
+ const text = (el.innerText || el.getAttribute('aria-label') || '').trim().replace(/\s+/g, ' ').slice(0, 40);
84
+ return `<${el.tagName.toLowerCase()}${id}${cls}>${text ? ` "${text}"` : ''}`;
85
+ };
86
+
87
+ const overflow = document.documentElement.scrollWidth - vw;
88
+ const wide = [];
89
+ if (overflow > 1) {
90
+ for (const el of document.querySelectorAll('body *')) {
91
+ const r = el.getBoundingClientRect();
92
+ if (r.width > 0 && r.right > vw + 1 && getComputedStyle(el).position !== 'fixed') {
93
+ wide.push(`${describeEl(el)} reaches ${Math.round(r.right)}px`);
94
+ if (wide.length >= 5) break;
95
+ }
96
+ }
97
+ }
98
+
99
+ const broken = [...document.images].filter((i) => i.complete && i.naturalWidth === 0).map((i) => i.src.slice(0, 80));
100
+ const noAlt = [...document.images].filter((i) => !i.hasAttribute('alt')).length;
101
+ const unnamed = [...document.querySelectorAll('button, a[href], [role="button"]')]
102
+ .filter((el) => !(el.innerText || '').trim() && !el.getAttribute('aria-label') && !el.getAttribute('title')
103
+ && !el.querySelector('[aria-label], title, img[alt]:not([alt=""])'))
104
+ .slice(0, 5).map(describeEl);
105
+ const inputsNoLabel = [...document.querySelectorAll('input:not([type="hidden"]), textarea, select')]
106
+ .filter((el) => !(el.id && document.querySelector(`label[for="${el.id}"]`)) && !el.closest('label')
107
+ && !el.getAttribute('aria-label') && !el.getAttribute('aria-labelledby'))
108
+ .length;
109
+ const tiny = vw < 600
110
+ ? [...document.querySelectorAll('button, a[href], [role="button"], input, select')]
111
+ .filter((el) => { const r = el.getBoundingClientRect(); return r.width > 0 && (r.height < 32 || r.width < 32); })
112
+ .length
113
+ : 0;
114
+ const smallText = [...document.querySelectorAll('p, li, span, a, button, label, td')]
115
+ .filter((el) => el.childElementCount === 0 && (el.innerText || '').trim() && parseFloat(getComputedStyle(el).fontSize) < 12)
116
+ .length;
117
+
118
+ return {
119
+ title: document.title,
120
+ overflow: overflow > 1 ? Math.round(overflow) : 0,
121
+ wide, broken, noAlt, unnamed, inputsNoLabel, tiny, smallText,
122
+ empty: !(document.body.innerText || '').trim(),
123
+ };
124
+ }
125
+
126
+ const safeName = (p) => (p === '/' ? 'home' : p.replace(/^\/+|\/+$/g, '').replace(/[^\w-]+/g, '_')) || 'page';
127
+
128
+ async function review(shots) {
129
+ const request = [
130
+ {
131
+ role: 'system',
132
+ content:
133
+ 'You are a senior product designer reviewing screenshots of a web app, one at a phone width ' +
134
+ 'and one at desktop width. List the concrete visual problems a user would notice, most ' +
135
+ 'important first: broken or cramped layout, overflow, misalignment, weak hierarchy (is the ' +
136
+ 'most important thing the most prominent?), inconsistent spacing, low contrast, default-looking ' +
137
+ 'components, awkward empty states, text that is too small. For each: where it is, what is wrong, ' +
138
+ 'and the specific fix. At most 8 points, one or two lines each. If it genuinely looks polished, ' +
139
+ 'say so in one line and name the one thing that would improve it most. No preamble.',
140
+ },
141
+ {
142
+ role: 'user',
143
+ content: shots.map((s) => `${s.label}`).join(' and ') + '.',
144
+ images: shots.map((s) => s.dataUrl),
145
+ },
146
+ ];
147
+ const reply = await ask(request, [], { model: VISION_MODEL, temperature: 0.2, maxOutputTokens: 900 });
148
+ return reply.text.trim();
149
+ }
150
+
151
+ export async function lookAtApp({ url, paths = ['/'], review: wantReview = true }) {
152
+ const base = String(url ?? '').trim().replace(/\/+$/, '');
153
+ if (!LOCAL.test(`${base}/`)) {
154
+ throw new ToolFailure({
155
+ kind: 'bad_args',
156
+ attempted: 'looking at the app',
157
+ failed: `"${url}" is not a local address. This only opens apps running on this machine.`,
158
+ fix: 'Pass the URL the dev server reported, e.g. http://localhost:3000',
159
+ });
160
+ }
161
+
162
+ const pages = (Array.isArray(paths) && paths.length ? paths : ['/'])
163
+ .map((p) => `/${String(p).trim().replace(/^\/+/, '')}`)
164
+ .slice(0, 4);
165
+
166
+ const shotsDir = path.join(getRoot(), '.ucode', 'screenshots');
167
+ await fs.mkdir(shotsDir, { recursive: true });
168
+
169
+ const b = await browser();
170
+ const sections = [];
171
+ const toReview = [];
172
+ let problems = 0;
173
+
174
+ for (const pagePath of pages) {
175
+ for (const size of WIDTHS) {
176
+ const context = await b.newContext({ viewport: { width: size.width, height: size.height }, deviceScaleFactor: 1 });
177
+ const page = await context.newPage();
178
+ const errors = [];
179
+ const failed = [];
180
+ page.on('console', (m) => {
181
+ if (m.type() === 'error' && !/devtools|download the react/i.test(m.text())) errors.push(m.text().slice(0, 200));
182
+ });
183
+ page.on('pageerror', (e) => errors.push(`uncaught: ${String(e.message).slice(0, 200)}`));
184
+ page.on('requestfailed', (r) => failed.push(`${r.method()} ${r.url().slice(0, 100)} — ${r.failure()?.errorText ?? 'failed'}`));
185
+ page.on('response', (r) => { if (r.status() >= 400) failed.push(`${r.status()} ${r.url().slice(0, 100)}`); });
186
+
187
+ const target = `${base}${pagePath}`;
188
+ let loadError = null;
189
+ try {
190
+ // A dev server compiles a page on its first request, which can take a
191
+ // while; networkidle then waits for the page's own data to arrive.
192
+ await page.goto(target, { waitUntil: 'networkidle', timeout: 60_000 });
193
+ } catch (err) {
194
+ try { await page.goto(target, { waitUntil: 'load', timeout: 30_000 }); }
195
+ catch (err2) { loadError = String(err2.message).split('\n')[0]; }
196
+ }
197
+ await page.waitForTimeout(600); // let entrance animations settle
198
+
199
+ const file = path.join(shotsDir, `${safeName(pagePath)}-${size.name}.jpg`);
200
+ let facts = null;
201
+ if (!loadError) {
202
+ facts = await page.evaluate(inspect).catch((err) => ({ error: err.message }));
203
+ const buffer = await page.screenshot({ type: 'jpeg', quality: 70, fullPage: false });
204
+ await fs.writeFile(file, buffer);
205
+ toReview.push({ label: `${pagePath} at ${size.width}px (${size.name})`, dataUrl: `data:image/jpeg;base64,${buffer.toString('base64')}` });
206
+ }
207
+ await context.close();
208
+
209
+ const lines = [`### ${pagePath} at ${size.width}px (${size.name})`];
210
+ if (loadError) {
211
+ lines.push(`Could not load: ${loadError}`);
212
+ problems++;
213
+ } else {
214
+ lines.push(`Screenshot: ${path.relative(getRoot(), file).split(path.sep).join('/')}`);
215
+ if (facts?.empty) { lines.push('- The page rendered no visible text at all.'); problems++; }
216
+ if (facts?.overflow) {
217
+ lines.push(`- Content is ${facts.overflow}px wider than the screen, so it scrolls sideways:`, ...facts.wide.map((w) => ` - ${w}`));
218
+ problems++;
219
+ }
220
+ if (facts?.broken?.length) { lines.push(`- Broken images: ${facts.broken.join(', ')}`); problems++; }
221
+ if (facts?.unnamed?.length) { lines.push(`- Buttons or links with no accessible name: ${facts.unnamed.join(', ')}`); problems++; }
222
+ if (facts?.inputsNoLabel) { lines.push(`- ${facts.inputsNoLabel} form field(s) without a label.`); problems++; }
223
+ if (facts?.noAlt) lines.push(`- ${facts.noAlt} image(s) without alt text.`);
224
+ if (facts?.tiny) lines.push(`- ${facts.tiny} tap target(s) smaller than 32px on a phone.`);
225
+ if (facts?.smallText) lines.push(`- ${facts.smallText} text element(s) under 12px.`);
226
+ }
227
+ if (errors.length) { lines.push('- Console errors:', ...[...new Set(errors)].slice(0, 6).map((e) => ` - ${e}`)); problems++; }
228
+ if (failed.length) { lines.push('- Failed requests:', ...[...new Set(failed)].slice(0, 6).map((f) => ` - ${f}`)); problems++; }
229
+ if (lines.length === 2 && !loadError) lines.push('- No errors, no overflow, nothing unlabeled.');
230
+ sections.push(lines.join('\n'));
231
+ }
232
+ }
233
+
234
+ let critique = '';
235
+ if (wantReview !== false && toReview.length) {
236
+ try {
237
+ critique = await review(toReview.slice(0, 4));
238
+ } catch (err) {
239
+ critique = `(The visual review could not run: ${err.failed ?? err.message}. The checks above still apply.)`;
240
+ }
241
+ }
242
+
243
+ const body = [
244
+ ...sections,
245
+ critique ? `## Visual review\n${critique}` : '',
246
+ '',
247
+ problems
248
+ ? 'Fix the problems above, then look again to confirm.'
249
+ : 'The automatic checks found nothing. Weigh the visual review, fix what is worth fixing.',
250
+ ].filter(Boolean).join('\n\n');
251
+
252
+ return result(
253
+ body,
254
+ problems
255
+ ? `${problems} problem${problems === 1 ? '' : 's'} found · screenshots in .ucode/screenshots`
256
+ : 'no errors · screenshots in .ucode/screenshots'
257
+ );
258
+ }
@@ -8,6 +8,7 @@ import { readFile, readFiles, writeFile, batchWrite, editFile, multiEdit, editFi
8
8
  import { listDir, glob, grep } from './search.js';
9
9
  import { runCommand, runCommands } from './shell.js';
10
10
  import { webSearch } from './web.js';
11
+ import { lookAtApp } from './browser.js';
11
12
  import { clip, READ_LINES } from './shared.js';
12
13
 
13
14
  export { setRoot, setConfirm, getRoot } from './shared.js';
@@ -267,6 +268,29 @@ export const tools = [
267
268
  required: ['commands'],
268
269
  },
269
270
  },
271
+ {
272
+ name: 'look_at_app',
273
+ description:
274
+ 'Open the running app in a real browser at a phone width (375px) and a desktop width ' +
275
+ '(1440px) and report what a person would run into: console errors, failed requests, ' +
276
+ 'content that spills off the side of the screen, broken images, unlabeled buttons and ' +
277
+ 'fields - plus a designer-style review of the screenshots. Use it once the dev server ' +
278
+ 'is ready, and again after visual changes, then fix what it reports. Screenshots are ' +
279
+ 'saved under .ucode/screenshots.',
280
+ parameters: {
281
+ type: 'object',
282
+ properties: {
283
+ url: str('The local URL the dev server reported, e.g. http://localhost:3000'),
284
+ paths: {
285
+ type: 'array',
286
+ description: 'Pages to open, e.g. ["/", "/settings"]. Defaults to ["/"]. Up to 4.',
287
+ items: { type: 'string' },
288
+ },
289
+ review: bool('Include the visual design review of the screenshots. Defaults to true.'),
290
+ },
291
+ required: ['url'],
292
+ },
293
+ },
270
294
  {
271
295
  name: 'web_search',
272
296
  description:
@@ -299,6 +323,7 @@ const run = {
299
323
  run_command: runCommand,
300
324
  run_commands: runCommands,
301
325
  web_search: webSearch,
326
+ look_at_app: lookAtApp,
302
327
  };
303
328
 
304
329
  /** Tools that change the project or execute code. */
@@ -435,6 +460,8 @@ export function describe(name, args = {}) {
435
460
  return `Running ${clip(args.command, 70)}${args.background ? ' in the background' : ''}`;
436
461
  case 'run_commands':
437
462
  return `Running ${args.commands?.length ?? 0} commands together`;
463
+ case 'look_at_app':
464
+ return `Looking at ${clip(args.url, 40)} on a phone and a desktop`;
438
465
  case 'web_search':
439
466
  return `Searching the web for ${clip(args.query, 60)}`;
440
467
  case 'load_skill':
@@ -200,7 +200,14 @@ function startServer(command, workdir, { env } = {}) {
200
200
  cwd: workdir.abs,
201
201
  shell: true,
202
202
  windowsHide: true,
203
- detached: true,
203
+ // Not detached on Windows, and this is load-bearing. A detached process
204
+ // there has no console, and programs launched under it write nothing
205
+ // to a redirected file — measured: every one of node, npm and next
206
+ // produced an empty log, so a server's "ready" line never arrived and
207
+ // every start waited out the full timer. Attached, the output lands.
208
+ // The server itself still outlives ucode: only this shell is tied to
209
+ // ucode's job object, and the job lets grandchildren break away.
210
+ detached: process.platform !== 'win32',
204
211
  stdio: ['ignore', fd, fd],
205
212
  env,
206
213
  });
package/src/ui/screen.js CHANGED
@@ -39,7 +39,7 @@ import chalk from 'chalk';
39
39
  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
- shortenPath, asLabel, ensureColour, planLine,
42
+ shortenPath, asLabel, ensureColour, planLine, bare,
43
43
  } from './theme.js';
44
44
  import { renderer, render, polish } from './markdown.js';
45
45
  import { VERSION } from '../core/version.js';
@@ -773,11 +773,15 @@ export class Screen {
773
773
  * cannot be given up permanently, because under alternate scroll the mouse
774
774
  * wheel arrives as arrow keys.
775
775
  */
776
- pick(items, { active = 0, hint = 'enter to choose · esc to cancel' } = {}) {
776
+ pick(items, { active = 0, hint = 'enter to choose · esc to cancel', deletable = false } = {}) {
777
777
  this.picker = {
778
778
  items,
779
779
  index: Math.min(Math.max(0, active), Math.max(0, items.length - 1)),
780
780
  hint,
781
+ // With deletable, `d` twice on a row resolves { delete: index }. Twice,
782
+ // because a single stray keypress should never cost a conversation.
783
+ deletable,
784
+ armed: null,
781
785
  };
782
786
  this.render();
783
787
  return new Promise((resolve) => { this.pickerResolve = resolve; });
@@ -799,7 +803,7 @@ export class Screen {
799
803
  * about instead of a column of near-identical titles.
800
804
  */
801
805
  pickerLines(height) {
802
- const { items, index, hint } = this.picker;
806
+ const { items, index, hint, armed } = this.picker;
803
807
  const room = Math.max(1, height - 2);
804
808
 
805
809
  // Rows per item, so the window can be sized in rows rather than in items.
@@ -821,12 +825,15 @@ export class Screen {
821
825
  for (let i = first; i <= last; i++) {
822
826
  const item = items[i];
823
827
  const body = typeof item === 'string' ? item : item.label;
824
- out.push(i === index ? `${blue('')} ${chalk.bold.white(body)}` : ` ${dim(body)}`);
828
+ if (i === armed) out.push(`${theme.warn('')} ${theme.warn(bare(body))}`);
829
+ else out.push(i === index ? `${blue('❯')} ${chalk.bold.white(body)}` : ` ${dim(body)}`);
825
830
  if (typeof item !== 'string' && item.sub) out.push(` ${item.sub}`);
826
831
  }
827
832
 
828
833
  out.push('');
829
- out.push(dim(` ${hint}`));
834
+ out.push(armed !== null && armed !== undefined
835
+ ? theme.warn(' press d again to delete this conversation · any other key keeps it')
836
+ : dim(` ${hint}`));
830
837
  return out;
831
838
  }
832
839
 
@@ -932,6 +939,13 @@ export class Screen {
932
939
  // An open picker owns the keyboard until it closes.
933
940
  if (this.picker) {
934
941
  const last = this.picker.items.length - 1;
942
+ if (this.picker.deletable && (key === 'd' || key === 'D' || key === `${ESC}[3~`)) {
943
+ if (this.picker.armed === this.picker.index) { this.closePicker({ delete: this.picker.index }); return; }
944
+ this.picker.armed = this.picker.index;
945
+ this.render();
946
+ return;
947
+ }
948
+ this.picker.armed = null; // any other key takes the delete back
935
949
  if (key === `${ESC}[A`) { this.picker.index = Math.max(0, this.picker.index - 1); this.render(); return; }
936
950
  if (key === `${ESC}[B`) { this.picker.index = Math.min(last, this.picker.index + 1); this.render(); return; }
937
951
  if (key === '\r' || key === '\n') { this.closePicker(this.picker.index); return; }
@@ -1149,7 +1163,7 @@ export class Screen {
1149
1163
 
1150
1164
  // The version, in the corner, and nothing else on the screen.
1151
1165
  if (VERSION) {
1152
- const tag = dim(`v${VERSION}`);
1166
+ const tag = dim(this.facts.update ? `v${VERSION} · v${this.facts.update} installed, starts next time` : `v${VERSION}`);
1153
1167
  frame[this.rows - 1] = ' '.repeat(Math.max(0, g.cols - visLen(tag) - 2)) + tag;
1154
1168
  }
1155
1169