diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c3f769a33..7a0d0d93c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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, `""` = 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 `//__advisor[.].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 diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 5eaf4eded..95325f2c2 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -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.", }, }, diff --git a/packages/coding-agent/src/modes/components/agent-dashboard.ts b/packages/coding-agent/src/modes/components/agent-dashboard.ts deleted file mode 100644 index ee475d545..000000000 --- a/packages/coding-agent/src/modes/components/agent-dashboard.ts +++ /dev/null @@ -1,1388 +0,0 @@ -/** - * AgentDashboard - dedicated control center for Task subagent configuration. - * - * Layout: - * - Top: source tabs (All, Project, User, Bundled) - * - Body: two-column view (agent list + inspector) - * - * Controls: - * - Up/Down or j/k: move selection - * - Tab / Shift+Tab or Left/Right: switch source tab - * - Space: enable/disable selected agent - * - Enter: edit model override for selected agent - * - N: start agent creation flow - * - Esc: clear search (if any) or close dashboard - * - Ctrl+R: reload discovered agents - */ -import * as fs from "node:fs/promises"; -import * as path from "node:path"; -import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; -import { - type Component, - Container, - Editor, - fuzzyMatch, - Input, - matchesKey, - padding, - replaceTabs, - ScrollView, - Spacer, - Text, - truncateToWidth, - visibleWidth, - wrapTextWithAnsi, -} from "@oh-my-pi/pi-tui"; -import { isEnoent, prompt } from "@oh-my-pi/pi-utils"; -import { YAML } from "bun"; -import { getConfigDirs } from "../../config"; -import type { ModelRegistry } from "../../config/model-registry"; -import { - formatModelString, - normalizeModelPatternList, - resolveAgentAdvisorSelection, - resolveAgentModelPatterns, - resolveAgentPrewalkPattern, - resolveConfiguredModelPatterns, - resolveModelOverride, -} from "../../config/model-resolver"; -import { Settings } from "../../config/settings"; -import agentCreationArchitectPrompt from "../../prompts/system/agent-creation-architect.md" with { type: "text" }; -import agentCreationUserPrompt from "../../prompts/system/agent-creation-user.md" with { type: "text" }; -import { createAgentSession } from "../../sdk"; -import { refreshAgentDiscovery } from "../../task"; -import { discoverAgents } from "../../task/discovery"; -import { resolveAgentPrewalkDefault } from "../../task/prewalk"; -import type { AgentDefinition, AgentSource } from "../../task/types"; -import { shortenPath } from "../../tools/render-utils"; -import { getEditorTheme, theme } from "../theme/theme"; -import { - matchesAppFollowUp, - matchesAppInterrupt, - matchesSelectDown, - matchesSelectUp, -} from "../utils/keybinding-matchers"; -import { DynamicBorder } from "./dynamic-border"; -import { clampSelection, handleTabSwitchKey, padLinesToHeight, searchableChar } from "./selector-helpers"; - -type SourceTabId = "all" | AgentSource; -type AgentScope = "project" | "user"; - -interface SourceTab { - id: SourceTabId; - label: string; - count: number; -} - -interface DashboardAgent extends AgentDefinition { - disabled: boolean; - overrideModel?: string; - /** `task.agentPrewalk` value for this agent: "on", "off", or a model pattern. */ - prewalkOverride?: string; - /** `task.agentAdvisor` value for this agent: "on", "off", or a model pattern. */ - advisorOverride?: string; -} - -interface ModelResolution { - resolved: string; - thinkingLevel?: string; - explicitThinkingLevel: boolean; -} -/** Which per-agent settings override the inline editor is editing. */ -type OverrideEditKind = "model" | "prewalk" | "advisor"; - -const OVERRIDE_EDIT_META: Record = { - model: { title: "Model override", hint: "Enter model pattern (empty clears override)" }, - prewalk: { - title: "Prewalk override", - hint: 'Enter "on", "off", or a prewalk target model pattern (empty = agent default)', - }, - advisor: { - title: "Advisor override", - hint: 'Enter "on", "off", or an advisor model pattern (empty = agent default)', - }, -}; - -interface GeneratedAgentSpec { - identifier: string; - whenToUse: string; - systemPrompt: string; -} - -interface AgentDashboardModelContext { - modelRegistry?: ModelRegistry; - activeModelPattern?: string; - defaultModelPattern?: string; -} - -const SOURCE_ORDER: Record = { - project: 0, - user: 1, - bundled: 2, -}; - -const SOURCE_LABEL: Record = { - project: "Project", - user: "User", - bundled: "Bundled", -}; - -const LIST_FOOTER = - " ↑/↓: navigate Space: toggle Enter: model override P: prewalk A: advisor N: new agent ←/→: source Ctrl+R: reload Esc: close"; - -const IDENTIFIER_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+){1,5}$/; -function joinPatterns(patterns: string[]): string { - if (patterns.length === 0) return "(session model)"; - return patterns.join(", "); -} - -function formatResolution(resolution: ModelResolution): string { - const resolved = theme.fg("success", resolution.resolved); - if (!resolution.explicitThinkingLevel || !resolution.thinkingLevel) return resolved; - return `${resolved} ${theme.fg("dim", `(${resolution.thinkingLevel})`)}`; -} - -function matchAgent(agent: DashboardAgent, query: string): boolean { - const text = `${agent.name} ${agent.description} ${SOURCE_LABEL[agent.source]} ${agent.overrideModel ?? ""}`; - return query - .trim() - .split(/\s+/) - .every(token => fuzzyMatch(token, text).matches); -} - -function extractAssistantText(messages: AgentMessage[]): string | null { - for (let i = messages.length - 1; i >= 0; i--) { - const message = messages[i]; - if (message?.role !== "assistant") continue; - const blocks = message.content; - if (!Array.isArray(blocks)) continue; - const text = blocks - .map(block => { - if (!block || typeof block !== "object") return ""; - if (!("type" in block) || (block as { type?: unknown }).type !== "text") return ""; - const value = (block as { text?: unknown }).text; - return typeof value === "string" ? value : ""; - }) - .join("\n") - .trim(); - if (text.length > 0) return text; - } - return null; -} - -function extractJsonObject(raw: string): string { - const fenceMatch = raw.match(/```(?:json)?\s*([\s\S]*?)```/i); - if (fenceMatch?.[1]) { - return fenceMatch[1].trim(); - } - const start = raw.indexOf("{"); - const end = raw.lastIndexOf("}"); - if (start >= 0 && end >= start) { - return raw.slice(start, end + 1).trim(); - } - return raw.trim(); -} - -function parseGeneratedAgentSpec(raw: string): GeneratedAgentSpec { - const parsed = JSON.parse(extractJsonObject(raw)) as Partial; - if (!parsed || typeof parsed !== "object") { - throw new Error("Model output is not a JSON object"); - } - if ( - typeof parsed.identifier !== "string" || - typeof parsed.whenToUse !== "string" || - typeof parsed.systemPrompt !== "string" - ) { - throw new Error("Model output is missing required fields (identifier, whenToUse, systemPrompt)"); - } - - const identifier = parsed.identifier.trim(); - const whenToUse = parsed.whenToUse.trim(); - const systemPrompt = parsed.systemPrompt.trim(); - - if (!IDENTIFIER_PATTERN.test(identifier)) { - throw new Error("Generated identifier is invalid (must be lowercase kebab-case, 2+ words)"); - } - if (!whenToUse.toLowerCase().startsWith("use this agent when")) { - throw new Error("Generated whenToUse must start with 'Use this agent when...'"); - } - if (!systemPrompt) { - throw new Error("Generated systemPrompt is empty"); - } - - return { identifier, whenToUse, systemPrompt }; -} - -class AgentListPane implements Component { - constructor( - private readonly agents: DashboardAgent[], - private readonly selectedIndex: number, - private readonly scrollOffset: number, - private readonly searchQuery: string, - private readonly maxVisible: number, - ) {} - - render(width: number): readonly string[] { - const lines: string[] = []; - const searchPrefix = theme.fg("muted", "Search: "); - const searchText = this.searchQuery || theme.fg("dim", "type to filter"); - lines.push(`${searchPrefix}${searchText}`); - lines.push(""); - - if (this.agents.length === 0) { - lines.push(theme.fg("muted", " No agents found.")); - return lines; - } - - const overflow = this.agents.length > this.maxVisible; - const rowWidth = Math.max(0, width - (overflow ? 1 : 0)); - const start = this.scrollOffset; - const end = Math.min(start + this.maxVisible, this.agents.length); - - const rows: string[] = []; - for (let i = start; i < end; i++) { - const agent = this.agents[i]; - const selected = i === this.selectedIndex; - const status = agent.disabled - ? theme.fg("dim", theme.status.disabled) - : theme.fg("success", theme.status.enabled); - const source = theme.fg("dim", `[${SOURCE_LABEL[agent.source]}]`); - const override = agent.overrideModel ? ` ${theme.fg("warning", "(override)")}` : ""; - let line = ` ${status} ${replaceTabs(agent.name)} ${source}${override}`; - - if (selected) { - line = theme.bg("selectedBg", theme.bold(theme.fg("accent", line))); - } else if (agent.disabled) { - line = theme.fg("dim", line); - } - - rows.push(truncateToWidth(line, rowWidth)); - } - - const sv = new ScrollView(rows, { - height: rows.length, - scrollbar: "auto", - totalRows: this.agents.length, - theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) }, - }); - sv.setScrollOffset(this.scrollOffset); - lines.push(...sv.render(width)); - - return lines; - } - - invalidate(): void {} -} - -class AgentInspectorPane implements Component { - constructor( - private readonly agent: DashboardAgent | null, - private readonly defaultPatterns: string[], - private readonly defaultResolution: ModelResolution | undefined, - private readonly effectivePatterns: string[], - private readonly effectiveResolution: ModelResolution | undefined, - private readonly prewalkPattern: string | undefined, - private readonly prewalkResolution: ModelResolution | undefined, - private readonly advisorPattern: string | undefined, - private readonly advisorResolution: ModelResolution | undefined, - ) {} - - render(width: number): readonly string[] { - if (!this.agent) { - return [theme.fg("muted", "Select an agent"), theme.fg("dim", "to inspect settings")]; - } - - const lines: string[] = []; - const state = this.agent.disabled - ? theme.fg("dim", `${theme.status.disabled} Disabled`) - : theme.fg("success", `${theme.status.enabled} Enabled`); - - lines.push(theme.bold(theme.fg("accent", replaceTabs(this.agent.name)))); - lines.push(""); - lines.push(`${theme.fg("muted", "Status:")} ${state}`); - lines.push(`${theme.fg("muted", "Source:")} ${SOURCE_LABEL[this.agent.source]}`); - lines.push(""); - - lines.push(`${theme.fg("muted", "Default pattern:")} ${replaceTabs(joinPatterns(this.defaultPatterns))}`); - lines.push( - `${theme.fg("muted", "Default resolves:")} ${this.defaultResolution ? this.#formatResolution(this.defaultResolution) : theme.fg("dim", "(unresolved)")}`, - ); - lines.push( - `${theme.fg("muted", "Override:")} ${this.agent.overrideModel ? theme.fg("warning", replaceTabs(this.agent.overrideModel)) : theme.fg("dim", "(none)")}`, - ); - lines.push(`${theme.fg("muted", "Effective pattern:")} ${replaceTabs(joinPatterns(this.effectivePatterns))}`); - lines.push( - `${theme.fg("muted", "Effective:")} ${this.effectiveResolution ? this.#formatResolution(this.effectiveResolution) : theme.fg("dim", "(unresolved)")}`, - ); - lines.push(`${theme.fg("muted", "Prewalk:")} ${this.#prewalkLabel()}`); - lines.push(`${theme.fg("muted", "Advisor:")} ${this.#advisorLabel()}`); - - if (this.agent.filePath) { - lines.push(""); - lines.push(theme.fg("muted", "Path:")); - lines.push(theme.fg("dim", ` ${replaceTabs(shortenPath(this.agent.filePath))}`)); - } - - if (this.agent.description) { - lines.push(""); - lines.push(theme.fg("muted", "Description:")); - for (const wrapped of wrapTextWithAnsi(replaceTabs(this.agent.description), Math.max(10, width - 2))) { - lines.push(truncateToWidth(wrapped, width)); - } - } - - return lines; - } - /** "off", "on → target" (with source: agent default vs override), or the unresolved pattern. */ - #prewalkLabel(): string { - if (!this.agent) return theme.fg("dim", "off"); - const override = this.agent.prewalkOverride?.trim(); - const sourceTag = override - ? theme.fg("warning", " (override)") - : this.agent.prewalk !== undefined && this.agent.prewalk !== false - ? theme.fg("dim", " (agent default)") - : ""; - if (!this.prewalkPattern) { - return `${theme.fg("dim", "off")}${override ? sourceTag : ""}`; - } - const target = this.prewalkResolution - ? this.#formatResolution(this.prewalkResolution) - : theme.fg("dim", "(unresolved)"); - return `${theme.fg("success", "on")} ${theme.fg("dim", `${replaceTabs(this.prewalkPattern)} →`)} ${target}${sourceTag}`; - } - /** "off", "on → advisor model" (with source: agent default vs override), or the unresolved pattern. */ - #advisorLabel(): string { - if (!this.agent) return theme.fg("dim", "off"); - const override = this.agent.advisorOverride?.trim(); - const sourceTag = override - ? theme.fg("warning", " (override)") - : this.agent.advisor !== undefined && this.agent.advisor !== false - ? theme.fg("dim", " (agent default)") - : ""; - if (!this.advisorPattern) { - return `${theme.fg("dim", "off")}${override ? sourceTag : ""}`; - } - const target = this.advisorResolution - ? this.#formatResolution(this.advisorResolution) - : theme.fg("dim", "(unresolved)"); - return `${theme.fg("success", "on")} ${theme.fg("dim", `${replaceTabs(this.advisorPattern)} →`)} ${target}${sourceTag}`; - } - - #formatResolution(resolution: ModelResolution): string { - return formatResolution(resolution); - } - - invalidate(): void {} -} - -class TwoColumnBody implements Component { - constructor( - private readonly leftPane: AgentListPane, - private readonly rightPane: AgentInspectorPane, - private readonly maxHeight: number, - ) {} - - render(width: number): readonly string[] { - const leftWidth = Math.floor(width * 0.5); - const rightWidth = width - leftWidth - 3; - const leftLines = this.leftPane.render(leftWidth); - const rightLines = this.rightPane.render(rightWidth); - const lineCount = this.maxHeight; - const out: string[] = []; - const separator = theme.fg("dim", ` ${theme.boxRound.vertical} `); - - for (let i = 0; i < lineCount; i++) { - const left = truncateToWidth(leftLines[i] ?? "", leftWidth); - const leftPadded = left + padding(Math.max(0, leftWidth - visibleWidth(left))); - const right = truncateToWidth(rightLines[i] ?? "", rightWidth); - out.push(leftPadded + separator + right); - } - - return out; - } - - invalidate(): void { - this.leftPane.invalidate?.(); - this.rightPane.invalidate?.(); - } -} - -export class AgentDashboard extends Container { - #settingsManager: Settings | null = null; - #allAgents: DashboardAgent[] = []; - #filteredAgents: DashboardAgent[] = []; - #tabs: SourceTab[] = [{ id: "all", label: "All", count: 0 }]; - #activeTabIndex = 0; - #selectedIndex = 0; - #scrollOffset = 0; - #searchQuery = ""; - #loading = true; - #loadError: string | null = null; - #notice: string | null = null; - #builtRows = -1; - #builtCols = -1; - - #editInput: Input | null = null; - #editKind: OverrideEditKind = "model"; - #editingAgentName: string | null = null; - - #createInput: Editor | null = null; - #createDescription = ""; - #createScope: AgentScope = "project"; - #createGenerating = false; - #createSpec: GeneratedAgentSpec | null = null; - #createError: string | null = null; - #createStreamingText = ""; - - onClose?: () => void; - onRequestRender?: () => void; - - private constructor( - private readonly cwd: string, - private readonly settings: Settings | null, - private readonly terminalHeight: number, - private readonly modelContext: AgentDashboardModelContext, - ) { - super(); - } - - static async create( - cwd: string, - settings: Settings | null = null, - terminalHeight?: number, - modelContext: AgentDashboardModelContext = {}, - ): Promise { - const dashboard = new AgentDashboard(cwd, settings, terminalHeight ?? process.stdout.rows ?? 24, modelContext); - await dashboard.#init(); - return dashboard; - } - - async #init(): Promise { - this.#settingsManager = this.settings ?? (await Settings.init()); - await this.#reloadData(); - this.#buildLayout(); - } - - async #reloadData(): Promise { - this.#loading = true; - this.#loadError = null; - this.#buildLayout(); - - try { - const selectedName = this.#selectedAgent()?.name; - const activeTabId = this.#tabs[this.#activeTabIndex]?.id ?? "all"; - const { agents } = await discoverAgents(this.cwd); - const disabled = new Set((this.#settingsManager?.get("task.disabledAgents") as string[] | undefined) ?? []); - const overrides = this.#settingsManager?.get("task.agentModelOverrides") ?? {}; - const prewalkOverrides = this.#settingsManager?.get("task.agentPrewalk") ?? {}; - const advisorOverrides = this.#settingsManager?.get("task.agentAdvisor") ?? {}; - - this.#allAgents = agents - .slice() - .sort((a, b) => { - const sourceCmp = SOURCE_ORDER[a.source] - SOURCE_ORDER[b.source]; - if (sourceCmp !== 0) return sourceCmp; - return a.name.localeCompare(b.name); - }) - .map(agent => ({ - ...agent, - disabled: disabled.has(agent.name), - overrideModel: normalizeModelPatternList(overrides[agent.name]).join(",") || undefined, - prewalkOverride: prewalkOverrides[agent.name]?.trim() || undefined, - advisorOverride: advisorOverrides[agent.name]?.trim() || undefined, - })); - - this.#tabs = this.#buildTabs(this.#allAgents); - const nextTabIndex = this.#tabs.findIndex(tab => tab.id === activeTabId); - this.#activeTabIndex = nextTabIndex >= 0 ? nextTabIndex : 0; - this.#applyFilters(); - - if (selectedName) { - const idx = this.#filteredAgents.findIndex(agent => agent.name === selectedName); - if (idx >= 0) { - this.#selectedIndex = idx; - } - } - this.#clampSelection(); - } catch (error) { - this.#allAgents = []; - this.#filteredAgents = []; - this.#tabs = [{ id: "all", label: "All", count: 0 }]; - this.#activeTabIndex = 0; - this.#selectedIndex = 0; - this.#scrollOffset = 0; - this.#loadError = error instanceof Error ? error.message : String(error); - } finally { - this.#loading = false; - this.#rebuildAndRender(); - } - } - - #buildTabs(agents: DashboardAgent[]): SourceTab[] { - const tabs: SourceTab[] = [{ id: "all", label: "All", count: agents.length }]; - const counts: Record = { project: 0, user: 0, bundled: 0 }; - - for (const agent of agents) { - counts[agent.source] += 1; - } - - for (const source of ["project", "user", "bundled"] as const) { - if (counts[source] > 0) { - tabs.push({ id: source, label: SOURCE_LABEL[source], count: counts[source] }); - } - } - - return tabs; - } - - #selectedAgent(): DashboardAgent | null { - return this.#filteredAgents[this.#selectedIndex] ?? null; - } - - #applyFilters(): void { - const activeTab = this.#tabs[this.#activeTabIndex] ?? this.#tabs[0]; - const tabFiltered = - activeTab.id === "all" ? this.#allAgents : this.#allAgents.filter(agent => agent.source === activeTab.id); - - if (!this.#searchQuery) { - this.#filteredAgents = tabFiltered; - } else { - this.#filteredAgents = tabFiltered.filter(agent => matchAgent(agent, this.#searchQuery)); - } - - this.#clampSelection(); - } - - /** Live terminal height so the dashboard tracks resize while open. */ - #terminalRows(): number { - return process.stdout.rows || this.terminalHeight || 24; - } - - #noticeBlockLines(): number { - if (!this.#notice) return 0; - return wrapTextWithAnsi(theme.fg("success", replaceTabs(this.#notice)), this.#uiWidth()).length + 1; - } - - #footerLines(): number { - return Math.max(1, wrapTextWithAnsi(theme.fg("dim", LIST_FOOTER), this.#uiWidth()).length); - } - - /** Height budget for the two-column body, sized to the live terminal. */ - #computeBodyHeight(): number { - // Chrome around the body: top border + title + tab bar + spacer (4), - // optional notice block, then spacer + footer + bottom border. - const chrome = 4 + this.#noticeBlockLines() + 1 + this.#footerLines() + 1; - return Math.max(5, this.#terminalRows() - chrome); - } - - #getMaxVisibleItems(): number { - // List pane chrome inside the body: search line, blank line, count line. - return Math.max(3, this.#computeBodyHeight() - 3); - } - - override render(width: number): readonly string[] { - // Rebuild when terminal geometry changes so the full-screen overlay - // re-fits on resize. - if (this.#terminalRows() !== this.#builtRows || this.#uiWidth() !== this.#builtCols) { - this.#buildLayout(); - } - const lines = super.render(width); - // Pad to the full viewport so every state (list, edit, create) covers the - // screen as a true full-screen view instead of letting the transcript peek - // through below it. - return padLinesToHeight(lines, this.#terminalRows()); - } - - #clampSelection(): void { - const next = clampSelection( - this.#selectedIndex, - this.#scrollOffset, - this.#filteredAgents.length, - this.#getMaxVisibleItems(), - ); - this.#selectedIndex = next.selectedIndex; - this.#scrollOffset = next.scrollOffset; - } - - #persistDisabledAgents(): void { - if (!this.#settingsManager) return; - const disabled = this.#allAgents - .filter(agent => agent.disabled) - .map(agent => agent.name) - .sort((a, b) => a.localeCompare(b)); - this.#settingsManager.set("task.disabledAgents", disabled); - } - - #persistModelOverrides(): void { - if (!this.#settingsManager) return; - const overrides: Record = {}; - for (const agent of this.#allAgents) { - const value = agent.overrideModel?.trim(); - if (value) { - overrides[agent.name] = value; - } - } - this.#settingsManager.set("task.agentModelOverrides", overrides); - } - #persistPrewalkOverrides(): void { - if (!this.#settingsManager) return; - const overrides: Record = {}; - for (const agent of this.#allAgents) { - const value = agent.prewalkOverride?.trim(); - if (value) { - overrides[agent.name] = value; - } - } - this.#settingsManager.set("task.agentPrewalk", overrides); - } - - #persistAdvisorOverrides(): void { - if (!this.#settingsManager) return; - const overrides: Record = {}; - for (const agent of this.#allAgents) { - const value = agent.advisorOverride?.trim(); - if (value) { - overrides[agent.name] = value; - } - } - this.#settingsManager.set("task.agentAdvisor", overrides); - } - - #toggleSelectedAgent(): void { - const selected = this.#selectedAgent(); - if (!selected) return; - selected.disabled = !selected.disabled; - this.#persistDisabledAgents(); - this.#buildLayout(); - } - - #overrideValueFor(agent: DashboardAgent, kind: OverrideEditKind): string | undefined { - switch (kind) { - case "model": - return agent.overrideModel; - case "prewalk": - return agent.prewalkOverride; - case "advisor": - return agent.advisorOverride; - } - } - - #beginOverrideEdit(kind: OverrideEditKind): void { - const selected = this.#selectedAgent(); - if (!selected) return; - this.#createError = null; - this.#editKind = kind; - this.#editingAgentName = selected.name; - this.#editInput = new Input(); - const current = this.#overrideValueFor(selected, kind); - if (current) { - this.#editInput.setValue(current); - } - this.#editInput.onSubmit = value => { - this.#saveOverrideEdit(value); - }; - this.#buildLayout(); - } - - #saveOverrideEdit(rawValue: string): void { - if (!this.#editingAgentName) return; - const selected = this.#allAgents.find(agent => agent.name === this.#editingAgentName); - if (!selected) return; - const kind = this.#editKind; - const value = rawValue.trim() || undefined; - switch (kind) { - case "model": - selected.overrideModel = value; - this.#persistModelOverrides(); - this.#notice = `Updated model override for ${selected.name}`; - break; - case "prewalk": { - selected.prewalkOverride = value; - this.#persistPrewalkOverrides(); - const pattern = resolveAgentPrewalkPattern({ - settingsOverride: value, - agentPrewalk: resolveAgentPrewalkDefault(selected, this.#settingsManager?.get("task.prewalk") ?? false), - }); - this.#notice = `Prewalk for ${selected.name}: ${value ?? "agent default"}${pattern ? ` (into ${pattern})` : " (off)"}`; - break; - } - case "advisor": { - selected.advisorOverride = value; - this.#persistAdvisorOverrides(); - const selection = resolveAgentAdvisorSelection({ settingsOverride: value, agentAdvisor: selected.advisor }); - this.#notice = `Advisor for ${selected.name}: ${value ?? "agent default"}${selection ? ` (advised by ${selection.model ?? "@advisor"})` : " (off)"}`; - break; - } - } - this.#editingAgentName = null; - this.#editInput = null; - this.#applyFilters(); - this.#buildLayout(); - } - #cancelOverrideEdit(): void { - this.#editingAgentName = null; - this.#editInput = null; - this.#buildLayout(); - } - #beginCreateFlow(): void { - if (this.#createGenerating) return; - this.#createError = null; - this.#createSpec = null; - this.#createDescription = ""; - const editor = new Editor(getEditorTheme()); - editor.setBorderVisible(false); - editor.setPromptGutter("> "); - editor.setMaxHeight(Math.max(3, Math.min(8, this.#terminalRows() - 12))); - editor.disableSubmit = true; - editor.onChange = value => { - this.#createDescription = value; - }; - this.#createInput = editor; - this.#buildLayout(); - } - - #clearCreateFlow(): void { - this.#createInput = null; - this.#createDescription = ""; - this.#createGenerating = false; - this.#createSpec = null; - this.#createError = null; - this.#createStreamingText = ""; - } - - #toggleCreateScope(): void { - this.#createScope = this.#createScope === "project" ? "user" : "project"; - this.#buildLayout(); - } - - #submitCreateDescription(): void { - if (!this.#createInput || this.#createGenerating) return; - const description = this.#createInput.getExpandedText(); - this.#createDescription = description; - void this.#generateAgentFromDescription(description); - } - - #insertCreateNewline(): void { - if (!this.#createInput || this.#createGenerating) return; - this.#createInput.handleInput("\n"); - this.#createDescription = this.#createInput.getExpandedText(); - this.#buildLayout(); - } - - async #generateAgentFromDescription(rawDescription: string): Promise { - const description = rawDescription.trim(); - this.#createDescription = description; - if (!description) { - this.#createError = "Description is required."; - this.#buildLayout(); - return; - } - - this.#createGenerating = true; - this.#createError = null; - this.#createSpec = null; - this.#createStreamingText = ""; - this.#buildLayout(); - - try { - const spec = await this.#runAgentCreationArchitect(description); - this.#createSpec = spec; - this.#notice = null; - } catch (error) { - this.#createError = error instanceof Error ? error.message : String(error); - } finally { - this.#createGenerating = false; - this.#rebuildAndRender(); - } - } - - async #runAgentCreationArchitect(description: string): Promise { - const modelRegistry = this.modelContext.modelRegistry; - if (!modelRegistry) { - throw new Error("Model registry unavailable in current session."); - } - await modelRegistry.refresh(); - - const settings = this.#settingsManager ?? undefined; - const modelPatterns = resolveConfiguredModelPatterns( - this.modelContext.activeModelPattern ?? - this.modelContext.defaultModelPattern ?? - settings?.getModelRole("default"), - settings, - ); - const { model } = resolveModelOverride(modelPatterns, modelRegistry, settings); - const fallbackModel = modelRegistry.getAvailable()[0]; - const selectedModel = model ?? fallbackModel; - if (!selectedModel) { - throw new Error("No available model to generate agent specification."); - } - - const systemPrompt = prompt.render(agentCreationArchitectPrompt, {}); - const userPrompt = prompt.render(agentCreationUserPrompt, { request: description }); - - const { session } = await createAgentSession({ - cwd: this.cwd, - authStorage: modelRegistry.authStorage, - modelRegistry, - settings, - model: selectedModel, - systemPrompt: [systemPrompt], - hasUI: false, - enableLsp: false, - enableMCP: false, - disableExtensionDiscovery: true, - toolNames: ["__none__"], - customTools: [], - skills: [], - contextFiles: [], - promptTemplates: [], - slashCommands: [], - }); - const unsubscribe = session.subscribe(event => { - if (event.type === "message_update" && "assistantMessageEvent" in event) { - const ame = event.assistantMessageEvent; - if (ame.type === "text_delta") { - this.#createStreamingText += ame.delta; - this.#rebuildAndRender(); - } - } - }); - - try { - await session.prompt(userPrompt, { expandPromptTemplates: false }); - const raw = extractAssistantText(session.state.messages); - if (!raw) { - throw new Error("No response returned by agent creation architect."); - } - return parseGeneratedAgentSpec(raw); - } finally { - unsubscribe(); - await session.dispose(); - } - } - - async #saveGeneratedAgent(): Promise { - const spec = this.#createSpec; - if (!spec) return; - - const dirs = getConfigDirs("agents", { - user: this.#createScope === "user", - project: this.#createScope === "project", - cwd: this.cwd, - }); - const targetDir = dirs[0]?.path; - if (!targetDir) { - throw new Error(`Cannot resolve ${this.#createScope} agents directory.`); - } - - const filePath = path.join(targetDir, `${spec.identifier}.md`); - try { - await fs.stat(filePath); - throw new Error(`Agent file already exists: ${shortenPath(filePath)}`); - } catch (error) { - if (!isEnoent(error)) { - throw error; - } - } - - const frontmatter = YAML.stringify( - { - name: spec.identifier, - description: spec.whenToUse, - }, - null, - 2, - ).trimEnd(); - const content = `---\n${frontmatter}\n---\n\n${spec.systemPrompt.trim()}\n`; - await Bun.write(filePath, content); - await refreshAgentDiscovery(this.cwd); - await this.#reloadData(); - this.#clearCreateFlow(); - this.#notice = `Created agent ${spec.identifier} at ${shortenPath(filePath)}`; - this.#rebuildAndRender(); - } - - #getModelSuggestions(input: string): string[] { - const modelRegistry = this.modelContext.modelRegistry; - if (!modelRegistry) return []; - const query = input.trim().toLowerCase(); - if (!query) return []; - const available = modelRegistry.getAvailable(); - const seen = new Set(); - const matches: string[] = []; - for (const model of available) { - const full = `${model.provider}/${model.id}`; - if (seen.has(full)) continue; - if (!full.toLowerCase().includes(query)) continue; - seen.add(full); - matches.push(full); - if (matches.length >= 5) break; - } - return matches; - } - - #switchTab(direction: 1 | -1): void { - if (this.#tabs.length === 0) return; - this.#activeTabIndex = (this.#activeTabIndex + direction + this.#tabs.length) % this.#tabs.length; - this.#selectedIndex = 0; - this.#scrollOffset = 0; - this.#applyFilters(); - this.#buildLayout(); - } - - #moveSelection(delta: -1 | 1): void { - if (this.#filteredAgents.length === 0) return; - this.#selectedIndex = Math.max(0, Math.min(this.#filteredAgents.length - 1, this.#selectedIndex + delta)); - this.#clampSelection(); - this.#buildLayout(); - } - - #defaultPatternsFor(agent: DashboardAgent): string[] { - return resolveAgentModelPatterns({ - agentModel: agent.model, - settings: this.#settingsManager ?? undefined, - activeModelPattern: this.modelContext.activeModelPattern, - fallbackModelPattern: this.modelContext.defaultModelPattern, - }); - } - - #effectivePatternsFor(agent: DashboardAgent, draftOverride: string | undefined): string[] { - return resolveAgentModelPatterns({ - settingsOverride: draftOverride, - agentModel: agent.model, - settings: this.#settingsManager ?? undefined, - activeModelPattern: this.modelContext.activeModelPattern, - fallbackModelPattern: this.modelContext.defaultModelPattern, - }); - } - - #resolvePatterns(patterns: string[]): ModelResolution | undefined { - const modelRegistry = this.modelContext.modelRegistry; - if (!modelRegistry || patterns.length === 0) return undefined; - const { model, thinkingLevel, explicitThinkingLevel } = resolveModelOverride( - patterns, - modelRegistry, - this.#settingsManager ?? undefined, - ); - if (!model) return undefined; - return { - resolved: formatModelString(model), - thinkingLevel, - explicitThinkingLevel, - }; - } - - #renderTabBar(): string { - const parts: string[] = [" "]; - for (let i = 0; i < this.#tabs.length; i++) { - const tab = this.#tabs[i]; - const label = `${tab.label} (${tab.count})`; - if (i === this.#activeTabIndex) { - parts.push(theme.bg("selectedBg", ` ${label} `)); - } else { - parts.push(theme.fg("muted", ` ${label} `)); - } - } - return parts.join(""); - } - #renderCreateInput(): void { - this.addChild(new Text(theme.bold(theme.fg("accent", " Create New Agent")), 0, 0)); - this.addChild(new Spacer(1)); - this.addChild(new Text(theme.fg("muted", "Describe what the new agent should do:"), 0, 0)); - this.addChild(new Spacer(1)); - if (this.#createInput) { - this.#createInput.setMaxHeight(Math.max(3, Math.min(8, this.#terminalRows() - 12))); - this.addChild(this.#createInput); - } - this.addChild(new Spacer(1)); - this.addChild(new Text(theme.fg("muted", `Scope: ${this.#createScope}`), 0, 0)); - if (this.#createGenerating) { - this.addChild(new Spacer(1)); - this.addChild(new Text(theme.fg("accent", "Generating agent specification..."), 0, 0)); - if (this.#createStreamingText) { - this.addChild(new Spacer(1)); - const maxPreview = Math.max(3, this.#terminalRows() - 18); - const contentWidth = Math.max(20, this.#uiWidth() - 4); - const wrappedLines: string[] = []; - for (const raw of this.#createStreamingText.split("\n")) { - for (const w of wrapTextWithAnsi(replaceTabs(raw), contentWidth)) { - wrappedLines.push(w); - } - } - const tail = wrappedLines.slice(-maxPreview); - if (wrappedLines.length > maxPreview) { - this.addChild(new Text(theme.fg("dim", ` ... ${wrappedLines.length - maxPreview} lines above`), 0, 0)); - } - for (const line of tail) { - this.addChild(new Text(theme.fg("dim", ` ${line}`), 0, 0)); - } - } - } - if (this.#createError) { - this.addChild(new Text(theme.fg("error", replaceTabs(this.#createError)), 0, 0)); - } - this.addChild(new Spacer(1)); - const hints = this.#createGenerating - ? " Generating..." - : " Ctrl+Q/Ctrl+Enter: generate Enter: newline Tab: toggle scope Esc: cancel"; - this.addChild(new Text(theme.fg("dim", hints), 0, 0)); - } - - #renderCreateReview(): void { - const spec = this.#createSpec; - if (!spec) return; - - this.addChild(new Text(theme.bold(theme.fg("accent", " Review Generated Agent")), 0, 0)); - this.addChild(new Spacer(1)); - this.addChild(new Text(theme.fg("muted", `Identifier: ${spec.identifier}`), 0, 0)); - this.addChild(new Text(theme.fg("muted", `Scope: ${this.#createScope}`), 0, 0)); - this.addChild(new Spacer(1)); - this.addChild(new Text(theme.fg("muted", "whenToUse:"), 0, 0)); - for (const line of wrapTextWithAnsi(replaceTabs(spec.whenToUse), Math.max(20, this.#uiWidth() - 2)).slice(0, 8)) { - this.addChild(new Text(truncateToWidth(line, this.#uiWidth() - 2), 0, 0)); - } - this.addChild(new Spacer(1)); - this.addChild(new Text(theme.fg("muted", "systemPrompt preview:"), 0, 0)); - const promptWidth = Math.max(20, this.#uiWidth() - 4); - const wrappedPrompt: string[] = []; - for (const raw of spec.systemPrompt.split("\n")) { - for (const w of wrapTextWithAnsi(replaceTabs(raw), promptWidth)) { - wrappedPrompt.push(w); - } - } - const promptPreview = wrappedPrompt.slice(0, 10); - for (const line of promptPreview) { - this.addChild(new Text(` ${line}`, 0, 0)); - } - if (wrappedPrompt.length > promptPreview.length) { - this.addChild( - new Text(theme.fg("dim", ` ... ${wrappedPrompt.length - promptPreview.length} more lines`), 0, 0), - ); - } - if (this.#createError) { - this.addChild(new Spacer(1)); - this.addChild(new Text(theme.fg("error", replaceTabs(this.#createError)), 0, 0)); - } - this.addChild(new Spacer(1)); - this.addChild(new Text(theme.fg("dim", " Enter: save Tab: toggle scope R: regenerate Esc: cancel"), 0, 0)); - } - - #uiWidth(): number { - return Math.max(40, process.stdout.columns ?? 100); - } - - /** Rebuild layout and request a TUI render pass (for use after async state changes). */ - #rebuildAndRender(): void { - this.#buildLayout(); - this.onRequestRender?.(); - } - - #buildLayout(): void { - this.clear(); - this.addChild(new DynamicBorder()); - this.addChild(new Text(theme.bold(theme.fg("accent", " Agent Control Center")), 0, 0)); - this.addChild(new Text(this.#renderTabBar(), 0, 0)); - this.addChild(new Spacer(1)); - - if (this.#notice) { - this.addChild(new Text(theme.fg("success", replaceTabs(this.#notice)), 0, 0)); - this.addChild(new Spacer(1)); - } - - if (this.#loading) { - this.addChild(new Text(theme.fg("muted", "Loading agents..."), 0, 0)); - this.addChild(new Spacer(1)); - } else if (this.#loadError) { - this.addChild(new Text(theme.fg("error", `Failed to load agents: ${replaceTabs(this.#loadError)}`), 0, 0)); - this.addChild(new Spacer(1)); - } else if (this.#createSpec) { - this.#renderCreateReview(); - } else if (this.#createInput || this.#createGenerating) { - this.#renderCreateInput(); - } else if (this.#editInput && this.#editingAgentName) { - const editingAgent = this.#allAgents.find(agent => agent.name === this.#editingAgentName) ?? null; - const kind = this.#editKind; - const meta = OVERRIDE_EDIT_META[kind]; - const draft = this.#editInput.getValue(); - const suggestions = this.#getModelSuggestions(draft); - - this.addChild( - new Text(theme.bold(theme.fg("accent", `${meta.title}: ${replaceTabs(this.#editingAgentName)}`)), 0, 0), - ); - this.addChild(new Spacer(1)); - this.addChild(new Text(theme.fg("muted", meta.hint), 0, 0)); - this.addChild(new Spacer(1)); - this.addChild(this.#editInput); - this.addChild(new Spacer(1)); - - if (kind === "model") { - const defaultPatterns = editingAgent ? this.#defaultPatternsFor(editingAgent) : []; - const defaultResolution = editingAgent ? this.#resolvePatterns(defaultPatterns) : undefined; - const previewPatterns = editingAgent ? this.#effectivePatternsFor(editingAgent, draft) : []; - const previewResolution = editingAgent ? this.#resolvePatterns(previewPatterns) : undefined; - this.addChild( - new Text(theme.fg("muted", `Default pattern: ${replaceTabs(joinPatterns(defaultPatterns))}`), 0, 0), - ); - this.addChild( - new Text( - `${theme.fg("muted", "Default resolves:")} ${defaultResolution ? formatResolution(defaultResolution) : theme.fg("dim", "(unresolved)")}`, - 0, - 0, - ), - ); - this.addChild( - new Text( - `${theme.fg("muted", "Preview effective:")} ${previewResolution ? formatResolution(previewResolution) : theme.fg("dim", "(unresolved)")}`, - 0, - 0, - ), - ); - } else if (editingAgent) { - // Prewalk/advisor: preview the effective state the draft override - // would produce, resolving "on"/"off"/pattern against the agent - // definition's own default. - const previewPattern = - kind === "prewalk" - ? resolveAgentPrewalkPattern({ - settingsOverride: draft, - agentPrewalk: resolveAgentPrewalkDefault( - editingAgent, - this.#settingsManager?.get("task.prewalk") ?? false, - ), - }) - : (() => { - const selection = resolveAgentAdvisorSelection({ - settingsOverride: draft, - agentAdvisor: editingAgent.advisor, - }); - return selection ? (selection.model ?? "@advisor") : undefined; - })(); - const previewResolution = previewPattern ? this.#resolvePatterns([previewPattern]) : undefined; - const agentDefault = kind === "prewalk" ? editingAgent.prewalk : editingAgent.advisor; - this.addChild( - new Text( - `${theme.fg("muted", "Agent default:")} ${ - agentDefault === undefined || agentDefault === false - ? theme.fg("dim", "off") - : replaceTabs(String(agentDefault)) - }`, - 0, - 0, - ), - ); - this.addChild( - new Text( - `${theme.fg("muted", "Preview effective:")} ${ - previewPattern - ? `${theme.fg("success", "on")} ${theme.fg("dim", `${replaceTabs(previewPattern)} →`)} ${previewResolution ? formatResolution(previewResolution) : theme.fg("dim", "(unresolved)")}` - : theme.fg("dim", "off") - }`, - 0, - 0, - ), - ); - } - - if (suggestions.length > 0) { - this.addChild(new Spacer(1)); - this.addChild(new Text(theme.fg("muted", "Suggestions:"), 0, 0)); - for (const suggestion of suggestions) { - this.addChild(new Text(theme.fg("dim", ` ${suggestion}`), 0, 0)); - } - } - - this.addChild(new Spacer(1)); - this.addChild(new Text(theme.fg("dim", " Enter: save Esc: cancel"), 0, 0)); - } else { - const selected = this.#selectedAgent(); - const defaultPatterns = selected ? this.#defaultPatternsFor(selected) : []; - const defaultResolution = selected ? this.#resolvePatterns(defaultPatterns) : undefined; - const effectivePatterns = selected ? this.#effectivePatternsFor(selected, selected.overrideModel) : []; - const effectiveResolution = selected ? this.#resolvePatterns(effectivePatterns) : undefined; - const prewalkPattern = selected - ? resolveAgentPrewalkPattern({ - settingsOverride: selected.prewalkOverride, - agentPrewalk: resolveAgentPrewalkDefault( - selected, - this.#settingsManager?.get("task.prewalk") ?? false, - ), - }) - : undefined; - const prewalkResolution = prewalkPattern ? this.#resolvePatterns([prewalkPattern]) : undefined; - const advisorSelection = selected - ? resolveAgentAdvisorSelection({ - settingsOverride: selected.advisorOverride, - agentAdvisor: selected.advisor, - }) - : undefined; - const advisorPattern = advisorSelection ? (advisorSelection.model ?? "@advisor") : undefined; - const advisorResolution = advisorPattern ? this.#resolvePatterns([advisorPattern]) : undefined; - - const listPane = new AgentListPane( - this.#filteredAgents, - this.#selectedIndex, - this.#scrollOffset, - this.#searchQuery, - this.#getMaxVisibleItems(), - ); - const inspector = new AgentInspectorPane( - selected, - defaultPatterns, - defaultResolution, - effectivePatterns, - effectiveResolution, - prewalkPattern, - prewalkResolution, - advisorPattern, - advisorResolution, - ); - const bodyHeight = this.#computeBodyHeight(); - this.addChild(new TwoColumnBody(listPane, inspector, bodyHeight)); - this.addChild(new Spacer(1)); - this.addChild(new Text(theme.fg("dim", LIST_FOOTER), 0, 0)); - } - - this.addChild(new DynamicBorder()); - this.#builtRows = this.#terminalRows(); - this.#builtCols = this.#uiWidth(); - } - - handleInput(data: string): void { - if (matchesKey(data, "ctrl+c")) { - this.onClose?.(); - return; - } - - if (this.#createSpec) { - if (matchesAppInterrupt(data)) { - this.#clearCreateFlow(); - this.#buildLayout(); - return; - } - if (matchesKey(data, "tab") || matchesKey(data, "shift+tab")) { - this.#toggleCreateScope(); - return; - } - if (data.toLowerCase() === "r") { - void this.#generateAgentFromDescription(this.#createDescription); - return; - } - if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") { - void this.#saveGeneratedAgent().catch(error => { - this.#createError = error instanceof Error ? error.message : String(error); - this.#rebuildAndRender(); - }); - return; - } - return; - } - - if (this.#createInput || this.#createGenerating) { - if (matchesAppInterrupt(data)) { - if (!this.#createGenerating) { - this.#clearCreateFlow(); - this.#buildLayout(); - } - return; - } - if (!this.#createGenerating && matchesAppFollowUp(data)) { - this.#submitCreateDescription(); - return; - } - if (!this.#createGenerating && (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n")) { - this.#insertCreateNewline(); - return; - } - if (!this.#createGenerating && (matchesKey(data, "tab") || matchesKey(data, "shift+tab"))) { - this.#toggleCreateScope(); - return; - } - if (!this.#createGenerating && this.#createInput) { - this.#createInput.handleInput(data); - this.#createDescription = this.#createInput.getExpandedText(); - this.#buildLayout(); - } - return; - } - - if (this.#editInput) { - if (matchesAppInterrupt(data)) { - this.#cancelOverrideEdit(); - return; - } - this.#editInput.handleInput(data); - if (this.#editInput) { - this.#buildLayout(); - } - return; - } - - if (matchesAppInterrupt(data)) { - if (this.#searchQuery.length > 0) { - this.#searchQuery = ""; - this.#applyFilters(); - this.#buildLayout(); - return; - } - this.onClose?.(); - return; - } - - if (matchesKey(data, "ctrl+r")) { - void this.#reloadData(); - return; - } - - if (handleTabSwitchKey(data, direction => this.#switchTab(direction))) { - return; - } - - if (matchesSelectUp(data) || matchesKey(data, "k")) { - this.#moveSelection(-1); - return; - } - if (matchesSelectDown(data) || matchesKey(data, "j")) { - this.#moveSelection(1); - return; - } - - if (data === " ") { - this.#toggleSelectedAgent(); - return; - } - if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") { - this.#beginOverrideEdit("model"); - return; - } - if (data.toLowerCase() === "n") { - this.#beginCreateFlow(); - return; - } - if (data.toLowerCase() === "p") { - this.#beginOverrideEdit("prewalk"); - return; - } - // Uppercase-only: lowercase `a` stays available for search-typing agent - // names ("task", "librarian", ...), unlike the rarely-typed n/p letters. - if (data === "A") { - this.#beginOverrideEdit("advisor"); - return; - } - - if (matchesKey(data, "backspace")) { - if (this.#searchQuery.length > 0) { - this.#searchQuery = this.#searchQuery.slice(0, -1); - this.#applyFilters(); - this.#buildLayout(); - } - return; - } - - const char = searchableChar(data); - if (char !== null) { - this.#searchQuery += char; - this.#applyFilters(); - this.#buildLayout(); - } - } -} diff --git a/packages/coding-agent/src/modes/components/agents-hub.ts b/packages/coding-agent/src/modes/components/agents-hub.ts new file mode 100644 index 000000000..566820cd1 --- /dev/null +++ b/packages/coding-agent/src/modes/components/agents-hub.ts @@ -0,0 +1,1453 @@ +/** + * Fullscreen /agents hub, shown on the alternate screen like /models. + * + * Layout mirrors the model hub: a sidebar of scopes (All agents, per-source + * groups, "+ New agent"), a body listing agents with type-to-filter search, + * and a footer that turns into a chip strip while configuring. Enter on an + * agent opens its property strip (enabled / model / prewalk / advisor); a + * property opens a value strip whose "pick model…" chip dives into the real + * ModelBrowser and whose "pattern…" chip opens an inline pattern input, so + * every per-agent knob is picked instead of memorized. + */ + +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; +import { + type Component, + Editor, + fuzzyMatch, + Input, + matchesKey, + replaceTabs, + routeSgrMouseInput, + type SgrMouseEvent, + type TUI, + truncateToWidth, + visibleWidth, + wrapTextWithAnsi, +} from "@oh-my-pi/pi-tui"; +import { isEnoent, prompt } from "@oh-my-pi/pi-utils"; +import { YAML } from "bun"; +import { getConfigDirs } from "../../config"; +import type { ModelRegistry } from "../../config/model-registry"; +import { + resolveAgentAdvisorSelection, + resolveAgentModelPatterns, + resolveAgentPrewalkPattern, + resolveConfiguredModelPatterns, + resolveModelOverride, +} from "../../config/model-resolver"; +import type { Settings } from "../../config/settings"; +import agentCreationArchitectPrompt from "../../prompts/system/agent-creation-architect.md" with { type: "text" }; +import agentCreationUserPrompt from "../../prompts/system/agent-creation-user.md" with { type: "text" }; +import { createAgentSession } from "../../sdk"; +import { refreshAgentDiscovery } from "../../task"; +import { discoverAgents } from "../../task/discovery"; +import { resolveAgentPrewalkDefault } from "../../task/prewalk"; +import type { AgentDefinition, AgentSource } from "../../task/types"; +import { shortenPath } from "../../tools/render-utils"; +import { getEditorTheme, theme } from "../theme/theme"; +import { + matchesAppFollowUp, + matchesSelectCancel, + matchesSelectDown, + matchesSelectUp, +} from "../utils/keybinding-matchers"; +import { buildBrowserItems, ModelBrowser, type ModelBrowserItem, sortModelItems } from "./model-browser"; +import { bottomBorder, dividerSplit, row, splitBodyWidth, splitRow, topBorderSplit } from "./overlay-box"; + +/** One agent with its per-agent settings overrides resolved for display. */ +interface HubAgent extends AgentDefinition { + disabled: boolean; + /** `task.agentModelOverrides[name]` as a comma-joined pattern list. */ + overrideModel?: string; + /** `task.agentPrewalk[name]`: "on", "off", or a model pattern. */ + prewalkOverride?: string; + /** `task.agentAdvisor[name]`: "on", "off", or a model pattern. */ + advisorOverride?: string; +} + +const SOURCE_LABEL: Record = { + project: "Project", + user: "User", + bundled: "Bundled", +}; +const SOURCE_ORDER: Record = { project: 0, user: 1, bundled: 2 }; + +interface SidebarEntry { + id: string; + kind: "all" | "source" | "new" | "separator"; + label: string; + source?: AgentSource; + annotation?: string; +} + +/** A body row of the agent list: an agent or the trailing "+ New agent…". */ +type ListRow = { kind: "agent"; agent: HubAgent } | { kind: "new" }; + +/** The per-agent knob a strip or the model browser is editing. */ +type PropertyKind = "model" | "prewalk" | "advisor"; + +interface StripChip { + label: string; + styled: string; + action: + | { kind: "toggle" } + | { kind: "property"; property: PropertyKind } + | { kind: "set"; property: PropertyKind; value: string | undefined } + | { kind: "pick"; property: PropertyKind } + | { kind: "pattern"; property: PropertyKind }; +} + +type StripState = + | { kind: "chips"; agent: HubAgent; property?: PropertyKind; chips: StripChip[]; index: number } + | { kind: "pattern"; agent: HubAgent; property: PropertyKind; input: Input }; + +/** Recorded chip hit-range on the footer row (columns relative to frame col 0). */ +interface ChipRange { + start: number; + end: number; + index: number; +} + +interface GeneratedAgentSpec { + identifier: string; + whenToUse: string; + systemPrompt: string; +} + +/** Ambient model context for resolution previews and the creation architect. */ +export interface AgentsHubModelContext { + modelRegistry?: ModelRegistry; + activeModelPattern?: string; + defaultModelPattern?: string; +} + +export interface AgentsHubCallbacks { + onCancel: () => void; +} + +const SIDEBAR_MIN_WIDTH = 16; +const SIDEBAR_MAX_WIDTH = 24; +const IDENTIFIER_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+){1,5}$/; + +function extractAssistantText(messages: AgentMessage[]): string | null { + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + if (message?.role !== "assistant") continue; + const blocks = message.content; + if (!Array.isArray(blocks)) continue; + const text = blocks + .map(block => { + if (!block || typeof block !== "object") return ""; + if (!("type" in block) || block.type !== "text" || !("text" in block)) return ""; + const value = block.text; + return typeof value === "string" ? value : ""; + }) + .join("\n") + .trim(); + if (text.length > 0) return text; + } + return null; +} + +function extractJsonObject(raw: string): string { + const fenceMatch = raw.match(/```(?:json)?\s*([\s\S]*?)```/i); + if (fenceMatch?.[1]) return fenceMatch[1].trim(); + const start = raw.indexOf("{"); + const end = raw.lastIndexOf("}"); + if (start >= 0 && end >= start) return raw.slice(start, end + 1).trim(); + return raw.trim(); +} + +function parseGeneratedAgentSpec(raw: string): GeneratedAgentSpec { + const parsed = JSON.parse(extractJsonObject(raw)) as Partial; + if (!parsed || typeof parsed !== "object") { + throw new Error("Model output is not a JSON object"); + } + if ( + typeof parsed.identifier !== "string" || + typeof parsed.whenToUse !== "string" || + typeof parsed.systemPrompt !== "string" + ) { + throw new Error("Model output is missing required fields (identifier, whenToUse, systemPrompt)"); + } + const identifier = parsed.identifier.trim(); + const whenToUse = parsed.whenToUse.trim(); + const systemPrompt = parsed.systemPrompt.trim(); + if (!IDENTIFIER_PATTERN.test(identifier)) { + throw new Error("Generated identifier is invalid (must be lowercase kebab-case, 2+ words)"); + } + if (!whenToUse.toLowerCase().startsWith("use this agent when")) { + throw new Error("Generated whenToUse must start with 'Use this agent when...'"); + } + if (!systemPrompt) { + throw new Error("Generated systemPrompt is empty"); + } + return { identifier, whenToUse, systemPrompt }; +} + +function matchAgent(agent: HubAgent, query: string): boolean { + const text = `${agent.name} ${agent.description} ${SOURCE_LABEL[agent.source]} ${agent.overrideModel ?? ""}`; + return query + .trim() + .split(/\s+/) + .every(token => fuzzyMatch(token, text).matches); +} + +/** + * The fullscreen agents hub component. Hosted via + * `ui.showOverlay(..., { fullscreen: true })`; the host must call + * {@link AgentsHubComponent.dispose} when the overlay closes. + */ +export class AgentsHubComponent implements Component { + #tui: TUI; + #cwd: string; + #settings: Settings; + #modelContext: AgentsHubModelContext; + #callbacks: AgentsHubCallbacks; + + #allAgents: HubAgent[] = []; + #entries: SidebarEntry[] = []; + #activeEntryId = "all"; + #sidebarScroll = 0; + #focus: "scope" | "list" = "list"; + + #rows: ListRow[] = []; + #rowIndex = 0; + #rowHover: number | null = null; + #listScroll = 0; + #searchQuery = ""; + #notice: string | null = null; + #loadError: string | null = null; + + #strip: StripState | null = null; + #chipRanges: ChipRange[] = []; + /** Non-null while the body shows the model browser for one agent property. */ + #assigning: { agent: HubAgent; property: PropertyKind } | null = null; + #browser: ModelBrowser; + + // Create flow (AI-generated agent definition). + #createInput: Editor | null = null; + #createDescription = ""; + #createScope: "project" | "user" = "project"; + #createGenerating = false; + #createSpec: GeneratedAgentSpec | null = null; + #createError: string | null = null; + #createStreamingText = ""; + + // Frame geometry from the last render, for mouse hit-testing. + #contentRowStart = 1; + #contentRowCount = 0; + #sidebarWidthLast = SIDEBAR_MIN_WIDTH; + #footerRow = 0; + /** First agent-list row's offset in body-line coordinates (after the status row). */ + #listRowStart = 2; + + private constructor( + tui: TUI, + cwd: string, + settings: Settings, + modelContext: AgentsHubModelContext, + callbacks: AgentsHubCallbacks, + ) { + this.#tui = tui; + this.#cwd = cwd; + this.#settings = settings; + this.#modelContext = modelContext; + this.#callbacks = callbacks; + this.#browser = new ModelBrowser(settings, { + emptyText: () => " No models available — configure a provider in /models first.", + }); + this.#browser.setShowProvider(true); + this.#browser.onActivate = item => this.#commitPickedModel(item); + this.#browser.onCancel = () => this.#cancelAssign(); + } + + static async create( + tui: TUI, + cwd: string, + settings: Settings, + modelContext: AgentsHubModelContext = {}, + callbacks: AgentsHubCallbacks = { onCancel: () => {} }, + ): Promise { + const hub = new AgentsHubComponent(tui, cwd, settings, modelContext, callbacks); + await hub.#reload(); + return hub; + } + + dispose(): void {} + invalidate(): void {} + + // ═══════════════════════════════════════════════════════════════════════ + // Data pipeline + // ═══════════════════════════════════════════════════════════════════════ + + async #reload(): Promise { + this.#loadError = null; + try { + const selectedName = this.#selectedAgent()?.name; + const { agents } = await discoverAgents(this.#cwd); + const disabled = new Set(this.#settings.get("task.disabledAgents") ?? []); + const overrides = this.#settings.get("task.agentModelOverrides") ?? {}; + const prewalkOverrides = this.#settings.get("task.agentPrewalk") ?? {}; + const advisorOverrides = this.#settings.get("task.agentAdvisor") ?? {}; + this.#allAgents = agents + .slice() + .sort((a, b) => { + const sourceCmp = SOURCE_ORDER[a.source] - SOURCE_ORDER[b.source]; + if (sourceCmp !== 0) return sourceCmp; + return a.name.localeCompare(b.name); + }) + .map(agent => { + const override = overrides[agent.name]; + const overrideModel = (Array.isArray(override) ? override.join(",") : (override ?? "")).trim(); + return { + ...agent, + disabled: disabled.has(agent.name), + overrideModel: overrideModel || undefined, + prewalkOverride: prewalkOverrides[agent.name]?.trim() || undefined, + advisorOverride: advisorOverrides[agent.name]?.trim() || undefined, + }; + }); + this.#buildSidebar(); + this.#buildRows(); + if (selectedName) { + const index = this.#rows.findIndex(r => r.kind === "agent" && r.agent.name === selectedName); + if (index >= 0) this.#rowIndex = index; + } + this.#clampRowIndex(); + } catch (error) { + this.#allAgents = []; + this.#buildSidebar(); + this.#buildRows(); + this.#loadError = error instanceof Error ? error.message : String(error); + } + this.#tui.requestRender(); + } + + #buildSidebar(): void { + const counts: Record = { project: 0, user: 0, bundled: 0 }; + for (const agent of this.#allAgents) counts[agent.source]++; + const entries: SidebarEntry[] = [ + { id: "all", kind: "all", label: "All agents", annotation: String(this.#allAgents.length) }, + ]; + const sources = (["project", "user", "bundled"] as const).filter(source => counts[source] > 0); + if (sources.length > 0) { + entries.push({ id: "sep:sources", kind: "separator", label: "" }); + for (const source of sources) { + entries.push({ + id: `source:${source}`, + kind: "source", + label: SOURCE_LABEL[source], + source, + annotation: String(counts[source]), + }); + } + } + entries.push({ id: "sep:actions", kind: "separator", label: "" }); + entries.push({ id: "new", kind: "new", label: "New agent" }); + this.#entries = entries; + if (!entries.some(entry => entry.id === this.#activeEntryId)) this.#activeEntryId = "all"; + } + + #activeEntry(): SidebarEntry { + return this.#entries.find(entry => entry.id === this.#activeEntryId) ?? this.#entries[0]; + } + + #buildRows(): void { + const entry = this.#activeEntry(); + const scoped = + entry.kind === "source" ? this.#allAgents.filter(agent => agent.source === entry.source) : this.#allAgents; + const filtered = this.#searchQuery ? scoped.filter(agent => matchAgent(agent, this.#searchQuery)) : scoped; + this.#rows = [...filtered.map(agent => ({ kind: "agent", agent }) as ListRow), { kind: "new" }]; + } + + #clampRowIndex(): void { + this.#rowIndex = Math.max(0, Math.min(this.#rowIndex, this.#rows.length - 1)); + } + + #selectedAgent(): HubAgent | undefined { + const rowDef = this.#rows[this.#rowIndex]; + return rowDef?.kind === "agent" ? rowDef.agent : undefined; + } + + // ═══════════════════════════════════════════════════════════════════════ + // Effective per-agent values + // ═══════════════════════════════════════════════════════════════════════ + + #effectiveModelPatterns(agent: HubAgent): string[] { + return resolveAgentModelPatterns({ + settingsOverride: agent.overrideModel, + agentModel: agent.model, + settings: this.#settings, + activeModelPattern: this.#modelContext.activeModelPattern, + fallbackModelPattern: this.#modelContext.defaultModelPattern, + }); + } + + #resolvePatterns(patterns: string[]): string | undefined { + const registry = this.#modelContext.modelRegistry; + if (!registry || patterns.length === 0) return undefined; + const { model, thinkingLevel, explicitThinkingLevel } = resolveModelOverride(patterns, registry, this.#settings); + if (!model) return undefined; + const level = explicitThinkingLevel && thinkingLevel ? `:${thinkingLevel}` : ""; + return `${model.provider}/${model.id}${level}`; + } + + #effectivePrewalkPattern(agent: HubAgent): string | undefined { + return resolveAgentPrewalkPattern({ + settingsOverride: agent.prewalkOverride, + agentPrewalk: resolveAgentPrewalkDefault(agent, this.#settings.get("task.prewalk") ?? false), + }); + } + + #effectiveAdvisorPattern(agent: HubAgent): string | undefined { + const selection = resolveAgentAdvisorSelection({ + settingsOverride: agent.advisorOverride, + agentAdvisor: agent.advisor, + }); + return selection ? (selection.model ?? "@advisor") : undefined; + } + + // ═══════════════════════════════════════════════════════════════════════ + // Mutations + // ═══════════════════════════════════════════════════════════════════════ + + #toggleAgent(agent: HubAgent): void { + agent.disabled = !agent.disabled; + const disabled = this.#allAgents + .filter(entry => entry.disabled) + .map(entry => entry.name) + .sort((a, b) => a.localeCompare(b)); + this.#settings.set("task.disabledAgents", disabled); + this.#notice = `${agent.name} ${agent.disabled ? "disabled" : "enabled"}`; + this.#tui.requestRender(); + } + + #persistRecord(property: PropertyKind): void { + const overrides: Record = {}; + for (const agent of this.#allAgents) { + const value = this.#overrideFor(agent, property)?.trim(); + if (value) overrides[agent.name] = value; + } + const key = + property === "model" + ? "task.agentModelOverrides" + : property === "prewalk" + ? "task.agentPrewalk" + : "task.agentAdvisor"; + this.#settings.set(key, overrides); + } + + #overrideFor(agent: HubAgent, property: PropertyKind): string | undefined { + switch (property) { + case "model": + return agent.overrideModel; + case "prewalk": + return agent.prewalkOverride; + case "advisor": + return agent.advisorOverride; + } + } + + #setOverride(agent: HubAgent, property: PropertyKind, value: string | undefined): void { + const trimmed = value?.trim() || undefined; + switch (property) { + case "model": + agent.overrideModel = trimmed; + break; + case "prewalk": + agent.prewalkOverride = trimmed; + break; + case "advisor": + agent.advisorOverride = trimmed; + break; + } + this.#persistRecord(property); + this.#notice = this.#describeProperty(agent, property); + this.#tui.requestRender(); + } + + /** One-line effective description used for notices and the status row. */ + #describeProperty(agent: HubAgent, property: PropertyKind): string { + switch (property) { + case "model": { + const patterns = this.#effectiveModelPatterns(agent); + const resolved = this.#resolvePatterns(patterns); + const base = agent.overrideModel ?? (patterns.length > 0 ? patterns.join(",") : "session model"); + return `${agent.name} model: ${base}${resolved ? ` → ${resolved}` : ""}`; + } + case "prewalk": { + const pattern = this.#effectivePrewalkPattern(agent); + return `${agent.name} prewalk: ${pattern ? `on (${pattern})` : "off"}`; + } + case "advisor": { + const pattern = this.#effectiveAdvisorPattern(agent); + return `${agent.name} advisor: ${pattern ? `on (${pattern})` : "off"}`; + } + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // Strips + // ═══════════════════════════════════════════════════════════════════════ + + #propertySummary(agent: HubAgent, property: PropertyKind): string { + switch (property) { + case "model": + return agent.overrideModel ?? "auto"; + case "prewalk": { + const pattern = this.#effectivePrewalkPattern(agent); + return pattern ?? "off"; + } + case "advisor": { + const pattern = this.#effectiveAdvisorPattern(agent); + return pattern ?? "off"; + } + } + } + + /** Level-1 strip: pick which knob of `agent` to change. */ + #openAgentStrip(agent: HubAgent): void { + const enabledChip: StripChip = { + label: agent.disabled ? "enable" : "disable", + styled: agent.disabled + ? theme.fg("success", `${theme.status.enabled} enable`) + : theme.fg("dim", `${theme.status.disabled} disable`), + action: { kind: "toggle" }, + }; + const propertyChip = (property: PropertyKind): StripChip => { + const summary = this.#propertySummary(agent, property); + return { + label: property, + styled: `${theme.fg("accent", property)}${theme.fg("dim", `: ${summary}`)}`, + action: { kind: "property", property }, + }; + }; + this.#strip = { + kind: "chips", + agent, + chips: [enabledChip, propertyChip("model"), propertyChip("prewalk"), propertyChip("advisor")], + index: 1, + }; + } + + /** Level-2 strip: value choices for one property of `agent`. */ + #openPropertyStrip(agent: HubAgent, property: PropertyKind): void { + const current = this.#overrideFor(agent, property)?.toLowerCase(); + const chips: StripChip[] = []; + const mark = (label: string, active: boolean, color: "accent" | "muted" = "muted"): string => + active ? theme.fg("accent", `${theme.status.enabled} ${label}`) : theme.fg(color, label); + if (property === "model") { + chips.push({ + label: "pick model…", + styled: theme.fg("accent", "pick model…"), + action: { kind: "pick", property }, + }); + chips.push({ + label: "pattern…", + styled: theme.fg("muted", "pattern…"), + action: { kind: "pattern", property }, + }); + if (agent.overrideModel) { + chips.push({ + label: "clear override", + styled: theme.fg("warning", "clear override"), + action: { kind: "set", property, value: undefined }, + }); + } + } else { + chips.push({ + label: "agent default", + styled: mark("agent default", current === undefined), + action: { kind: "set", property, value: undefined }, + }); + chips.push({ + label: "on", + styled: mark("on", current === "on"), + action: { kind: "set", property, value: "on" }, + }); + chips.push({ + label: "off", + styled: mark("off", current === "off"), + action: { kind: "set", property, value: "off" }, + }); + chips.push({ + label: "pick model…", + styled: theme.fg("accent", "pick model…"), + action: { kind: "pick", property }, + }); + chips.push({ + label: "pattern…", + styled: theme.fg("muted", "pattern…"), + action: { kind: "pattern", property }, + }); + } + this.#strip = { kind: "chips", agent, property, chips, index: 0 }; + } + + #openPatternStrip(agent: HubAgent, property: PropertyKind): void { + const input = new Input(); + const current = this.#overrideFor(agent, property); + if (current) input.setValue(current); + this.#strip = { kind: "pattern", agent, property, input }; + } + + #closeStrip(): void { + this.#strip = null; + this.#chipRanges = []; + } + + #activateStripChip(): void { + const strip = this.#strip; + if (strip?.kind !== "chips") return; + const chip = strip.chips[strip.index]; + if (!chip) return; + const action = chip.action; + switch (action.kind) { + case "toggle": + this.#toggleAgent(strip.agent); + this.#closeStrip(); + return; + case "property": + this.#openPropertyStrip(strip.agent, action.property); + return; + case "set": + this.#setOverride(strip.agent, action.property, action.value); + this.#closeStrip(); + return; + case "pick": + this.#closeStrip(); + this.#startAssign(strip.agent, action.property); + return; + case "pattern": + this.#openPatternStrip(strip.agent, action.property); + return; + } + } + + #submitPattern(): void { + const strip = this.#strip; + if (strip?.kind !== "pattern") return; + this.#setOverride(strip.agent, strip.property, strip.input.getValue()); + this.#closeStrip(); + } + + // ═══════════════════════════════════════════════════════════════════════ + // Model browser assign mode + // ═══════════════════════════════════════════════════════════════════════ + + #startAssign(agent: HubAgent, property: PropertyKind): void { + const registry = this.#modelContext.modelRegistry; + const models = registry?.getAvailable() ?? []; + const items = buildBrowserItems(models); + sortModelItems(items, { mruOrder: this.#settings.getStorage()?.getModelUsageOrder() ?? [] }); + this.#assigning = { agent, property }; + this.#browser.setItems(items); + this.#browser.setQuery(""); + const current = this.#overrideFor(agent, property); + if (current) this.#browser.selectSelector(current); + } + + #commitPickedModel(item: ModelBrowserItem): void { + const target = this.#assigning; + if (!target) return; + this.#assigning = null; + this.#browser.setQuery(""); + this.#setOverride(target.agent, target.property, item.selector); + } + + #cancelAssign(): void { + this.#assigning = null; + this.#browser.setQuery(""); + this.#tui.requestRender(); + } + + // ═══════════════════════════════════════════════════════════════════════ + // Create flow + // ═══════════════════════════════════════════════════════════════════════ + + get #createActive(): boolean { + return this.#createInput !== null || this.#createGenerating || this.#createSpec !== null; + } + + #beginCreateFlow(): void { + if (this.#createGenerating) return; + this.#createError = null; + this.#createSpec = null; + this.#createDescription = ""; + const editor = new Editor(getEditorTheme()); + editor.setBorderVisible(false); + editor.setPromptGutter("> "); + editor.setMaxHeight(Math.max(3, Math.min(8, this.#terminalRows() - 12))); + editor.disableSubmit = true; + editor.onChange = value => { + this.#createDescription = value; + }; + this.#createInput = editor; + this.#tui.requestRender(); + } + + #clearCreateFlow(): void { + this.#createInput = null; + this.#createDescription = ""; + this.#createGenerating = false; + this.#createSpec = null; + this.#createError = null; + this.#createStreamingText = ""; + } + + async #generateAgentFromDescription(rawDescription: string): Promise { + const description = rawDescription.trim(); + this.#createDescription = description; + if (!description) { + this.#createError = "Description is required."; + this.#tui.requestRender(); + return; + } + this.#createGenerating = true; + this.#createError = null; + this.#createSpec = null; + this.#createStreamingText = ""; + this.#tui.requestRender(); + try { + const spec = await this.#runAgentCreationArchitect(description); + this.#createSpec = spec; + this.#notice = null; + } catch (error) { + this.#createError = error instanceof Error ? error.message : String(error); + } finally { + this.#createGenerating = false; + this.#tui.requestRender(); + } + } + + async #runAgentCreationArchitect(description: string): Promise { + const modelRegistry = this.#modelContext.modelRegistry; + if (!modelRegistry) { + throw new Error("Model registry unavailable in current session."); + } + await modelRegistry.refresh(); + const modelPatterns = resolveConfiguredModelPatterns( + this.#modelContext.activeModelPattern ?? + this.#modelContext.defaultModelPattern ?? + this.#settings.getModelRole("default"), + this.#settings, + ); + const { model } = resolveModelOverride(modelPatterns, modelRegistry, this.#settings); + const selectedModel = model ?? modelRegistry.getAvailable()[0]; + if (!selectedModel) { + throw new Error("No available model to generate agent specification."); + } + const systemPrompt = prompt.render(agentCreationArchitectPrompt, {}); + const userPrompt = prompt.render(agentCreationUserPrompt, { request: description }); + const { session } = await createAgentSession({ + cwd: this.#cwd, + authStorage: modelRegistry.authStorage, + modelRegistry, + settings: this.#settings, + model: selectedModel, + systemPrompt: [systemPrompt], + hasUI: false, + enableLsp: false, + enableMCP: false, + disableExtensionDiscovery: true, + toolNames: ["__none__"], + customTools: [], + skills: [], + contextFiles: [], + promptTemplates: [], + slashCommands: [], + }); + const unsubscribe = session.subscribe(event => { + if (event.type === "message_update" && "assistantMessageEvent" in event) { + const ame = event.assistantMessageEvent; + if (ame.type === "text_delta") { + this.#createStreamingText += ame.delta; + this.#tui.requestRender(); + } + } + }); + try { + await session.prompt(userPrompt, { expandPromptTemplates: false }); + const raw = extractAssistantText(session.state.messages); + if (!raw) { + throw new Error("No response returned by agent creation architect."); + } + return parseGeneratedAgentSpec(raw); + } finally { + unsubscribe(); + await session.dispose(); + } + } + + async #saveGeneratedAgent(): Promise { + const spec = this.#createSpec; + if (!spec) return; + const dirs = getConfigDirs("agents", { + user: this.#createScope === "user", + project: this.#createScope === "project", + cwd: this.#cwd, + }); + const targetDir = dirs[0]?.path; + if (!targetDir) { + throw new Error(`Cannot resolve ${this.#createScope} agents directory.`); + } + const filePath = path.join(targetDir, `${spec.identifier}.md`); + try { + await fs.stat(filePath); + throw new Error(`Agent file already exists: ${shortenPath(filePath)}`); + } catch (error) { + if (!isEnoent(error)) throw error; + } + const frontmatter = YAML.stringify({ name: spec.identifier, description: spec.whenToUse }, null, 2).trimEnd(); + const content = `---\n${frontmatter}\n---\n\n${spec.systemPrompt.trim()}\n`; + await Bun.write(filePath, content); + await refreshAgentDiscovery(this.#cwd); + this.#clearCreateFlow(); + this.#notice = `Created agent ${spec.identifier} at ${shortenPath(filePath)}`; + await this.#reload(); + } + + // ═══════════════════════════════════════════════════════════════════════ + // Input + // ═══════════════════════════════════════════════════════════════════════ + + handleInput(data: string): void { + if (data.startsWith("\x1b[<")) { + routeSgrMouseInput(data, event => this.#routeMouseEvent(event)); + this.#tui.requestRender(); + return; + } + + if (this.#strip) { + this.#handleStripInput(data); + this.#tui.requestRender(); + return; + } + + if (this.#createActive) { + this.#handleCreateInput(data); + this.#tui.requestRender(); + return; + } + + if (matchesSelectCancel(data)) { + if (this.#assigning) { + this.#cancelAssign(); + return; + } + if (this.#searchQuery.length > 0) { + this.#searchQuery = ""; + this.#buildRows(); + this.#clampRowIndex(); + this.#tui.requestRender(); + return; + } + this.#callbacks.onCancel(); + return; + } + + if (this.#assigning) { + this.#browser.handleInput(data); + this.#tui.requestRender(); + return; + } + + if (matchesKey(data, "ctrl+r")) { + void this.#reload(); + return; + } + + if (matchesKey(data, "tab") || matchesKey(data, "shift+tab")) { + this.#focus = this.#focus === "scope" ? "list" : "scope"; + this.#tui.requestRender(); + return; + } + if (matchesKey(data, "left")) { + this.#focus = "scope"; + this.#tui.requestRender(); + return; + } + if (matchesKey(data, "right")) { + this.#focus = "list"; + this.#tui.requestRender(); + return; + } + + if (this.#focus === "scope") { + if (matchesSelectUp(data)) { + this.#moveSidebar(-1); + this.#tui.requestRender(); + return; + } + if (matchesSelectDown(data)) { + this.#moveSidebar(1); + this.#tui.requestRender(); + return; + } + if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") { + if (this.#activeEntry().kind === "new") { + this.#beginCreateFlow(); + } else { + this.#focus = "list"; + } + this.#tui.requestRender(); + return; + } + } + + if (matchesSelectUp(data)) { + this.#rowIndex = Math.max(0, this.#rowIndex - 1); + this.#tui.requestRender(); + return; + } + if (matchesSelectDown(data)) { + this.#rowIndex = Math.min(this.#rows.length - 1, this.#rowIndex + 1); + this.#tui.requestRender(); + return; + } + if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") { + this.#activateRow(this.#rows[this.#rowIndex]); + this.#tui.requestRender(); + return; + } + if (data === " " && this.#searchQuery.length === 0) { + const agent = this.#selectedAgent(); + if (agent) this.#toggleAgent(agent); + return; + } + if (matchesKey(data, "backspace")) { + if (this.#searchQuery.length > 0) { + this.#searchQuery = this.#searchQuery.slice(0, -1); + this.#buildRows(); + this.#clampRowIndex(); + this.#tui.requestRender(); + } + return; + } + // Type-to-filter: any printable character extends the query. + if (data.length === 1 && data >= " " && data !== "\x7f") { + this.#searchQuery += data; + this.#focus = "list"; + this.#buildRows(); + this.#rowIndex = 0; + this.#listScroll = 0; + this.#tui.requestRender(); + } + } + + #activateRow(rowDef: ListRow | undefined): void { + if (!rowDef) return; + if (rowDef.kind === "new") { + this.#beginCreateFlow(); + return; + } + this.#openAgentStrip(rowDef.agent); + } + + #handleStripInput(data: string): void { + const strip = this.#strip; + if (!strip) return; + if (matchesSelectCancel(data)) { + // A property strip steps back up to the agent strip instead of closing. + if (strip.kind === "chips" && strip.property) { + this.#openAgentStrip(strip.agent); + return; + } + if (strip.kind === "pattern") { + this.#openPropertyStrip(strip.agent, strip.property); + return; + } + this.#closeStrip(); + return; + } + if (strip.kind === "pattern") { + if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") { + this.#submitPattern(); + return; + } + strip.input.handleInput(data); + return; + } + if (matchesKey(data, "left") || matchesKey(data, "up") || matchesKey(data, "shift+tab")) { + strip.index = (strip.index - 1 + strip.chips.length) % strip.chips.length; + return; + } + if (matchesKey(data, "right") || matchesKey(data, "down") || matchesKey(data, "tab")) { + strip.index = (strip.index + 1) % strip.chips.length; + return; + } + if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") { + this.#activateStripChip(); + return; + } + } + + #handleCreateInput(data: string): void { + if (this.#createSpec) { + if (matchesSelectCancel(data)) { + this.#clearCreateFlow(); + return; + } + if (matchesKey(data, "tab") || matchesKey(data, "shift+tab")) { + this.#createScope = this.#createScope === "project" ? "user" : "project"; + return; + } + if (data.toLowerCase() === "r") { + void this.#generateAgentFromDescription(this.#createDescription); + return; + } + if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") { + void this.#saveGeneratedAgent().catch(error => { + this.#createError = error instanceof Error ? error.message : String(error); + this.#tui.requestRender(); + }); + } + return; + } + if (matchesSelectCancel(data)) { + if (!this.#createGenerating) this.#clearCreateFlow(); + return; + } + if (this.#createGenerating) return; + if (matchesAppFollowUp(data)) { + void this.#generateAgentFromDescription(this.#createInput?.getExpandedText() ?? this.#createDescription); + return; + } + if (matchesKey(data, "tab") || matchesKey(data, "shift+tab")) { + this.#createScope = this.#createScope === "project" ? "user" : "project"; + return; + } + if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") { + this.#createInput?.handleInput("\n"); + this.#createDescription = this.#createInput?.getExpandedText() ?? ""; + return; + } + this.#createInput?.handleInput(data); + this.#createDescription = this.#createInput?.getExpandedText() ?? ""; + } + + #moveSidebar(delta: number): void { + const count = this.#entries.length; + if (count === 0) return; + let index = this.#entries.findIndex(entry => entry.id === this.#activeEntryId); + if (index < 0) index = 0; + for (let step = 0; step < count; step++) { + index = (index + delta + count) % count; + const entry = this.#entries[index]; + if (entry && entry.kind !== "separator") { + this.#activeEntryId = entry.id; + if (entry.kind !== "new") { + this.#buildRows(); + this.#rowIndex = 0; + this.#listScroll = 0; + } + return; + } + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // Mouse + // ═══════════════════════════════════════════════════════════════════════ + + #routeMouseEvent(event: SgrMouseEvent): boolean { + const contentLine = event.row - this.#contentRowStart; + const overContent = contentLine >= 0 && contentLine < this.#contentRowCount; + const sidebarColEnd = 2 + this.#sidebarWidthLast; + const bodyColStart = this.#sidebarWidthLast + 5; + const overSidebar = overContent && event.col >= 0 && event.col < sidebarColEnd; + const overBody = overContent && event.col >= bodyColStart; + const bodyLine = contentLine - 1; // body row 0 is the status row + + if (event.row === this.#footerRow && this.#strip?.kind === "chips") { + const strip = this.#strip; + if (event.leftClick) { + for (const range of this.#chipRanges) { + if (event.col >= range.start && event.col < range.end) { + strip.index = range.index; + this.#activateStripChip(); + return true; + } + } + } + return true; + } + + if (this.#assigning) { + if (overBody) this.#browser.routeMouse(event, bodyLine); + return true; + } + if (this.#createActive || this.#strip) return true; + + if (event.wheel !== null) { + if (overSidebar) { + const maxScroll = Math.max(0, this.#entries.length - this.#contentRowCount); + this.#sidebarScroll = Math.max(0, Math.min(this.#sidebarScroll + event.wheel, maxScroll)); + } else if (overBody) { + this.#rowIndex = Math.max(0, Math.min(this.#rows.length - 1, this.#rowIndex + event.wheel)); + } + return true; + } + + if (event.motion) { + // Hover is stored as an absolute row index so paint and click agree. + const hoverRow = bodyLine - this.#listRowStart + this.#listScroll; + this.#rowHover = overBody && hoverRow >= 0 && hoverRow < this.#rows.length ? hoverRow : null; + return true; + } + + if (!event.leftClick) return true; + + if (overSidebar) { + const index = this.#sidebarScroll + contentLine; + const clicked = this.#entries[index]; + if (clicked && clicked.kind !== "separator") { + if (clicked.kind === "new") { + this.#beginCreateFlow(); + } else { + this.#activeEntryId = clicked.id; + this.#buildRows(); + this.#rowIndex = 0; + this.#focus = "scope"; + } + } + return true; + } + if (overBody) { + this.#focus = "list"; + const listLine = bodyLine - this.#listRowStart + this.#listScroll; + if (listLine >= 0 && listLine < this.#rows.length) { + if (listLine === this.#rowIndex) { + this.#activateRow(this.#rows[listLine]); + } else { + this.#rowIndex = listLine; + } + } + } + return true; + } + + // ═══════════════════════════════════════════════════════════════════════ + // Rendering + // ═══════════════════════════════════════════════════════════════════════ + + #terminalRows(): number { + return Math.max(16, this.#tui.terminal?.rows || process.stdout.rows || 40); + } + + #sidebarWidth(): number { + let longest = 0; + for (const entry of this.#entries) { + longest = Math.max(longest, visibleWidth(entry.label) + visibleWidth(entry.annotation ?? "") + 5); + } + return Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, longest)); + } + + #renderSidebar(width: number, rows: number): string[] { + const activeIndex = Math.max( + 0, + this.#entries.findIndex(entry => entry.id === this.#activeEntryId), + ); + if (activeIndex < this.#sidebarScroll) this.#sidebarScroll = activeIndex; + else if (activeIndex >= this.#sidebarScroll + rows) this.#sidebarScroll = activeIndex - rows + 1; + + const lines: string[] = []; + for (let i = this.#sidebarScroll; i < Math.min(this.#entries.length, this.#sidebarScroll + rows); i++) { + const entry = this.#entries[i]; + if (!entry) continue; + if (entry.kind === "separator") { + lines.push(theme.fg("border", "─".repeat(width))); + continue; + } + const active = entry.id === this.#activeEntryId; + const cursor = active && this.#focus === "scope" ? theme.fg("accent", theme.nav.cursor) : " "; + const icon = entry.kind === "all" ? theme.icon.model : entry.kind === "new" ? "+" : theme.status.enabled; + const labelStyled = active ? theme.bold(theme.fg("accent", entry.label)) : entry.label; + const left = `${cursor} ${theme.fg(entry.kind === "new" ? "dim" : "accent", icon)} ${labelStyled}`; + const annotation = theme.fg("dim", entry.annotation ?? ""); + const leftWidth = visibleWidth(left); + const annWidth = visibleWidth(annotation); + let line: string; + if (leftWidth + annWidth + 1 <= width) { + line = `${left}${" ".repeat(width - leftWidth - annWidth)}${annotation}`; + } else { + line = truncateToWidth(left, width); + } + lines.push(line); + } + return lines; + } + + #statusRow(width: number): string { + if (this.#loadError) return truncateToWidth(theme.fg("error", ` ${this.#loadError}`), width); + if (this.#assigning) { + const { agent, property } = this.#assigning; + const what = property === "model" ? "model override" : `${property} model`; + return truncateToWidth( + theme.fg("accent", ` Picking ${what} for ${theme.bold(agent.name)} — Enter assigns, Esc cancels`), + width, + ); + } + if (this.#createActive) { + return truncateToWidth(theme.fg("accent", " New agent — describe it and let the architect draft it"), width); + } + if (this.#notice) return truncateToWidth(theme.fg("success", ` ${this.#notice}`), width); + const entry = this.#activeEntry(); + const scopeLabel = entry.kind === "source" ? `${entry.label} agents` : "All agents"; + const count = this.#rows.filter(rowDef => rowDef.kind === "agent").length; + return truncateToWidth(theme.fg("muted", ` ${scopeLabel} · ${count}`), width); + } + + #renderList(width: number, rows: number): string[] { + const lines: string[] = []; + const searchText = this.#searchQuery ? theme.fg("accent", this.#searchQuery) : theme.fg("dim", "type to filter"); + lines.push(truncateToWidth(` ${theme.fg("muted", "search:")} ${searchText}`, width)); + lines.push(""); + this.#listRowStart = lines.length; + + const detailRows = 4; + const visibleRows = Math.max(3, rows - lines.length - detailRows); + if (this.#rowIndex < this.#listScroll) this.#listScroll = this.#rowIndex; + else if (this.#rowIndex >= this.#listScroll + visibleRows) this.#listScroll = this.#rowIndex - visibleRows + 1; + this.#listScroll = Math.max(0, Math.min(this.#listScroll, Math.max(0, this.#rows.length - visibleRows))); + + let nameWidth = 0; + for (const rowDef of this.#rows) { + if (rowDef.kind === "agent") nameWidth = Math.max(nameWidth, visibleWidth(rowDef.agent.name)); + } + + const listFocused = this.#focus === "list"; + for (let i = this.#listScroll; i < Math.min(this.#rows.length, this.#listScroll + visibleRows); i++) { + const rowDef = this.#rows[i]; + if (!rowDef) continue; + const selected = i === this.#rowIndex; + const hovered = i === this.#rowHover; + const cursor = selected && listFocused ? theme.fg("accent", theme.nav.cursor) : " "; + if (rowDef.kind === "new") { + let line = ` ${cursor} ${theme.fg(selected ? "accent" : "dim", "+ New agent…")}`; + if (hovered) line = theme.bg("selectedBg", line); + lines.push(truncateToWidth(line, width)); + continue; + } + const agent = rowDef.agent; + const dot = agent.disabled + ? theme.fg("dim", theme.status.disabled) + : theme.fg("success", theme.status.enabled); + const name = replaceTabs(agent.name).padEnd(nameWidth); + const nameStyled = agent.disabled + ? theme.fg("dim", name) + : selected + ? theme.bold(theme.fg("accent", name)) + : name; + const badges: string[] = []; + if (agent.overrideModel) badges.push(theme.fg("warning", agent.overrideModel)); + const prewalk = this.#effectivePrewalkPattern(agent); + if (prewalk) badges.push(theme.fg("dim", `pre:${prewalk}`)); + const advisor = this.#effectiveAdvisorPattern(agent); + if (advisor) badges.push(theme.fg("dim", `adv:${advisor}`)); + const sourceTag = theme.fg("dim", SOURCE_LABEL[agent.source].toLowerCase()); + let line = ` ${cursor} ${dot} ${nameStyled} ${sourceTag}`; + const right = badges.join(" "); + const rightWidth = visibleWidth(right); + const lineWidth = visibleWidth(line); + if (rightWidth > 0 && lineWidth + rightWidth + 2 <= width) { + line = `${line}${" ".repeat(width - lineWidth - rightWidth - 1)}${right}`; + } + line = truncateToWidth(line, width); + if (hovered) { + const w = visibleWidth(line); + if (w < width) line += " ".repeat(width - w); + line = theme.bg("selectedBg", line); + } + lines.push(line); + } + + // Selected-agent detail block pinned to the bottom of the body pane. + while (lines.length < rows - detailRows) lines.push(""); + const agent = this.#selectedAgent(); + lines.push(theme.fg("border", "─".repeat(Math.max(1, width)))); + if (agent) { + lines.push(truncateToWidth(` ${theme.fg("dim", replaceTabs(agent.description))}`, width)); + const patterns = this.#effectiveModelPatterns(agent); + const resolved = this.#resolvePatterns(patterns); + const modelLine = `${theme.fg("muted", "model:")} ${patterns.length > 0 ? replaceTabs(patterns.join(",")) : theme.fg("dim", "(session model)")}${resolved ? ` ${theme.fg("dim", "→")} ${theme.fg("success", resolved)}` : ""}`; + lines.push(truncateToWidth(` ${modelLine}`, width)); + const prewalk = this.#effectivePrewalkPattern(agent); + const advisor = this.#effectiveAdvisorPattern(agent); + const flagLine = [ + `${theme.fg("muted", "prewalk:")} ${prewalk ? theme.fg("success", prewalk) : theme.fg("dim", "off")}`, + `${theme.fg("muted", "advisor:")} ${advisor ? theme.fg("success", advisor) : theme.fg("dim", "off")}`, + agent.filePath ? theme.fg("dim", shortenPath(agent.filePath)) : "", + ] + .filter(Boolean) + .join(" "); + lines.push(truncateToWidth(` ${flagLine}`, width)); + } else { + lines.push(theme.fg("dim", " Select an agent to inspect")); + lines.push(""); + lines.push(""); + } + return lines.slice(0, rows); + } + + #renderCreate(width: number, rows: number): string[] { + const lines: string[] = []; + lines.push(""); + if (this.#createSpec) { + const spec = this.#createSpec; + lines.push(truncateToWidth(theme.bold(theme.fg("accent", " Review generated agent")), width)); + lines.push(""); + lines.push(truncateToWidth(theme.fg("muted", ` Identifier: ${spec.identifier}`), width)); + lines.push(truncateToWidth(theme.fg("muted", ` Scope: ${this.#createScope}`), width)); + lines.push(""); + lines.push(theme.fg("muted", " whenToUse:")); + for (const line of wrapTextWithAnsi(replaceTabs(spec.whenToUse), Math.max(20, width - 2)).slice(0, 6)) { + lines.push(truncateToWidth(` ${line}`, width)); + } + lines.push(""); + lines.push(theme.fg("muted", " systemPrompt preview:")); + const promptWidth = Math.max(20, width - 4); + const wrapped: string[] = []; + for (const raw of spec.systemPrompt.split("\n")) { + for (const w of wrapTextWithAnsi(replaceTabs(raw), promptWidth)) wrapped.push(w); + } + const budget = Math.max(3, rows - lines.length - 3); + for (const line of wrapped.slice(0, budget)) { + lines.push(truncateToWidth(` ${theme.fg("dim", line)}`, width)); + } + if (wrapped.length > budget) { + lines.push(theme.fg("dim", ` … ${wrapped.length - budget} more lines`)); + } + } else { + lines.push(truncateToWidth(theme.bold(theme.fg("accent", " Create new agent")), width)); + lines.push(""); + lines.push( + truncateToWidth( + theme.fg("muted", " Describe what the agent should do; scope: ") + theme.fg("accent", this.#createScope), + width, + ), + ); + lines.push(""); + if (this.#createInput && !this.#createGenerating) { + for (const line of this.#createInput.render(Math.max(20, width - 2))) { + lines.push(truncateToWidth(line, width)); + } + } + if (this.#createGenerating) { + lines.push(theme.fg("muted", " Generating…")); + lines.push(""); + const contentWidth = Math.max(20, width - 4); + const wrapped: string[] = []; + for (const raw of this.#createStreamingText.split("\n")) { + for (const w of wrapTextWithAnsi(replaceTabs(raw), contentWidth)) wrapped.push(w); + } + const budget = Math.max(3, rows - lines.length - 2); + for (const line of wrapped.slice(-budget)) { + lines.push(truncateToWidth(` ${theme.fg("dim", line)}`, width)); + } + } + } + if (this.#createError) { + lines.push(""); + lines.push(truncateToWidth(theme.fg("error", ` ${replaceTabs(this.#createError)}`), width)); + } + while (lines.length < rows) lines.push(""); + return lines.slice(0, rows); + } + + #footerHint(): string { + if (this.#strip) { + if (this.#strip.kind === "pattern") { + const property = this.#strip.property; + const values = property === "model" ? "a model pattern" : '"on", "off", or a model pattern'; + return `Enter ${values} (role aliases like @smol and :level suffixes work; empty clears) · Esc back`; + } + return this.#strip.property ? "←/→ choose · Enter apply · Esc back" : "←/→ choose · Enter open · Esc cancel"; + } + if (this.#assigning) { + return "Enter pick · ↑/↓ models · type to search · Esc cancel"; + } + if (this.#createActive) { + if (this.#createSpec) return "Enter save · Tab scope · r regenerate · Esc cancel"; + if (this.#createGenerating) return "Generating…"; + return "Ctrl+Q/Ctrl+Enter generate · Enter newline · Tab scope · Esc cancel"; + } + if (this.#focus === "scope") { + return "↑/↓ scopes · →/Enter agents · Esc close"; + } + return "Enter configure · Space enable/disable · ↑/↓ rows · type to search · Ctrl+R reload · Esc close"; + } + + #renderFooter(width: number): string { + this.#chipRanges = []; + const strip = this.#strip; + if (!strip) { + return truncateToWidth(theme.fg("dim", this.#footerHint()), width); + } + if (strip.kind === "pattern") { + const label = theme.fg("accent", `${strip.agent.name} ${strip.property} pattern:`); + const labelWidth = visibleWidth(`${strip.agent.name} ${strip.property} pattern:`); + const inputWidth = Math.max(8, Math.min(40, width - labelWidth - 4)); + const inputLine = strip.input.render(inputWidth)[0] ?? ""; + return truncateToWidth(`${label} ${inputLine}`, width); + } + const prefix = strip.property + ? `${theme.fg("accent", strip.agent.name)}${theme.fg("dim", ` · ${strip.property} →`)} ` + : `${theme.fg("accent", strip.agent.name)}${theme.fg("dim", " →")} `; + let line = prefix; + let col = 2 + visibleWidth(prefix); + for (let i = 0; i < strip.chips.length; i++) { + const chip = strip.chips[i]; + if (!chip) continue; + const selected = i === strip.index; + const body = ` ${chip.styled} `; + const rendered = selected + ? theme.bg("selectedBg", `${theme.fg("accent", "[")}${body}${theme.fg("accent", "]")}`) + : body; + const w = visibleWidth(body) + (selected ? 2 : 0); + this.#chipRanges.push({ start: col, end: col + w, index: i }); + line += rendered; + col += w; + line += " "; + col += 1; + } + return truncateToWidth(line, width); + } + + render(width: number): string[] { + const height = this.#terminalRows(); + const sidebarWidth = this.#sidebarWidth(); + this.#sidebarWidthLast = sidebarWidth; + const bodyWidth = splitBodyWidth(width, sidebarWidth); + const contentRows = Math.max(10, height - 4); + this.#contentRowCount = contentRows; + + const bodyLines: string[] = [this.#statusRow(bodyWidth)]; + if (this.#createActive) { + bodyLines.push(...this.#renderCreate(bodyWidth, contentRows - 1)); + } else if (this.#assigning) { + this.#browser.setMaxVisible(contentRows - 1 - 5); + this.#browser.setFocused(true); + bodyLines.push(...this.#browser.render(bodyWidth)); + } else { + bodyLines.push(...this.#renderList(bodyWidth, contentRows - 1)); + } + + const sidebarLines = this.#renderSidebar(sidebarWidth, contentRows); + const out: string[] = []; + out.push(topBorderSplit(width, "Agents", sidebarWidth)); + this.#contentRowStart = out.length; + for (let i = 0; i < contentRows; i++) { + out.push(splitRow(sidebarLines[i] ?? "", bodyLines[i] ?? "", width, sidebarWidth)); + } + out.push(dividerSplit(width, sidebarWidth)); + this.#footerRow = out.length; + out.push(row(this.#renderFooter(width - 4), width)); + out.push(bottomBorder(width)); + return out; + } +} diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index e7e5082be..d2c5cd788 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -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 { 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); } /** diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 1f487efaf..65f3f5acd 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -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 diff --git a/packages/coding-agent/src/slash-commands/builtin-session.ts b/packages/coding-agent/src/slash-commands/builtin-session.ts index 92cde7e3f..456a712e7 100644 --- a/packages/coding-agent/src/slash-commands/builtin-session.ts +++ b/packages/coding-agent/src/slash-commands/builtin-session.ts @@ -442,7 +442,7 @@ export const BUILTIN_SESSION_SLASH_COMMANDS: ReadonlyArray = [ }, { 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(""); diff --git a/packages/coding-agent/test/agent-dashboard-create-editor.test.ts b/packages/coding-agent/test/agent-dashboard-create-editor.test.ts deleted file mode 100644 index c224ce9e8..000000000 --- a/packages/coding-agent/test/agent-dashboard-create-editor.test.ts +++ /dev/null @@ -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 { - 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"); - }); -}); diff --git a/packages/coding-agent/test/agents-hub.test.ts b/packages/coding-agent/test/agents-hub.test.ts new file mode 100644 index 000000000..33fdd0153 --- /dev/null +++ b/packages/coding-agent/test/agents-hub.test.ts @@ -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 { + 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); + }); +});