201 lines
7.0 KiB
TypeScript
201 lines
7.0 KiB
TypeScript
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test";
|
|
import { resetSettingsForTest, Settings, settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
|
import { SettingsSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/settings-selector";
|
|
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
|
|
|
beforeAll(async () => {
|
|
await initTheme();
|
|
});
|
|
|
|
let geometryStub: { restore(): void } | undefined;
|
|
|
|
beforeEach(async () => {
|
|
resetSettingsForTest();
|
|
await Settings.init({ inMemory: true });
|
|
geometryStub = stubStdoutGeometry(120);
|
|
});
|
|
|
|
afterEach(() => {
|
|
resetSettingsForTest();
|
|
geometryStub?.restore();
|
|
geometryStub = undefined;
|
|
});
|
|
|
|
function stubStdoutGeometry(cols: number): { restore(): void } {
|
|
const rowsDesc = Object.getOwnPropertyDescriptor(process.stdout, "rows");
|
|
const colsDesc = Object.getOwnPropertyDescriptor(process.stdout, "columns");
|
|
const rows = 40;
|
|
Object.defineProperty(process.stdout, "rows", { configurable: true, get: () => rows, set: () => {} });
|
|
Object.defineProperty(process.stdout, "columns", { configurable: true, get: () => cols, set: () => {} });
|
|
const restoreOne = (key: "rows" | "columns", desc: PropertyDescriptor | undefined) => {
|
|
if (desc) Object.defineProperty(process.stdout, key, desc);
|
|
else Object.defineProperty(process.stdout, key, { configurable: true, value: undefined, writable: true });
|
|
};
|
|
return {
|
|
restore() {
|
|
restoreOne("rows", rowsDesc);
|
|
restoreOne("columns", colsDesc);
|
|
},
|
|
};
|
|
}
|
|
|
|
function createSelector(onCancel: () => void = () => {}): SettingsSelectorComponent {
|
|
return new SettingsSelectorComponent(
|
|
{
|
|
availableThinkingLevels: [],
|
|
thinkingLevel: undefined,
|
|
availableThemes: ["dark"],
|
|
providers: [],
|
|
cwd: process.cwd(),
|
|
},
|
|
{
|
|
onChange: () => {},
|
|
onCancel,
|
|
},
|
|
);
|
|
}
|
|
|
|
/** Switch the selector to the memory tab. SETTING_TABS puts memory at index 4 (after appearance/model/interaction/context). */
|
|
function focusMemoryTab(comp: SettingsSelectorComponent): void {
|
|
for (let i = 0; i < 4; i++) {
|
|
comp.handleInput("\x1b[C");
|
|
}
|
|
}
|
|
|
|
describe("SettingsSelectorComponent memory tab", () => {
|
|
it("reveals condition-gated Hindsight rows the moment memory.backend changes via the submenu", () => {
|
|
settings.set("memory.backend", "off");
|
|
const comp = createSelector();
|
|
focusMemoryTab(comp);
|
|
// Width 70 keeps the flat single-column layout (the wide split layout
|
|
// shows only the active section's rows, covered by the sidebar test).
|
|
const before = comp.render(70).join("\n");
|
|
expect(before).toContain("Memory Backend");
|
|
expect(before).not.toContain("Hindsight API URL");
|
|
|
|
// Memory Backend is the only visible row, so it's already selected at index 0.
|
|
// Enter opens the SelectSubmenu pre-positioned on "off"; navigate to "hindsight" (index 2) and confirm.
|
|
comp.handleInput("\n");
|
|
comp.handleInput("\x1b[B");
|
|
comp.handleInput("\x1b[B");
|
|
comp.handleInput("\n");
|
|
|
|
expect(settings.get("memory.backend")).toBe("hindsight");
|
|
const after = comp.render(70).join("\n");
|
|
expect(after).toContain("Memory Backend");
|
|
expect(after).toContain("Hindsight API URL");
|
|
expect(after).toContain("Hindsight Auto Recall");
|
|
});
|
|
|
|
it("hides Hindsight rows again when the backend is switched back to off without leaving the tab", () => {
|
|
settings.set("memory.backend", "hindsight");
|
|
const comp = createSelector();
|
|
focusMemoryTab(comp);
|
|
// Width 70 keeps the flat layout so all sections' rows render inline.
|
|
expect(comp.render(70).join("\n")).toContain("Hindsight API URL");
|
|
|
|
// Open Memory Backend → SelectSubmenu pre-selects the current value
|
|
// ("hindsight" at index 2) → step up twice to reach "off" → Enter confirms.
|
|
comp.handleInput("\n");
|
|
comp.handleInput("\x1b[A");
|
|
comp.handleInput("\x1b[A");
|
|
comp.handleInput("\n");
|
|
|
|
expect(settings.get("memory.backend")).toBe("off");
|
|
const after = comp.render(70).join("\n");
|
|
expect(after).toContain("Memory Backend");
|
|
expect(after).not.toContain("Hindsight API URL");
|
|
expect(after).not.toContain("Hindsight Auto Recall");
|
|
});
|
|
|
|
it("clears the global settings search on Escape before closing the selector", () => {
|
|
let cancelCount = 0;
|
|
const comp = createSelector(() => {
|
|
cancelCount++;
|
|
});
|
|
|
|
// Typing starts the cross-tab search: banner shows the query and matches.
|
|
comp.handleInput("b");
|
|
const strip = (line: string): string => line.replace(/\x1b\[[0-9;]*m/g, "");
|
|
const searching = comp.render(120).map(strip).join("\n");
|
|
const banner =
|
|
comp
|
|
.render(120)
|
|
.map(strip)
|
|
.find(line => /\d+ match/.test(line)) ?? "";
|
|
expect(banner).toContain(" b ");
|
|
expect(searching).toMatch(/\d+ match/);
|
|
|
|
// First Escape exits search mode without closing the panel.
|
|
comp.handleInput("\x1b");
|
|
expect(cancelCount).toBe(0);
|
|
expect(comp.render(120).join("\n")).not.toContain("matches");
|
|
|
|
comp.handleInput("\x1b");
|
|
expect(cancelCount).toBe(1);
|
|
});
|
|
|
|
it("puts the exact global settings search hit before incidental matches", () => {
|
|
const comp = createSelector();
|
|
for (const ch of "image provider") comp.handleInput(ch);
|
|
|
|
const strip = (line: string): string => line.replace(/\x1b\[[0-9;]*m/g, "");
|
|
const rendered = comp.render(120).map(strip).join("\n");
|
|
const providersIndex = rendered.indexOf("Providers");
|
|
const appearanceIndex = rendered.indexOf("Appearance");
|
|
|
|
expect(rendered).toContain("Image Provider");
|
|
expect(rendered).not.toContain("Include Model in Prompt");
|
|
expect(rendered).not.toContain("Service Tier");
|
|
expect(providersIndex).toBeGreaterThanOrEqual(0);
|
|
if (appearanceIndex >= 0) {
|
|
expect(appearanceIndex).toBeGreaterThan(providersIndex);
|
|
}
|
|
});
|
|
|
|
it("supports editor hotkeys in the global search bar", () => {
|
|
const comp = createSelector();
|
|
const strip = (line: string): string => line.replace(/\x1b\[[0-9;]*m/g, "");
|
|
const banner = (): string =>
|
|
comp
|
|
.render(120)
|
|
.map(strip)
|
|
.find(line => /\d+ match/.test(line)) ?? "";
|
|
|
|
// alt+backspace deletes the trailing word from the query.
|
|
for (const ch of "image provider") comp.handleInput(ch);
|
|
comp.handleInput("\x1b\x7f");
|
|
expect(banner()).toContain("image");
|
|
expect(banner()).not.toContain("provider");
|
|
|
|
// Arrow keys move the cursor; typing inserts mid-query instead of appending.
|
|
comp.handleInput("\x15"); // ctrl+u clears the rest of the query
|
|
for (const ch of "model") comp.handleInput(ch);
|
|
for (let i = 0; i < 5; i++) comp.handleInput("\x1b[D");
|
|
comp.handleInput("x");
|
|
expect(banner()).toContain("xmodel");
|
|
});
|
|
|
|
it("delegates Escape to an open settings submenu before closing the selector", () => {
|
|
let cancelCount = 0;
|
|
settings.set("memory.backend", "off");
|
|
const comp = createSelector(() => {
|
|
cancelCount++;
|
|
});
|
|
focusMemoryTab(comp);
|
|
|
|
comp.handleInput("\n");
|
|
expect(comp.render(120).join("\n")).toContain("Esc to go back");
|
|
|
|
comp.handleInput("\x1b");
|
|
const afterBack = comp.render(120).join("\n");
|
|
expect(cancelCount).toBe(0);
|
|
expect(afterBack).toContain("Memory Backend");
|
|
expect(afterBack).toContain("Esc to close");
|
|
expect(afterBack).not.toContain("Esc to go back");
|
|
|
|
comp.handleInput("\x1b");
|
|
expect(cancelCount).toBe(1);
|
|
});
|
|
});
|