Files
oh-my-pi/packages/coding-agent/src/modes/components/composer-shape-preview.ts
T
can1357 e8d7c0a493 feat: introduced pluggable composer layouts and status line gauge modes
- Introduced embedded mode and context gauge render options for the status line alongside boundary calculation helpers.
- Added pluggable composer styles with a style registry supporting box, claude, pi, and borderless layout rendering.
- Extracted speculation lead resolution into a dedicated shared module and added theme symbol definitions.
- Expanded test coverage for status line context gauge modes, composer shape previews, and editor style assertions.
2026-08-20 05:41:42 +02:00

121 lines
4.0 KiB
TypeScript

/**
* Live preview for the `composer.shape` setting and the setup-wizard composer
* scene. Chrome is rendered through the same {@link ComposerStyle} objects the
* real editor uses, and status rows come from the live
* {@link ComposerPreviewStatusSource} (the session's StatusLineComponent) —
* nothing about the preview is a re-implementation, so it cannot drift from
* the real composer. Only the prompt text is a stand-in.
*/
import {
type Component,
type ComposerChromeContext,
type EditorTopBorder,
getComposerStyle,
padding,
truncateToWidth,
visibleWidth,
} from "@oh-my-pi/pi-tui";
import type { ComposerShape } from "../../config/settings-schema";
import { theme } from "../theme/theme";
/**
* Real status renderer the preview borrows rows from — structurally satisfied
* by {@link StatusLineComponent}. Layout is parameterized so a preview can
* render a candidate shape's placement instead of the active one.
*/
export interface ComposerPreviewStatusSource {
/** Powerline bar with the context gauge (box top border content). */
getTopBorder(width: number): { content: string; width: number };
/** Plain right-group chip (claude top rule content). */
getStandaloneTopBorder(width: number): { content: string; width: number };
/** Plain standalone bottom bar carrying the given segment groups. */
renderBottomBar(width: number, groups: "left" | "full"): string;
}
export interface ComposerShapePreviewOptions {
requestRender?: () => void;
/** Live status renderer; omitted (tests), the chrome renders without status rows. */
status?: ComposerPreviewStatusSource;
}
export function renderComposerShapePreview(
shape: ComposerShape,
width: number,
status?: ComposerPreviewStatusSource,
): readonly string[] {
const previewWidth = Math.max(24, Math.min(width, 76));
const style = getComposerStyle(shape);
const paddingX = style.defaultPaddingX(undefined);
const chromeWidth = style.sideChromeWidth(paddingX);
let topBorder: EditorTopBorder | undefined;
if (status) {
if (style.statusAttachment === "top-border") {
const availableWidth = Math.max(1, previewWidth - chromeWidth * 2);
topBorder = status.getTopBorder(availableWidth);
} else if (style.statusAttachment === "top-rule-chip") {
topBorder = status.getStandaloneTopBorder(previewWidth);
}
}
const ctx: ComposerChromeContext = {
width: previewWidth,
paddingX,
borderColor: (str: string) => theme.fg("borderAccent", str),
box: theme.boxRound,
topBorder,
};
const gutter = style.defaultPromptGutter ?? "";
const contentWidth = Math.max(1, previewWidth - chromeWidth * 2 - visibleWidth(gutter));
const promptText = truncateToWidth("Ask anything, edit files, run tools", Math.max(1, contentWidth - 1));
const text = `${theme.fg("text", promptText)}${theme.inverse(" ")}`;
const pad = padding(Math.max(0, contentWidth - visibleWidth(promptText) - 1));
const styledGutter = gutter ? theme.fg("accent", gutter) : "";
const lines: string[] = [];
const top = style.renderTop(ctx);
if (top !== undefined) lines.push(top);
lines.push(
...style.renderRow({
...ctx,
text,
pad,
gutter: styledGutter,
isLastRow: true,
cursorOverflow: 0,
imeSafeCursorTail: false,
scrollbarThumb: false,
}),
);
const bottom = style.renderBottom(ctx);
if (bottom !== undefined) lines.push(bottom);
if (style.bottomBar !== "none" && status) {
const bar = status.renderBottomBar(previewWidth, style.bottomBar);
if (bar) lines.push(bar);
}
return lines;
}
export class ComposerShapePreview implements Component {
#shape: ComposerShape;
#options: ComposerShapePreviewOptions;
constructor(initialValue: ComposerShape = "box", options: ComposerShapePreviewOptions = {}) {
this.#shape = initialValue;
this.#options = options;
}
setValue(shape: ComposerShape): void {
if (this.#shape === shape) return;
this.#shape = shape;
this.#options.requestRender?.();
}
render(width: number): readonly string[] {
const lines = renderComposerShapePreview(this.#shape, width, this.#options.status);
return ["", theme.fg("muted", "Preview:"), ...lines];
}
}