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:
can1357
2026-08-13 05:09:58 +02:00
parent e8eed95130
commit a53e4e790d
9 changed files with 1702 additions and 1732 deletions
+1 -1
View File
@@ -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);
}
/**
+1 -1
View File
@@ -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);
});
});