Skip to content

fix(tui): file-writes rows pin keyword + metadata, head-truncate path — no more 2-line wraps#684

Merged
kelsonpw merged 8 commits into
mainfrom
fix/file-writes-no-wrap
May 9, 2026
Merged

fix(tui): file-writes rows pin keyword + metadata, head-truncate path — no more 2-line wraps#684
kelsonpw merged 8 commits into
mainfrom
fix/file-writes-no-wrap

Conversation

@kelsonpw
Copy link
Copy Markdown
Member

@kelsonpw kelsonpw commented May 9, 2026

User report

Three coupled rendering bugs visible in the FileWritesPanel during a recent run on a Next.js codebase:

✓ CREATE src/app/order/remove-from-cart-button.tsx · 788 bytes · 4ms
✓ MODIFY src/app/order/page.tsx · edited 6ms

✓ CREAT src/app/(category-sidebar)/products/[category]/[subcategory]/[product] · 646 bytes
                                                                                   5ms     ← bug A (wrap) + bug B (CREAT)

✓ MODIFYsrc/app/(category-sidebar)/products/[category]/[subcategory]/[product]…  · edited  9ms
                                                                                              ← bug A (wrap) + bug C (no space)

Bug A — long paths wrap to 2 rows

Long Next.js segment paths reflowed the row onto a second visual line; the trailing · edited Xms jumped to a new line, misaligned far to the right.

Bug B — CREATE truncated to CREAT

On the wrapped row the keyword lost its final E. Yoga was stealing a column from the keyword cell to make room for the path.

Bug C — missing space between keyword and path

Same wrap reflow also collapsed the gap between the keyword and the path (MODIFYsrc/app/…).

Root cause

The row was a flat sequence of <Text> nodes with implicit flex behavior. When the path overflowed the available width, Yoga shrank every cell — including the keyword, the icon column, and the inter-cell spaces — to make room. There was no fixed-width pin on any of the chrome cells.

Fix

Every cell is now an explicit <Box> with width behavior pinned:

Cell Width flexShrink
Indent + status icon 3 cols 0
Keyword (CREATE / MODIFY / DELETE) 7 cols (max keyword + 1 space) 0
Path flexible 1
Trailing detail ( · edited Xms) content-sized 0

The path cell is the only flexible column. It absorbs overflow via head-truncation (…/[product]/page.tsx) using a budget computed from useStdoutDimensions().cols - fixed_chrome - detail.length. wrap="truncate-end" on the inner Text node is a defense-in-depth safety net if the width prop disagrees with the actual frame width (resize lag).

truncatePathHead() walks segments from the right, dropping leading segments until the result fits the budget. If even the basename overflows, falls back to middle-truncation that keeps the file extension visible.

Before / After

Before (90-char path at 100 cols):

✓ CREAT src/app/(category-sidebar)/products/[category]/[subcategory]/[product] · 646 bytes
                                                                                  5ms

After (same path, same width):

✓ CREATE …/products/[category]/[subcategory]/[product]/page.tsx · 646 bytes 5ms

Coordination

Test plan

  • node_modules/.bin/tsc -p tsconfig.json — clean
  • node_modules/.bin/vitest run --pool=forks --maxWorkers=1 src/ui/tui/components/__tests__/FileWritesPanel.test.tsx — 21/21 pass
  • node_modules/.bin/vitest run --pool=forks --maxWorkers=1 — 3637/3637 pass (245 files)
  • pnpm lint — clean (only the pre-existing unrelated warning in EventPlanFullScreen.test.tsx)
  • New parameterized tests at 25 / 60 / 80 / 120 / 200 cols verify single-line rendering at every width
  • Regex assertions lock down bug B (CREATE\s) and bug C (no MODIFY[A-Za-z…/])
  • Manual smoke at narrow / medium / wide terminal widths

🤖 Generated with Claude Code


Note

Low Risk
Low risk: changes are limited to TUI layout/rendering and add regression tests; main risk is minor formatting regressions on unusual terminal widths.

