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");
});
});