feat(coding-agent/modes): introduced fullscreen agents hub and creation flow
- Replace the obsolete agent dashboard control center with a new fullscreen agents hub component. - Integrate AI-assisted agent creation architect flow using model sessions. - Provide interactive property strips and model browser integration for agent configuration. - Update tests and documentation to support the redesigned agents hub.
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
- Added first-party Nix support with reproducible source builds for Linux and macOS on x86-64 and ARM64, a pinned development shell, an overlay, NixOS and Home Manager modules, offline Bun dependencies, and lightweight flake evaluation in CI. Nix-managed installs now direct updates back through Nix instead of replacing store-managed executables.
|
||||
- `omp update` and the startup version check now follow an `omp.rename` pointer in the published npm manifest, preparing existing installs for the upcoming npm package rename. Migration is transactional: the renamed agent/natives packages are installed first (npm uses `--force` to take over the `omp` bin), so an install failure leaves the old install untouched; the old-name globals are removed only afterwards, and a broken bin link is restored by re-running the idempotent install before verification decides the outcome.
|
||||
- Added per-agent advisors: agent definitions accept an `advisor` frontmatter field (`true` = advise with the `advisor`-role model, `"<pattern>"` = an explicit advisor model with optional `:level` suffix), overridable via the `task.agentAdvisor` settings record. An explicit pattern lands on the spawned session's `modelRoles.advisor`, so different agents can be advised by different models; the effective opt-in is persisted in `session_init` and restored on cold revival, and each subagent advisor keeps its own `<session>/<SubId>/__advisor[.<slug>].jsonl` transcript.
|
||||
- Added inline override editors to `/agents`: `P` (prewalk) and `A` (advisor) now open the same pattern editor as the model override — accepting `on`, `off`, or a model pattern with suggestions and a live resolution preview — instead of only cycling on/off.
|
||||
- Redesigned `/agents` as a fullscreen hub in the `/models` idiom: a scope sidebar (All / Project / User / Bundled / New agent), type-to-filter agent rows with effective model/prewalk/advisor annotations, a pinned detail pane, and mouse support. Enter on an agent opens a property chip strip (enable, model, prewalk, advisor); each property is picked — on/off chips, the real model browser, or a raw pattern input — replacing the old `P`/`A`/`N` letter hotkeys.
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
|
||||
@@ -4744,7 +4744,7 @@ export const SETTINGS_SCHEMA = {
|
||||
group: "Subagents",
|
||||
label: "Generic Task Prewalk",
|
||||
description:
|
||||
"Arm prewalk for the bundled generic `task` subagent: it starts on its resolved model, plans and begins the implementation, then hands off to the 'smol' role at its first edit/write. Per-agent overrides (task.agentPrewalk, edited with P in /agents) and user agent `prewalk` frontmatter apply regardless of this toggle.",
|
||||
"Arm prewalk for the bundled generic `task` subagent: it starts on its resolved model, plans and begins the implementation, then hands off to the 'smol' role at its first edit/write. Per-agent overrides (task.agentPrewalk, configured from the /agents hub) and user agent `prewalk` frontmatter apply regardless of this toggle.",
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -80,8 +80,8 @@ import { copyToClipboard } from "../../utils/clipboard";
|
||||
import { repo } from "../../utils/git";
|
||||
import { setSessionTerminalTitle } from "../../utils/title-generator";
|
||||
import { type AdvisorConfigDeps, AdvisorConfigOverlayComponent } from "../components/advisor-config";
|
||||
import { AgentDashboard } from "../components/agent-dashboard";
|
||||
import { AgentHubOverlayComponent } from "../components/agent-hub";
|
||||
import { AgentsHubComponent } from "../components/agents-hub";
|
||||
import { AssistantMessageComponent } from "../components/assistant-message";
|
||||
import { CopySelectorComponent } from "../components/copy-selector";
|
||||
import { ExtensionDashboard } from "../components/extensions";
|
||||
@@ -388,31 +388,36 @@ export class SelectorController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the Agent Control Center dashboard.
|
||||
* Fullscreen agents hub on the alternate screen (the /models idiom): scope
|
||||
* sidebar, agent rows, and chip strips that dive into the model browser.
|
||||
*/
|
||||
async showAgentsDashboard(): Promise<void> {
|
||||
const activeModel = this.ctx.session.model;
|
||||
const activeModelPattern = activeModel ? `${activeModel.provider}/${activeModel.id}` : undefined;
|
||||
const defaultModelPattern = this.ctx.settings.getModelRole("default");
|
||||
const dashboard = await AgentDashboard.create(getProjectDir(), this.ctx.settings, this.ctx.ui.terminal.rows, {
|
||||
modelRegistry: this.ctx.session.modelRegistry,
|
||||
activeModelPattern,
|
||||
defaultModelPattern,
|
||||
});
|
||||
const overlay = this.ctx.ui.showOverlay(dashboard, {
|
||||
width: "100%",
|
||||
maxHeight: "100%",
|
||||
anchor: "top-left",
|
||||
margin: 0,
|
||||
});
|
||||
dashboard.onClose = () => {
|
||||
overlay.hide();
|
||||
let overlayHandle: OverlayHandle | undefined;
|
||||
let hub: AgentsHubComponent | undefined;
|
||||
let closed = false;
|
||||
const done = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
hub?.dispose();
|
||||
overlayHandle?.hide();
|
||||
this.focusActiveEditorArea();
|
||||
this.ctx.ui.requestRender();
|
||||
};
|
||||
dashboard.onRequestRender = () => {
|
||||
this.ctx.ui.requestRender();
|
||||
};
|
||||
hub = await AgentsHubComponent.create(
|
||||
this.ctx.ui,
|
||||
getProjectDir(),
|
||||
this.ctx.settings,
|
||||
{
|
||||
modelRegistry: this.ctx.session.modelRegistry,
|
||||
activeModelPattern,
|
||||
defaultModelPattern,
|
||||
},
|
||||
{ onCancel: () => done() },
|
||||
);
|
||||
overlayHandle = this.#showFullscreenMenu(hub);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1597,7 +1597,7 @@ async function createAgentSessionScoped(options: CreateAgentSessionOptions): Pro
|
||||
// Only the first top-level session in a process owns an AsyncJobManager.
|
||||
// Subagents inherit the parent's manager via `AsyncJobManager.instance()`
|
||||
// (set below), and any additional top-level session spun up in-process
|
||||
// (e.g. the agent-creation architect in `agent-dashboard.ts`) must share
|
||||
// (e.g. the agent-creation architect in `agents-hub.ts`) must share
|
||||
// the live singleton — otherwise its dispose path would clobber the
|
||||
// owning session's manager and break the `task`/`bash` async paths
|
||||
// (issue #1923). The `instance()` guard means later sessions also skip
|
||||
|
||||
@@ -442,7 +442,7 @@ export const BUILTIN_SESSION_SLASH_COMMANDS: ReadonlyArray<SlashCommandSpec> = [
|
||||
},
|
||||
{
|
||||
name: "agents",
|
||||
description: "Open Agent Control Center dashboard",
|
||||
description: "Open the agents hub (per-agent model, prewalk, and advisor)",
|
||||
handleTui: (_command, runtime) => {
|
||||
runtime.ctx.showAgentsDashboard();
|
||||
runtime.ctx.editor.setText("");
|
||||
|
||||
@@ -1,322 +0,0 @@
|
||||
import { afterEach, describe, expect, test, vi } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import { AgentDashboard } from "@oh-my-pi/pi-coding-agent/modes/components/agent-dashboard";
|
||||
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
||||
import * as discovery from "@oh-my-pi/pi-coding-agent/task/discovery";
|
||||
import { removeWithRetries } from "@oh-my-pi/pi-utils";
|
||||
|
||||
const ANSI_PATTERN = /\x1b\[[0-?]*[ -/]*[@-~]/g;
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
const settingsStub = {
|
||||
get: (_key: string) => undefined,
|
||||
set: (_key: string, _value: unknown) => {},
|
||||
getModelRole: (_role: string) => undefined,
|
||||
} as unknown as Settings;
|
||||
|
||||
async function makeTempCwd(): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-agent-dashboard-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function typeText(dashboard: AgentDashboard, text: string): void {
|
||||
for (const char of text) {
|
||||
dashboard.handleInput(char);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin the terminal geometry the dashboard reads via `process.stdout.rows/columns`
|
||||
* so the height-fit assertions don't depend on whether the suite runs under a TTY.
|
||||
*/
|
||||
function stubStdoutGeometry(cols: number): { setRows(n: number): void; restore(): void } {
|
||||
const rowsDesc = Object.getOwnPropertyDescriptor(process.stdout, "rows");
|
||||
const colsDesc = Object.getOwnPropertyDescriptor(process.stdout, "columns");
|
||||
let rows = 24;
|
||||
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 {
|
||||
setRows(n: number) {
|
||||
rows = n;
|
||||
},
|
||||
restore() {
|
||||
restoreOne("rows", rowsDesc);
|
||||
restoreOne("columns", colsDesc);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await Promise.all(tempDirs.splice(0).map(dir => removeWithRetries(dir)));
|
||||
});
|
||||
|
||||
describe("AgentDashboard create editor", () => {
|
||||
test("keeps carriage return as multiline editor text", async () => {
|
||||
await initTheme(false);
|
||||
const dashboard = await AgentDashboard.create(await makeTempCwd(), settingsStub, 24, {});
|
||||
|
||||
dashboard.handleInput("n");
|
||||
typeText(dashboard, "first line");
|
||||
dashboard.handleInput("\r");
|
||||
typeText(dashboard, "second line");
|
||||
const rendered = dashboard.render(80).join("\n").replace(ANSI_PATTERN, "");
|
||||
|
||||
expect(rendered).toContain("> first line");
|
||||
expect(rendered).toContain(" second line");
|
||||
expect(rendered).toContain("Ctrl+Q/Ctrl+Enter: generate");
|
||||
expect(rendered).toContain("Enter: newline");
|
||||
expect(rendered).not.toContain("Description is required.");
|
||||
});
|
||||
|
||||
test("submits multiline new-agent descriptions on CSI-u Ctrl+Enter", async () => {
|
||||
await initTheme(false);
|
||||
const dashboard = await AgentDashboard.create(await makeTempCwd(), settingsStub, 24, {});
|
||||
|
||||
dashboard.handleInput("n");
|
||||
typeText(dashboard, "first line");
|
||||
dashboard.handleInput("\r");
|
||||
typeText(dashboard, "second line");
|
||||
dashboard.handleInput("\x1b[13;5u");
|
||||
await Bun.sleep(0);
|
||||
const rendered = dashboard.render(80).join("\n").replace(ANSI_PATTERN, "");
|
||||
|
||||
expect(rendered).toContain("Model registry unavailable in current session.");
|
||||
expect(rendered).not.toContain("Description is required.");
|
||||
});
|
||||
|
||||
test("keeps bare LF as multiline editor text on non-Windows terminals", async () => {
|
||||
if (process.platform === "win32") return;
|
||||
await initTheme(false);
|
||||
const dashboard = await AgentDashboard.create(await makeTempCwd(), settingsStub, 24, {});
|
||||
|
||||
dashboard.handleInput("n");
|
||||
typeText(dashboard, "first line");
|
||||
dashboard.handleInput("\n");
|
||||
typeText(dashboard, "second line");
|
||||
const rendered = dashboard.render(80).join("\n").replace(ANSI_PATTERN, "");
|
||||
|
||||
expect(rendered).toContain("> first line");
|
||||
expect(rendered).toContain(" second line");
|
||||
expect(rendered).toContain("Ctrl+Q/Ctrl+Enter: generate");
|
||||
expect(rendered).toContain("Enter: newline");
|
||||
expect(rendered).not.toContain("Model registry unavailable in current session.");
|
||||
expect(rendered).not.toContain("Description is required.");
|
||||
});
|
||||
|
||||
test("submits new-agent descriptions on Ctrl+Q (Windows Terminal fallback for #2118)", async () => {
|
||||
await initTheme(false);
|
||||
const dashboard = await AgentDashboard.create(await makeTempCwd(), settingsStub, 24, {});
|
||||
|
||||
dashboard.handleInput("n");
|
||||
typeText(dashboard, "first line");
|
||||
dashboard.handleInput("\r");
|
||||
typeText(dashboard, "second line");
|
||||
// Ctrl+Q raw byte (0x11). Windows Terminal can't deliver a distinct
|
||||
// Ctrl+Enter event, so the app.message.followUp keybinding doubles as a
|
||||
// portable submit chord and must apply to the create form too.
|
||||
dashboard.handleInput("\x11");
|
||||
await Bun.sleep(0);
|
||||
const rendered = dashboard.render(80).join("\n").replace(ANSI_PATTERN, "");
|
||||
|
||||
expect(rendered).toContain("Model registry unavailable in current session.");
|
||||
expect(rendered).not.toContain("Description is required.");
|
||||
});
|
||||
|
||||
test("Ctrl+Q still works after pressing Enter for a newline (Windows Terminal)", async () => {
|
||||
await initTheme(false);
|
||||
const dashboard = await AgentDashboard.create(await makeTempCwd(), settingsStub, 24, {});
|
||||
|
||||
dashboard.handleInput("n");
|
||||
typeText(dashboard, "line one");
|
||||
// Windows Terminal sends bare `\r` for both Enter and Ctrl+Enter; the
|
||||
// dashboard must treat `\r` as a newline so the user can keep typing.
|
||||
dashboard.handleInput("\r");
|
||||
typeText(dashboard, "line two");
|
||||
const beforeSubmit = dashboard.render(80).join("\n").replace(ANSI_PATTERN, "");
|
||||
expect(beforeSubmit).toContain("> line one");
|
||||
expect(beforeSubmit).toContain(" line two");
|
||||
expect(beforeSubmit).not.toContain("Model registry unavailable in current session.");
|
||||
|
||||
dashboard.handleInput("\x11");
|
||||
await Bun.sleep(0);
|
||||
const afterSubmit = dashboard.render(80).join("\n").replace(ANSI_PATTERN, "");
|
||||
|
||||
expect(afterSubmit).toContain("Model registry unavailable in current session.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentDashboard layout", () => {
|
||||
test("fills the terminal height exactly and keeps the footer visible", async () => {
|
||||
await initTheme(false);
|
||||
const geo = stubStdoutGeometry(100);
|
||||
try {
|
||||
geo.setRows(30);
|
||||
const dashboard = await AgentDashboard.create(await makeTempCwd(), settingsStub, 30, {});
|
||||
const lines = dashboard.render(100);
|
||||
const plain = lines.map(line => line.replace(ANSI_PATTERN, "")).join("\n");
|
||||
|
||||
// Full-screen overlay must occupy exactly the viewport — never overflow
|
||||
// past it (which is what pushed the controls into scrollback).
|
||||
expect(lines.length).toBe(30);
|
||||
expect(plain).toContain("Agent Control Center");
|
||||
expect(plain).toContain("Esc: close");
|
||||
} finally {
|
||||
geo.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("re-fits the body when the terminal height shrinks", async () => {
|
||||
await initTheme(false);
|
||||
const geo = stubStdoutGeometry(100);
|
||||
try {
|
||||
geo.setRows(30);
|
||||
const dashboard = await AgentDashboard.create(await makeTempCwd(), settingsStub, 30, {});
|
||||
expect(dashboard.render(100).length).toBe(30);
|
||||
|
||||
geo.setRows(18);
|
||||
const shrunk = dashboard.render(100);
|
||||
expect(shrunk.length).toBe(18);
|
||||
// Footer survives the shrink instead of being clipped off the bottom.
|
||||
expect(shrunk.map(line => line.replace(ANSI_PATTERN, "")).join("\n")).toContain("Esc: close");
|
||||
} finally {
|
||||
geo.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentDashboard tab navigation", () => {
|
||||
test("left/right arrows switch source tabs", async () => {
|
||||
await initTheme(false);
|
||||
vi.spyOn(discovery, "discoverAgents").mockResolvedValue({
|
||||
projectAgentsDir: null,
|
||||
agents: [
|
||||
{ name: "proj-agent", description: "p", systemPrompt: "", source: "project" },
|
||||
{ name: "bundled-agent", description: "b", systemPrompt: "", source: "bundled" },
|
||||
],
|
||||
});
|
||||
const geo = stubStdoutGeometry(120);
|
||||
try {
|
||||
geo.setRows(30);
|
||||
const dashboard = await AgentDashboard.create(await makeTempCwd(), settingsStub, 30, {});
|
||||
const strip = () => dashboard.render(120).join("\n").replace(ANSI_PATTERN, "");
|
||||
|
||||
// "All" tab shows every source.
|
||||
const all = strip();
|
||||
expect(all).toContain("proj-agent");
|
||||
expect(all).toContain("bundled-agent");
|
||||
|
||||
// Right arrow advances to the "Project" tab, filtering out bundled agents.
|
||||
dashboard.handleInput("\x1b[C");
|
||||
const project = strip();
|
||||
expect(project).toContain("proj-agent");
|
||||
expect(project).not.toContain("bundled-agent");
|
||||
|
||||
// Right again lands on "Bundled".
|
||||
dashboard.handleInput("\x1b[C");
|
||||
const bundled = strip();
|
||||
expect(bundled).toContain("bundled-agent");
|
||||
expect(bundled).not.toContain("proj-agent");
|
||||
|
||||
// Left arrow walks back to "Project".
|
||||
dashboard.handleInput("\x1b[D");
|
||||
const back = strip();
|
||||
expect(back).toContain("proj-agent");
|
||||
expect(back).not.toContain("bundled-agent");
|
||||
} finally {
|
||||
geo.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentDashboard model overrides", () => {
|
||||
test("opens with an array model chain from settings", async () => {
|
||||
await initTheme(false);
|
||||
vi.spyOn(discovery, "discoverAgents").mockResolvedValue({
|
||||
projectAgentsDir: null,
|
||||
agents: [{ name: "dev", description: "Development agent", systemPrompt: "", source: "project" }],
|
||||
});
|
||||
const settings = Settings.isolated({
|
||||
"task.agentModelOverrides": {
|
||||
dev: ["opencode-go/deepseek:high", "bailian/deepseek:high"],
|
||||
},
|
||||
});
|
||||
|
||||
const dashboard = await AgentDashboard.create(await makeTempCwd(), settings, 24, {});
|
||||
const rendered = dashboard.render(120).join("\n").replace(ANSI_PATTERN, "");
|
||||
|
||||
expect(rendered).toContain("dev");
|
||||
expect(rendered).toContain("Override: opencode-go/deepseek:high,bailian/deepseek:high");
|
||||
expect(rendered).not.toContain("Failed to load agents");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentDashboard prewalk", () => {
|
||||
test("shows the bundled task prewalk default when task.prewalk is enabled", async () => {
|
||||
await initTheme(false);
|
||||
vi.spyOn(discovery, "discoverAgents").mockResolvedValue({
|
||||
projectAgentsDir: null,
|
||||
agents: [{ name: "task", description: "Generic task agent", systemPrompt: "", source: "bundled" }],
|
||||
});
|
||||
const settings = Settings.isolated({ "task.prewalk": true });
|
||||
const dashboard = await AgentDashboard.create(await makeTempCwd(), settings, 24, {});
|
||||
const rendered = dashboard.render(100).join("\n").replace(ANSI_PATTERN, "");
|
||||
|
||||
expect(rendered).toContain("Prewalk: on");
|
||||
expect(rendered).not.toContain("Prewalk: off");
|
||||
});
|
||||
});
|
||||
describe("AgentDashboard override editors", () => {
|
||||
test("A opens the advisor editor and saves a model pattern to task.agentAdvisor", async () => {
|
||||
await initTheme(false);
|
||||
vi.spyOn(discovery, "discoverAgents").mockResolvedValue({
|
||||
projectAgentsDir: null,
|
||||
agents: [{ name: "task", description: "Generic task agent", systemPrompt: "", source: "bundled" }],
|
||||
});
|
||||
const settings = Settings.isolated();
|
||||
const dashboard = await AgentDashboard.create(await makeTempCwd(), settings, 24, {});
|
||||
const strip = () => dashboard.render(120).join("\n").replace(ANSI_PATTERN, "");
|
||||
|
||||
dashboard.handleInput("A");
|
||||
expect(strip()).toContain("Advisor override: task");
|
||||
|
||||
typeText(dashboard, "moonshot/k3");
|
||||
dashboard.handleInput("\r");
|
||||
|
||||
expect(settings.get("task.agentAdvisor")).toEqual({ task: "moonshot/k3" });
|
||||
const rendered = strip();
|
||||
expect(rendered).toContain("Advisor: on moonshot/k3");
|
||||
});
|
||||
|
||||
test("P opens the prewalk editor pre-filled and clears the override on empty submit", async () => {
|
||||
await initTheme(false);
|
||||
vi.spyOn(discovery, "discoverAgents").mockResolvedValue({
|
||||
projectAgentsDir: null,
|
||||
agents: [{ name: "dev", description: "Development agent", systemPrompt: "", source: "project" }],
|
||||
});
|
||||
// Seed via set(): Settings.isolated seeds the runtime-override layer,
|
||||
// which the dashboard's set() (global layer) could never shadow.
|
||||
const settings = Settings.isolated();
|
||||
settings.set("task.agentPrewalk", { dev: "on" });
|
||||
const dashboard = await AgentDashboard.create(await makeTempCwd(), settings, 24, {});
|
||||
const strip = () => dashboard.render(120).join("\n").replace(ANSI_PATTERN, "");
|
||||
|
||||
dashboard.handleInput("p");
|
||||
expect(strip()).toContain("Prewalk override: dev");
|
||||
|
||||
// Pre-filled with the persisted "on"; clearing it reverts to agent default.
|
||||
typeText(dashboard, "\x7f\x7f");
|
||||
dashboard.handleInput("\r");
|
||||
|
||||
expect(settings.get("task.agentPrewalk")).toEqual({});
|
||||
expect(strip()).toContain("Prewalk: off");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Contracts of the fullscreen /agents hub: frame geometry, scope sidebar
|
||||
* filtering, type-to-filter search, the Space enable/disable toggle, and the
|
||||
* strip-driven configuration flows (property strips, pattern input, and the
|
||||
* model-browser pick) persisting to the per-agent settings records.
|
||||
*/
|
||||
import { afterEach, describe, expect, test, vi } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { Effort } from "@oh-my-pi/pi-ai";
|
||||
import { buildModel } from "@oh-my-pi/pi-catalog/build";
|
||||
import type { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import { AgentsHubComponent } from "@oh-my-pi/pi-coding-agent/modes/components/agents-hub";
|
||||
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
||||
import * as discovery from "@oh-my-pi/pi-coding-agent/task/discovery";
|
||||
import type { TUI } from "@oh-my-pi/pi-tui";
|
||||
import { removeWithRetries } from "@oh-my-pi/pi-utils";
|
||||
|
||||
const ANSI_PATTERN = /\x1b\[[0-?]*[ -/]*[@-~]/g;
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
// Narrow TUI stub: the hub only reads terminal rows and requests renders.
|
||||
const tuiStub = { requestRender: () => {}, terminal: { rows: 30 } } as unknown as TUI;
|
||||
|
||||
const sonnet = buildModel({
|
||||
id: "claude-sonnet-4-5",
|
||||
name: "Claude Sonnet 4.5",
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
reasoning: true,
|
||||
thinking: { mode: "budget", efforts: [Effort.Low, Effort.Medium, Effort.High] },
|
||||
input: ["text"],
|
||||
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
});
|
||||
|
||||
// Registry stub: the hub uses getAvailable() for browser items and resolution.
|
||||
const registryStub = { getAvailable: () => [sonnet] } as unknown as ModelRegistry;
|
||||
|
||||
async function makeTempCwd(): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-agents-hub-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function mockAgents(): void {
|
||||
vi.spyOn(discovery, "discoverAgents").mockResolvedValue({
|
||||
projectAgentsDir: null,
|
||||
agents: [
|
||||
{ name: "dev", description: "Development agent", systemPrompt: "", source: "project" },
|
||||
{ name: "scout", description: "Read-only research", systemPrompt: "", source: "bundled" },
|
||||
{ name: "task", description: "Generic task agent", systemPrompt: "", source: "bundled" },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async function createHub(settings: Settings): Promise<{
|
||||
hub: AgentsHubComponent;
|
||||
strip: () => string;
|
||||
type: (text: string) => void;
|
||||
cancelled: () => boolean;
|
||||
}> {
|
||||
await initTheme(false);
|
||||
let cancelled = false;
|
||||
const hub = await AgentsHubComponent.create(
|
||||
tuiStub,
|
||||
await makeTempCwd(),
|
||||
settings,
|
||||
{ modelRegistry: registryStub },
|
||||
{ onCancel: () => (cancelled = true) },
|
||||
);
|
||||
return {
|
||||
hub,
|
||||
strip: () => hub.render(120).join("\n").replace(ANSI_PATTERN, ""),
|
||||
type: (text: string) => {
|
||||
for (const char of text) hub.handleInput(char);
|
||||
},
|
||||
cancelled: () => cancelled,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await Promise.all(tempDirs.splice(0).map(dir => removeWithRetries(dir)));
|
||||
});
|
||||
|
||||
describe("AgentsHub layout", () => {
|
||||
test("renders the full-height split frame with sidebar scopes and agent rows", async () => {
|
||||
mockAgents();
|
||||
const { hub, strip } = await createHub(Settings.isolated());
|
||||
const lines = hub.render(120);
|
||||
// top border + content rows + divider + footer + bottom border = terminal rows.
|
||||
expect(lines.length).toBe(30);
|
||||
const rendered = strip();
|
||||
expect(rendered).toContain("Agents");
|
||||
expect(rendered).toContain("All agents");
|
||||
expect(rendered).toContain("Project");
|
||||
expect(rendered).toContain("Bundled");
|
||||
expect(rendered).toContain("dev");
|
||||
expect(rendered).toContain("scout");
|
||||
expect(rendered).toContain("+ New agent");
|
||||
});
|
||||
|
||||
test("sidebar scope filters the rows to one source", async () => {
|
||||
mockAgents();
|
||||
const { hub, strip } = await createHub(Settings.isolated());
|
||||
hub.handleInput("\x1b[D"); // left → scope focus
|
||||
hub.handleInput("\x1b[B"); // down → Project
|
||||
const rendered = strip();
|
||||
expect(rendered).toContain("Project agents · 1");
|
||||
expect(rendered).toContain("dev");
|
||||
expect(rendered).not.toContain("scout");
|
||||
});
|
||||
|
||||
test("type-to-filter narrows the list and Esc clears the query first", async () => {
|
||||
mockAgents();
|
||||
const { hub, strip, type, cancelled } = await createHub(Settings.isolated());
|
||||
type("sco");
|
||||
let rendered = strip();
|
||||
expect(rendered).toContain("scout");
|
||||
expect(rendered).not.toContain("dev");
|
||||
hub.handleInput("\x1b"); // Esc clears the query, not the hub
|
||||
expect(cancelled()).toBe(false);
|
||||
rendered = strip();
|
||||
expect(rendered).toContain("dev");
|
||||
hub.handleInput("\x1b");
|
||||
expect(cancelled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentsHub configuration strips", () => {
|
||||
test("Space toggles the selected agent's enabled state", async () => {
|
||||
mockAgents();
|
||||
const settings = Settings.isolated();
|
||||
const { hub } = await createHub(settings);
|
||||
hub.handleInput(" ");
|
||||
expect(settings.get("task.disabledAgents")).toEqual(["dev"]);
|
||||
hub.handleInput(" ");
|
||||
expect(settings.get("task.disabledAgents")).toEqual([]);
|
||||
});
|
||||
|
||||
test("Enter opens the property strip; advisor → on persists task.agentAdvisor", async () => {
|
||||
mockAgents();
|
||||
const settings = Settings.isolated();
|
||||
const { hub, strip } = await createHub(settings);
|
||||
hub.handleInput("\r"); // agent strip for `dev`
|
||||
expect(strip()).toContain("dev →");
|
||||
hub.handleInput("\x1b[C"); // model → prewalk
|
||||
hub.handleInput("\x1b[C"); // prewalk → advisor
|
||||
hub.handleInput("\r"); // advisor value strip
|
||||
expect(strip()).toContain("dev · advisor →");
|
||||
hub.handleInput("\x1b[C"); // agent default → on
|
||||
hub.handleInput("\r");
|
||||
expect(settings.get("task.agentAdvisor")).toEqual({ dev: "on" });
|
||||
expect(strip()).toContain("dev advisor: on (@advisor)");
|
||||
});
|
||||
|
||||
test("pattern… commits a custom advisor pattern and empty submit clears it", async () => {
|
||||
mockAgents();
|
||||
const settings = Settings.isolated();
|
||||
settings.set("task.agentAdvisor", { dev: "on" });
|
||||
const { hub, type } = await createHub(settings);
|
||||
hub.handleInput("\r");
|
||||
hub.handleInput("\x1b[C");
|
||||
hub.handleInput("\x1b[C");
|
||||
hub.handleInput("\r"); // advisor strip
|
||||
// agent default → on → off → pick model… → pattern…
|
||||
for (let i = 0; i < 4; i++) hub.handleInput("\x1b[C");
|
||||
hub.handleInput("\r"); // pattern input, pre-filled "on"
|
||||
type("\x7f\x7f"); // clear the prefill
|
||||
type("moonshot/k3:high");
|
||||
hub.handleInput("\r");
|
||||
expect(settings.get("task.agentAdvisor")).toEqual({ dev: "moonshot/k3:high" });
|
||||
});
|
||||
|
||||
test("pick model… dives into the model browser and persists the model override", async () => {
|
||||
mockAgents();
|
||||
const settings = Settings.isolated();
|
||||
const { hub, strip } = await createHub(settings);
|
||||
hub.handleInput("\r"); // agent strip (model chip preselected)
|
||||
hub.handleInput("\r"); // model value strip → [pick model…] first
|
||||
expect(strip()).toContain("dev · model →");
|
||||
hub.handleInput("\r"); // assign mode: model browser
|
||||
expect(strip()).toContain("Picking model override for dev");
|
||||
expect(strip()).toContain("claude-sonnet-4-5");
|
||||
hub.handleInput("\r"); // pick the only model
|
||||
expect(settings.get("task.agentModelOverrides")).toEqual({ dev: "anthropic/claude-sonnet-4-5" });
|
||||
// Back on the list with the override reflected.
|
||||
expect(strip()).toContain("anthropic/claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
test("clear override chip removes an existing model override", async () => {
|
||||
mockAgents();
|
||||
const settings = Settings.isolated();
|
||||
settings.set("task.agentModelOverrides", { dev: "anthropic/claude-sonnet-4-5" });
|
||||
const { hub, strip } = await createHub(settings);
|
||||
hub.handleInput("\r"); // agent strip
|
||||
hub.handleInput("\r"); // model value strip
|
||||
expect(strip()).toContain("clear override");
|
||||
hub.handleInput("\x1b[C"); // pick model… → pattern…
|
||||
hub.handleInput("\x1b[C"); // pattern… → clear override
|
||||
hub.handleInput("\r");
|
||||
expect(settings.get("task.agentModelOverrides")).toEqual({});
|
||||
});
|
||||
|
||||
test("Esc steps back from a value strip to the agent strip before closing", async () => {
|
||||
mockAgents();
|
||||
const settings = Settings.isolated();
|
||||
const { hub, strip, cancelled } = await createHub(settings);
|
||||
hub.handleInput("\r"); // agent strip
|
||||
hub.handleInput("\r"); // model value strip
|
||||
hub.handleInput("\x1b"); // back to agent strip
|
||||
expect(strip()).toContain("dev →");
|
||||
hub.handleInput("\x1b"); // close strip
|
||||
expect(strip()).not.toContain("dev →");
|
||||
expect(cancelled()).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user