fix(tui): file-writes rows pin keyword + metadata, head-truncate path — no more 2-line wraps#684
Conversation
… — 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>
There was a problem hiding this comment.
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
+1to+2to correctly account for the 2-char…/prefix, setKEYWORD_PATH_GAPto 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 the loop guard from
- ✅ Fixed: Middle-truncation garbles basenames that fit within budget
- Changed the middle-truncation guard from
basename.length + 1 >= maxWidthtobasename.length > maxWidthso basenames that fit within the budget are returned as-is instead of being unnecessarily mangled.
- Changed the middle-truncation guard from
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.
…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
There was a problem hiding this comment.
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 - 1tomaxWidth - 2to correctly reserve space for both the leading and middle ellipsis characters, preventing the.slice(0, maxWidth)from clipping the extension's last character.
- Changed
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.
… ellipsis characters Applied via @cursor push command
There was a problem hiding this comment.
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.
- Added a fallback check so that when no parent segment fits but the path had parent directories and
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}`; |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit 826c6bf. Configure here.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
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}towidth={cols - 2}at the FileWritesPanel call site to account for the 2 columns consumed by the parent Box'spaddingX={1}.
- Changed
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.



User report
Three coupled rendering bugs visible in the FileWritesPanel during a recent run on a Next.js codebase:
Bug A — long paths wrap to 2 rows
Long Next.js segment paths reflowed the row onto a second visual line; the trailing
· edited Xmsjumped to a new line, misaligned far to the right.Bug B —
CREATEtruncated toCREATOn 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:CREATE/MODIFY/DELETE)· edited Xms)The path cell is the only flexible column. It absorbs overflow via head-truncation (
…/[product]/page.tsx) using a budget computed fromuseStdoutDimensions().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):
After (same path, same width):
Coordination
× N): merged intomainwhile this PR was in flight; the rebase auto-resolved cleanly. The detail column now reads${sizeHint} ${editCount}× ${dur}when the file was edited more than once. Both behaviors are exercised in the merged 21-test suite.truncatePathHeadhelper inline so fix(tui): responsive layout — consistent file list + width-aware KeyHintBar + tab bar #667 can land independently. If fix(tui): responsive layout — consistent file list + width-aware KeyHintBar + tab bar #667 merges first the duplicate helper is a trivial rebase.Test plan
node_modules/.bin/tsc -p tsconfig.json— cleannode_modules/.bin/vitest run --pool=forks --maxWorkers=1 src/ui/tui/components/__tests__/FileWritesPanel.test.tsx— 21/21 passnode_modules/.bin/vitest run --pool=forks --maxWorkers=1— 3637/3637 pass (245 files)pnpm lint— clean (only the pre-existing unrelated warning inEventPlanFullScreen.test.tsx)CREATE\s) and bug C (noMODIFY[A-Za-z…/])🤖 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
FileWritesPanelrendering 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 newtruncatePathHead()to guarantee single-line rows.RunScreennow passes measured terminal width intoFileWritesPanel, 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.