mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 06:04:40 +00:00
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:
parent
dabcda0db3
commit
3de8c48692
3 changed files with 123 additions and 23 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
114
packages/coding-agent/test/footer-width.test.ts
Normal file
114
packages/coding-agent/test/footer-width.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue