From 0fe7872aa0278538909662ea975d837c0ef0ffe0 Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Thu, 25 Dec 2025 12:21:46 -0800 Subject: [PATCH] fix: ensure tool return text wrapping respects left column padding (#391) Co-authored-by: Letta --- src/cli/components/DiffRenderer.tsx | 135 +++++++---- src/cli/components/MemoryDiffRenderer.tsx | 263 +++++++++++++++------- src/cli/components/PlanRenderer.tsx | 35 ++- src/cli/components/TodoRenderer.tsx | 21 +- 4 files changed, 301 insertions(+), 153 deletions(-) diff --git a/src/cli/components/DiffRenderer.tsx b/src/cli/components/DiffRenderer.tsx index 3cf7b12..a56b17c 100644 --- a/src/cli/components/DiffRenderer.tsx +++ b/src/cli/components/DiffRenderer.tsx @@ -1,6 +1,7 @@ import { relative } from "node:path"; import * as Diff from "diff"; import { Box, Text } from "ink"; +import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { colors } from "./colors"; // Helper to format path as relative with ../ @@ -28,6 +29,7 @@ interface DiffLineProps { type: "add" | "remove"; content: string; compareContent?: string; // The other version to compare against for word diff + columns: number; } function DiffLine({ @@ -35,6 +37,7 @@ function DiffLine({ type, content, compareContent, + columns, }: DiffLineProps) { const prefix = type === "add" ? "+" : "-"; const lineBg = @@ -42,6 +45,9 @@ function DiffLine({ const wordBg = type === "add" ? colors.diff.addedWordBg : colors.diff.removedWordBg; + const prefixWidth = 1; // Single space prefix + const contentWidth = Math.max(0, columns - prefixWidth); + // If we have something to compare against, do word-level diff if (compareContent !== undefined && content.trim() && compareContent.trim()) { const wordDiffs = @@ -50,60 +56,74 @@ function DiffLine({ : Diff.diffWords(content, compareContent); return ( - - - - {`${lineNumber} ${prefix} `} - - {wordDiffs.map((part, i) => { - if (part.added && type === "add") { - // This part was added (show with brighter background, black text) - return ( - - {part.value} - - ); - } else if (part.removed && type === "remove") { - // This part was removed (show with brighter background, black text) - return ( - - {part.value} - - ); - } else if (!part.added && !part.removed) { - // Unchanged part (show with line background, white text) - return ( - - {part.value} - - ); - } - // Skip parts that don't belong in this line - return null; - })} + + + + + + + + {`${lineNumber} ${prefix} `} + + {wordDiffs.map((part, i) => { + if (part.added && type === "add") { + // This part was added (show with brighter background, black text) + return ( + + {part.value} + + ); + } else if (part.removed && type === "remove") { + // This part was removed (show with brighter background, black text) + return ( + + {part.value} + + ); + } else if (!part.added && !part.removed) { + // Unchanged part (show with line background, white text) + return ( + + {part.value} + + ); + } + // Skip parts that don't belong in this line + return null; + })} + + ); } // No comparison, just show the whole line with one background return ( - - - - {`${lineNumber} ${prefix} ${content}`} - + + + + + + + {`${lineNumber} ${prefix} ${content}`} + + ); } @@ -114,10 +134,14 @@ interface WriteRendererProps { } export function WriteRenderer({ filePath, content }: WriteRendererProps) { + const columns = useTerminalWidth(); const relativePath = formatRelativePath(filePath); const lines = content.split("\n"); const lineCount = lines.length; + const prefixWidth = 1; // Single space prefix + const contentWidth = Math.max(0, columns - prefixWidth); + return ( @@ -125,7 +149,14 @@ export function WriteRenderer({ filePath, content }: WriteRendererProps) { ⎿ Wrote {lineCount} line{lineCount !== 1 ? "s" : ""} to {relativePath} {lines.map((line, i) => ( - {line} + + + + + + {line} + + ))} ); @@ -142,6 +173,7 @@ export function EditRenderer({ oldString, newString, }: EditRendererProps) { + const columns = useTerminalWidth(); const relativePath = formatRelativePath(filePath); const oldLines = oldString.split("\n"); const newLines = newString.split("\n"); @@ -172,6 +204,7 @@ export function EditRenderer({ type="remove" content={line} compareContent={singleLineEdit ? newLines[0] : undefined} + columns={columns} /> ))} @@ -183,6 +216,7 @@ export function EditRenderer({ type="add" content={line} compareContent={singleLineEdit ? oldLines[0] : undefined} + columns={columns} /> ))} @@ -198,6 +232,7 @@ interface MultiEditRendererProps { } export function MultiEditRenderer({ filePath, edits }: MultiEditRendererProps) { + const columns = useTerminalWidth(); const relativePath = formatRelativePath(filePath); // Count total additions and removals @@ -238,6 +273,7 @@ export function MultiEditRenderer({ filePath, edits }: MultiEditRendererProps) { compareContent={ singleLineEdit && i === 0 ? newLines[0] : undefined } + columns={columns} /> ))} {newLines.map((line, i) => ( @@ -249,6 +285,7 @@ export function MultiEditRenderer({ filePath, edits }: MultiEditRendererProps) { compareContent={ singleLineEdit && i === 0 ? oldLines[0] : undefined } + columns={columns} /> ))} diff --git a/src/cli/components/MemoryDiffRenderer.tsx b/src/cli/components/MemoryDiffRenderer.tsx index 8d7443f..125c77d 100644 --- a/src/cli/components/MemoryDiffRenderer.tsx +++ b/src/cli/components/MemoryDiffRenderer.tsx @@ -1,5 +1,6 @@ import * as Diff from "diff"; import { Box, Text } from "ink"; +import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { colors } from "./colors"; interface MemoryDiffRendererProps { @@ -15,6 +16,8 @@ export function MemoryDiffRenderer({ argsText, toolName, }: MemoryDiffRendererProps) { + const columns = useTerminalWidth(); + try { const args = JSON.parse(argsText); @@ -22,7 +25,9 @@ export function MemoryDiffRenderer({ if (toolName === "memory_apply_patch") { const label = args.label || "unknown"; const patch = args.patch || ""; - return ; + return ( + + ); } // Handle memory tool (command-based) @@ -41,6 +46,7 @@ export function MemoryDiffRenderer({ blockName={blockName} oldStr={oldStr} newStr={newStr} + columns={columns} /> ); } @@ -48,6 +54,8 @@ export function MemoryDiffRenderer({ case "insert": { const insertText = args.insert_text || ""; const insertLine = args.insert_line; + const prefixWidth = 4; // " " indent + const contentWidth = Math.max(0, columns - prefixWidth); return ( @@ -57,14 +65,22 @@ export function MemoryDiffRenderer({ {insertLine !== undefined && ` at line ${insertLine}`} {insertText.split("\n").map((line: string, i: number) => ( - - - - {`+ ${line}`} - + + + {" "} + + + + {`+ ${line}`} + + ))} @@ -74,6 +90,8 @@ export function MemoryDiffRenderer({ case "create": { const description = args.description || ""; const fileText = args.file_text || ""; + const prefixWidth = 4; // " " indent + const contentWidth = Math.max(0, columns - prefixWidth); return ( @@ -88,14 +106,22 @@ export function MemoryDiffRenderer({ ?.split("\n") .slice(0, 3) .map((line: string, i: number) => ( - - - - {`+ ${truncate(line, 60)}`} - + + + {" "} + + + + {`+ ${truncate(line, 60)}`} + + ))} {fileText && fileText.split("\n").length > 3 && ( @@ -162,10 +188,12 @@ function MemoryStrReplaceDiff({ blockName, oldStr, newStr, + columns, }: { blockName: string; oldStr: string; newStr: string; + columns: number; }) { const oldLines = oldStr.split("\n"); const newLines = newStr.split("\n"); @@ -192,6 +220,7 @@ function MemoryStrReplaceDiff({ type="remove" content={line} compareContent={singleLine ? newLines[0] : undefined} + columns={columns} /> ))} @@ -202,6 +231,7 @@ function MemoryStrReplaceDiff({ type="add" content={line} compareContent={singleLine ? oldLines[0] : undefined} + columns={columns} /> ))} @@ -217,10 +247,12 @@ function DiffLine({ type, content, compareContent, + columns, }: { type: "add" | "remove"; content: string; compareContent?: string; + columns: number; }) { const prefix = type === "add" ? "+" : "-"; const lineBg = @@ -228,6 +260,9 @@ function DiffLine({ const wordBg = type === "add" ? colors.diff.addedWordBg : colors.diff.removedWordBg; + const prefixWidth = 4; // " " indent + const contentWidth = Math.max(0, columns - prefixWidth); + // Word-level diff if we have something to compare if (compareContent !== undefined && content.trim() && compareContent.trim()) { const wordDiffs = @@ -236,56 +271,70 @@ function DiffLine({ : Diff.diffWords(content, compareContent); return ( - - {" "} - - {`${prefix} `} - - {wordDiffs.map((part, i) => { - if (part.added && type === "add") { - return ( - - {part.value} - - ); - } else if (part.removed && type === "remove") { - return ( - - {part.value} - - ); - } else if (!part.added && !part.removed) { - return ( - - {part.value} - - ); - } - return null; - })} + + + {" "} + + + + + {`${prefix} `} + + {wordDiffs.map((part, i) => { + if (part.added && type === "add") { + return ( + + {part.value} + + ); + } else if (part.removed && type === "remove") { + return ( + + {part.value} + + ); + } else if (!part.added && !part.removed) { + return ( + + {part.value} + + ); + } + return null; + })} + + ); } // Simple line without word diff return ( - - {" "} - - {`${prefix} ${content}`} - + + + {" "} + + + + {`${prefix} ${content}`} + + ); } @@ -298,12 +347,23 @@ function truncate(str: string, maxLen: number): string { /** * Renders a unified-diff patch from memory_apply_patch tool */ -function PatchDiffRenderer({ label, patch }: { label: string; patch: string }) { +function PatchDiffRenderer({ + label, + patch, + columns, +}: { + label: string; + patch: string; + columns: number; +}) { const lines = patch.split("\n"); const maxLines = 8; const displayLines = lines.slice(0, maxLines); const hasMore = lines.length > maxLines; + const prefixWidth = 4; // " " indent + const contentWidth = Math.max(0, columns - prefixWidth); + return ( @@ -322,45 +382,74 @@ function PatchDiffRenderer({ label, patch }: { label: string; patch: string }) { if (firstChar === "+") { return ( - - {" "} - - {`+ ${content}`} - + + + {" "} + + + + {`+ ${content}`} + + ); } else if (firstChar === "-") { return ( - - {" "} - - {`- ${content}`} - + + + {" "} + + + + {`- ${content}`} + + ); } else if (firstChar === " ") { // Context line - show dimmed return ( - - - {" "} - {content} - + + + {" "} + + + + {content} + + ); } // Unknown format, show as-is return ( - - {" "} - {line} - + + + {" "} + + + + {line} + + + ); })} {hasMore && ( diff --git a/src/cli/components/PlanRenderer.tsx b/src/cli/components/PlanRenderer.tsx index 5d0d0fe..17e5201 100644 --- a/src/cli/components/PlanRenderer.tsx +++ b/src/cli/components/PlanRenderer.tsx @@ -1,5 +1,6 @@ import { Box, Text } from "ink"; import type React from "react"; +import { useTerminalWidth } from "../hooks/useTerminalWidth.js"; import { colors } from "./colors.js"; interface PlanItem { @@ -16,14 +17,22 @@ export const PlanRenderer: React.FC = ({ plan, explanation, }) => { + const columns = useTerminalWidth(); + const prefixWidth = 5; // " ⎿ " or " " + const contentWidth = Math.max(0, columns - prefixWidth); + return ( {explanation && ( - - {" ⎿ "} - - {explanation} - + + + {" ⎿ "} + + + + {explanation} + + )} {plan.map((item, index) => { @@ -34,21 +43,21 @@ export const PlanRenderer: React.FC = ({ if (item.status === "completed") { // Green with strikethrough textElement = ( - + {checkbox} {item.step} ); } else if (item.status === "in_progress") { // Blue bold textElement = ( - + {checkbox} {item.step} ); } else { // Plain text for pending textElement = ( - + {checkbox} {item.step} ); @@ -58,9 +67,13 @@ export const PlanRenderer: React.FC = ({ const prefix = index === 0 && !explanation ? " ⎿ " : " "; return ( - - {prefix} - {textElement} + + + {prefix} + + + {textElement} + ); })} diff --git a/src/cli/components/TodoRenderer.tsx b/src/cli/components/TodoRenderer.tsx index b3a7fcf..a1fd0e0 100644 --- a/src/cli/components/TodoRenderer.tsx +++ b/src/cli/components/TodoRenderer.tsx @@ -1,5 +1,6 @@ import { Box, Text } from "ink"; import type React from "react"; +import { useTerminalWidth } from "../hooks/useTerminalWidth.js"; import { colors } from "./colors.js"; interface TodoItem { @@ -14,6 +15,10 @@ interface TodoRendererProps { } export const TodoRenderer: React.FC = ({ todos }) => { + const columns = useTerminalWidth(); + const prefixWidth = 5; // " ⎿ " or " " + const contentWidth = Math.max(0, columns - prefixWidth); + return ( {todos.map((todo, index) => { @@ -24,21 +29,21 @@ export const TodoRenderer: React.FC = ({ todos }) => { if (todo.status === "completed") { // Green with strikethrough textElement = ( - + {checkbox} {todo.content} ); } else if (todo.status === "in_progress") { // Blue bold (like code formatting) textElement = ( - + {checkbox} {todo.content} ); } else { // Plain text for pending textElement = ( - + {checkbox} {todo.content} ); @@ -48,9 +53,13 @@ export const TodoRenderer: React.FC = ({ todos }) => { const prefix = index === 0 ? " ⎿ " : " "; return ( - - {prefix} - {textElement} + + + {prefix} + + + {textElement} + ); })}