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