fix(coding-agent): make footer truncation width-aware

Use visibleWidth/truncateToWidth for footer path and stats truncation so wide Unicode text cannot overflow terminal width.

Add regression tests for wide session/model/provider names and document the fix in changelog.

closes #1833
This commit is contained in:
Mario Zechner 2026-03-05 20:44:16 +01:00
parent dabcda0db3
commit 3de8c48692
3 changed files with 123 additions and 23 deletions

View file

@ -5,6 +5,7 @@
### Fixed
- Fixed editor/footer visibility drift during terminal resize by forcing full redraws when terminal width or height changes ([#1844](https://github.com/badlogic/pi-mono/pull/1844) by [@ghoulr](https://github.com/ghoulr)).
- Fixed footer width truncation for wide Unicode text (session name, model, provider) to prevent TUI crashes from rendered lines exceeding terminal width ([#1833](https://github.com/badlogic/pi-mono/issues/1833)).
## [0.56.1] - 2026-03-05

View file

@ -104,18 +104,6 @@ export class FooterComponent implements Component {
pwd = `${pwd}${sessionName}`;
}
// Truncate path if too long to fit width
if (pwd.length > width) {
const half = Math.floor(width / 2) - 2;
if (half > 1) {
const start = pwd.slice(0, half);
const end = pwd.slice(-(half - 1));
pwd = `${start}...${end}`;
} else {
pwd = pwd.slice(0, Math.max(1, width));
}
}
// Build stats line
const statsParts = [];
if (totalInput) statsParts.push(`${formatTokens(totalInput)}`);
@ -155,9 +143,7 @@ export class FooterComponent implements Component {
// If statsLeft is too wide, truncate it
if (statsLeftWidth > width) {
// Truncate statsLeft to fit width (no room for right side)
const plainStatsLeft = statsLeft.replace(/\x1b\[[0-9;]*m/g, "");
statsLeft = `${plainStatsLeft.substring(0, width - 3)}...`;
statsLeft = truncateToWidth(statsLeft, width, "...");
statsLeftWidth = visibleWidth(statsLeft);
}
@ -193,13 +179,11 @@ export class FooterComponent implements Component {
} else {
// Need to truncate right side
const availableForRight = width - statsLeftWidth - minPadding;
if (availableForRight > 3) {
// Truncate to fit (strip ANSI codes for length calculation, then truncate raw string)
const plainRightSide = rightSide.replace(/\x1b\[[0-9;]*m/g, "");
const truncatedPlain = plainRightSide.substring(0, availableForRight);
// For simplicity, just use plain truncated version (loses color, but fits)
const padding = " ".repeat(width - statsLeftWidth - truncatedPlain.length);
statsLine = statsLeft + padding + truncatedPlain;
if (availableForRight > 0) {
const truncatedRight = truncateToWidth(rightSide, availableForRight, "");
const truncatedRightWidth = visibleWidth(truncatedRight);
const padding = " ".repeat(Math.max(0, width - statsLeftWidth - truncatedRightWidth));
statsLine = statsLeft + padding + truncatedRight;
} else {
// Not enough space for right side at all
statsLine = statsLeft;
@ -213,7 +197,8 @@ export class FooterComponent implements Component {
const remainder = statsLine.slice(statsLeft.length); // padding + rightSide
const dimRemainder = theme.fg("dim", remainder);
const lines = [theme.fg("dim", pwd), dimStatsLeft + dimRemainder];
const pwdLine = truncateToWidth(theme.fg("dim", pwd), width, theme.fg("dim", "..."));
const lines = [pwdLine, dimStatsLeft + dimRemainder];
// Add extension statuses on a single line, sorted by key alphabetically
const extensionStatuses = this.footerData.getExtensionStatuses();

View file

@ -0,0 +1,114 @@
import { visibleWidth } from "@mariozechner/pi-tui";
import { beforeAll, describe, expect, it } from "vitest";
import type { AgentSession } from "../src/core/agent-session.js";
import type { ReadonlyFooterDataProvider } from "../src/core/footer-data-provider.js";
import { FooterComponent } from "../src/modes/interactive/components/footer.js";
import { initTheme } from "../src/modes/interactive/theme/theme.js";
type AssistantUsage = {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
cost: { total: number };
};
function createSession(options: {
sessionName: string;
modelId?: string;
provider?: string;
reasoning?: boolean;
thinkingLevel?: string;
usage?: AssistantUsage;
}): AgentSession {
const usage = options.usage;
const entries =
usage === undefined
? []
: [
{
type: "message",
message: {
role: "assistant",
usage,
},
},
];
const session = {
state: {
model: {
id: options.modelId ?? "test-model",
provider: options.provider ?? "test",
contextWindow: 200_000,
reasoning: options.reasoning ?? false,
},
thinkingLevel: options.thinkingLevel ?? "off",
},
sessionManager: {
getEntries: () => entries,
getSessionName: () => options.sessionName,
},
getContextUsage: () => ({ contextWindow: 200_000, percent: 12.3 }),
modelRegistry: {
isUsingOAuth: () => false,
},
};
return session as unknown as AgentSession;
}
function createFooterData(providerCount: number): ReadonlyFooterDataProvider {
const provider = {
getGitBranch: () => "main",
getExtensionStatuses: () => new Map<string, string>(),
getAvailableProviderCount: () => providerCount,
onBranchChange: (callback: () => void) => {
void callback;
return () => {};
},
};
return provider;
}
describe("FooterComponent width handling", () => {
beforeAll(() => {
initTheme(undefined, false);
});
it("keeps all lines within width for wide session names", () => {
const width = 93;
const session = createSession({ sessionName: "한글".repeat(30) });
const footer = new FooterComponent(session, createFooterData(1));
const lines = footer.render(width);
for (const line of lines) {
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
}
});
it("keeps stats line within width for wide model and provider names", () => {
const width = 60;
const session = createSession({
sessionName: "",
modelId: "模".repeat(30),
provider: "공급자",
reasoning: true,
thinkingLevel: "high",
usage: {
input: 12_345,
output: 6_789,
cacheRead: 0,
cacheWrite: 0,
cost: { total: 1.234 },
},
});
const footer = new FooterComponent(session, createFooterData(2));
const lines = footer.render(width);
for (const line of lines) {
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
}
});
});