ucode-agent 1.29.0 → 1.29.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucode-agent",
3
- "version": "1.29.0",
3
+ "version": "1.29.1",
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",
package/src/ui/screen.js CHANGED
@@ -186,6 +186,7 @@ export class Screen {
186
186
  this.intro = 0; // when the launch sweep began, 0 once it is over
187
187
  this.introTimer = null;
188
188
  this.pendingPrompt = null;
189
+ this.lastPrompt = ''; // the last thing the user said, for the echo check
189
190
  this.facts.branch = gitBranch(cwd);
190
191
 
191
192
  this.cols = output.columns || 80;
@@ -340,7 +341,8 @@ export class Screen {
340
341
  */
341
342
  assistant(text, { closing = false } = {}) {
342
343
  if (!text?.trim()) return;
343
- const body = closing ? trimAnswer(tidyReply(text)) : tidyReply(text);
344
+ const tidy = tidyReply(text, 4, this.lastPrompt);
345
+ const body = closing ? trimAnswer(tidy) : tidy;
344
346
  if (!body.trim()) return;
345
347
  this.endRun();
346
348
  this.add('');
@@ -367,6 +369,9 @@ export class Screen {
367
369
  * loud as the input box, none of them saying anything the rail does not.
368
370
  */
369
371
  userMessage(text) {
372
+ // Kept so the reply can be checked against it: an answer that opens by
373
+ // saying the request back is repeating the line directly above it.
374
+ this.lastPrompt = String(text ?? '');
370
375
  const room = Math.max(8, this.width() - 2); // the rail and the space after it
371
376
 
372
377
  const rows = [];
@@ -730,14 +735,21 @@ export class Screen {
730
735
  const room = Math.max(8, inner - BANNER_WIDTH - 6);
731
736
  const value = (v) => clip(String(v), Math.max(4, room - 10));
732
737
  const branch = this.facts.branch;
738
+
739
+ // The facts run together from the top with nothing between them, the credit
740
+ // sits on the last row, and whatever is left over is the gap between the
741
+ // two. Holding a row empty in the middle of the list — which is what a
742
+ // fixed six-row layout did when a fact was missing — reads as a line that
743
+ // failed to draw rather than as spacing.
733
744
  const facts = [
734
745
  ['dir', value(shortenPath(this.facts.cwd ?? this.cwd, room - 10))],
735
- branch ? ['branch', value(branch)] : ['', ''],
736
- VERSION ? ['version', value(this.facts.update ? `${VERSION} → ${this.facts.update} next start` : VERSION)] : ['', ''],
737
- ['', ''],
746
+ branch && ['branch', value(branch)],
747
+ VERSION && ['version', value(this.facts.update ? `${VERSION} → ${this.facts.update} next start` : VERSION)],
738
748
  ['keys', value('/help · esc interrupts · ctrl+b plan')],
739
- ['', 'made with ❤️ by om dixit'],
740
- ];
749
+ ].filter(Boolean).slice(0, BANNER.length - 1);
750
+
751
+ while (facts.length < BANNER.length - 1) facts.push(['', '']);
752
+ facts.push(['', 'made with ❤️ by om dixit']);
741
753
 
742
754
  const rows = BANNER.map((art, i) => {
743
755
  const [label, text] = facts[i] ?? ['', ''];
package/src/ui/theme.js CHANGED
@@ -535,7 +535,7 @@ export function withoutCodeBlocks(text, keepLines = 4) {
535
535
  * colon pointing at nothing reads as the reply having been cut off. A short
536
536
  * block stays: three lines showing a command to run belong in an answer.
537
537
  */
538
- export function tidyReply(text, keepLines = 4) {
538
+ export function tidyReply(text, keepLines = 4, prompt = '') {
539
539
  const MARK = "\u0000CUT\u0000";
540
540
  const FENCE = new RegExp("```([A-Za-z0-9+-]*)\\n([\\s\\S]*?)```", "g");
541
541
 
@@ -545,11 +545,76 @@ export function tidyReply(text, keepLines = 4) {
545
545
  });
546
546
 
547
547
  const leadIn = new RegExp("(?:^|\\n)[^\\n]{0,80}:[ \t]*\\n+" + MARK, "g");
548
- return marked
548
+ const body = marked
549
549
  .replace(leadIn, "\n")
550
550
  .split(MARK).join("")
551
551
  .replace(new RegExp("\\n{3,}", "g"), "\n\n")
552
552
  .trim();
553
+ return withoutRestatement(body, prompt);
554
+ }
555
+
556
+ /**
557
+ * The words of a line worth comparing: lowercase, no punctuation, and nothing
558
+ * short enough to turn up in any sentence at all.
559
+ */
560
+ const significant = (s) => String(s ?? '')
561
+ .toLowerCase()
562
+ .replace(/[^a-z0-9\s]+/g, ' ')
563
+ .split(/\s+/)
564
+ .filter((w) => w.length >= 3);
565
+
566
+ /**
567
+ * Is this line the request handed back?
568
+ *
569
+ * A list of phrases catches the openings a model reaches for out of habit, but
570
+ * the commonest way of repeating a request is simply saying it again in the
571
+ * asker's own words — "A Next.js habit tracker with a clean dashboard, coming
572
+ * right up" — and no list will ever match that. So the line is compared with
573
+ * what was actually typed.
574
+ *
575
+ * Three guards keep it off real answers. A prompt of four significant words or
576
+ * fewer is never matched, because at that length an overlap means nothing. A
577
+ * line much longer than the prompt is saying more than the prompt did, so it
578
+ * is content. And the bar is four fifths of the prompt's words rather than a
579
+ * majority: an answer naturally shares nouns with the request that prompted
580
+ * it, and only something repeating nearly all of it is a repetition.
581
+ */
582
+ export function echoesPrompt(line, prompt) {
583
+ const want = [...new Set(significant(prompt))];
584
+ if (want.length < 4) return false;
585
+
586
+ const text = String(line ?? '');
587
+ if (text.length > String(prompt ?? '').length * 2.5) return false;
588
+
589
+ const have = new Set(significant(text));
590
+ return want.filter((w) => have.has(w)).length / want.length >= 0.8;
591
+ }
592
+
593
+ /**
594
+ * The reply with any opening that reads the request back taken off the front.
595
+ *
596
+ * This ran only on the closing message before, and it belongs on every one. A
597
+ * model that answers "You asked me to add a dark mode toggle — done" has spent
598
+ * its first line telling someone something they typed themselves, and the line
599
+ * directly above it on screen is already their own message, in their own
600
+ * words, against a rail. Two copies of the request and one of the answer is
601
+ * the wrong ratio.
602
+ *
603
+ * It never returns nothing. A reply that is only a restatement is still the
604
+ * whole of the reply, and an empty answer on screen reads as a crash.
605
+ */
606
+ export function withoutRestatement(text, prompt = '') {
607
+ const rows = String(text ?? '').replace(/\r/g, '').split('\n');
608
+
609
+ let start = 0;
610
+ while (start < rows.length) {
611
+ const line = rows[start].trim();
612
+ if (!line) { start++; continue; }
613
+ if (!RESTATED.test(line) && !echoesPrompt(line, prompt)) break;
614
+ start++;
615
+ }
616
+
617
+ return rows.slice(start).join('\n').trim() || String(text ?? '').trim();
553
618
  }
554
619
 
555
620
  /**
@@ -568,7 +633,7 @@ export function tidyReply(text, keepLines = 4) {
568
633
  export const ANSWER_LINES = 8;
569
634
  const ANSWER_ROOM = 600; // eight wrapped lines of prose, for a reply with no line breaks in it
570
635
 
571
- 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;
636
+ export const RESTATED = /^(?:(?:sure|ok|okay|got it|understood|alright|right)\b[\s,!.—-]*)?(?:you(?:'ve| have)? (?:asked|want|wanted|requested|said|would like|need)\b|the (?:request|task|ask)\b|as (?:you )?requested\b|here(?:'s| is) what (?:you asked|i)\b|i(?:'ll| will|'m going to| am going to) (?:build|create|make|add|write|implement)\b|let(?:'s| us) (?:build|create|make|add|write|implement)\b|to (?:summarise|summarize|recap)\b|(?:request|task|summary|recap|overview)\s*:)/i;
572
637
  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;
573
638
  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;
574
639
  const BULLET = /^\s*(?:[-*•>]|\d+[.)]|[✓✔✅☑])\s+/;