ucode-agent 1.26.1 → 1.27.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.
@@ -8,7 +8,7 @@ import { readFile, readFiles, writeFile, batchWrite, editFile, multiEdit, editFi
8
8
  import { listDir, glob, grep } from './search.js';
9
9
  import { findSymbol, outline } from './symbols.js';
10
10
  import { renameSymbol } from './rename.js';
11
- import { addBlock, BLOCK_NAMES } from './blocks.js';
11
+ import { addBlock, BLOCK_NAMES, PLAIN_BLOCK_NAMES, ALL_BLOCK_NAMES } from './blocks.js';
12
12
  import { typeOf } from './types.js';
13
13
  import { runCommand, runCommands } from './shell.js';
14
14
  import { webSearch } from './web.js';
@@ -82,27 +82,45 @@ export const tools = [
82
82
  {
83
83
  name: 'create_app',
84
84
  description:
85
- 'Start a new app from a starter that already works. Two to choose between, and the ' +
86
- 'choice matters. "plain-html": one index.html, one stylesheet, one ES module — nothing ' +
87
- 'to install, nothing to build, opens straight in a browser. Use it whenever the user ' +
88
- 'asks for plain HTML/CSS/JS, or for a single page, a toy, a game or a visualisation. ' +
89
- '"next-shadcn": Next.js 16, TypeScript, Tailwind 4 and shadcn with 33 components, light ' +
90
- 'and dark, toasts, and a design preset — for anything with routes, data or many screens; ' +
91
- 'its packages install in the background so you can write components at once. This is how ' +
92
- 'every Next.js app begins - never run create-next-app or shadcn init. Do not reach for ' +
93
- 'Next.js when a single HTML file is what was asked for.',
85
+ 'Start a new app AND write it, in one call. Pass "files" with the whole app and this ' +
86
+ 'is the only call the build needs: the starter lands, your files are written over it, ' +
87
+ 'and the result comes back with everything. Two starters. "plain-html" (the default): ' +
88
+ 'one index.html, one stylesheet, one ES module — nothing to install, nothing to build, ' +
89
+ 'opens straight in a browser, and its three files come back inside this result so there ' +
90
+ 'is never a reason to read them. Use it for anything that is one page: a tasks app, a ' +
91
+ 'toy, a game, a visualisation, a calculator, a timer. "next-shadcn": Next.js 16, ' +
92
+ 'TypeScript, Tailwind 4 and shadcn with 33 components — only when the app genuinely ' +
93
+ 'needs routes, a database or many screens, because it costs an install and a build. ' +
94
+ 'This is how every Next.js app begins - never run create-next-app or shadcn init.',
94
95
  parameters: {
95
96
  type: 'object',
96
97
  properties: {
97
98
  folder: str('A new, empty folder for the app, relative to the project root, e.g. "stride".'),
98
99
  name: str('The display name of the app, e.g. "Stride".'),
99
100
  description: str('One line about the app, used in the page metadata.'),
101
+ files: {
102
+ type: 'array',
103
+ description:
104
+ 'The app itself, written in this same call, straight over the starter\'s files. ' +
105
+ 'Pass the whole app here rather than following up with batch_write - it saves a ' +
106
+ 'round trip, which is most of the time a build takes. Paths are relative to the ' +
107
+ 'project root and so include the app folder, e.g. "stride/index.html".',
108
+ items: {
109
+ type: 'object',
110
+ properties: {
111
+ path: str('Path relative to the project root, e.g. "stride/index.html".'),
112
+ content: str('The complete contents of the file.'),
113
+ },
114
+ required: ['path', 'content'],
115
+ },
116
+ },
100
117
  template: {
101
118
  type: 'string',
102
119
  enum: ['next-shadcn', 'plain-html'],
103
120
  description:
104
- 'Which starter. "plain-html" for plain HTML/CSS/JS, a single page, a toy or a game: ' +
105
- 'no install, no build. "next-shadcn" (the default) for routes, data or many screens.',
121
+ 'Which starter. "plain-html" (the default) for one page, a toy, a game, or any ' +
122
+ 'app that does not need a server: no install, no build, nothing to wait for. ' +
123
+ '"next-shadcn" only for routes, a database or many screens.',
106
124
  },
107
125
  design: {
108
126
  type: 'string',
@@ -369,16 +387,20 @@ export const tools = [
369
387
  {
370
388
  name: 'add_block',
371
389
  description:
372
- 'Add a ready-made, polished piece of an app — ' + BLOCK_NAMES.join(', ') + '. Each is ' +
373
- 'copied in as an ordinary source file you can then edit, built on the shadcn ' +
374
- 'components already in the starter, so nothing needs installing. Call it with no ' +
375
- 'name to see what each one is for. Prefer these over writing a table or an empty ' +
376
- 'state from scratch: they already handle sorting, empty and loading states, ' +
377
- 'alignment and small screens.',
390
+ 'Add a ready-made, polished piece of an app, copied in as an ordinary source file ' +
391
+ 'you can then edit. Which set you get is decided by the app itself, so you never ' +
392
+ 'pick wrong. For a plain page: ' + PLAIN_BLOCK_NAMES.join(', ') + ' — plain ES ' +
393
+ 'modules that import nothing and style themselves from the CSS variables already ' +
394
+ 'in styles.css. For a React app: ' + BLOCK_NAMES.join(', ') + ' — built on the ' +
395
+ 'shadcn components already in the starter. ALWAYS reach for these before writing a ' +
396
+ 'list, a filter row, a store, a dialog or a table by hand: they already handle the ' +
397
+ 'keyboard, the empty state, small screens and the cases that get skipped, and every ' +
398
+ 'one you use is a hundred lines you do not have to type. Call it with no name to ' +
399
+ 'see what each is for.',
378
400
  parameters: {
379
401
  type: 'object',
380
402
  properties: {
381
- name: str('Which block, e.g. "data-table". Omit to list them.'),
403
+ name: str(`Which block, e.g. "${ALL_BLOCK_NAMES[0]}". Omit to list the ones this app can use.`),
382
404
  folder: str('The app folder to add it to. Defaults to the project root.'),
383
405
  },
384
406
  },
@@ -506,7 +528,7 @@ const run = {
506
528
  /** Tools that change the project or execute code. */
507
529
  export const MUTATING = new Set([
508
530
  'write_file', 'batch_write', 'edit_file', 'multi_edit', 'edit_files', 'rename_symbol', 'add_block',
509
- 'run_command', 'run_commands', 'deploy',
531
+ 'run_command', 'run_commands', 'create_app', 'deploy',
510
532
  ]);
511
533
 
512
534
  /** Tools with no side effects, so several may run at the same time. */
@@ -519,7 +541,7 @@ export const WRITES = new Set([
519
541
  ]);
520
542
 
521
543
  /** Tools that change files on disk, which parallel workers take turns at. */
522
- export const FILE_WRITES = new Set(['write_file', 'batch_write', 'edit_file', 'multi_edit', 'edit_files', 'rename_symbol']);
544
+ export const FILE_WRITES = new Set(['write_file', 'batch_write', 'edit_file', 'multi_edit', 'edit_files', 'rename_symbol', 'create_app']);
523
545
 
524
546
  // ---------------------------------------------------------------------------
525
547
  // Argument checking
@@ -532,6 +554,14 @@ export const FILE_WRITES = new Set(['write_file', 'batch_write', 'edit_file', 'm
532
554
  * wrong and can correct itself, instead of a TypeError thrown from somewhere
533
555
  * inside fs that means nothing to anybody.
534
556
  */
557
+ /**
558
+ * Arguments whose tool reads more shapes than the schema advertises.
559
+ *
560
+ * Kept here rather than in the schema so the wire format stays exactly what
561
+ * the model is asked for — the leniency is ucode's, not part of the contract.
562
+ */
563
+ const LENIENT = new Set(['create_app.files']);
564
+
535
565
  function check(name, args) {
536
566
  const schema = tools.find((t) => t.name === name).parameters;
537
567
  const problems = [];
@@ -552,10 +582,27 @@ function check(name, args) {
552
582
  }
553
583
  if (value === undefined || value === null) continue;
554
584
 
555
- const actual = Array.isArray(value) ? 'array' : typeof value;
585
+ let actual = Array.isArray(value) ? 'array' : typeof value;
556
586
  const wanted = spec.type === 'integer' ? 'number' : spec.type;
557
587
  // A number sent as a string is close enough — the tool coerces it anyway.
558
588
  if (wanted === 'number' && actual === 'string' && value.trim() !== '' && !Number.isNaN(Number(value))) continue;
589
+
590
+ // An array or object sent as a JSON string is the single most common way
591
+ // a model gets a nested argument wrong, and it is one every model makes
592
+ // sometimes. Rejecting it costs a whole round trip to be told something
593
+ // that could simply be read: parse it and carry on.
594
+ if ((wanted === 'array' || wanted === 'object') && actual === 'string') {
595
+ try {
596
+ const parsed = JSON.parse(value);
597
+ const kind = Array.isArray(parsed) ? 'array' : typeof parsed;
598
+ if (kind === wanted || LENIENT.has(`${name}.${key}`)) { args[key] = parsed; actual = kind; }
599
+ } catch { /* not JSON either — the message below is the right answer */ }
600
+ }
601
+
602
+ // A list of files written as a { path: contents } map. The tool reads it
603
+ // either way, so refusing it here would be a round trip spent on nothing.
604
+ if (wanted === 'array' && actual === 'object' && LENIENT.has(`${name}.${key}`)) continue;
605
+
559
606
  if (actual !== wanted) problems.push(`"${key}" should be ${spec.type} but was ${actual}`);
560
607
  }
561
608
 
@@ -649,7 +696,8 @@ export function describe(name, args = {}) {
649
696
  // HTML app announced itself as Next.js, which is a line that is simply
650
697
  // untrue on screen while the opposite happens on disk.
651
698
  return `Creating ${clip(args.name || args.folder, 30)} from the ` +
652
- `${args.template === 'plain-html' ? 'HTML' : 'Next.js'} starter`;
699
+ `${args.template === 'next-shadcn' ? 'Next.js' : 'HTML'} starter` +
700
+ `${args.files?.length ? ` with ${args.files.length} file${args.files.length === 1 ? '' : 's'}` : ''}`;
653
701
  case 'look_at_app':
654
702
  return `Looking at ${clip(args.url, 40)} on a phone and a desktop`;
655
703
  case 'web_search':
@@ -15,6 +15,7 @@ import path from 'node:path';
15
15
  import { fileURLToPath } from 'node:url';
16
16
  import { ToolFailure } from '../core/failure.js';
17
17
  import { resolveIn, guard, result } from './shared.js';
18
+ import { batchWrite } from './files.js';
18
19
  import { packageJsonWritten, installIn } from './shell.js';
19
20
  import { restore, populate } from './cache.js';
20
21
 
@@ -28,6 +29,44 @@ const TEXT = /\.(?:json|md|mjs|css|html|jsx?|tsx?)$/i;
28
29
 
29
30
  export const TEMPLATE_NAMES = ['next-shadcn', 'plain-html'];
30
31
 
32
+ /**
33
+ * Which of a starter's files come back inside the result, in full.
34
+ *
35
+ * Reading a file ucode just copied is a whole round trip spent learning what
36
+ * it already had on disk, and a round trip is ten to forty seconds. The
37
+ * three-file starter is small enough to hand over outright; the Next.js one
38
+ * is a hundred files and its guide has to do that job instead.
39
+ */
40
+ const SHOW_BACK = { 'plain-html': ['index.html', 'styles.css', 'app.js'] };
41
+
42
+ /**
43
+ * The files argument, however it was written.
44
+ *
45
+ * A list of `{ path, content }` is what the schema asks for, and it is what
46
+ * arrives most of the time. The rest of the time it is a JSON string, or a
47
+ * `{ "todo/app.js": "..." }` map, or the same list with the keys named
48
+ * something adjacent. Each of those, refused, is a round trip spent being
49
+ * told what could have been read — so they are all read.
50
+ */
51
+ function normaliseFiles(files) {
52
+ let value = files;
53
+ if (typeof value === 'string') {
54
+ try { value = JSON.parse(value); } catch { return []; }
55
+ }
56
+ if (!value || typeof value !== 'object') return [];
57
+
58
+ const entries = Array.isArray(value)
59
+ ? value
60
+ : Object.entries(value).map(([path, content]) => ({ path, content }));
61
+
62
+ return entries.map((entry) => {
63
+ if (typeof entry !== 'object' || entry === null) return entry;
64
+ const path = entry.path ?? entry.file ?? entry.filename ?? entry.name;
65
+ const content = entry.content ?? entry.contents ?? entry.text ?? entry.body ?? entry.source;
66
+ return { ...entry, path, content };
67
+ });
68
+ }
69
+
31
70
  /** What each starter is for, so the choice is made on purpose. */
32
71
  export const TEMPLATE_NOTES = {
33
72
  'next-shadcn': 'Next.js, TypeScript, Tailwind and shadcn/ui. For anything with routes, data or many components.',
@@ -63,14 +102,6 @@ async function copyTree(from, to, fill) {
63
102
  return copied;
64
103
  }
65
104
 
66
- /**
67
- * @param {object} o
68
- * @param {string} o.folder new, empty folder for the app
69
- * @param {string} o.name display name, e.g. "Stride"
70
- * @param {string} [o.description]
71
- * @param {string} [o.template]
72
- * @param {boolean} [o.install] start the background install (tests turn it off)
73
- */
74
105
  /**
75
106
  * Give the new app its look: one of the hand-picked presets in the starter's
76
107
  * presets/ folder — a full light and dark palette and a font — written into
@@ -129,7 +160,21 @@ export async function applyDesign(appDir, design) {
129
160
  return preset;
130
161
  }
131
162
 
132
- export async function createApp({ folder, name, description, template = 'next-shadcn', design, install = true }) {
163
+ /**
164
+ * Start an app, and — when the model passes them — write its files in the
165
+ * same call.
166
+ *
167
+ * @param {object} o
168
+ * @param {string} o.folder new, empty folder for the app
169
+ * @param {string} o.name display name, e.g. "Stride"
170
+ * @param {string} [o.description]
171
+ * @param {string} [o.template] defaults to plain-html: nothing to install
172
+ * @param {string} [o.design]
173
+ * @param {{path: string, content: string}[]} [o.files] the app itself, paths
174
+ * relative to the project root, written straight over the starter's
175
+ * @param {boolean} [o.install] start the background install (tests turn it off)
176
+ */
177
+ export async function createApp({ folder, name, description, template = 'plain-html', design, files, install = true }) {
133
178
  if (!TEMPLATE_NAMES.includes(template)) {
134
179
  throw new ToolFailure({
135
180
  kind: 'bad_args',
@@ -139,6 +184,26 @@ export async function createApp({ folder, name, description, template = 'next-sh
139
184
  });
140
185
  }
141
186
 
187
+ // Every shape a model reaches for when handing over a set of files. Reading
188
+ // them all costs nothing; refusing them costs a round trip each, which is
189
+ // the whole reason this argument exists.
190
+ const given = normaliseFiles(files);
191
+
192
+ // Checked before anything is copied: a bad entry found halfway through
193
+ // would leave the folder created, and the retry would then be refused for
194
+ // already having files in it.
195
+ const bad = given.findIndex(
196
+ (f) => typeof f?.path !== 'string' || typeof f?.content !== 'string'
197
+ );
198
+ if (bad !== -1) {
199
+ throw new ToolFailure({
200
+ kind: 'bad_args',
201
+ attempted: 'creating an app',
202
+ failed: `Entry ${bad + 1} of "files" is missing "path" or "content" — both must be strings.`,
203
+ fix: 'Fix that entry and call create_app again. Nothing has been created yet.',
204
+ });
205
+ }
206
+
142
207
  const target = resolveIn(folder, 'create_app', 'folder');
143
208
  const attempted = `creating an app in ${target.show}`;
144
209
  if (target.show === '.') {
@@ -173,7 +238,7 @@ export async function createApp({ folder, name, description, template = 'next-sh
173
238
  __APP_DESCRIPTION__: plain(description) || display,
174
239
  };
175
240
 
176
- const files = await copyTree(path.join(TEMPLATES, template), target.abs, fill);
241
+ const copied = await copyTree(path.join(TEMPLATES, template), target.abs, fill);
177
242
  // Next.js serves static files from public/; a plain page has no such place
178
243
  // and an empty folder in a three-file app is clutter.
179
244
  if (template !== 'plain-html') await fs.mkdir(path.join(target.abs, 'public'), { recursive: true });
@@ -202,9 +267,27 @@ export async function createApp({ folder, name, description, template = 'next-sh
202
267
 
203
268
  const guide = await fs.readFile(path.join(target.abs, 'TEMPLATE.md'), 'utf8').catch(() => '');
204
269
 
205
- return result(
206
- `Created ${target.show} from the ${template} starter — ${files.length} files, already known to build.\n` +
270
+ // The app's own files, written in this same call. Two round trips become
271
+ // one, and round trips are nearly all of the time a build takes.
272
+ const mine = given;
273
+ const wrote = mine.length ? await batchWrite({ files: mine }) : null;
274
+ const written = new Set(mine.map((f) => resolveIn(f.path, 'create_app', 'files').abs));
275
+
276
+ // The starter's own files, in full, so there is never a reason to read them
277
+ // back — and only the ones this call did not already write over. A read is
278
+ // another round trip to learn what ucode already knows.
279
+ const starter = [];
280
+ for (const rel of SHOW_BACK[template] ?? []) {
281
+ const abs = path.join(target.abs, rel);
282
+ if (written.has(abs)) continue;
283
+ const text = await fs.readFile(abs, 'utf8').catch(() => null);
284
+ if (text !== null) starter.push(`=== ${target.show}/${rel} ===\n${text}`);
285
+ }
286
+
287
+ const out = result(
288
+ `Created ${target.show} from the ${template} starter — ${copied.length} files, already known to build.\n` +
207
289
  (look ? `Design: the ${look.name} preset (${look.summary}), font ${look.fonts?.sans ?? 'Geist'}.\n` : '') +
290
+ (wrote ? `\nYour ${mine.length} file${mine.length === 1 ? '' : 's'}:\n${wrote.content}\n` : '') +
208
291
  (linked
209
292
  ? `Its packages are already in place (${linked.toLocaleString()} files, linked from the starter cache) — ` +
210
293
  'nothing to install: build and run straight away.\n'
@@ -215,7 +298,14 @@ export async function createApp({ folder, name, description, template = 'next-sh
215
298
  (needsInstall
216
299
  ? `Run this app's commands with cwd: "${target.show}" (npm run build, npm run dev).\n\n${guide}`
217
300
  : `Nothing to install and nothing to build: open ${target.show}/index.html directly, or serve the ` +
218
- `folder with "python -m http.server 8000" if it fetches anything.\n\n${guide}`),
219
- `${files.length} files${linked ? ' · packages ready' : install && needsInstall ? ' · installing in the background' : ''}`
301
+ `folder with "python -m http.server 8000" if it fetches anything.\n\n${guide}`) +
302
+ (starter.length
303
+ ? `\n\nThe starter's files, in full — they are below, so do not read them back:\n\n${starter.join('\n\n')}`
304
+ : ''),
305
+ `${copied.length} files${wrote ? ` · ${mine.length} written` : ''}` +
306
+ `${linked ? ' · packages ready' : install && needsInstall ? ' · installing in the background' : ''}`,
307
+ 24_000
220
308
  );
309
+ if (wrote?.diff?.length) out.diff = wrote.diff;
310
+ return out;
221
311
  }
package/src/ui/plain.js CHANGED
@@ -14,6 +14,7 @@ import chalk from 'chalk';
14
14
  import {
15
15
  theme, blue, sky, dim, boxTop, boxBottom, boxRow,
16
16
  BANNER, BANNER_WIDTH, SPINNER, clip, shortenPath, asLabel, padVis, visLen, planLine,
17
+ tidyReply, trimAnswer,
17
18
  } from './theme.js';
18
19
  import { formatDuration, doneLine } from './activity.js';
19
20
  import { renderer, render } from './markdown.js';
@@ -185,8 +186,9 @@ export class Plain {
185
186
  for (const line of lines) this.output.write(` ${dim(line)}\n`);
186
187
  }
187
188
 
188
- assistant(text) {
189
- const out = render(this.md, text);
189
+ assistant(text, { closing = false } = {}) {
190
+ const body = closing ? trimAnswer(tidyReply(text)) : tidyReply(text);
191
+ const out = render(this.md, body);
190
192
  if (!out) return;
191
193
  this.stopSpinner();
192
194
  this.output.write(`\n${out}\n\n`);
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, bare, narration, narrationMark, groupKind, groupLabel, groupTarget, runLine, planRows, tidyReply } from './theme.js';
42
+ shortenPath, asLabel, ensureColour, planLine, bare, narration, narrationMark, groupKind, groupLabel, groupTarget, runLine, planRows, tidyReply, trimAnswer } from './theme.js';
43
43
  import { FRAME_MS, fitActivity, shimmer, spinnerGlyph, formatDuration, doneLine, stepPaint } from './activity.js';
44
44
  import { renderer, render, polish } from './markdown.js';
45
45
  import { VERSION } from '../core/version.js';
@@ -254,11 +254,20 @@ export class Screen {
254
254
  this.render();
255
255
  }
256
256
 
257
- assistant(text) {
257
+ /**
258
+ * The reply, at full strength, with room either side.
259
+ *
260
+ * `closing` says this is the last thing the turn will say. It is then also
261
+ * the last thing left on screen, and what the whole session reads like
262
+ * afterwards, so it is cut to eight lines — see trimAnswer.
263
+ */
264
+ assistant(text, { closing = false } = {}) {
258
265
  if (!text?.trim()) return;
266
+ const body = closing ? trimAnswer(tidyReply(text)) : tidyReply(text);
267
+ if (!body.trim()) return;
259
268
  this.endRun();
260
269
  this.add('');
261
- this.add(render(this.md, tidyReply(text)));
270
+ this.add(render(this.md, body));
262
271
  this.add('');
263
272
  this.render();
264
273
  }
@@ -529,7 +538,7 @@ export class Screen {
529
538
  * rather than an answer, in which case one short line folds down into the
530
539
  * status line it was always meant to be.
531
540
  */
532
- streamEnd({ asNarration = false } = {}) {
541
+ streamEnd({ asNarration = false, closing = false } = {}) {
533
542
  if (this.streamAt === undefined) return '';
534
543
  const text = this.streamBuf;
535
544
  this.lines.length = this.streamAt;
@@ -537,7 +546,7 @@ export class Screen {
537
546
  this.streamBuf = '';
538
547
 
539
548
  if (asNarration && isLabel(text)) this.narrate(text);
540
- else if (text.trim()) this.assistant(text);
549
+ else if (text.trim()) this.assistant(text, { closing });
541
550
  else this.render();
542
551
  return text;
543
552
  }
package/src/ui/theme.js CHANGED
@@ -395,7 +395,7 @@ export function runLine({ label, count = 1, targets = [], added = 0, removed = 0
395
395
  * taken out, is a colon pointing at nothing — which reads as the reply having
396
396
  * been cut off mid-thought. The lead-in goes with what it was leading to.
397
397
  */
398
- const LEAD_IN = /(?:^|\n)[^\n]{0,80}:[ \t]*\n+$/;
398
+ const LEAD_IN = /(?:^|\n)[^\n]{0,80}:[ \t]*\n+$/;
399
399
 
400
400
  export function withoutCodeBlocks(text, keepLines = 4) {
401
401
  const FENCE = /```([A-Za-z0-9+-]*)\n([\s\S]*?)```/g;
@@ -411,26 +411,102 @@ export function withoutCodeBlocks(text, keepLines = 4) {
411
411
  * The reply as it should be read: no pasted code, and no sentence left
412
412
  * pointing at code that is no longer there.
413
413
  */
414
- /**
415
- * The reply as it should be read.
416
- *
417
- * A long pasted block goes, and so does the sentence that introduced it — a
418
- * colon pointing at nothing reads as the reply having been cut off. A short
419
- * block stays: three lines showing a command to run belong in an answer.
420
- */
421
- export function tidyReply(text, keepLines = 4) {
422
- const MARK = "\u0000CUT\u0000";
423
- const FENCE = new RegExp("```([A-Za-z0-9+-]*)\\n([\\s\\S]*?)```", "g");
424
-
425
- const marked = String(text ?? "").replace(FENCE, (all, lang, body) => {
426
- const rows = body.replace(new RegExp("\\n+$"), "").split("\n");
427
- return rows.length <= keepLines ? all : MARK;
428
- });
429
-
430
- const leadIn = new RegExp("(?:^|\\n)[^\\n]{0,80}:[ \t]*\\n+" + MARK, "g");
431
- return marked
432
- .replace(leadIn, "\n")
433
- .split(MARK).join("")
434
- .replace(new RegExp("\\n{3,}", "g"), "\n\n")
435
- .trim();
436
- }
414
+ /**
415
+ * The reply as it should be read.
416
+ *
417
+ * A long pasted block goes, and so does the sentence that introduced it — a
418
+ * colon pointing at nothing reads as the reply having been cut off. A short
419
+ * block stays: three lines showing a command to run belong in an answer.
420
+ */
421
+ export function tidyReply(text, keepLines = 4) {
422
+ const MARK = "\u0000CUT\u0000";
423
+ const FENCE = new RegExp("```([A-Za-z0-9+-]*)\\n([\\s\\S]*?)```", "g");
424
+
425
+ const marked = String(text ?? "").replace(FENCE, (all, lang, body) => {
426
+ const rows = body.replace(new RegExp("\\n+$"), "").split("\n");
427
+ return rows.length <= keepLines ? all : MARK;
428
+ });
429
+
430
+ const leadIn = new RegExp("(?:^|\\n)[^\\n]{0,80}:[ \t]*\\n+" + MARK, "g");
431
+ return marked
432
+ .replace(leadIn, "\n")
433
+ .split(MARK).join("")
434
+ .replace(new RegExp("\\n{3,}", "g"), "\n\n")
435
+ .trim();
436
+ }
437
+
438
+ /**
439
+ * The closing message, cut to what a terminal can take.
440
+ *
441
+ * A model that finishes a build by walking back through the request — every
442
+ * feature ticked off, every file listed — leaves that as the last thing on
443
+ * screen, and the whole session then reads like a status report. Eight lines
444
+ * is the whole of it: what it is, and how to try it.
445
+ *
446
+ * What goes: an opening that reads the request back, and the middle of a list
447
+ * too long to be worth reading. What stays: the first lines, the line that
448
+ * admits something is unfinished, and the line naming a file or a command —
449
+ * the two the user actually acts on, and both of them live at the end.
450
+ */
451
+ export const ANSWER_LINES = 8;
452
+ const ANSWER_ROOM = 600; // eight wrapped lines of prose, for a reply with no line breaks in it
453
+
454
+ const RESTATED = /^(?:you (?:asked|wanted|requested|said)\b|the (?:request|task|ask)\b|as (?:you )?requested\b|here(?:'s| is) what (?:you asked|i)\b|to (?:summarise|summarize|recap)\b|(?:request|task|summary|recap|overview)\s*:)/i;
455
+ const CAVEAT = /\b(?:however|failed|couldn't|could not|cannot|can't|didn't|did not|isn't|is not|doesn't|does not|not (?:yet|wired|working|done|implemented)|missing|unfinished|except)\b/i;
456
+ const ACTIONABLE = /\b(?:open|run|serve|visit|try|start|npm|npx|node|pnpm|yarn)\b|https?:\/\/|\.(?:html?|css|jsx?|tsx?|md|json|py|rs|go)\b/i;
457
+ const BULLET = /^\s*(?:[-*•>]|\d+[.)]|[✓✔✅☑])\s+/;
458
+
459
+ export function trimAnswer(text, max = ANSWER_LINES) {
460
+ const all = String(text ?? '').replace(/\r/g, '').split('\n');
461
+
462
+ let start = 0;
463
+ while (start < all.length && (!all[start].trim() || RESTATED.test(all[start].trim()))) start++;
464
+ const rows = all.slice(start);
465
+
466
+ const body = rows.map((row, i) => ({ row, i })).filter((r) => r.row.trim());
467
+ if (!body.length) return '';
468
+
469
+ let kept;
470
+ if (body.length <= max) {
471
+ kept = body.map((r) => r.i);
472
+ } else {
473
+ // Searched from the end: the caveat and the how-to-try-it line are the
474
+ // last things written, and they are the two worth pulling out of the part
475
+ // being dropped.
476
+ const tail = body.slice(Math.max(1, max - 2));
477
+ const pick = (re) => [...tail].reverse().find((r) => re.test(r.row))?.i;
478
+ const rescued = [...new Set([pick(CAVEAT), pick(ACTIONABLE)])].filter((i) => i !== undefined);
479
+ const head = body.slice(0, max - rescued.length).map((r) => r.i);
480
+ kept = [...new Set([...head, ...rescued])].sort((a, b) => a - b);
481
+ }
482
+
483
+ const out = [];
484
+ let previous = -1;
485
+ for (const i of kept) {
486
+ if (previous >= 0 && i > previous + 1) out.push(''); // a gap in the middle is a paragraph break
487
+ // A line lifted out of a list is no longer in one.
488
+ out.push(previous >= 0 && i > previous + 1 ? rows[i].replace(BULLET, '') : rows[i]);
489
+ previous = i;
490
+ }
491
+
492
+ return withinRoom(out.join('\n').replace(/\n{3,}/g, '\n\n').trim());
493
+ }
494
+
495
+ /**
496
+ * One long paragraph is one line and fills the screen anyway. Whole sentences
497
+ * only: a reply cut mid-clause reads as a crash rather than as an ending.
498
+ */
499
+ function withinRoom(text, room = ANSWER_ROOM) {
500
+ if (text.length <= room) return text;
501
+
502
+ const parts = text.split(/(?<=[.!?])(\s+)/);
503
+ let out = '';
504
+ let sentences = 0;
505
+ for (let i = 0; i < parts.length; i += 2) {
506
+ const next = out + parts[i] + (parts[i + 1] ?? '');
507
+ if (sentences >= 2 && next.trimEnd().length > room) break;
508
+ out = next;
509
+ sentences++;
510
+ }
511
+ return (out.trim() || text.slice(0, room)).trim();
512
+ }