diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 34e7d19f0..62716c0ea 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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. diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 69f10c99c..598d9dda9 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -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, diff --git a/packages/coding-agent/src/modes/components/composer-shape-preview.ts b/packages/coding-agent/src/modes/components/composer-shape-preview.ts index d9c27d29d..75cbdb093 100644 --- a/packages/coding-agent/src/modes/components/composer-shape-preview.ts +++ b/packages/coding-agent/src/modes/components/composer-shape-preview.ts @@ -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]; } } diff --git a/packages/coding-agent/src/modes/components/settings-selector.ts b/packages/coding-agent/src/modes/components/settings-selector.ts index b52212d95..2bd9dee35 100644 --- a/packages/coding-agent/src/modes/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/components/settings-selector.ts @@ -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; diff --git a/packages/coding-agent/src/modes/components/status-line/component.ts b/packages/coding-agent/src/modes/components/status-line/component.ts index cd494e08c..1716dc437 100644 --- a/packages/coding-agent/src/modes/components/status-line/component.ts +++ b/packages/coding-agent/src/modes/components/status-line/component.ts @@ -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): 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) { diff --git a/packages/coding-agent/src/modes/components/status-line/types.ts b/packages/coding-agent/src/modes/components/status-line/types.ts index c32a39389..d24894adc 100644 --- a/packages/coding-agent/src/modes/components/status-line/types.ts +++ b/packages/coding-agent/src/modes/components/status-line/types.ts @@ -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 * " · " 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< diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index 21c1f77c7..18cd2254f 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -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(); }, diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 2247fbb2c..80059b96e 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -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(); } diff --git a/packages/coding-agent/src/modes/setup-wizard/scenes/composer.ts b/packages/coding-agent/src/modes/setup-wizard/scenes/composer.ts index 94ab37aef..70ce4d772 100644 --- a/packages/coding-agent/src/modes/setup-wizard/scenes/composer.ts +++ b/packages/coding-agent/src/modes/setup-wizard/scenes/composer.ts @@ -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, ""); } diff --git a/packages/coding-agent/src/modes/theme/symbols.ts b/packages/coding-agent/src/modes/theme/symbols.ts index 8704ca98c..c793b85c0 100644 --- a/packages/coding-agent/src/modes/theme/symbols.ts +++ b/packages/coding-agent/src/modes/theme/symbols.ts @@ -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": "+", diff --git a/packages/coding-agent/src/modes/utils/context-usage.ts b/packages/coding-agent/src/modes/utils/context-usage.ts index c04c3beae..d46b9a894 100644 --- a/packages/coding-agent/src/modes/utils/context-usage.ts +++ b/packages/coding-agent/src/modes/utils/context-usage.ts @@ -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[]; diff --git a/packages/coding-agent/src/session/session-maintenance.ts b/packages/coding-agent/src/session/session-maintenance.ts index 4907c9c2a..6dee380ff 100644 --- a/packages/coding-agent/src/session/session-maintenance.ts +++ b/packages/coding-agent/src/session/session-maintenance.ts @@ -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; diff --git a/packages/coding-agent/src/session/speculation-lead.ts b/packages/coding-agent/src/session/speculation-lead.ts new file mode 100644 index 000000000..8bf314cca --- /dev/null +++ b/packages/coding-agent/src/session/speculation-lead.ts @@ -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)), + ); +} diff --git a/packages/coding-agent/test/modes/components/composer-shape-preview.test.ts b/packages/coding-agent/test/modes/components/composer-shape-preview.test.ts index 57df8ae22..3fb11fe94 100644 --- a/packages/coding-agent/test/modes/components/composer-shape-preview.test.ts +++ b/packages/coding-agent/test/modes/components/composer-shape-preview.test.ts @@ -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 () => { diff --git a/packages/coding-agent/test/status-line-context-cache.test.ts b/packages/coding-agent/test/status-line-context-cache.test.ts index a4e6b9f28..416058eb7 100644 --- a/packages/coding-agent/test/status-line-context-cache.test.ts +++ b/packages/coding-agent/test/status-line-context-cache.test.ts @@ -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"); }); }); diff --git a/packages/tui/src/components/composer/borderless.ts b/packages/tui/src/components/composer/borderless.ts new file mode 100644 index 000000000..546fb8a9f --- /dev/null +++ b/packages/tui/src/components/composer/borderless.ts @@ -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; + }, +}; diff --git a/packages/tui/src/components/composer/box.ts b/packages/tui/src/components/composer/box.ts new file mode 100644 index 000000000..2b56254cf --- /dev/null +++ b/packages/tui/src/components/composer/box.ts @@ -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; + }, +}; diff --git a/packages/tui/src/components/composer/claude.ts b/packages/tui/src/components/composer/claude.ts new file mode 100644 index 000000000..833678c91 --- /dev/null +++ b/packages/tui/src/components/composer/claude.ts @@ -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)); + }, +}; diff --git a/packages/tui/src/components/composer/index.ts b/packages/tui/src/components/composer/index.ts new file mode 100644 index 000000000..ed4bd8373 --- /dev/null +++ b/packages/tui/src/components/composer/index.ts @@ -0,0 +1,6 @@ +export * from "./borderless"; +export * from "./box"; +export * from "./claude"; +export * from "./pi"; +export * from "./registry"; +export * from "./types"; diff --git a/packages/tui/src/components/composer/pi.ts b/packages/tui/src/components/composer/pi.ts new file mode 100644 index 000000000..87ddb90e7 --- /dev/null +++ b/packages/tui/src/components/composer/pi.ts @@ -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)); + }, +}; diff --git a/packages/tui/src/components/composer/registry.ts b/packages/tui/src/components/composer/registry.ts new file mode 100644 index 000000000..57989792e --- /dev/null +++ b/packages/tui/src/components/composer/registry.ts @@ -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 = { + 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; +} diff --git a/packages/tui/src/components/composer/types.ts b/packages/tui/src/components/composer/types.ts new file mode 100644 index 000000000..9b60583f0 --- /dev/null +++ b/packages/tui/src/components/composer/types.ts @@ -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; +} diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 9fe84cad1..34fdbdb27 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -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) { diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 40f594d62..53865ce6e 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -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"; diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index eacd45fd7..4a2b79b4f 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -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", () => {