feat: introduced pluggable composer layouts and status line gauge modes

- Introduced embedded mode and context gauge render options for the status line alongside boundary calculation helpers.
- Added pluggable composer styles with a style registry supporting box, claude, pi, and borderless layout rendering.
- Extracted speculation lead resolution into a dedicated shared module and added theme symbol definitions.
- Expanded test coverage for status line context gauge modes, composer shape previews, and editor style assertions.
This commit is contained in:
can1357
2026-08-20 05:41:42 +02:00
parent aeed1e6195
commit e8d7c0a493
25 changed files with 1113 additions and 284 deletions
+2
View File
@@ -15,6 +15,8 @@
### Changed
- The context-reactive status line gained an Embedded mode that absorbs configured context segments into in-gauge percentage/window labels. Annotated and Embedded gauges use `󰕝` for the async-speculation boundary and `󰁨` for the compaction boundary under the Nerd Font symbol preset; Unicode and ASCII keep their existing boundary ticks.
- Unified inline overlay chrome on the rounded-box style used by the model picker/hub and `/settings`: selectors (theme, thinking, queue mode, show images, login/logout, reset usage, session account, plugins, MCP wizard, history search, branch-from-message, sessions, session tree, debug tools) and the `/cleanse`, `/omfg`, `/btw` run panels now render inside a titled `╭─╮│ │╰─╯` box (new shared `OverlayPanel` container) instead of two full-width horizontal rules.
- `omp cleanse` and the `/cleanse` slash command now render a live interactive status board with running checkers, repair subagents, tool counts, token/cost totals, and live scrollback in both the CLI and interactive terminal modes
- Replaced the single `compaction.strategy` / `compaction.remoteEnabled` policy with ordered `compaction.methodOrder` preferences. The default now tries OpenAI-compatible server compaction, snapcompact, handoff, shake, then soft compaction; unavailable or failed methods advance through that list.
@@ -84,6 +84,9 @@ export type ModelRoleStorage = "global" | "project";
export type ComposerShape = "box" | "claude" | "pi" | "borderless";
export const COMPOSER_SHAPE_VALUES = ["box", "claude", "pi", "borderless"] as const;
export type ContextLineMode = "off" | "percentage" | "annotated" | "embedded";
export const CONTEXT_LINE_MODE_VALUES = ["off", "percentage", "annotated", "embedded"] as const;
export type SettingTab =
| "appearance"
| "model"
@@ -714,6 +717,36 @@ export const SETTINGS_SCHEMA = {
},
},
"statusLine.contextLine": {
type: "enum",
values: CONTEXT_LINE_MODE_VALUES,
default: "annotated",
ui: {
tab: "appearance",
group: "Status Line",
label: "Context-Reactive Line",
description: "How the line between the left and right segments reflects context usage (box composer only)",
options: [
{ value: "off", label: "Off", description: "Solid accent line, no context feedback" },
{
value: "percentage",
label: "Percentage",
description: "Used portion in accent color, remainder dimmed",
},
{
value: "annotated",
label: "Annotated",
description: "Percentage plus ticks at the speculative and auto-compaction boundaries",
},
{
value: "embedded",
label: "Embedded",
description: "Annotated line with the context percentage and window embedded in the gauge",
},
],
},
},
"statusLine.sessionAccent": {
type: "boolean",
default: true,
@@ -1,78 +1,101 @@
import { type Component, padding, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
/**
* Live preview for the `composer.shape` setting and the setup-wizard composer
* scene. Chrome is rendered through the same {@link ComposerStyle} objects the
* real editor uses, and status rows come from the live
* {@link ComposerPreviewStatusSource} (the session's StatusLineComponent) —
* nothing about the preview is a re-implementation, so it cannot drift from
* the real composer. Only the prompt text is a stand-in.
*/
import {
type Component,
type ComposerChromeContext,
type EditorTopBorder,
getComposerStyle,
padding,
truncateToWidth,
visibleWidth,
} from "@oh-my-pi/pi-tui";
import type { ComposerShape } from "../../config/settings-schema";
import { theme } from "../theme/theme";
/**
* Real status renderer the preview borrows rows from — structurally satisfied
* by {@link StatusLineComponent}. Layout is parameterized so a preview can
* render a candidate shape's placement instead of the active one.
*/
export interface ComposerPreviewStatusSource {
/** Powerline bar with the context gauge (box top border content). */
getTopBorder(width: number): { content: string; width: number };
/** Plain right-group chip (claude top rule content). */
getStandaloneTopBorder(width: number): { content: string; width: number };
/** Plain standalone bottom bar carrying the given segment groups. */
renderBottomBar(width: number, groups: "left" | "full"): string;
}
export interface ComposerShapePreviewOptions {
requestRender?: () => void;
/** Live status renderer; omitted (tests), the chrome renders without status rows. */
status?: ComposerPreviewStatusSource;
}
function fitLine(line: string, width: number): string {
const truncated = truncateToWidth(line, width);
return truncated + padding(Math.max(0, width - visibleWidth(truncated)));
}
export function renderMockStatusLine(width: number): string {
const sep = theme.fg("statusLineSep", ` ${theme.sep.powerlineThin} `);
const leftContent = [
theme.fg("statusLineModel", `${theme.icon.model} sonnet`),
theme.fg("statusLinePath", "~/project"),
theme.fg("statusLineGitDirty", `${theme.icon.git} main +2`),
].join(sep);
const rightContent = [
theme.fg("statusLineContext", `${theme.icon.context} 42%`),
theme.fg("statusLineCost", `${theme.icon.cost} 0.18`),
].join(sep);
const bgAnsi = theme.getBgAnsi("statusLineBg");
const isTransparent = bgAnsi === "\x1b[49m" || !bgAnsi;
const capAnsi = isTransparent ? "" : bgAnsi.replace("\x1b[48;", "\x1b[38;");
const leftCap = isTransparent ? "" : `${capAnsi}${theme.sep.powerline}\x1b[39m`;
const rightCap = isTransparent ? "" : `${capAnsi}${theme.sep.powerlineLeft}\x1b[39m`;
const leftGroup = `${theme.bg("statusLineBg", ` ${leftContent} `)}${leftCap}`;
const rightGroup = `${rightCap}${theme.bg("statusLineBg", ` ${rightContent} `)}`;
const leftWidth = visibleWidth(leftGroup);
const rightWidth = visibleWidth(rightGroup);
const gapWidth = Math.max(1, width - leftWidth - rightWidth);
const usedCount = Math.round(0.42 * gapWidth);
const unusedCount = gapWidth - usedCount;
const usedFill = usedCount > 0 ? theme.fg("borderAccent", theme.boxRound.horizontal.repeat(usedCount)) : "";
const unusedFill = unusedCount > 0 ? theme.fg("border", theme.boxRound.horizontal.repeat(unusedCount)) : "";
const gap = `\x1b[49m${usedFill}${unusedFill}\x1b[39m`;
return `${leftGroup}${gap}${rightGroup}`;
}
export function renderComposerShapePreview(shape: ComposerShape, width: number): readonly string[] {
export function renderComposerShapePreview(
shape: ComposerShape,
width: number,
status?: ComposerPreviewStatusSource,
): readonly string[] {
const previewWidth = Math.max(24, Math.min(width, 76));
const box = theme.boxRound;
const innerWidth = Math.max(1, previewWidth - 2);
const promptText = "Ask anything, edit files, run tools";
const style = getComposerStyle(shape);
const paddingX = style.defaultPaddingX(undefined);
const chromeWidth = style.sideChromeWidth(paddingX);
switch (shape) {
case "box": {
const statusContent = renderMockStatusLine(innerWidth);
const top = `${theme.fg("borderAccent", `${box.topLeft}${box.horizontal}`)} ${statusContent} ${theme.fg("borderAccent", `${box.horizontal}${box.topRight}`)}`;
const bottomInner = `${promptText} `;
const bottomFill = box.horizontal.repeat(Math.max(0, innerWidth - visibleWidth(bottomInner) - 2));
const bottom = `${theme.fg("borderAccent", `${box.bottomLeft}${box.horizontal} `)}${theme.fg("text", promptText)}${theme.inverse(" ")}${theme.fg("borderAccent", ` ${bottomFill}${box.bottomRight}`)}`;
return [top, bottom];
}
case "claude": {
const rule = theme.fg("borderAccent", box.horizontal.repeat(previewWidth));
const prompt = `${theme.fg("accent", "❯")} ${theme.fg("text", promptText)}${theme.inverse(" ")}`;
return [rule, prompt, rule, renderMockStatusLine(previewWidth)];
}
case "pi": {
const horizontal = box.horizontal.repeat(innerWidth);
const top = theme.fg("borderAccent", `${box.topLeft}${horizontal}${box.topRight}`);
const prompt = `${theme.fg("accent", ">")} ${theme.fg("text", promptText)}${theme.inverse(" ")}`;
const content = `${theme.fg("borderAccent", box.vertical)} ${fitLine(prompt, innerWidth - 2)} ${theme.fg("borderAccent", box.vertical)}`;
const bottom = theme.fg("borderAccent", `${box.bottomLeft}${horizontal}${box.bottomRight}`);
return [top, content, bottom, renderMockStatusLine(previewWidth)];
}
case "borderless": {
const prompt = `${theme.fg("accent", "❯")} ${theme.fg("text", promptText)}${theme.inverse(" ")}`;
return [prompt, renderMockStatusLine(previewWidth)];
let topBorder: EditorTopBorder | undefined;
if (status) {
if (style.statusAttachment === "top-border") {
const availableWidth = Math.max(1, previewWidth - chromeWidth * 2);
topBorder = status.getTopBorder(availableWidth);
} else if (style.statusAttachment === "top-rule-chip") {
topBorder = status.getStandaloneTopBorder(previewWidth);
}
}
const ctx: ComposerChromeContext = {
width: previewWidth,
paddingX,
borderColor: (str: string) => theme.fg("borderAccent", str),
box: theme.boxRound,
topBorder,
};
const gutter = style.defaultPromptGutter ?? "";
const contentWidth = Math.max(1, previewWidth - chromeWidth * 2 - visibleWidth(gutter));
const promptText = truncateToWidth("Ask anything, edit files, run tools", Math.max(1, contentWidth - 1));
const text = `${theme.fg("text", promptText)}${theme.inverse(" ")}`;
const pad = padding(Math.max(0, contentWidth - visibleWidth(promptText) - 1));
const styledGutter = gutter ? theme.fg("accent", gutter) : "";
const lines: string[] = [];
const top = style.renderTop(ctx);
if (top !== undefined) lines.push(top);
lines.push(
...style.renderRow({
...ctx,
text,
pad,
gutter: styledGutter,
isLastRow: true,
cursorOverflow: 0,
imeSafeCursorTail: false,
scrollbarThumb: false,
}),
);
const bottom = style.renderBottom(ctx);
if (bottom !== undefined) lines.push(bottom);
if (style.bottomBar !== "none" && status) {
const bar = status.renderBottomBar(previewWidth, style.bottomBar);
if (bar) lines.push(bar);
}
return lines;
}
export class ComposerShapePreview implements Component {
@@ -91,7 +114,7 @@ export class ComposerShapePreview implements Component {
}
render(width: number): readonly string[] {
const lines = renderComposerShapePreview(this.#shape, width);
const lines = renderComposerShapePreview(this.#shape, width, this.#options.status);
return ["", theme.fg("muted", "Preview:"), ...lines];
}
}
@@ -36,6 +36,7 @@ import {
} from "../../config/settings";
import type {
ComposerShape,
ContextLineMode,
SettingTab,
StatusLinePreset,
StatusLineSegmentId,
@@ -45,7 +46,7 @@ import { SETTING_TABS, TAB_METADATA } from "../../config/settings-schema";
import { getCurrentThemeName, getSelectListTheme, getSettingsListTheme, theme } from "../../modes/theme/theme";
import { AUTO_THINKING, type ConfiguredThinkingLevel } from "../../thinking";
import { getTabBarTheme } from "../shared";
import { ComposerShapePreview } from "./composer-shape-preview";
import { type ComposerPreviewStatusSource, ComposerShapePreview } from "./composer-shape-preview";
import { bottomBorder, divider, row, topBorder } from "./overlay-box";
import { handleInputOrEscape, PluginSettingsComponent } from "./plugin-settings";
import { getSettingDef, getSettingsForTab, type SettingDef } from "./settings-defs";
@@ -557,11 +558,14 @@ export interface SettingsRuntimeContext {
imageBudget?: ImageBudget;
/** Schedules a re-render after async preview work completes. */
requestRender?: () => void;
/** Live status renderer for composer-shape previews (the session's status line). */
composerPreviewStatus?: ComposerPreviewStatusSource;
}
/** Status line settings subset for preview */
export interface StatusLinePreviewSettings {
preset?: StatusLinePreset;
contextLine?: ContextLineMode;
leftSegments?: StatusLineSegmentId[];
rightSegments?: StatusLineSegmentId[];
separator?: StatusLineSeparatorStyle;
@@ -1118,6 +1122,13 @@ export class SettingsSelectorComponent implements Component {
const separator = settings.get("statusLine.separator");
this.callbacks.onStatusLinePreview?.({ separator });
};
} else if (def.path === "statusLine.contextLine") {
onPreview = value => {
this.callbacks.onStatusLinePreview?.({ contextLine: value as ContextLineMode });
};
onPreviewCancel = () => {
this.callbacks.onStatusLinePreview?.({ contextLine: settings.get("statusLine.contextLine") });
};
} else if (def.path === "snapcompact.shape") {
const shapePreview = new SnapcompactShapePreview(currentValue, {
model: this.context.model,
@@ -1129,6 +1140,7 @@ export class SettingsSelectorComponent implements Component {
} else if (def.path === "composer.shape") {
const shapePreview = new ComposerShapePreview(currentValue as ComposerShape, {
requestRender: this.context.requestRender,
status: this.context.composerPreviewStatus,
});
onPreview = value => shapePreview.setValue(value as ComposerShape);
footer = shapePreview;
@@ -1,8 +1,15 @@
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 { getProjectDir } from "@oh-my-pi/pi-utils";
import {
type Component,
type ComposerStyle,
claudeComposerStyle,
padding,
truncateToWidth,
visibleWidth,
} from "@oh-my-pi/pi-tui";
import { formatNumber, getProjectDir } from "@oh-my-pi/pi-utils";
import { settings } from "../../../config/settings";
import type { AgentSession } from "../../../session/agent-session";
import type { OAuthAccountIdentity } from "../../../session/auth-storage";
@@ -14,6 +21,7 @@ import { getSessionAccentAnsi, getSessionAccentHex } from "../../../utils/sessio
import { calculateTokensPerSecond } from "../../../utils/token-rate";
import { sanitizeStatusText } from "../../shared";
import { theme } from "../../theme/theme";
import { type CompactionBoundaries, computeCompactionBoundaries } from "../../utils/context-usage";
import {
type CodexResetFireworksEvent,
type CodexResetUsageSnapshot,
@@ -268,9 +276,37 @@ const EMPTY_MESSAGES: readonly AgentMessage[] = [];
const STATUS_USAGE_START_DELAY_MS = 0;
const STATUS_USAGE_REFRESH_TIMEOUT_MS = 2_000;
function isContextSegment(segment: StatusLineSegmentId): boolean {
return segment === "context_pct" || segment === "context_total";
}
function hasContextSegment(segments: readonly StatusLineSegmentId[]): boolean {
return segments.includes("context_pct") || segments.includes("context_total");
}
function hasNonContextSegment(segments: readonly StatusLineSegmentId[]): boolean {
for (const segment of segments) {
if (!isContextSegment(segment)) return true;
}
return false;
}
function removeContextSegments(parts: string[], segments: StatusLineSegmentId[]): void {
let writeIndex = 0;
for (let readIndex = 0; readIndex < segments.length; readIndex++) {
const segment = segments[readIndex];
if (isContextSegment(segment)) continue;
parts[writeIndex] = parts[readIndex];
segments[writeIndex] = segment;
writeIndex++;
}
parts.length = writeIndex;
segments.length = writeIndex;
}
function formatEmbeddedContextPercent(percent: number): string {
return `${percent > 0 && percent < 1 ? percent.toFixed(1) : Math.round(percent)}%`;
}
function hasGitSegment(segments: readonly StatusLineSegmentId[]): boolean {
return segments.includes("git");
}
@@ -291,7 +327,8 @@ function hasGitBackedSegment(segments: readonly StatusLineSegmentId[]): boolean
// ═══════════════════════════════════════════════════════════════════════════
export class StatusLineComponent implements Component {
#standalone = false;
#standalone: false | "full" | "left-only" = false;
#autocompleteActiveProbe: (() => boolean) | undefined;
#widthEpochRevision = 0;
#settings: StatusLineSettings = {};
#effectiveSettings: EffectiveStatusLineSettings | undefined;
@@ -430,6 +467,7 @@ export class StatusLineComponent implements Component {
sessionAccent: settings.get("statusLine.sessionAccent"),
transparent: settings.get("statusLine.transparent"),
compactThinkingLevel: settings.get("statusLine.compactThinkingLevel"),
contextLine: settings.get("statusLine.contextLine"),
};
}
#gitEnabled(): boolean {
@@ -1708,8 +1746,19 @@ export class StatusLineComponent implements Component {
return theme.fg("statusLineSubagents", `${theme.icon.agents} ${this.#subagentCount} ${noun}`);
}
#buildStatusLine(width: number): string {
/**
* Build the status bar for one of four layouts:
* - `box`: powerline groups joined by the context-reactive gauge line
* (embedded in the editor's top border).
* - `plain-full`: no background, no powerline caps, dot separators, gap is
* plain spaces — the standalone bottom bar for pi/borderless composers.
* - `plain-left`: left segments only (claude composer; the right group
* lives in the editor's top rule).
* - `plain-right`: right segments only (claude composer's top rule).
*/
#buildStatusLine(width: number, layout: "box" | "plain-full" | "plain-left" | "plain-right" = "box"): string {
const effectiveSettings = this.#resolveSettings();
const plain = layout !== "box";
const includePath =
hasPathSegment(effectiveSettings.leftSegments) || hasPathSegment(effectiveSettings.rightSegments);
const gitEnabled = this.#gitEnabled();
@@ -1725,7 +1774,9 @@ export class StatusLineComponent implements Component {
includeGit,
includePr,
);
const separatorDef = getSeparator(effectiveSettings.separator ?? "powerline-thin", theme);
const separatorDef = plain
? { left: "·", right: "·" }
: getSeparator(effectiveSettings.separator ?? "powerline-thin", theme);
// `transparent` reuses the empty-string sentinel (`\x1b[49m`) so the bar
// inherits the terminal's default background, matching custom themes that
@@ -1734,7 +1785,10 @@ export class StatusLineComponent implements Component {
// stray glyphs, so the cap renderer drops them when the fill is empty.
const TRANSPARENT_BG_ANSI = "\x1b[49m";
const themeBgAnsi = theme.getBgAnsi("statusLineBg");
const bgAnsi = effectiveSettings.transparent ? TRANSPARENT_BG_ANSI : themeBgAnsi;
// Plain bottom bars drop the background entirely; the claude top-rule
// chip (`plain-right`) keeps it so the group reads as a chip on the rule.
const transparentLayout = layout === "plain-full" || layout === "plain-left";
const bgAnsi = transparentLayout || effectiveSettings.transparent ? TRANSPARENT_BG_ANSI : themeBgAnsi;
const transparentBg = bgAnsi === TRANSPARENT_BG_ANSI;
const fgAnsi = theme.getFgAnsi("text");
const sepAnsi = theme.getFgAnsi("statusLineSep");
@@ -1743,7 +1797,8 @@ export class StatusLineComponent implements Component {
// Collect visible segment contents
const leftParts: string[] = [];
const leftSegIds: StatusLineSegmentId[] = [];
for (const segId of effectiveSettings.leftSegments) {
const leftSegmentIds = layout === "plain-right" ? [] : effectiveSettings.leftSegments;
for (const segId of leftSegmentIds) {
if (subagentBadge && segId === "subagents") continue;
const rendered = renderSegment(segId, ctx);
if (rendered.visible && rendered.content) {
@@ -1753,20 +1808,39 @@ export class StatusLineComponent implements Component {
}
const rightParts: string[] = [];
for (const segId of effectiveSettings.rightSegments) {
const rightSegIds: StatusLineSegmentId[] = [];
const rightSegmentIds = layout === "plain-left" ? [] : effectiveSettings.rightSegments;
for (const segId of rightSegmentIds) {
if (subagentBadge && segId === "subagents") continue;
const rendered = renderSegment(segId, ctx);
if (rendered.visible && rendered.content) {
rightParts.push(rendered.content);
rightSegIds.push(segId);
}
}
const runningBackgroundJobs = this.session.getAsyncJobSnapshot()?.running.length ?? 0;
if (runningBackgroundJobs > 0) {
rightParts.unshift(theme.fg("statusLineSubagents", `${theme.icon.job} ${runningBackgroundJobs}`));
const embedContext =
!plain &&
effectiveSettings.contextLine === "embedded" &&
ctx.contextPercent !== null &&
ctx.contextPercent !== undefined &&
ctx.contextWindow > 0 &&
(hasContextSegment(leftSegIds) || hasContextSegment(rightSegIds)) &&
hasNonContextSegment(leftSegIds) &&
hasNonContextSegment(rightSegIds);
if (embedContext) {
removeContextSegments(leftParts, leftSegIds);
removeContextSegments(rightParts, rightSegIds);
}
if (subagentBadge) {
rightParts.unshift(subagentBadge);
if (layout !== "plain-left") {
const runningBackgroundJobs = this.session.getAsyncJobSnapshot()?.running.length ?? 0;
if (runningBackgroundJobs > 0) {
rightParts.unshift(theme.fg("statusLineSubagents", `${theme.icon.job} ${runningBackgroundJobs}`));
}
if (subagentBadge) {
rightParts.unshift(subagentBadge);
}
}
const topFillWidth = Math.max(0, width);
const left = [...leftParts];
@@ -1878,23 +1952,141 @@ export class StatusLineComponent implements Component {
}
const gapWidth = Math.max(1, topFillWidth - leftWidth - rightWidth);
if (plain) {
// Standalone composers: no gauge line between the groups, just air.
return leftGroup + padding(gapWidth) + rightGroup;
}
return leftGroup + this.#buildContextGaugeFill(gapWidth, ctx, effectiveSettings, embedContext) + rightGroup;
}
/**
* The gauge line bridging the left and right groups in box layout. Driven
* by `statusLine.contextLine`:
* - `off`: solid accent line (no context feedback).
* - `percentage`: used portion in accent, remainder in the border color.
* - `annotated` (default): percentage plus preset-aware markers where
* speculative compaction starts and where auto-compaction fires.
* - `embedded`: annotated markers plus percentage/window labels absorbed
* from configured context segments.
*/
#buildContextGaugeFill(
gapWidth: number,
ctx: SegmentContext,
effectiveSettings: EffectiveStatusLineSettings,
embedContext: boolean,
): string {
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("borderAccent");
const unusedColor = theme.getFgAnsi("border");
let usedCount = gapWidth;
if (ctx.contextPercent !== null && ctx.contextPercent !== undefined) {
const clampedPct = Math.min(100, Math.max(0, ctx.contextPercent));
usedCount = Math.min(gapWidth, Math.max(0, Math.round((clampedPct / 100) * gapWidth)));
const usedColor = getSessionAccentAnsi(accentHex) ?? theme.getFgAnsi("borderAccent");
const horizontal = theme.boxRound.horizontal;
const mode = effectiveSettings.contextLine ?? "annotated";
const pct = ctx.contextPercent;
if (mode === "off" || pct === null || pct === undefined) {
return `\x1b[49m${usedColor}${horizontal.repeat(gapWidth)}\x1b[39m`;
}
const clampedPct = Math.min(100, Math.max(0, pct));
let percentLabel = "";
let windowLabel = "";
let percentStart = -1;
let windowStart = -1;
let scaleWidth = gapWidth;
if (embedContext) {
const candidatePercent = formatEmbeddedContextPercent(clampedPct);
const candidateWindow = formatNumber(ctx.contextWindow);
if (gapWidth >= candidatePercent.length + candidateWindow.length + 4) {
percentLabel = candidatePercent;
windowLabel = candidateWindow;
windowStart = gapWidth - windowLabel.length - 1;
scaleWidth = windowStart;
}
}
const usedCount = Math.min(scaleWidth, Math.max(0, Math.round((clampedPct / 100) * scaleWidth)));
const unusedColor = theme.getFgAnsi("border");
// Boundary markers are only meaningful when auto-compaction can fire and
// the line is long enough for the markers to read as positions.
let speculationIdx = -1;
let thresholdIdx = -1;
if ((mode === "annotated" || mode === "embedded") && ctx.autoCompactEnabled && gapWidth >= 8) {
const boundaries = this.#compactionBoundaries(ctx.contextWindow);
if (boundaries) {
const cellFor = (percent: number) =>
Math.min(scaleWidth - 1, Math.max(0, Math.round((percent / 100) * scaleWidth)));
thresholdIdx = cellFor(boundaries.thresholdPercent);
speculationIdx = cellFor(boundaries.speculationPercent);
if (speculationIdx === thresholdIdx) speculationIdx = -1; // threshold wins the cell
}
}
if (percentLabel) {
const maxStart = scaleWidth - percentLabel.length - 1;
const preferredStart = Math.min(maxStart, Math.max(1, usedCount));
const overlapsBoundary = (start: number): boolean => {
const end = start + percentLabel.length;
return (speculationIdx >= start && speculationIdx < end) || (thresholdIdx >= start && thresholdIdx < end);
};
for (let distance = 0; distance <= maxStart; distance++) {
const left = preferredStart - distance;
if (left >= 1 && !overlapsBoundary(left)) {
percentStart = left;
break;
}
if (distance === 0) continue;
const right = preferredStart + distance;
if (right <= maxStart && !overlapsBoundary(right)) {
percentStart = right;
break;
}
}
}
const speculationGlyph = theme.symbol("context.speculation");
const thresholdGlyph = theme.symbol("context.compaction");
const speculationColor = theme.getFgAnsi("muted");
const thresholdColor = theme.getFgAnsi("warning");
let out = "\x1b[49m";
let activeColor = "";
for (let i = 0; i < gapWidth; i++) {
let color = i < usedCount ? usedColor : unusedColor;
let glyph = horizontal;
if (percentStart >= 0 && i >= percentStart && i < percentStart + percentLabel.length) {
color = usedColor;
glyph = percentLabel.charAt(i - percentStart);
} else if (i === thresholdIdx) {
color = thresholdColor;
glyph = thresholdGlyph;
} else if (i === speculationIdx) {
color = speculationColor;
glyph = speculationGlyph;
} else if (windowStart >= 0 && i >= windowStart && i < windowStart + windowLabel.length) {
color = unusedColor;
glyph = windowLabel.charAt(i - windowStart);
}
if (color !== activeColor) {
out += color;
activeColor = color;
}
out += glyph;
}
return `${out}\x1b[39m`;
}
/** Auto-compaction boundary percents, or null when unavailable (disabled, no window). */
#compactionBoundaries(contextWindow: number): CompactionBoundaries | null {
// Collab-guest replicas and test mocks have no session-scoped settings;
// the global store carries the same compaction knobs.
const source = typeof this.session.settings?.getGroup === "function" ? this.session.settings : settings;
try {
return computeCompactionBoundaries(source, contextWindow);
} catch {
return null;
}
const unusedCount = gapWidth - usedCount;
const usedFill = usedCount > 0 ? `${gapColor}${theme.boxRound.horizontal.repeat(usedCount)}` : "";
const unusedFill = unusedCount > 0 ? `${unusedColor}${theme.boxRound.horizontal.repeat(unusedCount)}` : "";
const gapFill = `\x1b[49m${usedFill}${unusedFill}\x1b[39m`;
return leftGroup + gapFill + rightGroup;
}
getTopBorder(width: number): { content: string; width: number; revision: number } {
@@ -1910,20 +2102,89 @@ export class StatusLineComponent implements Component {
revision: this.#widthEpochRevision,
};
}
setStandalone(standalone: boolean): void {
/**
* Standalone bar placement for non-box composer shapes. `"full"` renders
* both groups on the bottom bar (pi/borderless); `"left-only"` renders just
* the left group there — the right group attaches to the editor's top rule
* via {@link getStandaloneTopBorder} (claude). `false` returns the bar to
* the box composer's embedded top border.
*/
setStandalone(standalone: false | "full" | "left-only"): void {
this.#standalone = standalone;
}
/** While true, the standalone bar yields its row to the editor's autocomplete menu. */
setAutocompleteActiveProbe(probe: (() => boolean) | undefined): void {
this.#autocompleteActiveProbe = probe;
}
/** Plain right-group content for the claude composer's top rule. */
getStandaloneTopBorder(width: number): { content: string; width: number; revision: number } {
let content = this.#buildStatusLine(width, "plain-right");
if (this.#focusedAgentId && content) {
content = `\x1b[2m${content.replaceAll("\x1b[0m", "\x1b[0m\x1b[2m")}\x1b[22m`;
}
return {
content,
width: visibleWidth(content),
revision: this.#widthEpochRevision,
};
}
/**
* The plain standalone bottom bar through the real segment/gauge pipeline —
* `groups` picks which segment groups it carries. Used by the live render
* loop and by composer previews (which inject a candidate layout instead of
* the active one).
*/
renderBottomBar(width: number, groups: "left" | "full"): string {
let content = this.#buildStatusLine(width, groups === "left" ? "plain-left" : "plain-full");
if (this.#focusedAgentId && content) {
content = `\x1b[2m${content.replaceAll("\x1b[0m", "\x1b[0m\x1b[2m")}\x1b[22m`;
}
return content;
}
/**
* Status bar lines for a composer layout, rendered through the real
* pipeline — the single source for the /settings appearance preview.
* `style` overrides the layout (candidate composer shape); omitted, the
* active layout is used. Ignores the autocomplete probe: previews always
* render.
*/
getPreviewLines(width: number, style?: Pick<ComposerStyle, "statusAttachment" | "bottomBar">): string[] {
const attachment =
style?.statusAttachment ??
(this.#standalone === false ? "top-border" : this.#standalone === "left-only" ? "top-rule-chip" : "none");
const bottomBar =
style?.bottomBar ?? (this.#standalone === false ? "none" : this.#standalone === "left-only" ? "left" : "full");
const lines: string[] = [];
if (attachment === "top-border") {
const border = this.getTopBorder(width);
if (border.content) lines.push(border.content);
} else if (attachment === "top-rule-chip") {
// Render the chip on its rule exactly as the claude composer does.
const rule = claudeComposerStyle.renderTop({
width,
paddingX: 0,
borderColor: str => theme.fg("border", str),
box: theme.boxRound,
topBorder: this.getStandaloneTopBorder(width),
});
if (rule !== undefined) lines.push(rule);
}
if (bottomBar !== "none") {
const main = this.renderBottomBar(width, bottomBar);
if (main) lines.push(main);
}
return lines;
}
render(width: number): readonly string[] {
const lines: string[] = [];
if (this.#standalone) {
let content = this.#buildStatusLine(width);
if (content) {
if (this.#focusedAgentId) {
content = `\x1b[2m${content.replaceAll("\x1b[0m", "\x1b[0m\x1b[2m")}\x1b[22m`;
}
lines.push(content);
}
if (this.#standalone && !this.#autocompleteActiveProbe?.()) {
const content = this.renderBottomBar(width, this.#standalone === "left-only" ? "left" : "full");
if (content) lines.push(content);
}
const showHooks = this.#settings.showHookStatus ?? true;
if (showHooks && this.#hookStatuses.size > 0) {
@@ -1,10 +1,15 @@
import type { CollabSessionState } from "../../../collab/protocol";
import type { StatusLinePreset, StatusLineSegmentId, StatusLineSeparatorStyle } from "../../../config/settings-schema";
import type {
ContextLineMode,
StatusLinePreset,
StatusLineSegmentId,
StatusLineSeparatorStyle,
} from "../../../config/settings-schema";
import type { AgentSession } from "../../../session/agent-session";
import type { ActiveRepoContext } from "../../../utils/active-repo-context";
import type { LoopLimitRuntime } from "../../loop-limit";
export type { StatusLinePreset, StatusLineSegmentId, StatusLineSeparatorStyle };
export type { ContextLineMode, StatusLinePreset, StatusLineSegmentId, StatusLineSeparatorStyle };
/** Collab session indicator + (guest-only) host-state override for segments. */
export interface CollabStatus {
@@ -35,6 +40,10 @@ export interface StatusLineSettings {
/** Replace the model-segment icon with the thinking-level glyph and drop the
* " · <level>" suffix, so the thinking level reads as a single compact icon. */
compactThinkingLevel?: boolean;
/** How the gap line between the left and right groups reacts to context
* usage. `embedded` moves configured context segments into the annotated
* gauge as percentage and window labels. Box composer only. */
contextLine?: ContextLineMode;
}
export type EffectiveStatusLineSettings = Required<
@@ -202,6 +202,7 @@ export class SelectorController {
model: this.ctx.session.model,
imageBudget: this.ctx.ui.imageBudget,
requestRender: () => this.ctx.ui.requestRender(),
composerPreviewStatus: this.ctx.statusLine,
},
{
onChange: (id, value) => this.handleSettingChange(id, value),
@@ -224,14 +225,16 @@ export class SelectorController {
sessionAccent: settings.get("statusLine.sessionAccent"),
transparent: settings.get("statusLine.transparent"),
compactThinkingLevel: settings.get("statusLine.compactThinkingLevel"),
contextLine: settings.get("statusLine.contextLine"),
...previewSettings,
});
this.ctx.ui.requestRender();
},
getStatusLinePreview: () => {
// Return the rendered status line for inline preview
// The bar exactly as the active composer shape renders it (box top
// border, claude rule + chip, or the plain standalone bottom bar).
const availableWidth = this.ctx.editor.getTopBorderAvailableWidth(this.ctx.ui.terminal.columns);
return this.ctx.statusLine.getTopBorder(availableWidth).content;
return this.ctx.statusLine.getPreviewLines(availableWidth).join("\n");
},
onPluginsChanged: async () => {
const projectPath = await resolveActiveProjectRegistryPath(this.ctx.sessionManager.getCwd());
@@ -253,6 +256,7 @@ export class SelectorController {
sessionAccent: settings.get("statusLine.sessionAccent"),
transparent: settings.get("statusLine.transparent"),
compactThinkingLevel: settings.get("statusLine.compactThinkingLevel"),
contextLine: settings.get("statusLine.contextLine"),
});
this.ctx.ui.requestRender();
},
@@ -26,6 +26,7 @@ import type {
import {
Container,
clearRenderCache,
getComposerStyle,
Loader,
Markdown,
ProcessTerminal,
@@ -1920,19 +1921,29 @@ export class InteractiveMode implements InteractiveModeContext {
transparent: settings.get("statusLine.transparent"),
segmentOptions: settings.get("statusLine.segmentOptions"),
compactThinkingLevel: settings.get("statusLine.compactThinkingLevel"),
contextLine: settings.get("statusLine.contextLine"),
});
}
syncComposerShape(): void {
const shape = (settings.get("composer.shape") as ComposerShape) ?? "box";
const style = getComposerStyle(shape);
this.editor.setBorderStyle(shape);
if (shape === "box") {
this.editor.setTopBorderProvider(availableWidth => this.statusLine.getTopBorder(availableWidth));
this.statusLine.setStandalone(false);
} else {
this.editor.setTopBorderProvider(undefined);
this.editor.setTopBorder(undefined);
this.statusLine.setStandalone(true);
this.statusLine.setAutocompleteActiveProbe(() => this.editor.isAutocompleteActive());
switch (style.statusAttachment) {
case "top-border":
this.editor.setTopBorderProvider(availableWidth => this.statusLine.getTopBorder(availableWidth));
break;
case "top-rule-chip":
this.editor.setTopBorderProvider(availableWidth => this.statusLine.getStandaloneTopBorder(availableWidth));
break;
case "none":
this.editor.setTopBorderProvider(undefined);
this.editor.setTopBorder(undefined);
break;
}
this.statusLine.setStandalone(
style.bottomBar === "none" ? false : style.bottomBar === "left" ? "left-only" : "full",
);
this.updateEditorBorderColor();
this.ui.requestRender();
}
@@ -79,7 +79,7 @@ class ComposerSceneController implements SetupSceneController {
const budget = maxLines ?? Number.POSITIVE_INFINITY;
const lines = [theme.fg("muted", "Select a layout; live preview updates below. Press Enter to confirm."), ""];
const previewLines = renderComposerShapePreview(this.#currentShape, width);
const previewLines = renderComposerShapePreview(this.#currentShape, width, this.host.ctx.statusLine);
if (budget - lines.length - previewLines.length - 2 >= COMPOSER_ITEMS.length) {
lines.push(theme.fg("muted", "Preview:"), ...previewLines, "");
}
@@ -35,6 +35,9 @@ export type SymbolKey =
// Progress Bar
| "progress.filled"
| "progress.empty"
// Context gauge boundaries
| "context.speculation"
| "context.compaction"
// Box Drawing - Rounded
| "boxRound.topLeft"
| "boxRound.topRight"
@@ -248,6 +251,9 @@ const UNICODE_SYMBOLS: SymbolMap = {
// Progress bar
"progress.filled": "━",
"progress.empty": "─",
// Context gauge boundaries
"context.speculation": "╎",
"context.compaction": "┃",
// Box (rounded)
"boxRound.topLeft": "╭",
"boxRound.topRight": "╮",
@@ -483,6 +489,9 @@ const NERD_SYMBOLS: SymbolMap = {
"progress.filled": "━",
// pick: ─ | alt: ▱ ▯ ╌
"progress.empty": "─",
// Context gauge boundaries — vector intersection starts async speculation; auto-fix applies compaction.
"context.speculation": "\u{f055d}",
"context.compaction": "\u{f0068}",
// Box Drawing - Rounded (same as unicode)
// pick: ╭ | alt: ┌ ┏ ╔
"boxRound.topLeft": "╭",
@@ -776,6 +785,9 @@ const ASCII_SYMBOLS: SymbolMap = {
// Progress Bar
"progress.filled": "=",
"progress.empty": "-",
// Context gauge boundaries
"context.speculation": ":",
"context.compaction": "|",
// Box Drawing - Rounded (ASCII fallback)
"boxRound.topLeft": "+",
"boxRound.topRight": "+",
@@ -7,6 +7,7 @@ import { formatNumber } from "@oh-my-pi/pi-utils";
import type { Skill } from "../../extensibility/skills";
import type { AgentSession } from "../../session/agent-session";
import { estimateInlineSavings, type SnapcompactSavingsEstimate } from "../../session/snapcompact-inline";
import { resolveSpeculationLeadTokens } from "../../session/speculation-lead";
import type { Tool } from "../../tools";
import type { theme as Theme } from "../theme/theme";
@@ -41,6 +42,35 @@ export interface ContextBreakdown {
snapcompact?: SnapcompactSavingsEstimate;
}
/** Percent positions (0–100 of the context window) for the auto-compaction boundaries. */
export interface CompactionBoundaries {
/** Where auto-compaction fires. */
thresholdPercent: number;
/** Where the background speculative summarizer starts (threshold − lead). */
speculationPercent: number;
}
/**
* Boundary positions for the status line's annotated context gauge. `null`
* when compaction is disabled/off or the window is unknown — the gauge then
* renders without markers.
*/
export function computeCompactionBoundaries(
settings: AgentSession["settings"],
contextWindow: number,
): CompactionBoundaries | null {
if (!(contextWindow > 0)) return null;
const compactionSettings = settings.getGroup("compaction") as CompactionSettings;
if (!compactionSettings.enabled || compactionSettings.strategy === "off") return null;
const thresholdTokens = resolveThresholdTokens(contextWindow, compactionSettings);
if (!(thresholdTokens > 0) || thresholdTokens > contextWindow) return null;
const leadTokens = resolveSpeculationLeadTokens(thresholdTokens);
return {
thresholdPercent: (thresholdTokens / contextWindow) * 100,
speculationPercent: (Math.max(0, thresholdTokens - leadTokens) / contextWindow) * 100,
};
}
/** Stable inputs used to cache non-message token estimates. */
export interface NonMessageTokenSource {
readonly systemPrompt?: string[];
@@ -85,6 +85,7 @@ import { getLatestCompactionEntry, getOpenAiRemoteCompactionPayload } from "./se
import type { CompactionEntry, SessionEntry } from "./session-entries";
import type { SessionManager } from "./session-manager";
import type { ShakeMode, ShakeResult } from "./shake-types";
import { resolveSpeculationLeadTokens, SPECULATION_LEAD_MIN_TOKENS } from "./speculation-lead";
export type CompactionCheckResult = Readonly<{
deferredHandoff: boolean;
@@ -207,27 +208,6 @@ const PRUNE_IDLE_FLUSH_MS = 90 * 60_000;
*/
const COMPACTION_RECOVERY_BAND = 0.8;
/**
* Speculative-compaction lead: how far below the compaction threshold the
* background summarizer starts. Derived from the threshold instead of a second
* user-facing knob so the band scales with the window — a fixed percentage gap
* would be 200k tokens on a 1M model and useless on a 32k one. The floor keeps
* tiny windows from speculating every turn; the cap bounds how much history the
* armed summary misses (the kept tail grows by at most ~lead tokens between
* compute and apply).
*/
const SPECULATION_LEAD_FRACTION = 0.125;
const SPECULATION_LEAD_MIN_TOKENS = 8_192;
const SPECULATION_LEAD_MAX_TOKENS = 32_000;
/** Tokens the threshold band spans: speculation fires inside `[threshold − lead, threshold)`. */
function resolveSpeculationLeadTokens(thresholdTokens: number): number {
return Math.min(
SPECULATION_LEAD_MAX_TOKENS,
Math.max(SPECULATION_LEAD_MIN_TOKENS, Math.floor(thresholdTokens * SPECULATION_LEAD_FRACTION)),
);
}
/** A speculation-produced compaction result, ready to commit at threshold. */
interface ArmedSpeculation {
result: CompactionResult;
@@ -0,0 +1,24 @@
/**
* Speculative-compaction lead: how far below the compaction threshold the
* background summarizer starts. Derived from the threshold instead of a second
* user-facing knob so the band scales with the window — a fixed percentage gap
* would be 200k tokens on a 1M model and useless on a 32k one. The floor keeps
* tiny windows from speculating every turn; the cap bounds how much history the
* armed summary misses (the kept tail grows by at most ~lead tokens between
* compute and apply).
*
* Shared by the maintenance loop (fire decision) and the status line's
* annotated context gauge (boundary marker position).
*/
const SPECULATION_LEAD_FRACTION = 0.125;
/** Floor of the speculation band; also the armed-summary refresh budget floor. */
export const SPECULATION_LEAD_MIN_TOKENS = 8_192;
const SPECULATION_LEAD_MAX_TOKENS = 32_000;
/** Tokens the threshold band spans: speculation fires inside `[threshold − lead, threshold)`. */
export function resolveSpeculationLeadTokens(thresholdTokens: number): number {
return Math.min(
SPECULATION_LEAD_MAX_TOKENS,
Math.max(SPECULATION_LEAD_MIN_TOKENS, Math.floor(thresholdTokens * SPECULATION_LEAD_FRACTION)),
);
}
@@ -4,7 +4,6 @@ import { COMPOSER_SHAPE_VALUES, type ComposerShape } from "@oh-my-pi/pi-coding-a
import {
ComposerShapePreview,
renderComposerShapePreview,
renderMockStatusLine,
} from "@oh-my-pi/pi-coding-agent/modes/components/composer-shape-preview";
import { SettingsSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/settings-selector";
import { initTheme, setTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
@@ -58,12 +57,38 @@ describe("composer shape preview", () => {
expect(nextLines.some(l => l.includes("Preview:"))).toBe(true);
});
it("renders mock status line with powerline separator and status elements", async () => {
it("borrows status rows from the live status source per shape layout", async () => {
await setTheme("dark");
const rendered = renderMockStatusLine(80);
expect(rendered).toContain("sonnet");
expect(rendered).toContain("~/project");
expect(rendered).toContain("42%");
const calls: string[] = [];
const status = {
getTopBorder: (width: number) => {
calls.push(`top:${width}`);
return { content: "TOPBAR", width: 6 };
},
getStandaloneTopBorder: (width: number) => {
calls.push(`chip:${width}`);
return { content: "CHIP", width: 4 };
},
renderBottomBar: (_width: number, groups: "left" | "full") => {
calls.push(`bottom:${groups}`);
return `BOTTOM-${groups.toUpperCase()}`;
},
};
const box = renderComposerShapePreview("box", 80, status).join("\n");
expect(box).toContain("TOPBAR"); // embedded in the top border
expect(box).not.toContain("BOTTOM"); // box has no standalone bottom bar
const claude = renderComposerShapePreview("claude", 80, status).join("\n");
expect(claude).toContain("CHIP"); // right group chips onto the top rule
expect(claude).toContain("BOTTOM-LEFT"); // left group only on the bottom bar
const pi = renderComposerShapePreview("pi", 80, status).join("\n");
expect(pi).not.toContain("CHIP");
expect(pi).toContain("BOTTOM-FULL"); // both groups on the bottom bar
const borderless = renderComposerShapePreview("borderless", 80, status).join("\n");
expect(borderless).toContain("BOTTOM-FULL");
});
it("renders preview inside SettingsSelectorComponent submenu without crashing", async () => {
@@ -14,10 +14,10 @@
* redraw — that per-event recompute is what previously froze large sessions.
*/
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { resetSettingsForTest, Settings, settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import type { ContextUsage } from "@oh-my-pi/pi-coding-agent/extensibility/extensions/types";
import { StatusLineComponent } from "@oh-my-pi/pi-coding-agent/modes/components/status-line";
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import { initTheme, setSymbolPreset, theme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
beforeAll(async () => {
@@ -40,7 +40,12 @@ interface Fake {
setRevision: (n: number) => void;
}
function makeSession(opts: { messages: unknown[]; contextWindow?: number; usage?: ContextUsage | undefined }): Fake {
function makeSession(opts: {
messages: unknown[];
contextWindow?: number;
usage?: ContextUsage | undefined;
settings?: AgentSession["settings"];
}): Fake {
const contextWindow = opts.contextWindow ?? 200_000;
let usage: ContextUsage | undefined = "usage" in opts ? opts.usage : { tokens: 1234, contextWindow, percent: 0.6 };
let calls = 0;
@@ -53,6 +58,7 @@ function makeSession(opts: { messages: unknown[]; contextWindow?: number; usage?
model: { id: "test-model", contextWindow },
modelRegistry: { isUsingOAuth: () => false },
state: { messages: opts.messages, model: { contextWindow } },
settings: opts.settings,
sessionManager: {
getUsageStatistics: () => ({
input: 0,
@@ -276,7 +282,7 @@ describe("StatusLineComponent context breakdown", () => {
expect(plain).not.toContain("0.0%/0");
});
it("dims the unused portion of the gap fill between left and right segments based on context usage", () => {
it("splits the gap gauge into used (accent) and unused (border) portions", () => {
const { session } = makeSession({
messages: [userMessage("hi"), assistantMessage("done")],
usage: { tokens: 50_000, contextWindow: 100_000, percent: 50 },
@@ -288,25 +294,158 @@ describe("StatusLineComponent context breakdown", () => {
rightSegments: ["session_name"],
separator: "none",
sessionAccent: false,
contextLine: "percentage",
});
const border = comp.getTopBorder(80).content;
// With 50% context usage, the gap fill contains a faint sequence \x1b[2m for the right half
expect(border).toContain("\x1b[2m");
expect(border).toContain("\x1b[22m");
// The gauge resets the background and paints the used half in the accent
// border color, the remainder in the plain border color.
expect(border).toContain("\x1b[49m");
expect(border).toContain(theme.getFgAnsi("borderAccent"));
expect(border).toContain(theme.getFgAnsi("border"));
});
it("renders standalone status line when setStandalone is true", () => {
it("contextLine off renders a solid accent gauge without the unused split", () => {
const { session } = makeSession({
messages: [userMessage("hi"), assistantMessage("done")],
usage: { tokens: 50_000, contextWindow: 100_000, percent: 50 },
});
const comp = new StatusLineComponent(session);
comp.updateSettings({
preset: "custom",
leftSegments: ["pi"],
rightSegments: ["session_name"],
separator: "none",
sessionAccent: false,
contextLine: "off",
});
const border = comp.getTopBorder(80).content;
expect(border).toContain(theme.getFgAnsi("borderAccent"));
expect(border).not.toContain(`${theme.getFgAnsi("border")}─`);
});
it("loads embedded mode on the initial render and absorbs configured context segments", () => {
const { session } = makeSession({
messages: [userMessage("hi"), assistantMessage("done")],
usage: { tokens: 80_000, contextWindow: 1_000_000, percent: 8 },
});
settings.override("statusLine.preset", "custom");
settings.override("statusLine.leftSegments", ["pi", "context_pct"]);
settings.override("statusLine.rightSegments", ["context_total", "session_name"]);
settings.override("statusLine.contextLine", "embedded");
try {
const comp = new StatusLineComponent(session);
const border = comp.getTopBorder(120);
const plain = border.content.replaceAll(/\x1b\[[0-9;]*m/g, "");
const percentIndex = plain.indexOf("8%");
const speculationIndex = plain.indexOf("╎");
const compactionIndex = plain.indexOf("┃");
const windowIndex = plain.indexOf("1M");
expect(border.width).toBe(120);
expect(plain).not.toContain("8.0%/1M");
expect(percentIndex).toBeGreaterThanOrEqual(0);
expect(speculationIndex).toBeGreaterThan(percentIndex);
expect(compactionIndex).toBeGreaterThan(speculationIndex);
expect(windowIndex).toBeGreaterThan(compactionIndex);
expect(plain.indexOf("1M", windowIndex + 1)).toBe(-1);
} finally {
settings.clearOverride("statusLine.contextLine");
settings.clearOverride("statusLine.rightSegments");
settings.clearOverride("statusLine.leftSegments");
settings.clearOverride("statusLine.preset");
}
});
it("uses semantic Nerd Font markers for async speculation and compaction boundaries", async () => {
const { session } = makeSession({
messages: [userMessage("hi"), assistantMessage("done")],
usage: { tokens: 50_000, contextWindow: 100_000, percent: 50 },
settings,
});
const comp = new StatusLineComponent(session);
comp.updateSettings({
preset: "custom",
leftSegments: ["pi"],
rightSegments: ["session_name"],
separator: "none",
sessionAccent: false,
contextLine: "annotated",
});
await setSymbolPreset("nerd");
try {
const nerd = comp.getTopBorder(80).content.replaceAll(/\x1b\[[0-9;]*m/g, "");
const speculationIndex = nerd.indexOf("󰕝");
const compactionIndex = nerd.indexOf("󰁨");
expect(speculationIndex).toBeGreaterThanOrEqual(0);
expect(compactionIndex).toBeGreaterThanOrEqual(0);
expect(speculationIndex).toBeLessThan(compactionIndex);
expect(nerd).not.toContain("╎");
expect(nerd).not.toContain("┃");
await setSymbolPreset("unicode");
const unicode = comp.getTopBorder(80).content.replaceAll(/\x1b\[[0-9;]*m/g, "");
expect(unicode).toContain("╎");
expect(unicode).toContain("┃");
expect(unicode).not.toContain("󰕝");
expect(unicode).not.toContain("󰁨");
} finally {
await initTheme();
}
});
it("standalone mode renders a plain bottom bar without powerline chrome", () => {
const { session } = makeSession({
messages: [userMessage("hi")],
usage: { tokens: 1000, contextWindow: 100_000, percent: 1 },
});
const comp = new StatusLineComponent(session);
expect(comp.render(80)).toHaveLength(0); // Not standalone -> no main status in render()
expect(comp.render(80)).toHaveLength(0); // box mode: main status lives in the editor border
comp.setStandalone(true);
comp.setStandalone("full");
const lines = comp.render(80);
expect(lines).toHaveLength(1); // Standalone -> renders main status line
expect(lines[0]).toContain("pi");
expect(lines).toHaveLength(1);
// Plain bar: transparent background, no powerline caps or bg fill.
expect(lines[0]).not.toContain("\x1b[48;");
expect(lines[0]).toContain("\x1b[49m");
});
it("standalone bar yields to the autocomplete menu via the probe", () => {
const { session } = makeSession({
messages: [userMessage("hi")],
usage: { tokens: 1000, contextWindow: 100_000, percent: 1 },
});
const comp = new StatusLineComponent(session);
comp.setStandalone("full");
let menuOpen = true;
comp.setAutocompleteActiveProbe(() => menuOpen);
expect(comp.render(80)).toHaveLength(0);
menuOpen = false;
expect(comp.render(80)).toHaveLength(1);
});
it("claude layout splits groups: left-only bottom bar, right group as top-rule chip", () => {
const { session } = makeSession({
messages: [userMessage("hi")],
usage: { tokens: 1000, contextWindow: 100_000, percent: 1 },
});
const comp = new StatusLineComponent(session);
comp.updateSettings({
preset: "custom",
leftSegments: ["pi"],
rightSegments: ["session_name"],
separator: "none",
sessionAccent: false,
});
comp.setStandalone("left-only");
const bottom = comp.render(80);
expect(bottom).toHaveLength(1);
expect(bottom[0]).not.toContain("test"); // session name lives in the chip, not the bottom bar
const chip = comp.getStandaloneTopBorder(80);
expect(chip.width).toBeGreaterThan(0);
expect(chip.content).toContain("test");
});
});
@@ -0,0 +1,36 @@
/**
* Chrome-free composer: a bare `❯ ` prompt with no rules or borders. Also the
* effective style whenever a host calls `setBorderVisible(false)` (hook
* editors, agents hub). The status bar renders as a plain standalone bottom
* bar with both segment groups.
*/
import type { ComposerRowContext, ComposerStyle } from "./types";
export const borderlessComposerStyle: ComposerStyle = {
id: "borderless",
sideBorders: false,
verticalChrome: 0,
statusAttachment: "none",
bottomBar: "full",
defaultPromptGutter: "❯ ",
defaultPaddingX(): number {
return 0;
},
sideChromeWidth(): number {
return 0;
},
renderTop(): undefined {
return undefined;
},
renderRow(ctx: ComposerRowContext): string[] {
return [ctx.gutter + ctx.text + ctx.pad];
},
renderBottom(): undefined {
return undefined;
},
};
@@ -0,0 +1,84 @@
/**
* The classic omp composer: rounded frame, status line embedded in the top
* border, and the last content row merged into the bottom border
* (`╰─ text … ─╯`), keeping a one-line prompt at two rows total.
*/
import { padding, truncateToWidth, visibleWidth } from "../../utils";
import type { ComposerChromeContext, ComposerRowContext, ComposerStyle } from "./types";
export const boxComposerStyle: ComposerStyle = {
id: "box",
sideBorders: true,
verticalChrome: 2,
statusAttachment: "top-border",
bottomBar: "none",
defaultPromptGutter: undefined,
defaultPaddingX(themePaddingX: number | undefined): number {
return Math.max(0, themePaddingX ?? 2);
},
sideChromeWidth(paddingX: number): number {
return paddingX + 1;
},
renderTop(ctx: ComposerChromeContext): string {
const { box, paddingX, width, borderColor, topBorder } = ctx;
const topLeft = borderColor(`${box.topLeft}${box.horizontal.repeat(paddingX)}`);
const topRight = borderColor(`${box.horizontal.repeat(paddingX)}${box.topRight}`);
const topFillWidth = Math.max(0, width - this.sideChromeWidth(paddingX) * 2);
if (!topBorder) {
return topLeft + borderColor(box.horizontal.repeat(topFillWidth)) + topRight;
}
const { content, width: statusWidth } = topBorder;
if (statusWidth <= topFillWidth) {
// Status fits - add fill after it
const fillWidth = topFillWidth - statusWidth;
return topLeft + content + borderColor(box.horizontal.repeat(fillWidth)) + topRight;
}
// 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);
return topLeft + truncated + borderColor(box.horizontal.repeat(fillWidth)) + topRight;
},
renderRow(ctx: ComposerRowContext): string[] {
const { box, paddingX, width, borderColor, text, pad, isLastRow } = ctx;
// When the end-of-line cursor glyph (or a wide trailing grapheme) extends
// past the content width, shrink the right chrome by the exact overflow
// count: drop padding spaces first, then the trailing `─`, but never the
// corner/vertical bar itself.
const rightChromeCells = Math.max(1, paddingX + 1 - ctx.cursorOverflow);
if (isLastRow && ctx.imeSafeCursorTail) {
// Terminal frontends render IME marked text locally before committed
// bytes reach the application. Keep the end-of-input cursor row empty
// to its right so insertion cannot shift box chrome onto the next row.
const leftBorder = borderColor(`${box.vertical}${padding(paddingX)}`);
const bottomBorder = borderColor(
`${box.bottomLeft}${box.horizontal.repeat(Math.max(0, width - 2))}${box.bottomRight}`,
);
return [leftBorder + text, bottomBorder];
}
if (isLastRow) {
const bottomLeft = borderColor(`${box.bottomLeft}${box.horizontal}${padding(Math.max(0, paddingX - 1))}`);
const rightPad = Math.max(0, rightChromeCells - 2);
const includeHorizontal = rightChromeCells >= 2;
const bottomRightAdjusted = borderColor(
`${padding(rightPad)}${includeHorizontal ? box.horizontal : ""}${box.bottomRight}`,
);
return [`${bottomLeft}${text}${pad}${bottomRightAdjusted}`];
}
const leftBorder = borderColor(`${box.vertical}${padding(paddingX)}`);
// When the scrollbar is active, replace the right border vertical with a
// thumb glyph (█) inside the thumb range, keeping the track (│) elsewhere.
const rightGlyph = ctx.scrollbarThumb ? "█" : box.vertical;
const rightBorder = borderColor(`${padding(Math.max(0, rightChromeCells - 1))}${rightGlyph}`);
return [leftBorder + text + pad + rightBorder];
},
renderBottom(): undefined {
// The bottom border is merged into the last content row.
return undefined;
},
};
@@ -0,0 +1,43 @@
/**
* Claude Code-like composer: full-width horizontal rules above and below a
* borderless `❯ ` prompt. The right status group rides the top rule as a
* bg chip near the right edge (`─────── hi ─`); the left group renders as a
* plain standalone bottom bar that yields its row to the autocomplete menu.
*/
import type { ComposerChromeContext, ComposerRowContext, ComposerStyle } from "./types";
export const claudeComposerStyle: ComposerStyle = {
id: "claude",
sideBorders: false,
verticalChrome: 2,
statusAttachment: "top-rule-chip",
bottomBar: "left",
defaultPromptGutter: "❯ ",
defaultPaddingX(): number {
return 0;
},
sideChromeWidth(paddingX: number): number {
return paddingX;
},
renderTop(ctx: ComposerChromeContext): string {
const { box, width, borderColor, topBorder } = ctx;
// Attach the status chip near the right edge, one trailing rule cell:
// `─────────────── hi ─`.
if (topBorder && topBorder.width > 0 && topBorder.width <= width - 2) {
const leftFill = Math.max(0, width - topBorder.width - 1);
return borderColor(box.horizontal.repeat(leftFill)) + topBorder.content + borderColor(box.horizontal);
}
return borderColor(box.horizontal.repeat(width));
},
renderRow(ctx: ComposerRowContext): string[] {
return [ctx.gutter + ctx.text + ctx.pad];
},
renderBottom(ctx: ComposerChromeContext): string {
return ctx.borderColor(ctx.box.horizontal.repeat(ctx.width));
},
};
@@ -0,0 +1,6 @@
export * from "./borderless";
export * from "./box";
export * from "./claude";
export * from "./pi";
export * from "./registry";
export * from "./types";
@@ -0,0 +1,36 @@
/**
* Upstream-pi composer: full-width horizontal rules above and below plain
* padded text — no side borders, no prompt gutter. The status bar renders as
* a plain standalone bottom bar with both segment groups.
*/
import { padding } from "../../utils";
import type { ComposerChromeContext, ComposerRowContext, ComposerStyle } from "./types";
export const piComposerStyle: ComposerStyle = {
id: "pi",
sideBorders: false,
verticalChrome: 2,
statusAttachment: "none",
bottomBar: "full",
defaultPromptGutter: undefined,
defaultPaddingX(): number {
return 1;
},
sideChromeWidth(paddingX: number): number {
return paddingX;
},
renderTop(ctx: ComposerChromeContext): string {
return ctx.borderColor(ctx.box.horizontal.repeat(ctx.width));
},
renderRow(ctx: ComposerRowContext): string[] {
return [padding(this.sideChromeWidth(ctx.paddingX)) + ctx.gutter + ctx.text + ctx.pad];
},
renderBottom(ctx: ComposerChromeContext): string {
return ctx.borderColor(ctx.box.horizontal.repeat(ctx.width));
},
};
@@ -0,0 +1,17 @@
import { borderlessComposerStyle } from "./borderless";
import { boxComposerStyle } from "./box";
import { claudeComposerStyle } from "./claude";
import { piComposerStyle } from "./pi";
import type { ComposerStyle, EditorBorderStyle } from "./types";
const COMPOSER_STYLES: Record<EditorBorderStyle, ComposerStyle> = {
box: boxComposerStyle,
claude: claudeComposerStyle,
pi: piComposerStyle,
borderless: borderlessComposerStyle,
};
/** Style object for a composer shape; unknown ids fall back to `box`. */
export function getComposerStyle(id: EditorBorderStyle): ComposerStyle {
return COMPOSER_STYLES[id] ?? boxComposerStyle;
}
@@ -0,0 +1,86 @@
/**
* Composer chrome contract. A {@link ComposerStyle} owns everything about how
* the input editor's frame looks — top/bottom chrome, per-row side chrome,
* default padding and prompt gutter — plus metadata telling the host where the
* status bar attaches. The editor, the /settings preview, and the setup-wizard
* preview all render through the same style object, so the three surfaces can
* never drift apart.
*/
import type { SymbolTheme } from "../../symbols";
/** Box-drawing glyph set used for composer chrome (the theme's `boxRound`). */
export type ComposerBox = SymbolTheme["boxRound"];
/** Available composer shapes. `box` is the classic rounded frame with the
* status line embedded in its top border. */
export type EditorBorderStyle = "box" | "claude" | "pi" | "borderless";
/** Pre-rendered status content injected into the top chrome. */
export interface EditorTopBorder {
/** The status content (already styled) */
content: string;
/** Visible width of the content */
width: number;
/** Optional logical revision that changes independently of available width. */
revision?: number;
}
/** Inputs shared by every chrome row. */
export interface ComposerChromeContext {
/** Full terminal width available to the composer. */
width: number;
/** Horizontal padding inside the side chrome. */
paddingX: number;
borderColor: (str: string) => string;
/** Box-drawing glyph set (theme's `boxRound`). */
box: ComposerBox;
/** Status content for the top chrome; box embeds it after the corner,
* claude chips it against the right edge, other styles ignore it. */
topBorder?: EditorTopBorder;
}
/** Inputs for one content row. */
export interface ComposerRowContext extends ComposerChromeContext {
/** Fully decorated row text (cursor glyph / IME marker included). */
text: string;
/** Spaces padding the text out to the content width. */
pad: string;
/** Prompt gutter cells for this row ("" when the style has none). */
gutter: string;
isLastRow: boolean;
/** Cells the end-of-line cursor overflowed into the right chrome (box). */
cursorOverflow: number;
/** Emit an empty right chrome after the cursor so terminal-local IME
* preedit cannot shift the frame (box last row). */
imeSafeCursorTail: boolean;
/** Row lies inside the right-border scrollbar thumb (box). */
scrollbarThumb: boolean;
}
export interface ComposerStyle {
readonly id: EditorBorderStyle;
/** Content rows carry left/right border glyphs; drives the cursor-reserve
* column, IME-safe layout, and the right-border scrollbar. */
readonly sideBorders: boolean;
/** Rows consumed by top+bottom chrome (drives maxHeight budgeting). */
readonly verticalChrome: 0 | 2;
/** Where the host should attach the status bar: embedded in the top border
* (box), chipped onto the top rule (claude), or detached (rule/borderless
* styles render it as a standalone bottom bar). */
readonly statusAttachment: "top-border" | "top-rule-chip" | "none";
/** Which segment groups the standalone bottom status bar shows. */
readonly bottomBar: "none" | "left" | "full";
/** Default prompt gutter when the host sets none. */
readonly defaultPromptGutter: string | undefined;
/** Default horizontal padding; `themePaddingX` is the theme's request. */
defaultPaddingX(themePaddingX: number | undefined): number;
/** Cells consumed per side on content rows (border glyph + padding). */
sideChromeWidth(paddingX: number): number;
/** Top chrome row; `undefined` renders none. */
renderTop(ctx: ComposerChromeContext): string | undefined;
/** Chrome-wrapped content row; box's IME-safe last row emits two rows. */
renderRow(ctx: ComposerRowContext): string[];
/** Bottom chrome row; `undefined` renders none (box merges the bottom
* border into its last content row). */
renderBottom(ctx: ComposerChromeContext): string | undefined;
}
+83 -129
View File
@@ -23,6 +23,17 @@ import {
truncateToWidth,
visibleWidth,
} from "../utils";
import {
borderlessComposerStyle,
type ComposerChromeContext,
type ComposerStyle,
type EditorBorderStyle,
type EditorTopBorder,
getComposerStyle,
} from "./composer";
export type { EditorBorderStyle, EditorTopBorder };
import { type SelectItem, SelectList, type SelectListLayoutOptions, type SelectListTheme } from "./select-list";
const AUTOCOMPLETE_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
@@ -385,16 +396,6 @@ export interface EditorTheme {
/** Style function for inline hint/ghost text (dim text after cursor) */
hintStyle?: (text: string) => string;
}
export type EditorBorderStyle = "box" | "claude" | "pi" | "borderless";
export interface EditorTopBorder {
/** The status content (already styled) */
content: string;
/** Visible width of the content */
width: number;
/** Optional logical revision that changes independently of available width. */
revision?: number;
}
interface HistoryEntry {
prompt: string;
@@ -592,6 +593,11 @@ export class Editor implements Component, Focusable {
this.#widthEpochRevision++;
}
/** True while the autocomplete/slash-command menu is open below the editor. */
isAutocompleteActive(): boolean {
return this.#autocompleteState !== null;
}
/**
* Get the available width for top border content given a total terminal width.
* Accounts for the border characters and horizontal padding when visible.
@@ -734,28 +740,30 @@ export class Editor implements Component, Focusable {
// No cached state to invalidate currently
}
#isBorderVisible(): boolean {
return this.#borderVisible && this.#borderStyle !== "borderless";
/** Active chrome style; a hidden border collapses every shape to borderless. */
#effectiveStyle(): ComposerStyle {
return this.#borderVisible ? getComposerStyle(this.#borderStyle) : borderlessComposerStyle;
}
#getEffectivePromptGutter(): string | undefined {
const style = this.#effectiveStyle();
// The box frame never renders a gutter; hosts that set one expect it only
// in borderless contexts (hook editors, agents hub).
if (style.sideBorders) return undefined;
if (this.#promptGutter !== undefined) return this.#promptGutter;
if (this.#borderStyle === "claude" || this.#borderStyle === "borderless") return "❯ ";
if (this.#borderStyle === "pi") return "> ";
return undefined;
// Legacy `setBorderVisible(false)` callers control the gutter themselves;
// only an explicitly selected composer shape gets the style default.
if (!this.#borderVisible) return undefined;
return style.defaultPromptGutter;
}
#getEditorPaddingX(): number {
if (this.#paddingXOverride !== undefined) return Math.max(0, this.#paddingXOverride);
if (this.#borderStyle === "claude" || this.#borderStyle === "borderless") return 0;
if (this.#borderStyle === "pi") return 1;
const padding = this.#theme.editorPaddingX ?? 2;
return Math.max(0, padding);
return this.#effectiveStyle().defaultPaddingX(this.#theme.editorPaddingX);
}
#getHorizontalChromeWidth(paddingX: number): number {
if (!this.#isBorderVisible() || this.#borderStyle === "claude") return 0;
return paddingX + 1;
return this.#effectiveStyle().sideChromeWidth(paddingX);
}
#getPromptGutterWidth(width: number, paddingX: number): number {
@@ -788,16 +796,14 @@ export class Editor implements Component, Focusable {
#getLayoutWidth(width: number, paddingX: number): number {
const contentWidth = this.#getContentWidth(width, paddingX);
const isBox = this.#isBorderVisible() && this.#borderStyle !== "claude";
const cursorReserve = isBox && paddingX === 0 ? 1 : 0;
const cursorReserve = this.#effectiveStyle().sideBorders && paddingX === 0 ? 1 : 0;
// Keep cursor/scroll layout addressable even when a borderless prompt gutter consumes every visible column.
return Math.max(1, contentWidth - cursorReserve);
}
#getVisibleContentHeight(contentLines: number): number {
if (this.#maxHeight === undefined) return contentLines;
const verticalChrome = !this.#isBorderVisible() ? 0 : 2;
return Math.max(1, this.#maxHeight - verticalChrome);
return Math.max(1, this.#maxHeight - this.#effectiveStyle().verticalChrome);
}
/** Apply the optional input decorator to a plain (ANSI-free) text segment.
* Decoration only adds zero-width SGR codes, so visible width is unchanged.
@@ -909,22 +915,16 @@ export class Editor implements Component, Focusable {
}
render(width: number): readonly string[] {
const style = this.#effectiveStyle();
const paddingX = this.#getEditorPaddingX();
const borderVisible = this.#isBorderVisible();
const borderStyle = this.#borderStyle;
const isSideBordered = borderVisible && borderStyle !== "claude";
const isSideBordered = style.sideBorders;
const promptGutter = this.#getPromptGutter(width, paddingX);
const contentAreaWidth = this.#getContentWidth(width, paddingX);
const layoutWidth = this.#getLayoutWidth(width, paddingX);
this.#lastLayoutWidth = layoutWidth;
// Box-drawing characters for rounded corners
const box = this.#theme.symbols.boxRound;
const borderWidth = this.#getHorizontalChromeWidth(paddingX);
const topLeft = this.borderColor(`${box.topLeft}${box.horizontal.repeat(paddingX)}`);
const topRight = this.borderColor(`${box.horizontal.repeat(paddingX)}${box.topRight}`);
const bottomLeft = this.borderColor(`${box.bottomLeft}${box.horizontal}${padding(Math.max(0, paddingX - 1))}`);
const horizontal = this.borderColor(box.horizontal);
// Layout the text
const layoutLines = this.#layoutText(layoutWidth);
@@ -950,52 +950,45 @@ export class Editor implements Component, Focusable {
scrollbarThumb = { start, end: start + thumbSize };
}
if (borderVisible) {
if (borderStyle === "claude") {
result.push(this.borderColor(box.horizontal.repeat(width)));
} else if (borderStyle === "pi") {
const topFillWidth = Math.max(0, width - borderWidth * 2);
result.push(topLeft + horizontal.repeat(topFillWidth) + topRight);
// Resolve the custom top-border content once per frame; the style decides
// how (and whether) to draw it. Provider caching stays editor-owned so
// per-event rebuilds keep coalescing to one per painted frame.
const topFillWidth = Math.max(0, width - borderWidth * 2);
let topBorder: EditorTopBorder | undefined;
if (style.statusAttachment !== "none") {
if (this.#topBorderProvider) {
const previousWidth = this.#topBorderProviderWidth;
topBorder = this.#topBorderProvider(topFillWidth);
const signature = topBorder ? `${topBorder.width}\0${topBorder.content}` : "";
const revision = topBorder?.revision;
if (
(previousWidth !== undefined &&
revision !== undefined &&
this.#topBorderProviderRevision !== undefined &&
revision !== this.#topBorderProviderRevision) ||
(previousWidth === topFillWidth && signature !== this.#topBorderProviderSignature)
) {
this.#widthEpochRevision++;
}
this.#topBorderProviderWidth = topFillWidth;
this.#topBorderProviderSignature = signature;
this.#topBorderProviderRevision = revision;
} else {
const topFillWidth = Math.max(0, width - borderWidth * 2);
let topBorder: EditorTopBorder | undefined;
if (this.#topBorderProvider) {
const previousWidth = this.#topBorderProviderWidth;
topBorder = this.#topBorderProvider(topFillWidth);
const signature = topBorder ? `${topBorder.width}\0${topBorder.content}` : "";
const revision = topBorder?.revision;
if (
(previousWidth !== undefined &&
revision !== undefined &&
this.#topBorderProviderRevision !== undefined &&
revision !== this.#topBorderProviderRevision) ||
(previousWidth === topFillWidth && signature !== this.#topBorderProviderSignature)
) {
this.#widthEpochRevision++;
}
this.#topBorderProviderWidth = topFillWidth;
this.#topBorderProviderSignature = signature;
this.#topBorderProviderRevision = revision;
} else {
topBorder = this.#topBorderContent;
}
if (topBorder) {
const { content, width: statusWidth } = topBorder;
if (statusWidth <= topFillWidth) {
const fillWidth = topFillWidth - statusWidth;
result.push(topLeft + content + this.borderColor(box.horizontal.repeat(fillWidth)) + topRight);
} else {
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);
}
} else {
result.push(topLeft + horizontal.repeat(topFillWidth) + topRight);
}
topBorder = this.#topBorderContent;
}
}
const chromeCtx: ComposerChromeContext = {
width,
paddingX,
borderColor: (str: string) => this.borderColor(str),
box,
topBorder,
};
const topRow = style.renderTop(chromeCtx);
if (topRow !== undefined) result.push(topRow);
// Render each layout line
// Keep the hardware cursor at the text insertion point while autocomplete
// rows render below it; terminals use that position to anchor IME candidates.
@@ -1166,62 +1159,23 @@ export class Editor implements Component, Focusable {
const linePad = padding(Math.max(0, lineContentWidth - displayWidth));
if (!isSideBordered) {
result.push(gutterText + displayText + linePad);
continue;
}
const rightChromeCells = Math.max(1, paddingX + 1 - cursorPaddingOverflow);
if (borderStyle === "pi") {
const leftBorder = this.borderColor(`${box.vertical}${padding(paddingX)}`);
const inThumb = scrollbarThumb && visibleIndex >= scrollbarThumb.start && visibleIndex < scrollbarThumb.end;
const rightGlyph = inThumb ? "█" : box.vertical;
const rightBorder = this.borderColor(`${padding(Math.max(0, rightChromeCells - 1))}${rightGlyph}`);
result.push(leftBorder + gutterText + displayText + linePad + rightBorder);
continue;
}
// All lines have consistent borders based on padding. When the end-of-line cursor
// glyph (or a wide trailing grapheme) extends past `lineContentWidth`, shrink the
// right chrome by the exact overflow count: drop padding spaces first, then the
// trailing `─`, but never the corner/vertical bar itself.
const isLastLine = visibleIndex === visibleLayoutLines.length - 1;
if (isLastLine && imeSafeCursorTail) {
const leftBorder = this.borderColor(`${box.vertical}${padding(paddingX)}`);
const bottomBorder = this.borderColor(
`${box.bottomLeft}${box.horizontal.repeat(Math.max(0, width - 2))}${box.bottomRight}`,
);
result.push(leftBorder + displayText);
result.push(bottomBorder);
continue;
}
if (isLastLine) {
const rightPad = Math.max(0, rightChromeCells - 2);
const includeHorizontal = rightChromeCells >= 2;
const bottomRightAdjusted = this.borderColor(
`${padding(rightPad)}${includeHorizontal ? box.horizontal : ""}${box.bottomRight}`,
);
result.push(`${bottomLeft}${displayText}${linePad}${bottomRightAdjusted}`);
} else {
const leftBorder = this.borderColor(`${box.vertical}${padding(paddingX)}`);
// When scrollbar is active, replace the right border vertical with a
// thumb glyph (█) on lines inside the thumb range, keeping the track (│) elsewhere.
const inThumb = scrollbarThumb && visibleIndex >= scrollbarThumb.start && visibleIndex < scrollbarThumb.end;
const rightGlyph = inThumb ? "█" : box.vertical;
const rightBorder = this.borderColor(`${padding(Math.max(0, rightChromeCells - 1))}${rightGlyph}`);
result.push(leftBorder + displayText + linePad + rightBorder);
}
result.push(
...style.renderRow({
...chromeCtx,
text: displayText,
pad: linePad,
gutter: gutterText,
isLastRow: visibleIndex === visibleLayoutLines.length - 1,
cursorOverflow: cursorPaddingOverflow,
imeSafeCursorTail,
scrollbarThumb:
scrollbarThumb !== null && visibleIndex >= scrollbarThumb.start && visibleIndex < scrollbarThumb.end,
}),
);
}
if (borderVisible) {
if (borderStyle === "claude") {
result.push(this.borderColor(box.horizontal.repeat(width)));
} else if (borderStyle === "pi") {
result.push(
this.borderColor(`${box.bottomLeft}${box.horizontal.repeat(Math.max(0, width - 2))}${box.bottomRight}`),
);
}
}
const bottomRow = style.renderBottom(chromeCtx);
if (bottomRow !== undefined) result.push(bottomRow);
// Add autocomplete list if active
if (this.#autocompleteState && this.#autocompleteList) {
+1
View File
@@ -5,6 +5,7 @@ export * from "./autocomplete";
// Components
export * from "./components/box";
export * from "./components/cancellable-loader";
export * from "./components/composer";
export * from "./components/editor";
export * from "./components/image";
export * from "./components/input";
+6 -5
View File
@@ -2960,15 +2960,16 @@ describe("Editor component", () => {
expect(lines[2]).toBe("─".repeat(20));
});
it("renders pi style with framed box, prompt gutter, and separate bottom border", () => {
it("renders pi style with full-width rules, padded content, and no prompt gutter", () => {
const editor = new Editor(unicodeTheme);
editor.setBorderStyle("pi");
editor.setText("hello");
const lines = editor.render(20);
expect(lines.length).toBe(3); // top border, content, bottom border
expect(lines[0]).toBe(`╭${"─".repeat(18)}╮`);
expect(lines[1]).toContain("> hello");
expect(lines[2]).toBe(`╰${"─".repeat(18)}╯`);
expect(lines.length).toBe(3); // top rule, content, bottom rule
expect(lines[0]).toBe("─".repeat(20));
expect(lines[1]).toStartWith(" hello");
expect(lines[1]).not.toContain(">");
expect(lines[2]).toBe("─".repeat(20));
});
it("renders borderless style without box borders", () => {