fix: shrink path segment before dropping branch from status line
The overflow logic in #buildStatusLine() pops left segments from the end, which always drops git branch before path. Path is the only elastic segment (supports progressive truncation), so shrink it first before dropping others. Adds a path-shrink step between the right-pop and left-pop loops that re-renders the path segment with a reduced maxLength when overflowing.
This commit is contained in:
@@ -48,6 +48,7 @@
|
||||
|
||||
- Fixed `read` output for file-backed internal URLs like `local://...` to include hashline prefixes in hashline edit mode, preserving usable line refs for follow-up edits
|
||||
- Fixed the plan review selector to support the external editor shortcut for opening and updating the current plan from the approval screen
|
||||
- Fixed status line dropping git branch name when path is long by shrinking the path segment before dropping other segments
|
||||
|
||||
## [13.18.0] - 2026-04-02
|
||||
### Breaking Changes
|
||||
|
||||
@@ -387,10 +387,12 @@ export class StatusLineComponent implements Component {
|
||||
|
||||
// Collect visible segment contents
|
||||
const leftParts: string[] = [];
|
||||
const leftSegIds: StatusLineSegmentId[] = [];
|
||||
for (const segId of effectiveSettings.leftSegments) {
|
||||
const rendered = renderSegment(segId, ctx);
|
||||
if (rendered.visible && rendered.content) {
|
||||
leftParts.push(rendered.content);
|
||||
leftSegIds.push(segId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,8 +435,42 @@ export class StatusLineComponent implements Component {
|
||||
right.pop();
|
||||
rightWidth = groupWidth(right, rightCapWidth, rightSepWidth);
|
||||
}
|
||||
// Shrink path before dropping left segments — path is the only elastic segment
|
||||
const pathIdx = leftSegIds.indexOf("path");
|
||||
if (pathIdx >= 0 && totalWidth() > topFillWidth) {
|
||||
const overflow = totalWidth() - topFillWidth;
|
||||
const currentPathVW = visibleWidth(left[pathIdx]);
|
||||
const minPathVW = 8; // icon + ellipsis + a few chars
|
||||
const shrinkable = currentPathVW - minPathVW;
|
||||
if (shrinkable > 0) {
|
||||
const shrinkBy = Math.min(shrinkable, overflow);
|
||||
const currentMaxLen = ctx.options.path?.maxLength ?? 40;
|
||||
let newMaxLen = Math.max(4, Math.min(currentMaxLen, currentPathVW) - shrinkBy);
|
||||
const pathCtx = (maxLen: number): SegmentContext => ({
|
||||
...ctx,
|
||||
options: { ...ctx.options, path: { ...ctx.options.path, maxLength: maxLen } },
|
||||
});
|
||||
let reRendered = renderSegment("path", pathCtx(newMaxLen));
|
||||
if (reRendered.visible && reRendered.content) {
|
||||
// maxLength governs path text, not icon prefix; iterate to compensate
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const saved = currentPathVW - visibleWidth(reRendered.content);
|
||||
if (saved >= shrinkBy) break;
|
||||
const nextMaxLen = Math.max(4, newMaxLen - (shrinkBy - saved));
|
||||
if (nextMaxLen >= newMaxLen) break; // no progress or hit floor
|
||||
newMaxLen = nextMaxLen;
|
||||
const adjusted = renderSegment("path", pathCtx(newMaxLen));
|
||||
if (!adjusted.visible || !adjusted.content) break;
|
||||
reRendered = adjusted;
|
||||
}
|
||||
left[pathIdx] = reRendered.content;
|
||||
leftWidth = groupWidth(left, leftCapWidth, leftSepWidth);
|
||||
}
|
||||
}
|
||||
}
|
||||
while (totalWidth() > topFillWidth && left.length > 0) {
|
||||
left.pop();
|
||||
leftSegIds.pop();
|
||||
leftWidth = groupWidth(left, leftCapWidth, leftSepWidth);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { visibleWidth } from "@oh-my-pi/pi-tui";
|
||||
import { getProjectDir, setProjectDir } from "@oh-my-pi/pi-utils";
|
||||
import type { StatusLineSegmentId } from "../src/config/settings-schema";
|
||||
import type { SegmentContext } from "../src/modes/components/status-line/segments";
|
||||
import { renderSegment } from "../src/modes/components/status-line/segments";
|
||||
import { initTheme } from "../src/modes/theme/theme";
|
||||
|
||||
const originalProjectDir = getProjectDir();
|
||||
|
||||
beforeAll(async () => {
|
||||
await initTheme();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
setProjectDir(originalProjectDir);
|
||||
});
|
||||
|
||||
/** Minimal SegmentContext factory — only path/git fields matter for these tests. */
|
||||
function createCtx(overrides?: { pathMaxLength?: number; branch?: string | null }): SegmentContext {
|
||||
return {
|
||||
session: {
|
||||
state: {},
|
||||
isFastModeEnabled: () => false,
|
||||
modelRegistry: { isUsingOAuth: () => false },
|
||||
sessionManager: undefined,
|
||||
} as unknown as SegmentContext["session"],
|
||||
width: 120,
|
||||
options: {
|
||||
path: {
|
||||
abbreviate: false,
|
||||
maxLength: overrides?.pathMaxLength ?? 40,
|
||||
stripWorkPrefix: false,
|
||||
},
|
||||
},
|
||||
planMode: null,
|
||||
usageStats: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
premiumRequests: 0,
|
||||
cost: 0,
|
||||
tokensPerSecond: null,
|
||||
},
|
||||
contextPercent: 0,
|
||||
contextWindow: 0,
|
||||
autoCompactEnabled: false,
|
||||
subagentCount: 0,
|
||||
sessionStartTime: Date.now(),
|
||||
git: {
|
||||
branch: overrides?.branch ?? null,
|
||||
status: null,
|
||||
pr: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("path segment truncation at varying maxLength", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeAll(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-overflow-very-long-directory-name-for-testing-"));
|
||||
setProjectDir(tmpDir);
|
||||
});
|
||||
|
||||
it("truncates path with ellipsis when maxLength is smaller than path", () => {
|
||||
const full = renderSegment("path", createCtx({ pathMaxLength: 200 }));
|
||||
const short = renderSegment("path", createCtx({ pathMaxLength: 10 }));
|
||||
|
||||
expect(full.visible).toBe(true);
|
||||
expect(short.visible).toBe(true);
|
||||
expect(visibleWidth(short.content)).toBeLessThan(visibleWidth(full.content));
|
||||
});
|
||||
|
||||
it("reduces visible width monotonically as maxLength decreases", () => {
|
||||
const widths = [40, 20, 10, 4].map(maxLen => {
|
||||
const rendered = renderSegment("path", createCtx({ pathMaxLength: maxLen }));
|
||||
return visibleWidth(rendered.content);
|
||||
});
|
||||
|
||||
for (let i = 1; i < widths.length; i++) {
|
||||
expect(widths[i]).toBeLessThanOrEqual(widths[i - 1]);
|
||||
}
|
||||
});
|
||||
|
||||
it("still renders a visible segment at maxLength=4", () => {
|
||||
const rendered = renderSegment("path", createCtx({ pathMaxLength: 4 }));
|
||||
expect(rendered.visible).toBe(true);
|
||||
expect(visibleWidth(rendered.content)).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("overflow: path shrinks before git is dropped", () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeAll(() => {
|
||||
// Long dir name guarantees the path segment is wide
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-overflow-a-very-long-worktree-directory-name-here-"));
|
||||
setProjectDir(tmpDir);
|
||||
});
|
||||
|
||||
/**
|
||||
* Simulates the overflow algorithm from #buildStatusLine:
|
||||
* render left segments, then shrink path before popping, same as production code.
|
||||
*/
|
||||
function simulateOverflow(
|
||||
width: number,
|
||||
leftSegmentIds: StatusLineSegmentId[],
|
||||
ctx: SegmentContext,
|
||||
): { surviving: StatusLineSegmentId[]; contents: string[] } {
|
||||
const left: string[] = [];
|
||||
const leftSegIds: StatusLineSegmentId[] = [];
|
||||
for (const segId of leftSegmentIds) {
|
||||
const rendered = renderSegment(segId, ctx);
|
||||
if (rendered.visible && rendered.content) {
|
||||
left.push(rendered.content);
|
||||
leftSegIds.push(segId);
|
||||
}
|
||||
}
|
||||
|
||||
// Simplified groupWidth: sum of visible widths + padding between segments
|
||||
const groupWidth = () => {
|
||||
if (left.length === 0) return 0;
|
||||
const partsWidth = left.reduce((sum, p) => sum + visibleWidth(p), 0);
|
||||
// Each separator gap ~ 3 chars, plus 2 for outer padding
|
||||
return partsWidth + Math.max(0, left.length - 1) * 3 + 2;
|
||||
};
|
||||
|
||||
// Path shrink step (mirrors production code)
|
||||
const pathIdx = leftSegIds.indexOf("path");
|
||||
if (pathIdx >= 0 && groupWidth() > width) {
|
||||
const overflow = groupWidth() - width;
|
||||
const currentPathVW = visibleWidth(left[pathIdx]);
|
||||
const minPathVW = 8;
|
||||
const shrinkable = currentPathVW - minPathVW;
|
||||
if (shrinkable > 0) {
|
||||
const shrinkBy = Math.min(shrinkable, overflow);
|
||||
const currentMaxLen = ctx.options.path?.maxLength ?? 40;
|
||||
let newMaxLen = Math.max(4, Math.min(currentMaxLen, currentPathVW) - shrinkBy);
|
||||
const pathCtx = (maxLen: number): SegmentContext => ({
|
||||
...ctx,
|
||||
options: { ...ctx.options, path: { ...ctx.options.path, maxLength: maxLen } },
|
||||
});
|
||||
let reRendered = renderSegment("path", pathCtx(newMaxLen));
|
||||
if (reRendered.visible && reRendered.content) {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const saved = currentPathVW - visibleWidth(reRendered.content);
|
||||
if (saved >= shrinkBy) break;
|
||||
const nextMaxLen = Math.max(4, newMaxLen - (shrinkBy - saved));
|
||||
if (nextMaxLen >= newMaxLen) break;
|
||||
newMaxLen = nextMaxLen;
|
||||
const adjusted = renderSegment("path", pathCtx(newMaxLen));
|
||||
if (!adjusted.visible || !adjusted.content) break;
|
||||
reRendered = adjusted;
|
||||
}
|
||||
left[pathIdx] = reRendered.content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Left-pop loop (fallback)
|
||||
while (groupWidth() > width && left.length > 0) {
|
||||
left.pop();
|
||||
leftSegIds.pop();
|
||||
}
|
||||
|
||||
return { surviving: [...leftSegIds], contents: [...left] };
|
||||
}
|
||||
|
||||
it("keeps git segment when path can be shrunk to fit", () => {
|
||||
const ctx = createCtx({ pathMaxLength: 40, branch: "feat/long-branch-name" });
|
||||
// Use a width that's tight but should fit both after path shrinks
|
||||
const fullPath = renderSegment("path", ctx);
|
||||
const fullGit = renderSegment("git", ctx);
|
||||
const bothWidth = visibleWidth(fullPath.content) + visibleWidth(fullGit.content);
|
||||
// Set width to ~60% of both segments — forces shrink but should keep both
|
||||
const tightWidth = Math.floor(bothWidth * 0.6) + 10;
|
||||
|
||||
const result = simulateOverflow(tightWidth, ["path", "git"], ctx);
|
||||
|
||||
expect(result.surviving).toContain("git");
|
||||
expect(result.surviving).toContain("path");
|
||||
});
|
||||
|
||||
it("drops git only when terminal is extremely narrow", () => {
|
||||
const ctx = createCtx({ pathMaxLength: 40, branch: "main" });
|
||||
// Absurdly narrow — even minimally-truncated path won't fit with git
|
||||
const result = simulateOverflow(5, ["path", "git"], ctx);
|
||||
|
||||
// At 5 columns, nothing fits
|
||||
expect(result.surviving.length).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("is a no-op when there is enough space", () => {
|
||||
const ctx = createCtx({ pathMaxLength: 40, branch: "main" });
|
||||
const result = simulateOverflow(200, ["path", "git"], ctx);
|
||||
|
||||
expect(result.surviving).toEqual(["path", "git"]);
|
||||
});
|
||||
|
||||
it("shrinks a short path when maxLength exceeds actual path length", () => {
|
||||
// Short dir name — rendered path is well under maxLength=80
|
||||
const shortDir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-short-"));
|
||||
setProjectDir(shortDir);
|
||||
try {
|
||||
const ctx = createCtx({ pathMaxLength: 80, branch: "feat/long-branch-name" });
|
||||
const fullPath = renderSegment("path", ctx);
|
||||
const fullGit = renderSegment("git", ctx);
|
||||
const pathVW = visibleWidth(fullPath.content);
|
||||
const gitVW = visibleWidth(fullGit.content);
|
||||
|
||||
// Sanity: path is shorter than maxLength — this is the bug scenario
|
||||
expect(pathVW).toBeLessThan(80);
|
||||
|
||||
// Width that fits a shrunken path + git but not the full path + git
|
||||
const tightWidth = Math.floor(pathVW * 0.5) + gitVW + 10;
|
||||
|
||||
const result = simulateOverflow(tightWidth, ["path", "git"], ctx);
|
||||
|
||||
expect(result.surviving).toContain("path");
|
||||
expect(result.surviving).toContain("git");
|
||||
} finally {
|
||||
// Restore for other tests
|
||||
setProjectDir(tmpDir);
|
||||
}
|
||||
});
|
||||
it("preserves git when overflow is only 1-2 columns", () => {
|
||||
const shortDir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-narrow-ovf-"));
|
||||
setProjectDir(shortDir);
|
||||
try {
|
||||
const ctx = createCtx({ pathMaxLength: 80, branch: "main" });
|
||||
const fullPath = renderSegment("path", ctx);
|
||||
const fullGit = renderSegment("git", ctx);
|
||||
const pathVW = visibleWidth(fullPath.content);
|
||||
const gitVW = visibleWidth(fullGit.content);
|
||||
|
||||
// Compute exact full width using the test's groupWidth formula:
|
||||
// partsWidth + (numParts - 1) * 3 + 2
|
||||
const fullWidth = pathVW + gitVW + (2 - 1) * 3 + 2;
|
||||
|
||||
// Overflow by exactly 2 columns — the scenario the single-pass missed
|
||||
const result = simulateOverflow(fullWidth - 2, ["path", "git"], ctx);
|
||||
|
||||
expect(result.surviving).toContain("path");
|
||||
expect(result.surviving).toContain("git");
|
||||
|
||||
// Path must have actually shrunk (proves the loop ran)
|
||||
const shrunkPathVW = visibleWidth(result.contents[result.surviving.indexOf("path")]);
|
||||
expect(shrunkPathVW).toBeLessThan(pathVW);
|
||||
} finally {
|
||||
setProjectDir(tmpDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user