ucode-agent 1.54.0 → 1.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ui/plain.js CHANGED
@@ -186,14 +186,20 @@ export class Plain {
186
186
  for (const line of lines) this.output.write(` ${dim(line)}\n`);
187
187
  }
188
188
 
189
- assistant(text, { closing = false } = {}) {
190
- const body = closing ? trimAnswer(tidyReply(text)) : tidyReply(text);
189
+ assistant(text, { closing = false, replay = false } = {}) {
190
+ const body = replay ? String(text) : (closing ? trimAnswer(tidyReply(text)) : tidyReply(text));
191
191
  const out = render(this.md, body);
192
192
  if (!out) return;
193
193
  this.stopSpinner();
194
194
  this.output.write(`\n${out}\n\n`);
195
195
  }
196
196
 
197
+ userMessage(text) {
198
+ this.stopSpinner();
199
+ const first = String(text ?? '').split('\n')[0] ?? '';
200
+ if (first.trim()) this.output.write(`\n${blue('›')} ${first}\n`);
201
+ }
202
+
197
203
  narrate(text) {
198
204
  const line = asLabel(text);
199
205
  if (!line) return;
package/src/ui/screen.js CHANGED
@@ -99,6 +99,34 @@ const NEWLINE = String.fromCharCode(10);
99
99
  const at = (row, col) => `${ESC}[${row};${col}H`;
100
100
  const title = (t) => `${ESC}]0;${t}\x07`;
101
101
 
102
+ /**
103
+ * Every paint is wrapped in these. Autowrap off means a row that is one cell
104
+ * wider than we counted loses its last cell instead of wrapping onto the next
105
+ * row and scrolling the whole frame up — that scroll was the glitch. The
106
+ * synchronized-update pair makes terminals that support it show the frame in
107
+ * one go; the rest ignore it.
108
+ */
109
+ const PAINT_BEGIN = `${ESC}[?2026h${ESC}[?7l`;
110
+ const PAINT_END = `${ESC}[?7h${ESC}[?2026l`;
111
+
112
+ /**
113
+ * Text as it may be drawn: colour codes kept, everything that moves the cursor
114
+ * gone. A carriage return from a CRLF file sent the padding back over the
115
+ * line, a tab took eight cells while it was counted as one, and a clear-screen
116
+ * from a tool's output wiped the frame mid-paint.
117
+ *
118
+ * The SGR colour codes (\x1b[..m) are matched first and kept. An earlier
119
+ * version only excluded them from the CSI branch, so the bare-\x1b fallback
120
+ * stripped the ESC off every colour code and left "[36m" littered across
121
+ * coloured lines — visible only in a real terminal, never in tests, which is
122
+ * why the glitch survived the suite.
123
+ */
124
+ function printable(text) {
125
+ return String(text)
126
+ .replace(/\t/g, ' ')
127
+ .replace(/\x1b\[[0-9;]*m|(\x1b(?:\[[0-?]*[ -\/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[()#][0-9A-Za-z]|[\x30-\x7e])?|[\x00-\x09\x0b-\x1a\x1c-\x1f\x7f])/g, (m, bad) => (bad ? '' : m));
128
+ }
129
+
102
130
  /**
103
131
  * Fixed rows below the header: the gap under it, the gap above the input box,
104
132
  * the input box's two borders, the blank row inside it, and the status row.
@@ -181,6 +209,7 @@ export class Screen {
181
209
  this.onModeChange = null;
182
210
  this.spinTimer = null;
183
211
  this.paintedBusy = false; // whose turn the frame on screen was drawn for
212
+ this.paintedLines = null; // transcript length at the last paint; growth since then belongs underneath a scrolled-back view
184
213
  this.activity = null; // the turn in flight: when it began, how many steps
185
214
  this.tick = 0; // animation frames painted, for the spinner
186
215
  this.intro = 0; // when the launch sweep began, 0 once it is over
@@ -292,11 +321,10 @@ export class Screen {
292
321
  */
293
322
  add(text = '') {
294
323
  const width = this.width();
295
- for (const raw of String(text).split('\n')) {
324
+ for (const raw of printable(text).split('\n')) {
296
325
  if (visLen(raw) <= width) this.lines.push(raw);
297
326
  else for (const wrapped of wrapAnsi(raw, width)) this.lines.push(wrapped);
298
327
  }
299
- this.scroll = 0; // new output snaps back to the bottom
300
328
  }
301
329
 
302
330
  push(text = '') {
@@ -329,6 +357,7 @@ export class Screen {
329
357
  clearScreen() {
330
358
  this.lines = [];
331
359
  this.scroll = 0;
360
+ this.paintedLines = null;
332
361
  this.render();
333
362
  }
334
363
 
@@ -339,10 +368,14 @@ export class Screen {
339
368
  * the last thing left on screen, and what the whole session reads like
340
369
  * afterwards, so it is cut to eight lines — see trimAnswer.
341
370
  */
342
- assistant(text, { closing = false } = {}) {
371
+ assistant(text, { closing = false, replay = false, silent = false } = {}) {
343
372
  if (!text?.trim()) return;
373
+ // A replayed answer goes back exactly as it was first shown: no tidying,
374
+ // no trimming, no re-read of the request. Tidy-up exists to keep a live
375
+ // answer short; on a resumed one it rewrites history, which is why a
376
+ // resumed session read like only fragments had survived.
344
377
  const tidy = tidyReply(text, 4, this.lastPrompt);
345
- const body = closing ? trimAnswer(tidy) : tidy;
378
+ const body = replay ? String(text) : (closing ? trimAnswer(tidy) : tidy);
346
379
  if (!body.trim()) return;
347
380
  this.endRun();
348
381
  this.add('');
@@ -355,7 +388,7 @@ export class Screen {
355
388
  this.add(render(this.md, body));
356
389
 
357
390
  this.add('');
358
- this.render();
391
+ if (!silent) this.render();
359
392
  }
360
393
 
361
394
  /**
@@ -368,10 +401,11 @@ export class Screen {
368
401
  * ladder of rules across the page: two horizontal lines per message, each as
369
402
  * loud as the input box, none of them saying anything the rail does not.
370
403
  */
371
- userMessage(text) {
404
+ userMessage(text, { silent = false } = {}) {
372
405
  // Kept so the reply can be checked against it: an answer that opens by
373
406
  // saying the request back is repeating the line directly above it.
374
407
  this.lastPrompt = String(text ?? '');
408
+ this.scroll = 0; // sending something is the one thing that jumps to the bottom
375
409
  const room = Math.max(8, this.width() - 2); // the rail and the space after it
376
410
 
377
411
  const rows = [];
@@ -385,7 +419,7 @@ export class Screen {
385
419
  for (const row of rows) this.add(`${blue(RAIL)} ${chalk.white(row)}`);
386
420
  this.add('');
387
421
  this.add('');
388
- this.render();
422
+ if (!silent) this.render();
389
423
  }
390
424
 
391
425
  /**
@@ -1019,10 +1053,10 @@ export class Screen {
1019
1053
  const width = this.width();
1020
1054
  const liveAt = this.activityRowAt();
1021
1055
  this.output.write(
1022
- HIDE +
1056
+ PAINT_BEGIN + HIDE +
1023
1057
  (liveAt ? at(liveAt, 1) + CLEAR_LINE + padVis(this.activityLine(width), width) : '') +
1024
1058
  at(this.rows - 1, 1) + CLEAR_LINE + boxRow(this.statusRow(width), width, this.borderPaint()) +
1025
- at(row, col) + SHOW
1059
+ at(row, col) + SHOW + PAINT_END
1026
1060
  );
1027
1061
  }
1028
1062
 
@@ -1149,6 +1183,7 @@ export class Screen {
1149
1183
  }
1150
1184
 
1151
1185
  this.pendingPrompt = 'go ahead? [y/N]';
1186
+ this.scroll = 0; // the question has to be on screen to be answered
1152
1187
  this.render();
1153
1188
 
1154
1189
  return this.nextLine().then((answer) => {
@@ -1273,7 +1308,8 @@ export class Screen {
1273
1308
  const before = this.scroll;
1274
1309
  this.scroll = Math.min(Math.max(0, this.scroll + delta), max);
1275
1310
  if (this.scroll === before && delta > 0) this.flash('already at the top');
1276
- this.render();
1311
+ // One wheel notch arrives as three arrow keys in one chunk; one frame, not three.
1312
+ this.soon();
1277
1313
  }
1278
1314
 
1279
1315
  /**
@@ -1511,6 +1547,20 @@ export class Screen {
1511
1547
  const width = this.width();
1512
1548
  const height = this.viewportHeight();
1513
1549
 
1550
+ // Scrolled back, the view stays on what is being read while output grows
1551
+ // underneath: every line added since the last paint extends the scroll by
1552
+ // the same amount, so the same rows stay on screen. Snapping to the
1553
+ // bottom on every streamed line fought the wheel sixteen times a second.
1554
+ // A streamed reply truncates its tail and rebuilds it on every delta, so
1555
+ // the growth since the last paint is net — but the tail sits below a
1556
+ // scrolled-back viewport, and net growth still moves the scroll by exactly
1557
+ // the amount that keeps the visible rows put.
1558
+ if (this.scroll > 0 && this.paintedLines != null) {
1559
+ const grown = this.lines.length - this.paintedLines;
1560
+ this.scroll = Math.min(Math.max(0, this.scroll + grown), Math.max(0, this.lines.length - height));
1561
+ }
1562
+ this.paintedLines = this.lines.length;
1563
+
1514
1564
  // The live line is the last line of the conversation, not a fixture above
1515
1565
  // the input box. Pinned down there it sat at the bottom of the screen while
1516
1566
  // the message that started it was at the top, with the empty middle of the
@@ -1539,13 +1589,13 @@ export class Screen {
1539
1589
  // The cursor is hidden for the duration of the paint. Without this it is
1540
1590
  // dragged through every line as the frame is written, which shows up as a
1541
1591
  // dot flickering above the input box on every keystroke.
1542
- const out = [HIDE, HOME];
1592
+ const out = [PAINT_BEGIN, HIDE];
1543
1593
  for (let i = 0; i < this.rows; i++) {
1544
- out.push(CLEAR_LINE + padVis(frame[i] ?? '', width) + (i === this.rows - 1 ? '' : '\n'));
1594
+ out.push(at(i + 1, 1) + CLEAR_LINE + padVis(frame[i] ?? '', width));
1545
1595
  }
1546
1596
 
1547
1597
  const [row, col] = this.caret();
1548
- out.push(at(row, col) + SHOW);
1598
+ out.push(at(row, col) + SHOW + PAINT_END);
1549
1599
  this.paintedBusy = this.busy();
1550
1600
  this.output.write(out.join(''));
1551
1601
  }
@@ -1637,12 +1687,12 @@ export class Screen {
1637
1687
  frame[this.rows - 1] = ' '.repeat(Math.max(0, g.cols - visLen(tag) - 2)) + tag;
1638
1688
  }
1639
1689
 
1640
- const out = [HIDE, HOME];
1690
+ const out = [PAINT_BEGIN, HIDE];
1641
1691
  for (let i = 0; i < this.rows; i++) {
1642
- out.push(CLEAR_LINE + padVis(frame[i], g.cols) + (i === this.rows - 1 ? '' : '\n'));
1692
+ out.push(at(i + 1, 1) + CLEAR_LINE + padVis(frame[i], g.cols));
1643
1693
  }
1644
1694
  const [row, col] = this.caret();
1645
- out.push(at(row, col) + SHOW);
1695
+ out.push(at(row, col) + SHOW + PAINT_END);
1646
1696
  this.paintedBusy = this.busy();
1647
1697
  this.output.write(out.join(''));
1648
1698
  }
package/src/ui/theme.js CHANGED
@@ -174,6 +174,21 @@ export const bare = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, '');
174
174
  * and a table of every Unicode width would be a megabyte to get the last
175
175
  * fraction of a percent right.
176
176
  */
177
+ /**
178
+ * The emoji among the older symbol blocks — ✅ ❌ ⚡ ⭐ ⌛ and friends. They sit
179
+ * between box-drawing characters that are one cell, so they are listed one by
180
+ * one rather than as a range. Counted as one cell, a model's "✅ Done" line
181
+ * ran a column past the edge, wrapped, and scrolled the whole frame.
182
+ */
183
+ const WIDE_SYMBOLS = new Set([
184
+ 0x231a, 0x231b, 0x23e9, 0x23ea, 0x23eb, 0x23ec, 0x23f0, 0x23f3, 0x25fd, 0x25fe,
185
+ 0x2614, 0x2615, 0x2648, 0x2649, 0x264a, 0x264b, 0x264c, 0x264d, 0x264e, 0x264f,
186
+ 0x2650, 0x2651, 0x2652, 0x2653, 0x267f, 0x2693, 0x26a1, 0x26aa, 0x26ab, 0x26bd,
187
+ 0x26be, 0x26c4, 0x26c5, 0x26ce, 0x26d4, 0x26ea, 0x26f2, 0x26f3, 0x26f5, 0x26fa,
188
+ 0x26fd, 0x2705, 0x270a, 0x270b, 0x2728, 0x274c, 0x274e, 0x2753, 0x2754, 0x2755,
189
+ 0x2757, 0x2795, 0x2796, 0x2797, 0x27b0, 0x27bf, 0x2b1b, 0x2b1c, 0x2b50, 0x2b55,
190
+ ]);
191
+
177
192
  export function charWidth(code) {
178
193
  // Zero: combining marks, joiners, variation selectors.
179
194
  if ((code >= 0x0300 && code <= 0x036f)
@@ -194,10 +209,15 @@ export function charWidth(code) {
194
209
  || (code >= 0xfe30 && code <= 0xfe6f)
195
210
  || (code >= 0xff00 && code <= 0xff60)
196
211
  || (code >= 0xffe0 && code <= 0xffe6)
212
+ || (code >= 0x1f000 && code <= 0x1f02f)
213
+ || code === 0x1f0cf
214
+ || (code >= 0x1f18e && code <= 0x1f2ff)
197
215
  || (code >= 0x1f300 && code <= 0x1f64f)
198
216
  || (code >= 0x1f680 && code <= 0x1f6ff)
199
- || (code >= 0x1f900 && code <= 0x1f9ff)
200
- || (code >= 0x20000 && code <= 0x3fffd)) return 2;
217
+ || (code >= 0x1f7e0 && code <= 0x1f7eb)
218
+ || (code >= 0x1f900 && code <= 0x1faff)
219
+ || (code >= 0x20000 && code <= 0x3fffd)
220
+ || WIDE_SYMBOLS.has(code)) return 2;
201
221
 
202
222
  return 1;
203
223
  }