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