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");
});
});