feat: introduced custom composer styles and runtime extension registration

- Added new field, rail, and rule composer styles with accent and surface color support.
- Introduced runtime extension registration for custom composer shapes and styles.
- Updated status line rendering and theme adapters to support dynamic composer styling and gaps.
- Added comprehensive unit tests for new composer shapes, extension loading, and rendering behaviors.
This commit is contained in:
can1357
2026-08-20 06:11:57 +02:00
parent baf894425c
commit 43a557081f
28 changed files with 562 additions and 99 deletions
@@ -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",
},
},
@@ -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(),
@@ -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<string, ComposerShapeDefinition>();
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
@@ -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<string, MessageRenderer>;
composerShapes: Map<string, ComposerShapeDefinition>;
commands: Map<string, RegisteredCommand>;
flags: Map<string, ExtensionFlag>;
shortcuts: Map<KeyId, ExtensionShortcut>;
@@ -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;
}
@@ -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<string, SubmenuOption>();
/** 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()];
}
@@ -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<void>) | 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
@@ -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<ComposerStyle, "bottomBar" | "bottomBarGap">): 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) {
@@ -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<string, ExtensionUiComponent>();
#hookWidgetsBelow = new Map<string, ExtensionUiComponent>();
// 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();
}
/**
@@ -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();
}
@@ -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<Record<ComposerShape, string>> = {
box: "Rounded Box (Default)",
claude: "Claude Code",
pi: "Pi",
borderless: "Borderless",
};
const COMPOSER_DESCRIPTIONS: Readonly<Record<ComposerShape, string>> = {
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, "");
}
@@ -128,6 +128,8 @@ const LANG_BRAND_COLORS: Partial<Record<SymbolKey, string>> = {
"lang.julia": "#9558b2",
};
const BACKGROUND_RESET_PATTERN = /\x1b\[(?:0|49)m/g;
export class Theme {
#fgColors: Record<ThemeColor, string>;
#bgColors: Record<ThemeBg, string>;
@@ -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);
}
@@ -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),
@@ -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 = `
@@ -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 () => {
@@ -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);
@@ -12,6 +12,7 @@ export const borderlessComposerStyle: ComposerStyle = {
verticalChrome: 0,
statusAttachment: "none",
bottomBar: "full",
bottomBarGap: false,
defaultPromptGutter: "❯ ",
defaultPaddingX(): number {
@@ -12,6 +12,7 @@ export const boxComposerStyle: ComposerStyle = {
verticalChrome: 2,
statusAttachment: "top-border",
bottomBar: "none",
bottomBarGap: false,
defaultPromptGutter: undefined,
defaultPaddingX(themePaddingX: number | undefined): number {
@@ -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[] {
@@ -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;
},
};
@@ -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";
@@ -12,6 +12,7 @@ export const piComposerStyle: ComposerStyle = {
verticalChrome: 2,
statusAttachment: "none",
bottomBar: "full",
bottomBarGap: false,
defaultPromptGutter: undefined,
defaultPaddingX(): number {
@@ -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;
},
};
@@ -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<EditorBorderStyle, ComposerStyle> = {
const BUILTIN_COMPOSER_STYLES: Readonly<Record<string, ComposerStyle>> = {
box: boxComposerStyle,
claude: claudeComposerStyle,
pi: piComposerStyle,
borderless: borderlessComposerStyle,
rule: ruleComposerStyle,
field: fieldComposerStyle,
rail: railComposerStyle,
};
const extensionComposerStyles = new Map<string, ComposerStyle>();
/** 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;
}
@@ -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;
},
};
+21 -9
View File
@@ -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. */
+8
View File
@@ -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,
};
+40
View File
@@ -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");
});
});
});