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