diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 598d9dda9..262c7db47 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -81,8 +81,50 @@ import { export type ModelRoleStorage = "global" | "project"; -export type ComposerShape = "box" | "claude" | "pi" | "borderless"; -export const COMPOSER_SHAPE_VALUES = ["box", "claude", "pi", "borderless"] as const; +/** Composer shape id; extensions may register additional values at runtime. */ +export type ComposerShape = string; + +/** Built-in composer choices and their shared settings/setup copy. */ +export const BUILTIN_COMPOSER_SHAPES = [ + { + value: "box", + label: "Rounded Box (Default)", + description: "Status line embedded in top border, compact 2-line prompt", + }, + { + value: "claude", + label: "Claude Code", + description: "Full-width horizontal rules above and below, status line at bottom", + }, + { + value: "pi", + label: "Pi", + description: "Framed horizontal rules with status line at bottom", + }, + { + value: "borderless", + label: "Borderless", + description: "Clean prompt glyph with status line at bottom, no box borders", + }, + { + value: "rule", + label: "Top Rule Dock", + description: "Single top rule with status docked onto it and below", + }, + { + value: "field", + label: "Compact Field", + description: "Filled one-row field with accent end caps", + }, + { + value: "rail", + label: "Accent Rail", + description: "Filled one-row field anchored by a single accent rail", + }, +] as const; + +/** Built-in composer ids used by tests and non-runtime consumers. */ +export const COMPOSER_SHAPE_VALUES = BUILTIN_COMPOSER_SHAPES.map(shape => shape.value); export type ContextLineMode = "off" | "percentage" | "annotated" | "embedded"; export const CONTEXT_LINE_MODE_VALUES = ["off", "percentage", "annotated", "embedded"] as const; @@ -641,36 +683,14 @@ export const SETTINGS_SCHEMA = { }, // Composer "composer.shape": { - type: "enum", - values: COMPOSER_SHAPE_VALUES, + type: "string", default: "box", ui: { tab: "appearance", group: "Composer", label: "Composer Shape", description: "Visual layout of the input editor and status line", - options: [ - { - value: "box", - label: "Rounded Box (Default)", - description: "Status line embedded in top border, compact 2-line prompt", - }, - { - value: "claude", - label: "Claude Code", - description: "Full-width horizontal rules above and below, status line at bottom", - }, - { - value: "pi", - label: "Pi", - description: "Framed rounded box with prompt glyph, status line at bottom", - }, - { - value: "borderless", - label: "Borderless", - description: "Clean prompt glyph with status line at bottom, no box borders", - }, - ], + options: "runtime", }, }, diff --git a/packages/coding-agent/src/extensibility/extensions/loader.ts b/packages/coding-agent/src/extensibility/extensions/loader.ts index b0fb283d3..ae5de155d 100644 --- a/packages/coding-agent/src/extensibility/extensions/loader.ts +++ b/packages/coding-agent/src/extensibility/extensions/loader.ts @@ -16,7 +16,7 @@ import type { TextContent, TSchema, } from "@oh-my-pi/pi-ai"; -import type { KeyId } from "@oh-my-pi/pi-tui"; +import { isBuiltinComposerStyle, type KeyId } from "@oh-my-pi/pi-tui"; import { hasFsCode, isEacces, isEnoent, logger } from "@oh-my-pi/pi-utils"; import { type ExtensionModule, extensionModuleCapability } from "../../capability/extension-module"; import { type Hook, hookCapability } from "../../capability/hook"; @@ -37,6 +37,7 @@ import { getAllPluginExtensionPaths } from "../plugins/loader"; import { resolvePath, withHostGuard } from "../utils"; import type { AssistantThinkingRenderer, + ComposerShapeDefinition, Extension, ExtensionAPI, ExtensionContext, @@ -235,6 +236,20 @@ class ConcreteExtensionAPI implements ExtensionAPI, IExtensionRuntime { this.extension.assistantThinkingRenderers.push(renderer); } + registerComposerShape(definition: ComposerShapeDefinition): void { + const id = definition.style.id; + if (id.length === 0 || id !== id.trim()) { + throw new TypeError("Composer shape id must be a non-empty trimmed string"); + } + if (definition.label.trim().length === 0) { + throw new TypeError(`Composer shape "${id}" must have a label`); + } + if (isBuiltinComposerStyle(id)) { + throw new Error(`Cannot replace built-in composer shape "${id}"`); + } + this.extension.composerShapes.set(id, definition); + } + getFlag(name: string): boolean | string | undefined { if (!this.extension.flags.has(name)) return undefined; return this.runtime.flagValues.get(name); @@ -332,6 +347,7 @@ function createExtension(extensionPath: string, resolvedPath: string): Extension fileWriteFallbackHandlers: [], fileDeleteFallbackHandlers: [], messageRenderers: new Map(), + composerShapes: new Map(), commands: new Map(), flags: new Map(), shortcuts: new Map(), diff --git a/packages/coding-agent/src/extensibility/extensions/runner.ts b/packages/coding-agent/src/extensibility/extensions/runner.ts index 031b96bed..4327fb41b 100644 --- a/packages/coding-agent/src/extensibility/extensions/runner.ts +++ b/packages/coding-agent/src/extensibility/extensions/runner.ts @@ -31,6 +31,7 @@ import type { BeforeProviderRequestEvent, BeforeProviderRequestEventResult, CompactOptions, + ComposerShapeDefinition, ContextEvent, ContextEventResult, ContextUsage, @@ -976,6 +977,15 @@ export class ExtensionRunner { if (firstFailure) throw firstFailure.reason; } + /** Composer shapes registered during extension load, with later extensions winning id collisions. */ + getComposerShapes(): ComposerShapeDefinition[] { + const shapes = new Map(); + for (const extension of this.extensions) { + for (const [id, shape] of extension.composerShapes) shapes.set(id, shape); + } + return [...shapes.values()]; + } + /** * Aggregate the registered CLI flags across a set of extensions (last write * wins on name collision). Static so callers that need the flag set before a diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index ccc5e0179..4e0f1b185 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -42,6 +42,7 @@ import type { AutocompleteItem, AutocompleteProvider, Component, + ComposerStyle, EditorTheme, KeyId, OverlayHandle, @@ -368,6 +369,16 @@ export interface ExtensionUIContext { setToolsExpanded(expanded: boolean): void; } +/** Visual composer style and selector copy registered by an extension. */ +export interface ComposerShapeDefinition { + /** User-facing name shown in composer-shape selectors. */ + label: string; + /** Optional detail shown under the selector label. */ + description?: string; + /** Renderer contract; its id becomes the persisted `composer.shape` value. */ + style: ComposerStyle; +} + // ============================================================================ // Extension Context // ============================================================================ @@ -1363,6 +1374,14 @@ export interface ExtensionAPI { /** Register a renderer for assistant thinking blocks. Rendered after the original thinking text. */ registerAssistantThinkingRenderer(renderer: AssistantThinkingRenderer): void; + /** + * Register a composer shape for the interactive editor. + * + * Registration happens during extension load. Built-in ids cannot be + * replaced; when extensions reuse an id, the later extension wins. + */ + registerComposerShape(definition: ComposerShapeDefinition): void; + // ========================================================================= // Actions // ========================================================================= @@ -1693,6 +1712,7 @@ export interface Extension { fileWriteFallbackHandlers: FileWriteFallbackHandler[]; fileDeleteFallbackHandlers: FileDeleteFallbackHandler[]; messageRenderers: Map; + composerShapes: Map; commands: Map; flags: Map; shortcuts: Map; 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 75cbdb093..117024b87 100644 --- a/packages/coding-agent/src/modes/components/composer-shape-preview.ts +++ b/packages/coding-agent/src/modes/components/composer-shape-preview.ts @@ -62,6 +62,8 @@ export function renderComposerShapePreview( width: previewWidth, paddingX, borderColor: (str: string) => theme.fg("borderAccent", str), + accentColor: (str: string) => theme.fg("accent", str), + surfaceColor: (str: string) => theme.bgFill("userMessageBg", str), box: theme.boxRound, topBorder, }; @@ -93,7 +95,10 @@ export function renderComposerShapePreview( if (style.bottomBar !== "none" && status) { const bar = status.renderBottomBar(previewWidth, style.bottomBar); - if (bar) lines.push(bar); + if (bar) { + if (style.bottomBarGap) lines.push(""); + lines.push(bar); + } } return lines; } diff --git a/packages/coding-agent/src/modes/components/composer-shape-registry.ts b/packages/coding-agent/src/modes/components/composer-shape-registry.ts new file mode 100644 index 000000000..5cb012457 --- /dev/null +++ b/packages/coding-agent/src/modes/components/composer-shape-registry.ts @@ -0,0 +1,26 @@ +import { registerComposerStyle } from "@oh-my-pi/pi-tui"; +import { BUILTIN_COMPOSER_SHAPES, type SubmenuOption } from "../../config/settings-schema"; +import type { ComposerShapeDefinition } from "../../extensibility/extensions"; + +const extensionComposerShapes = new Map(); + +/** Install one extension composer shape into rendering and selector registries. */ +export function installExtensionComposerShape(definition: ComposerShapeDefinition): () => void { + const unregisterStyle = registerComposerStyle(definition.style); + const id = definition.style.id; + const option: SubmenuOption = { + value: id, + label: definition.label, + description: definition.description, + }; + extensionComposerShapes.set(id, option); + return () => { + if (extensionComposerShapes.get(id) === option) extensionComposerShapes.delete(id); + unregisterStyle(); + }; +} + +/** Available built-in and extension composer choices in selector order. */ +export function getComposerShapeOptions(): readonly SubmenuOption[] { + return [...BUILTIN_COMPOSER_SHAPES, ...extensionComposerShapes.values()]; +} diff --git a/packages/coding-agent/src/modes/components/settings-selector.ts b/packages/coding-agent/src/modes/components/settings-selector.ts index 2bd9dee35..fd865b7b8 100644 --- a/packages/coding-agent/src/modes/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/components/settings-selector.ts @@ -47,6 +47,7 @@ import { getCurrentThemeName, getSelectListTheme, getSettingsListTheme, theme } import { AUTO_THINKING, type ConfiguredThinkingLevel } from "../../thinking"; import { getTabBarTheme } from "../shared"; import { type ComposerPreviewStatusSource, ComposerShapePreview } from "./composer-shape-preview"; +import { getComposerShapeOptions } from "./composer-shape-registry"; import { bottomBorder, divider, row, topBorder } from "./overlay-box"; import { handleInputOrEscape, PluginSettingsComponent } from "./plugin-settings"; import { getSettingDef, getSettingsForTab, type SettingDef } from "./settings-defs"; @@ -1077,8 +1078,9 @@ export class SettingsSelectorComponent implements Component { }); } else if (def.path === "theme.dark" || def.path === "theme.light") { options = this.context.availableThemes.map(t => ({ value: t, label: t })); + } else if (def.path === "composer.shape") { + options = getComposerShapeOptions(); } - // Preview handlers let onPreview: ((value: string) => void | Promise) | undefined; let onPreviewCancel: (() => void) | undefined; @@ -1138,11 +1140,11 @@ export class SettingsSelectorComponent implements Component { onPreview = value => shapePreview.setValue(value); footer = shapePreview; } else if (def.path === "composer.shape") { - const shapePreview = new ComposerShapePreview(currentValue as ComposerShape, { + const shapePreview = new ComposerShapePreview(String(currentValue ?? "box"), { requestRender: this.context.requestRender, status: this.context.composerPreviewStatus, }); - onPreview = value => shapePreview.setValue(value as ComposerShape); + onPreview = value => shapePreview.setValue(value); footer = shapePreview; } // Provide status line preview for theme selection 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 57058b2f5..18c36911d 100644 --- a/packages/coding-agent/src/modes/components/status-line/component.ts +++ b/packages/coding-agent/src/modes/components/status-line/component.ts @@ -328,6 +328,7 @@ function hasGitBackedSegment(segments: readonly StatusLineSegmentId[]): boolean export class StatusLineComponent implements Component { #standalone: false | "full" | "left-only" = false; + #standaloneGap = false; #autocompleteActiveProbe: (() => boolean) | undefined; #widthEpochRevision = 0; #settings: StatusLineSettings = {}; @@ -2105,14 +2106,17 @@ export class StatusLineComponent implements Component { }; } /** - * 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. + * Standalone bar placement derived from the composer style. `bottomBar` + * `"full"` renders both groups on the bottom bar (pi/borderless/field/rail); + * `"left"` renders just the left group there — the right group attaches to + * the editor's top rule via {@link getStandaloneTopBorder} (claude/rule); + * `"none"` returns the bar to the box composer's embedded top border. + * `bottomBarGap` inserts a blank spacer row above the bar for styles whose + * editor has no bottom chrome. */ - setStandalone(standalone: false | "full" | "left-only"): void { - this.#standalone = standalone; + setComposerStyle(style: Pick): void { + this.#standalone = style.bottomBar === "none" ? false : style.bottomBar === "left" ? "left-only" : "full"; + this.#standaloneGap = style.bottomBarGap; } /** While true, the standalone bar yields its row to the editor's autocomplete menu. */ @@ -2170,6 +2174,8 @@ export class StatusLineComponent implements Component { width, paddingX: 0, borderColor: str => theme.fg("border", str), + accentColor: str => theme.fg("accent", str), + surfaceColor: str => theme.bgFill("userMessageBg", str), box: theme.boxRound, topBorder: this.getStandaloneTopBorder(width), }); @@ -2186,7 +2192,10 @@ export class StatusLineComponent implements Component { const lines: string[] = []; if (this.#standalone && !this.#autocompleteActiveProbe?.()) { const content = this.renderBottomBar(width, this.#standalone === "left-only" ? "left" : "full"); - if (content) lines.push(content); + if (content) { + if (this.#standaloneGap) lines.push(""); + lines.push(content); + } } const showHooks = this.#settings.showHookStatus ?? true; if (showHooks && this.#hookStatuses.size > 0) { diff --git a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts index fce13e198..9b3be9da7 100644 --- a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts +++ b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts @@ -22,6 +22,7 @@ import type { TerminalInputHandler, } from "../../extensibility/extensions"; import { getSessionSlashCommands } from "../../extensibility/extensions/get-commands-handler"; +import { installExtensionComposerShape } from "../../modes/components/composer-shape-registry"; import { AskDialogComponent, boundPromptTitle } from "../../modes/components/ask-dialog"; import { HookEditorComponent } from "../../modes/components/hook-editor"; import { HookInputComponent } from "../../modes/components/hook-input"; @@ -64,6 +65,7 @@ function toWireSelectOptions(options: ExtensionUISelectItem[]): CollabUiSelectIt export class ExtensionUiController { #extensionTerminalInputUnsubscribers = new Set<() => void>(); + #composerShapeDisposers: Array<() => void> = []; #hookWidgetsAbove = new Map(); #hookWidgetsBelow = new Map(); // Single-file dialog surface (`editorContainer` + focus) is shared by the @@ -79,6 +81,19 @@ export class ExtensionUiController { #toolUIContext: ExtensionUIContext | undefined; constructor(private ctx: InteractiveModeContext) {} + #syncExtensionComposerShapes(): void { + this.disposeComposerShapes(); + for (const definition of this.ctx.session.extensionRunner?.getComposerShapes() ?? []) { + this.#composerShapeDisposers.push(installExtensionComposerShape(definition)); + } + this.ctx.syncComposerShape(); + } + + /** Remove extension-owned composer styles from the process registries. */ + disposeComposerShapes(): void { + for (const dispose of this.#composerShapeDisposers.splice(0)) dispose(); + } + /** * Initialize the hook system with TUI-based UI context. */ @@ -142,6 +157,7 @@ export class ExtensionUiController { }); const extensionRunner = this.ctx.session.extensionRunner; + this.#syncExtensionComposerShapes(); if (!extensionRunner) { return; // No hooks loaded } @@ -513,6 +529,7 @@ export class ExtensionUiController { }; extensionRunner.initialize(actions, contextActions, commandActions, uiContext, "tui"); + this.#syncExtensionComposerShapes(); } /** diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 80059b96e..5abe00351 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -68,7 +68,6 @@ import { Settings, settings, } from "../config/settings"; -import type { ComposerShape } from "../config/settings-schema"; import { clearClaudePluginRootsCache } from "../discovery/helpers"; import type { AutocompleteProviderFactory, @@ -1925,7 +1924,7 @@ export class InteractiveMode implements InteractiveModeContext { }); } syncComposerShape(): void { - const shape = (settings.get("composer.shape") as ComposerShape) ?? "box"; + const shape = settings.get("composer.shape") ?? "box"; const style = getComposerStyle(shape); this.editor.setBorderStyle(shape); this.statusLine.setAutocompleteActiveProbe(() => this.editor.isAutocompleteActive()); @@ -1941,9 +1940,7 @@ export class InteractiveMode implements InteractiveModeContext { this.editor.setTopBorder(undefined); break; } - this.statusLine.setStandalone( - style.bottomBar === "none" ? false : style.bottomBar === "left" ? "left-only" : "full", - ); + this.statusLine.setComposerStyle(style); this.updateEditorBorderColor(); this.ui.requestRender(); } @@ -4391,6 +4388,7 @@ export class InteractiveMode implements InteractiveModeContext { } this.#extensionUiController.clearExtensionTerminalInputListeners(); this.#extensionUiController.clearHookWidgets(); + this.#extensionUiController.disposeComposerShapes(); for (const unsubscribe of this.#eventBusUnsubscribers) { unsubscribe(); } 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 70ce4d772..89fe3a725 100644 --- a/packages/coding-agent/src/modes/setup-wizard/scenes/composer.ts +++ b/packages/coding-agent/src/modes/setup-wizard/scenes/composer.ts @@ -1,53 +1,41 @@ import { routeSelectListMouse, type SelectItem, SelectList, type SgrMouseEvent } from "@oh-my-pi/pi-tui"; import type { ComposerShape } from "../../../config/settings-schema"; +import { getComposerShapeOptions } from "../../components/composer-shape-registry"; import { renderComposerShapePreview } from "../../components/composer-shape-preview"; import { getSelectListTheme, theme } from "../../theme/theme"; import type { SetupScene, SetupSceneController, SetupSceneHost } from "./types"; -const COMPOSER_SHAPES: readonly ComposerShape[] = ["box", "claude", "pi", "borderless"]; - -const COMPOSER_LABELS: Readonly> = { - box: "Rounded Box (Default)", - claude: "Claude Code", - pi: "Pi", - borderless: "Borderless", -}; - -const COMPOSER_DESCRIPTIONS: Readonly> = { - box: "Status line integrated in top border, compact 2-line prompt", - claude: "Full-width horizontal rules above and below, status line at bottom", - pi: "Framed rounded box with prompt glyph, status line at bottom", - borderless: "Clean prompt glyph with status line at bottom, no box borders", -}; - -const COMPOSER_ITEMS: readonly SelectItem[] = COMPOSER_SHAPES.map((shape, index) => ({ - value: shape, - label: `${index + 1} ${COMPOSER_LABELS[shape]}`, - description: COMPOSER_DESCRIPTIONS[shape], -})); - class ComposerSceneController implements SetupSceneController { title = "Choose composer shape"; subtitle = "Pick the prompt and status line layout for your workflow."; #selectList: SelectList; + #shapes: readonly ComposerShape[]; + #items: readonly SelectItem[]; #currentShape: ComposerShape = "box"; #committing = false; #listRowStart = 0; constructor(private readonly host: SetupSceneHost) { - const configuredShape = host.ctx.settings.get("composer.shape") as ComposerShape; - const initialShape = COMPOSER_SHAPES.includes(configuredShape) ? configuredShape : "box"; + const choices = getComposerShapeOptions(); + this.#shapes = choices.map(choice => choice.value); + this.#items = choices.map((choice, index) => ({ + value: choice.value, + label: `${index + 1} ${choice.label}`, + description: choice.description, + })); + const configuredShape = host.ctx.settings.get("composer.shape") ?? "box"; + const initialShape = this.#shapes.includes(configuredShape) ? configuredShape : "box"; this.#currentShape = initialShape; - const initialIndex = Math.max(0, COMPOSER_SHAPES.indexOf(initialShape)); + const initialIndex = Math.max(0, this.#shapes.indexOf(initialShape)); const selectListTheme = getSelectListTheme(); - this.#selectList = new SelectList(COMPOSER_ITEMS, COMPOSER_ITEMS.length, selectListTheme); + this.#selectList = new SelectList(this.#items, this.#items.length, selectListTheme); this.#selectList.setSelectedIndex(initialIndex); this.#selectList.onSelectionChange = item => { - this.#preview(item.value as ComposerShape); + this.#preview(item.value); }; this.#selectList.onSelect = item => { - void this.#commit(item.value as ComposerShape); + void this.#commit(item.value); }; this.#selectList.onCancel = () => { // Esc skips the scene without saving; the configured shape stays untouched. @@ -61,10 +49,10 @@ class ComposerSceneController implements SetupSceneController { handleInput(data: string): void { if (this.#committing) return; - const quickIndex = data >= "1" && data <= "4" ? Number(data) - 1 : -1; - if (quickIndex >= 0 && quickIndex < COMPOSER_ITEMS.length) { + const quickIndex = data.length === 1 ? Number(data) - 1 : -1; + if (Number.isInteger(quickIndex) && quickIndex >= 0 && quickIndex < this.#items.length) { this.#selectList.setSelectedIndex(quickIndex); - this.#preview(COMPOSER_SHAPES[quickIndex] ?? "box"); + this.#preview(this.#shapes[quickIndex] ?? "box"); return; } this.#selectList.handleInput(data); @@ -80,7 +68,7 @@ class ComposerSceneController implements SetupSceneController { const lines = [theme.fg("muted", "Select a layout; live preview updates below. Press Enter to confirm."), ""]; const previewLines = renderComposerShapePreview(this.#currentShape, width, this.host.ctx.statusLine); - if (budget - lines.length - previewLines.length - 2 >= COMPOSER_ITEMS.length) { + if (budget - lines.length - previewLines.length - 2 >= this.#items.length) { lines.push(theme.fg("muted", "Preview:"), ...previewLines, ""); } diff --git a/packages/coding-agent/src/modes/theme/theme-class.ts b/packages/coding-agent/src/modes/theme/theme-class.ts index 57b26bd6d..a4048fcd2 100644 --- a/packages/coding-agent/src/modes/theme/theme-class.ts +++ b/packages/coding-agent/src/modes/theme/theme-class.ts @@ -128,6 +128,8 @@ const LANG_BRAND_COLORS: Partial> = { "lang.julia": "#9558b2", }; +const BACKGROUND_RESET_PATTERN = /\x1b\[(?:0|49)m/g; + export class Theme { #fgColors: Record; #bgColors: Record; @@ -273,6 +275,18 @@ export class Theme { return `${ansi}${text}\x1b[49m`; // Reset only background color } + /** + * Apply a background fill that resumes after nested full/background resets. + * + * Composer rows contain styled text and cursor escapes; a normal background + * wrapper would otherwise stop at the first nested reset. + */ + bgFill(color: ThemeBg, text: string): string { + const ansi = this.#bgColors[color]; + if (!ansi) throw new Error(`Unknown theme background color: ${color}`); + return `${ansi}${text.replace(BACKGROUND_RESET_PATTERN, `$&${ansi}`)}\x1b[49m`; + } + bold(text: string): string { return chalk.bold(text); } diff --git a/packages/coding-agent/src/modes/theme/tui-adapters.ts b/packages/coding-agent/src/modes/theme/tui-adapters.ts index c6be3cc3a..213c2177e 100644 --- a/packages/coding-agent/src/modes/theme/tui-adapters.ts +++ b/packages/coding-agent/src/modes/theme/tui-adapters.ts @@ -230,6 +230,8 @@ export function getEditorTheme(): EditorTheme { if (typeof theme === "undefined") { return { borderColor: (text: string) => text, + accentColor: (text: string) => text, + surfaceColor: (text: string) => text, selectList: getSelectListTheme(), symbols: getSymbolTheme(), hintStyle: (text: string) => text, @@ -237,6 +239,8 @@ export function getEditorTheme(): EditorTheme { } return { borderColor: (text: string) => theme.fg("borderMuted", text), + accentColor: (text: string) => theme.fg("accent", text), + surfaceColor: (text: string) => theme.bgFill("userMessageBg", text), selectList: getSelectListTheme(), symbols: getSymbolTheme(), hintStyle: (text: string) => theme.fg("dim", text), diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts index 614ac791a..86751fe31 100644 --- a/packages/coding-agent/test/extensions-runner.test.ts +++ b/packages/coding-agent/test/extensions-runner.test.ts @@ -491,6 +491,46 @@ describe("ExtensionRunner", () => { }); }); + describe("composer shapes", () => { + it("collects extension-defined renderer contracts and selector copy", async () => { + const extCode = ` + export default function(pi) { + pi.registerComposerShape({ + label: "Extension Dock", + description: "Custom extension composer", + style: { + id: "extension-dock", + sideBorders: false, + verticalChrome: 0, + statusAttachment: "none", + bottomBar: "full", + bottomBarGap: false, + defaultPaddingX: () => 0, + sideChromeWidth: () => 0, + renderTop: () => undefined, + renderRow: context => [context.gutter + context.text + context.pad], + renderBottom: () => undefined, + }, + }); + } + `; + fs.writeFileSync(path.join(extensionsDir, "composer-shape.ts"), extCode); + + const result = await loadTestExtensions(); + const runner = new ExtensionRunner( + result.extensions, + result.runtime, + tempDir.path(), + sessionManager, + modelRegistry, + ); + + const [definition] = runner.getComposerShapes(); + expect(definition.label).toBe("Extension Dock"); + expect(definition.description).toBe("Custom extension composer"); + expect(definition.style.id).toBe("extension-dock"); + }); + }); describe("flags", () => { it("collects flags from extensions", async () => { const extCode = ` 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 3fb11fe94..0d6117f73 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 @@ -5,8 +5,13 @@ import { ComposerShapePreview, renderComposerShapePreview, } from "@oh-my-pi/pi-coding-agent/modes/components/composer-shape-preview"; +import { + getComposerShapeOptions, + installExtensionComposerShape, +} from "@oh-my-pi/pi-coding-agent/modes/components/composer-shape-registry"; 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"; +import type { ComposerStyle } from "@oh-my-pi/pi-tui"; beforeAll(async () => { await initTheme(); @@ -83,12 +88,61 @@ describe("composer shape preview", () => { expect(claude).toContain("CHIP"); // right group chips onto the top rule expect(claude).toContain("BOTTOM-LEFT"); // left group only on the bottom bar + const rule = renderComposerShapePreview("rule", 80, status).join("\n"); + expect(rule).toContain("CHIP"); + expect(rule).toContain("BOTTOM-LEFT"); + 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"); + + for (const shape of ["field", "rail"]) { + const rendered = renderComposerShapePreview(shape, 80, status).join("\n"); + expect(rendered).not.toContain("CHIP"); + expect(rendered).toContain("BOTTOM-FULL"); + } + }); + + it("installs extension shapes into both selectors and live rendering", async () => { + await setTheme("dark"); + const style: ComposerStyle = { + id: "extension-dock", + sideBorders: false, + verticalChrome: 1, + statusAttachment: "none", + bottomBar: "full", + bottomBarGap: false, + defaultPromptGutter: "EXT ", + defaultPaddingX: () => 0, + sideChromeWidth: () => 0, + renderTop: context => context.borderColor("=".repeat(context.width)), + renderRow: context => [context.gutter + context.text + context.pad], + renderBottom: () => undefined, + }; + const dispose = installExtensionComposerShape({ + label: "Extension Dock", + description: "Custom extension composer", + style, + }); + + try { + expect(getComposerShapeOptions().at(-1)).toEqual({ + value: "extension-dock", + label: "Extension Dock", + description: "Custom extension composer", + }); + const rendered = renderComposerShapePreview("extension-dock", 76).join("\n"); + expect(rendered).toContain("=".repeat(76)); + expect(rendered).toContain("EXT "); + expect(rendered).toContain("Ask anything"); + } finally { + dispose(); + } + + expect(getComposerShapeOptions().some(option => option.value === "extension-dock")).toBe(false); }); 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 416058eb7..c3b5d0a29 100644 --- a/packages/coding-agent/test/status-line-context-cache.test.ts +++ b/packages/coding-agent/test/status-line-context-cache.test.ts @@ -403,12 +403,20 @@ describe("StatusLineComponent context breakdown", () => { const comp = new StatusLineComponent(session); expect(comp.render(80)).toHaveLength(0); // box mode: main status lives in the editor border - comp.setStandalone("full"); + comp.setComposerStyle({ bottomBar: "full", bottomBarGap: false }); const lines = comp.render(80); 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"); + + // Styles without bottom chrome (rule/field/rail) request a spacer row so + // the bar doesn't sit flush against the last input row. + comp.setComposerStyle({ bottomBar: "full", bottomBarGap: true }); + const gapped = comp.render(80); + expect(gapped).toHaveLength(2); + expect(gapped[0]).toBe(""); + expect(gapped[1]).toBe(lines[0]); }); it("standalone bar yields to the autocomplete menu via the probe", () => { @@ -417,12 +425,12 @@ describe("StatusLineComponent context breakdown", () => { usage: { tokens: 1000, contextWindow: 100_000, percent: 1 }, }); const comp = new StatusLineComponent(session); - comp.setStandalone("full"); + comp.setComposerStyle({ bottomBar: "full", bottomBarGap: true }); let menuOpen = true; comp.setAutocompleteActiveProbe(() => menuOpen); expect(comp.render(80)).toHaveLength(0); menuOpen = false; - expect(comp.render(80)).toHaveLength(1); + expect(comp.render(80)).toHaveLength(2); // spacer + bar return together }); it("claude layout splits groups: left-only bottom bar, right group as top-rule chip", () => { @@ -438,7 +446,7 @@ describe("StatusLineComponent context breakdown", () => { separator: "none", sessionAccent: false, }); - comp.setStandalone("left-only"); + comp.setComposerStyle({ bottomBar: "left", bottomBarGap: false }); const bottom = comp.render(80); expect(bottom).toHaveLength(1); diff --git a/packages/tui/src/components/composer/borderless.ts b/packages/tui/src/components/composer/borderless.ts index 546fb8a9f..23c96b455 100644 --- a/packages/tui/src/components/composer/borderless.ts +++ b/packages/tui/src/components/composer/borderless.ts @@ -12,6 +12,7 @@ export const borderlessComposerStyle: ComposerStyle = { verticalChrome: 0, statusAttachment: "none", bottomBar: "full", + bottomBarGap: false, defaultPromptGutter: "❯ ", defaultPaddingX(): number { diff --git a/packages/tui/src/components/composer/box.ts b/packages/tui/src/components/composer/box.ts index 2b56254cf..663db309c 100644 --- a/packages/tui/src/components/composer/box.ts +++ b/packages/tui/src/components/composer/box.ts @@ -12,6 +12,7 @@ export const boxComposerStyle: ComposerStyle = { verticalChrome: 2, statusAttachment: "top-border", bottomBar: "none", + bottomBarGap: false, defaultPromptGutter: undefined, defaultPaddingX(themePaddingX: number | undefined): number { diff --git a/packages/tui/src/components/composer/claude.ts b/packages/tui/src/components/composer/claude.ts index 833678c91..32d5ff770 100644 --- a/packages/tui/src/components/composer/claude.ts +++ b/packages/tui/src/components/composer/claude.ts @@ -5,6 +5,7 @@ * plain standalone bottom bar that yields its row to the autocomplete menu. */ import type { ComposerChromeContext, ComposerRowContext, ComposerStyle } from "./types"; +import { renderTopRule } from "./rule"; export const claudeComposerStyle: ComposerStyle = { id: "claude", @@ -12,6 +13,7 @@ export const claudeComposerStyle: ComposerStyle = { verticalChrome: 2, statusAttachment: "top-rule-chip", bottomBar: "left", + bottomBarGap: false, defaultPromptGutter: "❯ ", defaultPaddingX(): number { @@ -23,14 +25,7 @@ export const claudeComposerStyle: ComposerStyle = { }, 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)); + return renderTopRule(ctx); }, renderRow(ctx: ComposerRowContext): string[] { diff --git a/packages/tui/src/components/composer/field.ts b/packages/tui/src/components/composer/field.ts new file mode 100644 index 000000000..ec19349ea --- /dev/null +++ b/packages/tui/src/components/composer/field.ts @@ -0,0 +1,47 @@ +/** + * Compact filled composer: every input row is a one-line field with accent end + * caps and a subtle surface fill. The complete status line remains below it. + */ +import { padding } from "../../utils"; +import type { ComposerRowContext, ComposerStyle } from "./types"; + +const LEFT_CAP = "▐"; +const RIGHT_CAP = "▌"; + +/** One-row filled field with accent caps. */ +export const fieldComposerStyle: ComposerStyle = { + id: "field", + sideBorders: true, + verticalChrome: 0, + statusAttachment: "none", + bottomBar: "full", + bottomBarGap: true, + defaultPromptGutter: undefined, + + defaultPaddingX(): number { + return 1; + }, + + sideChromeWidth(paddingX: number): number { + return paddingX + 1; + }, + + renderTop(): undefined { + return undefined; + }, + + renderRow(ctx: ComposerRowContext): string[] { + const left = ctx.accentColor(LEFT_CAP); + const leftFill = padding(ctx.paddingX) + ctx.gutter + ctx.text; + if (ctx.imeSafeCursorTail) return [left + ctx.surfaceColor(leftFill)]; + + const rightChromeCells = Math.max(1, ctx.paddingX + 1 - ctx.cursorOverflow); + const interior = leftFill + ctx.pad + padding(rightChromeCells - 1); + const rightGlyph = ctx.scrollbarThumb ? "█" : RIGHT_CAP; + return [left + ctx.surfaceColor(interior) + ctx.accentColor(rightGlyph)]; + }, + + renderBottom(): undefined { + return undefined; + }, +}; diff --git a/packages/tui/src/components/composer/index.ts b/packages/tui/src/components/composer/index.ts index ed4bd8373..9c0708001 100644 --- a/packages/tui/src/components/composer/index.ts +++ b/packages/tui/src/components/composer/index.ts @@ -1,6 +1,9 @@ export * from "./borderless"; export * from "./box"; export * from "./claude"; +export * from "./field"; export * from "./pi"; +export * from "./rail"; +export * from "./rule"; export * from "./registry"; export * from "./types"; diff --git a/packages/tui/src/components/composer/pi.ts b/packages/tui/src/components/composer/pi.ts index 87ddb90e7..262b29308 100644 --- a/packages/tui/src/components/composer/pi.ts +++ b/packages/tui/src/components/composer/pi.ts @@ -12,6 +12,7 @@ export const piComposerStyle: ComposerStyle = { verticalChrome: 2, statusAttachment: "none", bottomBar: "full", + bottomBarGap: false, defaultPromptGutter: undefined, defaultPaddingX(): number { diff --git a/packages/tui/src/components/composer/rail.ts b/packages/tui/src/components/composer/rail.ts new file mode 100644 index 000000000..83ee06859 --- /dev/null +++ b/packages/tui/src/components/composer/rail.ts @@ -0,0 +1,48 @@ +/** + * Filled composer with one strong accent rail on the left. The asymmetric + * silhouette separates input from transcript without enclosing it in a box. + */ +import { padding } from "../../utils"; +import type { ComposerRowContext, ComposerStyle } from "./types"; + +const ACCENT_RAIL = "▎"; + +/** Filled composer surface anchored by a single left accent rail. */ +export const railComposerStyle: ComposerStyle = { + id: "rail", + sideBorders: true, + verticalChrome: 0, + statusAttachment: "none", + bottomBar: "full", + bottomBarGap: true, + defaultPromptGutter: undefined, + + defaultPaddingX(): number { + return 1; + }, + + sideChromeWidth(paddingX: number): number { + return paddingX + 1; + }, + + renderTop(): undefined { + return undefined; + }, + + renderRow(ctx: ComposerRowContext): string[] { + const rail = ctx.accentColor(ACCENT_RAIL); + const leftFill = padding(ctx.paddingX) + ctx.gutter + ctx.text; + if (ctx.imeSafeCursorTail) return [rail + ctx.surfaceColor(leftFill)]; + + const rightFillCells = Math.max(0, ctx.paddingX + 1 - ctx.cursorOverflow); + if (ctx.scrollbarThumb && rightFillCells > 0) { + const interior = leftFill + ctx.pad + padding(rightFillCells - 1); + return [rail + ctx.surfaceColor(interior) + ctx.accentColor("█")]; + } + return [rail + ctx.surfaceColor(leftFill + ctx.pad + padding(rightFillCells))]; + }, + + renderBottom(): undefined { + return undefined; + }, +}; diff --git a/packages/tui/src/components/composer/registry.ts b/packages/tui/src/components/composer/registry.ts index 57989792e..7a42fa648 100644 --- a/packages/tui/src/components/composer/registry.ts +++ b/packages/tui/src/components/composer/registry.ts @@ -1,17 +1,46 @@ import { borderlessComposerStyle } from "./borderless"; import { boxComposerStyle } from "./box"; import { claudeComposerStyle } from "./claude"; +import { fieldComposerStyle } from "./field"; import { piComposerStyle } from "./pi"; +import { railComposerStyle } from "./rail"; +import { ruleComposerStyle } from "./rule"; import type { ComposerStyle, EditorBorderStyle } from "./types"; -const COMPOSER_STYLES: Record = { +const BUILTIN_COMPOSER_STYLES: Readonly> = { box: boxComposerStyle, claude: claudeComposerStyle, pi: piComposerStyle, borderless: borderlessComposerStyle, + rule: ruleComposerStyle, + field: fieldComposerStyle, + rail: railComposerStyle, }; +const extensionComposerStyles = new Map(); + +/** Whether an id names a composer style shipped by pi-tui. */ +export function isBuiltinComposerStyle(id: string): boolean { + return Object.hasOwn(BUILTIN_COMPOSER_STYLES, id); +} + +/** + * Register one extension-owned composer style for this process. + * + * Built-in ids and duplicate extension ids are rejected. The returned disposer + * removes only this registration. + */ +export function registerComposerStyle(style: ComposerStyle): () => void { + const id = style.id.trim(); + if (id.length === 0 || id !== style.id) throw new TypeError("Composer style id must be a non-empty trimmed string"); + if (isBuiltinComposerStyle(id)) throw new Error(`Cannot replace built-in composer style "${id}"`); + if (extensionComposerStyles.has(id)) throw new Error(`Composer style "${id}" is already registered`); + extensionComposerStyles.set(id, style); + return () => { + if (extensionComposerStyles.get(id) === style) extensionComposerStyles.delete(id); + }; +} /** Style object for a composer shape; unknown ids fall back to `box`. */ export function getComposerStyle(id: EditorBorderStyle): ComposerStyle { - return COMPOSER_STYLES[id] ?? boxComposerStyle; + return extensionComposerStyles.get(id) ?? BUILTIN_COMPOSER_STYLES[id] ?? boxComposerStyle; } diff --git a/packages/tui/src/components/composer/rule.ts b/packages/tui/src/components/composer/rule.ts new file mode 100644 index 000000000..d2dbabe3e --- /dev/null +++ b/packages/tui/src/components/composer/rule.ts @@ -0,0 +1,47 @@ +/** + * Single-rule composer: a borderless prompt docked below one top rule. The + * right status group rides the rule while the left group remains below the + * editor, preserving the compact status split without a closing rule. + */ +import type { ComposerChromeContext, ComposerRowContext, ComposerStyle } from "./types"; + +/** Draw a full-width rule with optional status content docked at its right edge. */ +export function renderTopRule(ctx: ComposerChromeContext): string { + const { box, width, borderColor, topBorder } = ctx; + 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)); +} + +/** Composer style with one status-bearing top rule and no bottom chrome. */ +export const ruleComposerStyle: ComposerStyle = { + id: "rule", + sideBorders: false, + verticalChrome: 1, + statusAttachment: "top-rule-chip", + bottomBar: "left", + bottomBarGap: true, + defaultPromptGutter: "❯ ", + + defaultPaddingX(): number { + return 0; + }, + + sideChromeWidth(paddingX: number): number { + return paddingX; + }, + + renderTop(ctx: ComposerChromeContext): string { + return renderTopRule(ctx); + }, + + renderRow(ctx: ComposerRowContext): string[] { + return [ctx.gutter + ctx.text + ctx.pad]; + }, + + renderBottom(): undefined { + return undefined; + }, +}; diff --git a/packages/tui/src/components/composer/types.ts b/packages/tui/src/components/composer/types.ts index 9b60583f0..ebae73ee0 100644 --- a/packages/tui/src/components/composer/types.ts +++ b/packages/tui/src/components/composer/types.ts @@ -11,9 +11,14 @@ 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"; +/** Built-in composer shape identifiers shipped by pi-tui. */ +export const BUILTIN_EDITOR_BORDER_STYLES = ["box", "claude", "pi", "borderless", "rule", "field", "rail"] as const; + +/** Identifier for a built-in composer shape. */ +export type BuiltinEditorBorderStyle = (typeof BUILTIN_EDITOR_BORDER_STYLES)[number]; + +/** Composer shape identifier; extensions may register additional strings. */ +export type EditorBorderStyle = string; /** Pre-rendered status content injected into the top chrome. */ export interface EditorTopBorder { @@ -32,10 +37,14 @@ export interface ComposerChromeContext { /** Horizontal padding inside the side chrome. */ paddingX: number; borderColor: (str: string) => string; + /** Stable accent used by shape-defining chrome such as field caps and rails. */ + accentColor: (str: string) => string; + /** Background fill for composer surfaces; preserves the fill across nested SGR resets. */ + surfaceColor: (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. */ + /** Status content for the top chrome; box embeds it after the corner while + * rule-based styles dock it against the right edge. */ topBorder?: EditorTopBorder; } @@ -63,13 +72,16 @@ export interface ComposerStyle { * 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 verticalChrome: 0 | 1 | 2; + /** Where the host should attach the status bar: embedded in the top border, + * docked onto a top rule, or detached into 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"; + /** Insert a blank spacer row between the editor and the standalone bottom + * bar. Styles without bottom chrome need it so the bar doesn't sit flush + * against the last input row. */ + readonly bottomBarGap: boolean; /** Default prompt gutter when the host sets none. */ readonly defaultPromptGutter: string | undefined; /** Default horizontal padding; `themePaddingX` is the theme's request. */ diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 34fdbdb27..b8d760550 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -36,6 +36,8 @@ export type { EditorBorderStyle, EditorTopBorder }; import { type SelectItem, SelectList, type SelectListLayoutOptions, type SelectListTheme } from "./select-list"; +const PASSTHROUGH_COLOR = (text: string): string => text; + const AUTOCOMPLETE_SELECT_LIST_LAYOUT: SelectListLayoutOptions = { overflowSearch: false, }; @@ -390,6 +392,10 @@ interface WrapEntry { export interface EditorTheme { borderColor: (str: string) => string; + /** Stable accent for composer chrome that should not follow the mutable border state. */ + accentColor?: (str: string) => string; + /** Background fill used by filled composer styles. */ + surfaceColor?: (str: string) => string; selectList: SelectListTheme; symbols: SymbolTheme; editorPaddingX?: number; @@ -982,6 +988,8 @@ export class Editor implements Component, Focusable { width, paddingX, borderColor: (str: string) => this.borderColor(str), + accentColor: this.#theme.accentColor ?? this.borderColor, + surfaceColor: this.#theme.surfaceColor ?? PASSTHROUGH_COLOR, box, topBorder, }; diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 4a2b79b4f..568a27334 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -2989,5 +2989,45 @@ describe("Editor component", () => { expect(lines[0]).toContain("╭"); expect(lines[1]).toContain("╰─ hello"); }); + + it("renders rule style as a top-rule dock without closing chrome", () => { + const editor = new Editor(unicodeTheme); + editor.setBorderStyle("rule"); + editor.setText("hello"); + const lines = editor.render(20); + expect(lines).toHaveLength(2); + expect(lines[0]).toBe("─".repeat(20)); + expect(lines[1]).toStartWith("❯ hello"); + }); + + it("renders field style as one filled row with accent caps", () => { + const editor = new Editor({ + ...unicodeTheme, + accentColor: text => `\x1b[35m${text}\x1b[39m`, + surfaceColor: text => `\x1b[44m${text}\x1b[49m`, + }); + editor.setBorderStyle("field"); + editor.setText("hello"); + const [line] = editor.render(20); + expect(stripVTControlCharacters(line)).toStartWith("▐ hello"); + expect(stripVTControlCharacters(line)).toEndWith("▌"); + expect(visibleWidth(line)).toBe(20); + expect(line).toContain("\x1b[44m"); + }); + + it("renders rail style as a full-width surface with one accent edge", () => { + const editor = new Editor({ + ...unicodeTheme, + accentColor: text => `\x1b[35m${text}\x1b[39m`, + surfaceColor: text => `\x1b[44m${text}\x1b[49m`, + }); + editor.setBorderStyle("rail"); + editor.setText("hello"); + const [line] = editor.render(20); + expect(stripVTControlCharacters(line)).toStartWith("▎ hello"); + expect(stripVTControlCharacters(line)).not.toContain("▌"); + expect(visibleWidth(line)).toBe(20); + expect(line).toContain("\x1b[44m"); + }); }); });