diff --git a/e2e/cate-control.spec.ts b/e2e/cate-control.spec.ts new file mode 100644 index 00000000..4e1dba75 --- /dev/null +++ b/e2e/cate-control.spec.ts @@ -0,0 +1,77 @@ +// E2E coverage for the cate-control agent feature - drives the real renderer +// dispatcher (window.__cateE2E.cateControl) exactly as an agent tool call would, +// then observes the live app. Uses the lean, titles-only 4-tool surface +// (layout / panel{open,close,move} / browser / terminal{run,read}). Focused on: +// 1. terminal commands actually run (panel open + terminal run) +// 2. opening an editor straight into markdown preview +import { test, expect } from '@playwright/test' +import { launchApp, closeApp } from './fixtures/electron-app' +import type { ElectronApplication, Page } from 'playwright' + +let app: ElectronApplication +let page: Page + +test.beforeEach(async () => { + ;({ electronApp: app, mainWindow: page } = await launchApp()) +}) +test.afterEach(async () => closeApp(app)) + +async function cate(p: Page, action: string, params: Record): Promise { + return p.evaluate( + ({ action, params }) => window.__cateE2E!.cateControl(action, params), + { action, params }, + ) +} + +// cate tools report panel TITLES; resolve to a panelId for the harness reads. +async function panelId(p: Page, title: string): Promise { + return p.evaluate((t) => window.__cateE2E!.panelIdByTitle(t), title) +} + +test('terminal run executes the command in a live PTY', async () => { + const res = await cate(page, 'terminal', { op: 'run', command: 'echo $((6*7))_CATEOK', newPanel: true }) + expect(res.ok).toBe(true) + const title = res.result.terminal as string + expect(title).toBeTruthy() + // "42_CATEOK" appears only in the command OUTPUT (the echoed input line shows + // the literal "echo $((6*7))_CATEOK"), so matching it proves the shell ran. + await expect + .poll(async () => { + const pid = await panelId(page, title) + return pid ? page.evaluate((p) => window.__cateE2E!.terminalText(p), pid) : '' + }, { timeout: 15_000, intervals: [250] }) + .toContain('42_CATEOK') +}) + +test('panel open (terminal, command) runs the command', async () => { + const res = await cate(page, 'panel', { op: 'open', type: 'terminal', target: { command: 'echo $((8*8))_CATEOPEN' } }) + expect(res.ok).toBe(true) + const title = res.result.title as string + await expect + .poll(async () => { + const pid = await panelId(page, title) + return pid ? page.evaluate((p) => window.__cateE2E!.terminalText(p), pid) : '' + }, { timeout: 15_000, intervals: [250] }) + .toContain('64_CATEOPEN') +}) + +test('panel open with target.preview enters markdown preview', async () => { + const opened = await cate(page, 'panel', { op: 'open', type: 'editor', target: { path: 'CATE_NOTES.md', preview: true } }) + expect(opened.ok).toBe(true) + const pid = await panelId(page, opened.result.title as string) + expect(pid).toBeTruthy() + const nodeId = await page.evaluate( + (p) => window.__cateE2E!.nodes().find((n) => n.panelId === p)?.id ?? null, + pid, + ) + expect(nodeId).toBeTruthy() + const nodeSel = `[data-node-id="${nodeId}"]` + await page.waitForSelector(nodeSel) + // Preview active → the toggle reads "Source" (click to go back to source). + await expect(page.locator(`${nodeSel} button:has-text("Source")`)).toBeVisible() +}) + +test('closing a non-existent panel title errors', async () => { + const res = await cate(page, 'panel', { op: 'close', panel: 'does-not-exist' }) + expect(res.ok).toBe(false) +}) diff --git a/src/agent/extensions/cate-control/index.ts b/src/agent/extensions/cate-control/index.ts new file mode 100644 index 00000000..ceb039f6 --- /dev/null +++ b/src/agent/extensions/cate-control/index.ts @@ -0,0 +1,119 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" +import { Type } from "typebox" + +// Inlined sentinel (must equal CATE_SENTINEL in src/shared/cateControl.ts). +// Pi loads this file via jiti from the workspace dir, where @shared can't resolve. +const CATE_SENTINEL = "@@cate-control@@" + +type CateResponse = { ok: boolean; result?: unknown; error?: string; denied?: boolean } + +async function sendControlRequest(ctx: any, action: string, params: Record): Promise { + const payload = CATE_SENTINEL + JSON.stringify({ action, params }) + const raw = await ctx.ui.input(payload) + if (typeof raw !== "string") return { ok: false, error: "no response from Cate (cancelled or timed out)" } + try { return JSON.parse(raw) as CateResponse } + catch { return { ok: false, error: "malformed response from Cate" } } +} + +function toResult(action: string, res: CateResponse) { + const text = res.ok + ? `${action} ok: ${JSON.stringify(res.result ?? {})}` + : res.denied ? `${action} denied by user` : `${action} failed: ${res.error ?? "unknown error"}` + return { content: [{ type: "text" as const, text }], details: res } +} + +const Placement = Type.Optional(Type.Object({ + relativeTo: Type.Optional(Type.String({ description: "panel id (e.g. \"a1b2c3\") or 'self'" })), + position: Type.Optional(Type.Union([Type.Literal("right"), Type.Literal("left"), Type.Literal("above"), Type.Literal("below")])), +})) + +const CATE_TOOLS = ["cate_layout", "cate_panel", "cate_browser", "cate_terminal"] + +export default function (pi: ExtensionAPI) { + // On/off without a reload: the tools are always registered, but we add/remove + // them from the session's ACTIVE set, which is what gets advertised to the + // model. Inactive => the agent never sees them and spends no tokens on their + // definitions. The renderer flips this live by firing /cate-on | /cate-off + // (like /plan); the env var seeds the initial state for a fresh session. + let desired = process.env.CATE_CONTROL_ENABLED !== "0" + const apply = () => { + const active = new Set(pi.getActiveTools()) + for (const t of CATE_TOOLS) { if (desired) active.add(t); else active.delete(t) } + pi.setActiveTools([...active]) + } + const setEnabled = (on: boolean) => { desired = on; apply() } + // Re-apply on every session start/resume/reload so the live state survives. + pi.on("session_start", () => apply()) + pi.registerCommand("cate-on", { description: "Enable Cate panel control.", handler: async () => setEnabled(true) }) + pi.registerCommand("cate-off", { description: "Disable Cate panel control.", handler: async () => setEnabled(false) }) + + const tool = (name: string, label: string, description: string, parameters: any, action: string) => + pi.registerTool({ + name, label, description, parameters, + async execute(_id, params, _signal, _onUpdate, ctx) { + return toResult(action, await sendControlRequest(ctx, action, params as Record)) + }, + }) + + tool("cate_layout", "Read the canvas", + "Return the open panels - {id, title, type, focused, isSelf} for each. Target panels in the other cate tools by their `id` (e.g. \"a1b2c3\") - it is stable. `title` is only a display label and changes (a browser's title becomes the page title, etc.).", + Type.Object({}), "layout") + + tool("cate_panel", "Open, close, or move a panel", + [ + "Open, close, or move a canvas panel. Choose `op`:", + "- 'open': create a panel. {type: editor|terminal|browser|document, target?, placement?}. target: {path,line?,column?,preview?} for editor (preview:true opens a markdown file straight into rendered preview); {url} for browser; {cwd?,command?} for terminal. Returns the new panel's {id, title} - keep the id to target it later.", + "- 'close': {panel} - the panel id.", + "- 'move': {panel, placement:{relativeTo,position}} - reposition relative to another panel.", + "`panel` and placement.relativeTo are panel IDs like \"a1b2c3\" (from cate_layout or an open result), or 'self' for your own panel. This never pans or zooms the user's view.", + ].join("\n"), + Type.Object({ + op: Type.Union([Type.Literal("open"), Type.Literal("close"), Type.Literal("move")]), + panel: Type.Optional(Type.String({ description: "panel id (for close / move)" })), + type: Type.Optional(Type.String()), + target: Type.Optional(Type.Object({ + path: Type.Optional(Type.String()), line: Type.Optional(Type.Number()), column: Type.Optional(Type.Number()), + url: Type.Optional(Type.String()), cwd: Type.Optional(Type.String()), command: Type.Optional(Type.String()), + preview: Type.Optional(Type.Boolean()), + })), + placement: Placement, + }), "panel") + + tool("cate_browser", "Control a browser panel", + [ + "Drive a browser panel. Choose `op`:", + "- 'navigate': load a url. {panel, url}.", + "- 'back' | 'forward' | 'reload' | 'stop': history / loading control. {panel}.", + "- 'info': report the current {url, title, canGoBack, canGoForward}. {panel}.", + "- 'read': the page's visible text, or one CSS selector's text. {panel, selector?}.", + "- 'eval': run JavaScript in the page and return its result (use this to click, fill, or scroll). {panel, js}.", + "- 'screenshot': capture the page to an image file. {panel}.", + "`panel` is a panel id (e.g. \"a1b2c3\", from cate_layout or the open result).", + ].join("\n"), + Type.Object({ + op: Type.Union([ + Type.Literal("navigate"), Type.Literal("back"), Type.Literal("forward"), + Type.Literal("reload"), Type.Literal("stop"), Type.Literal("info"), + Type.Literal("read"), Type.Literal("eval"), Type.Literal("screenshot"), + ]), + panel: Type.String({ description: "panel id, e.g. \"a1b2c3\"" }), + url: Type.Optional(Type.String()), + selector: Type.Optional(Type.String({ description: "CSS selector for read" })), + js: Type.Optional(Type.String({ description: "JavaScript to run in the page for eval" })), + }), "browser") + + tool("cate_terminal", "Run or read a terminal", + [ + "Drive a terminal panel. Choose `op`:", + "- 'run': run a shell command. {command, panel? (reuse an existing terminal by id), newPanel?:bool (force a fresh one)}. Returns the terminal's {id, title}.", + "- 'read': read recent output (visible screen + scrollback) as text. {panel, lines?:number (trailing lines, default 50, max 1000)}.", + "`panel` is a panel id (e.g. \"a1b2c3\").", + ].join("\n"), + Type.Object({ + op: Type.Union([Type.Literal("run"), Type.Literal("read")]), + panel: Type.Optional(Type.String()), + command: Type.Optional(Type.String()), + newPanel: Type.Optional(Type.Boolean()), + lines: Type.Optional(Type.Number()), + }), "terminal") +} diff --git a/src/agent/extensions/cate-control/package.json b/src/agent/extensions/cate-control/package.json new file mode 100644 index 00000000..670df561 --- /dev/null +++ b/src/agent/extensions/cate-control/package.json @@ -0,0 +1,6 @@ +{ + "name": "cate-control", + "description": "Control Cate panels by title: read the canvas, open/close/move panels, run/read terminals, navigate browsers.", + "private": true, + "pi": { "extensions": ["./index.ts"] } +} diff --git a/src/agent/main/agentManager.ts b/src/agent/main/agentManager.ts index 6727295f..a721c71c 100644 --- a/src/agent/main/agentManager.ts +++ b/src/agent/main/agentManager.ts @@ -40,6 +40,8 @@ import type { import { AGENT_EVENT } from '../../shared/ipc-channels' import { installSubagentExtension } from './installSubagents' import { installPlanModeExtension } from './installPlanMode' +import { installCateControlExtension } from './installCateControl' +import { getSettingSync } from '../../main/store' import { agentDirFor, prepareAgentDir, watchWorkspaceAuth, pushSharedToWorkspace } from './agentDir' import { mirrorModelsToWorkspace } from './customModels' import type { AuthManager } from './authManager' @@ -92,6 +94,10 @@ function buildAgentEnv(cwd: string): Record { // Scope pi's entire config (extensions, sessions, settings, auth) to this // workspace instead of the user's global ~/.pi/agent. env.PI_CODING_AGENT_DIR = agentDirFor(cwd) + // The cate-control extension reads this at load time and registers its tools + // only when enabled, so disabling the feature costs the agent zero tokens + // (no tool definitions) without removing the extension from disk. + env.CATE_CONTROL_ENABLED = getSettingSync('cateControlEnabled') ? '1' : '0' const needsShim = app.isPackaged || !nodeExistsOnPath(env) if (needsShim) { const shimDir = ensureElectronNodeShim() @@ -191,6 +197,9 @@ export class AgentManager { await mirrorModelsToWorkspace(opts.cwd) await installSubagentExtension(opts.cwd) await installPlanModeExtension(opts.cwd) + // Always installed; whether it registers any tools is gated at load time by + // CATE_CONTROL_ENABLED (see buildAgentEnv), so toggling never touches disk. + await installCateControlExtension(opts.cwd) const extraArgs: string[] = [] if (opts.sessionFile) extraArgs.push('--session', opts.sessionFile) diff --git a/src/agent/main/installCateControl.test.ts b/src/agent/main/installCateControl.test.ts new file mode 100644 index 00000000..82b9dcc1 --- /dev/null +++ b/src/agent/main/installCateControl.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import fs from 'fs/promises' +import os from 'os' +import path from 'path' + +// installCateControl (and agentDir, which it imports) pull in electron + the +// main logger, neither of which load under the node test env. Stub them so the +// module graph evaluates. +vi.mock('electron', () => ({ app: { getAppPath: () => '/nonexistent', getPath: () => os.tmpdir() } })) +vi.mock('../../main/logger', () => ({ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } })) + +import { copyIfChanged } from './installCateControl' + +let dir: string +beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'cate-install-')) +}) +afterEach(async () => { + await fs.rm(dir, { recursive: true, force: true }) +}) + +describe('copyIfChanged', () => { + it('writes the file (and parent dirs) when the destination is missing', async () => { + const src = path.join(dir, 'src.ts') + const dest = path.join(dir, 'nested', 'dest.ts') + await fs.writeFile(src, 'NEW') + await copyIfChanged(src, dest) + expect(await fs.readFile(dest, 'utf8')).toBe('NEW') + }) + + it('overwrites a stale destination whose bytes differ (the protocol-desync bug)', async () => { + // Regression: skip-if-exists left an old extension installed, so the agent + // emitted action names (cate_open_panel) the renderer no longer handled. + const src = path.join(dir, 'src.ts') + const dest = path.join(dir, 'dest.ts') + await fs.writeFile(src, 'tool("cate_panel")') + await fs.writeFile(dest, 'tool("cate_open_panel")') + await copyIfChanged(src, dest) + expect(await fs.readFile(dest, 'utf8')).toBe('tool("cate_panel")') + }) + + it('does not rewrite when the destination already matches', async () => { + const src = path.join(dir, 'src.ts') + const dest = path.join(dir, 'dest.ts') + await fs.writeFile(src, 'SAME') + await fs.writeFile(dest, 'SAME') + const past = new Date(Date.now() - 60_000) + await fs.utimes(dest, past, past) + const before = (await fs.stat(dest)).mtimeMs + await copyIfChanged(src, dest) + expect((await fs.stat(dest)).mtimeMs).toBe(before) // skipped — not rewritten + }) +}) diff --git a/src/agent/main/installCateControl.ts b/src/agent/main/installCateControl.ts new file mode 100644 index 00000000..7024e951 --- /dev/null +++ b/src/agent/main/installCateControl.ts @@ -0,0 +1,75 @@ +// ============================================================================= +// installCateControl - copy the bundled cate-control extension into a +// workspace's pi-agent extensions dir, where pi auto-discovers it. +// +// Source lives in our own tree at src/agent/extensions/cate-control/. Pi +// loads .ts directly via jiti, so we just ship the raw .ts and .json files. +// +// Dev: src/ is on disk under app.getAppPath(). +// Prod: src/agent/extensions/cate-control/ is copied into resources via +// electron-builder.yml `extraResources`, so we resolve from +// process.resourcesPath there. +// +// Refresh-on-change (NOT skip-if-exists): the extension's tool/action protocol +// is tightly coupled to the renderer dispatcher (src/agent/renderer/cateControl +// + cateExecutors). A stale installed copy makes the agent emit action names the +// renderer no longer handles ("Unknown or unimplemented action"), so the bundled +// copy is authoritative and is rewritten whenever its bytes differ from the +// installed copy. (Previously skip-if-exists, which silently broke the feature +// after any extension update - dev or app upgrade.) +// ============================================================================= + +import fs from 'fs' +import fsp from 'fs/promises' +import path from 'path' +import { app } from 'electron' +import log from '../../main/logger' +import { agentDirFor } from './agentDir' + +const installed = new Set() + +/** Source dir of the bundled extension. Tries dev path first (src/ on disk), + * then production extraResources copy. */ +function sourceDir(): string | null { + const candidates = [ + path.join(app.getAppPath(), 'src', 'agent', 'extensions', 'cate-control'), + path.join(process.resourcesPath ?? '', 'cate-extensions', 'cate-control'), + ] + for (const c of candidates) { + if (c && fs.existsSync(c)) return c + } + return null +} + +/** Write `src` → `dest` when the destination is missing or its bytes differ. + * Keeps the installed extension in lock-step with the bundled source so the + * agent never emits a stale action the renderer dispatcher can't handle. */ +export async function copyIfChanged(src: string, dest: string): Promise { + const srcData = await fsp.readFile(src) + try { + const destData = await fsp.readFile(dest) + if (destData.equals(srcData)) return // up to date - nothing to do + } catch { /* missing - fall through to write */ } + await fsp.mkdir(path.dirname(dest), { recursive: true }) + await fsp.writeFile(dest, srcData) + log.info('[installCateControl] installed/updated %s', dest) +} + +/** Idempotent - safe to call from AgentManager.create() on every session. */ +export async function installCateControlExtension(cwd: string): Promise { + const home = agentDirFor(cwd) + if (installed.has(home)) return + installed.add(home) + try { + const src = sourceDir() + if (!src) { + log.warn('[installCateControl] source dir not found - cate-control not installed') + return + } + const destDir = path.join(home, 'extensions', 'cate-control') + await copyIfChanged(path.join(src, 'index.ts'), path.join(destDir, 'index.ts')) + await copyIfChanged(path.join(src, 'package.json'), path.join(destDir, 'package.json')) + } catch (err) { + log.warn('[installCateControl] install failed: %O', err) + } +} diff --git a/src/agent/main/sessionFiles.test.ts b/src/agent/main/sessionFiles.test.ts new file mode 100644 index 00000000..b2dd2f35 --- /dev/null +++ b/src/agent/main/sessionFiles.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import fs from 'fs/promises' +import os from 'os' +import path from 'path' +import { loadSessionTranscript, type RendererToolMessage } from './sessionFiles' + +// loadSessionTranscript guards on the path containing the sessions segment and +// ending in .jsonl, so the fixture has to live under a matching dir. +let dir: string +let file: string + +beforeAll(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'cate-sess-')) + dir = path.join(dir, '.cate', 'pi-agent', 'sessions', 'ws') + await fs.mkdir(dir, { recursive: true }) + file = path.join(dir, '123_abc.jsonl') +}) + +afterAll(async () => { await fs.rm(dir, { recursive: true, force: true }).catch(() => {}) }) + +function line(obj: unknown): string { return JSON.stringify(obj) } + +async function transcript(lines: unknown[]): Promise { + await fs.writeFile(file, lines.map(line).join('\n')) + const out = await loadSessionTranscript(file) + return out.filter((m): m is RendererToolMessage => m.type === 'tool') +} + +describe('loadSessionTranscript — cate-control tools', () => { + it('keeps the raw cate_* name (renderer matches it like plan_complete)', async () => { + const tools = await transcript([ + { type: 'message', id: 'e1', message: { role: 'assistant', content: [ + { type: 'toolCall', id: 't1', name: 'cate_browser', arguments: { op: 'read', panel: 'p1' } }, + ] } }, + ]) + expect(tools).toHaveLength(1) + expect(tools[0].name).toBe('cate_browser') + expect(tools[0].args).toEqual({ op: 'read', panel: 'p1' }) + }) + + it('rebuilds the structured result from details (not the prose content)', async () => { + const tools = await transcript([ + { type: 'message', id: 'e1', message: { role: 'assistant', content: [ + { type: 'toolCall', id: 't1', name: 'cate_browser', arguments: { op: 'read', panel: 'p1' } }, + ] } }, + { type: 'message', id: 'e2', message: { + role: 'toolResult', toolCallId: 't1', + content: [{ type: 'text', text: 'browser ok: {"text":"hi"}' }], + details: { ok: true, result: { browser: 'Browser', text: 'hi' } }, + } }, + ]) + expect(tools[0].status).toBe('success') + // Result is JSON the renderer parses back into an object — not the prose. + expect(JSON.parse(tools[0].result!)).toEqual({ browser: 'Browser', text: 'hi' }) + }) + + it('surfaces a failed cate op as an error from details.ok=false', async () => { + const tools = await transcript([ + { type: 'message', id: 'e1', message: { role: 'assistant', content: [ + { type: 'toolCall', id: 't1', name: 'cate_panel', arguments: { op: 'close', panel: 'p9' } }, + ] } }, + { type: 'message', id: 'e2', message: { + role: 'toolResult', toolCallId: 't1', + content: [{ type: 'text', text: 'panel failed: no such panel' }], + details: { ok: false, error: 'no such panel' }, + } }, + ]) + expect(tools[0].name).toBe('cate_panel') + expect(tools[0].status).toBe('error') + expect(tools[0].error).toBe('no such panel') + expect(tools[0].result).toBeUndefined() + }) + + it('leaves non-cate tools untouched', async () => { + const tools = await transcript([ + { type: 'message', id: 'e1', message: { role: 'assistant', content: [ + { type: 'toolCall', id: 't1', name: 'bash', arguments: { command: 'ls' } }, + ] } }, + { type: 'message', id: 'e2', message: { + role: 'toolResult', toolCallId: 't1', + content: [{ type: 'text', text: 'a.ts' }], + } }, + ]) + expect(tools[0].name).toBe('bash') + expect(tools[0].result).toBe('a.ts') + }) +}) diff --git a/src/agent/main/sessionFiles.ts b/src/agent/main/sessionFiles.ts index c7556c72..d7066f75 100644 --- a/src/agent/main/sessionFiles.ts +++ b/src/agent/main/sessionFiles.ts @@ -305,6 +305,15 @@ export async function loadSessionTranscript(filePath: string): Promise | undefined { + if (!details || typeof details !== 'object') return undefined + const d = details as Record + if (typeof d.ok !== 'boolean') return undefined + if (!d.ok) { + return { status: 'error', error: typeof d.error === 'string' ? d.error : 'Cate reported an error' } + } + return { + status: 'success', + result: d.result === undefined ? undefined : JSON.stringify(d.result), + } +} + function extractText(content: unknown): string { if (typeof content === 'string') return content if (!Array.isArray(content)) return '' diff --git a/src/agent/renderer/AgentChatInput.tsx b/src/agent/renderer/AgentChatInput.tsx index 4b1a6cfb..4c3861fd 100644 --- a/src/agent/renderer/AgentChatInput.tsx +++ b/src/agent/renderer/AgentChatInput.tsx @@ -13,6 +13,7 @@ import { ClipboardText, Spinner, ArrowsClockwise, + SquaresFour, } from '@phosphor-icons/react' import { ImageAttachButton, @@ -49,6 +50,8 @@ export function ChatInput({ compactionActive, planModeActive, onTogglePlanMode, + cateControlEnabled, + onToggleCateControl, placeholder: placeholderOverride, }: { draft: string @@ -73,6 +76,8 @@ export function ChatInput({ compactionActive: boolean planModeActive: boolean onTogglePlanMode: () => void + cateControlEnabled?: boolean + onToggleCateControl?: () => void placeholder?: string }) { useEffect(() => { @@ -224,6 +229,17 @@ export function ChatInput({ > + s.cateControlEnabled) const uiRequests = slice?.uiRequests ?? [] const currentUiRequest = uiRequests[0] @@ -503,6 +516,19 @@ export default function AgentPanel({ panelId, workspaceId }: PanelProps) { [activeAgentKey], ) + // Register this chat's cate-control context so the dispatcher can resolve its + // workspace/canvas. Re-registers whenever the active chat or workspace changes. + useEffect(() => { + if (!activeAgentKey) return + const key = activeAgentKey + registerCateContext(key, { + workspaceId, + hostPanelId: panelId, + canvasStore: canvasStoreApi, + }) + return () => unregisterCateContext(key) + }, [activeAgentKey, workspaceId, panelId, canvasStoreApi]) + // --------------------------------------------------------------------------- // Derived state // --------------------------------------------------------------------------- @@ -706,6 +732,17 @@ export default function AgentPanel({ panelId, workspaceId }: PanelProps) { catch (err) { log.warn('[AgentPanel] toggle plan mode failed', err) } }, [activeAgentKey]) + const handleToggleCateControl = useCallback(async () => { + const s = useSettingsStore.getState() + const next = !s.cateControlEnabled + s.setSetting('cateControlEnabled', next) + // Apply live to the current chat (no reload), mirroring plan mode's /plan: + // the extension's /cate-on|/cate-off command flips the tools' active state. + if (!activeAgentKey) return + try { await window.electronAPI.agentPrompt(activeAgentKey, next ? '/cate-on' : '/cate-off') } + catch (err) { log.warn('[AgentPanel] toggle cate control failed', err) } + }, [activeAgentKey]) + const handleImplementPlan = useCallback(async () => { if (!activeAgentKey) return const key = activeAgentKey @@ -931,6 +968,8 @@ export default function AgentPanel({ panelId, workspaceId }: PanelProps) { compactionActive={compaction.active} planModeActive={planModeActive} onTogglePlanMode={handleTogglePlanMode} + cateControlEnabled={cateControlEnabled} + onToggleCateControl={handleToggleCateControl} placeholder={ !selectedModel ? 'Pick a model to start…' : !selectedProviderConnected ? `Connect ${selectedModel.provider} to start…` @@ -989,6 +1028,8 @@ export default function AgentPanel({ panelId, workspaceId }: PanelProps) { compactionActive={compaction.active} planModeActive={planModeActive} onTogglePlanMode={handleTogglePlanMode} + cateControlEnabled={cateControlEnabled} + onToggleCateControl={handleToggleCateControl} /> )} diff --git a/src/agent/renderer/ChatThread.tsx b/src/agent/renderer/ChatThread.tsx index b87c9ef8..72b117eb 100644 --- a/src/agent/renderer/ChatThread.tsx +++ b/src/agent/renderer/ChatThread.tsx @@ -7,7 +7,7 @@ // expands what they want to see. // ============================================================================= -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useRenderCount } from '../../renderer/lib/perf/perfClient' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' @@ -33,6 +33,7 @@ import type { ToolMessage, } from './agentStore' import { deriveDiff } from './agentStore' +import { cateToolDisplay, cateActionName, cateToolFields, isCateTool, type CateField } from './cateToolDisplay' // Per-conversation scroll memory — survives the dock-tab unmount/remount cycle. // Transient UI state, intentionally module-level (not persisted to disk/store). @@ -373,6 +374,9 @@ function MessageRow({ : 'text-muted' return
{msg.text}
} + if (msg.type === 'tool' && isCateTool(msg.name)) { + return + } if (msg.type === 'tool' && msg.name === 'subagent') { return } @@ -599,6 +603,173 @@ function toolVerb(msg: ToolMessage): string { } } +// ----------------------------------------------------------------------------- +// Cate-control card — renders the agent's canvas actions (open / move / arrange +// panels, run+read terminals, …) using the same borderless verb + summary layout +// as the standard ToolCard, so Cate's actions read identically to its other tool +// calls. The cate-specific verb/summary come from cateToolDisplay. +// ----------------------------------------------------------------------------- + +// Aligned label/value rows for the expanded cate card (input params + flat +// result values). Reads like a small field list instead of a JSON blob. +function CateFieldRows({ fields }: { fields: CateField[] }) { + return ( +
+ {fields.map((f) => ( +
+ {f.label} + {f.value} +
+ ))} +
+ ) +} + +// `terminal read` output, rendered like a terminal buffer rather than escaped JSON. +function CateTerminalOutput({ text, lineCount }: { text: string; lineCount?: number }) { + return ( +
+ {typeof lineCount === 'number' && ( +
{lineCount} line{lineCount === 1 ? '' : 's'}
+ )} +
+        {text || '(no output)'}
+      
+
+ ) +} + +// `layout get` result: one compact row per open panel — stable id + type + +// title, with focused/self tags. The id is the handle the agent targets by. +function CatePanelList({ panels }: { panels: Array> }) { + if (!panels.length) return
No open panels.
+ return ( +
+ {panels.map((p, i) => ( +
+ {String(p.id ?? '')} + {String(p.type ?? 'panel')} + {String(p.title || '(untitled)')} + {p.focused === true && focused} + {p.isSelf === true && self} +
+ ))} +
+ ) +} + +// Browser-control result body. Only renders genuinely NEW information (read text, +// eval result, info/state, screenshot path); navigate + history ops just echo +// their input (already shown by the field rows), so they render nothing. +function CateBrowserResult({ op, rec }: { op: string; rec: Record }) { + if (op === 'read') { + const text = typeof rec.text === 'string' ? rec.text : '' + return ( +
+ {rec.truncated === true &&
truncated
} +
+          {text || '(empty)'}
+        
+
+ ) + } + if (op === 'eval') { + const v = rec.result + return ( +
+        {v === undefined ? 'undefined' : String(v)}
+      
+ ) + } + if (op === 'info') { + return ( + + ) + } + if (op === 'screenshot') { + return + } + return null +} + +function parseCateResult(result?: string): unknown { + if (!result) return undefined + try { return JSON.parse(result) } catch { return result } +} + +function CateToolCard({ msg, shimmer }: { msg: ToolMessage; shimmer?: boolean }) { + const [expanded, setExpanded] = useState(false) + const action = cateActionName(msg.name) + const params = (msg.args ?? {}) as Record + const { verb, summary } = useMemo(() => cateToolDisplay(action, params), [action, params]) + const fields = useMemo(() => cateToolFields(action, params), [action, params]) + const result = useMemo(() => parseCateResult(msg.result), [msg.result]) + + // The result section is only for genuinely NEW information: terminal output and + // the canvas panel list. Everything else a cate op returns is just an echo of + // its input (panelId, command, coords) — surfaced already by the input fields — + // so we don't render it again. A non-JSON string result falls back to a block. + const resultNode: ReactNode = useMemo(() => { + const rec = result && typeof result === 'object' && !Array.isArray(result) + ? (result as Record) + : undefined + if (action === 'terminal' && typeof rec?.text === 'string') { + return + } + if (action === 'layout' && Array.isArray(rec?.panels)) { + return >} /> + } + if (action === 'browser' && rec) { + return + } + if (!rec && typeof result === 'string' && result) { + return ( +
+          {result}
+        
+ ) + } + return null + }, [action, result, params]) + + const isRunning = msg.status === 'running' || msg.status === 'pending' + const isDenied = msg.status === 'denied' + const hasExtras = fields.length > 0 || !!resultNode || !!msg.error || isDenied + + return ( +
+ + {expanded && hasExtras && ( +
+ {fields.length > 0 && } + {resultNode} + {isDenied && ( +
Denied by user
+ )} + {msg.error && ( +
+              {msg.error}
+            
+ )} +
+ )} +
+ ) +} + function ToolCard({ msg, shimmer }: { msg: ToolMessage; shimmer?: boolean }) { const isBash = msg.name === 'bash' || msg.name === 'shell' const isRead = msg.name === 'read' || msg.name === 'view' diff --git a/src/agent/renderer/agentStore.ts b/src/agent/renderer/agentStore.ts index 56b9c22a..708c647b 100644 --- a/src/agent/renderer/agentStore.ts +++ b/src/agent/renderer/agentStore.ts @@ -23,6 +23,9 @@ import type { AgentThinkingLevel, AgentToolApprovalRequest, } from '../../shared/types' +import { CATE_SENTINEL, type CateControlRequest } from '../../shared/cateControl' +import { dispatchCateRequest } from './cateControl' +import './cateExecutors' // side-effect import: registers the executor map (setCateExecutors) // ----------------------------------------------------------------------------- // Message types — local to the renderer @@ -298,11 +301,15 @@ function withPanel( return { panels: { ...state.panels, [panelId]: next } } } +// Monotonic id for the synthetic tool-call messages we add to the thread so the +// user sees each cate-control action render as a card (not a silent round-trip). +let cateCallSeq = 0 + // ----------------------------------------------------------------------------- // Store // ----------------------------------------------------------------------------- -export const useAgentStore = create((set) => ({ +export const useAgentStore = create((set, get) => ({ panels: {}, init(panelId) { @@ -829,6 +836,11 @@ function handleEvent(panelId: string, event: { type: string; [key: string]: unkn const name = asString(event.toolName) ?? asString(event.name) ?? 'tool' const args = event.args ?? event.input ?? {} if (!toolCallId) return + // cate-control tools (cate_terminal / cate_panel / cate_layout / + // cate_browser) round-trip through ctx.ui.input(), which the interception + // below renders as a richer CateToolCard (structured params + response). + // Skip the raw pi tool card so we don't show a duplicate bare-JSON entry. + if (name.startsWith('cate_')) return useAgentStore.getState().addToolCall(panelId, toolCallId, name, args) useAgentStore.getState().updateToolCall(panelId, toolCallId, { status: 'running' }) return @@ -969,6 +981,46 @@ function handleEvent(panelId: string, event: { type: string; [key: string]: unkn // channel — see AgentPanel.) return } + // cate-control round-trip: intercept input() calls whose title carries + // our sentinel and route them to the dispatcher instead of showing a + // dialog. The reply resolves the awaiting ctx.ui.input() in the pi tool. + if (method === 'input') { + const title = asString(event.title) ?? '' + if (title.startsWith(CATE_SENTINEL)) { + let request: CateControlRequest + try { + request = JSON.parse(title.slice(CATE_SENTINEL.length)) + } catch { + window.electronAPI.agentUiResponse(panelId, { + id, + value: JSON.stringify({ ok: false, error: 'malformed request' }), + }) + return + } + // Surface the action as a tool card in the thread so the user sees + // what Cate did (custom-rendered by ChatThread's CateToolCard), then + // update it to the final status once the dispatcher resolves. + const callId = `cate-call:${request.action}:${cateCallSeq++}` + const store = useAgentStore.getState() + store.addToolCall(panelId, callId, `cate:${request.action}`, request.params) + store.updateToolCall(panelId, callId, { status: 'running' }) + void dispatchCateRequest(panelId, request).then((response) => { + const result = + response.result === undefined + ? undefined + : typeof response.result === 'string' + ? response.result + : JSON.stringify(response.result, null, 2) + useAgentStore.getState().updateToolCall(panelId, callId, { + status: response.ok ? 'success' : 'error', + result, + error: response.error, + }) + window.electronAPI.agentUiResponse(panelId, { id, value: JSON.stringify(response) }) + }) + return + } + } // Dialog methods (select / confirm / input / editor) — enqueue for the // panel renderer to handle inline. useAgentStore.getState().addUiRequest(panelId, req) diff --git a/src/agent/renderer/cateControl.test.ts b/src/agent/renderer/cateControl.test.ts new file mode 100644 index 00000000..7c988410 --- /dev/null +++ b/src/agent/renderer/cateControl.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' + +// cateControl / agentStore / settingsStore all import the renderer logger, which +// pulls in electron-log/renderer. That module hangs in the bare node test env, so +// stub it like the other renderer suites do (see agentStore.cateControl.test.ts). +vi.mock('../../renderer/lib/logger', () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})) + +import { + registerCateContext, + unregisterCateContext, + dispatchCateRequest, + __setExecutorsForTest, +} from './cateControl' +import { useSettingsStore } from '../../renderer/stores/settingsStore' + +function fakeCanvasStore() { + return { getState: () => ({ nodes: {}, viewportOffset: { x: 0, y: 0 }, zoomLevel: 1 }) } as any +} + +describe('dispatchCateRequest', () => { + beforeEach(() => { + useSettingsStore.setState({ cateControlEnabled: true } as any) + unregisterCateContext('k1') + __setExecutorsForTest(null) + }) + + it('errors when the feature is disabled', async () => { + useSettingsStore.setState({ cateControlEnabled: false } as any) + registerCateContext('k1', { workspaceId: 'w1', hostPanelId: 'p1', canvasStore: fakeCanvasStore() }) + const res = await dispatchCateRequest('k1', { action: 'layout', params: {} }) + expect(res.ok).toBe(false) + expect(res.error).toMatch(/disabled/i) + }) + + it('errors when no context is registered for the chat', async () => { + const res = await dispatchCateRequest('unknown', { action: 'layout', params: {} }) + expect(res.ok).toBe(false) + expect(res.error).toMatch(/not registered|no context/i) + }) + + it('runs the executor for the action when enabled (no approval gate)', async () => { + registerCateContext('k1', { workspaceId: 'w1', hostPanelId: 'p1', canvasStore: fakeCanvasStore() }) + const exec = vi.fn().mockResolvedValue({ ok: true, result: { panels: [] } }) + __setExecutorsForTest({ layout: exec } as any) + const res = await dispatchCateRequest('k1', { action: 'layout', params: {} }) + expect(exec).toHaveBeenCalledTimes(1) + expect(res).toEqual({ ok: true, result: { panels: [] } }) + }) + + it('runs side-effect actions immediately (no guard)', async () => { + registerCateContext('k1', { workspaceId: 'w1', hostPanelId: 'p1', canvasStore: fakeCanvasStore() }) + const exec = vi.fn().mockResolvedValue({ ok: true }) + __setExecutorsForTest({ panel: exec } as any) + const res = await dispatchCateRequest('k1', { action: 'panel', params: { op: 'close', panel: 'x' } }) + expect(exec).toHaveBeenCalledTimes(1) + expect(res.ok).toBe(true) + }) + + it('errors for an unknown action', async () => { + registerCateContext('k1', { workspaceId: 'w1', hostPanelId: 'p1', canvasStore: fakeCanvasStore() }) + __setExecutorsForTest({} as any) + const res = await dispatchCateRequest('k1', { action: 'nope' as any, params: {} }) + expect(res.ok).toBe(false) + expect(res.error).toMatch(/unknown or unimplemented/i) + }) + + it('catches executor errors and returns them', async () => { + registerCateContext('k1', { workspaceId: 'w1', hostPanelId: 'p1', canvasStore: fakeCanvasStore() }) + __setExecutorsForTest({ layout: vi.fn().mockRejectedValue(new Error('boom')) } as any) + const res = await dispatchCateRequest('k1', { action: 'layout', params: {} }) + expect(res.ok).toBe(false) + expect(res.error).toMatch(/boom/) + }) +}) diff --git a/src/agent/renderer/cateControl.ts b/src/agent/renderer/cateControl.ts new file mode 100644 index 00000000..07c9453d --- /dev/null +++ b/src/agent/renderer/cateControl.ts @@ -0,0 +1,80 @@ +// ============================================================================= +// Renderer-side dispatcher for the cate-control agent feature. Receives control +// requests intercepted from pi's ctx.ui.input() channel (see agentStore +// handleEvent), resolves the calling chat's workspace/canvas via a registry that +// AgentPanel populates, and runs an executor. Returns a CateControlResponse the +// extension reads back. The feature is a plain on/off (cateControlEnabled): when +// off the extension isn't even installed, so the agent never sees the tools. +// ============================================================================= + +import type { StoreApi } from 'zustand' +import type { CanvasStore } from '../../renderer/stores/canvasStore' +import { type CateControlRequest, type CateControlResponse, type CateControlAction } from '../../shared/cateControl' +import { useSettingsStore } from '../../renderer/stores/settingsStore' +import log from '../../renderer/lib/logger' + +/** Everything an executor needs, resolved per calling chat. */ +export interface CateControlContext { + workspaceId: string + /** React panelId of the AgentPanel hosting this chat (for isSelf / self-protection). */ + hostPanelId: string + /** The canvas store for this chat's workspace. */ + canvasStore: StoreApi +} + +export type CateExecutor = ( + params: Record, + ctx: CateControlContext, + agentKey: string, +) => Promise + +const registry = new Map() + +export function registerCateContext(agentKey: string, ctx: CateControlContext): void { + registry.set(agentKey, ctx) +} +export function unregisterCateContext(agentKey: string): void { + registry.delete(agentKey) +} + +// Executor map is assembled in Tasks 6–7; overridable in tests. Held inside a +// hoisted-function holder (not a top-level `let`) so cateExecutors' import-time +// `setCateExecutors(...)` call is safe even when this module is mid-evaluation +// in the import cycle (cateControl → agentStore → cateExecutors → cateControl). +// A top-level `let`/`const` would be in its temporal dead zone at that point. +function executorHolder(): { map: Partial> | null } { + const g = executorHolder as unknown as { + _store?: { map: Partial> | null } + } + if (!g._store) g._store = { map: null } + return g._store +} +export function __setExecutorsForTest(map: Partial> | null): void { + executorHolder().map = map +} +/** Real registration entry point (Task 7). */ +export function setCateExecutors(map: Partial>): void { + const holder = executorHolder() + if (!holder.map) holder.map = {} + Object.assign(holder.map, map) +} + +export async function dispatchCateRequest( + agentKey: string, + req: CateControlRequest, +): Promise { + try { + if (!useSettingsStore.getState().cateControlEnabled) { + return { ok: false, error: 'Cate control is disabled.' } + } + const ctx = registry.get(agentKey) + if (!ctx) return { ok: false, error: 'No context registered for this chat.' } + + const exec = executorHolder().map?.[req.action] + if (!exec) return { ok: false, error: `Unknown or unimplemented action: ${req.action}` } + return await exec(req.params, ctx, agentKey) + } catch (err) { + log.warn('[cateControl] dispatch failed for %s: %O', req.action, err) + return { ok: false, error: err instanceof Error ? err.message : String(err) } + } +} diff --git a/src/agent/renderer/cateExecutors.test.tsx b/src/agent/renderer/cateExecutors.test.tsx new file mode 100644 index 00000000..6b483e72 --- /dev/null +++ b/src/agent/renderer/cateExecutors.test.tsx @@ -0,0 +1,390 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { createCanvasStore } from '../../renderer/stores/canvasStore' +import { openFileAsPanel } from '../../renderer/lib/fileRouting' +import { terminalRegistry } from '../../renderer/lib/terminalRegistry' +import { portalRegistry } from '../../renderer/lib/portalRegistry' +import type { CateControlContext } from './cateControl' +import { + execGetLayout, execOpenPanel, execClosePanel, execMovePanel, + execRunInTerminal, execReadTerminal, + execPanel, execBrowser, execTerminal, +} from './cateExecutors' + +// The cateExecutors -> cateControl -> agentStore/settingsStore chain pulls in +// electron-log via lib/logger, whose import-time side effects hang under jsdom. +// Mirror the drag-harness mock so the module graph loads cleanly. +vi.mock('../../renderer/lib/logger', () => ({ + default: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), log: vi.fn() }, +})) + +// execOpenPanel routes file opens through openFileAsPanel (not createEditor directly). +vi.mock('../../renderer/lib/fileRouting', () => ({ + openFileAsPanel: vi.fn(() => 'ed-panel'), +})) +vi.mock('../../renderer/lib/editorReveal', () => ({ + setPendingReveal: vi.fn(), +})) +vi.mock('../../renderer/lib/terminalRegistry', () => ({ + terminalRegistry: { getEntry: vi.fn(() => ({ ptyId: 'pty-1' })) }, +})) + +// Mock appStore module so executors call into controllable spies. The workspace +// panels carry titles so resolvePanelRef (title -> panelId) has something to map. +vi.mock('../../renderer/stores/appStore', () => { + const created: any[] = [] + const closed: any[] = [] + return { + __created: created, + __closed: closed, + useAppStore: { + getState: () => ({ + createEditor: (...a: any[]) => { created.push(['editor', ...a]); return 'ed-panel' }, + createTerminal: (...a: any[]) => { created.push(['terminal', ...a]); return 'tm-panel' }, + createBrowser: (...a: any[]) => { created.push(['browser', ...a]); return 'br-panel' }, + createDocument: (...a: any[]) => { created.push(['document', ...a]); return 'doc-panel' }, + closePanel: (...a: any[]) => { closed.push(a) }, + updatePanelUrl: (...a: any[]) => { created.push(['url', ...a]) }, + setPanelMarkdownPreview: (...a: any[]) => { created.push(['mdpreview', ...a]) }, + workspaces: [{ id: 'w1', panels: { + // The agent-facing id is the first 6 chars of these (e.g. "ed-pan"); + // they're given distinct 6-char prefixes so the short ids don't collide. + 'ed-panel': { id: 'ed-panel', type: 'editor', title: 'a.ts', filePath: 'a.ts' }, + 'tm-panel': { id: 'tm-panel', type: 'terminal', title: 'Terminal 1' }, + 'br-panel': { id: 'br-panel', type: 'browser', title: 'Browser' }, + } }], + selectedWorkspaceId: 'w1', + }), + }, + } +}) + +function ctxWith(store = createCanvasStore()): CateControlContext { + return { workspaceId: 'w1', hostPanelId: 'host', canvasStore: store } +} + +// Result the stubbed browser returns from executeJavaScript (read/eval). +let browserEvalResult: unknown = '' + +beforeEach(async () => { + const mod: any = await import('../../renderer/stores/appStore') + mod.__created.length = 0 + mod.__closed.length = 0 + vi.mocked(openFileAsPanel).mockClear() + vi.mocked(terminalRegistry.getEntry).mockReturnValue({ ptyId: 'pty-1' } as any) + // Stand in for the live of the mock 'Browser' panel (id 'br-panel'). + browserEvalResult = '' + portalRegistry.register('br-panel', { + getWebContentsId: () => 1, + getURL: () => 'https://example.com', + getTitle: () => 'Example', + loadURL: () => {}, + goBack: () => {}, goForward: () => {}, canGoBack: () => true, canGoForward: () => false, + reload: () => {}, stop: () => {}, + executeJavaScript: async () => browserEvalResult, + }) +}) + +describe('execOpenPanel', () => { + it('opens an editor with a file path via openFileAsPanel and reports its id + title', async () => { + const res = await execOpenPanel({ type: 'editor', target: { path: 'a.ts' } }, ctxWith(), 'k1') + expect(res.ok).toBe(true) + expect((res.result as any).id).toBe('ed-pan') + expect((res.result as any).title).toBe('a.ts') + expect((res.result as any).type).toBe('editor') + expect(openFileAsPanel).toHaveBeenCalledWith('w1', 'a.ts') + }) + + it('opens a blank editor via createEditor when no path is given', async () => { + await execOpenPanel({ type: 'editor' }, ctxWith(), 'k1') + const mod: any = await import('../../renderer/stores/appStore') + expect(mod.__created[0][0]).toBe('editor') + }) + + it('rejects an unknown panel type', async () => { + const res = await execOpenPanel({ type: 'hologram' }, ctxWith(), 'k1') + expect(res.ok).toBe(false) + expect(res.error).toMatch(/type/i) + }) + + it('focuses the panel it opens so it lands in view (open-focus fix)', async () => { + const store = createCanvasStore() + store.getState().addNode('br-panel', 'browser', { x: 800, y: 800 }, { width: 100, height: 100 }) + const res = await execOpenPanel({ type: 'browser', target: { url: 'https://example.com' } }, ctxWith(store), 'k1') + expect(res.ok).toBe(true) + expect(store.getState().focusedNodeId).toBe(store.getState().nodeForPanel('br-panel')) + }) + + it('never moves the camera and drops the new panel at the viewport center', async () => { + const store = createCanvasStore() + store.getState().setContainerSize({ width: 1000, height: 1000 }) + store.getState().addNode('br-panel', 'browser', { x: 5000, y: 5000 }, { width: 100, height: 100 }) + const offsetBefore = { ...store.getState().viewportOffset } + const zoomBefore = store.getState().zoomLevel + await execOpenPanel({ type: 'browser', target: { url: 'https://example.com' } }, ctxWith(store), 'k1') + expect(store.getState().viewportOffset).toEqual(offsetBefore) + expect(store.getState().zoomLevel).toBe(zoomBefore) + const nodeId = store.getState().nodeForPanel('br-panel')! + expect(store.getState().nodes[nodeId].origin).toEqual({ x: 450, y: 450 }) + }) + + it('opens an editor straight into markdown preview when target.preview is true', async () => { + const res = await execOpenPanel({ type: 'editor', target: { path: 'README.md', preview: true } }, ctxWith(), 'k1') + expect(res.ok).toBe(true) + const mod: any = await import('../../renderer/stores/appStore') + expect(mod.__created.find((c: any[]) => c[0] === 'mdpreview')).toEqual(['mdpreview', 'w1', 'ed-panel', true]) + }) +}) + +describe('execClosePanel', () => { + it('errors when no panel matches the ref', async () => { + const res = await execClosePanel({ panel: 'nope' }, ctxWith(), 'k1') + expect(res.ok).toBe(false) + expect(res.error).toMatch(/no panel with id/i) + }) + + it('closes a panel addressed by title', async () => { + const res = await execClosePanel({ panel: 'a.ts' }, ctxWith(), 'k1') + expect(res.ok).toBe(true) + expect((res.result as any).closed).toBe('a.ts') + const mod: any = await import('../../renderer/stores/appStore') + expect(mod.__closed[0]).toEqual(['w1', 'ed-panel']) + }) + + it('closes a panel addressed by its short id (UUID prefix)', async () => { + const res = await execClosePanel({ panel: 'ed-pan' }, ctxWith(), 'k1') + expect(res.ok).toBe(true) + const mod: any = await import('../../renderer/stores/appStore') + expect(mod.__closed[0]).toEqual(['w1', 'ed-panel']) + }) + + it("refuses to close the agent's own panel", async () => { + const res = await execClosePanel({ panel: 'self' }, ctxWith(), 'k1') + expect(res.ok).toBe(false) + expect(res.error).toMatch(/refusing/i) + }) +}) + +describe('execMovePanel', () => { + it('moves a panel (by title) relative to another panel', async () => { + const store = createCanvasStore() + store.getState().setContainerSize({ width: 1000, height: 1000 }) + store.getState().addNode('ed-panel', 'editor', { x: 0, y: 0 }, { width: 100, height: 100 }) + store.getState().addNode('tm-panel', 'terminal', { x: 600, y: 0 }, { width: 100, height: 100 }) + const res = await execMovePanel({ panel: 'a.ts', placement: { relativeTo: 'Terminal 1', position: 'right' } }, ctxWith(store), 'k1') + expect(res.ok).toBe(true) + expect((res.result as any).moved).toBe('a.ts') + // Placed to the right of Terminal 1 (x 600 + width 100 + gap 40 = 740). + const node = store.getState().nodeForPanel('ed-panel')! + expect(store.getState().nodes[node].origin.x).toBe(740) + }) + + it("refuses to move the agent's own panel", async () => { + const res = await execMovePanel({ panel: 'self' }, ctxWith(), 'k1') + expect(res.ok).toBe(false) + expect(res.error).toMatch(/refusing/i) + }) +}) + +describe('execGetLayout', () => { + it('reports panels with a stable id + title/type and an isSelf flag (no raw panelId)', async () => { + const store = createCanvasStore() + store.getState().addNode('host', 'agent', { x: 0, y: 0 }, { width: 200, height: 200 }) + store.getState().addNode('ed-panel', 'editor', { x: 300, y: 0 }, { width: 200, height: 200 }) + const res = await execGetLayout({}, ctxWith(store), 'k1') + expect(res.ok).toBe(true) + const panels = (res.result as any).panels as any[] + expect(panels.some((p) => p.isSelf)).toBe(true) + expect(panels.find((p) => p.title === 'a.ts')?.type).toBe('editor') + // the short id (first 6 chars of the panel UUID) is exposed as `id`. + expect(panels.find((p) => p.title === 'a.ts')?.id).toBe('ed-pan') + // the raw internal panelId is never leaked. + expect(panels.every((p) => p.panelId === undefined)).toBe(true) + }) +}) + +describe('content executors', () => { + it('run_in_terminal writes the command to a fresh PTY (newPanel)', async () => { + ;(window.electronAPI as any).terminalWrite = vi.fn() + const res = await execRunInTerminal({ command: 'ls', newPanel: true }, ctxWith(), 'k1') + expect(res.ok).toBe(true) + expect((res.result as any).terminal).toBe('Terminal 1') + expect((window.electronAPI as any).terminalWrite).toHaveBeenCalledWith('pty-1', 'ls\r') + }) + + it('run_in_terminal rejects an empty command', async () => { + const res = await execRunInTerminal({ command: ' ' }, ctxWith(), 'k1') + expect(res.ok).toBe(false) + }) + + it('browser navigate points an existing panel (by title) at a url', async () => { + const res = await execBrowser({ op: 'navigate', panel: 'Browser', url: 'https://example.com' }, ctxWith(), 'k1') + expect(res.ok).toBe(true) + expect((res.result as any).browser).toBe('Browser') + const mod: any = await import('../../renderer/stores/appStore') + expect(mod.__created.find((c: any[]) => c[0] === 'url')).toEqual(['url', 'w1', 'br-panel', 'https://example.com']) + }) + + it('browser navigate rejects a non-url', async () => { + const res = await execBrowser({ op: 'navigate', panel: 'Browser', url: 'not a url' }, ctxWith(), 'k1') + expect(res.ok).toBe(false) + }) + + it('browser navigate errors when the panel has no live web view', async () => { + const res = await execBrowser({ op: 'navigate', panel: 'nope', url: 'https://example.com' }, ctxWith(), 'k1') + expect(res.ok).toBe(false) + }) + + it('browser read returns a selector\'s text from the page', async () => { + browserEvalResult = 'hello world' + const res = await execBrowser({ op: 'read', panel: 'Browser', selector: 'h1' }, ctxWith(), 'k1') + expect(res.ok).toBe(true) + expect((res.result as any).text).toBe('hello world') + expect((res.result as any).selector).toBe('h1') + }) + + it('browser eval returns the (serialized) script result', async () => { + browserEvalResult = { count: 2 } + const res = await execBrowser({ op: 'eval', panel: 'Browser', js: 'doStuff()' }, ctxWith(), 'k1') + expect(res.ok).toBe(true) + expect((res.result as any).result).toBe('{"count":2}') + }) + + it('browser info reports the current navigation state', async () => { + const res = await execBrowser({ op: 'info', panel: 'Browser' }, ctxWith(), 'k1') + expect(res.ok).toBe(true) + expect((res.result as any).url).toBe('https://example.com') + expect((res.result as any).canGoBack).toBe(true) + }) + + it('read_terminal (by title) returns the trailing buffer lines as text', async () => { + const lines = ['$ echo hi', 'hi', '', ''] + vi.mocked(terminalRegistry.getEntry).mockReturnValue({ + ptyId: 'pty-1', + terminal: { buffer: { active: { + length: lines.length, + getLine: (i: number) => ({ translateToString: () => lines[i] }), + } } }, + } as any) + const res = await execReadTerminal({ panel: 'Terminal 1' }, ctxWith(), 'k1') + expect(res.ok).toBe(true) + expect((res.result as any).text).toBe('$ echo hi\nhi') + expect((res.result as any).lineCount).toBe(2) + expect((res.result as any).terminal).toBe('Terminal 1') + }) + + it('read_terminal errors when the terminal is not live', async () => { + vi.mocked(terminalRegistry.getEntry).mockReturnValue(undefined as any) + const res = await execReadTerminal({ panel: 'Terminal 1' }, ctxWith(), 'k1') + expect(res.ok).toBe(false) + expect(res.error).toMatch(/no live terminal/i) + }) +}) + +describe('terminal command reliability', () => { + beforeEach(() => { + ;(window.electronAPI as any).terminalWrite = vi.fn() + vi.mocked(terminalRegistry.getEntry).mockReturnValue({ ptyId: 'pty-1' } as any) + }) + + it('run_in_terminal polls until the PTY registers, then writes the command', async () => { + let calls = 0 + vi.mocked(terminalRegistry.getEntry).mockImplementation(() => { + calls += 1 + if (calls === 1) return undefined as any + if (calls === 2) return { ptyId: '' } as any + return { ptyId: 'pty-9' } as any + }) + const res = await execRunInTerminal({ command: 'npm test', newPanel: true }, ctxWith(), 'k1') + expect(res.ok).toBe(true) + expect(calls).toBeGreaterThanOrEqual(3) + expect((window.electronAPI as any).terminalWrite).toHaveBeenCalledWith('pty-9', 'npm test\r') + }) + + it('open_panel(terminal, command) runs the command via the PTY, not the dropped initialInput path', async () => { + const res = await execOpenPanel({ type: 'terminal', target: { command: 'npm test' } }, ctxWith(), 'k1') + expect(res.ok).toBe(true) + expect((window.electronAPI as any).terminalWrite).toHaveBeenCalledWith('pty-1', 'npm test\r') + const mod: any = await import('../../renderer/stores/appStore') + const termCreate = mod.__created.find((c: any[]) => c[0] === 'terminal') + expect(termCreate?.[2]).toBeUndefined() + }) +}) + +// --------------------------------------------------------------------------- +// Op routers (4-tool surface: layout / panel / browser / terminal) +// --------------------------------------------------------------------------- + +describe('op routers', () => { + it('layout reads the canvas', async () => { + const store = createCanvasStore() + store.getState().addNode('ed-panel', 'editor', { x: 0, y: 0 }, { width: 100, height: 100 }) + const res = await execGetLayout({}, ctxWith(store), 'k1') + expect(res.ok).toBe(true) + expect((res.result as any).panels).toBeDefined() + }) + + it("execPanel routes op:'open'", async () => { + const res = await execPanel({ op: 'open', type: 'editor', target: { path: 'a.ts' } }, ctxWith(), 'k1') + expect(res.ok).toBe(true) + expect((res.result as any).title).toBe('a.ts') + }) + + it("execPanel routes op:'close' (by title)", async () => { + const res = await execPanel({ op: 'close', panel: 'a.ts' }, ctxWith(), 'k1') + expect(res.ok).toBe(true) + const mod: any = await import('../../renderer/stores/appStore') + expect(mod.__closed[0]).toEqual(['w1', 'ed-panel']) + }) + + it("execPanel routes op:'move'", async () => { + const store = createCanvasStore() + store.getState().setContainerSize({ width: 1000, height: 1000 }) + store.getState().addNode('ed-panel', 'editor', { x: 0, y: 0 }, { width: 100, height: 100 }) + const res = await execPanel({ op: 'move', panel: 'a.ts' }, ctxWith(store), 'k1') + expect(res.ok).toBe(true) + expect((res.result as any).moved).toBe('a.ts') + }) + + it('execPanel rejects an unknown op', async () => { + const res = await execPanel({ op: 'teleport', panel: 'x' }, ctxWith(), 'k1') + expect(res.ok).toBe(false) + expect(res.error).toMatch(/unknown op/i) + }) + + it("execBrowser routes op:'navigate'", async () => { + const res = await execBrowser({ op: 'navigate', panel: 'Browser', url: 'https://example.com' }, ctxWith(), 'k1') + expect(res.ok).toBe(true) + expect((res.result as any).browser).toBe('Browser') + }) + + it('execBrowser rejects an unknown op', async () => { + const res = await execBrowser({ op: 'teleport', panel: 'Browser' }, ctxWith(), 'k1') + expect(res.ok).toBe(false) + expect(res.error).toMatch(/unknown op/i) + }) + + it("execTerminal routes op:'run'", async () => { + ;(window.electronAPI as any).terminalWrite = vi.fn() + vi.mocked(terminalRegistry.getEntry).mockReturnValue({ ptyId: 'pty-1' } as any) + const res = await execTerminal({ op: 'run', command: 'ls', newPanel: true }, ctxWith(), 'k1') + expect(res.ok).toBe(true) + expect((window.electronAPI as any).terminalWrite).toHaveBeenCalledWith('pty-1', 'ls\r') + }) + + it("execTerminal routes op:'read'", async () => { + const lines = ['output line', ''] + vi.mocked(terminalRegistry.getEntry).mockReturnValue({ + ptyId: 'pty-1', + terminal: { buffer: { active: { length: lines.length, getLine: (i: number) => ({ translateToString: () => lines[i] }) } } }, + } as any) + const res = await execTerminal({ op: 'read', panel: 'Terminal 1' }, ctxWith(), 'k1') + expect(res.ok).toBe(true) + expect((res.result as any).text).toBe('output line') + }) + + it('execTerminal rejects an unknown op', async () => { + const res = await execTerminal({ op: 'beam' }, ctxWith(), 'k1') + expect(res.ok).toBe(false) + expect(res.error).toMatch(/unknown op/i) + }) +}) diff --git a/src/agent/renderer/cateExecutors.ts b/src/agent/renderer/cateExecutors.ts new file mode 100644 index 00000000..2b3e2727 --- /dev/null +++ b/src/agent/renderer/cateExecutors.ts @@ -0,0 +1,480 @@ +// ============================================================================= +// Concrete executors for cate-control actions. Each takes (params, ctx, agentKey) +// and returns a CateControlResponse. Pure-ish: side effects go through appStore / +// canvasStore / terminalRegistry. Geometry comes from cateControlLayout. +// +// The agent addresses panels by a short id - the first 6 chars of the panel's +// UUID (e.g. "a1b2c3"), which cate_layout reports and resolvePanelRef() resolves +// back to the full panelId by prefix. The full UUID and an exact title are +// accepted as fallbacks. The short id is stable across restarts because the +// panel keeps its UUID on restore. 'self' refers to the agent's own host panel. +// ============================================================================= + +import type { CateControlResponse } from '../../shared/cateControl' +import type { CateControlContext, CateExecutor } from './cateControl' +import { useAppStore } from '../../renderer/stores/appStore' +import { PANEL_DEFINITIONS } from '../../shared/panels' +import type { PanelType } from '../../shared/types' +import { computePlacement, type Rect } from '../../renderer/lib/cateControlLayout' +import { openFileAsPanel } from '../../renderer/lib/fileRouting' +import { setPendingReveal } from '../../renderer/lib/editorReveal' +import { terminalRegistry } from '../../renderer/lib/terminalRegistry' +import { portalRegistry, type PortalWebview } from '../../renderer/lib/portalRegistry' +import { setCateExecutors } from './cateControl' + +/** Cap on text returned to the agent from read/eval (keeps tool results small). */ +const MAX_BROWSER_TEXT = 30000 + +const OPENABLE: PanelType[] = ['editor', 'terminal', 'browser', 'document'] + +function fail(error: string): CateControlResponse { return { ok: false, error } } +function ok(result?: unknown): CateControlResponse { return { ok: true, result } } + +/** The panels of the executor's workspace, keyed by panelId. */ +function workspacePanels(ctx: CateControlContext): Record { + const ws = useAppStore.getState().workspaces.find((w: any) => w.id === ctx.workspaceId) + return (ws?.panels ?? {}) as Record +} + +/** Human title for a panelId (falls back to the id if the panel is gone). */ +function titleFor(ctx: CateControlContext, panelId: string): string { + return workspacePanels(ctx)[panelId]?.title || panelId +} + +/** SHORT_ID_LEN chars of a panel's UUID — the stable handle the agent targets. + * Long enough that a collision within one workspace is astronomically unlikely; + * resolvePanelRef still errors loudly if two panels ever share a prefix. */ +const SHORT_ID_LEN = 6 +function shortId(panelId: string): string { + return panelId.slice(0, SHORT_ID_LEN) +} + +/** Resolve a panel reference - the short id (e.g. "a1b2c3"), or 'self' for the + * agent's host panel - to its full panelId. The full UUID and an exact title + * are accepted as fallbacks. The short id is the canonical, stable way to + * target a panel (titles track the page/file and change underfoot). */ +function resolvePanelRef(ctx: CateControlContext, ref: unknown): { panelId?: string; error?: string } { + const s = String(ref ?? '').trim() + if (!s) return { error: 'missing `panel` (expected a panel id like "a1b2c3").' } + if (s === 'self') return { panelId: ctx.hostPanelId } + const panels = workspacePanels(ctx) + // Exact full UUID. + if (panels[s]) return { panelId: s } + // Primary: the short id (a UUID prefix). Error if it's ambiguous so the agent + // retries with a longer prefix instead of acting on the wrong panel. + const byPrefix = Object.keys(panels).filter((id) => id.startsWith(s)) + if (byPrefix.length === 1) return { panelId: byPrefix[0] } + if (byPrefix.length > 1) return { error: `"${s}" matches ${byPrefix.length} panels - use a longer id prefix (call cate_layout for the full ids).` } + // Fallback: an exact (but possibly stale/ambiguous) title. + const byTitle = Object.keys(panels).filter((id) => panels[id]?.title === s) + if (byTitle.length === 1) return { panelId: byTitle[0] } + if (byTitle.length === 0) return { error: `No panel with id "${s}" - call cate_layout to list panel ids.` } + return { error: `"${s}" matches several panels by title - target it by id (e.g. "a1b2c3") instead.` } +} + +/** Read occupied rects + viewport center from a context's canvas store. */ +function readCanvasGeometry(ctx: CateControlContext): { occupied: Rect[]; viewportCenter: { x: number; y: number }; nodesByPanel: Map } { + const st = ctx.canvasStore.getState() + const occupied: Rect[] = [] + const nodesByPanel = new Map() + for (const node of Object.values(st.nodes)) { + const rect: Rect = { x: node.origin.x, y: node.origin.y, width: node.size.width, height: node.size.height } + occupied.push(rect) + nodesByPanel.set(node.panelId, { nodeId: node.id, rect }) + } + // Real viewport center in canvas-space: map the center of the canvas container + // through the current zoom/offset. This is what the user is actually looking at, + // so new panels land in view (the old centroid-of-all-nodes estimate could drop + // a panel far off-screen). Fall back to the node centroid only when the canvas + // container hasn't been measured yet (headless / pre-mount / tests). + const cs = st.containerSize + let center: { x: number; y: number } + if (cs.width > 0 && cs.height > 0) { + center = st.viewToCanvas({ x: cs.width / 2, y: cs.height / 2 }) + } else if (occupied.length) { + center = { x: occupied.reduce((s, r) => s + r.x + r.width / 2, 0) / occupied.length, y: occupied.reduce((s, r) => s + r.y + r.height / 2, 0) / occupied.length } + } else { + center = { x: 0, y: 0 } + } + return { occupied, viewportCenter: center, nodesByPanel } +} + +/** Resolve a placement's `relativeTo` (a title or 'self') to a node rect. */ +function relativeRect(ctx: CateControlContext, placement: Record, nodesByPanel: Map): Rect | undefined { + if (placement.relativeTo == null) return undefined + const relPanelId = resolvePanelRef(ctx, placement.relativeTo).panelId + return relPanelId ? nodesByPanel.get(relPanelId)?.rect : undefined +} + +/** Run a node-creating action while keeping the camera fixed. Every app.create*() + * routes through addNodeAndFocus, which focus-AND-centers the new node - i.e. it + * pans/zooms the user's view. The agent must never move the camera, so we + * snapshot the viewport, run the creator, then restore it. */ +function withCameraPreserved(ctx: CateControlContext, create: () => T): T { + const s = ctx.canvasStore.getState() + const cam = { zoom: s.zoomLevel, offset: { ...s.viewportOffset } } + const out = create() + ctx.canvasStore.getState().setZoomAndOffset(cam.zoom, cam.offset) + return out +} + +/** Move a freshly-created node into view: to an explicit semantic placement if + * given, else the current viewport center. Never moves the camera. addNode's + * default drops the node near the focused node, which may be off-screen. */ +function placeNewNode( + ctx: CateControlContext, + panelId: string, + fallbackType: PanelType, + placement: Record = {}, +): void { + const node = ctx.canvasStore.getState().nodeForPanel(panelId) + if (!node) return + const { occupied, viewportCenter, nodesByPanel } = readCanvasGeometry(ctx) + const size = ctx.canvasStore.getState().nodes[node]?.size ?? PANEL_DEFINITIONS[fallbackType].defaultSize + const relativeTo = relativeRect(ctx, placement, nodesByPanel) + // Exclude the freshly-created node from its own obstacle set (by identity). + const selfRect = nodesByPanel.get(panelId)?.rect + const obstacles = selfRect ? occupied.filter((r) => r !== selfRect) : occupied + const rect = computePlacement({ size, relativeTo, position: placement.position as any, occupied: obstacles, viewportCenter }) + ctx.canvasStore.getState().moveNode(node, { x: rect.x, y: rect.y }) +} + +/** Send `command` to a terminal panel's PTY, waiting for the PTY to spawn. + * A freshly-created terminal needs panel mount + async node-pty spawn before + * terminalRegistry has its ptyId, so a single fixed delay is unreliable - poll + * until ready (or time out). Returns true once the command was written. + * (`appStore.createTerminal`'s `initialInput` arg is intentionally not persisted + * to PanelState - it would re-run on session restore - so it can't be used here.) */ +async function writeToTerminalWhenReady(panelId: string, command: string, timeoutMs = 6000): Promise { + const data = command.endsWith('\r') || command.endsWith('\n') ? command : command + '\r' + const deadline = Date.now() + timeoutMs + for (;;) { + const ptyId = terminalRegistry.getEntry(panelId)?.ptyId + if (ptyId) { window.electronAPI.terminalWrite(ptyId, data); return true } + if (Date.now() >= deadline) return false + await new Promise((r) => setTimeout(r, 100)) + } +} + +// --------------------------------------------------------------------------- +// layout - read the canvas +// --------------------------------------------------------------------------- + +/** Report the open panels by title/type so the agent can target them. */ +export const execGetLayout: CateExecutor = async (_params, ctx) => { + const panels = workspacePanels(ctx) + const st = ctx.canvasStore.getState() + const out = Object.values(st.nodes).map((node: any) => { + const panel = panels[node.panelId] + return { + id: shortId(node.panelId), + title: panel?.title ?? '', + type: panel?.type ?? 'unknown', + focused: st.focusedNodeId === node.id, + isSelf: node.panelId === ctx.hostPanelId, + } + }) + return ok({ panels: out }) +} + +// --------------------------------------------------------------------------- +// panel - open / close / move +// --------------------------------------------------------------------------- + +export const execOpenPanel: CateExecutor = async (params, ctx) => { + const type = String(params.type ?? '') as PanelType + if (!OPENABLE.includes(type)) return fail(`Unsupported panel type: ${String(params.type)}`) + const target = (params.target ?? {}) as Record + const app = useAppStore.getState() + const wsId = ctx.workspaceId + + // A terminal `command` is run after the panel mounts (see below) - NOT passed + // as createTerminal's `initialInput`, which the store drops. + let pendingTerminalCommand: string | undefined + // create*() pans/zooms to the new node; keep the camera fixed (see helper). + const panelId = withCameraPreserved(ctx, () => { + switch (type) { + case 'editor': { + const path = typeof target.path === 'string' ? target.path : undefined + const id = path ? openFileAsPanel(wsId, path) : app.createEditor(wsId) + if (path && (typeof target.line === 'number')) { + setPendingReveal(id, { line: target.line as number, column: typeof target.column === 'number' ? (target.column as number) : undefined }) + } + // Convenience: open straight into rendered markdown preview (markdown files only). + if (target.preview === true) { + app.setPanelMarkdownPreview(wsId, id, true) + } + return id + } + case 'terminal': { + const id = app.createTerminal(wsId, undefined, undefined, undefined, typeof target.cwd === 'string' ? target.cwd : undefined) + pendingTerminalCommand = typeof target.command === 'string' && target.command.trim() ? target.command : undefined + return id + } + case 'browser': + return app.createBrowser(wsId, typeof target.url === 'string' ? target.url : undefined) + case 'document': + return typeof target.path === 'string' ? openFileAsPanel(wsId, target.path) : app.createEditor(wsId) + default: + return '' + } + }) + if (!panelId) return fail(`Unsupported panel type: ${String(params.type)}`) + + // Place the new node into view (explicit placement or viewport center) without + // moving the camera. + placeNewNode(ctx, panelId, type, (params.placement ?? {}) as Record) + + // Run the requested command once the freshly-created terminal's PTY is live. + if (pendingTerminalCommand) { + await writeToTerminalWhenReady(panelId, pendingTerminalCommand) + } + + // Raise + focus the freshly opened panel, but never move the camera. + const node = ctx.canvasStore.getState().nodeForPanel(panelId) + if (node) ctx.canvasStore.getState().focusNode(node) + return ok({ id: shortId(panelId), title: titleFor(ctx, panelId), type }) +} + +export const execClosePanel: CateExecutor = async (params, ctx) => { + const ref = resolvePanelRef(ctx, params.panel) + if (ref.error) return fail(ref.error) + const panelId = ref.panelId! + if (panelId === ctx.hostPanelId) return fail('Refusing to close the agent panel hosting this chat.') + const title = titleFor(ctx, panelId) + useAppStore.getState().closePanel(ctx.workspaceId, panelId) + return ok({ closed: title }) +} + +export const execMovePanel: CateExecutor = async (params, ctx) => { + const ref = resolvePanelRef(ctx, params.panel) + if (ref.error) return fail(ref.error) + const panelId = ref.panelId! + if (panelId === ctx.hostPanelId) return fail('Refusing to move the agent panel hosting this chat.') + const node = ctx.canvasStore.getState().nodeForPanel(panelId) + if (!node) return fail(`Panel "${titleFor(ctx, panelId)}" is not on the canvas.`) + const { occupied, viewportCenter, nodesByPanel } = readCanvasGeometry(ctx) + const st = ctx.canvasStore.getState() + const size = st.nodes[node].size + const placement = (params.placement ?? {}) as Record + const relativeTo = relativeRect(ctx, placement, nodesByPanel) + // Exclude the node being moved from its own obstacle set (by identity). + const selfRect = nodesByPanel.get(panelId)?.rect + const obstacles = selfRect ? occupied.filter((r) => r !== selfRect) : occupied + const rect = computePlacement({ size, relativeTo, position: placement.position as any, occupied: obstacles, viewportCenter }) + st.moveNode(node, { x: rect.x, y: rect.y }) + return ok({ moved: titleFor(ctx, panelId) }) +} + +// --------------------------------------------------------------------------- +// browser - control an existing browser panel (opening is the `panel` tool's job) +// --------------------------------------------------------------------------- + +/** Resolve a panel ref to its live . The panel must be a browser and + * have mounted (registered its guest in portalRegistry). */ +function resolveBrowser( + ctx: CateControlContext, + ref: unknown, +): { webview?: PortalWebview; panelId?: string; title?: string; error?: string } { + const r = resolvePanelRef(ctx, ref) + if (r.error) return { error: r.error } + const panelId = r.panelId! + const type = workspacePanels(ctx)[panelId]?.type + if (type && type !== 'browser') return { error: `Panel "${titleFor(ctx, panelId)}" is not a browser panel.` } + const webview = portalRegistry.get(panelId) + if (!webview) return { error: `Browser "${titleFor(ctx, panelId)}" is not ready (no live web view).` } + return { webview, panelId, title: titleFor(ctx, panelId) } +} + +function truncate(text: string): { text: string; truncated?: true } { + if (text.length <= MAX_BROWSER_TEXT) return { text } + return { text: text.slice(0, MAX_BROWSER_TEXT), truncated: true } +} + +/** navigate - point an existing browser panel at a url. */ +export const execBrowserNavigate: CateExecutor = async (params, ctx) => { + const url = String(params.url ?? '') + if (!/^(https?|file):\/\//i.test(url)) return fail('browser navigate requires an http(s) or file URL.') + const b = resolveBrowser(ctx, params.panel) + if (b.error) return fail(b.error) + await b.webview!.loadURL(url) + // Persist so a session restore reopens the panel on this url (the webview's + // own did-navigate also persists, but loadURL is async - do it eagerly too). + useAppStore.getState().updatePanelUrl(ctx.workspaceId, b.panelId!, url) + return ok({ browser: b.title, url }) +} + +/** back / forward / reload / stop - history + loading control. */ +export const execBrowserHistory: CateExecutor = async (params, ctx) => { + const op = String(params.op ?? '') + const b = resolveBrowser(ctx, params.panel) + if (b.error) return fail(b.error) + const wv = b.webview! + switch (op) { + case 'back': + if (!wv.canGoBack()) return fail(`Browser "${b.title}" cannot go back.`) + wv.goBack(); break + case 'forward': + if (!wv.canGoForward()) return fail(`Browser "${b.title}" cannot go forward.`) + wv.goForward(); break + case 'reload': wv.reload(); break + case 'stop': wv.stop(); break + default: return fail(`browser: unknown history op "${op}".`) + } + return ok({ browser: b.title, op }) +} + +/** info - report the current navigation state (read-only). */ +export const execBrowserInfo: CateExecutor = async (params, ctx) => { + const b = resolveBrowser(ctx, params.panel) + if (b.error) return fail(b.error) + const wv = b.webview! + return ok({ + browser: b.title, + url: wv.getURL(), + title: wv.getTitle(), + canGoBack: wv.canGoBack(), + canGoForward: wv.canGoForward(), + }) +} + +/** read - the page's visible text, or one CSS selector's text (read-only). */ +export const execBrowserRead: CateExecutor = async (params, ctx) => { + const b = resolveBrowser(ctx, params.panel) + if (b.error) return fail(b.error) + const selector = typeof params.selector === 'string' ? params.selector.trim() : '' + const code = selector + ? `(() => { const el = document.querySelector(${JSON.stringify(selector)}); return el ? el.innerText : null })()` + : `document.body ? document.body.innerText : ''` + const raw = await b.webview!.executeJavaScript(code) + if (selector && raw == null) return fail(`No element matches selector "${selector}".`) + const { text, truncated } = truncate(String(raw ?? '')) + return ok({ browser: b.title, url: b.webview!.getURL(), ...(selector ? { selector } : {}), text, ...(truncated ? { truncated } : {}) }) +} + +/** eval - run arbitrary JavaScript in the page and return its result. */ +export const execBrowserEval: CateExecutor = async (params, ctx) => { + const js = String(params.js ?? '') + if (!js.trim()) return fail('browser eval requires `js` (JavaScript to run in the page).') + const b = resolveBrowser(ctx, params.panel) + if (b.error) return fail(b.error) + const raw = await b.webview!.executeJavaScript(js, true) + let result: string | undefined + if (raw !== undefined) { + let serialized: string + try { serialized = typeof raw === 'string' ? raw : JSON.stringify(raw) } catch { serialized = String(raw) } + result = truncate(serialized ?? String(raw)).text + } + return ok({ browser: b.title, result }) +} + +/** screenshot - capture the page to an image file (reuses the main-process path). */ +export const execBrowserScreenshot: CateExecutor = async (params, ctx) => { + const b = resolveBrowser(ctx, params.panel) + if (b.error) return fail(b.error) + const wcId = b.webview!.getWebContentsId() + const shot = await window.electronAPI.webviewScreenshot(wcId) + if (!shot?.filePath) return fail(`Screenshot of "${b.title}" failed.`) + return ok({ browser: b.title, filePath: shot.filePath }) +} + +// --------------------------------------------------------------------------- +// terminal - run / read +// --------------------------------------------------------------------------- + +export const execRunInTerminal: CateExecutor = async (params, ctx) => { + const command = String(params.command ?? '') + if (!command.trim()) return fail('terminal run requires a non-empty command.') + const app = useAppStore.getState() + let panelId = '' + if (params.panel != null && !params.newPanel) { + const ref = resolvePanelRef(ctx, params.panel) + if (ref.error) return fail(ref.error) + panelId = ref.panelId! + } + if (!panelId) { + panelId = withCameraPreserved(ctx, () => app.createTerminal(ctx.workspaceId)) + placeNewNode(ctx, panelId, 'terminal') + } + const sent = await writeToTerminalWhenReady(panelId, command) + if (!sent) return fail(`Terminal "${titleFor(ctx, panelId)}" did not become ready to receive input (timed out).`) + return ok({ id: shortId(panelId), terminal: titleFor(ctx, panelId), command }) +} + +/** Read the recent buffer (visible screen + scrollback) of a terminal panel as + * plain text. Lets an agent inspect command output it ran via terminal run. + * Reads straight from the live xterm buffer; no PTY round-trip. Safe (read-only). */ +export const execReadTerminal: CateExecutor = async (params, ctx) => { + const ref = resolvePanelRef(ctx, params.panel) + if (ref.error) return fail(ref.error) + const panelId = ref.panelId! + const entry = terminalRegistry.getEntry(panelId) + const buffer = (entry as { terminal?: { buffer?: { active?: any } } } | undefined)?.terminal?.buffer?.active + if (!entry || !buffer) return fail(`No live terminal for "${titleFor(ctx, panelId)}".`) + + const requested = typeof params.lines === 'number' ? Math.floor(params.lines) : 50 + const maxLines = Math.max(1, Math.min(requested, 1000)) + const total: number = buffer.length ?? 0 + const start = Math.max(0, total - maxLines) + const collected: string[] = [] + for (let i = start; i < total; i++) { + const line = buffer.getLine(i) + collected.push(line ? line.translateToString(true) : '') + } + // Drop trailing blank rows (an idle terminal pads the screen with empties). + while (collected.length && collected[collected.length - 1] === '') collected.pop() + return ok({ terminal: titleFor(ctx, panelId), lineCount: collected.length, text: collected.join('\n') }) +} + +// --------------------------------------------------------------------------- +// Op-routers - the agent sees four tools (layout / panel / browser / terminal); +// each dispatches to the focused executors above by `op`. +// --------------------------------------------------------------------------- + +/** Single-panel lifecycle/geometry. */ +export const execPanel: CateExecutor = async (params, ctx, agentKey) => { + const op = String(params.op ?? '') + switch (op) { + case 'open': return execOpenPanel(params, ctx, agentKey) + case 'close': return execClosePanel(params, ctx, agentKey) + case 'move': return execMovePanel(params, ctx, agentKey) + default: + return fail(`panel: unknown op "${op}". Expected open|close|move.`) + } +} + +/** Browser control: drive an existing browser panel (opening is the panel tool). */ +export const execBrowser: CateExecutor = async (params, ctx, agentKey) => { + const op = String(params.op ?? '') + switch (op) { + case 'navigate': return execBrowserNavigate(params, ctx, agentKey) + case 'back': + case 'forward': + case 'reload': + case 'stop': return execBrowserHistory(params, ctx, agentKey) + case 'info': return execBrowserInfo(params, ctx, agentKey) + case 'read': return execBrowserRead(params, ctx, agentKey) + case 'eval': return execBrowserEval(params, ctx, agentKey) + case 'screenshot': return execBrowserScreenshot(params, ctx, agentKey) + default: + return fail(`browser: unknown op "${op}". Expected navigate|back|forward|reload|stop|info|read|eval|screenshot.`) + } +} + +export const execTerminal: CateExecutor = async (params, ctx, agentKey) => { + const op = String(params.op ?? '') + switch (op) { + case 'run': return execRunInTerminal(params, ctx, agentKey) + case 'read': return execReadTerminal(params, ctx, agentKey) + default: + return fail(`terminal: unknown op "${op}". Expected run|read.`) + } +} + +// Register the 4-tool surface with the dispatcher. +setCateExecutors({ + layout: execGetLayout, + panel: execPanel, + browser: execBrowser, + terminal: execTerminal, +}) diff --git a/src/agent/renderer/cateToolDisplay.test.ts b/src/agent/renderer/cateToolDisplay.test.ts new file mode 100644 index 00000000..679aa74d --- /dev/null +++ b/src/agent/renderer/cateToolDisplay.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect } from 'vitest' +import { cateToolDisplay, cateActionName, cateToolFields, isCateTool } from './cateToolDisplay' + +describe('cateActionName', () => { + it('strips the cate: prefix (live round-trip name)', () => { + expect(cateActionName('cate:panel')).toBe('panel') + expect(cateActionName('panel')).toBe('panel') + }) + it('strips the cate_ prefix (raw pi name replayed on resume)', () => { + expect(cateActionName('cate_browser')).toBe('browser') + }) +}) + +describe('isCateTool', () => { + it('matches both the live cate: and persisted cate_ forms', () => { + expect(isCateTool('cate:browser')).toBe(true) + expect(isCateTool('cate_browser')).toBe(true) + }) + it('does not match other tools', () => { + expect(isCateTool('plan_complete')).toBe(false) + expect(isCateTool('bash')).toBe(false) + }) +}) + +describe('cateToolDisplay', () => { + it('reads the canvas for layout', () => { + const d = cateToolDisplay('layout', {}) + expect(d.verb).toBe('Read') + expect(d.summary).toBe('canvas layout') + }) + + it('summarises a browser navigate with its url', () => { + const d = cateToolDisplay('browser', { panel: 'Browser', url: 'https://example.com' }) + expect(d.verb).toBe('Navigated') + expect(d.request).toBe('navigate') + expect(d.summary).toBe('https://example.com') + }) + + it('summarises browser read / eval / reload by op', () => { + expect(cateToolDisplay('browser', { op: 'eval', panel: 'Browser', js: 'foo()' }).verb).toBe('Evaluated') + const r = cateToolDisplay('browser', { op: 'read', panel: 'Browser', selector: 'h1' }) + expect(r.verb).toBe('Read') + expect(r.summary).toBe('h1') + expect(cateToolDisplay('browser', { op: 'reload', panel: 'Browser' }).verb).toBe('Reloaded') + }) + + it('summarises panel open with the panel type and target', () => { + const d = cateToolDisplay('panel', { op: 'open', type: 'editor', target: { path: 'src/main/index.ts' } }) + expect(d.verb).toBe('Opened') + expect(d.summary).toBe('editor · src/main/index.ts') + }) + + it('uses the command as the summary for a terminal run', () => { + const d = cateToolDisplay('terminal', { op: 'run', command: 'npm test' }) + expect(d.verb).toBe('Ran') + expect(d.request).toBe('run') + expect(d.summary).toBe('npm test') + }) + + it('summarises a terminal read by panel title', () => { + const d = cateToolDisplay('terminal', { op: 'read', panel: 'Terminal 2' }) + expect(d.verb).toBe('Read') + expect(d.summary).toBe('Terminal 2') + }) + + it('summarises a terminal panel open with its command', () => { + const d = cateToolDisplay('panel', { op: 'open', type: 'terminal', target: { command: 'ls -la' } }) + expect(d.summary).toBe('terminal · ls -la') + }) + + it('falls back to the panel type when no target detail is present', () => { + const d = cateToolDisplay('panel', { op: 'open', type: 'document' }) + expect(d.summary).toBe('document') + }) + + it('describes a move op by panel title', () => { + const d = cateToolDisplay('panel', { op: 'move', panel: 'a.ts' }) + expect(d.verb).toBe('Moved') + expect(d.summary).toBe('a.ts') + }) + + it('describes a close op by panel title', () => { + const d = cateToolDisplay('panel', { op: 'close', panel: 'Terminal 2' }) + expect(d.verb).toBe('Closed') + expect(d.request).toBe('close') + expect(d.summary).toBe('Terminal 2') + }) + + it('always returns a usable icon + verb + summary, even for unknown actions', () => { + const d = cateToolDisplay('not_a_real_action', {}) + expect(d.Icon).toBeTruthy() + expect(d.verb).toBeTruthy() + expect(d.summary).toBe('not_a_real_action') + }) +}) + +describe('cateToolFields', () => { + it('expands a panel open into typed rows (skipping empty values)', () => { + const fields = cateToolFields('panel', { + op: 'open', + type: 'editor', + target: { path: 'src/main/index.ts', line: 42 }, + placement: { position: 'right', relativeTo: 'self' }, + }) + expect(fields).toEqual([ + { label: 'type', value: 'editor' }, + { label: 'path', value: 'src/main/index.ts' }, + { label: 'line', value: '42' }, + { label: 'placement', value: 'right of self' }, + ]) + }) + + it('surfaces the command and reused panel (by title) for a terminal run', () => { + const fields = cateToolFields('terminal', { op: 'run', command: 'npm test', panel: 'Terminal 1' }) + expect(fields).toEqual([ + { label: 'panel', value: 'Terminal 1' }, + { label: 'command', value: 'npm test' }, + ]) + }) + + it('shows the panel title for a close', () => { + expect(cateToolFields('panel', { op: 'close', panel: 'a.ts' })).toEqual([ + { label: 'panel', value: 'a.ts' }, + ]) + }) + + it('shows the target panel and placement for a move', () => { + expect(cateToolFields('panel', { op: 'move', panel: 'a.ts', placement: { relativeTo: 'Terminal 1', position: 'right' } })).toEqual([ + { label: 'panel', value: 'a.ts' }, + { label: 'placement', value: 'right of Terminal 1' }, + ]) + }) + + it('returns no rows for a layout read', () => { + expect(cateToolFields('layout', {})).toEqual([]) + }) +}) diff --git a/src/agent/renderer/cateToolDisplay.ts b/src/agent/renderer/cateToolDisplay.ts new file mode 100644 index 00000000..c31c660f --- /dev/null +++ b/src/agent/renderer/cateToolDisplay.ts @@ -0,0 +1,170 @@ +// ============================================================================= +// Presentation mapping for cate-control tool calls - turns an (action, params) +// pair into an icon + human-readable verb + short summary. Shared by the +// in-thread CateToolCard so the agent panel renders Cate's canvas actions as +// compact custom cards instead of raw JSON. +// The agent addresses panels by title, so values are already human-readable. +// Pure (no React/DOM) beyond the icon component references. +// ============================================================================= + +import { + Stack, + FileText, + FileCode, + Terminal, + Globe, + SquaresFour, + X, + ArrowsOutCardinal, + type Icon as PhosphorIcon, +} from '@phosphor-icons/react' + +export interface CateToolDisplay { + Icon: PhosphorIcon + /** Past-tense verb for a completed action ("Opened", "Ran", …). */ + verb: string + /** Lowercase present-tense verb for an approval prompt ("open", "run", …). */ + request: string + /** Short human label describing the target (title, path, command, url, …). */ + summary: string +} + +function str(v: unknown): string { + return typeof v === 'string' ? v : '' +} + +/** A readable label/value row for the expanded tool-call body. */ +export interface CateField { + label: string + value: string +} + +/** Turn a control request's params into human-readable rows for the expanded + * card body - a structured view of what Cate was asked to do, instead of a raw + * JSON dump. Pure; only the meaningful params for the action/op are surfaced. */ +export function cateToolFields( + action: string, + params: Record = {}, +): CateField[] { + const p = params ?? {} + const fields: CateField[] = [] + const add = (label: string, value: unknown) => { + if (value === undefined || value === null || value === '') return + fields.push({ label, value: String(value) }) + } + const target = (p.target ?? {}) as Record + const placement = (p.placement ?? {}) as Record + const placementText = (): string => { + const rel = str(placement.relativeTo) + const pos = str(placement.position) + if (pos && rel) return `${pos} of ${rel}` + if (pos) return pos + if (rel) return `near ${rel}` + return '' + } + + switch (action) { + case 'panel': { + const op = str(p.op) + add('panel', p.panel) + if (op === 'open') { + add('type', p.type) + add('path', target.path) + if (typeof target.line === 'number') add('line', target.line) + add('url', target.url) + add('command', target.command) + add('cwd', target.cwd) + if (target.preview === true) add('preview', 'on') + } + add('placement', placementText()) + break + } + case 'browser': + add('panel', p.panel) + add('url', p.url) + add('selector', p.selector) + add('js', p.js) + break + case 'terminal': { + const op = str(p.op) + add('panel', p.panel) + if (op === 'run') { + add('command', p.command) + if (p.newPanel === true) add('new panel', 'yes') + } + if (op === 'read' && typeof p.lines === 'number') add('lines', p.lines) + break + } + // layout (read) has no input params to surface. + } + return fields +} + +const PANEL_ICONS: Record = { + editor: FileCode, + terminal: Terminal, + browser: Globe, + document: FileText, +} + +/** True for a cate-control tool call, in either form: the live round-trip's + * synthetic `cate:` name, or pi's raw `cate_` name as persisted + * in the session file (replayed verbatim on resume, like `plan_complete`). */ +export function isCateTool(toolName: string): boolean { + return toolName.startsWith('cate:') || toolName.startsWith('cate_') +} + +/** Strip the `cate:` / `cate_` prefix to the bare action (e.g. "browser"). */ +export function cateActionName(toolName: string): string { + return isCateTool(toolName) ? toolName.slice('cate:'.length) : toolName +} + +export function cateToolDisplay( + action: string, + params: Record = {}, +): CateToolDisplay { + const p = params ?? {} + const panel = (): string => str(p.panel) || 'panel' + if (action === 'layout') { + return { Icon: Stack, verb: 'Read', request: 'read', summary: 'canvas layout' } + } + if (action === 'browser') { + // Tolerate a bare {url} (no op) as a navigate, for back-compat. + const op = str(p.op) || (str(p.url) ? 'navigate' : '') + switch (op) { + case 'navigate': return { Icon: Globe, verb: 'Navigated', request: 'navigate', summary: str(p.url) || panel() } + case 'back': return { Icon: Globe, verb: 'Went back', request: 'go back', summary: panel() } + case 'forward': return { Icon: Globe, verb: 'Went forward', request: 'go forward', summary: panel() } + case 'reload': return { Icon: Globe, verb: 'Reloaded', request: 'reload', summary: panel() } + case 'stop': return { Icon: Globe, verb: 'Stopped', request: 'stop', summary: panel() } + case 'info': return { Icon: Globe, verb: 'Read', request: 'read', summary: panel() } + case 'read': return { Icon: Globe, verb: 'Read', request: 'read', summary: str(p.selector) || panel() } + case 'eval': return { Icon: Globe, verb: 'Evaluated', request: 'evaluate', summary: str(p.js) || panel() } + case 'screenshot': return { Icon: Globe, verb: 'Captured', request: 'screenshot', summary: panel() } + default: return { Icon: Globe, verb: 'Browser', request: 'control', summary: panel() } + } + } + if (action === 'terminal') { + if (str(p.op) === 'read') { + return { Icon: Terminal, verb: 'Read', request: 'read', summary: panel() } + } + return { Icon: Terminal, verb: 'Ran', request: 'run', summary: str(p.command) || 'command' } + } + if (action === 'panel') { + const target = (p.target ?? {}) as Record + switch (str(p.op)) { + case 'open': { + const type = str(p.type) || 'panel' + const detail = str(target.path) || str(target.url) || str(target.command) || '' + return { Icon: PANEL_ICONS[type] ?? SquaresFour, verb: 'Opened', request: 'open', summary: detail ? `${type} · ${detail}` : type } + } + case 'close': + return { Icon: X, verb: 'Closed', request: 'close', summary: panel() } + case 'move': + return { Icon: ArrowsOutCardinal, verb: 'Moved', request: 'move', summary: panel() } + default: + return { Icon: SquaresFour, verb: 'Panel', request: 'manage', summary: str(p.op) || panel() } + } + } + return { Icon: SquaresFour, verb: 'Used', request: 'run', summary: action } +} diff --git a/src/main/store.ts b/src/main/store.ts index fad22c8b..9e643062 100644 --- a/src/main/store.ts +++ b/src/main/store.ts @@ -66,6 +66,7 @@ const SETTINGS_SCHEMA: Record = { notifyOnlyWhenUnfocused: 'boolean', crashReportingEnabled: 'boolean', usageAnalyticsEnabled: 'boolean', + cateControlEnabled: 'boolean', } // Settings that open windows react to live (via onSettingsChanged). The diff --git a/src/renderer/drag/__tests__/setup.ts b/src/renderer/drag/__tests__/setup.ts index 699a3444..4b04fe57 100644 --- a/src/renderer/drag/__tests__/setup.ts +++ b/src/renderer/drag/__tests__/setup.ts @@ -7,6 +7,15 @@ import { vi } from 'vitest' +// Some renderer modules (e.g. terminalRegistry → @xterm/addon-fit) reference the +// browser `self` global in their UMD wrappers at module-eval time. The node test +// env has no `self`; alias it to globalThis so node-env (.test.ts) suites that +// transitively import these modules can load. Harmless in jsdom, where `self` +// already exists. +if (typeof (globalThis as { self?: unknown }).self === 'undefined') { + ;(globalThis as { self?: unknown }).self = globalThis +} + // jsdom doesn't implement getBoundingClientRect layout. The harness assigns // rects manually via setBoundingClientRectFor() in harness.tsx, but elements // that don't have an explicit rect should at least return a zeroed object. diff --git a/src/renderer/lib/cateControlLayout.test.ts b/src/renderer/lib/cateControlLayout.test.ts new file mode 100644 index 00000000..799bb481 --- /dev/null +++ b/src/renderer/lib/cateControlLayout.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest' +import { computePlacement, type Rect } from './cateControlLayout' + +const GAP = 40 + +describe('computePlacement', () => { + const ref: Rect = { x: 100, y: 100, width: 200, height: 150 } + const size = { width: 300, height: 200 } + + it('places to the right of the reference with a gap', () => { + const r = computePlacement({ + size, + relativeTo: ref, + position: 'right', + occupied: [], + viewportCenter: { x: 0, y: 0 }, + }) + expect(r).toEqual({ x: 100 + 200 + GAP, y: 100, width: 300, height: 200 }) + }) + + it('places to the left of the reference with a gap', () => { + const r = computePlacement({ size, relativeTo: ref, position: 'left', occupied: [], viewportCenter: { x: 0, y: 0 } }) + expect(r).toEqual({ x: 100 - GAP - 300, y: 100, width: 300, height: 200 }) + }) + + it('places below the reference with a gap', () => { + const r = computePlacement({ size, relativeTo: ref, position: 'below', occupied: [], viewportCenter: { x: 0, y: 0 } }) + expect(r).toEqual({ x: 100, y: 100 + 150 + GAP, width: 300, height: 200 }) + }) + + it('auto centers on the viewport center when there is no reference', () => { + const r = computePlacement({ size, occupied: [], viewportCenter: { x: 500, y: 400 } }) + expect(r).toEqual({ x: 500 - 150, y: 400 - 100, width: 300, height: 200 }) + }) + + it('auto nudges right to avoid overlap with an occupied rect', () => { + const occupied: Rect[] = [{ x: 350, y: 300, width: 300, height: 200 }] + const r = computePlacement({ size, occupied, viewportCenter: { x: 500, y: 400 } }) + // candidate at {350,300} overlaps -> shifted right by width+gap + expect(r.x).toBe(350 + 300 + GAP) + expect(r.y).toBe(300) + }) +}) diff --git a/src/renderer/lib/cateControlLayout.ts b/src/renderer/lib/cateControlLayout.ts new file mode 100644 index 00000000..de994c76 --- /dev/null +++ b/src/renderer/lib/cateControlLayout.ts @@ -0,0 +1,54 @@ +// ============================================================================= +// Pure placement geometry for the cate-control feature. No store / DOM access - +// callers pass in occupied rects + viewport so this stays unit-testable. +// All coordinates are canvas-space. +// ============================================================================= + +export interface Rect { x: number; y: number; width: number; height: number } +export interface Size { width: number; height: number } +export interface Point { x: number; y: number } + +export type RelPosition = 'right' | 'left' | 'above' | 'below' + +const GAP = 40 + +function overlaps(a: Rect, b: Rect): boolean { + return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y +} + +export interface PlacementInput { + size: Size + /** When omitted, places relative to the viewport center. */ + relativeTo?: Rect + position?: RelPosition + /** Existing node rects to avoid overlapping. */ + occupied: Rect[] + /** Canvas-space point to center on when there is no relativeTo. */ + viewportCenter: Point +} + +/** Compute a non-overlapping canvas-space rect for a new/moved panel. */ +export function computePlacement(input: PlacementInput): Rect { + const { size, relativeTo, position, occupied, viewportCenter } = input + let x: number + let y: number + + if (relativeTo && position) { + switch (position) { + case 'right': x = relativeTo.x + relativeTo.width + GAP; y = relativeTo.y; break + case 'left': x = relativeTo.x - GAP - size.width; y = relativeTo.y; break + case 'below': x = relativeTo.x; y = relativeTo.y + relativeTo.height + GAP; break + case 'above': x = relativeTo.x; y = relativeTo.y - GAP - size.height; break + } + } else { + x = viewportCenter.x - size.width / 2 + y = viewportCenter.y - size.height / 2 + } + + // Nudge right until no overlap (bounded to avoid runaway). + let candidate: Rect = { x, y, width: size.width, height: size.height } + for (let i = 0; i < 64 && occupied.some((o) => overlaps(candidate, o)); i++) { + candidate = { ...candidate, x: candidate.x + size.width + GAP } + } + return candidate +} diff --git a/src/renderer/lib/e2eHarness.ts b/src/renderer/lib/e2eHarness.ts index bea1f739..731f6a5c 100644 --- a/src/renderer/lib/e2eHarness.ts +++ b/src/renderer/lib/e2eHarness.ts @@ -27,6 +27,16 @@ declare global { terminalPtyId(nodeId: string): string | null /** Write raw data to a terminal node's PTY (e.g. a flooding command). */ writeTerminal(nodeId: string, data: string): boolean + /** Read the rendered text of a terminal panel's xterm buffer (by panelId). */ + terminalText(panelId: string): string + /** Resolve a panel title to its panelId (cate tools report titles, not ids). */ + panelIdByTitle(title: string): string | null + /** Dispatch a cate-control action through the real renderer dispatcher, + * exactly as an agent tool call would. */ + cateControl( + action: string, + params: Record, + ): Promise<{ ok: boolean; result?: unknown; error?: string }> dragSnapshot(): { isDragging: boolean sourceKind: string | null @@ -133,6 +143,43 @@ export function installE2EHarness(): void { return true } + const terminalText = (panelId: string): string => { + const term = terminalRegistry.getEntry(panelId)?.terminal as + | { buffer?: { active?: { length: number; getLine(i: number): { translateToString(trim?: boolean): string } | undefined } } } + | undefined + const buf = term?.buffer?.active + if (!buf) return '' + let out = '' + for (let i = 0; i < buf.length; i++) out += (buf.getLine(i)?.translateToString(true) ?? '') + '\n' + return out + } + + const panelIdByTitle = (title: string): string | null => { + for (const ws of useAppStore.getState().workspaces) { + for (const [id, panel] of Object.entries(ws.panels ?? {})) { + if ((panel as { title?: string } | null)?.title === title) return id + } + } + return null + } + + const cateControl = async (action: string, params: Record) => { + // Lazy-load the agent dispatcher + executors so this stays out of the main + // bundle. Importing cateExecutors runs its setCateExecutors() registration. + const [{ registerCateContext, dispatchCateRequest }] = await Promise.all([ + import('../../agent/renderer/cateControl'), + import('../../agent/renderer/cateExecutors'), + ]) + const wsId = useAppStore.getState().selectedWorkspaceId + const cs = activeCanvasStore() + registerCateContext('e2e-agent', { + workspaceId: wsId, + hostPanelId: 'e2e-host', + canvasStore: cs!, + }) + return dispatchCateRequest('e2e-agent', { action: action as never, params }) + } + const dragSnapshot = () => { const s = useDragStore.getState() return { @@ -156,6 +203,9 @@ export function installE2EHarness(): void { resetViewport, terminalPtyId, writeTerminal, + terminalText, + panelIdByTitle, + cateControl, dragSnapshot, } } diff --git a/src/renderer/lib/portalRegistry.ts b/src/renderer/lib/portalRegistry.ts index cd822dca..1f05f4fd 100644 --- a/src/renderer/lib/portalRegistry.ts +++ b/src/renderer/lib/portalRegistry.ts @@ -19,6 +19,13 @@ export interface PortalWebview { getURL(): string getTitle(): string loadURL(url: string): void + goBack(): void + goForward(): void + canGoBack(): boolean + canGoForward(): boolean + reload(): void + stop(): void + executeJavaScript(code: string, userGesture?: boolean): Promise } interface Entry { diff --git a/src/renderer/lib/session.ts b/src/renderer/lib/session.ts index bf052b6c..bc880856 100644 --- a/src/renderer/lib/session.ts +++ b/src/renderer/lib/session.ts @@ -609,7 +609,7 @@ export async function restoreSession(snapshot: SessionSnapshot, canvasStoreApi?: switch (nodeSnap.panelType) { case 'terminal': { - const panelId = appStore.createTerminal(wsId, undefined, position) + const panelId = appStore.createTerminal(wsId, undefined, position, undefined, undefined, nodeSnap.panelId) // Restore the original title (e.g. "Sage", "Reviewer") instead of the // default "Terminal N" that createTerminal auto-assigned. Without this, // every cate-recruit'd terminal comes back as "Terminal 1/2/3..." on @@ -634,7 +634,7 @@ export async function restoreSession(snapshot: SessionSnapshot, canvasStoreApi?: break } case 'editor': { - const panelId = appStore.createEditor(wsId, nodeSnap.filePath ?? undefined) + const panelId = appStore.createEditor(wsId, nodeSnap.filePath ?? undefined, undefined, undefined, nodeSnap.panelId) if (nodeSnap.title) appStore.updatePanelTitle(wsId, panelId, nodeSnap.title) if (!nodeSnap.filePath && nodeSnap.unsavedContent) { appStore.setPanelUnsavedContent(wsId, panelId, nodeSnap.unsavedContent) @@ -654,7 +654,7 @@ export async function restoreSession(snapshot: SessionSnapshot, canvasStoreApi?: break } case 'browser': { - const panelId = appStore.createBrowser(wsId, nodeSnap.url ?? undefined) + const panelId = appStore.createBrowser(wsId, nodeSnap.url ?? undefined, undefined, undefined, nodeSnap.panelId) // Browser panels are addressed by their title in `cate portal` — same // reason as terminals, the saved name must come back. if (nodeSnap.title) appStore.updatePanelTitle(wsId, panelId, nodeSnap.title) @@ -673,7 +673,7 @@ export async function restoreSession(snapshot: SessionSnapshot, canvasStoreApi?: break } case 'document': { - const panelId = appStore.createDocument(wsId, nodeSnap.filePath ?? undefined, nodeSnap.documentType, position) + const panelId = appStore.createDocument(wsId, nodeSnap.filePath ?? undefined, nodeSnap.documentType, position, undefined, nodeSnap.panelId) if (nodeSnap.title) appStore.updatePanelTitle(wsId, panelId, nodeSnap.title) const canvasState = getCanvasState() if (canvasState) { @@ -690,7 +690,7 @@ export async function restoreSession(snapshot: SessionSnapshot, canvasStoreApi?: break } case 'agent': { - const panelId = appStore.createAgent(wsId, position) + const panelId = appStore.createAgent(wsId, position, undefined, nodeSnap.panelId) if (nodeSnap.title) appStore.updatePanelTitle(wsId, panelId, nodeSnap.title) const canvasState = getCanvasState() if (canvasState) { diff --git a/src/renderer/stores/appStore.ts b/src/renderer/stores/appStore.ts index 4f7277f8..99647264 100644 --- a/src/renderer/stores/appStore.ts +++ b/src/renderer/stores/appStore.ts @@ -294,14 +294,17 @@ interface AppStoreActions { selectWorkspace: (id: string) => Promise removeWorkspace: (id: string, forgetRecent?: boolean) => void - // Panel creation — each adds a PanelState to the workspace AND places it - createTerminal: (workspaceId: string, initialInput?: string, position?: Point, placement?: PanelPlacement, cwd?: string) => string - createBrowser: (workspaceId: string, url?: string, position?: Point, placement?: PanelPlacement) => string - createEditor: (workspaceId: string, filePath?: string, position?: Point, placement?: PanelPlacement) => string + // Panel creation — each adds a PanelState to the workspace AND places it. + // The trailing `id` is only passed by session restore, which reuses the + // panel's persisted UUID so its identity (and the agent-facing short id + // derived from it) survives a restart instead of being regenerated. + createTerminal: (workspaceId: string, initialInput?: string, position?: Point, placement?: PanelPlacement, cwd?: string, id?: string) => string + createBrowser: (workspaceId: string, url?: string, position?: Point, placement?: PanelPlacement, id?: string) => string + createEditor: (workspaceId: string, filePath?: string, position?: Point, placement?: PanelPlacement, id?: string) => string createDiffEditor: (workspaceId: string, filePath: string, diffMode: 'staged' | 'working', position?: Point, placement?: PanelPlacement) => string createCanvas: (workspaceId: string, position?: Point, placement?: PanelPlacement) => string - createAgent: (workspaceId: string, position?: Point, placement?: PanelPlacement) => string - createDocument: (workspaceId: string, filePath?: string, documentType?: 'pdf' | 'docx' | 'image', position?: Point, placement?: PanelPlacement) => string + createAgent: (workspaceId: string, position?: Point, placement?: PanelPlacement, id?: string) => string + createDocument: (workspaceId: string, filePath?: string, documentType?: 'pdf' | 'docx' | 'image', position?: Point, placement?: PanelPlacement, id?: string) => string // Ensure the center dock zone contains a canvas panel for the given workspace. // Covers session-restore and new-workspace paths where the center layout may @@ -401,11 +404,10 @@ function addAndPlacePanel( position: Point | undefined, ): string { set((state) => ({ - workspaces: state.workspaces.map((ws) => - ws.id === workspaceId - ? { ...ws, panels: { ...ws.panels, [panel.id]: panel } } - : ws, - ), + workspaces: state.workspaces.map((ws) => { + if (ws.id !== workspaceId) return ws + return { ...ws, panels: { ...ws.panels, [panel.id]: panel } } + }), })) try { placePanel(panel.id, panel.type, placement, position, workspaceId === get().selectedWorkspaceId) @@ -753,8 +755,8 @@ export const useAppStore = create((set, get) => ({ // --- Panel creation --- - createTerminal(workspaceId, initialInput?, position?, placement?, cwd?) { - const panelId = generateId() + createTerminal(workspaceId, initialInput?, position?, placement?, cwd?, id?) { + const panelId = id ?? generateId() // Auto-number terminal titles within the workspace so `cate ask "Terminal 2"` // and similar inter-panel calls can address each one unambiguously. Looks // for the highest existing "Terminal N" name and picks N+1. @@ -780,8 +782,8 @@ export const useAppStore = create((set, get) => ({ return addAndPlacePanel(set, get, workspaceId, panel, placement, position) }, - createBrowser(workspaceId, url?, position?, placement?) { - const panelId = generateId() + createBrowser(workspaceId, url?, position?, placement?, id?) { + const panelId = id ?? generateId() const panel: PanelState = { id: panelId, type: 'browser', @@ -792,8 +794,8 @@ export const useAppStore = create((set, get) => ({ return addAndPlacePanel(set, get, workspaceId, panel, placement, position) }, - createEditor(workspaceId, filePath?, position?, placement?) { - const panelId = generateId() + createEditor(workspaceId, filePath?, position?, placement?, id?) { + const panelId = id ?? generateId() const fileName = filePath ? filePath.split('/').pop() ?? 'Untitled' : 'Untitled' const panel: PanelState = { id: panelId, @@ -805,8 +807,8 @@ export const useAppStore = create((set, get) => ({ return addAndPlacePanel(set, get, workspaceId, panel, placement, position) }, - createDocument(workspaceId, filePath?, documentType?, position?, placement?) { - const panelId = generateId() + createDocument(workspaceId, filePath?, documentType?, position?, placement?, id?) { + const panelId = id ?? generateId() const fileName = filePath ? filePath.split('/').pop() ?? 'Document' : 'Document' const panel: PanelState = { id: panelId, @@ -844,9 +846,9 @@ export const useAppStore = create((set, get) => ({ return addAndPlacePanel(set, get, workspaceId, panel, placement, position) }, - createAgent(workspaceId, position?, placement?) { + createAgent(workspaceId, position?, placement?, id?) { const panel: PanelState = { - id: generateId(), + id: id ?? generateId(), type: 'agent', title: 'Agent', isDirty: false, diff --git a/src/shared/cateControl.test.ts b/src/shared/cateControl.test.ts new file mode 100644 index 00000000..8ff0dd1e --- /dev/null +++ b/src/shared/cateControl.test.ts @@ -0,0 +1,8 @@ +import { describe, it, expect } from 'vitest' +import { CATE_SENTINEL } from './cateControl' + +describe('cateControl wire protocol', () => { + it('exposes a stable sentinel string', () => { + expect(CATE_SENTINEL).toBe('@@cate-control@@') + }) +}) diff --git a/src/shared/cateControl.ts b/src/shared/cateControl.ts new file mode 100644 index 00000000..5c8e57e2 --- /dev/null +++ b/src/shared/cateControl.ts @@ -0,0 +1,47 @@ +// ============================================================================= +// Wire protocol for the cate-control agent feature. Shared by the pi extension +// (src/agent/extensions/cate-control), the renderer dispatcher +// (src/agent/renderer/cateControl.ts), and tests. No React / electron imports. +// ============================================================================= + +/** Sentinel prefix carried in a pi ctx.ui.input() title to tag a control + * request. The renderer intercepts these before the dialog queue. */ +export const CATE_SENTINEL = '@@cate-control@@' + +export type CateControlAction = + | 'layout' + | 'panel' + | 'browser' + | 'terminal' + +/** Sub-operations of the per-panel `panel` tool. */ +export type PanelOp = 'open' | 'close' | 'move' + +/** Sub-operations of the `browser` tool. It controls an existing browser panel; + * opening a new one is the `panel` tool's job. */ +export type BrowserOp = + | 'navigate' + | 'back' + | 'forward' + | 'reload' + | 'stop' + | 'info' + | 'read' + | 'eval' + | 'screenshot' + +/** Sub-operations of the `terminal` tool. */ +export type TerminalOp = 'run' | 'read' + +/** Emitted by the extension (inside the input() title, after CATE_SENTINEL). */ +export interface CateControlRequest { + action: CateControlAction + params: Record +} + +/** Returned to the extension as the input() value (JSON-stringified). */ +export interface CateControlResponse { + ok: boolean + result?: unknown + error?: string +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 12293814..625141be 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -882,6 +882,10 @@ export interface AppSettings { notificationsEnabled: boolean notifyOnlyWhenUnfocused: boolean + // Agent + /** Master switch for the cate-control feature (agent panel orchestration). */ + cateControlEnabled: boolean + // Privacy /** Send automatic error/crash reports to Sentry. Takes effect on next launch. */ crashReportingEnabled: boolean @@ -941,6 +945,9 @@ export const DEFAULT_SETTINGS: AppSettings = { notificationsEnabled: true, notifyOnlyWhenUnfocused: true, + // Agent + cateControlEnabled: false, + // Privacy crashReportingEnabled: true, usageAnalyticsEnabled: true,