Overview
Fixes FileWritesPanel rendering for long file paths by pinning the icon/keyword/detail columns (flexShrink={0}), computing a per-row path width budget, and head-truncating paths via new truncatePathHead() to guarantee single-line rows.

RunScreen now passes measured terminal width into FileWritesPanel, and the test suite adds parameterized no-wrap regression coverage to prevent keyword truncation, missing keyword/path spacing, and detail-column wrapping.

Reviewed by Cursor Bugbot for commit 5ac7acf. Bugbot is set up for automated code reviews on this repo. Configure here.

… — no more 2-line wraps

Long paths (e.g. `src/app/(category-sidebar)/products/[category]/[subcategory]/[product]/page.tsx`)
made every row in the FileWritesPanel reflow onto two visual lines, and Yoga
stole columns from neighboring cells in the process — the keyword printed as
`CREAT` (missing trailing E) and the gap between keyword and path collapsed
(`MODIFYsrc/app/...`).

Three coupled bugs in one fix:
  A. Path wraps to 2 rows when it overflows the row width.
  B. `CREATE` truncated to `CREAT` on wrapped rows.
  C. Space between keyword and path lost on wrapped rows.

Root cause: the row was a flat sequence of `<Text>` nodes with implicit
flex behavior. When the path overflowed, Yoga shrank every cell to make
room — including the keyword, the icon, and the leading space.

Fix: every cell becomes an explicit `<Box>` with width pinned via
`flexShrink={0}`. The keyword cell is `width=7` so `MODIFY` + 1 trailing
space always fits. The trailing detail cell is also `flexShrink={0}` so
it stays glued to the row. The path is the only `flexShrink={1}` cell
and absorbs overflow via head-truncation (`…/[product]/page.tsx`),
implemented via `truncatePathHead()` against a width budget computed
from the visible terminal columns. `wrap="truncate-end"` on the inner
path Text is a defense-in-depth safety net for resize lag.

Coordinates with #676 (dedupe `× N` annotation): the merge auto-resolved
cleanly, both behaviors are preserved by the test suite (21/21 pass).

Test plan:
  - 5 widths (25 / 60 / 80 / 120 / 200 cols) × long path → 1 row each
  - assert `CREATE\s` matches and `CREATE[^\s]` doesn't (bug B/C)
  - assert head-truncated form contains `…` and the basename survives
  - 3637 / 3637 vitest pass; tsc clean; pnpm lint clean (only pre-existing
    unrelated warning in EventPlanFullScreen.test.tsx)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@kelsonpw kelsonpw requested a review from a team as a code owner May 9, 2026 00:13
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: truncatePathHead can return strings exceeding maxWidth by one
    • Fixed the loop guard from +1 to +2 to correctly account for the 2-char …/ prefix, set KEYWORD_PATH_GAP to 0 since the keyword box already provides the trailing space, and added an early return of the bare basename when no parent segments fit to avoid prepending …/ to a string that would overflow.
  • ✅ Fixed: Middle-truncation garbles basenames that fit within budget
    • Changed the middle-truncation guard from basename.length + 1 >= maxWidth to basename.length > maxWidth so basenames that fit within the budget are returned as-is instead of being unnecessarily mangled.

Create PR

Or push these changes by commenting:

@cursor push 8fffdcab77
Preview (8fffdcab77)
diff --git a/src/ui/tui/components/FileWritesPanel.tsx b/src/ui/tui/components/FileWritesPanel.tsx
--- a/src/ui/tui/components/FileWritesPanel.tsx
+++ b/src/ui/tui/components/FileWritesPanel.tsx
@@ -99,7 +99,7 @@
   const basename = segments[segments.length - 1] ?? raw;
   // Basename alone overflows — middle-truncate it. Keeping the file
   // extension visible is the priority.
