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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Chrome-free composer: a bare `❯ ` prompt with no rules or borders. Also the
|
||||
* effective style whenever a host calls `setBorderVisible(false)` (hook
|
||||
* editors, agents hub). The status bar renders as a plain standalone bottom
|
||||
* bar with both segment groups.
|
||||
*/
|
||||
import type { ComposerRowContext, ComposerStyle } from "./types";
|
||||
|
||||
export const borderlessComposerStyle: ComposerStyle = {
|
||||
id: "borderless",
|
||||
sideBorders: false,
|
||||
verticalChrome: 0,
|
||||
statusAttachment: "none",
|
||||
bottomBar: "full",
|
||||
defaultPromptGutter: "❯ ",
|
||||
|
||||
defaultPaddingX(): number {
|
||||
return 0;
|
||||
},
|
||||
|
||||
sideChromeWidth(): number {
|
||||
return 0;
|
||||
},
|
||||
|
||||
renderTop(): undefined {
|
||||
return undefined;
|
||||
},
|
||||
|
||||
renderRow(ctx: ComposerRowContext): string[] {
|
||||
return [ctx.gutter + ctx.text + ctx.pad];
|
||||
},
|
||||
|
||||
renderBottom(): undefined {
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* The classic omp composer: rounded frame, status line embedded in the top
|
||||
* border, and the last content row merged into the bottom border
|
||||
* (`╰─ text … ─╯`), keeping a one-line prompt at two rows total.
|
||||
*/
|
||||
import { padding, truncateToWidth, visibleWidth } from "../../utils";
|
||||
import type { ComposerChromeContext, ComposerRowContext, ComposerStyle } from "./types";
|
||||
|
||||
export const boxComposerStyle: ComposerStyle = {
|
||||
id: "box",
|
||||
sideBorders: true,
|
||||
verticalChrome: 2,
|
||||
statusAttachment: "top-border",
|
||||
bottomBar: "none",
|
||||
defaultPromptGutter: undefined,
|
||||
|
||||
defaultPaddingX(themePaddingX: number | undefined): number {
|
||||
return Math.max(0, themePaddingX ?? 2);
|
||||
},
|
||||
|
||||
sideChromeWidth(paddingX: number): number {
|
||||
return paddingX + 1;
|
||||
},
|
||||
|
||||
renderTop(ctx: ComposerChromeContext): string {
|
||||
const { box, paddingX, width, borderColor, topBorder } = ctx;
|
||||
const topLeft = borderColor(`${box.topLeft}${box.horizontal.repeat(paddingX)}`);
|
||||
const topRight = borderColor(`${box.horizontal.repeat(paddingX)}${box.topRight}`);
|
||||
const topFillWidth = Math.max(0, width - this.sideChromeWidth(paddingX) * 2);
|
||||
if (!topBorder) {
|
||||
return topLeft + borderColor(box.horizontal.repeat(topFillWidth)) + topRight;
|
||||
}
|
||||
const { content, width: statusWidth } = topBorder;
|
||||
if (statusWidth <= topFillWidth) {
|
||||
// Status fits - add fill after it
|
||||
const fillWidth = topFillWidth - statusWidth;
|
||||
return topLeft + content + borderColor(box.horizontal.repeat(fillWidth)) + topRight;
|
||||
}
|
||||
// Status too long - truncate it
|
||||
const truncated = truncateToWidth(content, Math.max(0, topFillWidth - 1));
|
||||
const truncatedWidth = visibleWidth(truncated);
|
||||
const fillWidth = Math.max(0, topFillWidth - truncatedWidth);
|
||||
return topLeft + truncated + borderColor(box.horizontal.repeat(fillWidth)) + topRight;
|
||||
},
|
||||
|
||||
renderRow(ctx: ComposerRowContext): string[] {
|
||||
const { box, paddingX, width, borderColor, text, pad, isLastRow } = ctx;
|
||||
// When the end-of-line cursor glyph (or a wide trailing grapheme) extends
|
||||
// past the content width, shrink the right chrome by the exact overflow
|
||||
// count: drop padding spaces first, then the trailing `─`, but never the
|
||||
// corner/vertical bar itself.
|
||||
const rightChromeCells = Math.max(1, paddingX + 1 - ctx.cursorOverflow);
|
||||
if (isLastRow && ctx.imeSafeCursorTail) {
|
||||
// Terminal frontends render IME marked text locally before committed
|
||||
// bytes reach the application. Keep the end-of-input cursor row empty
|
||||
// to its right so insertion cannot shift box chrome onto the next row.
|
||||
const leftBorder = borderColor(`${box.vertical}${padding(paddingX)}`);
|
||||
const bottomBorder = borderColor(
|
||||
`${box.bottomLeft}${box.horizontal.repeat(Math.max(0, width - 2))}${box.bottomRight}`,
|
||||
);
|
||||
return [leftBorder + text, bottomBorder];
|
||||
}
|
||||
if (isLastRow) {
|
||||
const bottomLeft = borderColor(`${box.bottomLeft}${box.horizontal}${padding(Math.max(0, paddingX - 1))}`);
|
||||
const rightPad = Math.max(0, rightChromeCells - 2);
|
||||
const includeHorizontal = rightChromeCells >= 2;
|
||||
const bottomRightAdjusted = borderColor(
|
||||
`${padding(rightPad)}${includeHorizontal ? box.horizontal : ""}${box.bottomRight}`,
|
||||
);
|
||||
return [`${bottomLeft}${text}${pad}${bottomRightAdjusted}`];
|
||||
}
|
||||
const leftBorder = borderColor(`${box.vertical}${padding(paddingX)}`);
|
||||
// When the scrollbar is active, replace the right border vertical with a
|
||||
// thumb glyph (█) inside the thumb range, keeping the track (│) elsewhere.
|
||||
const rightGlyph = ctx.scrollbarThumb ? "█" : box.vertical;
|
||||
const rightBorder = borderColor(`${padding(Math.max(0, rightChromeCells - 1))}${rightGlyph}`);
|
||||
return [leftBorder + text + pad + rightBorder];
|
||||
},
|
||||
|
||||
renderBottom(): undefined {
|
||||
// The bottom border is merged into the last content row.
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Claude Code-like composer: full-width horizontal rules above and below a
|
||||
* borderless `❯ ` prompt. The right status group rides the top rule as a
|
||||
* bg chip near the right edge (`─────── hi ─`); the left group renders as a
|
||||
* plain standalone bottom bar that yields its row to the autocomplete menu.
|
||||
*/
|
||||
import type { ComposerChromeContext, ComposerRowContext, ComposerStyle } from "./types";
|
||||
|
||||
export const claudeComposerStyle: ComposerStyle = {
|
||||
id: "claude",
|
||||
sideBorders: false,
|
||||
verticalChrome: 2,
|
||||
statusAttachment: "top-rule-chip",
|
||||
bottomBar: "left",
|
||||
defaultPromptGutter: "❯ ",
|
||||
|
||||
defaultPaddingX(): number {
|
||||
return 0;
|
||||
},
|
||||
|
||||
sideChromeWidth(paddingX: number): number {
|
||||
return paddingX;
|
||||
},
|
||||
|
||||
renderTop(ctx: ComposerChromeContext): string {
|
||||
const { box, width, borderColor, topBorder } = ctx;
|
||||
// Attach the status chip near the right edge, one trailing rule cell:
|
||||
// `─────────────── hi ─`.
|
||||
if (topBorder && topBorder.width > 0 && topBorder.width <= width - 2) {
|
||||
const leftFill = Math.max(0, width - topBorder.width - 1);
|
||||
return borderColor(box.horizontal.repeat(leftFill)) + topBorder.content + borderColor(box.horizontal);
|
||||
}
|
||||
return borderColor(box.horizontal.repeat(width));
|
||||
},
|
||||
|
||||
renderRow(ctx: ComposerRowContext): string[] {
|
||||
return [ctx.gutter + ctx.text + ctx.pad];
|
||||
},
|
||||
|
||||
renderBottom(ctx: ComposerChromeContext): string {
|
||||
return ctx.borderColor(ctx.box.horizontal.repeat(ctx.width));
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "./borderless";
|
||||
export * from "./box";
|
||||
export * from "./claude";
|
||||
export * from "./pi";
|
||||
export * from "./registry";
|
||||
export * from "./types";
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Upstream-pi composer: full-width horizontal rules above and below plain
|
||||
* padded text — no side borders, no prompt gutter. The status bar renders as
|
||||
* a plain standalone bottom bar with both segment groups.
|
||||
*/
|
||||
import { padding } from "../../utils";
|
||||
import type { ComposerChromeContext, ComposerRowContext, ComposerStyle } from "./types";
|
||||
|
||||
export const piComposerStyle: ComposerStyle = {
|
||||
id: "pi",
|
||||
sideBorders: false,
|
||||
verticalChrome: 2,
|
||||
statusAttachment: "none",
|
||||
bottomBar: "full",
|
||||
defaultPromptGutter: undefined,
|
||||
|
||||
defaultPaddingX(): number {
|
||||
return 1;
|
||||
},
|
||||
|
||||
sideChromeWidth(paddingX: number): number {
|
||||
return paddingX;
|
||||
},
|
||||
|
||||
renderTop(ctx: ComposerChromeContext): string {
|
||||
return ctx.borderColor(ctx.box.horizontal.repeat(ctx.width));
|
||||
},
|
||||
|
||||
renderRow(ctx: ComposerRowContext): string[] {
|
||||
return [padding(this.sideChromeWidth(ctx.paddingX)) + ctx.gutter + ctx.text + ctx.pad];
|
||||
},
|
||||
|
||||
renderBottom(ctx: ComposerChromeContext): string {
|
||||
return ctx.borderColor(ctx.box.horizontal.repeat(ctx.width));
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { borderlessComposerStyle } from "./borderless";
|
||||
import { boxComposerStyle } from "./box";
|
||||
import { claudeComposerStyle } from "./claude";
|
||||
import { piComposerStyle } from "./pi";
|
||||
import type { ComposerStyle, EditorBorderStyle } from "./types";
|
||||
|
||||
const COMPOSER_STYLES: Record<EditorBorderStyle, ComposerStyle> = {
|
||||
box: boxComposerStyle,
|
||||
claude: claudeComposerStyle,
|
||||
pi: piComposerStyle,
|
||||
borderless: borderlessComposerStyle,
|
||||
};
|
||||
|
||||
/** Style object for a composer shape; unknown ids fall back to `box`. */
|
||||
export function getComposerStyle(id: EditorBorderStyle): ComposerStyle {
|
||||
return COMPOSER_STYLES[id] ?? boxComposerStyle;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Composer chrome contract. A {@link ComposerStyle} owns everything about how
|
||||
* the input editor's frame looks — top/bottom chrome, per-row side chrome,
|
||||
* default padding and prompt gutter — plus metadata telling the host where the
|
||||
* status bar attaches. The editor, the /settings preview, and the setup-wizard
|
||||
* preview all render through the same style object, so the three surfaces can
|
||||
* never drift apart.
|
||||
*/
|
||||
import type { SymbolTheme } from "../../symbols";
|
||||
|
||||
/** Box-drawing glyph set used for composer chrome (the theme's `boxRound`). */
|
||||
export type ComposerBox = SymbolTheme["boxRound"];
|
||||
|
||||
/** Available composer shapes. `box` is the classic rounded frame with the
|
||||
* status line embedded in its top border. */
|
||||
export type EditorBorderStyle = "box" | "claude" | "pi" | "borderless";
|
||||
|
||||
/** Pre-rendered status content injected into the top chrome. */
|
||||
export interface EditorTopBorder {
|
||||
/** The status content (already styled) */
|
||||
content: string;
|
||||
/** Visible width of the content */
|
||||
width: number;
|
||||
/** Optional logical revision that changes independently of available width. */
|
||||
revision?: number;
|
||||
}
|
||||
|
||||
/** Inputs shared by every chrome row. */
|
||||
export interface ComposerChromeContext {
|
||||
/** Full terminal width available to the composer. */
|
||||
width: number;
|
||||
/** Horizontal padding inside the side chrome. */
|
||||
paddingX: number;
|
||||
borderColor: (str: string) => string;
|
||||
/** Box-drawing glyph set (theme's `boxRound`). */
|
||||
box: ComposerBox;
|
||||
/** Status content for the top chrome; box embeds it after the corner,
|
||||
* claude chips it against the right edge, other styles ignore it. */
|
||||
topBorder?: EditorTopBorder;
|
||||
}
|
||||
|
||||
/** Inputs for one content row. */
|
||||
export interface ComposerRowContext extends ComposerChromeContext {
|
||||
/** Fully decorated row text (cursor glyph / IME marker included). */
|
||||
text: string;
|
||||
/** Spaces padding the text out to the content width. */
|
||||
pad: string;
|
||||
/** Prompt gutter cells for this row ("" when the style has none). */
|
||||
gutter: string;
|
||||
isLastRow: boolean;
|
||||
/** Cells the end-of-line cursor overflowed into the right chrome (box). */
|
||||
cursorOverflow: number;
|
||||
/** Emit an empty right chrome after the cursor so terminal-local IME
|
||||
* preedit cannot shift the frame (box last row). */
|
||||
imeSafeCursorTail: boolean;
|
||||
/** Row lies inside the right-border scrollbar thumb (box). */
|
||||
scrollbarThumb: boolean;
|
||||
}
|
||||
|
||||
export interface ComposerStyle {
|
||||
readonly id: EditorBorderStyle;
|
||||
/** Content rows carry left/right border glyphs; drives the cursor-reserve
|
||||
* column, IME-safe layout, and the right-border scrollbar. */
|
||||
readonly sideBorders: boolean;
|
||||
/** Rows consumed by top+bottom chrome (drives maxHeight budgeting). */
|
||||
readonly verticalChrome: 0 | 2;
|
||||
/** Where the host should attach the status bar: embedded in the top border
|
||||
* (box), chipped onto the top rule (claude), or detached (rule/borderless
|
||||
* styles render it as a standalone bottom bar). */
|
||||
readonly statusAttachment: "top-border" | "top-rule-chip" | "none";
|
||||
/** Which segment groups the standalone bottom status bar shows. */
|
||||
readonly bottomBar: "none" | "left" | "full";
|
||||
/** Default prompt gutter when the host sets none. */
|
||||
readonly defaultPromptGutter: string | undefined;
|
||||
/** Default horizontal padding; `themePaddingX` is the theme's request. */
|
||||
defaultPaddingX(themePaddingX: number | undefined): number;
|
||||
/** Cells consumed per side on content rows (border glyph + padding). */
|
||||
sideChromeWidth(paddingX: number): number;
|
||||
/** Top chrome row; `undefined` renders none. */
|
||||
renderTop(ctx: ComposerChromeContext): string | undefined;
|
||||
/** Chrome-wrapped content row; box's IME-safe last row emits two rows. */
|
||||
renderRow(ctx: ComposerRowContext): string[];
|
||||
/** Bottom chrome row; `undefined` renders none (box merges the bottom
|
||||
* border into its last content row). */
|
||||
renderBottom(ctx: ComposerChromeContext): string | undefined;
|
||||
}
|
||||
@@ -23,6 +23,17 @@ import {
|
||||
truncateToWidth,
|
||||
visibleWidth,
|
||||
} from "../utils";
|
||||
import {
|
||||
borderlessComposerStyle,
|
||||
type ComposerChromeContext,
|
||||
type ComposerStyle,
|
||||
type EditorBorderStyle,
|
||||
type EditorTopBorder,
|
||||
getComposerStyle,
|
||||
} from "./composer";
|
||||
|
||||
export type { EditorBorderStyle, EditorTopBorder };
|
||||
|
||||
import { type SelectItem, SelectList, type SelectListLayoutOptions, type SelectListTheme } from "./select-list";
|
||||
|
||||
const AUTOCOMPLETE_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
|
||||
@@ -385,16 +396,6 @@ export interface EditorTheme {
|
||||
/** Style function for inline hint/ghost text (dim text after cursor) */
|
||||
hintStyle?: (text: string) => string;
|
||||
}
|
||||
export type EditorBorderStyle = "box" | "claude" | "pi" | "borderless";
|
||||
|
||||
export interface EditorTopBorder {
|
||||
/** The status content (already styled) */
|
||||
content: string;
|
||||
/** Visible width of the content */
|
||||
width: number;
|
||||
/** Optional logical revision that changes independently of available width. */
|
||||
revision?: number;
|
||||
}
|
||||
|
||||
interface HistoryEntry {
|
||||
prompt: string;
|
||||
@@ -592,6 +593,11 @@ export class Editor implements Component, Focusable {
|
||||
this.#widthEpochRevision++;
|
||||
}
|
||||
|
||||
/** True while the autocomplete/slash-command menu is open below the editor. */
|
||||
isAutocompleteActive(): boolean {
|
||||
return this.#autocompleteState !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the available width for top border content given a total terminal width.
|
||||
* Accounts for the border characters and horizontal padding when visible.
|
||||
@@ -734,28 +740,30 @@ export class Editor implements Component, Focusable {
|
||||
// No cached state to invalidate currently
|
||||
}
|
||||
|
||||
#isBorderVisible(): boolean {
|
||||
return this.#borderVisible && this.#borderStyle !== "borderless";
|
||||
/** Active chrome style; a hidden border collapses every shape to borderless. */
|
||||
#effectiveStyle(): ComposerStyle {
|
||||
return this.#borderVisible ? getComposerStyle(this.#borderStyle) : borderlessComposerStyle;
|
||||
}
|
||||
|
||||
#getEffectivePromptGutter(): string | undefined {
|
||||
const style = this.#effectiveStyle();
|
||||
// The box frame never renders a gutter; hosts that set one expect it only
|
||||
// in borderless contexts (hook editors, agents hub).
|
||||
if (style.sideBorders) return undefined;
|
||||
if (this.#promptGutter !== undefined) return this.#promptGutter;
|
||||
if (this.#borderStyle === "claude" || this.#borderStyle === "borderless") return "❯ ";
|
||||
if (this.#borderStyle === "pi") return "> ";
|
||||
return undefined;
|
||||
// Legacy `setBorderVisible(false)` callers control the gutter themselves;
|
||||
// only an explicitly selected composer shape gets the style default.
|
||||
if (!this.#borderVisible) return undefined;
|
||||
return style.defaultPromptGutter;
|
||||
}
|
||||
|
||||
#getEditorPaddingX(): number {
|
||||
if (this.#paddingXOverride !== undefined) return Math.max(0, this.#paddingXOverride);
|
||||
if (this.#borderStyle === "claude" || this.#borderStyle === "borderless") return 0;
|
||||
if (this.#borderStyle === "pi") return 1;
|
||||
const padding = this.#theme.editorPaddingX ?? 2;
|
||||
return Math.max(0, padding);
|
||||
return this.#effectiveStyle().defaultPaddingX(this.#theme.editorPaddingX);
|
||||
}
|
||||
|
||||
#getHorizontalChromeWidth(paddingX: number): number {
|
||||
if (!this.#isBorderVisible() || this.#borderStyle === "claude") return 0;
|
||||
return paddingX + 1;
|
||||
return this.#effectiveStyle().sideChromeWidth(paddingX);
|
||||
}
|
||||
|
||||
#getPromptGutterWidth(width: number, paddingX: number): number {
|
||||
@@ -788,16 +796,14 @@ export class Editor implements Component, Focusable {
|
||||
|
||||
#getLayoutWidth(width: number, paddingX: number): number {
|
||||
const contentWidth = this.#getContentWidth(width, paddingX);
|
||||
const isBox = this.#isBorderVisible() && this.#borderStyle !== "claude";
|
||||
const cursorReserve = isBox && paddingX === 0 ? 1 : 0;
|
||||
const cursorReserve = this.#effectiveStyle().sideBorders && paddingX === 0 ? 1 : 0;
|
||||
// Keep cursor/scroll layout addressable even when a borderless prompt gutter consumes every visible column.
|
||||
return Math.max(1, contentWidth - cursorReserve);
|
||||
}
|
||||
|
||||
#getVisibleContentHeight(contentLines: number): number {
|
||||
if (this.#maxHeight === undefined) return contentLines;
|
||||
const verticalChrome = !this.#isBorderVisible() ? 0 : 2;
|
||||
return Math.max(1, this.#maxHeight - verticalChrome);
|
||||
return Math.max(1, this.#maxHeight - this.#effectiveStyle().verticalChrome);
|
||||
}
|
||||
/** Apply the optional input decorator to a plain (ANSI-free) text segment.
|
||||
* Decoration only adds zero-width SGR codes, so visible width is unchanged.
|
||||
@@ -909,22 +915,16 @@ export class Editor implements Component, Focusable {
|
||||
}
|
||||
|
||||
render(width: number): readonly string[] {
|
||||
const style = this.#effectiveStyle();
|
||||
const paddingX = this.#getEditorPaddingX();
|
||||
const borderVisible = this.#isBorderVisible();
|
||||
const borderStyle = this.#borderStyle;
|
||||
const isSideBordered = borderVisible && borderStyle !== "claude";
|
||||
const isSideBordered = style.sideBorders;
|
||||
const promptGutter = this.#getPromptGutter(width, paddingX);
|
||||
const contentAreaWidth = this.#getContentWidth(width, paddingX);
|
||||
const layoutWidth = this.#getLayoutWidth(width, paddingX);
|
||||
this.#lastLayoutWidth = layoutWidth;
|
||||
|
||||
// Box-drawing characters for rounded corners
|
||||
const box = this.#theme.symbols.boxRound;
|
||||
const borderWidth = this.#getHorizontalChromeWidth(paddingX);
|
||||
const topLeft = this.borderColor(`${box.topLeft}${box.horizontal.repeat(paddingX)}`);
|
||||
const topRight = this.borderColor(`${box.horizontal.repeat(paddingX)}${box.topRight}`);
|
||||
const bottomLeft = this.borderColor(`${box.bottomLeft}${box.horizontal}${padding(Math.max(0, paddingX - 1))}`);
|
||||
const horizontal = this.borderColor(box.horizontal);
|
||||
|
||||
// Layout the text
|
||||
const layoutLines = this.#layoutText(layoutWidth);
|
||||
@@ -950,52 +950,45 @@ export class Editor implements Component, Focusable {
|
||||
scrollbarThumb = { start, end: start + thumbSize };
|
||||
}
|
||||
|
||||
if (borderVisible) {
|
||||
if (borderStyle === "claude") {
|
||||
result.push(this.borderColor(box.horizontal.repeat(width)));
|
||||
} else if (borderStyle === "pi") {
|
||||
const topFillWidth = Math.max(0, width - borderWidth * 2);
|
||||
result.push(topLeft + horizontal.repeat(topFillWidth) + topRight);
|
||||
// Resolve the custom top-border content once per frame; the style decides
|
||||
// how (and whether) to draw it. Provider caching stays editor-owned so
|
||||
// per-event rebuilds keep coalescing to one per painted frame.
|
||||
const topFillWidth = Math.max(0, width - borderWidth * 2);
|
||||
let topBorder: EditorTopBorder | undefined;
|
||||
if (style.statusAttachment !== "none") {
|
||||
if (this.#topBorderProvider) {
|
||||
const previousWidth = this.#topBorderProviderWidth;
|
||||
topBorder = this.#topBorderProvider(topFillWidth);
|
||||
const signature = topBorder ? `${topBorder.width}\0${topBorder.content}` : "";
|
||||
const revision = topBorder?.revision;
|
||||
if (
|
||||
(previousWidth !== undefined &&
|
||||
revision !== undefined &&
|
||||
this.#topBorderProviderRevision !== undefined &&
|
||||
revision !== this.#topBorderProviderRevision) ||
|
||||
(previousWidth === topFillWidth && signature !== this.#topBorderProviderSignature)
|
||||
) {
|
||||
this.#widthEpochRevision++;
|
||||
}
|
||||
this.#topBorderProviderWidth = topFillWidth;
|
||||
this.#topBorderProviderSignature = signature;
|
||||
this.#topBorderProviderRevision = revision;
|
||||
} else {
|
||||
const topFillWidth = Math.max(0, width - borderWidth * 2);
|
||||
let topBorder: EditorTopBorder | undefined;
|
||||
if (this.#topBorderProvider) {
|
||||
const previousWidth = this.#topBorderProviderWidth;
|
||||
topBorder = this.#topBorderProvider(topFillWidth);
|
||||
const signature = topBorder ? `${topBorder.width}\0${topBorder.content}` : "";
|
||||
const revision = topBorder?.revision;
|
||||
if (
|
||||
(previousWidth !== undefined &&
|
||||
revision !== undefined &&
|
||||
this.#topBorderProviderRevision !== undefined &&
|
||||
revision !== this.#topBorderProviderRevision) ||
|
||||
(previousWidth === topFillWidth && signature !== this.#topBorderProviderSignature)
|
||||
) {
|
||||
this.#widthEpochRevision++;
|
||||
}
|
||||
this.#topBorderProviderWidth = topFillWidth;
|
||||
this.#topBorderProviderSignature = signature;
|
||||
this.#topBorderProviderRevision = revision;
|
||||
} else {
|
||||
topBorder = this.#topBorderContent;
|
||||
}
|
||||
if (topBorder) {
|
||||
const { content, width: statusWidth } = topBorder;
|
||||
if (statusWidth <= topFillWidth) {
|
||||
const fillWidth = topFillWidth - statusWidth;
|
||||
result.push(topLeft + content + this.borderColor(box.horizontal.repeat(fillWidth)) + topRight);
|
||||
} else {
|
||||
const truncated = truncateToWidth(content, Math.max(0, topFillWidth - 1));
|
||||
const truncatedWidth = visibleWidth(truncated);
|
||||
const fillWidth = Math.max(0, topFillWidth - truncatedWidth);
|
||||
result.push(topLeft + truncated + this.borderColor(box.horizontal.repeat(fillWidth)) + topRight);
|
||||
}
|
||||
} else {
|
||||
result.push(topLeft + horizontal.repeat(topFillWidth) + topRight);
|
||||
}
|
||||
topBorder = this.#topBorderContent;
|
||||
}
|
||||
}
|
||||
|
||||
const chromeCtx: ComposerChromeContext = {
|
||||
width,
|
||||
paddingX,
|
||||
borderColor: (str: string) => this.borderColor(str),
|
||||
box,
|
||||
topBorder,
|
||||
};
|
||||
|
||||
const topRow = style.renderTop(chromeCtx);
|
||||
if (topRow !== undefined) result.push(topRow);
|
||||
|
||||
// Render each layout line
|
||||
// Keep the hardware cursor at the text insertion point while autocomplete
|
||||
// rows render below it; terminals use that position to anchor IME candidates.
|
||||
@@ -1166,62 +1159,23 @@ export class Editor implements Component, Focusable {
|
||||
|
||||
const linePad = padding(Math.max(0, lineContentWidth - displayWidth));
|
||||
|
||||
if (!isSideBordered) {
|
||||
result.push(gutterText + displayText + linePad);
|
||||
continue;
|
||||
}
|
||||
|
||||
const rightChromeCells = Math.max(1, paddingX + 1 - cursorPaddingOverflow);
|
||||
if (borderStyle === "pi") {
|
||||
const leftBorder = this.borderColor(`${box.vertical}${padding(paddingX)}`);
|
||||
const inThumb = scrollbarThumb && visibleIndex >= scrollbarThumb.start && visibleIndex < scrollbarThumb.end;
|
||||
const rightGlyph = inThumb ? "█" : box.vertical;
|
||||
const rightBorder = this.borderColor(`${padding(Math.max(0, rightChromeCells - 1))}${rightGlyph}`);
|
||||
result.push(leftBorder + gutterText + displayText + linePad + rightBorder);
|
||||
continue;
|
||||
}
|
||||
|
||||
// All lines have consistent borders based on padding. When the end-of-line cursor
|
||||
// glyph (or a wide trailing grapheme) extends past `lineContentWidth`, shrink the
|
||||
// right chrome by the exact overflow count: drop padding spaces first, then the
|
||||
// trailing `─`, but never the corner/vertical bar itself.
|
||||
const isLastLine = visibleIndex === visibleLayoutLines.length - 1;
|
||||
if (isLastLine && imeSafeCursorTail) {
|
||||
const leftBorder = this.borderColor(`${box.vertical}${padding(paddingX)}`);
|
||||
const bottomBorder = this.borderColor(
|
||||
`${box.bottomLeft}${box.horizontal.repeat(Math.max(0, width - 2))}${box.bottomRight}`,
|
||||
);
|
||||
result.push(leftBorder + displayText);
|
||||
result.push(bottomBorder);
|
||||
continue;
|
||||
}
|
||||
if (isLastLine) {
|
||||
const rightPad = Math.max(0, rightChromeCells - 2);
|
||||
const includeHorizontal = rightChromeCells >= 2;
|
||||
const bottomRightAdjusted = this.borderColor(
|
||||
`${padding(rightPad)}${includeHorizontal ? box.horizontal : ""}${box.bottomRight}`,
|
||||
);
|
||||
result.push(`${bottomLeft}${displayText}${linePad}${bottomRightAdjusted}`);
|
||||
} else {
|
||||
const leftBorder = this.borderColor(`${box.vertical}${padding(paddingX)}`);
|
||||
// When scrollbar is active, replace the right border vertical with a
|
||||
// thumb glyph (█) on lines inside the thumb range, keeping the track (│) elsewhere.
|
||||
const inThumb = scrollbarThumb && visibleIndex >= scrollbarThumb.start && visibleIndex < scrollbarThumb.end;
|
||||
const rightGlyph = inThumb ? "█" : box.vertical;
|
||||
const rightBorder = this.borderColor(`${padding(Math.max(0, rightChromeCells - 1))}${rightGlyph}`);
|
||||
result.push(leftBorder + displayText + linePad + rightBorder);
|
||||
}
|
||||
result.push(
|
||||
...style.renderRow({
|
||||
...chromeCtx,
|
||||
text: displayText,
|
||||
pad: linePad,
|
||||
gutter: gutterText,
|
||||
isLastRow: visibleIndex === visibleLayoutLines.length - 1,
|
||||
cursorOverflow: cursorPaddingOverflow,
|
||||
imeSafeCursorTail,
|
||||
scrollbarThumb:
|
||||
scrollbarThumb !== null && visibleIndex >= scrollbarThumb.start && visibleIndex < scrollbarThumb.end,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (borderVisible) {
|
||||
if (borderStyle === "claude") {
|
||||
result.push(this.borderColor(box.horizontal.repeat(width)));
|
||||
} else if (borderStyle === "pi") {
|
||||
result.push(
|
||||
this.borderColor(`${box.bottomLeft}${box.horizontal.repeat(Math.max(0, width - 2))}${box.bottomRight}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
const bottomRow = style.renderBottom(chromeCtx);
|
||||
if (bottomRow !== undefined) result.push(bottomRow);
|
||||
|
||||
// Add autocomplete list if active
|
||||
if (this.#autocompleteState && this.#autocompleteList) {
|
||||
|
||||
@@ -5,6 +5,7 @@ export * from "./autocomplete";
|
||||
// Components
|
||||
export * from "./components/box";
|
||||
export * from "./components/cancellable-loader";
|
||||
export * from "./components/composer";
|
||||
export * from "./components/editor";
|
||||
export * from "./components/image";
|
||||
export * from "./components/input";
|
||||
|
||||
@@ -2960,15 +2960,16 @@ describe("Editor component", () => {
|
||||
expect(lines[2]).toBe("─".repeat(20));
|
||||
});
|
||||
|
||||
it("renders pi style with framed box, prompt gutter, and separate bottom border", () => {
|
||||
it("renders pi style with full-width rules, padded content, and no prompt gutter", () => {
|
||||
const editor = new Editor(unicodeTheme);
|
||||
editor.setBorderStyle("pi");
|
||||
editor.setText("hello");
|
||||
const lines = editor.render(20);
|
||||
expect(lines.length).toBe(3); // top border, content, bottom border
|
||||
expect(lines[0]).toBe(`╭${"─".repeat(18)}╮`);
|
||||
expect(lines[1]).toContain("> hello");
|
||||
expect(lines[2]).toBe(`╰${"─".repeat(18)}╯`);
|
||||
expect(lines.length).toBe(3); // top rule, content, bottom rule
|
||||
expect(lines[0]).toBe("─".repeat(20));
|
||||
expect(lines[1]).toStartWith(" hello");
|
||||
expect(lines[1]).not.toContain(">");
|
||||
expect(lines[2]).toBe("─".repeat(20));
|
||||
});
|
||||
|
||||
it("renders borderless style without box borders", () => {
|
||||
|
||||
Reference in New Issue
Block a user