From 0c5d4fcb067e1027a68392feff1c6b0648635518 Mon Sep 17 00:00:00 2001 From: Clarity-89 Date: Thu, 2 Apr 2026 14:21:28 +0300 Subject: [PATCH] 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. --- packages/coding-agent/CHANGELOG.md | 1 + .../src/modes/components/status-line.ts | 36 +++ .../test/status-line-overflow.test.ts | 258 ++++++++++++++++++ 3 files changed, 295 insertions(+) create mode 100644 packages/coding-agent/test/status-line-overflow.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8daef5ebd..6ed253ebe 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/modes/components/status-line.ts b/packages/coding-agent/src/modes/components/status-line.ts index e2c1895cc..9618ff312 100644 --- a/packages/coding-agent/src/modes/components/status-line.ts +++ b/packages/coding-agent/src/modes/components/status-line.ts @@ -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); } } diff --git a/packages/coding-agent/test/status-line-overflow.test.ts b/packages/coding-agent/test/status-line-overflow.test.ts new file mode 100644 index 000000000..bb9b4cdf9 --- /dev/null +++ b/packages/coding-agent/test/status-line-overflow.test.ts @@ -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); + } + }); +});