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