-  if (basename.length + 1 >= maxWidth) {
+  if (basename.length > maxWidth) {
     if (maxWidth <= 3) return '…';
     const keep = maxWidth - 1; // reserve 1 col for the ellipsis
     const head = Math.ceil(keep / 2);
@@ -112,10 +112,11 @@
   let acc = basename;
   for (let i = segments.length - 2; i >= 0; i--) {
     const next = `${segments[i]}/${acc}`;
-    // +1 for the leading ellipsis. Stop one segment short.
-    if (next.length + 1 > maxWidth) break;
+    // +2 for the leading `…/` prefix. Stop one segment short.
+    if (next.length + 2 > maxWidth) break;
     acc = next;
   }
+  if (acc === basename) return basename;
   return `…/${acc}`;
 };
 
@@ -157,7 +158,9 @@
  * same string. Display-time relativization happens in `displayPath`
  * downstream.
  */
-const dedupeByPath = (entries: readonly FileWriteEntry[]): DedupedFileWrite[] => {
+const dedupeByPath = (
+  entries: readonly FileWriteEntry[],
+): DedupedFileWrite[] => {
   const byPath = new Map<string, DedupedFileWrite>();
   for (const entry of entries) {
     const prev = byPath.get(entry.path);
@@ -290,8 +293,9 @@
 const KEYWORD_WIDTH = 7;
 /** Leading indent (' ' before icon) + status icon + space. */
 const ICON_WIDTH = 3;
-/** Single space gap between keyword cell and path cell. */
-const KEYWORD_PATH_GAP = 1;
+/** The keyword box width already includes a trailing space (width 7 for
+ *  a max-6-char label), so no extra gap column is needed. */
+const KEYWORD_PATH_GAP = 0;
 /** Separator between path and trailing detail (` · `). */
 const SEPARATOR = ' · ';
 /** Floor on usable columns when the terminal is unreasonably narrow. */

You can send follow-ups to the cloud agent here.

Comment thread src/ui/tui/components/FileWritesPanel.tsx
Comment thread src/ui/tui/components/FileWritesPanel.tsx
@kelsonpw
Copy link
Copy Markdown
Member Author

kelsonpw commented May 9, 2026

@cursor push 8fffdca

cursoragent and others added 2 commits May 9, 2026 00:34
…lculation

- Fix loop guard from +1 to +2 to account for the 2-char '…/' prefix,
  preventing returned strings from exceeding maxWidth by one.
- Change middle-truncation guard from 'basename.length + 1 >= maxWidth'
  to 'basename.length > maxWidth' so basenames that fit within budget
  are returned as-is instead of being unnecessarily mangled.
- Return basename directly when no parent segments fit, avoiding the
  '…/' prefix that would overflow.
- Set KEYWORD_PATH_GAP to 0 since the keyword box width (7) already
  includes the trailing space — the phantom gap made pathBudget 1 too
  small, which previously masked the truncatePathHead overflow.

Applied via @cursor push command
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Off-by-one in middle-truncation loses extension's last character
    • Changed maxWidth - 1 to maxWidth - 2 to correctly reserve space for both the leading and middle ellipsis characters, preventing the .slice(0, maxWidth) from clipping the extension's last character.

Create PR

Or push these changes by commenting:

@cursor push bd96d851d0
Preview (bd96d851d0)
diff --git a/src/ui/tui/components/FileWritesPanel.tsx b/src/ui/tui/components/FileWritesPanel.tsx
--- a/src/ui/tui/components/FileWritesPanel.tsx
+++ b/src/ui/tui/components/FileWritesPanel.tsx
@@ -101,7 +101,7 @@
   // extension visible is the priority.
   if (basename.length > maxWidth) {
     if (maxWidth <= 3) return '…';
-    const keep = maxWidth - 1; // reserve 1 col for the ellipsis
+    const keep = maxWidth - 2; // reserve 2 cols for the leading + middle ellipses
     const head = Math.ceil(keep / 2);
     const tail = keep - head;
     return `…${basename.slice(0, head)}…${basename.slice(-tail)}`.slice(

You can send follow-ups to the cloud agent here.

Comment thread src/ui/tui/components/FileWritesPanel.tsx
@kelsonpw
Copy link
Copy Markdown
Member Author

kelsonpw commented May 9, 2026

@cursor push bd96d85

Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Missing ellipsis prefix when only basename fits budget
    • Added a fallback check so that when no parent segment fits but the path had parent directories and …/${basename} fits within maxWidth, the ellipsis prefix is included to indicate truncation.

Create PR

Or push these changes by commenting:

@cursor push 33bc1a1ed9
Preview (33bc1a1ed9)
diff --git a/src/ui/tui/components/FileWritesPanel.tsx b/src/ui/tui/components/FileWritesPanel.tsx
--- a/src/ui/tui/components/FileWritesPanel.tsx
+++ b/src/ui/tui/components/FileWritesPanel.tsx
@@ -116,7 +116,11 @@
     if (next.length + 2 > maxWidth) break;
     acc = next;
   }
-  if (acc === basename) return basename;
+  if (acc === basename) {
+    if (segments.length > 1 && 2 + basename.length <= maxWidth)
+      return `…/${basename}`;
+    return basename;
+  }
   return `…/${acc}`;
 };

You can send follow-ups to the cloud agent here.

acc = next;
}
if (acc === basename) return basename;
return `…/${acc}`;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing ellipsis prefix when only basename fits budget

Low Severity

truncatePathHead returns bare basename without a …/ truncation indicator even when …/${basename} would fit within maxWidth. When acc === basename (no parent segment could be prepended via the loop), the function checks only whether a full parent segment fits — it never checks whether just the 2-char …/ prefix alone would fit alongside the basename. At realistic narrow widths (e.g., 38 cols with path "components/Button.tsx"), the user sees "Button.tsx" instead of "…/Button.tsx", losing the visual cue that parent directories were dropped.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 826c6bf. Configure here.

Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Width budget ignores parent paddingX, path over-truncates wrong end
    • Changed width={cols} to width={cols - 2} at the FileWritesPanel call site to account for the 2 columns consumed by the parent Box's paddingX={1}.

Create PR

Or push these changes by commenting:

@cursor push 978f666a58
Preview (978f666a58)
diff --git a/src/ui/tui/screens/RunScreen.tsx b/src/ui/tui/screens/RunScreen.tsx
--- a/src/ui/tui/screens/RunScreen.tsx
+++ b/src/ui/tui/screens/RunScreen.tsx
@@ -152,9 +152,7 @@
  *   (#1) already follows the same pattern; this extends it to the
  *   in-loop journey steps.
  */
-export function resolveRunScreenStatus(
-  store: WizardStore,
-): string | undefined {
+export function resolveRunScreenStatus(store: WizardStore): string | undefined {
   const activePostAgentStep = store.session.postAgentSteps.find(
     (s) => s.status === PostAgentStepStatus.InProgress,
   );
@@ -468,7 +466,7 @@
           entries={store.fileWrites}
           installDir={store.session.installDir}
           spinnerFrame={spinnerFrame}
-          width={cols}
+          width={cols - 2}
         />
 
         {/* Coaching: surfaces calmly after 90s of no task-count progress.
@@ -486,7 +484,7 @@
               {Icons.dash}
               {coachingTier >= 2
                 ? " This is unusually slow. Press ← / → to switch to the Logs tab and see what's stuck — or Ctrl+C to cancel."
-                : ' Still working — press ← / → to switch to the Logs tab and see what\'s happening, or Ctrl+C to cancel.'}
+                : " Still working — press ← / → to switch to the Logs tab and see what's happening, or Ctrl+C to cancel."}
             </Text>
           </Box>
         )}
@@ -532,9 +530,7 @@
   // `overflow="hidden"` handles wide terminals, but a JS cap is the
   // belt-and-braces guard against unbounded strings.
   const rawLastStatus = resolveRunScreenStatus(store);
-  const lastStatus = rawLastStatus
-    ? truncateStatus(rawLastStatus)
-    : undefined;
+  const lastStatus = rawLastStatus ? truncateStatus(rawLastStatus) : undefined;
 
   const hasEvents = store.eventPlan.length > 0;

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit ef087b4. Configure here.

Comment thread src/ui/tui/screens/RunScreen.tsx Outdated
@kelsonpw
Copy link
Copy Markdown
Member Author

kelsonpw commented May 9, 2026

@cursor push 978f666

@kelsonpw kelsonpw merged commit 3baf9d2 into main May 9, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants