Skip to content

Commit 8572393

Browse files
egavrindevagent
andcommitted
fix(cli): keep framed TUI wraps in gutter
- Wrap framed card body text before Ink can soft-wrap it - Cover error, info, final output, and zero-column PTY cases Co-Authored-By: devagent <devagent@egavrin>
1 parent f553c29 commit 8572393

7 files changed

Lines changed: 192 additions & 19 deletions

File tree

packages/cli/src/tui/App.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { StatusBar } from "./StatusBar.js";
1616
import { useAgentLog } from "./useAgentLog.js";
1717
import type { PresentedTurnStatus } from "../transcript-composer.js";
1818
import {
19+
makeErrorPart,
1920
makeFinalOutputPart,
2021
makeInfoPart,
2122
makeTurnSummaryPart,
@@ -81,6 +82,21 @@ function countPromptPlaceholders(text: string): number {
8182
return (stripAnsi(text).match(/Ask anything/g) ?? []).length;
8283
}
8384

85+
function outputLines(text: string): string[] {
86+
return stripAnsi(text).replace(/\r/g, "\n").split("\n");
87+
}
88+
89+
function expectWrappedCardFragmentsInsideGutter(text: string, fragments: ReadonlyArray<string>): void {
90+
const lines = outputLines(text);
91+
for (const fragment of fragments) {
92+
const matchingLines = lines.filter((line) => line.includes(fragment));
93+
expect(matchingLines, `expected output to include fragment ${fragment}`).not.toEqual([]);
94+
for (const line of matchingLines) {
95+
expect(line, `fragment ${fragment} should stay inside the card gutter`).toMatch(/^ /);
96+
}
97+
}
98+
}
99+
84100
async function typeAndSubmit(stdin: TestInput, text: string): Promise<void> {
85101
stdin.write(text);
86102
await settle();
@@ -738,6 +754,72 @@ describe("interactive completion notices", () => {
738754
});
739755
});
740756

757+
describe("framed TUI wrapping", () => {
758+
const cases: Array<{
759+
readonly name: string;
760+
readonly columns: number;
761+
readonly node: TranscriptNode;
762+
readonly fragments: ReadonlyArray<string>;
763+
}> = [
764+
{
765+
name: "error card",
766+
columns: 72,
767+
node: makeTurnNode(
768+
"Verify image",
769+
[{
770+
id: "provider-error-1",
771+
part: makeErrorPart({
772+
message: "Provider error (attempt 1, ProviderError): OpenAI API error: terminated. Retrying in 611ms...",
773+
code: "PROVIDER_RETRY",
774+
}),
775+
}],
776+
{ status: "error", elapsedMs: 300 },
777+
),
778+
fragments: ["Provider error", "Retrying in", "611ms"],
779+
},
780+
{
781+
name: "info card",
782+
columns: 66,
783+
node: {
784+
id: "info-1",
785+
kind: "part",
786+
part: makeInfoPart("status", [
787+
"A very long status update is rendered as a transcript card and should wrap through the shared framed-line gutter instead of escaping to column zero.",
788+
]),
789+
},
790+
fragments: ["A very long", "shared framed-line", "column zero"],
791+
},
792+
{
793+
name: "final output card",
794+
columns: 68,
795+
node: makeTurnNode(
796+
"Explain retry",
797+
[{
798+
id: "final-wrap-1",
799+
part: makeFinalOutputPart("**Provider retry** details stay visible while markdown formatted assistant output wraps inside the framed response gutter."),
800+
}],
801+
{ status: "completed", elapsedMs: 300 },
802+
),
803+
fragments: ["Provider retry", "markdown formatted", "response gutter"],
804+
},
805+
];
806+
807+
it.each(cases)("keeps wrapped $name text inside the left gutter", async ({ columns, fragments, node }) => {
808+
const view = renderForTest(
809+
React.createElement(TranscriptView, {
810+
showWelcome: false,
811+
model: "test-model",
812+
transcriptNodes: [node],
813+
}),
814+
{ columns },
815+
);
816+
817+
await settle();
818+
819+
expectWrappedCardFragmentsInsideGutter(view.stdout.readAll(), fragments);
820+
});
821+
});
822+
741823
describe("interactive transcript typed rows", () => {
742824
it("renders typed progress and status transcript rows for compaction and approvals", async () => {
743825
const view = renderForTest(React.createElement(StatusHarness));

packages/cli/src/tui/FinalOutput.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,21 @@
22
* FinalOutput — renders the agent's final response inside the transcript card system.
33
*/
44

5-
import { Box, Text } from "ink";
5+
import { Box, Text, useStdout } from "ink";
66
import React from "react";
77

8+
import { framedBodyWidth, wrapAnsiTextByWidth } from "./shared.js";
89
import { renderMarkdown } from "../markdown-render.js";
910

1011
interface FinalOutputProps {
1112
readonly text: string;
1213
}
1314

1415
export function FinalOutput({ text }: FinalOutputProps): React.ReactElement {
16+
const { stdout } = useStdout();
1517
const rendered = renderMarkdown(text);
16-
const lines = rendered.split("\n");
18+
const bodyWidth = framedBodyWidth(stdout.columns);
19+
const lines = rendered.split("\n").flatMap((line) => wrapAnsiTextByWidth(line, bodyWidth));
1720

1821
return (
1922
<Box flexDirection="column" marginTop={1}>

packages/cli/src/tui/LogEntryView.tsx

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* Used by both App.tsx and SingleShotApp.tsx.
55
*/
66

7-
import { Box, Text } from "ink";
7+
import { Box, Text, useStdout } from "ink";
88
import React from "react";
99

1010
import { FinalOutput } from "./FinalOutput.js";
@@ -20,7 +20,7 @@ import {
2020
ValidationResultDisplay,
2121
} from "./ToolDisplay.js";
2222
import type { PresentedStatus } from "../transcript-presenter.js";
23-
import { cleanTime } from "./shared.js";
23+
import { cleanTime, framedBodyWidth, wrapAnsiTextByWidth } from "./shared.js";
2424
export const LogEntryView = React.memo(function LogEntryView({ entry }: { entry: LogEntry }): React.ReactElement | null {
2525
const direct = renderDirectEntry(entry);
2626
if (direct !== undefined) return direct;
@@ -146,18 +146,23 @@ function TranscriptCard(
146146
readonly marginTop?: number;
147147
},
148148
): React.ReactElement {
149+
const { stdout } = useStdout();
150+
const bodyWidth = framedBodyWidth(stdout.columns);
151+
149152
return (
150153
<Box flexDirection="column" marginTop={marginTop}>
151154
<Text>
152155
<Text dimColor> ╭─ </Text>
153156
<Text color={color}>{title}</Text>
154157
</Text>
155-
{lines.map((line, index) => (
156-
<Text key={`${title}-${index}`}>
157-
<Text dimColor></Text>
158-
<Text bold={boldBody}>{line}</Text>
159-
</Text>
160-
))}
158+
{lines
159+
.flatMap((line) => wrapAnsiTextByWidth(line, bodyWidth))
160+
.map((line, index) => (
161+
<Text key={`${title}-${index}`}>
162+
<Text dimColor></Text>
163+
<Text bold={boldBody}>{line}</Text>
164+
</Text>
165+
))}
161166
<Text dimColor> ╰─</Text>
162167
</Box>
163168
);

packages/cli/src/tui/MessageView.tsx

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,25 @@
1-
import { Box, Text } from "ink";
1+
import { Box, Text, useStdout } from "ink";
22
import React from "react";
33

4+
import { framedBodyWidth, wrapAnsiTextByWidth } from "./shared.js";
5+
46
export function ErrorView({ message, code }: { message: string; code: string }): React.ReactElement {
7+
const { stdout } = useStdout();
8+
const body = code && code !== "LSP_INFO" ? `${message} (${code})` : message;
9+
const rows = wrapAnsiTextByWidth(body, framedBodyWidth(stdout.columns));
10+
511
return (
612
<Box flexDirection="column">
713
<Text>
814
<Text dimColor> ╭─ </Text>
915
<Text color="red">error</Text>
1016
</Text>
11-
<Text>
12-
<Text dimColor></Text>
13-
<Text color="red">{message}</Text>
14-
{code && code !== "LSP_INFO" && <Text dimColor> ({code})</Text>}
15-
</Text>
17+
{rows.map((row, index) => (
18+
<Text key={`error-line-${index}`}>
19+
<Text dimColor></Text>
20+
<Text color="red">{row}</Text>
21+
</Text>
22+
))}
1623
<Text dimColor> ╰─</Text>
1724
</Box>
1825
);

packages/cli/src/tui/PromptInput.test.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
shouldSubmitPromptInput,
1414
SLASH_COMMANDS,
1515
} from "./PromptInput.js";
16-
import { cycleApprovalMode, resolvePromptTabAction } from "./shared.js";
16+
import { cycleApprovalMode, framedBodyWidth, resolvePromptTabAction, resolveTerminalColumns } from "./shared.js";
1717

1818
describe("PromptInput slash completions", () => {
1919
it("includes review and simplify prompt shortcuts", () => {
@@ -114,6 +114,21 @@ describe("PromptInput layout helpers", () => {
114114
]);
115115
expect(rows.map((row) => stringWidth(row.text))).toEqual([4, 1]);
116116
});
117+
118+
it("falls back when a PTY reports zero terminal columns", () => {
119+
const previousColumns = process.env["COLUMNS"];
120+
process.env["COLUMNS"] = "120";
121+
try {
122+
expect(resolveTerminalColumns(0)).toBe(120);
123+
expect(framedBodyWidth(0)).toBe(116);
124+
} finally {
125+
if (previousColumns === undefined) {
126+
delete process.env["COLUMNS"];
127+
} else {
128+
process.env["COLUMNS"] = previousColumns;
129+
}
130+
}
131+
});
117132
});
118133

119134
describe("safety mode helpers", () => {

packages/cli/src/tui/PromptInput.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { join, dirname, basename } from "node:path";
2121
import React, { useRef, useState } from "react";
2222
import stringWidth from "string-width";
2323

24-
import { getApprovalModeColor, resolvePromptTabAction } from "./shared.js";
24+
import { getApprovalModeColor, resolvePromptTabAction, resolveTerminalColumns } from "./shared.js";
2525

2626
export const SLASH_COMMANDS = [
2727
"/help",
@@ -395,7 +395,7 @@ export function PromptInput({
395395
const savedInputRef = useRef("");
396396
const { stdout } = useStdout();
397397
const accentColor = getApprovalModeColor(approvalMode);
398-
const contentWidth = Math.max(1, (stdout.columns ?? 80) - INPUT_FRAME_WIDTH);
398+
const contentWidth = Math.max(1, resolveTerminalColumns(stdout.columns) - INPUT_FRAME_WIDTH);
399399
const promptRows = buildPromptRows(value, cursorPos, placeholder, contentWidth);
400400
useInput((input, key) => {
401401
handlePromptInput(input, key, {

packages/cli/src/tui/shared.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44

55
import { SafetyMode } from "@devagent/runtime";
6+
import stringWidth from "string-width";
67

78
import type { TranscriptNode } from "../transcript-composer.js";
89
import type { TranscriptPart } from "../transcript-presenter.js";
@@ -38,6 +39,11 @@ const APPROVAL_MODE_ORDER = [
3839
] as const;
3940

4041
type PromptTabAction = "cycle-mode" | "complete" | "none";
42+
const FRAMED_BODY_PREFIX = " │ ";
43+
const DEFAULT_TERMINAL_COLUMNS = 80;
44+
const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
45+
const ANSI_CSI_PATTERN = /^\x1B\[[0-?]*[ -/]*[@-~]/;
46+
const ANSI_OSC_PATTERN = /^\x1B\][\s\S]*?(?:\x07|\x1B\\)/;
4147

4248
export function cycleApprovalMode(mode: string): SafetyMode {
4349
const currentIndex = APPROVAL_MODE_ORDER.indexOf(mode as SafetyMode);
@@ -67,6 +73,61 @@ export function cleanTime(text: string): string {
6773
return text.replace(/(\d+)\.\d+s/g, "$1s");
6874
}
6975

76+
export function resolveTerminalColumns(columns: number | undefined): number {
77+
if (columns !== undefined && columns > 0) {
78+
return columns;
79+
}
80+
const envColumns = Number.parseInt(process.env["COLUMNS"] ?? "", 10);
81+
return Number.isFinite(envColumns) && envColumns > 0 ? envColumns : DEFAULT_TERMINAL_COLUMNS;
82+
}
83+
84+
export function framedBodyWidth(columns: number | undefined): number {
85+
return Math.max(1, resolveTerminalColumns(columns) - FRAMED_BODY_PREFIX.length);
86+
}
87+
88+
export function wrapAnsiTextByWidth(text: string, width: number): string[] {
89+
const maxWidth = Math.max(1, width);
90+
if (text.length === 0) return [""];
91+
92+
const rows: string[] = [];
93+
let current = "";
94+
let currentWidth = 0;
95+
let index = 0;
96+
97+
while (index < text.length) {
98+
const remaining = text.slice(index);
99+
const controlSequence = readAnsiSequence(remaining);
100+
if (controlSequence) {
101+
current += controlSequence;
102+
index += controlSequence.length;
103+
continue;
104+
}
105+
106+
const grapheme = nextGrapheme(remaining);
107+
const graphemeWidth = stringWidth(grapheme);
108+
if (currentWidth > 0 && currentWidth + graphemeWidth > maxWidth) {
109+
rows.push(current);
110+
current = "";
111+
currentWidth = 0;
112+
}
113+
114+
current += grapheme;
115+
currentWidth += graphemeWidth;
116+
index += grapheme.length;
117+
}
118+
119+
rows.push(current);
120+
return rows;
121+
}
122+
123+
function readAnsiSequence(text: string): string | null {
124+
return text.match(ANSI_CSI_PATTERN)?.[0] ?? text.match(ANSI_OSC_PATTERN)?.[0] ?? null;
125+
}
126+
127+
function nextGrapheme(text: string): string {
128+
return GRAPHEME_SEGMENTER.segment(text)[Symbol.iterator]().next().value?.segment ?? text[0] ?? "";
129+
}
130+
70131
export function tokenProgressBar(used: number, max: number): string {
71132
const pct = Math.round((used / max) * 100);
72133
const width = 20;

0 commit comments

Comments
 (0)