fix(status-line): wrapped overflow segments
Preserved configured segment priority by packing overflow into continuation rows. Extended EditorTopBorder to render ordered rows inside the editor frame. Fixes #5749
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the editor status line silently dropping lower-priority segments in narrow terminals; configured segments now flow onto continuation rows in priority order ([#5749](https://github.com/can1357/oh-my-pi/issues/5749)).
|
||||
|
||||
## [17.0.1] - 2026-07-16
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -77,7 +77,10 @@ describe("StatusLineComponent", () => {
|
||||
// Let's get the border and see if Prewalk is rendered.
|
||||
const border = statusLine.getTopBorder(100);
|
||||
// SGR codes might be included, so we check if the stripped content contains "Prewalk"
|
||||
const stripped = border.content.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
const stripped = border.lines
|
||||
.map(line => line.content)
|
||||
.join("\n")
|
||||
.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
expect(stripped).toContain("Prewalk");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
||||
import type { AssistantMessage, UsageLimit, UsageReport } from "@oh-my-pi/pi-ai";
|
||||
import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
|
||||
import { type Component, type EditorTopBorder, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
|
||||
import { getProjectDir } from "@oh-my-pi/pi-utils";
|
||||
import { settings } from "../../../config/settings";
|
||||
import type { AgentSession } from "../../../session/agent-session";
|
||||
@@ -1131,7 +1131,7 @@ export class StatusLineComponent implements Component {
|
||||
return theme.fg("statusLineSubagents", `${theme.icon.agents} ${this.#subagentCount} ${noun}`);
|
||||
}
|
||||
|
||||
#buildStatusLine(width: number): string {
|
||||
#buildStatusLine(width: number): string[] {
|
||||
const effectiveSettings = this.#resolveSettings();
|
||||
const includePath =
|
||||
hasPathSegment(effectiveSettings.leftSegments) || hasPathSegment(effectiveSettings.rightSegments);
|
||||
@@ -1166,15 +1166,12 @@ export class StatusLineComponent implements Component {
|
||||
const sepAnsi = theme.getFgAnsi("statusLineSep");
|
||||
const subagentBadge = this.#subagentBadgeText();
|
||||
|
||||
// Collect visible segment contents
|
||||
const leftParts: string[] = [];
|
||||
const leftSegIds: StatusLineSegmentId[] = [];
|
||||
for (const segId of effectiveSettings.leftSegments) {
|
||||
if (subagentBadge && segId === "subagents") continue;
|
||||
const rendered = renderSegment(segId, ctx);
|
||||
if (rendered.visible && rendered.content) {
|
||||
leftParts.push(rendered.content);
|
||||
leftSegIds.push(segId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1194,10 +1191,9 @@ export class StatusLineComponent implements Component {
|
||||
if (subagentBadge) {
|
||||
rightParts.unshift(subagentBadge);
|
||||
}
|
||||
const topFillWidth = Math.max(0, width);
|
||||
const left = [...leftParts];
|
||||
const right = [...rightParts];
|
||||
if (leftParts.length === 0 && rightParts.length === 0) return [];
|
||||
|
||||
const topFillWidth = Math.max(0, width);
|
||||
const leftSepWidth = visibleWidth(separatorDef.left);
|
||||
const rightSepWidth = visibleWidth(separatorDef.right);
|
||||
// Transparent mode drops powerline caps (they need a bg fill to bridge),
|
||||
@@ -1212,65 +1208,34 @@ export class StatusLineComponent implements Component {
|
||||
return partsWidth + sepTotal + 2 + capWidth;
|
||||
};
|
||||
|
||||
let leftWidth = groupWidth(left, leftCapWidth, leftSepWidth);
|
||||
let rightWidth = groupWidth(right, rightCapWidth, rightSepWidth);
|
||||
const totalWidth = () => leftWidth + rightWidth + (left.length > 0 && right.length > 0 ? 1 : 0);
|
||||
|
||||
if (topFillWidth > 0) {
|
||||
while (totalWidth() > topFillWidth && right.length > 0) {
|
||||
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);
|
||||
// Preset order is priority order: fill rows with left segments first, then
|
||||
// right segments. A segment wider than one row is clipped only after it has
|
||||
// been isolated, so it never displaces or discards later segments.
|
||||
const groups: Array<{ left: string[]; right: string[] }> = [{ left: [], right: [] }];
|
||||
if (topFillWidth === 0) {
|
||||
groups[0]!.left.push(...leftParts);
|
||||
groups[0]!.right.push(...rightParts);
|
||||
} else {
|
||||
const orderedParts: Array<{ side: "left" | "right"; parts: string[] }> = [
|
||||
{ side: "left", parts: leftParts },
|
||||
{ side: "right", parts: rightParts },
|
||||
];
|
||||
for (const { side, parts } of orderedParts) {
|
||||
for (const part of parts) {
|
||||
let current = groups[groups.length - 1]!;
|
||||
const currentSide = current[side];
|
||||
currentSide.push(part);
|
||||
const leftWidth = groupWidth(current.left, leftCapWidth, leftSepWidth);
|
||||
const rightWidth = groupWidth(current.right, rightCapWidth, rightSepWidth);
|
||||
const totalWidth = leftWidth + rightWidth + (leftWidth > 0 && rightWidth > 0 ? 1 : 0);
|
||||
if (totalWidth > topFillWidth && current.left.length + current.right.length > 1) {
|
||||
currentSide.pop();
|
||||
current = { left: [], right: [] };
|
||||
current[side].push(part);
|
||||
groups.push(current);
|
||||
}
|
||||
}
|
||||
}
|
||||
const leftOverflowDropIndex = (): number => {
|
||||
// Preserve the current working directory as long as possible. The
|
||||
// previous right-to-left pop could collapse a normal-width bar to
|
||||
// just the model segment, hiding the path before less-critical left
|
||||
// segments such as model/mode/collab were removed.
|
||||
for (let i = leftSegIds.length - 1; i >= 0; i--) {
|
||||
if (leftSegIds[i] !== "path") return i;
|
||||
}
|
||||
return left.length - 1;
|
||||
};
|
||||
|
||||
while (totalWidth() > topFillWidth && left.length > 0) {
|
||||
const dropIdx = leftOverflowDropIndex();
|
||||
left.splice(dropIdx, 1);
|
||||
leftSegIds.splice(dropIdx, 1);
|
||||
leftWidth = groupWidth(left, leftCapWidth, leftSepWidth);
|
||||
}
|
||||
}
|
||||
|
||||
const renderGroup = (parts: string[], direction: "left" | "right"): string => {
|
||||
@@ -1295,35 +1260,46 @@ export class StatusLineComponent implements Component {
|
||||
return content;
|
||||
};
|
||||
|
||||
const leftGroup = renderGroup(left, "left");
|
||||
const rightGroup = renderGroup(right, "right");
|
||||
if (!leftGroup && !rightGroup) return "";
|
||||
|
||||
if (topFillWidth === 0 || left.length === 0 || right.length === 0) {
|
||||
return leftGroup + (leftGroup && rightGroup ? " " : "") + rightGroup;
|
||||
}
|
||||
|
||||
const gapWidth = Math.max(1, topFillWidth - leftWidth - rightWidth);
|
||||
const sessionName =
|
||||
effectiveSettings.sessionAccent !== false ? this.session.sessionManager?.getSessionName() : undefined;
|
||||
const accentHex = sessionName
|
||||
? getSessionAccentHex(sessionName, theme.getMajorThemeColorHexes(), theme.accentSurfaceLuminance)
|
||||
: undefined;
|
||||
const gapColor = getSessionAccentAnsi(accentHex) ?? theme.getFgAnsi("border");
|
||||
const gapFill = `${gapColor}${theme.boxRound.horizontal.repeat(gapWidth)}\x1b[39m`;
|
||||
return leftGroup + gapFill + rightGroup;
|
||||
const lines: string[] = [];
|
||||
for (const group of groups) {
|
||||
const leftGroup = renderGroup(group.left, "left");
|
||||
const rightGroup = renderGroup(group.right, "right");
|
||||
if (!leftGroup && !rightGroup) continue;
|
||||
|
||||
let content = leftGroup || rightGroup;
|
||||
if (leftGroup && rightGroup) {
|
||||
const leftWidth = groupWidth(group.left, leftCapWidth, leftSepWidth);
|
||||
const rightWidth = groupWidth(group.right, rightCapWidth, rightSepWidth);
|
||||
const gapWidth = Math.max(1, topFillWidth - leftWidth - rightWidth);
|
||||
content = `${leftGroup}${gapColor}${theme.boxRound.horizontal.repeat(gapWidth)}\x1b[39m${rightGroup}`;
|
||||
}
|
||||
if (topFillWidth > 0 && visibleWidth(content) > topFillWidth) {
|
||||
content = truncateToWidth(content, topFillWidth);
|
||||
}
|
||||
lines.push(content);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
getTopBorder(width: number): { content: string; width: number } {
|
||||
let content = this.#buildStatusLine(width);
|
||||
if (this.#focusedAgentId && content) {
|
||||
/** Builds the prioritized status rows consumed by the editor's top border. */
|
||||
getTopBorder(width: number): EditorTopBorder {
|
||||
let contents = this.#buildStatusLine(width);
|
||||
if (this.#focusedAgentId) {
|
||||
// Dim the whole bar while focus-proxied. Group/cap terminators emit full
|
||||
// `\x1b[0m` resets that would cancel faint mid-bar, so re-open it after each.
|
||||
content = `\x1b[2m${content.replaceAll("\x1b[0m", "\x1b[0m\x1b[2m")}\x1b[22m`;
|
||||
contents = contents.map(content => `\x1b[2m${content.replaceAll("\x1b[0m", "\x1b[0m\x1b[2m")}\x1b[22m`);
|
||||
}
|
||||
return {
|
||||
content,
|
||||
width: visibleWidth(content),
|
||||
lines: contents.map(content => ({
|
||||
content,
|
||||
width: visibleWidth(content),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -183,7 +183,10 @@ export class SelectorController {
|
||||
getStatusLinePreview: () => {
|
||||
// Return the rendered status line for inline preview
|
||||
const availableWidth = this.ctx.editor.getTopBorderAvailableWidth(this.ctx.ui.terminal.columns);
|
||||
return this.ctx.statusLine.getTopBorder(availableWidth).content;
|
||||
return this.ctx.statusLine
|
||||
.getTopBorder(availableWidth)
|
||||
.lines.map(line => line.content)
|
||||
.join("\n");
|
||||
},
|
||||
onPluginsChanged: async () => {
|
||||
const projectPath = await resolveActiveProjectRegistryPath(this.ctx.sessionManager.getCwd());
|
||||
|
||||
@@ -214,7 +214,7 @@ describe("StatusLineComponent context breakdown", () => {
|
||||
});
|
||||
|
||||
const border = comp.getTopBorder(80);
|
||||
expect(border.content.length).toBeGreaterThan(0);
|
||||
expect(border.lines.length).toBeGreaterThan(0);
|
||||
expect(usageCalls()).toBe(0);
|
||||
});
|
||||
|
||||
@@ -232,7 +232,11 @@ describe("StatusLineComponent context breakdown", () => {
|
||||
});
|
||||
|
||||
// 5000 / 272000 → 1.8%, window formatted as 272K (matches the footer gauge).
|
||||
const plain = comp.getTopBorder(80).content.replaceAll(/\x1b\[[0-9;]*m/g, "");
|
||||
const plain = comp
|
||||
.getTopBorder(80)
|
||||
.lines.map(line => line.content)
|
||||
.join("\n")
|
||||
.replaceAll(/\x1b\[[0-9;]*m/g, "");
|
||||
expect(plain).toContain("1.8%/272K");
|
||||
});
|
||||
|
||||
@@ -249,7 +253,11 @@ describe("StatusLineComponent context breakdown", () => {
|
||||
separator: "powerline-thin",
|
||||
});
|
||||
|
||||
const plain = comp.getTopBorder(80).content.replaceAll(/\x1b\[[0-9;]*m/g, "");
|
||||
const plain = comp
|
||||
.getTopBorder(80)
|
||||
.lines.map(line => line.content)
|
||||
.join("\n")
|
||||
.replaceAll(/\x1b\[[0-9;]*m/g, "");
|
||||
expect(plain).toContain("0.5%/272K");
|
||||
});
|
||||
|
||||
@@ -267,7 +275,11 @@ describe("StatusLineComponent context breakdown", () => {
|
||||
separator: "powerline-thin",
|
||||
});
|
||||
|
||||
const plain = comp.getTopBorder(80).content.replaceAll(/\x1b\[[0-9;]*m/g, "");
|
||||
const plain = comp
|
||||
.getTopBorder(80)
|
||||
.lines.map(line => line.content)
|
||||
.join("\n")
|
||||
.replaceAll(/\x1b\[[0-9;]*m/g, "");
|
||||
expect(plain).toContain("5K/?");
|
||||
expect(plain).not.toContain("0.0%/0");
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import type { StatusLineSegmentId } from "@oh-my-pi/pi-coding-agent/config/settings-schema";
|
||||
import { StatusLineComponent } from "@oh-my-pi/pi-coding-agent/modes/components/status-line";
|
||||
import type { SegmentContext } from "@oh-my-pi/pi-coding-agent/modes/components/status-line/segments";
|
||||
import { renderSegment } from "@oh-my-pi/pi-coding-agent/modes/components/status-line/segments";
|
||||
@@ -144,14 +143,20 @@ describe("status line session accent", () => {
|
||||
it("paints the gap with the session accent when enabled", () => {
|
||||
const ansi = accentAnsi();
|
||||
expect(ansi).toBeDefined();
|
||||
const border = buildComponent(true).getTopBorder(80).content;
|
||||
const border = buildComponent(true)
|
||||
.getTopBorder(80)
|
||||
.lines.map(line => line.content)
|
||||
.join("\n");
|
||||
expect(border).toContain(`${ansi}${theme.boxRound.horizontal}`);
|
||||
});
|
||||
|
||||
it("paints the gap with the border color and omits the session accent when disabled", () => {
|
||||
const ansi = accentAnsi();
|
||||
expect(ansi).toBeDefined();
|
||||
const border = buildComponent(false).getTopBorder(80).content;
|
||||
const border = buildComponent(false)
|
||||
.getTopBorder(80)
|
||||
.lines.map(line => line.content)
|
||||
.join("\n");
|
||||
// Positive: gap is rendered with the theme border color.
|
||||
expect(border).toContain(`${theme.getFgAnsi("border")}${theme.boxRound.horizontal}`);
|
||||
// Negative: the gap-painting pattern (accent ANSI directly followed by a horizontal
|
||||
@@ -196,186 +201,14 @@ describe("path segment truncation at varying maxLength", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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-segment fallback loop.
|
||||
const leftOverflowDropIndex = (): number => {
|
||||
for (let i = leftSegIds.length - 1; i >= 0; i--) {
|
||||
if (leftSegIds[i] !== "path") return i;
|
||||
}
|
||||
return left.length - 1;
|
||||
};
|
||||
while (groupWidth() > width && left.length > 0) {
|
||||
const dropIdx = leftOverflowDropIndex();
|
||||
left.splice(dropIdx, 1);
|
||||
leftSegIds.splice(dropIdx, 1);
|
||||
}
|
||||
|
||||
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 the configured maxLength.
|
||||
const shortDir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-short-"));
|
||||
setProjectDir(shortDir);
|
||||
try {
|
||||
const maxLength = 160;
|
||||
const ctx = createCtx({ pathMaxLength: maxLength, 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.
|
||||
// macOS temp paths can exceed 80 columns once the path icon is included.
|
||||
expect(pathVW).toBeLessThan(maxLength);
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("overflow: path survives before model", () => {
|
||||
it("drops the model segment before the cwd path when both cannot fit", () => {
|
||||
describe("overflow continuation lines for left segments", () => {
|
||||
it("preserves model and path on separate rows when they cannot fit together", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "omp-statusline-overflow-"));
|
||||
const cwd = path.join(root, "cwdxyz");
|
||||
fs.mkdirSync(cwd);
|
||||
setProjectDir(cwd);
|
||||
|
||||
const modelName = `MODEL_SHOULD_DROP_${"x".repeat(24)}`;
|
||||
const modelName = `MODEL_MUST_CONTINUE_${"x".repeat(24)}`;
|
||||
const session = createStatusLineSession("overflow test", modelName);
|
||||
const component = new StatusLineComponent(session);
|
||||
const pathOptions = {
|
||||
@@ -406,22 +239,37 @@ describe("overflow: path survives before model", () => {
|
||||
} as SegmentContext;
|
||||
const pi = renderSegment("pi", ctx).content;
|
||||
const model = renderSegment("model", ctx).content;
|
||||
const minPath = renderSegment("path", {
|
||||
...ctx,
|
||||
options: { ...ctx.options, path: { ...pathOptions, maxLength: 4 } },
|
||||
}).content;
|
||||
const separatorWidth = visibleWidth(theme.sep.space);
|
||||
const groupWidth = (parts: string[]) =>
|
||||
parts.reduce((sum, part) => sum + visibleWidth(part), 0) +
|
||||
Math.max(0, parts.length - 1) * (separatorWidth + 2) +
|
||||
2;
|
||||
const width = groupWidth([pi, model]) + 1;
|
||||
const width = visibleWidth(pi) + visibleWidth(model) + separatorWidth + 3;
|
||||
|
||||
expect(groupWidth([pi, model, minPath])).toBeGreaterThan(width);
|
||||
expect(groupWidth([pi, minPath])).toBeLessThanOrEqual(width);
|
||||
const border = component.getTopBorder(width);
|
||||
const rendered = stripAnsi(border.lines.map(line => line.content).join("\n"));
|
||||
|
||||
const rendered = stripAnsi(component.getTopBorder(width).content);
|
||||
expect(border.lines.length).toBeGreaterThan(1);
|
||||
expect(rendered).toContain(modelName);
|
||||
expect(rendered).toContain("xyz");
|
||||
expect(rendered).not.toContain("MODEL_SHOULD_DROP");
|
||||
});
|
||||
});
|
||||
|
||||
describe("overflow continuation lines", () => {
|
||||
it("preserves lower-priority segments when one line is too narrow", () => {
|
||||
const component = new StatusLineComponent(createStatusLineSession("SESSION_MUST_CONTINUE", "PRIORITY_MODEL"));
|
||||
component.updateSettings({
|
||||
preset: "custom",
|
||||
leftSegments: ["model"],
|
||||
rightSegments: ["session_name"],
|
||||
separator: "none",
|
||||
sessionAccent: false,
|
||||
transparent: true,
|
||||
segmentOptions: {
|
||||
model: { showThinkingLevel: false },
|
||||
},
|
||||
});
|
||||
|
||||
const border = component.getTopBorder(24);
|
||||
const rendered = stripAnsi(border.lines.map(line => line.content).join("\n"));
|
||||
|
||||
expect(rendered).toContain("PRIORITY_MODEL");
|
||||
expect(rendered).toContain("SESSION_MUST_CONTINUE");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -127,7 +127,14 @@ describe("StatusLineComponent effective settings cache", () => {
|
||||
expect(secondEffective.separator).toBe("slash");
|
||||
expect(secondEffective.sessionAccent).toBe(false);
|
||||
expect(secondEffective.segmentOptions.path?.maxLength).toBe(12);
|
||||
expect(stripVTControlCharacters(component.getTopBorder(80).content)).toContain("Cache Session");
|
||||
expect(
|
||||
stripVTControlCharacters(
|
||||
component
|
||||
.getTopBorder(80)
|
||||
.lines.map(line => line.content)
|
||||
.join("\n"),
|
||||
),
|
||||
).toContain("Cache Session");
|
||||
expect(component.render(80)).toEqual(["lint running"]);
|
||||
});
|
||||
|
||||
@@ -157,7 +164,7 @@ describe("StatusLineComponent effective settings cache", () => {
|
||||
const customComponent = makeComponent({ preset: "custom", leftSegments: [], rightSegments: [] });
|
||||
expect(customComponent.getEffectiveSettingsForTest().leftSegments).toEqual([]);
|
||||
expect(customComponent.getEffectiveSettingsForTest().rightSegments).toEqual([]);
|
||||
expect(customComponent.getTopBorder(120)).toEqual({ content: "", width: 0 });
|
||||
expect(customComponent.getTopBorder(120)).toEqual({ lines: [] });
|
||||
});
|
||||
|
||||
it("surfaces active subagents even when custom segments omit subagents", () => {
|
||||
@@ -165,7 +172,12 @@ describe("StatusLineComponent effective settings cache", () => {
|
||||
|
||||
component.setSubagentCount(2);
|
||||
|
||||
const content = stripVTControlCharacters(component.getTopBorder(120).content);
|
||||
const content = stripVTControlCharacters(
|
||||
component
|
||||
.getTopBorder(120)
|
||||
.lines.map(line => line.content)
|
||||
.join("\n"),
|
||||
);
|
||||
expect(content).toContain("2 agents");
|
||||
expect(content).not.toContain("running");
|
||||
});
|
||||
@@ -173,10 +185,22 @@ describe("StatusLineComponent effective settings cache", () => {
|
||||
it("keeps plan and hook state dynamic without settings invalidation", () => {
|
||||
const component = makeComponent({ preset: "custom", leftSegments: ["mode"], rightSegments: [] });
|
||||
const effective = component.getEffectiveSettingsForTest();
|
||||
expect(component.getTopBorder(80).content).toBe("");
|
||||
expect(
|
||||
component
|
||||
.getTopBorder(80)
|
||||
.lines.map(line => line.content)
|
||||
.join("\n"),
|
||||
).toBe("");
|
||||
|
||||
component.setPlanModeStatus({ enabled: true, paused: false });
|
||||
expect(stripVTControlCharacters(component.getTopBorder(80).content)).toContain("Plan");
|
||||
expect(
|
||||
stripVTControlCharacters(
|
||||
component
|
||||
.getTopBorder(80)
|
||||
.lines.map(line => line.content)
|
||||
.join("\n"),
|
||||
),
|
||||
).toContain("Plan");
|
||||
expect(component.getEffectiveSettingsForTest()).toBe(effective);
|
||||
|
||||
component.setHookStatus("hook", "hook running");
|
||||
|
||||
@@ -71,12 +71,18 @@ describe("status line transparent background", () => {
|
||||
// otherwise the negative case below would be vacuous.
|
||||
expect(themeBg).toMatch(/\x1b\[48;/);
|
||||
|
||||
const border = buildComponent(false).getTopBorder(80).content;
|
||||
const border = buildComponent(false)
|
||||
.getTopBorder(80)
|
||||
.lines.map(line => line.content)
|
||||
.join("\n");
|
||||
expect(border).toContain(themeBg);
|
||||
});
|
||||
|
||||
it("drops the theme bg fill and powerline caps when enabled", () => {
|
||||
const border = buildComponent(true).getTopBorder(80).content;
|
||||
const border = buildComponent(true)
|
||||
.getTopBorder(80)
|
||||
.lines.map(line => line.content)
|
||||
.join("\n");
|
||||
const themeBg = theme.getBgAnsi("statusLineBg");
|
||||
|
||||
// No 48; (background) ANSI escape anywhere in the rendered bar — every bg is
|
||||
|
||||
@@ -151,12 +151,26 @@ describe("StatusLineComponent usage refresh", () => {
|
||||
vi.advanceTimersByTime(2_000);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(plain(component.getTopBorder(80).content)).not.toContain("5h");
|
||||
expect(
|
||||
plain(
|
||||
component
|
||||
.getTopBorder(80)
|
||||
.lines.map(line => line.content)
|
||||
.join("\n"),
|
||||
),
|
||||
).not.toContain("5h");
|
||||
|
||||
late.resolve(usageReport(42));
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(plain(component.getTopBorder(80).content)).toContain("5h 42%");
|
||||
expect(
|
||||
plain(
|
||||
component
|
||||
.getTopBorder(80)
|
||||
.lines.map(line => line.content)
|
||||
.join("\n"),
|
||||
),
|
||||
).toContain("5h 42%");
|
||||
});
|
||||
|
||||
it("re-fetches usage immediately when the session rotates to another org under the same email", async () => {
|
||||
|
||||
@@ -101,7 +101,12 @@ describe("usage status-line segment", () => {
|
||||
|
||||
component.refreshUsageInBackground();
|
||||
await flushUsageRefresh();
|
||||
const content = stripVTControlCharacters(component.getTopBorder(200).content);
|
||||
const content = stripVTControlCharacters(
|
||||
component
|
||||
.getTopBorder(200)
|
||||
.lines.map(line => line.content)
|
||||
.join("\n"),
|
||||
);
|
||||
|
||||
expect(content).toContain("prolite");
|
||||
expect(content).toContain("5h");
|
||||
@@ -123,7 +128,12 @@ describe("usage status-line segment", () => {
|
||||
|
||||
component.refreshUsageInBackground();
|
||||
await flushUsageRefresh();
|
||||
const content = stripVTControlCharacters(component.getTopBorder(200).content);
|
||||
const content = stripVTControlCharacters(
|
||||
component
|
||||
.getTopBorder(200)
|
||||
.lines.map(line => line.content)
|
||||
.join("\n"),
|
||||
);
|
||||
|
||||
expect(content).toContain("prolite");
|
||||
expect(content).not.toContain("stale");
|
||||
@@ -162,7 +172,12 @@ describe("usage status-line segment", () => {
|
||||
|
||||
component.refreshUsageInBackground();
|
||||
await flushUsageRefresh();
|
||||
const content = stripVTControlCharacters(component.getTopBorder(200).content);
|
||||
const content = stripVTControlCharacters(
|
||||
component
|
||||
.getTopBorder(200)
|
||||
.lines.map(line => line.content)
|
||||
.join("\n"),
|
||||
);
|
||||
|
||||
expect(content).toContain("prolite");
|
||||
expect(content).toContain("24%");
|
||||
@@ -226,15 +241,32 @@ describe("usage status-line segment", () => {
|
||||
|
||||
component.refreshUsageInBackground();
|
||||
await flushUsageRefresh();
|
||||
expect(stripVTControlCharacters(component.getTopBorder(200).content)).toContain("80%");
|
||||
expect(
|
||||
stripVTControlCharacters(
|
||||
component
|
||||
.getTopBorder(200)
|
||||
.lines.map(line => line.content)
|
||||
.join("\n"),
|
||||
),
|
||||
).toContain("80%");
|
||||
|
||||
provider = "anthropic";
|
||||
model.provider = provider;
|
||||
|
||||
const immediate = stripVTControlCharacters(component.getTopBorder(200).content);
|
||||
const immediate = stripVTControlCharacters(
|
||||
component
|
||||
.getTopBorder(200)
|
||||
.lines.map(line => line.content)
|
||||
.join("\n"),
|
||||
);
|
||||
expect(immediate).not.toContain("80%");
|
||||
await flushUsageRefresh();
|
||||
const refreshed = stripVTControlCharacters(component.getTopBorder(200).content);
|
||||
const refreshed = stripVTControlCharacters(
|
||||
component
|
||||
.getTopBorder(200)
|
||||
.lines.map(line => line.content)
|
||||
.join("\n"),
|
||||
);
|
||||
expect(refreshed).toContain("24%");
|
||||
});
|
||||
|
||||
@@ -260,7 +292,12 @@ describe("usage status-line segment", () => {
|
||||
|
||||
component.refreshUsageInBackground();
|
||||
await flushUsageRefresh();
|
||||
const content = stripVTControlCharacters(component.getTopBorder(200).content);
|
||||
const content = stripVTControlCharacters(
|
||||
component
|
||||
.getTopBorder(200)
|
||||
.lines.map(line => line.content)
|
||||
.join("\n"),
|
||||
);
|
||||
|
||||
expect(content).toContain("5h");
|
||||
expect(content).toContain("24%");
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Changed `EditorTopBorder` to expose ordered `lines` instead of one `content`/`width` pair, allowing the editor to frame every continuation row rather than truncate one oversized status row ([#5749](https://github.com/can1357/oh-my-pi/issues/5749)).
|
||||
|
||||
## [17.0.1] - 2026-07-16
|
||||
|
||||
### Added
|
||||
|
||||
@@ -352,13 +352,20 @@ export interface EditorTheme {
|
||||
hintStyle?: (text: string) => string;
|
||||
}
|
||||
|
||||
export interface EditorTopBorder {
|
||||
/** The status content (already styled) */
|
||||
/** One styled row supplied for the editor's top border. */
|
||||
export interface EditorTopBorderLine {
|
||||
/** Status content with any ANSI styling already applied. */
|
||||
content: string;
|
||||
/** Visible width of the content */
|
||||
/** Visible cell width of {@link content}. */
|
||||
width: number;
|
||||
}
|
||||
|
||||
/** Ordered status rows rendered above the editor input. */
|
||||
export interface EditorTopBorder {
|
||||
/** Styled rows in display order; the first row forms the box top. */
|
||||
lines: readonly EditorTopBorderLine[];
|
||||
}
|
||||
|
||||
interface HistoryEntry {
|
||||
prompt: string;
|
||||
}
|
||||
@@ -839,18 +846,26 @@ export class Editor implements Component, Focusable {
|
||||
// wants the coalesced path; falling back to eager keeps existing
|
||||
// setTopBorder callers working unchanged.
|
||||
const topBorder = this.#topBorderProvider ? this.#topBorderProvider(topFillWidth) : this.#topBorderContent;
|
||||
if (topBorder) {
|
||||
const { content, width: statusWidth } = topBorder;
|
||||
if (statusWidth <= topFillWidth) {
|
||||
// Status fits - add fill after it
|
||||
const fillWidth = topFillWidth - statusWidth;
|
||||
result.push(topLeft + content + this.borderColor(box.horizontal.repeat(fillWidth)) + topRight);
|
||||
} else {
|
||||
// Status too long - truncate it
|
||||
const truncated = truncateToWidth(content, Math.max(0, topFillWidth - 1));
|
||||
const truncatedWidth = visibleWidth(truncated);
|
||||
const fillWidth = Math.max(0, topFillWidth - truncatedWidth);
|
||||
result.push(topLeft + truncated + this.borderColor(box.horizontal.repeat(fillWidth)) + topRight);
|
||||
if (topBorder?.lines.length) {
|
||||
for (let index = 0; index < topBorder.lines.length; index++) {
|
||||
const line = topBorder.lines[index]!;
|
||||
let content = line.content;
|
||||
let contentWidth = line.width;
|
||||
if (contentWidth > topFillWidth) {
|
||||
content = truncateToWidth(content, topFillWidth);
|
||||
contentWidth = visibleWidth(content);
|
||||
}
|
||||
const fillWidth = Math.max(0, topFillWidth - contentWidth);
|
||||
if (index === 0) {
|
||||
result.push(topLeft + content + this.borderColor(box.horizontal.repeat(fillWidth)) + topRight);
|
||||
} else {
|
||||
result.push(
|
||||
this.borderColor(`${box.vertical}${padding(paddingX)}`) +
|
||||
content +
|
||||
padding(fillWidth) +
|
||||
this.borderColor(`${padding(paddingX)}${box.vertical}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result.push(topLeft + horizontal.repeat(topFillWidth) + topRight);
|
||||
|
||||
@@ -21,7 +21,7 @@ import { Editor, type EditorTopBorder } from "@oh-my-pi/pi-tui/components/editor
|
||||
import { defaultEditorTheme } from "./test-themes";
|
||||
|
||||
function stubTopBorder(label: string): EditorTopBorder {
|
||||
return { content: label, width: label.length };
|
||||
return { lines: [{ content: label, width: label.length }] };
|
||||
}
|
||||
|
||||
describe("Editor lazy top-border provider (#4145)", () => {
|
||||
@@ -91,3 +91,21 @@ describe("Editor lazy top-border provider (#4145)", () => {
|
||||
expect(widths[1]).toBe(editor.getTopBorderAvailableWidth(120));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Editor top-border continuation lines", () => {
|
||||
it("frames every status row without truncating later rows", () => {
|
||||
const editor = new Editor(defaultEditorTheme);
|
||||
editor.setTopBorder({
|
||||
lines: [
|
||||
{ content: "PRIMARY", width: 7 },
|
||||
{ content: "CONTINUATION", width: 12 },
|
||||
],
|
||||
});
|
||||
|
||||
const frame = editor.render(24);
|
||||
|
||||
expect(frame[0]).toContain("PRIMARY");
|
||||
expect(frame[1]).toContain("CONTINUATION");
|
||||
expect(frame[1]).toContain(defaultEditorTheme.symbols.boxRound.vertical);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user