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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user