diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 058f8c66..58df46e6 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/modes/interactive/components/footer.ts b/packages/coding-agent/src/modes/interactive/components/footer.ts index e409a86e..d16ed12c 100644 --- a/packages/coding-agent/src/modes/interactive/components/footer.ts +++ b/packages/coding-agent/src/modes/interactive/components/footer.ts @@ -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(); diff --git a/packages/coding-agent/test/footer-width.test.ts b/packages/coding-agent/test/footer-width.test.ts new file mode 100644 index 00000000..ac649834 --- /dev/null +++ b/packages/coding-agent/test/footer-width.test.ts @@ -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(), + 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); + } + }); +});