feat: introduced customizable composer shapes and status line rendering options

- Add support for customizable composer shapes and editor border styles including box, claude, pi, and borderless.
- Introduce a setup wizard scene and settings selector integration for choosing and previewing composer shapes.
- Enhance status line rendering to support standalone placement and context usage percentage shading.
- Add comprehensive unit tests covering composer shape previews, editor border styles, and status line rendering.
This commit is contained in:
can1357
2026-08-20 05:06:17 +02:00
parent 14992fbc41
commit 3ef3b43b49
20 changed files with 668 additions and 97 deletions
+1
View File
@@ -4,6 +4,7 @@
### Added
- Added `composer.shape` setting (`/settings` → Appearance → Composer) to customize the editor's visual layout, including live layout previews in settings and the setup wizard
- Added `omp ps` for inspecting and controlling daemon-broker supervised processes from outside the harness: an interactive alt-screen monitor on TTYs (live table, info/logs views, stop/kill/restart, all-scopes toggle) plus static `--plain`/`--json` listings and `info`/`logs`/`stop`/`kill`/`restart` subactions with `--all`, `--dir`, and `--global` scope selectors. Brokers now record their project directory in `scope.json` so runtime scopes can be mapped back to projects offline.
- Added `qwenTemplateReasoningEffort` to the `models.yml` `compat` schema, so the auto-enabled Qwen 3.8+ template effort dialect (`chat_template_kwargs.reasoning_effort`) can be switched off per provider/model for strict local servers that reject unknown `chat_template_kwargs`.
- Added `tokenizer` to custom model and `modelOverrides` configuration. It overrides the catalog-resolved local tokenizer family for a model when a proxy serves a known model id with a different tokenizer.
@@ -81,6 +81,9 @@ import {
export type ModelRoleStorage = "global" | "project";
export type ComposerShape = "box" | "claude" | "pi" | "borderless";
export const COMPOSER_SHAPE_VALUES = ["box", "claude", "pi", "borderless"] as const;
export type SettingTab =
| "appearance"
| "model"
@@ -130,7 +133,7 @@ export const TAB_METADATA: Record<SettingTab, { label: string; icon: `tab.${stri
* Ungrouped settings render first, before any section heading.
*/
export const TAB_GROUPS: Record<SettingTab, readonly string[]> = {
appearance: ["Theme", "Status Line", "Display", "Images"],
appearance: ["Theme", "Composer", "Status Line", "Display", "Images"],
model: ["Thinking", "Sampling", "Prompt", "Retry & Fallback", "Advisor", "Prewalk", "Vision"],
interaction: [
"Input",
@@ -633,6 +636,40 @@ export const SETTINGS_SCHEMA = {
description: "Use blue instead of green for diff additions",
},
},
// Composer
"composer.shape": {
type: "enum",
values: COMPOSER_SHAPE_VALUES,
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",
},
],
},
},
// Status line
"statusLine.preset": {
@@ -0,0 +1,97 @@
import { type Component, padding, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
import type { ComposerShape } from "../../config/settings-schema";
import { theme } from "../theme/theme";
export interface ComposerShapePreviewOptions {
requestRender?: () => void;
}
function fitLine(line: string, width: number): string {
const truncated = truncateToWidth(line, width);
return truncated + padding(Math.max(0, width - visibleWidth(truncated)));
}
export function renderMockStatusLine(width: number): string {
const sep = theme.fg("statusLineSep", ` ${theme.sep.powerlineThin} `);
const leftContent = [
theme.fg("statusLineModel", `${theme.icon.model} sonnet`),
theme.fg("statusLinePath", "~/project"),
theme.fg("statusLineGitDirty", `${theme.icon.git} main +2`),
].join(sep);
const rightContent = [
theme.fg("statusLineContext", `${theme.icon.context} 42%`),
theme.fg("statusLineCost", `${theme.icon.cost} 0.18`),
].join(sep);
const bgAnsi = theme.getBgAnsi("statusLineBg");
const isTransparent = bgAnsi === "\x1b[49m" || !bgAnsi;
const capAnsi = isTransparent ? "" : bgAnsi.replace("\x1b[48;", "\x1b[38;");
const leftCap = isTransparent ? "" : `${capAnsi}${theme.sep.powerline}\x1b[39m`;
const rightCap = isTransparent ? "" : `${capAnsi}${theme.sep.powerlineLeft}\x1b[39m`;
const leftGroup = `${theme.bg("statusLineBg", ` ${leftContent} `)}${leftCap}`;
const rightGroup = `${rightCap}${theme.bg("statusLineBg", ` ${rightContent} `)}`;
const leftWidth = visibleWidth(leftGroup);
const rightWidth = visibleWidth(rightGroup);
const gapWidth = Math.max(1, width - leftWidth - rightWidth);
const usedCount = Math.round(0.42 * gapWidth);
const unusedCount = gapWidth - usedCount;
const usedFill = usedCount > 0 ? theme.fg("borderAccent", theme.boxRound.horizontal.repeat(usedCount)) : "";
const unusedFill = unusedCount > 0 ? theme.fg("border", theme.boxRound.horizontal.repeat(unusedCount)) : "";
const gap = `\x1b[49m${usedFill}${unusedFill}\x1b[39m`;
return `${leftGroup}${gap}${rightGroup}`;
}
export function renderComposerShapePreview(shape: ComposerShape, width: number): readonly string[] {
const previewWidth = Math.max(24, Math.min(width, 76));
const box = theme.boxRound;
const innerWidth = Math.max(1, previewWidth - 2);
const promptText = "Ask anything, edit files, run tools";
switch (shape) {
case "box": {
const statusContent = renderMockStatusLine(innerWidth);
const top = `${theme.fg("borderAccent", `${box.topLeft}${box.horizontal}`)} ${statusContent} ${theme.fg("borderAccent", `${box.horizontal}${box.topRight}`)}`;
const bottomInner = `${promptText} `;
const bottomFill = box.horizontal.repeat(Math.max(0, innerWidth - visibleWidth(bottomInner) - 2));
const bottom = `${theme.fg("borderAccent", `${box.bottomLeft}${box.horizontal} `)}${theme.fg("text", promptText)}${theme.inverse(" ")}${theme.fg("borderAccent", ` ${bottomFill}${box.bottomRight}`)}`;
return [top, bottom];
}
case "claude": {
const rule = theme.fg("borderAccent", box.horizontal.repeat(previewWidth));
const prompt = `${theme.fg("accent", "❯")} ${theme.fg("text", promptText)}${theme.inverse(" ")}`;
return [rule, prompt, rule, renderMockStatusLine(previewWidth)];
}
case "pi": {
const horizontal = box.horizontal.repeat(innerWidth);
const top = theme.fg("borderAccent", `${box.topLeft}${horizontal}${box.topRight}`);
const prompt = `${theme.fg("accent", ">")} ${theme.fg("text", promptText)}${theme.inverse(" ")}`;
const content = `${theme.fg("borderAccent", box.vertical)} ${fitLine(prompt, innerWidth - 2)} ${theme.fg("borderAccent", box.vertical)}`;
const bottom = theme.fg("borderAccent", `${box.bottomLeft}${horizontal}${box.bottomRight}`);
return [top, content, bottom, renderMockStatusLine(previewWidth)];
}
case "borderless": {
const prompt = `${theme.fg("accent", "❯")} ${theme.fg("text", promptText)}${theme.inverse(" ")}`;
return [prompt, renderMockStatusLine(previewWidth)];
}
}
}
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);
return ["", theme.fg("muted", "Preview:"), ...lines];
}
}
@@ -35,6 +35,7 @@ import {
validateProviderMaxInFlightRequests,
} from "../../config/settings";
import type {
ComposerShape,
SettingTab,
StatusLinePreset,
StatusLineSegmentId,
@@ -44,6 +45,7 @@ import { SETTING_TABS, TAB_METADATA } from "../../config/settings-schema";
import { getCurrentThemeName, getSelectListTheme, getSettingsListTheme, theme } from "../../modes/theme/theme";
import { AUTO_THINKING, type ConfiguredThinkingLevel } from "../../thinking";
import { getTabBarTheme } from "../shared";
import { ComposerShapePreview } from "./composer-shape-preview";
import { bottomBorder, divider, row, topBorder } from "./overlay-box";
import { handleInputOrEscape, PluginSettingsComponent } from "./plugin-settings";
import { getSettingDef, getSettingsForTab, type SettingDef } from "./settings-defs";
@@ -1124,8 +1126,13 @@ 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, {
requestRender: this.context.requestRender,
});
onPreview = value => shapePreview.setValue(value as ComposerShape);
footer = shapePreview;
}
// Provide status line preview for theme selection
const isThemeSetting = def.path === "theme.dark" || def.path === "theme.light";
const getPreview = isThemeSetting ? this.callbacks.getStatusLinePreview : undefined;
@@ -291,6 +291,7 @@ function hasGitBackedSegment(segments: readonly StatusLineSegmentId[]): boolean
// ═══════════════════════════════════════════════════════════════════════════
export class StatusLineComponent implements Component {
#standalone = false;
#widthEpochRevision = 0;
#settings: StatusLineSettings = {};
#effectiveSettings: EffectiveStatusLineSettings | undefined;
@@ -1537,7 +1538,7 @@ export class StatusLineComponent implements Component {
return { usedTokens: cache.usedTokens, contextWindow: cache.contextWindow };
}
const usage = this.session.getContextUsage();
const usage = typeof this.session.getContextUsage === "function" ? this.session.getContextUsage() : undefined;
const usedTokens = usage?.tokens ?? 0;
const contextWindow = usage?.contextWindow ?? modelContextWindow;
this.#contextUsageCache = {
@@ -1559,7 +1560,6 @@ export class StatusLineComponent implements Component {
width: number,
segmentOptions: StatusLineSettings["segmentOptions"],
includePath: boolean,
includeContext: boolean,
includeGit: boolean,
includePr: boolean,
): SegmentContext {
@@ -1587,15 +1587,10 @@ export class StatusLineComponent implements Component {
};
let contextWindow = state.model?.contextWindow ?? this.session.model?.contextWindow ?? 0;
let contextPercent: number | null = 0;
let contextTokens = 0;
if (includeContext) {
const breakdown = this.getCachedContextBreakdown();
contextTokens = breakdown.usedTokens;
contextWindow = breakdown.contextWindow || contextWindow;
contextPercent = contextWindow > 0 ? (breakdown.usedTokens / contextWindow) * 100 : null;
}
const breakdown = this.getCachedContextBreakdown();
let contextTokens = breakdown.usedTokens;
contextWindow = breakdown.contextWindow || contextWindow;
let contextPercent: number | null = contextWindow > 0 ? (breakdown.usedTokens / contextWindow) * 100 : null;
// Collab guest: context comes from the host's state frames — the local
// replica does no accounting of its own.
const collabState = this.#collabStatus?.stateOverride;
@@ -1717,8 +1712,6 @@ export class StatusLineComponent implements Component {
const effectiveSettings = this.#resolveSettings();
const includePath =
hasPathSegment(effectiveSettings.leftSegments) || hasPathSegment(effectiveSettings.rightSegments);
const includeContext =
hasContextSegment(effectiveSettings.leftSegments) || hasContextSegment(effectiveSettings.rightSegments);
const gitEnabled = this.#gitEnabled();
const includeGit =
gitEnabled &&
@@ -1729,7 +1722,6 @@ export class StatusLineComponent implements Component {
width,
effectiveSettings.segmentOptions,
includePath,
includeContext,
includeGit,
includePr,
);
@@ -1891,8 +1883,17 @@ export class StatusLineComponent implements Component {
const accentHex = sessionName
? getSessionAccentHex(sessionName, theme.getMajorThemeColorHexes(), theme.accentSurfaceLuminance)
: undefined;
const gapColor = getSessionAccentAnsi(accentHex) ?? theme.getFgAnsi("border");
const gapFill = `${gapColor}${theme.boxRound.horizontal.repeat(gapWidth)}\x1b[39m`;
const gapColor = getSessionAccentAnsi(accentHex) ?? theme.getFgAnsi("borderAccent");
const unusedColor = theme.getFgAnsi("border");
let usedCount = gapWidth;
if (ctx.contextPercent !== null && ctx.contextPercent !== undefined) {
const clampedPct = Math.min(100, Math.max(0, ctx.contextPercent));
usedCount = Math.min(gapWidth, Math.max(0, Math.round((clampedPct / 100) * gapWidth)));
}
const unusedCount = gapWidth - usedCount;
const usedFill = usedCount > 0 ? `${gapColor}${theme.boxRound.horizontal.repeat(usedCount)}` : "";
const unusedFill = unusedCount > 0 ? `${unusedColor}${theme.boxRound.horizontal.repeat(unusedCount)}` : "";
const gapFill = `\x1b[49m${usedFill}${unusedFill}\x1b[39m`;
return leftGroup + gapFill + rightGroup;
}
@@ -1909,16 +1910,28 @@ export class StatusLineComponent implements Component {
revision: this.#widthEpochRevision,
};
}
setStandalone(standalone: boolean): void {
this.#standalone = standalone;
}
render(width: number): readonly string[] {
// Only render hook statuses - main status is in editor's top border
const showHooks = this.#settings.showHookStatus ?? true;
if (!showHooks || this.#hookStatuses.size === 0) {
return [];
const lines: string[] = [];
if (this.#standalone) {
let content = this.#buildStatusLine(width);
if (content) {
if (this.#focusedAgentId) {
content = `\x1b[2m${content.replaceAll("\x1b[0m", "\x1b[0m\x1b[2m")}\x1b[22m`;
}
lines.push(content);
}
}
return Array.from(this.#hookStatuses.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([, text]) => truncateToWidth(sanitizeStatusText(text), width));
const showHooks = this.#settings.showHookStatus ?? true;
if (showHooks && this.#hookStatuses.size > 0) {
const hookLines = Array.from(this.#hookStatuses.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([, text]) => truncateToWidth(sanitizeStatusText(text), width));
lines.push(...hookLines);
}
return lines;
}
}
@@ -436,7 +436,7 @@ const costSegment: StatusLineSegment = {
const advisorCost = ctx.session.getAdvisorCost?.() ?? 0;
const normalizedPremiumRequests = normalizePremiumRequests(premiumRequests);
const state = ctx.session.state;
const usingSubscription = state.model ? ctx.session.modelRegistry.isUsingOAuth(state.model) : false;
const usingSubscription = state.model ? (ctx.session.modelRegistry?.isUsingOAuth(state.model) ?? false) : false;
if (!cost && !advisorCost && !usingSubscription && !normalizedPremiumRequests) {
return { content: "", visible: false };
@@ -443,6 +443,9 @@ export class SelectorController {
this.ctx.session.setAutoCompactionEnabled(value as boolean);
this.ctx.statusLine.setAutoCompactEnabled(value as boolean);
break;
case "composer.shape":
this.ctx.syncComposerShape();
break;
case "advisor.enabled":
this.ctx.session.setAdvisorEnabled(value as boolean);
this.ctx.statusLine.invalidate();
@@ -67,6 +67,7 @@ import {
Settings,
settings,
} from "../config/settings";
import type { ComposerShape } from "../config/settings-schema";
import { clearClaudePluginRootsCache } from "../discovery/helpers";
import type {
AutocompleteProviderFactory,
@@ -1105,11 +1106,12 @@ export class InteractiveMode implements InteractiveModeContext {
// HUDs, just above the editor's hook-widget top margin — so it reads next to
// the prompt while keeping the one-line gap above the editor.
this.ui.addChild(this.statusContainer);
this.ui.addChild(this.statusLine); // Only renders hook statuses (main status in editor border)
this.ui.addChild(this.hookWidgetContainerAbove);
this.ui.addChild(this.editorContainer);
this.ui.addChild(this.hookWidgetContainerBelow);
this.ui.addChild(this.statusLine);
this.ui.setFocus(this.editor);
this.syncComposerShape();
this.#inputController.setupKeyHandlers();
this.#inputController.setupEditorSubmitHandler();
@@ -1920,6 +1922,20 @@ export class InteractiveMode implements InteractiveModeContext {
compactThinkingLevel: settings.get("statusLine.compactThinkingLevel"),
});
}
syncComposerShape(): void {
const shape = (settings.get("composer.shape") as ComposerShape) ?? "box";
this.editor.setBorderStyle(shape);
if (shape === "box") {
this.editor.setTopBorderProvider(availableWidth => this.statusLine.getTopBorder(availableWidth));
this.statusLine.setStandalone(false);
} else {
this.editor.setTopBorderProvider(undefined);
this.editor.setTopBorder(undefined);
this.statusLine.setStandalone(true);
}
this.updateEditorBorderColor();
this.ui.requestRender();
}
#handleSessionAccentInputsChanged(): void {
this.#clearWorkingMessageAccentCache();
@@ -4470,7 +4486,8 @@ export class InteractiveMode implements InteractiveModeContext {
this.ui.requestRender();
};
nextEditor.setShimmerRepaintHandler(() => this.ui.requestDirectWrite(nextEditor));
nextEditor.setTopBorderProvider(availableWidth => this.statusLine.getTopBorder(availableWidth));
this.editor = nextEditor;
this.syncComposerShape();
nextEditor.setMaxHeight(this.#computeEditorMaxHeight());
if (this.historyStorage) {
nextEditor.setHistoryStorage(this.historyStorage);
@@ -8,4 +8,4 @@
* the overlay component and their TUI deps. MUST equal `max(scene.minVersion)`
* across `ALL_SCENES`; the `setup-wizard` barrel and test suite guard it.
*/
export const CURRENT_SETUP_VERSION = 1;
export const CURRENT_SETUP_VERSION = 2;
@@ -1,6 +1,7 @@
import type { Settings } from "../../config/settings";
import { CURRENT_SETUP_VERSION } from "../setup-version";
import type { InteractiveModeContext } from "../types";
import { composerSetupScene } from "./scenes/composer";
import { glyphSetupScene } from "./scenes/glyph";
import { modelSetupScene } from "./scenes/model";
import { providersSetupScene } from "./scenes/providers";
@@ -17,6 +18,7 @@ export const ALL_SCENES = [
providersSetupScene,
modelSetupScene,
glyphSetupScene,
composerSetupScene,
themeSetupScene,
] as const satisfies readonly SetupScene[];
@@ -0,0 +1,114 @@
import { routeSelectListMouse, type SelectItem, SelectList, type SgrMouseEvent } from "@oh-my-pi/pi-tui";
import type { ComposerShape } from "../../../config/settings-schema";
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;
#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";
this.#currentShape = initialShape;
const initialIndex = Math.max(0, COMPOSER_SHAPES.indexOf(initialShape));
const selectListTheme = getSelectListTheme();
this.#selectList = new SelectList(COMPOSER_ITEMS, COMPOSER_ITEMS.length, selectListTheme);
this.#selectList.setSelectedIndex(initialIndex);
this.#selectList.onSelectionChange = item => {
this.#preview(item.value as ComposerShape);
};
this.#selectList.onSelect = item => {
void this.#commit(item.value as ComposerShape);
};
this.#selectList.onCancel = () => {
// Esc skips the scene without saving; the configured shape stays untouched.
this.host.finish("skipped");
};
}
invalidate(): void {
this.#selectList.invalidate();
}
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) {
this.#selectList.setSelectedIndex(quickIndex);
this.#preview(COMPOSER_SHAPES[quickIndex] ?? "box");
return;
}
this.#selectList.handleInput(data);
}
routeMouse(event: SgrMouseEvent, line: number, _col: number): void {
const listLine = line - this.#listRowStart;
routeSelectListMouse(this.#selectList, event, listLine);
}
render(width: number, maxLines?: number): readonly string[] {
const budget = maxLines ?? Number.POSITIVE_INFINITY;
const lines = [theme.fg("muted", "Select a layout; live preview updates below. Press Enter to confirm."), ""];
const previewLines = renderComposerShapePreview(this.#currentShape, width);
if (budget - lines.length - previewLines.length - 2 >= COMPOSER_ITEMS.length) {
lines.push(theme.fg("muted", "Preview:"), ...previewLines, "");
}
this.#listRowStart = lines.length;
lines.push(...this.#selectList.render(width));
return lines;
}
async #commit(shape: ComposerShape): Promise<void> {
if (this.#committing) return;
this.#committing = true;
try {
this.host.ctx.settings.set("composer.shape", shape);
await this.host.ctx.settings.flush();
} finally {
this.host.finish("done");
}
}
#preview(shape: ComposerShape): void {
this.#currentShape = shape;
this.host.requestRender();
}
}
export const composerSetupScene: SetupScene = {
id: "composer-shape",
title: "Choose composer shape",
minVersion: 2,
mount: host => new ComposerSceneController(host),
};
+1
View File
@@ -121,6 +121,7 @@ export interface InteractiveModeContext {
hookWidgetContainerAbove: Container;
hookWidgetContainerBelow: Container;
statusLine: StatusLineComponent;
syncComposerShape(): void;
// Session access
session: AgentSession;
@@ -105,6 +105,8 @@ function createContext(): {
const hasActiveBtw = vi.fn(() => false);
const handleOmfgEscape = vi.fn(() => true);
const hasActiveOmfg = vi.fn(() => false);
const handleCleanseEscape = vi.fn(() => true);
const hasActiveCleanse = vi.fn(() => false);
const updatePendingMessagesDisplay = vi.fn();
const prompt = vi.fn();
const startPendingSubmission = vi.fn(
@@ -214,6 +216,8 @@ function createContext(): {
hasActiveBtw,
handleOmfgEscape,
hasActiveOmfg,
handleCleanseEscape,
hasActiveCleanse,
showTreeSelector: vi.fn(),
showUserMessageSelector: vi.fn(),
showSessionSelector: vi.fn(),
@@ -0,0 +1,100 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test";
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { COMPOSER_SHAPE_VALUES, type ComposerShape } from "@oh-my-pi/pi-coding-agent/config/settings-schema";
import {
ComposerShapePreview,
renderComposerShapePreview,
renderMockStatusLine,
} from "@oh-my-pi/pi-coding-agent/modes/components/composer-shape-preview";
import { SettingsSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/settings-selector";
import { initTheme, setTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
beforeAll(async () => {
await initTheme();
});
describe("composer shape preview", () => {
beforeEach(async () => {
resetSettingsForTest();
await Settings.init({ inMemory: true });
});
afterEach(() => {
resetSettingsForTest();
});
const shapes: ComposerShape[] = [...COMPOSER_SHAPE_VALUES];
it.each(shapes)("renders %s shape preview without throwing in dark theme", async (shape: ComposerShape) => {
await setTheme("dark");
const lines = renderComposerShapePreview(shape, 80);
expect(lines.length).toBeGreaterThan(0);
const joined = lines.join("\n");
expect(joined).toContain("Ask anything");
});
it.each(shapes)("renders %s shape preview without throwing in light theme", async (shape: ComposerShape) => {
await setTheme("light");
const lines = renderComposerShapePreview(shape, 80);
expect(lines.length).toBeGreaterThan(0);
const joined = lines.join("\n");
expect(joined).toContain("Ask anything");
});
it("updates preview when setValue is called on ComposerShapePreview component", async () => {
await setTheme("dark");
let renderRequested = false;
const preview = new ComposerShapePreview("box", {
requestRender: () => {
renderRequested = true;
},
});
const initialLines = preview.render(80);
expect(initialLines.some(l => l.includes("Preview:"))).toBe(true);
preview.setValue("claude");
expect(renderRequested).toBe(true);
const nextLines = preview.render(80);
expect(nextLines.some(l => l.includes("Preview:"))).toBe(true);
});
it("renders mock status line with powerline separator and status elements", async () => {
await setTheme("dark");
const rendered = renderMockStatusLine(80);
expect(rendered).toContain("sonnet");
expect(rendered).toContain("~/project");
expect(rendered).toContain("42%");
});
it("renders preview inside SettingsSelectorComponent submenu without crashing", async () => {
await setTheme("dark");
const selector = new SettingsSelectorComponent(
{
availableThinkingLevels: [],
thinkingLevel: undefined,
availableThemes: ["dark", "light"],
providers: [],
cwd: process.cwd(),
},
{
onChange: () => {},
onCancel: () => {},
},
);
for (const ch of "composer shape") selector.handleInput(ch);
// Open the composer.shape submenu
selector.handleInput("\n");
const rendered = selector.render(80).join("\n");
expect(rendered).toContain("Composer Shape");
expect(rendered).toContain("Preview:");
expect(rendered).toContain("Ask anything");
// Cycle down to claude
selector.handleInput("\x1b[B");
const nextRendered = selector.render(80).join("\n");
expect(nextRendered).toContain("Claude Code");
expect(nextRendered).toContain("Preview:");
});
});
@@ -194,7 +194,7 @@ describe("OmfgController", () => {
[PROJECT_OPTION, GLOBAL_OPTION, AMEND_OPTION],
]);
expect(harness.ttsrAddRule.mock.calls[0]?.[0].path).toBe(savedPath);
const rendered = Bun.stripANSI(harness.container.render(120).join("\n"));
const rendered = Bun.stripANSI(harness.container.render(200).join("\n"));
expect(rendered).toContain("Registered live");
expect(rendered).toContain(path.join(".omp", "rules", "ts-no-any.md"));
expect(rendered).toContain("Esc dismiss");
@@ -390,7 +390,7 @@ describe("setup wizard short terminals", () => {
component.handleInput(key);
const frame = component.render(80).map(line => Bun.stripANSI(line));
expect(frame.length).toBe(24);
expect(frame.some(line => line.trimStart().startsWith(theme.nav.cursor))).toBe(true);
expect(frame.some(line => line.includes(`${theme.nav.cursor} `))).toBe(true);
}
} finally {
nowSpy.mockRestore();
@@ -51,6 +51,7 @@ function makeSession(opts: { messages: unknown[]; contextWindow?: number; usage?
agent: { state: { tools: [] } },
skills: [],
model: { id: "test-model", contextWindow },
modelRegistry: { isUsingOAuth: () => false },
state: { messages: opts.messages, model: { contextWindow } },
sessionManager: {
getUsageStatistics: () => ({
@@ -68,6 +69,7 @@ function makeSession(opts: { messages: unknown[]; contextWindow?: number; usage?
getSessionName: () => "test",
},
getAsyncJobSnapshot: () => ({ running: [] }),
isFastModeActive: () => false,
getContextUsage: () => {
calls++;
return usage;
@@ -203,7 +205,7 @@ describe("StatusLineComponent context breakdown", () => {
expect(breakdown.contextWindow).toBe(128_000);
});
it("does not query usage when no context segment is rendered", () => {
it("memoizes usage queries so repeated renders query only once", () => {
const { session, usageCalls } = makeSession({ messages: [userMessage("hi")] });
const comp = new StatusLineComponent(session);
comp.updateSettings({
@@ -213,9 +215,11 @@ describe("StatusLineComponent context breakdown", () => {
separator: "powerline-thin",
});
const border = comp.getTopBorder(80);
expect(border.content.length).toBeGreaterThan(0);
expect(usageCalls()).toBe(0);
const border1 = comp.getTopBorder(80);
const border2 = comp.getTopBorder(80);
expect(border1.content.length).toBeGreaterThan(0);
expect(border2.content.length).toBeGreaterThan(0);
expect(usageCalls()).toBe(1);
});
it("renders the anchored percent against the (sub-)budget window in the context segment", () => {
@@ -271,4 +275,38 @@ describe("StatusLineComponent context breakdown", () => {
expect(plain).toContain("5K/?");
expect(plain).not.toContain("0.0%/0");
});
it("dims the unused portion of the gap fill between left and right segments based on context usage", () => {
const { session } = makeSession({
messages: [userMessage("hi"), assistantMessage("done")],
usage: { tokens: 50_000, contextWindow: 100_000, percent: 50 },
});
const comp = new StatusLineComponent(session);
comp.updateSettings({
preset: "custom",
leftSegments: ["pi"],
rightSegments: ["session_name"],
separator: "none",
sessionAccent: false,
});
const border = comp.getTopBorder(80).content;
// With 50% context usage, the gap fill contains a faint sequence \x1b[2m for the right half
expect(border).toContain("\x1b[2m");
expect(border).toContain("\x1b[22m");
});
it("renders standalone status line when setStandalone is true", () => {
const { session } = makeSession({
messages: [userMessage("hi")],
usage: { tokens: 1000, contextWindow: 100_000, percent: 1 },
});
const comp = new StatusLineComponent(session);
expect(comp.render(80)).toHaveLength(0); // Not standalone -> no main status in render()
comp.setStandalone(true);
const lines = comp.render(80);
expect(lines).toHaveLength(1); // Standalone -> renders main status line
expect(lines[0]).toContain("pi");
});
});
+1
View File
@@ -4,6 +4,7 @@
### Added
- Added support for customizable editor border styles including box, claude, pi, and borderless
- Added support for warning risk notes and row markers in settings lists
## [17.3.8] - 2026-08-19
+108 -59
View File
@@ -385,6 +385,7 @@ export interface EditorTheme {
/** Style function for inline hint/ghost text (dim text after cursor) */
hintStyle?: (text: string) => string;
}
export type EditorBorderStyle = "box" | "claude" | "pi" | "borderless";
export interface EditorTopBorder {
/** The status content (already styled) */
@@ -523,7 +524,7 @@ export class Editor implements Component, Focusable {
#topBorderProviderSignature: string | undefined;
#topBorderProviderRevision: number | undefined;
#borderVisible = true;
#borderStyle: EditorBorderStyle = "box";
constructor(theme: EditorTheme) {
this.#theme = theme;
this.borderColor = theme.borderColor;
@@ -581,6 +582,15 @@ export class Editor implements Component, Focusable {
setPromptGutter(promptGutter: string | undefined): void {
this.#promptGutter = promptGutter;
}
getBorderStyle(): EditorBorderStyle {
return this.#borderStyle;
}
setBorderStyle(style: EditorBorderStyle): void {
if (this.#borderStyle === style) return;
this.#borderStyle = style;
this.#widthEpochRevision++;
}
/**
* Get the available width for top border content given a total terminal width.
@@ -724,31 +734,48 @@ export class Editor implements Component, Focusable {
// No cached state to invalidate currently
}
#isBorderVisible(): boolean {
return this.#borderVisible && this.#borderStyle !== "borderless";
}
#getEffectivePromptGutter(): string | undefined {
if (this.#promptGutter !== undefined) return this.#promptGutter;
if (this.#borderStyle === "claude" || this.#borderStyle === "borderless") return "❯ ";
if (this.#borderStyle === "pi") return "> ";
return undefined;
}
#getEditorPaddingX(): number {
const padding = this.#paddingXOverride ?? this.#theme.editorPaddingX ?? 2;
if (this.#paddingXOverride !== undefined) return Math.max(0, this.#paddingXOverride);
if (this.#borderStyle === "claude" || this.#borderStyle === "borderless") return 0;
if (this.#borderStyle === "pi") return 1;
const padding = this.#theme.editorPaddingX ?? 2;
return Math.max(0, padding);
}
#getHorizontalChromeWidth(paddingX: number): number {
return this.#borderVisible ? paddingX + 1 : 0;
if (!this.#isBorderVisible() || this.#borderStyle === "claude") return 0;
return paddingX + 1;
}
#getPromptGutterWidth(width: number, paddingX: number): number {
if (this.#borderVisible || !this.#promptGutter) return 0;
const gutter = this.#getEffectivePromptGutter();
if (!gutter) return 0;
const chromeWidth = 2 * this.#getHorizontalChromeWidth(paddingX);
const availableWidth = Math.max(0, width - chromeWidth);
return Math.min(visibleWidth(this.#promptGutter), availableWidth);
return Math.min(visibleWidth(gutter), availableWidth);
}
#getPromptGutter(
width: number,
paddingX: number,
): { firstLine: string; continuation: string; width: number } | undefined {
if (this.#borderVisible || !this.#promptGutter) return undefined;
const gutter = this.#getEffectivePromptGutter();
if (!gutter) return undefined;
const gutterWidth = this.#getPromptGutterWidth(width, paddingX);
if (gutterWidth === 0) return undefined;
return {
firstLine: sliceByColumn(this.#promptGutter, 0, gutterWidth, true),
firstLine: sliceByColumn(gutter, 0, gutterWidth, true),
continuation: padding(gutterWidth),
width: gutterWidth,
};
@@ -761,17 +788,17 @@ export class Editor implements Component, Focusable {
#getLayoutWidth(width: number, paddingX: number): number {
const contentWidth = this.#getContentWidth(width, paddingX);
const cursorReserve = this.#borderVisible && paddingX === 0 ? 1 : 0;
const isBox = this.#isBorderVisible() && this.#borderStyle !== "claude";
const cursorReserve = isBox && paddingX === 0 ? 1 : 0;
// Keep cursor/scroll layout addressable even when a borderless prompt gutter consumes every visible column.
return Math.max(1, contentWidth - cursorReserve);
}
#getVisibleContentHeight(contentLines: number): number {
if (this.#maxHeight === undefined) return contentLines;
const verticalChrome = this.#borderVisible ? 2 : 0;
const verticalChrome = !this.#isBorderVisible() ? 0 : 2;
return Math.max(1, this.#maxHeight - verticalChrome);
}
/** Apply the optional input decorator to a plain (ANSI-free) text segment.
* Decoration only adds zero-width SGR codes, so visible width is unchanged.
* Splits around CURSOR_MARKER so each user-text segment is decorated in
@@ -883,7 +910,9 @@ export class Editor implements Component, Focusable {
render(width: number): readonly string[] {
const paddingX = this.#getEditorPaddingX();
const borderVisible = this.#borderVisible;
const borderVisible = this.#isBorderVisible();
const borderStyle = this.#borderStyle;
const isSideBordered = borderVisible && borderStyle !== "claude";
const promptGutter = this.#getPromptGutter(width, paddingX);
const contentAreaWidth = this.#getContentWidth(width, paddingX);
const layoutWidth = this.#getLayoutWidth(width, paddingX);
@@ -922,47 +951,48 @@ export class Editor implements Component, Focusable {
}
if (borderVisible) {
// Render top border: ╭─ [status content] ────────────────╮
const topFillWidth = Math.max(0, width - borderWidth * 2);
// Provider (lazy) wins over eager content — a host that installs both
// wants the coalesced path; falling back to eager keeps existing
// setTopBorder callers working unchanged.
let topBorder: EditorTopBorder | undefined;
if (this.#topBorderProvider) {
const previousWidth = this.#topBorderProviderWidth;
topBorder = this.#topBorderProvider(topFillWidth);
const signature = topBorder ? `${topBorder.width}\0${topBorder.content}` : "";
const revision = topBorder?.revision;
if (
(previousWidth !== undefined &&
revision !== undefined &&
this.#topBorderProviderRevision !== undefined &&
revision !== this.#topBorderProviderRevision) ||
(previousWidth === topFillWidth && signature !== this.#topBorderProviderSignature)
) {
this.#widthEpochRevision++;
}
this.#topBorderProviderWidth = topFillWidth;
this.#topBorderProviderSignature = signature;
this.#topBorderProviderRevision = revision;
} else {
topBorder = this.#topBorderContent;
}
if (topBorder) {
const { content, width: statusWidth } = topBorder;
if (statusWidth <= topFillWidth) {
// Status fits - add fill after it
const fillWidth = topFillWidth - statusWidth;
result.push(topLeft + content + this.borderColor(box.horizontal.repeat(fillWidth)) + topRight);
} else {
// Status too long - truncate it
const truncated = truncateToWidth(content, Math.max(0, topFillWidth - 1));
const truncatedWidth = visibleWidth(truncated);
const fillWidth = Math.max(0, topFillWidth - truncatedWidth);
result.push(topLeft + truncated + this.borderColor(box.horizontal.repeat(fillWidth)) + topRight);
}
} else {
if (borderStyle === "claude") {
result.push(this.borderColor(box.horizontal.repeat(width)));
} else if (borderStyle === "pi") {
const topFillWidth = Math.max(0, width - borderWidth * 2);
result.push(topLeft + horizontal.repeat(topFillWidth) + topRight);
} else {
const topFillWidth = Math.max(0, width - borderWidth * 2);
let topBorder: EditorTopBorder | undefined;
if (this.#topBorderProvider) {
const previousWidth = this.#topBorderProviderWidth;
topBorder = this.#topBorderProvider(topFillWidth);
const signature = topBorder ? `${topBorder.width}\0${topBorder.content}` : "";
const revision = topBorder?.revision;
if (
(previousWidth !== undefined &&
revision !== undefined &&
this.#topBorderProviderRevision !== undefined &&
revision !== this.#topBorderProviderRevision) ||
(previousWidth === topFillWidth && signature !== this.#topBorderProviderSignature)
) {
this.#widthEpochRevision++;
}
this.#topBorderProviderWidth = topFillWidth;
this.#topBorderProviderSignature = signature;
this.#topBorderProviderRevision = revision;
} else {
topBorder = this.#topBorderContent;
}
if (topBorder) {
const { content, width: statusWidth } = topBorder;
if (statusWidth <= topFillWidth) {
const fillWidth = topFillWidth - statusWidth;
result.push(topLeft + content + this.borderColor(box.horizontal.repeat(fillWidth)) + topRight);
} else {
const truncated = truncateToWidth(content, Math.max(0, topFillWidth - 1));
const truncatedWidth = visibleWidth(truncated);
const fillWidth = Math.max(0, topFillWidth - truncatedWidth);
result.push(topLeft + truncated + this.borderColor(box.horizontal.repeat(fillWidth)) + topRight);
}
} else {
result.push(topLeft + horizontal.repeat(topFillWidth) + topRight);
}
}
}
@@ -991,12 +1021,12 @@ export class Editor implements Component, Focusable {
const hasCursor = layoutLine.hasCursor && layoutLine.cursorPos !== undefined;
const marker = emitCursorMarker ? CURSOR_MARKER : "";
if (!borderVisible && displayWidth > lineContentWidth) {
if (!isSideBordered && displayWidth > lineContentWidth) {
displayText = sliceByColumn(displayText, 0, lineContentWidth, true);
displayWidth = visibleWidth(displayText);
}
if (!borderVisible && lineContentWidth === 0) {
if (!isSideBordered && lineContentWidth === 0) {
if (hasCursor && !this.#useTerminalCursor) {
const zeroWidthCursorBudget = visibleWidth(gutterText);
const zeroWidthCursorReplacement = this.cursorOverride
@@ -1039,7 +1069,7 @@ export class Editor implements Component, Focusable {
if (marker) {
const before = displayText.slice(0, layoutLine.cursorPos);
const after = displayText.slice(layoutLine.cursorPos);
if (this.#imeSafeCursorLayout && after.length === 0 && borderVisible) {
if (this.#imeSafeCursorLayout && after.length === 0 && isSideBordered) {
// Terminal frontends render IME marked text locally before committed bytes
// reach the application. Keep the end-of-input cursor row empty to its
// right so that insertion cannot shift box chrome onto the next row.
@@ -1050,7 +1080,7 @@ export class Editor implements Component, Focusable {
const hintText = hintStyle(truncateToWidth(inlineHint, availWidth));
displayText = before + marker + hintText;
displayWidth += Math.min(visibleWidth(inlineHint), availWidth);
} else if (after.length === 0 && !borderVisible && displayWidth >= lineContentWidth) {
} else if (after.length === 0 && !isSideBordered && displayWidth >= lineContentWidth) {
displayText = this.#renderTerminalCursorMarker(before, marker, lineContentWidth);
} else {
displayText = before + marker + after;
@@ -1076,7 +1106,7 @@ export class Editor implements Component, Focusable {
} else if (this.cursorOverride) {
// Cursor override replaces the normal end-of-text cursor glyph
const overrideWidth = this.cursorOverrideWidth ?? 1;
if (!borderVisible && displayWidth + overrideWidth > lineContentWidth) {
if (!isSideBordered && displayWidth + overrideWidth > lineContentWidth) {
// Borderless editors have no spare padding cell for an end-of-line cursor glyph.
// Preserve cursorOverride by replacing the tail of the line with it.
const widthLimitedCursor = this.#renderEndOfLineCursorAtWidthLimit(before, marker, lineContentWidth, {
@@ -1097,7 +1127,7 @@ export class Editor implements Component, Focusable {
} else {
// Cursor is at the end - add thin cursor glyph
const { text: cursor, width: cursorWidth } = this.#getStyledInputCursor();
if (!borderVisible && displayWidth + cursorWidth > lineContentWidth) {
if (!isSideBordered && displayWidth + cursorWidth > lineContentWidth) {
// Borderless editors have no spare padding cell for an end-of-line cursor glyph.
// Highlight the last grapheme so the cursor stays visible without consuming width.
const widthLimitedCursor = this.#renderEndOfLineCursorAtWidthLimit(before, marker, lineContentWidth);
@@ -1136,17 +1166,26 @@ export class Editor implements Component, Focusable {
const linePad = padding(Math.max(0, lineContentWidth - displayWidth));
if (!borderVisible) {
if (!isSideBordered) {
result.push(gutterText + displayText + linePad);
continue;
}
const rightChromeCells = Math.max(1, paddingX + 1 - cursorPaddingOverflow);
if (borderStyle === "pi") {
const leftBorder = this.borderColor(`${box.vertical}${padding(paddingX)}`);
const inThumb = scrollbarThumb && visibleIndex >= scrollbarThumb.start && visibleIndex < scrollbarThumb.end;
const rightGlyph = inThumb ? "█" : box.vertical;
const rightBorder = this.borderColor(`${padding(Math.max(0, rightChromeCells - 1))}${rightGlyph}`);
result.push(leftBorder + gutterText + displayText + linePad + rightBorder);
continue;
}
// All lines have consistent borders based on padding. When the end-of-line cursor
// glyph (or a wide trailing grapheme) extends past `lineContentWidth`, shrink the
// right chrome by the exact overflow count: drop padding spaces first, then the
// trailing `─`, but never the corner/vertical bar itself.
const isLastLine = visibleIndex === visibleLayoutLines.length - 1;
const rightChromeCells = Math.max(1, paddingX + 1 - cursorPaddingOverflow);
if (isLastLine && imeSafeCursorTail) {
const leftBorder = this.borderColor(`${box.vertical}${padding(paddingX)}`);
const bottomBorder = this.borderColor(
@@ -1174,6 +1213,16 @@ export class Editor implements Component, Focusable {
}
}
if (borderVisible) {
if (borderStyle === "claude") {
result.push(this.borderColor(box.horizontal.repeat(width)));
} else if (borderStyle === "pi") {
result.push(
this.borderColor(`${box.bottomLeft}${box.horizontal.repeat(Math.max(0, width - 2))}${box.bottomRight}`),
);
}
}
// Add autocomplete list if active
if (this.#autocompleteState && this.#autocompleteList) {
const autocompleteResult = this.#autocompleteList.render(width);
+89 -2
View File
@@ -3,9 +3,8 @@ import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { stripVTControlCharacters } from "node:util";
import { CURSOR_MARKER, TUI } from "@oh-my-pi/pi-tui";
import { CURSOR_MARKER, Editor, type EditorTheme, TUI } from "@oh-my-pi/pi-tui";
import { CombinedAutocompleteProvider } from "@oh-my-pi/pi-tui/autocomplete";
import { Editor } from "@oh-my-pi/pi-tui/components/editor";
import { KeybindingsManager, setKeybindings, TUI_KEYBINDINGS } from "@oh-my-pi/pi-tui/keybindings";
import { setKittyProtocolActive } from "@oh-my-pi/pi-tui/keys";
import { visibleWidth } from "@oh-my-pi/pi-tui/utils";
@@ -2902,4 +2901,92 @@ describe("Editor component", () => {
expect(editor.getText()).toBe("single line");
});
});
describe("composer border styles", () => {
const unicodeTheme: EditorTheme = {
...defaultEditorTheme,
borderColor: (t: string) => t,
symbols: {
cursor: "❯",
inputCursor: "│",
boxRound: {
topLeft: "╭",
topRight: "╮",
bottomLeft: "╰",
bottomRight: "╯",
horizontal: "─",
vertical: "│",
},
boxSharp: {
topLeft: "┌",
topRight: "┐",
bottomLeft: "└",
bottomRight: "┘",
horizontal: "─",
vertical: "│",
teeDown: "┬",
teeUp: "┴",
teeLeft: "├",
teeRight: "┤",
cross: "┼",
},
table: {
topLeft: "┌",
topRight: "┐",
bottomLeft: "└",
bottomRight: "┘",
horizontal: "─",
vertical: "│",
teeDown: "┬",
teeUp: "┴",
teeLeft: "├",
teeRight: "┤",
cross: "┼",
},
quoteBorder: "│",
hrChar: "─",
spinnerFrames: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
},
};
it("renders claude style with top and bottom horizontal rules and prompt gutter", () => {
const editor = new Editor(unicodeTheme);
editor.setBorderStyle("claude");
editor.setText("hello");
const lines = editor.render(20);
expect(lines.length).toBe(3); // top rule, content, bottom rule
expect(lines[0]).toBe("─".repeat(20));
expect(lines[1]).toContain("❯ hello");
expect(lines[2]).toBe("─".repeat(20));
});
it("renders pi style with framed box, prompt gutter, and separate bottom border", () => {
const editor = new Editor(unicodeTheme);
editor.setBorderStyle("pi");
editor.setText("hello");
const lines = editor.render(20);
expect(lines.length).toBe(3); // top border, content, bottom border
expect(lines[0]).toBe(`╭${"─".repeat(18)}╮`);
expect(lines[1]).toContain("> hello");
expect(lines[2]).toBe(`╰${"─".repeat(18)}╯`);
});
it("renders borderless style without box borders", () => {
const editor = new Editor(unicodeTheme);
editor.setBorderStyle("borderless");
editor.setText("hello");
const lines = editor.render(20);
expect(lines.length).toBe(1); // content only
expect(lines[0]).toContain("❯ hello");
});
it("renders default box style with compact bottom border", () => {
const editor = new Editor(unicodeTheme);
editor.setText("hello");
const lines = editor.render(20);
expect(lines.length).toBe(2); // top border, bottom border with content
expect(lines[0]).toContain("╭");
expect(lines[1]).toContain("╰─ hello");
});
});
});