From 3ec651c7946bf943c2f542088b74197a364849f2 Mon Sep 17 00:00:00 2001 From: Artur Karapetyan Date: Sat, 30 May 2026 23:31:32 +0700 Subject: [PATCH 01/22] feat(agent): scaffold cate-control extension installer (spike validated transport) --- src/agent/extensions/cate-control/index.ts | 4 ++ .../extensions/cate-control/package.json | 6 ++ src/agent/main/agentManager.ts | 2 + src/agent/main/installCateControl.ts | 65 +++++++++++++++++++ 4 files changed, 77 insertions(+) create mode 100644 src/agent/extensions/cate-control/index.ts create mode 100644 src/agent/extensions/cate-control/package.json create mode 100644 src/agent/main/installCateControl.ts diff --git a/src/agent/extensions/cate-control/index.ts b/src/agent/extensions/cate-control/index.ts new file mode 100644 index 00000000..6ee18fe9 --- /dev/null +++ b/src/agent/extensions/cate-control/index.ts @@ -0,0 +1,4 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" + +// Tools are registered in Task 10. Empty scaffold keeps the extension loadable. +export default function (_pi: ExtensionAPI) {} diff --git a/src/agent/extensions/cate-control/package.json b/src/agent/extensions/cate-control/package.json new file mode 100644 index 00000000..3a9c4d41 --- /dev/null +++ b/src/agent/extensions/cate-control/package.json @@ -0,0 +1,6 @@ +{ + "name": "cate-control", + "description": "Control Cate panels: open, arrange, focus, manage layout.", + "private": true, + "pi": { "extensions": ["./index.ts"] } +} diff --git a/src/agent/main/agentManager.ts b/src/agent/main/agentManager.ts index 086510a7..258e0b15 100644 --- a/src/agent/main/agentManager.ts +++ b/src/agent/main/agentManager.ts @@ -40,6 +40,7 @@ import type { import { AGENT_EVENT } from '../../shared/ipc-channels' import { installSubagentExtension } from './installSubagents' import { installPlanModeExtension } from './installPlanMode' +import { installCateControlExtension } from './installCateControl' import { agentDirFor, prepareAgentDir, watchWorkspaceAuth, pushSharedToWorkspace } from './agentDir' import type { AuthManager } from './authManager' @@ -178,6 +179,7 @@ export class AgentManager { await prepareAgentDir(opts.cwd) await installSubagentExtension(opts.cwd) await installPlanModeExtension(opts.cwd) + await installCateControlExtension(opts.cwd) const extraArgs: string[] = [] if (opts.sessionFile) extraArgs.push('--session', opts.sessionFile) diff --git a/src/agent/main/installCateControl.ts b/src/agent/main/installCateControl.ts new file mode 100644 index 00000000..8c002fe8 --- /dev/null +++ b/src/agent/main/installCateControl.ts @@ -0,0 +1,65 @@ +// ============================================================================= +// installCateControl — copy the bundled cate-control extension into a +// workspace's pi-agent extensions dir on first use, 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. +// +// Skip-if-exists: never overwrite a user's modified copy. +// ============================================================================= + +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 +} + +async function copyIfMissing(src: string, dest: string): Promise { + try { + await fsp.access(dest) + return // already present + } catch { /* fall through */ } + await fsp.mkdir(path.dirname(dest), { recursive: true }) + await fsp.copyFile(src, dest) + log.info('[installCateControl] installed %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 copyIfMissing(path.join(src, 'index.ts'), path.join(destDir, 'index.ts')) + await copyIfMissing(path.join(src, 'package.json'), path.join(destDir, 'package.json')) + } catch (err) { + log.warn('[installCateControl] install failed: %O', err) + } +} From 6208ea19d3d9c94de2d829da0a7865754d303219 Mon Sep 17 00:00:00 2001 From: Artur Karapetyan Date: Sat, 30 May 2026 23:32:44 +0700 Subject: [PATCH 02/22] feat(agent): cate-control wire types + action classifier --- src/shared/cateControl.test.ts | 32 ++++++++++++++++ src/shared/cateControl.ts | 68 ++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 src/shared/cateControl.test.ts create mode 100644 src/shared/cateControl.ts diff --git a/src/shared/cateControl.test.ts b/src/shared/cateControl.test.ts new file mode 100644 index 00000000..0ef14179 --- /dev/null +++ b/src/shared/cateControl.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest' +import { classifyCateAction, CATE_SENTINEL } from './cateControl' + +describe('classifyCateAction', () => { + it('marks queries and layout ops as safe', () => { + expect(classifyCateAction('get_layout', {})).toBe('safe') + expect(classifyCateAction('focus_panel', { panelId: 'p' })).toBe('safe') + expect(classifyCateAction('move_panel', { panelId: 'p' })).toBe('safe') + expect(classifyCateAction('resize_panel', { panelId: 'p' })).toBe('safe') + expect(classifyCateAction('arrange', { layout: 'tile' })).toBe('safe') + expect(classifyCateAction('reveal_in_editor', { path: 'a.ts' })).toBe('safe') + expect(classifyCateAction('pan_to', { panelId: 'p' })).toBe('safe') + expect(classifyCateAction('zoom', { level: 'fit' })).toBe('safe') + }) + + it('marks destructive and network/content ops as side-effect', () => { + expect(classifyCateAction('close_panel', { panelId: 'p' })).toBe('side-effect') + expect(classifyCateAction('run_in_terminal', { command: 'ls' })).toBe('side-effect') + expect(classifyCateAction('open_url', { url: 'https://x.com' })).toBe('side-effect') + }) + + it('treats open_panel as safe unless it carries an auto-run command or a remote url', () => { + expect(classifyCateAction('open_panel', { type: 'editor' })).toBe('safe') + expect(classifyCateAction('open_panel', { type: 'terminal', target: { command: 'npm test' } })).toBe('side-effect') + expect(classifyCateAction('open_panel', { type: 'browser', target: { url: 'https://x.com' } })).toBe('side-effect') + expect(classifyCateAction('open_panel', { type: 'browser', target: { url: 'file:///tmp/x.html' } })).toBe('safe') + }) + + 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..7444777c --- /dev/null +++ b/src/shared/cateControl.ts @@ -0,0 +1,68 @@ +// ============================================================================= +// 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 = + | 'get_layout' + | 'open_panel' + | 'close_panel' + | 'focus_panel' + | 'move_panel' + | 'resize_panel' + | 'arrange' + | 'run_in_terminal' + | 'open_url' + | 'reveal_in_editor' + | 'pan_to' + | 'zoom' + +/** 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). + * Invariant: `denied: true` implies `ok: false` and `result` is undefined. */ +export interface CateControlResponse { + ok: boolean + result?: unknown + error?: string + denied?: boolean +} + +export type CateActionClass = 'safe' | 'side-effect' + +/** A url that does not hit the network (local preview) stays safe. */ +function isRemoteUrl(url: unknown): boolean { + if (typeof url !== 'string') return false + return /^https?:\/\//i.test(url) +} + +/** Static classification + per-call escalation for open_panel. Drives whether + * guarded mode requires approval. Pure — no side effects. */ +export function classifyCateAction( + action: CateControlAction, + params: Record, +): CateActionClass { + switch (action) { + case 'close_panel': + case 'run_in_terminal': + case 'open_url': + return 'side-effect' + case 'open_panel': { + const target = (params.target ?? {}) as Record + if (typeof target.command === 'string' && target.command.trim()) return 'side-effect' + if (isRemoteUrl(target.url)) return 'side-effect' + return 'safe' + } + default: + return 'safe' + } +} From 605e58c8e0a6160b00bdee36577b6d5b5f17b51d Mon Sep 17 00:00:00 2001 From: Artur Karapetyan Date: Sat, 30 May 2026 23:34:18 +0700 Subject: [PATCH 03/22] feat(settings): add cateControlEnabled flag (default on) --- src/shared/types.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/shared/types.ts b/src/shared/types.ts index 8ab0d635..99d6030b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -855,6 +855,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 @@ -911,6 +915,9 @@ export const DEFAULT_SETTINGS: AppSettings = { notificationsEnabled: true, notifyOnlyWhenUnfocused: true, + // Agent + cateControlEnabled: true, + // Privacy crashReportingEnabled: true, usageAnalyticsEnabled: true, From d2dec336e5838c8a0b16f658a72e92dc5b982d19 Mon Sep 17 00:00:00 2001 From: Artur Karapetyan Date: Sat, 30 May 2026 23:35:18 +0700 Subject: [PATCH 04/22] feat(agent): pure placement + arrange geometry for cate-control --- src/renderer/lib/cateControlLayout.test.ts | 68 ++++++++++++++++++ src/renderer/lib/cateControlLayout.ts | 84 ++++++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 src/renderer/lib/cateControlLayout.test.ts create mode 100644 src/renderer/lib/cateControlLayout.ts diff --git a/src/renderer/lib/cateControlLayout.test.ts b/src/renderer/lib/cateControlLayout.test.ts new file mode 100644 index 00000000..2d4a971d --- /dev/null +++ b/src/renderer/lib/cateControlLayout.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest' +import { computePlacement, computeArrange, 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) + }) +}) + +describe('computeArrange', () => { + const viewport = { x: 0, y: 0, width: 1000, height: 800 } + + it('tiles 4 rects into a 2x2 grid filling the viewport', () => { + const out = computeArrange('tile', 4, viewport) + expect(out).toHaveLength(4) + expect(out[0]).toEqual({ x: 0, y: 0, width: 500, height: 400 }) + expect(out[1]).toEqual({ x: 500, y: 0, width: 500, height: 400 }) + expect(out[2]).toEqual({ x: 0, y: 400, width: 500, height: 400 }) + expect(out[3]).toEqual({ x: 500, y: 400, width: 500, height: 400 }) + }) + + it('cascades rects with a fixed offset', () => { + const out = computeArrange('cascade', 3, viewport) + expect(out[0]).toEqual({ x: 0, y: 0, width: 600, height: 480 }) + expect(out[1]).toEqual({ x: 40, y: 40, width: 600, height: 480 }) + expect(out[2]).toEqual({ x: 80, y: 80, width: 600, height: 480 }) + }) + + it('returns one full-viewport rect for focus-one', () => { + const out = computeArrange('focus-one', 1, viewport) + expect(out).toEqual([{ x: 0, y: 0, width: 1000, height: 800 }]) + }) +}) diff --git a/src/renderer/lib/cateControlLayout.ts b/src/renderer/lib/cateControlLayout.ts new file mode 100644 index 00000000..9d369c02 --- /dev/null +++ b/src/renderer/lib/cateControlLayout.ts @@ -0,0 +1,84 @@ +// ============================================================================= +// 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' +export type ArrangeLayout = 'tile' | 'grid' | 'cascade' | 'focus-one' + +const GAP = 40 +const CASCADE_STEP = 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 +} + +/** Lay out `count` panels within the given viewport rect. */ +export function computeArrange(layout: ArrangeLayout, count: number, viewport: Rect): Rect[] { + if (count <= 0) return [] + if (layout === 'focus-one') { + return [{ x: viewport.x, y: viewport.y, width: viewport.width, height: viewport.height }] + } + if (layout === 'cascade') { + const w = Math.round(viewport.width * 0.6) + const h = Math.round(viewport.height * 0.6) + return Array.from({ length: count }, (_, i) => ({ + x: viewport.x + i * CASCADE_STEP, + y: viewport.y + i * CASCADE_STEP, + width: w, + height: h, + })) + } + // tile / grid: square-ish grid. + const cols = Math.ceil(Math.sqrt(count)) + const rows = Math.ceil(count / cols) + const cellW = Math.floor(viewport.width / cols) + const cellH = Math.floor(viewport.height / rows) + return Array.from({ length: count }, (_, i) => { + const col = i % cols + const row = Math.floor(i / cols) + return { x: viewport.x + col * cellW, y: viewport.y + row * cellH, width: cellW, height: cellH } + }) +} From c858f1aef7e0719d277b6cc8b3090f37fe7f6554 Mon Sep 17 00:00:00 2001 From: Artur Karapetyan Date: Sun, 31 May 2026 00:05:43 +0700 Subject: [PATCH 05/22] feat(agent): per-chat cateControlMode state in agentStore --- .../renderer/agentStore.cateControl.test.ts | 31 +++++++++++++++++++ src/agent/renderer/agentStore.ts | 18 ++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 src/agent/renderer/agentStore.cateControl.test.ts diff --git a/src/agent/renderer/agentStore.cateControl.test.ts b/src/agent/renderer/agentStore.cateControl.test.ts new file mode 100644 index 00000000..5252155c --- /dev/null +++ b/src/agent/renderer/agentStore.cateControl.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' + +// agentStore imports 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 WorkspaceTab.test.tsx / terminalRegistry.test.ts). +vi.mock('../../renderer/lib/logger', () => ({ + default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})) + +import { useAgentStore } from './agentStore' + +describe('agentStore cateControlMode', () => { + beforeEach(() => { + useAgentStore.setState({ panels: {} }) + }) + + it('defaults to guarded when read for an unknown panel', () => { + expect(useAgentStore.getState().getCateControlMode('k1')).toBe('guarded') + }) + + it('setCateControlMode creates the panel slice and stores the mode', () => { + useAgentStore.getState().setCateControlMode('k1', 'auto') + expect(useAgentStore.getState().getCateControlMode('k1')).toBe('auto') + }) + + it('toggles back to guarded', () => { + useAgentStore.getState().setCateControlMode('k1', 'auto') + useAgentStore.getState().setCateControlMode('k1', 'guarded') + expect(useAgentStore.getState().getCateControlMode('k1')).toBe('guarded') + }) +}) diff --git a/src/agent/renderer/agentStore.ts b/src/agent/renderer/agentStore.ts index 56b9c22a..fa3cf430 100644 --- a/src/agent/renderer/agentStore.ts +++ b/src/agent/renderer/agentStore.ts @@ -208,6 +208,9 @@ export interface PanelAgentState { /** Optional session display name (mirrors pi's `set_session_name`). */ sessionName?: string sessionFile?: string + /** Renderer-side control mode for the cate-control feature. Defaults to + * 'guarded' (side-effects need approval); 'auto' executes immediately. */ + cateControlMode?: 'guarded' | 'auto' } interface AgentStoreState { @@ -243,6 +246,8 @@ interface AgentStoreActions { setRetry: (panelId: string, next: Partial) => void setQueues: (panelId: string, steering: string[], followUp: string[]) => void setExtensionStatus: (panelId: string, key: string, text?: string) => void + setCateControlMode: (panelId: string, mode: 'guarded' | 'auto') => void + getCateControlMode: (panelId: string) => 'guarded' | 'auto' setExtensionWidget: ( panelId: string, key: string, @@ -302,7 +307,7 @@ function withPanel( // Store // ----------------------------------------------------------------------------- -export const useAgentStore = create((set) => ({ +export const useAgentStore = create((set, get) => ({ panels: {}, init(panelId) { @@ -556,6 +561,17 @@ export const useAgentStore = create((set) => ({ ) }, + setCateControlMode(panelId, mode) { + set((state) => { + const current = state.panels[panelId] ?? emptyPanel() + return { panels: { ...state.panels, [panelId]: { ...current, cateControlMode: mode } } } + }) + }, + + getCateControlMode(panelId) { + return get().panels[panelId]?.cateControlMode ?? 'guarded' + }, + setExtensionWidget(panelId, key, lines, placement) { set((state) => withPanel(state, panelId, (p) => { From 8dc7d16d11ba61892624f370279a8fddc59b6ca2 Mon Sep 17 00:00:00 2001 From: Artur Karapetyan Date: Sun, 31 May 2026 00:55:03 +0700 Subject: [PATCH 06/22] feat(agent): cate-control dispatcher core (registry + gating) --- src/agent/renderer/cateControl.test.ts | 83 +++++++++++++++++++++++++ src/agent/renderer/cateControl.ts | 84 ++++++++++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 src/agent/renderer/cateControl.test.ts create mode 100644 src/agent/renderer/cateControl.ts diff --git a/src/agent/renderer/cateControl.test.ts b/src/agent/renderer/cateControl.test.ts new file mode 100644 index 00000000..2e5bf76b --- /dev/null +++ b/src/agent/renderer/cateControl.test.ts @@ -0,0 +1,83 @@ +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 { useAgentStore } from './agentStore' +import { useSettingsStore } from '../../renderer/stores/settingsStore' + +function fakeCanvasStore() { + return { getState: () => ({ nodes: {}, viewportOffset: { x: 0, y: 0 }, zoomLevel: 1 }) } as any +} + +describe('dispatchCateRequest', () => { + beforeEach(() => { + useAgentStore.setState({ panels: {} }) + useSettingsStore.setState({ cateControlEnabled: true } as any) + unregisterCateContext('k1') + __setExecutorsForTest(null) + }) + + it('errors when the feature is globally disabled', async () => { + useSettingsStore.setState({ cateControlEnabled: false } as any) + registerCateContext('k1', { workspaceId: 'w1', hostPanelId: 'p1', canvasStore: fakeCanvasStore() }) + const res = await dispatchCateRequest('k1', { action: 'get_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: 'get_layout', params: {} }) + expect(res.ok).toBe(false) + expect(res.error).toMatch(/not registered|no context/i) + }) + + it('runs safe actions immediately without approval', async () => { + registerCateContext('k1', { workspaceId: 'w1', hostPanelId: 'p1', canvasStore: fakeCanvasStore() }) + const exec = vi.fn().mockResolvedValue({ ok: true, result: { panels: [] } }) + __setExecutorsForTest({ get_layout: exec } as any) + const res = await dispatchCateRequest('k1', { action: 'get_layout', params: {} }) + expect(exec).toHaveBeenCalledTimes(1) + expect(res).toEqual({ ok: true, result: { panels: [] } }) + }) + + it('auto mode runs side-effect actions without approval', async () => { + registerCateContext('k1', { workspaceId: 'w1', hostPanelId: 'p1', canvasStore: fakeCanvasStore() }) + useAgentStore.getState().setCateControlMode('k1', 'auto') + const exec = vi.fn().mockResolvedValue({ ok: true }) + __setExecutorsForTest({ close_panel: exec } as any) + const res = await dispatchCateRequest('k1', { action: 'close_panel', params: { panelId: 'x' } }) + expect(exec).toHaveBeenCalledTimes(1) + expect(res.ok).toBe(true) + }) + + it('guarded mode asks for approval and denies when the resolver says deny', async () => { + const requestApproval = vi.fn().mockResolvedValue(false) + registerCateContext('k1', { workspaceId: 'w1', hostPanelId: 'p1', canvasStore: fakeCanvasStore(), requestApproval }) + useAgentStore.getState().setCateControlMode('k1', 'guarded') + const exec = vi.fn().mockResolvedValue({ ok: true }) + __setExecutorsForTest({ close_panel: exec } as any) + const res = await dispatchCateRequest('k1', { action: 'close_panel', params: { panelId: 'x' } }) + expect(requestApproval).toHaveBeenCalledWith('close_panel', { panelId: 'x' }) + expect(exec).not.toHaveBeenCalled() + expect(res).toEqual({ ok: false, denied: true }) + }) + + it('catches executor errors and returns them', async () => { + registerCateContext('k1', { workspaceId: 'w1', hostPanelId: 'p1', canvasStore: fakeCanvasStore() }) + __setExecutorsForTest({ get_layout: vi.fn().mockRejectedValue(new Error('boom')) } as any) + const res = await dispatchCateRequest('k1', { action: 'get_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..4657ea9d --- /dev/null +++ b/src/agent/renderer/cateControl.ts @@ -0,0 +1,84 @@ +// ============================================================================= +// 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, gates side-effects per the chat's mode, and runs an +// executor. Returns a CateControlResponse the extension reads back. +// ============================================================================= + +import type { StoreApi } from 'zustand' +import type { CanvasStore } from '../../renderer/stores/canvasStore' +import { classifyCateAction, type CateControlRequest, type CateControlResponse, type CateControlAction } from '../../shared/cateControl' +import { useAgentStore } from './agentStore' +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 + /** Renders an inline approval card and resolves true=allow / false=deny. + * Injected by AgentPanel; in tests a stub is supplied. */ + requestApproval?: (action: CateControlAction, params: Record) => Promise +} + +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. +let executors: Partial> | null = null +export function __setExecutorsForTest(map: Partial> | null): void { + executors = map +} +/** Real registration entry point (Task 7). */ +export function setCateExecutors(map: Partial>): void { + if (!executors) executors = {} + Object.assign(executors, map) +} + +export async function dispatchCateRequest( + agentKey: string, + req: CateControlRequest, +): Promise { + try { + if (!useSettingsStore.getState().cateControlEnabled) { + return { ok: false, error: 'Cate control is disabled in settings.' } + } + const ctx = registry.get(agentKey) + if (!ctx) return { ok: false, error: 'No context registered for this chat.' } + + // Guard: only the active workspace can be controlled in v1. + // (Resolution of non-active workspaces is deferred — spec §11.) + + const klass = classifyCateAction(req.action, req.params) + if (klass === 'side-effect') { + const mode = useAgentStore.getState().getCateControlMode(agentKey) + if (mode === 'guarded') { + const allowed = ctx.requestApproval ? await ctx.requestApproval(req.action, req.params) : false + if (!allowed) return { ok: false, denied: true } + } + } + + const exec = executors?.[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) } + } +} From 839971186618dd527b2e781a9f23e5efb3833709 Mon Sep 17 00:00:00 2001 From: Artur Karapetyan Date: Sun, 31 May 2026 00:58:39 +0700 Subject: [PATCH 07/22] feat(agent): cate-control lifecycle executors (get_layout/open/close) --- src/agent/renderer/cateExecutors.test.tsx | 98 ++++++++++++++++ src/agent/renderer/cateExecutors.ts | 130 ++++++++++++++++++++++ 2 files changed, 228 insertions(+) create mode 100644 src/agent/renderer/cateExecutors.test.tsx create mode 100644 src/agent/renderer/cateExecutors.ts diff --git a/src/agent/renderer/cateExecutors.test.tsx b/src/agent/renderer/cateExecutors.test.tsx new file mode 100644 index 00000000..b746ee43 --- /dev/null +++ b/src/agent/renderer/cateExecutors.test.tsx @@ -0,0 +1,98 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { execGetLayout, execOpenPanel, execClosePanel } from './cateExecutors' +import { createCanvasStore } from '../../renderer/stores/canvasStore' +import { openFileAsPanel } from '../../renderer/lib/fileRouting' +import type { CateControlContext } from './cateControl' + +// execOpenPanel routes file opens through openFileAsPanel (not createEditor directly). +vi.mock('../../renderer/lib/fileRouting', () => ({ + openFileAsPanel: vi.fn(() => 'panel-ed'), +})) +vi.mock('../../renderer/lib/editorReveal', () => ({ + setPendingReveal: vi.fn(), +})) + +// Mock appStore module so executors call into controllable spies. +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 'panel-ed' }, + createTerminal: (...a: any[]) => { created.push(['terminal', ...a]); return 'panel-tm' }, + createBrowser: (...a: any[]) => { created.push(['browser', ...a]); return 'panel-br' }, + createDocument: (...a: any[]) => { created.push(['document', ...a]); return 'panel-doc' }, + createGit: (...a: any[]) => { created.push(['git', ...a]); return 'panel-gt' }, + createFileExplorer: (...a: any[]) => { created.push(['fileExplorer', ...a]); return 'panel-fe' }, + closePanel: (...a: any[]) => { closed.push(a) }, + updatePanelUrl: (...a: any[]) => { created.push(['url', ...a]) }, + workspaces: [{ id: 'w1', panels: { 'panel-ed': { id: 'panel-ed', type: 'editor', title: 'a.ts', filePath: 'a.ts' } } }], + selectedWorkspaceId: 'w1', + }), + }, + } +}) + +function ctxWith(store = createCanvasStore()): CateControlContext { + return { workspaceId: 'w1', hostPanelId: 'host', canvasStore: store } +} + +describe('execOpenPanel', () => { + beforeEach(async () => { + const mod: any = await import('../../renderer/stores/appStore') + mod.__created.length = 0 + mod.__closed.length = 0 + vi.mocked(openFileAsPanel).mockClear() + }) + + it('opens an editor with a file path via openFileAsPanel and returns the panelId', async () => { + const res = await execOpenPanel({ type: 'editor', target: { path: 'a.ts' } }, ctxWith(), 'k1') + expect(res.ok).toBe(true) + expect((res.result as any).panelId).toBe('panel-ed') + 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) + }) +}) + +describe('execClosePanel', () => { + it('errors when the panel is not found', async () => { + const res = await execClosePanel({ panelId: 'nope' }, ctxWith(), 'k1') + expect(res.ok).toBe(false) + expect(res.error).toMatch(/not found/i) + }) + + it('closes a known panel', async () => { + const res = await execClosePanel({ panelId: 'panel-ed' }, ctxWith(), 'k1') + expect(res.ok).toBe(true) + const mod: any = await import('../../renderer/stores/appStore') + expect(mod.__closed[0]).toEqual(['w1', 'panel-ed']) + }) +}) + +describe('execGetLayout', () => { + it('returns panels with isSelf flag and viewport', async () => { + const store = createCanvasStore() + store.getState().addNode('host', 'agent', { x: 0, y: 0 }, { width: 200, height: 200 }) + store.getState().addNode('panel-ed', '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[] + const self = panels.find((p) => p.panelId === 'host') + expect(self.isSelf).toBe(true) + expect((res.result as any).viewport).toBeDefined() + }) +}) diff --git a/src/agent/renderer/cateExecutors.ts b/src/agent/renderer/cateExecutors.ts new file mode 100644 index 00000000..5e9c9b0c --- /dev/null +++ b/src/agent/renderer/cateExecutors.ts @@ -0,0 +1,130 @@ +// ============================================================================= +// 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. +// ============================================================================= + +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' + +const OPENABLE: PanelType[] = ['editor', 'terminal', 'browser', 'git', 'fileExplorer', 'document'] + +function fail(error: string): CateControlResponse { return { ok: false, error } } +function ok(result?: unknown): CateControlResponse { return { ok: true, result } } + +/** 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 }) + } + // Viewport center in canvas-space ≈ (-offset + screen/2)/zoom; we approximate + // with the centroid of existing nodes, falling back to origin. + const center = occupied.length + ? { 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 } + : { x: 0, y: 0 } + return { occupied, viewportCenter: center, nodesByPanel } +} + +export const execGetLayout: CateExecutor = async (_params, ctx) => { + const app = useAppStore.getState() + const ws = app.workspaces.find((w: any) => w.id === ctx.workspaceId) + const st = ctx.canvasStore.getState() + const panels = Object.values(st.nodes).map((node: any) => { + const panel = ws?.panels?.[node.panelId] + return { + panelId: node.panelId, + type: panel?.type ?? 'unknown', + title: panel?.title ?? '', + x: node.origin.x, y: node.origin.y, width: node.size.width, height: node.size.height, + focused: st.focusedNodeId === node.id, + isSelf: node.panelId === ctx.hostPanelId, + } + }) + return ok({ + workspaceId: ctx.workspaceId, + viewport: { zoom: st.zoomLevel, offset: st.viewportOffset }, + panels, + }) +} + +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 + + let panelId: string + switch (type) { + case 'editor': { + const path = typeof target.path === 'string' ? target.path : undefined + panelId = path ? openFileAsPanel(wsId, path) : app.createEditor(wsId) + if (path && (typeof target.line === 'number')) { + setPendingReveal(panelId, { line: target.line as number, column: typeof target.column === 'number' ? (target.column as number) : undefined }) + } + break + } + case 'terminal': + panelId = app.createTerminal(wsId, typeof target.command === 'string' ? `${target.command}\r` : undefined, undefined, undefined, typeof target.cwd === 'string' ? target.cwd : undefined) + break + case 'browser': + panelId = app.createBrowser(wsId, typeof target.url === 'string' ? target.url : undefined) + break + case 'git': + panelId = app.createGit(wsId) + break + case 'fileExplorer': + panelId = app.createFileExplorer(wsId) + break + case 'document': + panelId = typeof target.path === 'string' ? openFileAsPanel(wsId, target.path) : app.createEditor(wsId) + break + default: + return fail(`Unsupported panel type: ${type}`) + } + + // Apply semantic placement if requested (move the freshly-created node). + const placement = (params.placement ?? {}) as Record + if (placement.position || placement.relativeTo) { + const { occupied, viewportCenter, nodesByPanel } = readCanvasGeometry(ctx) + const size = PANEL_DEFINITIONS[type].defaultSize + const relPanelId = placement.relativeTo === 'self' ? ctx.hostPanelId : (typeof placement.relativeTo === 'string' ? placement.relativeTo : undefined) + const relativeTo = relPanelId ? nodesByPanel.get(relPanelId)?.rect : undefined + const rect = computePlacement({ + size, + relativeTo, + position: placement.position as any, + occupied, + viewportCenter, + }) + const node = ctx.canvasStore.getState().nodeForPanel(panelId) + if (node) { + ctx.canvasStore.getState().moveNode(node, { x: rect.x, y: rect.y }) + } + } + + const node = ctx.canvasStore.getState().nodeForPanel(panelId) + const frame = node ? ctx.canvasStore.getState().nodes[node] : undefined + return ok({ panelId, x: frame?.origin.x, y: frame?.origin.y, width: frame?.size.width, height: frame?.size.height }) +} + +export const execClosePanel: CateExecutor = async (params, ctx) => { + const panelId = String(params.panelId ?? '') + const app = useAppStore.getState() + const ws = app.workspaces.find((w: any) => w.id === ctx.workspaceId) + if (!ws?.panels?.[panelId]) return fail(`Panel not found: ${panelId}`) + if (panelId === ctx.hostPanelId) return fail('Refusing to close the agent panel hosting this chat.') + app.closePanel(ctx.workspaceId, panelId) + return ok({ closed: panelId }) +} From 692afb5097be24d7b622714c04a842443415fc54 Mon Sep 17 00:00:00 2001 From: Artur Karapetyan Date: Sun, 31 May 2026 01:38:24 +0700 Subject: [PATCH 08/22] feat(agent): cate-control management/content/viewport executors + map --- src/agent/renderer/cateExecutors.test.tsx | 82 ++++++++++++ src/agent/renderer/cateExecutors.ts | 153 ++++++++++++++++++++++ 2 files changed, 235 insertions(+) diff --git a/src/agent/renderer/cateExecutors.test.tsx b/src/agent/renderer/cateExecutors.test.tsx index b746ee43..15df6bce 100644 --- a/src/agent/renderer/cateExecutors.test.tsx +++ b/src/agent/renderer/cateExecutors.test.tsx @@ -4,6 +4,13 @@ import { createCanvasStore } from '../../renderer/stores/canvasStore' import { openFileAsPanel } from '../../renderer/lib/fileRouting' import type { CateControlContext } from './cateControl' +// 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(() => 'panel-ed'), @@ -96,3 +103,78 @@ describe('execGetLayout', () => { expect((res.result as any).viewport).toBeDefined() }) }) + +import { execFocusPanel, execResizePanel, execArrange, execZoom } from './cateExecutors' +import { execRunInTerminal, execOpenUrl, execRevealInEditor, execPanTo } from './cateExecutors' + +vi.mock('../../renderer/lib/terminalRegistry', () => ({ + terminalRegistry: { getEntry: vi.fn(() => ({ ptyId: 'pty-1' })) }, +})) + +describe('management executors', () => { + it('focus errors on unknown panel', async () => { + const res = await execFocusPanel({ panelId: 'nope' }, ctxWith(), 'k1') + expect(res.ok).toBe(false) + }) + + it('focuses a known node', async () => { + const store = createCanvasStore() + store.getState().addNode('panel-ed', 'editor', { x: 0, y: 0 }, { width: 100, height: 100 }) + const res = await execFocusPanel({ panelId: 'panel-ed' }, ctxWith(store), 'k1') + expect(res.ok).toBe(true) + expect(store.getState().focusedNodeId).toBe(store.getState().nodeForPanel('panel-ed')) + }) + + it('resize applies a preset size', async () => { + const store = createCanvasStore() + store.getState().addNode('panel-ed', 'editor', { x: 0, y: 0 }, { width: 100, height: 100 }) + const res = await execResizePanel({ panelId: 'panel-ed', preset: 'large' }, ctxWith(store), 'k1') + expect(res.ok).toBe(true) + const node = store.getState().nodeForPanel('panel-ed')! + expect(store.getState().nodes[node].size.width).toBeGreaterThan(100) + }) + + it('zoom fit calls zoomToFit', async () => { + const store = createCanvasStore() + const spy = vi.spyOn(store.getState(), 'zoomToFit') + const res = await execZoom({ level: 'fit' }, ctxWith(store), 'k1') + expect(res.ok).toBe(true) + expect(spy).toHaveBeenCalled() + }) +}) + +describe('content executors', () => { + it('run_in_terminal writes the command to the 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((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('open_url creates a browser panel when no panelId given', async () => { + const res = await execOpenUrl({ url: 'https://example.com' }, ctxWith(), 'k1') + expect(res.ok).toBe(true) + expect((res.result as any).panelId).toBe('panel-br') + }) + + it('open_url rejects a non-url', async () => { + const res = await execOpenUrl({ url: 'not a url' }, ctxWith(), 'k1') + expect(res.ok).toBe(false) + }) + + it('reveal_in_editor routes through openFileAsPanel', async () => { + const res = await execRevealInEditor({ path: 'a.ts', line: 10 }, ctxWith(), 'k1') + expect(res.ok).toBe(true) + expect(openFileAsPanel).toHaveBeenCalledWith('w1', 'a.ts') + }) + + it('pan_to errors on an unknown panel', async () => { + const res = await execPanTo({ panelId: 'nope' }, ctxWith(), 'k1') + expect(res.ok).toBe(false) + }) +}) diff --git a/src/agent/renderer/cateExecutors.ts b/src/agent/renderer/cateExecutors.ts index 5e9c9b0c..c3fea37e 100644 --- a/src/agent/renderer/cateExecutors.ts +++ b/src/agent/renderer/cateExecutors.ts @@ -128,3 +128,156 @@ export const execClosePanel: CateExecutor = async (params, ctx) => { app.closePanel(ctx.workspaceId, panelId) return ok({ closed: panelId }) } + +import { computeArrange } from '../../renderer/lib/cateControlLayout' +import { terminalRegistry } from '../../renderer/lib/terminalRegistry' +import { setCateExecutors } from './cateControl' + +const SIZE_PRESETS: Record = { + small: { width: 400, height: 300 }, + medium: { width: 640, height: 480 }, + large: { width: 960, height: 720 }, +} + +function requireNode(ctx: CateControlContext, panelId: string): string | null { + return ctx.canvasStore.getState().nodeForPanel(panelId) +} + +export const execFocusPanel: CateExecutor = async (params, ctx) => { + const panelId = String(params.panelId ?? '') + const node = requireNode(ctx, panelId) + if (!node) return fail(`Panel not found on canvas: ${panelId}`) + ctx.canvasStore.getState().focusAndCenter(node) + return ok({ focused: panelId }) +} + +export const execMovePanel: CateExecutor = async (params, ctx) => { + const panelId = String(params.panelId ?? '') + if (panelId === ctx.hostPanelId && !params.placement) return fail('Refusing to move the host agent panel without an explicit placement.') + const node = requireNode(ctx, panelId) + if (!node) return fail(`Panel not found on canvas: ${panelId}`) + const { occupied, viewportCenter, nodesByPanel } = readCanvasGeometry(ctx) + const st = ctx.canvasStore.getState() + const size = st.nodes[node].size + const placement = (params.placement ?? {}) as Record + const relPanelId = placement.relativeTo === 'self' ? ctx.hostPanelId : (typeof placement.relativeTo === 'string' ? placement.relativeTo : undefined) + const relativeTo = relPanelId ? nodesByPanel.get(relPanelId)?.rect : undefined + // Exclude the node being moved from its own obstacle set (by identity, not index). + 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({ panelId, x: rect.x, y: rect.y }) +} + +export const execResizePanel: CateExecutor = async (params, ctx) => { + const panelId = String(params.panelId ?? '') + const node = requireNode(ctx, panelId) + if (!node) return fail(`Panel not found on canvas: ${panelId}`) + let size: { width: number; height: number } | undefined + if (typeof params.preset === 'string') size = SIZE_PRESETS[params.preset] + else if (params.size && typeof params.size === 'object') { + const s = params.size as Record + if (typeof s.width === 'number' && typeof s.height === 'number') size = { width: s.width, height: s.height } + } + if (!size) return fail('resize requires a valid `preset` (small|medium|large) or `size` {width,height}.') + ctx.canvasStore.getState().resizeNode(node, size) + return ok({ panelId, ...size }) +} + +export const execArrange: CateExecutor = async (params, ctx) => { + const layout = String(params.layout ?? 'tile') as 'tile' | 'grid' | 'cascade' | 'focus-one' + const st = ctx.canvasStore.getState() + const all = Object.values(st.nodes).filter((n: any) => n.panelId !== ctx.hostPanelId) // self-protection + const requested = Array.isArray(params.panelIds) ? (params.panelIds as string[]) : null + const targets = requested + ? all.filter((n: any) => requested.includes(n.panelId)) + : all + if (!targets.length) return ok({ arranged: 0 }) + // Frame: union viewport of current nodes (canvas-space). + const minX = Math.min(...targets.map((n: any) => n.origin.x)) + const minY = Math.min(...targets.map((n: any) => n.origin.y)) + const viewport: Rect = { x: minX, y: minY, width: 1200, height: 900 } + const rects = computeArrange(layout, targets.length, viewport) + targets.forEach((n: any, i) => { + st.moveNode(n.id, { x: rects[i].x, y: rects[i].y }) + st.resizeNode(n.id, { width: rects[i].width, height: rects[i].height }) + }) + return ok({ arranged: targets.length, layout }) +} + +export const execRunInTerminal: CateExecutor = async (params, ctx) => { + const command = String(params.command ?? '') + if (!command.trim()) return fail('run_in_terminal requires a non-empty command.') + const app = useAppStore.getState() + let panelId = typeof params.panelId === 'string' ? params.panelId : '' + if (!panelId || params.newPanel) { + panelId = app.createTerminal(ctx.workspaceId) + } + // Send the command to the PTY. The terminal may need a tick to register. + const send = () => { + const entry = terminalRegistry.getEntry(panelId) + if (entry?.ptyId) { window.electronAPI.terminalWrite(entry.ptyId, command + '\r'); return true } + return false + } + if (!send()) { + await new Promise((r) => setTimeout(r, 250)) + if (!send()) return fail(`Terminal ${panelId} is not ready to receive input.`) + } + return ok({ panelId, command }) +} + +export const execOpenUrl: CateExecutor = async (params, ctx) => { + const url = String(params.url ?? '') + if (!/^(https?|file):\/\//i.test(url)) return fail('open_url requires an http(s) or file URL.') + const app = useAppStore.getState() + let panelId = typeof params.panelId === 'string' ? params.panelId : '' + if (!panelId) { panelId = app.createBrowser(ctx.workspaceId, url); return ok({ panelId, url }) } + app.updatePanelUrl(ctx.workspaceId, panelId, url) + return ok({ panelId, url }) +} + +export const execRevealInEditor: CateExecutor = async (params, ctx) => { + const path = String(params.path ?? '') + if (!path) return fail('reveal_in_editor requires a path.') + const panelId = openFileAsPanel(ctx.workspaceId, path) + if (typeof params.line === 'number') { + setPendingReveal(panelId, { line: params.line as number, column: typeof params.column === 'number' ? (params.column as number) : undefined }) + } + const node = ctx.canvasStore.getState().nodeForPanel(panelId) + if (node) ctx.canvasStore.getState().focusAndCenter(node) + return ok({ panelId, path }) +} + +export const execPanTo: CateExecutor = async (params, ctx) => { + const panelId = String(params.panelId ?? '') + const node = requireNode(ctx, panelId) + if (!node) return fail(`Panel not found on canvas: ${panelId}`) + ctx.canvasStore.getState().focusAndCenter(node) + return ok({ panelId }) +} + +export const execZoom: CateExecutor = async (params, ctx) => { + const st = ctx.canvasStore.getState() + if (params.level === 'fit') { st.zoomToFit(); return ok({ zoom: 'fit' }) } + const level = Number(params.level) + if (!Number.isFinite(level)) return fail('zoom requires a numeric level or "fit".') + st.setZoom(level) + return ok({ zoom: level }) +} + +// Register everything with the dispatcher. +setCateExecutors({ + get_layout: execGetLayout, + open_panel: execOpenPanel, + close_panel: execClosePanel, + focus_panel: execFocusPanel, + move_panel: execMovePanel, + resize_panel: execResizePanel, + arrange: execArrange, + run_in_terminal: execRunInTerminal, + open_url: execOpenUrl, + reveal_in_editor: execRevealInEditor, + pan_to: execPanTo, + zoom: execZoom, +}) From 0c6821a2961aa8fa300a47be3a4f881810aa1838 Mon Sep 17 00:00:00 2001 From: Artur Karapetyan Date: Sun, 31 May 2026 01:47:35 +0700 Subject: [PATCH 09/22] feat(agent): route cate-control requests to dispatcher + approval gating Replaces the spike sentinel interception in agentStore.handleEvent with a real dispatchCateRequest round-trip, adds the side-effect cateExecutors import, registers each chat's CateControlContext from AgentPanel (under the top-level CanvasStoreProvider, whose store is the active workspace canvas), and adds requestCateApproval/resolveCateApproval plus the cate:-prefix branch in handleApproval. Also makes the cateControl executor holder hoisted-function-based so the import-cycle (cateControl -> agentStore -> cateExecutors -> cateControl) registration is TDZ-safe regardless of module entry order, and polyfills `self` in the node test env so .test.ts suites that transitively import terminalRegistry (xterm) can load. --- src/agent/renderer/AgentPanel.tsx | 31 ++++++++++++++++++ src/agent/renderer/agentStore.ts | 49 ++++++++++++++++++++++++++++ src/agent/renderer/cateControl.ts | 23 +++++++++---- src/renderer/drag/__tests__/setup.ts | 9 +++++ 4 files changed, 106 insertions(+), 6 deletions(-) diff --git a/src/agent/renderer/AgentPanel.tsx b/src/agent/renderer/AgentPanel.tsx index 12f40a57..e73ac049 100644 --- a/src/agent/renderer/AgentPanel.tsx +++ b/src/agent/renderer/AgentPanel.tsx @@ -30,7 +30,9 @@ import log from '../../renderer/lib/logger' import type { PanelProps } from '../../renderer/panels/types' import { useAppStore } from '../../renderer/stores/appStore' import { useStatusStore } from '../../renderer/stores/statusStore' +import { useCanvasStoreApi } from '../../renderer/stores/CanvasStoreContext' import { useAgentStore } from './agentStore' +import { registerCateContext, unregisterCateContext } from './cateControl' import { ChatThread } from './ChatThread' import { AgentSidebar } from './AgentSidebar' import { ChatInput } from './AgentChatInput' @@ -79,6 +81,13 @@ export default function AgentPanel({ panelId, workspaceId }: PanelProps) { : undefined const cwd = taggedWorktree?.path ?? workspace?.rootPath ?? '' + // Canvas store for this workspace — used to register a cate-control context so + // the agent can orchestrate panels on the canvas it lives in. In the main + // window AgentPanel mounts under the top-level CanvasStoreProvider whose store + // is the primary `useCanvasStore` (the active workspace's canvas), so this + // resolves to the right store; see the registration effect below. + const canvasStoreApi = useCanvasStoreApi() + // --------------------------------------------------------------------------- // Multi-chat session bookkeeping. // @@ -490,6 +499,12 @@ export default function AgentPanel({ panelId, workspaceId }: PanelProps) { const handleApproval = useCallback( async (toolCallId: string, decision: 'allow' | 'deny') => { if (!activeAgentKey) return + // cate-control approvals resolve a local Promise in the dispatcher — there + // is no pi tool call awaiting them, so don't hit the pi IPC channel. + if (toolCallId.startsWith('cate:')) { + useAgentStore.getState().resolveCateApproval(activeAgentKey, toolCallId, decision === 'allow') + return + } useAgentStore.getState().resolveApproval(activeAgentKey, toolCallId) try { await window.electronAPI.agentToolDecision(activeAgentKey, toolCallId, decision) @@ -503,6 +518,22 @@ export default function AgentPanel({ panelId, workspaceId }: PanelProps) { [activeAgentKey], ) + // Register this chat's cate-control context so the dispatcher can resolve its + // workspace/canvas and route guarded-mode approvals through the panel's + // approval card. Re-registers whenever the active chat or workspace changes. + useEffect(() => { + if (!activeAgentKey) return + const key = activeAgentKey + registerCateContext(key, { + workspaceId, + hostPanelId: panelId, + canvasStore: canvasStoreApi, + requestApproval: (action, params) => + useAgentStore.getState().requestCateApproval(key, action, params), + }) + return () => unregisterCateContext(key) + }, [activeAgentKey, workspaceId, panelId, canvasStoreApi]) + // --------------------------------------------------------------------------- // Derived state // --------------------------------------------------------------------------- diff --git a/src/agent/renderer/agentStore.ts b/src/agent/renderer/agentStore.ts index fa3cf430..77f0f14e 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 @@ -234,6 +237,8 @@ interface AgentStoreActions { setModel: (panelId: string, model: AgentModelRef | null) => void addApproval: (panelId: string, req: AgentToolApprovalRequest) => void resolveApproval: (panelId: string, toolCallId: string) => void + requestCateApproval: (panelId: string, action: string, params: Record) => Promise + resolveCateApproval: (panelId: string, toolCallId: string, allow: boolean) => void appendSystem: (panelId: string, text: string, kind?: SystemMessage['kind']) => void loadMessages: (panelId: string, messages: AgentMessage[]) => void clearMessages: (panelId: string) => void @@ -303,6 +308,11 @@ function withPanel( return { panels: { ...state.panels, [panelId]: next } } } +// In-flight cate-control approvals, keyed by a synthetic toolCallId. The +// dispatcher awaits these Promises; the AgentPanel approval card resolves them. +const pendingCateApprovals = new Map void>() +let cateApprovalSeq = 0 + // ----------------------------------------------------------------------------- // Store // ----------------------------------------------------------------------------- @@ -497,6 +507,23 @@ export const useAgentStore = create((set, get) => ({ ) }, + requestCateApproval(panelId, action, params) { + const toolCallId = `cate:${action}:${cateApprovalSeq++}` + return new Promise((resolve) => { + pendingCateApprovals.set(toolCallId, resolve) + get().addApproval(panelId, { panelId, toolCallId, toolName: `cate:${action}`, args: params }) + }) + }, + + resolveCateApproval(panelId, toolCallId, allow) { + const resolver = pendingCateApprovals.get(toolCallId) + if (resolver) { + pendingCateApprovals.delete(toolCallId) + resolver(allow) + } + get().resolveApproval(panelId, toolCallId) + }, + setStats(panelId, stats) { set((state) => withPanel(state, panelId, (p) => ({ ...p, stats }))) }, @@ -985,6 +1012,28 @@ 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 + } + void dispatchCateRequest(panelId, request).then((response) => { + 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.ts b/src/agent/renderer/cateControl.ts index 4657ea9d..6761c473 100644 --- a/src/agent/renderer/cateControl.ts +++ b/src/agent/renderer/cateControl.ts @@ -40,15 +40,26 @@ export function unregisterCateContext(agentKey: string): void { registry.delete(agentKey) } -// Executor map is assembled in Tasks 6–7; overridable in tests. -let executors: Partial> | null = null +// 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 { - executors = map + executorHolder().map = map } /** Real registration entry point (Task 7). */ export function setCateExecutors(map: Partial>): void { - if (!executors) executors = {} - Object.assign(executors, map) + const holder = executorHolder() + if (!holder.map) holder.map = {} + Object.assign(holder.map, map) } export async function dispatchCateRequest( @@ -74,7 +85,7 @@ export async function dispatchCateRequest( } } - const exec = executors?.[req.action] + 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) { 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. From 86bcbcad9ebbc7c1c121ca6cab219dce02a7e72d Mon Sep 17 00:00:00 2001 From: Artur Karapetyan Date: Sun, 31 May 2026 01:50:35 +0700 Subject: [PATCH 10/22] feat(agent): full cate-control tool surface in the pi extension --- src/agent/extensions/cate-control/index.ts | 69 +++++++++++++++++++++- 1 file changed, 67 insertions(+), 2 deletions(-) diff --git a/src/agent/extensions/cate-control/index.ts b/src/agent/extensions/cate-control/index.ts index 6ee18fe9..ec2c5ac1 100644 --- a/src/agent/extensions/cate-control/index.ts +++ b/src/agent/extensions/cate-control/index.ts @@ -1,4 +1,69 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" +import { Type } from "typebox" -// Tools are registered in Task 10. Empty scaffold keeps the extension loadable. -export default function (_pi: ExtensionAPI) {} +// 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: "panelId or 'self'" })), + position: Type.Optional(Type.Union([Type.Literal("right"), Type.Literal("left"), Type.Literal("above"), Type.Literal("below")])), +})) + +export default function (pi: ExtensionAPI) { + 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_get_layout", "Read Cate layout", + "Return the current canvas: open panels (id, type, title, position, size, focused, isSelf) and viewport.", + Type.Object({}), "get_layout") + + tool("cate_open_panel", "Open a panel", + "Open a panel on the canvas. type: editor|terminal|browser|git|fileExplorer|document. target: {path,line?} for editor; {url} for browser; {cwd?,command?} for terminal. Optional semantic placement.", + Type.Object({ + type: 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()), + })), + placement: Placement, + }), "open_panel") + + tool("cate_close_panel", "Close a panel", "Close a panel by id.", Type.Object({ panelId: Type.String() }), "close_panel") + tool("cate_focus_panel", "Focus a panel", "Focus a panel and center it in view.", Type.Object({ panelId: Type.String() }), "focus_panel") + tool("cate_move_panel", "Move a panel", "Move a panel using semantic placement.", Type.Object({ panelId: Type.String(), placement: Placement }), "move_panel") + tool("cate_resize_panel", "Resize a panel", "Resize a panel by preset (small|medium|large) or explicit {width,height}.", + Type.Object({ panelId: Type.String(), preset: Type.Optional(Type.String()), size: Type.Optional(Type.Object({ width: Type.Number(), height: Type.Number() })) }), "resize_panel") + tool("cate_arrange", "Arrange panels", "Arrange panels: tile|grid|cascade|focus-one. Optional panelIds to limit scope.", + Type.Object({ layout: Type.String(), panelIds: Type.Optional(Type.Array(Type.String())) }), "arrange") + tool("cate_run_in_terminal", "Run in terminal", "Run a shell command in a terminal panel (opens one if newPanel).", + Type.Object({ panelId: Type.Optional(Type.String()), command: Type.String(), newPanel: Type.Optional(Type.Boolean()) }), "run_in_terminal") + tool("cate_open_url", "Open a URL", "Open or navigate a browser panel to a URL.", + Type.Object({ panelId: Type.Optional(Type.String()), url: Type.String() }), "open_url") + tool("cate_reveal_in_editor", "Reveal in editor", "Open a file in the editor at a line and focus it.", + Type.Object({ path: Type.String(), line: Type.Optional(Type.Number()), column: Type.Optional(Type.Number()) }), "reveal_in_editor") + tool("cate_pan_to", "Pan to a panel", "Center the viewport on a panel.", Type.Object({ panelId: Type.String() }), "pan_to") + tool("cate_zoom", "Zoom", "Set zoom level (number) or 'fit'.", Type.Object({ level: Type.Union([Type.Number(), Type.Literal("fit")]) }), "zoom") +} From 5fa6c8914dbc61cac0fa3baa95c9e39ad48fb9d5 Mon Sep 17 00:00:00 2001 From: Artur Karapetyan Date: Sun, 31 May 2026 01:53:57 +0700 Subject: [PATCH 11/22] feat(agent): guarded/auto toggle for cate-control --- src/agent/renderer/AgentChatInput.tsx | 16 ++++++++++++++++ src/agent/renderer/AgentPanel.tsx | 11 +++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/agent/renderer/AgentChatInput.tsx b/src/agent/renderer/AgentChatInput.tsx index 364021d3..a1490c61 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, @@ -68,6 +69,8 @@ export function ChatInput({ compactionActive, planModeActive, onTogglePlanMode, + cateControlMode, + onToggleCateControlMode, placeholder: placeholderOverride, }: { draft: string @@ -92,6 +95,8 @@ export function ChatInput({ compactionActive: boolean planModeActive: boolean onTogglePlanMode: () => void + cateControlMode?: 'guarded' | 'auto' + onToggleCateControlMode?: () => void placeholder?: string }) { useEffect(() => { @@ -243,6 +248,17 @@ export function ChatInput({ > + { + if (!activeAgentKey) return + const next = useAgentStore.getState().getCateControlMode(activeAgentKey) === 'auto' ? 'guarded' : 'auto' + useAgentStore.getState().setCateControlMode(activeAgentKey, next) + }, [activeAgentKey]) + const handleImplementPlan = useCallback(async () => { if (!activeAgentKey) return const key = activeAgentKey @@ -962,6 +969,8 @@ export default function AgentPanel({ panelId, workspaceId }: PanelProps) { compactionActive={compaction.active} planModeActive={planModeActive} onTogglePlanMode={handleTogglePlanMode} + cateControlMode={cateControlMode} + onToggleCateControlMode={handleToggleCateControlMode} placeholder={ !selectedModel ? 'Pick a model to start…' : !selectedProviderConnected ? `Connect ${selectedModel.provider} to start…` @@ -1019,6 +1028,8 @@ export default function AgentPanel({ panelId, workspaceId }: PanelProps) { compactionActive={compaction.active} planModeActive={planModeActive} onTogglePlanMode={handleTogglePlanMode} + cateControlMode={cateControlMode} + onToggleCateControlMode={handleToggleCateControlMode} /> )} From 0387093373e2c1520e8f1c765ceef0452cd61db8 Mon Sep 17 00:00:00 2001 From: Artur Karapetyan Date: Sun, 31 May 2026 01:58:06 +0700 Subject: [PATCH 12/22] fix(settings): register cateControlEnabled in SETTINGS_SCHEMA (fixes typecheck) --- src/main/store.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/store.ts b/src/main/store.ts index e67bf103..3bd9a54c 100644 --- a/src/main/store.ts +++ b/src/main/store.ts @@ -60,6 +60,7 @@ const SETTINGS_SCHEMA: Record = { notifyOnlyWhenUnfocused: 'boolean', crashReportingEnabled: 'boolean', usageAnalyticsEnabled: 'boolean', + cateControlEnabled: 'boolean', } // Settings that open windows react to live (via onSettingsChanged). The From 576c88a8cdb6b8f720c810b065503edebc8a29a0 Mon Sep 17 00:00:00 2001 From: Artur Karapetyan Date: Sun, 31 May 2026 02:29:03 +0700 Subject: [PATCH 13/22] fix(agent): reliably run terminal commands + add markdown preview tool - open_panel/run_in_terminal: send the command to the PTY via condition-based waiting (poll until node-pty registers) instead of a 250ms guess; stop relying on createTerminal's initialInput, which the store never forwards. - add cate_set_markdown_preview tool + preview option on cate_reveal_in_editor, wired to appStore.setPanelMarkdownPreview (the app already supports preview; the agent just had no way to trigger it). --- src/agent/extensions/cate-control/index.ts | 6 +- src/agent/renderer/cateExecutors.test.tsx | 68 ++++++++++++++++++++++ src/agent/renderer/cateExecutors.ts | 61 +++++++++++++++---- src/shared/cateControl.ts | 1 + 4 files changed, 123 insertions(+), 13 deletions(-) diff --git a/src/agent/extensions/cate-control/index.ts b/src/agent/extensions/cate-control/index.ts index ec2c5ac1..1b151a40 100644 --- a/src/agent/extensions/cate-control/index.ts +++ b/src/agent/extensions/cate-control/index.ts @@ -62,8 +62,10 @@ export default function (pi: ExtensionAPI) { Type.Object({ panelId: Type.Optional(Type.String()), command: Type.String(), newPanel: Type.Optional(Type.Boolean()) }), "run_in_terminal") tool("cate_open_url", "Open a URL", "Open or navigate a browser panel to a URL.", Type.Object({ panelId: Type.Optional(Type.String()), url: Type.String() }), "open_url") - tool("cate_reveal_in_editor", "Reveal in editor", "Open a file in the editor at a line and focus it.", - Type.Object({ path: Type.String(), line: Type.Optional(Type.Number()), column: Type.Optional(Type.Number()) }), "reveal_in_editor") + tool("cate_reveal_in_editor", "Reveal in editor", "Open a file in the editor at a line and focus it. Set preview:true to open a markdown file straight into rendered preview.", + Type.Object({ path: Type.String(), line: Type.Optional(Type.Number()), column: Type.Optional(Type.Number()), preview: Type.Optional(Type.Boolean()) }), "reveal_in_editor") + tool("cate_set_markdown_preview", "Toggle markdown preview", "Show (preview:true) or hide (preview:false) the rendered markdown preview for an open editor panel. Markdown files only.", + Type.Object({ panelId: Type.String(), preview: Type.Optional(Type.Boolean()) }), "set_markdown_preview") tool("cate_pan_to", "Pan to a panel", "Center the viewport on a panel.", Type.Object({ panelId: Type.String() }), "pan_to") tool("cate_zoom", "Zoom", "Set zoom level (number) or 'fit'.", Type.Object({ level: Type.Union([Type.Number(), Type.Literal("fit")]) }), "zoom") } diff --git a/src/agent/renderer/cateExecutors.test.tsx b/src/agent/renderer/cateExecutors.test.tsx index 15df6bce..975d538e 100644 --- a/src/agent/renderer/cateExecutors.test.tsx +++ b/src/agent/renderer/cateExecutors.test.tsx @@ -36,6 +36,7 @@ vi.mock('../../renderer/stores/appStore', () => { createFileExplorer: (...a: any[]) => { created.push(['fileExplorer', ...a]); return 'panel-fe' }, closePanel: (...a: any[]) => { closed.push(a) }, updatePanelUrl: (...a: any[]) => { created.push(['url', ...a]) }, + setPanelMarkdownPreview: (...a: any[]) => { created.push(['mdpreview', ...a]) }, workspaces: [{ id: 'w1', panels: { 'panel-ed': { id: 'panel-ed', type: 'editor', title: 'a.ts', filePath: 'a.ts' } } }], selectedWorkspaceId: 'w1', }), @@ -178,3 +179,70 @@ describe('content executors', () => { expect(res.ok).toBe(false) }) }) + +// --------------------------------------------------------------------------- +// Regression fixes from live testing (2026-05-31) +// --------------------------------------------------------------------------- +import { terminalRegistry } from '../../renderer/lib/terminalRegistry' +import { execSetMarkdownPreview } from './cateExecutors' + +describe('terminal command reliability (Issue 1 fix)', () => { + beforeEach(async () => { + ;(window.electronAPI as any).terminalWrite = vi.fn() + vi.mocked(terminalRegistry.getEntry).mockReturnValue({ ptyId: 'pty-1' } as any) + const mod: any = await import('../../renderer/stores/appStore') + mod.__created.length = 0 + }) + + it('run_in_terminal polls until the PTY registers, then writes the command', async () => { + // Not ready for the first two polls (no entry, then entry with empty ptyId), + // then the PTY spawns — proves condition-based waiting, not a fixed delay. + 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') + // createTerminal must NOT receive the command as initialInput (that arg is a no-op via the store). + const mod: any = await import('../../renderer/stores/appStore') + const termCreate = mod.__created.find((c: any[]) => c[0] === 'terminal') + expect(termCreate?.[2]).toBeUndefined() + }) +}) + +describe('markdown preview (Issue 2 fix)', () => { + beforeEach(async () => { + const mod: any = await import('../../renderer/stores/appStore') + mod.__created.length = 0 + }) + + it('set_markdown_preview toggles preview on an editor panel', async () => { + const res = await execSetMarkdownPreview({ panelId: 'panel-ed', 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', 'panel-ed', true]) + }) + + it('set_markdown_preview errors when the panel is missing', async () => { + const res = await execSetMarkdownPreview({ panelId: 'nope', preview: true }, ctxWith(), 'k1') + expect(res.ok).toBe(false) + }) + + it('reveal_in_editor with preview:true turns on markdown preview', async () => { + const res = await execRevealInEditor({ 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', 'panel-ed', true]) + }) +}) diff --git a/src/agent/renderer/cateExecutors.ts b/src/agent/renderer/cateExecutors.ts index c3fea37e..14f52e39 100644 --- a/src/agent/renderer/cateExecutors.ts +++ b/src/agent/renderer/cateExecutors.ts @@ -66,6 +66,9 @@ export const execOpenPanel: CateExecutor = async (params, ctx) => { const wsId = ctx.workspaceId let panelId: string + // 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 switch (type) { case 'editor': { const path = typeof target.path === 'string' ? target.path : undefined @@ -76,7 +79,8 @@ export const execOpenPanel: CateExecutor = async (params, ctx) => { break } case 'terminal': - panelId = app.createTerminal(wsId, typeof target.command === 'string' ? `${target.command}\r` : undefined, undefined, undefined, typeof target.cwd === 'string' ? target.cwd : undefined) + panelId = app.createTerminal(wsId, undefined, undefined, undefined, typeof target.cwd === 'string' ? target.cwd : undefined) + pendingTerminalCommand = typeof target.command === 'string' && target.command.trim() ? target.command : undefined break case 'browser': panelId = app.createBrowser(wsId, typeof target.url === 'string' ? target.url : undefined) @@ -114,6 +118,11 @@ export const execOpenPanel: CateExecutor = async (params, ctx) => { } } + // Run the requested command once the freshly-created terminal's PTY is live. + if (pendingTerminalCommand) { + await writeToTerminalWhenReady(panelId, pendingTerminalCommand) + } + const node = ctx.canvasStore.getState().nodeForPanel(panelId) const frame = node ? ctx.canvasStore.getState().nodes[node] : undefined return ok({ panelId, x: frame?.origin.x, y: frame?.origin.y, width: frame?.size.width, height: frame?.size.height }) @@ -143,6 +152,23 @@ function requireNode(ctx: CateControlContext, panelId: string): string | null { return ctx.canvasStore.getState().nodeForPanel(panelId) } +/** 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)) + } +} + export const execFocusPanel: CateExecutor = async (params, ctx) => { const panelId = String(params.panelId ?? '') const node = requireNode(ctx, panelId) @@ -214,16 +240,8 @@ export const execRunInTerminal: CateExecutor = async (params, ctx) => { if (!panelId || params.newPanel) { panelId = app.createTerminal(ctx.workspaceId) } - // Send the command to the PTY. The terminal may need a tick to register. - const send = () => { - const entry = terminalRegistry.getEntry(panelId) - if (entry?.ptyId) { window.electronAPI.terminalWrite(entry.ptyId, command + '\r'); return true } - return false - } - if (!send()) { - await new Promise((r) => setTimeout(r, 250)) - if (!send()) return fail(`Terminal ${panelId} is not ready to receive input.`) - } + const sent = await writeToTerminalWhenReady(panelId, command) + if (!sent) return fail(`Terminal ${panelId} did not become ready to receive input (timed out).`) return ok({ panelId, command }) } @@ -244,11 +262,31 @@ export const execRevealInEditor: CateExecutor = async (params, ctx) => { if (typeof params.line === 'number') { setPendingReveal(panelId, { line: params.line as number, column: typeof params.column === 'number' ? (params.column as number) : undefined }) } + // Convenience: open straight into rendered markdown preview (markdown files only). + if (params.preview === true) { + useAppStore.getState().setPanelMarkdownPreview(ctx.workspaceId, panelId, true) + } const node = ctx.canvasStore.getState().nodeForPanel(panelId) if (node) ctx.canvasStore.getState().focusAndCenter(node) return ok({ panelId, path }) } +/** Toggle the rendered markdown preview for an open editor panel. The app gates + * the actual render to .md files (EditorPanel), so this is a no-op visually for + * non-markdown editors but still records the flag. */ +export const execSetMarkdownPreview: CateExecutor = async (params, ctx) => { + const panelId = String(params.panelId ?? '') + if (!panelId) return fail('set_markdown_preview requires a panelId.') + const app = useAppStore.getState() + const ws = app.workspaces.find((w: any) => w.id === ctx.workspaceId) + const panel = ws?.panels?.[panelId] + if (!panel) return fail(`Panel not found: ${panelId}`) + if (panel.type !== 'editor') return fail(`Panel ${panelId} is a ${panel.type}; markdown preview applies to editor panels.`) + const preview = params.preview !== false + app.setPanelMarkdownPreview(ctx.workspaceId, panelId, preview) + return ok({ panelId, preview }) +} + export const execPanTo: CateExecutor = async (params, ctx) => { const panelId = String(params.panelId ?? '') const node = requireNode(ctx, panelId) @@ -278,6 +316,7 @@ setCateExecutors({ run_in_terminal: execRunInTerminal, open_url: execOpenUrl, reveal_in_editor: execRevealInEditor, + set_markdown_preview: execSetMarkdownPreview, pan_to: execPanTo, zoom: execZoom, }) diff --git a/src/shared/cateControl.ts b/src/shared/cateControl.ts index 7444777c..22677a74 100644 --- a/src/shared/cateControl.ts +++ b/src/shared/cateControl.ts @@ -19,6 +19,7 @@ export type CateControlAction = | 'run_in_terminal' | 'open_url' | 'reveal_in_editor' + | 'set_markdown_preview' | 'pan_to' | 'zoom' From 1b205aa0c2f72b9a37cce3e826c19a76b11cf5d2 Mon Sep 17 00:00:00 2001 From: Artur Karapetyan Date: Sun, 31 May 2026 02:32:26 +0700 Subject: [PATCH 14/22] test(e2e): cover cate-control terminal command execution + markdown preview Drives the real renderer dispatcher via window.__cateE2E.cateControl (as an agent tool would) and observes the live app: run_in_terminal and open_panel(terminal,command) actually execute in a spawned PTY (asserted via command output in the xterm buffer), and set_markdown_preview flips the editor into preview. Adds terminalText + cateControl e2e harness hooks. --- e2e/cate-control.spec.ts | 78 ++++++++++++++++++++++++++++++++++ src/renderer/lib/e2eHarness.ts | 39 +++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 e2e/cate-control.spec.ts diff --git a/e2e/cate-control.spec.ts b/e2e/cate-control.spec.ts new file mode 100644 index 00000000..830d5094 --- /dev/null +++ b/e2e/cate-control.spec.ts @@ -0,0 +1,78 @@ +// 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. Focused on the two fixes from live testing: +// 1. terminal commands actually run (open_panel + run_in_terminal) +// 2. markdown preview can be toggled +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 }, + ) +} + +test('run_in_terminal runs the command in a live PTY', async () => { + const res = await cate(page, 'run_in_terminal', { command: 'echo $((6*7))_CATEOK', newPanel: true }) + expect(res.ok).toBe(true) + const panelId = res.result.panelId as string + expect(panelId).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(() => page.evaluate((pid) => window.__cateE2E!.terminalText(pid), panelId), { + timeout: 15_000, + intervals: [250], + }) + .toContain('42_CATEOK') +}) + +test('open_panel(terminal, command) runs the command', async () => { + const res = await cate(page, 'open_panel', { type: 'terminal', target: { command: 'echo $((8*8))_CATEOPEN' } }) + expect(res.ok).toBe(true) + const panelId = res.result.panelId as string + await expect + .poll(() => page.evaluate((pid) => window.__cateE2E!.terminalText(pid), panelId), { + timeout: 15_000, + intervals: [250], + }) + .toContain('64_CATEOPEN') +}) + +test('set_markdown_preview toggles the editor into preview mode', async () => { + const opened = await cate(page, 'open_panel', { type: 'editor', target: { path: 'CATE_NOTES.md' } }) + expect(opened.ok).toBe(true) + const panelId = opened.result.panelId as string + const nodeId = await page.evaluate( + (pid) => window.__cateE2E!.nodes().find((n) => n.panelId === pid)?.id ?? null, + panelId, + ) + expect(nodeId).toBeTruthy() + const nodeSel = `[data-node-id="${nodeId}"]` + await page.waitForSelector(nodeSel) + + // Before: a markdown file shows the "Preview" toggle (source mode). + await expect(page.locator(`${nodeSel} button:has-text("Preview")`)).toBeVisible() + + // Turn preview on through the tool. + const pv = await cate(page, 'set_markdown_preview', { panelId, preview: true }) + expect(pv.ok).toBe(true) + + // After: the toggle flips to "Source" — preview is now active. + await expect(page.locator(`${nodeSel} button:has-text("Source")`)).toBeVisible() +}) + +test('set_markdown_preview rejects a non-existent panel', async () => { + const res = await cate(page, 'set_markdown_preview', { panelId: 'does-not-exist', preview: true }) + expect(res.ok).toBe(false) +}) diff --git a/src/renderer/lib/e2eHarness.ts b/src/renderer/lib/e2eHarness.ts index 5ec23f44..441d9c14 100644 --- a/src/renderer/lib/e2eHarness.ts +++ b/src/renderer/lib/e2eHarness.ts @@ -26,6 +26,14 @@ 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 + /** Dispatch a cate-control action through the real renderer dispatcher, + * exactly as an agent tool call would. Side-effects are auto-approved. */ + cateControl( + action: string, + params: Record, + ): Promise<{ ok: boolean; result?: unknown; error?: string; denied?: boolean }> dragSnapshot(): { isDragging: boolean sourceKind: string | null @@ -107,6 +115,35 @@ 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 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!, + requestApproval: async () => true, + }) + return dispatchCateRequest('e2e-agent', { action: action as never, params }) + } + const dragSnapshot = () => { const s = useDragStore.getState() return { @@ -129,6 +166,8 @@ export function installE2EHarness(): void { resetViewport, terminalPtyId, writeTerminal, + terminalText, + cateControl, dragSnapshot, } } From d465932a03967b1c1e2f1573004dd9f5f48fea7d Mon Sep 17 00:00:00 2001 From: Artur Karapetyan Date: Sun, 31 May 2026 19:17:08 +0700 Subject: [PATCH 15/22] refactor(agent): trim cate-control toolset, add terminal read, fix open-focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review on #211 (keep the agent toolset lean + focused). - Drop camera-control tools: remove `pan_to` (was identical to `focus_panel` — both just focusAndCenter) and `zoom` (the agent shouldn't drive zoom/viewport). - Fold `reveal_in_editor` into `open_panel`: open_panel now focuses+centers what it opens and accepts target.preview for markdown, so the dedicated reveal tool was redundant. - Add `read_terminal`: read a terminal panel's recent buffer (visible screen + scrollback) as text, so an agent can inspect output it ran via run_in_terminal — the other half of terminal orchestration. Net 13 → 11 tools. Fix the "new panel pans to a random location" bug: execOpenPanel never focused the panel it created and estimated the viewport center as the centroid of all nodes (could be far off-screen). Now it centers on the real viewport (via viewToCanvas + containerSize) and focusAndCenters the opened panel so it lands in view. --- src/agent/extensions/cate-control/index.ts | 12 ++-- src/agent/renderer/cateExecutors.test.tsx | 48 ++++++++----- src/agent/renderer/cateExecutors.ts | 81 ++++++++++++---------- src/shared/cateControl.test.ts | 4 +- src/shared/cateControl.ts | 4 +- 5 files changed, 83 insertions(+), 66 deletions(-) diff --git a/src/agent/extensions/cate-control/index.ts b/src/agent/extensions/cate-control/index.ts index 1b151a40..e5e3d56c 100644 --- a/src/agent/extensions/cate-control/index.ts +++ b/src/agent/extensions/cate-control/index.ts @@ -41,12 +41,13 @@ export default function (pi: ExtensionAPI) { Type.Object({}), "get_layout") tool("cate_open_panel", "Open a panel", - "Open a panel on the canvas. type: editor|terminal|browser|git|fileExplorer|document. target: {path,line?} for editor; {url} for browser; {cwd?,command?} for terminal. Optional semantic placement.", + "Open (or re-focus) a panel on the canvas, then center the view on it. type: editor|terminal|browser|git|fileExplorer|document. target: {path,line?,preview?} for editor (preview:true opens a markdown file straight into rendered preview); {url} for browser; {cwd?,command?} for terminal. Optional semantic placement.", Type.Object({ type: 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, }), "open_panel") @@ -58,14 +59,13 @@ export default function (pi: ExtensionAPI) { Type.Object({ panelId: Type.String(), preset: Type.Optional(Type.String()), size: Type.Optional(Type.Object({ width: Type.Number(), height: Type.Number() })) }), "resize_panel") tool("cate_arrange", "Arrange panels", "Arrange panels: tile|grid|cascade|focus-one. Optional panelIds to limit scope.", Type.Object({ layout: Type.String(), panelIds: Type.Optional(Type.Array(Type.String())) }), "arrange") - tool("cate_run_in_terminal", "Run in terminal", "Run a shell command in a terminal panel (opens one if newPanel).", + tool("cate_run_in_terminal", "Run in terminal", "Run a shell command in a terminal panel (opens one if newPanel). Use cate_read_terminal afterwards to read the output.", Type.Object({ panelId: Type.Optional(Type.String()), command: Type.String(), newPanel: Type.Optional(Type.Boolean()) }), "run_in_terminal") + tool("cate_read_terminal", "Read terminal output", + "Read the recent visible + scrollback output of a terminal panel as plain text (for inspecting command results). lines = how many trailing lines to return (default 50, max 1000).", + Type.Object({ panelId: Type.String(), lines: Type.Optional(Type.Number()) }), "read_terminal") tool("cate_open_url", "Open a URL", "Open or navigate a browser panel to a URL.", Type.Object({ panelId: Type.Optional(Type.String()), url: Type.String() }), "open_url") - tool("cate_reveal_in_editor", "Reveal in editor", "Open a file in the editor at a line and focus it. Set preview:true to open a markdown file straight into rendered preview.", - Type.Object({ path: Type.String(), line: Type.Optional(Type.Number()), column: Type.Optional(Type.Number()), preview: Type.Optional(Type.Boolean()) }), "reveal_in_editor") tool("cate_set_markdown_preview", "Toggle markdown preview", "Show (preview:true) or hide (preview:false) the rendered markdown preview for an open editor panel. Markdown files only.", Type.Object({ panelId: Type.String(), preview: Type.Optional(Type.Boolean()) }), "set_markdown_preview") - tool("cate_pan_to", "Pan to a panel", "Center the viewport on a panel.", Type.Object({ panelId: Type.String() }), "pan_to") - tool("cate_zoom", "Zoom", "Set zoom level (number) or 'fit'.", Type.Object({ level: Type.Union([Type.Number(), Type.Literal("fit")]) }), "zoom") } diff --git a/src/agent/renderer/cateExecutors.test.tsx b/src/agent/renderer/cateExecutors.test.tsx index 975d538e..59526d98 100644 --- a/src/agent/renderer/cateExecutors.test.tsx +++ b/src/agent/renderer/cateExecutors.test.tsx @@ -74,6 +74,16 @@ describe('execOpenPanel', () => { expect(res.ok).toBe(false) expect(res.error).toMatch(/type/i) }) + + it('focuses + centers the panel it opens so it lands in view (open-focus fix)', async () => { + const store = createCanvasStore() + // The app adds a canvas node for the new panel; simulate it for the id the + // mocked createBrowser returns so focusAndCenter has a node to act on. + store.getState().addNode('panel-br', '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('panel-br')) + }) }) describe('execClosePanel', () => { @@ -105,8 +115,8 @@ describe('execGetLayout', () => { }) }) -import { execFocusPanel, execResizePanel, execArrange, execZoom } from './cateExecutors' -import { execRunInTerminal, execOpenUrl, execRevealInEditor, execPanTo } from './cateExecutors' +import { execFocusPanel, execResizePanel, execArrange } from './cateExecutors' +import { execRunInTerminal, execOpenUrl, execReadTerminal } from './cateExecutors' vi.mock('../../renderer/lib/terminalRegistry', () => ({ terminalRegistry: { getEntry: vi.fn(() => ({ ptyId: 'pty-1' })) }, @@ -134,14 +144,6 @@ describe('management executors', () => { const node = store.getState().nodeForPanel('panel-ed')! expect(store.getState().nodes[node].size.width).toBeGreaterThan(100) }) - - it('zoom fit calls zoomToFit', async () => { - const store = createCanvasStore() - const spy = vi.spyOn(store.getState(), 'zoomToFit') - const res = await execZoom({ level: 'fit' }, ctxWith(store), 'k1') - expect(res.ok).toBe(true) - expect(spy).toHaveBeenCalled() - }) }) describe('content executors', () => { @@ -168,15 +170,27 @@ describe('content executors', () => { expect(res.ok).toBe(false) }) - it('reveal_in_editor routes through openFileAsPanel', async () => { - const res = await execRevealInEditor({ path: 'a.ts', line: 10 }, ctxWith(), 'k1') + it('read_terminal 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({ panelId: 'panel-tm' }, ctxWith(), 'k1') expect(res.ok).toBe(true) - expect(openFileAsPanel).toHaveBeenCalledWith('w1', 'a.ts') + // Trailing blank rows are trimmed. + expect((res.result as any).text).toBe('$ echo hi\nhi') + expect((res.result as any).lineCount).toBe(2) }) - it('pan_to errors on an unknown panel', async () => { - const res = await execPanTo({ panelId: 'nope' }, ctxWith(), 'k1') + it('read_terminal errors when the terminal is not live', async () => { + vi.mocked(terminalRegistry.getEntry).mockReturnValue(undefined as any) + const res = await execReadTerminal({ panelId: 'gone' }, ctxWith(), 'k1') expect(res.ok).toBe(false) + expect(res.error).toMatch(/no live terminal/i) }) }) @@ -239,8 +253,8 @@ describe('markdown preview (Issue 2 fix)', () => { expect(res.ok).toBe(false) }) - it('reveal_in_editor with preview:true turns on markdown preview', async () => { - const res = await execRevealInEditor({ path: 'README.md', preview: true }, ctxWith(), 'k1') + it('open_panel editor with target.preview:true turns on markdown preview', 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', 'panel-ed', true]) diff --git a/src/agent/renderer/cateExecutors.ts b/src/agent/renderer/cateExecutors.ts index 14f52e39..9ae35a4b 100644 --- a/src/agent/renderer/cateExecutors.ts +++ b/src/agent/renderer/cateExecutors.ts @@ -28,11 +28,20 @@ function readCanvasGeometry(ctx: CateControlContext): { occupied: Rect[]; viewpo occupied.push(rect) nodesByPanel.set(node.panelId, { nodeId: node.id, rect }) } - // Viewport center in canvas-space ≈ (-offset + screen/2)/zoom; we approximate - // with the centroid of existing nodes, falling back to origin. - const center = occupied.length - ? { 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 } - : { x: 0, y: 0 } + // 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 } } @@ -76,6 +85,10 @@ export const execOpenPanel: CateExecutor = async (params, ctx) => { if (path && (typeof target.line === 'number')) { setPendingReveal(panelId, { 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, panelId, true) + } break } case 'terminal': @@ -124,6 +137,10 @@ export const execOpenPanel: CateExecutor = async (params, ctx) => { } const node = ctx.canvasStore.getState().nodeForPanel(panelId) + // Focus + center the freshly opened panel so it lands in view. Without this the + // viewport stayed where it was and a newly-opened panel could appear off-screen + // (read as "panned to a random location"). + if (node) ctx.canvasStore.getState().focusAndCenter(node) const frame = node ? ctx.canvasStore.getState().nodes[node] : undefined return ok({ panelId, x: frame?.origin.x, y: frame?.origin.y, width: frame?.size.width, height: frame?.size.height }) } @@ -255,22 +272,6 @@ export const execOpenUrl: CateExecutor = async (params, ctx) => { return ok({ panelId, url }) } -export const execRevealInEditor: CateExecutor = async (params, ctx) => { - const path = String(params.path ?? '') - if (!path) return fail('reveal_in_editor requires a path.') - const panelId = openFileAsPanel(ctx.workspaceId, path) - if (typeof params.line === 'number') { - setPendingReveal(panelId, { line: params.line as number, column: typeof params.column === 'number' ? (params.column as number) : undefined }) - } - // Convenience: open straight into rendered markdown preview (markdown files only). - if (params.preview === true) { - useAppStore.getState().setPanelMarkdownPreview(ctx.workspaceId, panelId, true) - } - const node = ctx.canvasStore.getState().nodeForPanel(panelId) - if (node) ctx.canvasStore.getState().focusAndCenter(node) - return ok({ panelId, path }) -} - /** Toggle the rendered markdown preview for an open editor panel. The app gates * the actual render to .md files (EditorPanel), so this is a no-op visually for * non-markdown editors but still records the flag. */ @@ -287,21 +288,29 @@ export const execSetMarkdownPreview: CateExecutor = async (params, ctx) => { return ok({ panelId, preview }) } -export const execPanTo: CateExecutor = async (params, ctx) => { +/** Read the recent buffer (visible screen + scrollback) of a terminal panel as + * plain text. Lets an agent inspect command output it ran via run_in_terminal — + * the other half of terminal orchestration. Reads straight from the live xterm + * buffer; no PTY round-trip. Safe (read-only). */ +export const execReadTerminal: CateExecutor = async (params) => { const panelId = String(params.panelId ?? '') - const node = requireNode(ctx, panelId) - if (!node) return fail(`Panel not found on canvas: ${panelId}`) - ctx.canvasStore.getState().focusAndCenter(node) - return ok({ panelId }) -} + if (!panelId) return fail('read_terminal requires a 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 panel ${panelId}.`) -export const execZoom: CateExecutor = async (params, ctx) => { - const st = ctx.canvasStore.getState() - if (params.level === 'fit') { st.zoomToFit(); return ok({ zoom: 'fit' }) } - const level = Number(params.level) - if (!Number.isFinite(level)) return fail('zoom requires a numeric level or "fit".') - st.setZoom(level) - return ok({ zoom: level }) + 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({ panelId, lineCount: collected.length, text: collected.join('\n') }) } // Register everything with the dispatcher. @@ -314,9 +323,7 @@ setCateExecutors({ resize_panel: execResizePanel, arrange: execArrange, run_in_terminal: execRunInTerminal, + read_terminal: execReadTerminal, open_url: execOpenUrl, - reveal_in_editor: execRevealInEditor, set_markdown_preview: execSetMarkdownPreview, - pan_to: execPanTo, - zoom: execZoom, }) diff --git a/src/shared/cateControl.test.ts b/src/shared/cateControl.test.ts index 0ef14179..af626ece 100644 --- a/src/shared/cateControl.test.ts +++ b/src/shared/cateControl.test.ts @@ -8,9 +8,7 @@ describe('classifyCateAction', () => { expect(classifyCateAction('move_panel', { panelId: 'p' })).toBe('safe') expect(classifyCateAction('resize_panel', { panelId: 'p' })).toBe('safe') expect(classifyCateAction('arrange', { layout: 'tile' })).toBe('safe') - expect(classifyCateAction('reveal_in_editor', { path: 'a.ts' })).toBe('safe') - expect(classifyCateAction('pan_to', { panelId: 'p' })).toBe('safe') - expect(classifyCateAction('zoom', { level: 'fit' })).toBe('safe') + expect(classifyCateAction('read_terminal', { panelId: 'p' })).toBe('safe') }) it('marks destructive and network/content ops as side-effect', () => { diff --git a/src/shared/cateControl.ts b/src/shared/cateControl.ts index 22677a74..e6b2bac9 100644 --- a/src/shared/cateControl.ts +++ b/src/shared/cateControl.ts @@ -17,11 +17,9 @@ export type CateControlAction = | 'resize_panel' | 'arrange' | 'run_in_terminal' + | 'read_terminal' | 'open_url' - | 'reveal_in_editor' | 'set_markdown_preview' - | 'pan_to' - | 'zoom' /** Emitted by the extension (inside the input() title, after CATE_SENTINEL). */ export interface CateControlRequest { From b6ac76bbcd4fcaeaa9717b0515429fec34d91804 Mon Sep 17 00:00:00 2001 From: Artur Karapetyan Date: Sun, 31 May 2026 19:17:08 +0700 Subject: [PATCH 16/22] feat(agent): custom rendering + approval cards for cate-control actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review on #211 (render tool calls as custom UI, not raw JSON; make the approval workflow fit the agent panel). - cate-control calls are now surfaced in the chat thread as compact, accent-tinted CateToolCards (icon + verb + summary, expandable to params/result) instead of being silent round-trips. Status tracks running → success / denied / error. - The guarded-mode ApprovalCard renders cate actions with the same icon + a human-readable request ("Let Cate run `npm test`?") rather than a raw `cate:` name + JSON dump. - New cateToolDisplay maps (action, params) → { icon, verb, summary } and is shared by both the thread card and the approval card so they stay consistent. --- src/agent/renderer/ChatThread.tsx | 119 ++++++++++++++++++--- src/agent/renderer/agentStore.ts | 21 ++++ src/agent/renderer/cateToolDisplay.test.ts | 46 ++++++++ src/agent/renderer/cateToolDisplay.ts | 103 ++++++++++++++++++ 4 files changed, 275 insertions(+), 14 deletions(-) create mode 100644 src/agent/renderer/cateToolDisplay.test.ts create mode 100644 src/agent/renderer/cateToolDisplay.ts diff --git a/src/agent/renderer/ChatThread.tsx b/src/agent/renderer/ChatThread.tsx index 8d91a293..a4a92322 100644 --- a/src/agent/renderer/ChatThread.tsx +++ b/src/agent/renderer/ChatThread.tsx @@ -32,6 +32,7 @@ import type { ToolMessage, } from './agentStore' import { deriveDiff } from './agentStore' +import { cateToolDisplay, cateActionName } from './cateToolDisplay' interface ChatThreadProps { messages: AgentMessage[] @@ -294,6 +295,9 @@ function MessageRow({ : 'text-muted' return
{msg.text}
} + if (msg.type === 'tool' && msg.name.startsWith('cate:')) { + return + } if (msg.type === 'tool' && msg.name === 'subagent') { return } @@ -520,6 +524,67 @@ function toolVerb(msg: ToolMessage): string { } } +// ----------------------------------------------------------------------------- +// Cate-control card — custom rendering for the agent's canvas actions (open / +// move / arrange panels, run+read terminals, …). Accent-tinted to read as "Cate +// touched the workspace", with an expandable params/result body. Mirrors the +// approval card so the two share one visual language (see cateToolDisplay). +// ----------------------------------------------------------------------------- + +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 { Icon, verb, summary } = useMemo(() => cateToolDisplay(action, params), [action, params]) + + const isRunning = msg.status === 'running' || msg.status === 'pending' + const isError = msg.status === 'error' + const isDenied = msg.status === 'denied' + const hasExtras = msg.args != null || !!msg.result || !!msg.error + + const accent = isError + ? 'border-rose-500/30 bg-rose-500/[0.06]' + : isDenied + ? 'border-white/10 bg-white/[0.03]' + : 'border-agent/25 bg-agent/[0.07]' + const iconColor = isError ? 'text-rose-300' : isDenied ? 'text-muted' : 'text-agent-light' + + return ( +
+ + {expanded && hasExtras && ( +
+ {msg.args != null && ( +
+              {prettyArgs(msg.args)}
+            
+ )} + {msg.result && ( +
+              {msg.result}
+            
+ )} + {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' @@ -1106,6 +1171,45 @@ function ApprovalCard({ req: { toolCallId: string; toolName: string; args: unknown } onDecide: (decision: 'allow' | 'deny') => void }) { + const isCate = req.toolName.startsWith('cate:') + const buttons = ( +
+ + +
+ ) + + // cate-control: custom rendering matching the in-thread CateToolCard (icon + + // human-readable request) instead of a raw `cate:` + JSON dump. + if (isCate) { + const { Icon, request, summary } = cateToolDisplay( + cateActionName(req.toolName), + (req.args ?? {}) as Record, + ) + return ( +
+
+ + + Let Cate {request}{' '} + {summary}? + +
+ {buttons} +
+ ) + } + return (
@@ -1117,20 +1221,7 @@ function ApprovalCard({
         {prettyArgs(req.args)}
       
-
- - -
+ {buttons}
) } diff --git a/src/agent/renderer/agentStore.ts b/src/agent/renderer/agentStore.ts index 77f0f14e..ab0a2600 100644 --- a/src/agent/renderer/agentStore.ts +++ b/src/agent/renderer/agentStore.ts @@ -312,6 +312,9 @@ function withPanel( // dispatcher awaits these Promises; the AgentPanel approval card resolves them. const pendingCateApprovals = new Map void>() let cateApprovalSeq = 0 +// 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 @@ -1028,7 +1031,25 @@ function handleEvent(panelId: string, event: { type: string; [key: string]: unkn }) 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.denied ? 'denied' : response.ok ? 'success' : 'error', + result, + error: response.error, + }) window.electronAPI.agentUiResponse(panelId, { id, value: JSON.stringify(response) }) }) return diff --git a/src/agent/renderer/cateToolDisplay.test.ts b/src/agent/renderer/cateToolDisplay.test.ts new file mode 100644 index 00000000..7b442beb --- /dev/null +++ b/src/agent/renderer/cateToolDisplay.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest' +import { cateToolDisplay, cateActionName } from './cateToolDisplay' + +describe('cateActionName', () => { + it('strips the cate: prefix', () => { + expect(cateActionName('cate:open_panel')).toBe('open_panel') + expect(cateActionName('open_panel')).toBe('open_panel') + }) +}) + +describe('cateToolDisplay', () => { + it('summarises open_panel with the panel type and target', () => { + const d = cateToolDisplay('open_panel', { 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 run_in_terminal', () => { + const d = cateToolDisplay('run_in_terminal', { command: 'npm test' }) + expect(d.verb).toBe('Ran') + expect(d.request).toBe('run') + expect(d.summary).toBe('npm test') + }) + + it('summarises a terminal open with its command', () => { + const d = cateToolDisplay('open_panel', { 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('open_panel', { type: 'git' }) + expect(d.summary).toBe('git') + }) + + it('describes resize with its preset', () => { + const d = cateToolDisplay('resize_panel', { panelId: 'p1', preset: 'large' }) + expect(d.summary).toBe('p1 → large') + }) + + 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') + }) +}) diff --git a/src/agent/renderer/cateToolDisplay.ts b/src/agent/renderer/cateToolDisplay.ts new file mode 100644 index 00000000..437a1dbc --- /dev/null +++ b/src/agent/renderer/cateToolDisplay.ts @@ -0,0 +1,103 @@ +// ============================================================================= +// 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 and the guarded-mode ApprovalCard so the agent panel +// renders Cate's canvas actions as compact custom cards instead of raw JSON. +// Pure (no React/DOM) beyond the icon component references. +// ============================================================================= + +import { + Stack, + FileText, + FileCode, + Terminal, + Globe, + GitBranch, + TreeStructure, + SquaresFour, + X, + Crosshair, + ArrowsOutCardinal, + CornersOut, + GridFour, + Eye, + 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 (path, command, url, panelId, …). */ + summary: string +} + +function str(v: unknown): string { + return typeof v === 'string' ? v : '' +} + +const PANEL_ICONS: Record = { + editor: FileCode, + terminal: Terminal, + browser: Globe, + git: GitBranch, + fileExplorer: TreeStructure, + document: FileText, +} + +/** Strip the `cate:` prefix from a synthetic tool name, if present. */ +export function cateActionName(toolName: string): string { + return toolName.startsWith('cate:') ? toolName.slice('cate:'.length) : toolName +} + +export function cateToolDisplay( + action: string, + params: Record = {}, +): CateToolDisplay { + const p = params ?? {} + const target = (p.target ?? {}) as Record + switch (action) { + case 'get_layout': + return { Icon: Stack, verb: 'Read', request: 'read', summary: 'canvas layout' } + case 'open_panel': { + 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_panel': + return { Icon: X, verb: 'Closed', request: 'close', summary: str(p.panelId) || 'panel' } + case 'focus_panel': + return { Icon: Crosshair, verb: 'Focused', request: 'focus', summary: str(p.panelId) || 'panel' } + case 'move_panel': + return { Icon: ArrowsOutCardinal, verb: 'Moved', request: 'move', summary: str(p.panelId) || 'panel' } + case 'resize_panel': { + const size = str(p.preset) || (p.size && typeof p.size === 'object' ? 'custom' : '') + const panelId = str(p.panelId) || 'panel' + return { Icon: CornersOut, verb: 'Resized', request: 'resize', summary: size ? `${panelId} → ${size}` : panelId } + } + case 'arrange': + return { Icon: GridFour, verb: 'Arranged', request: 'arrange', summary: `panels · ${str(p.layout) || 'tile'}` } + case 'run_in_terminal': + return { Icon: Terminal, verb: 'Ran', request: 'run', summary: str(p.command) || 'command' } + case 'read_terminal': + return { Icon: Terminal, verb: 'Read', request: 'read', summary: `terminal ${str(p.panelId)}`.trim() } + case 'open_url': + return { Icon: Globe, verb: 'Opened URL', request: 'open', summary: str(p.url) || 'url' } + case 'set_markdown_preview': + return { + Icon: Eye, + verb: p.preview === false ? 'Hid preview' : 'Previewed', + request: p.preview === false ? 'hide preview for' : 'preview', + summary: str(p.panelId) || 'panel', + } + default: + return { Icon: SquaresFour, verb: 'Used', request: 'run', summary: action } + } +} From 2e453cf01e529c4f1bb87ae5aae451411839ffe6 Mon Sep 17 00:00:00 2001 From: Artur Karapetyan Date: Sun, 31 May 2026 20:42:51 +0700 Subject: [PATCH 17/22] refactor(agent): consolidate cate-control to 4 op-based tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to review feedback on #211 (Anton-Horn: "11 tools still seem like a lot of complexity / token usage"). Collapses the surface the agent sees from 11 tools to 4, grouped by concept rather than per-verb: - cate_layout {op: get|arrange} — read the canvas / rearrange panels - cate_panel {op: open|focus|move|resize|close|preview} — single-panel lifecycle - cate_browser {panelId?, url} — navigate a browser panel (room to grow) - cate_terminal {op: run|read} — run a command / read output Implementation: thin op-routers (execLayout/execPanel/execBrowser/execTerminal) delegate to the same focused executors as before, so per-op behavior and the self-protection guards (won't close/move the host agent panel) are unchanged. classifyCateAction still escalates only destructive (close) and outbound (run a command, navigate/open a remote url) ops to guarded-mode approval. `arrange` moved out of panel into `layout` (it's a canvas-wide op, not per-panel); `navigate` moved into the new `browser` tool; `editor` preview stays a panel op (thin — can be split out symmetrically with browser later if it grows). cateToolDisplay + the thread/approval cards updated for the new actions. Shared protocol, extension tool defs, executors, e2e spec, and all unit tests migrated. --- e2e/cate-control.spec.ts | 25 ++--- src/agent/extensions/cate-control/index.ts | 71 +++++++++----- src/agent/renderer/cateControl.test.ts | 22 ++--- src/agent/renderer/cateExecutors.test.tsx | 107 +++++++++++++++++++++ src/agent/renderer/cateExecutors.ts | 75 +++++++++++---- src/agent/renderer/cateToolDisplay.test.ts | 55 ++++++++--- src/agent/renderer/cateToolDisplay.ts | 81 ++++++++-------- src/shared/cateControl.test.ts | 37 ++++--- src/shared/cateControl.ts | 61 +++++++----- 9 files changed, 382 insertions(+), 152 deletions(-) diff --git a/e2e/cate-control.spec.ts b/e2e/cate-control.spec.ts index 830d5094..34ea5c90 100644 --- a/e2e/cate-control.spec.ts +++ b/e2e/cate-control.spec.ts @@ -1,8 +1,9 @@ // 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. Focused on the two fixes from live testing: -// 1. terminal commands actually run (open_panel + run_in_terminal) -// 2. markdown preview can be toggled +// then observes the live app. Uses the consolidated 4-tool surface +// (layout{op} / panel{op} / browser / terminal{op}). Focused on: +// 1. terminal commands actually run (panel open + terminal run) +// 2. markdown preview can be toggled (panel preview) import { test, expect } from '@playwright/test' import { launchApp, closeApp } from './fixtures/electron-app' import type { ElectronApplication, Page } from 'playwright' @@ -22,8 +23,8 @@ async function cate(p: Page, action: string, params: Record): P ) } -test('run_in_terminal runs the command in a live PTY', async () => { - const res = await cate(page, 'run_in_terminal', { command: 'echo $((6*7))_CATEOK', newPanel: true }) +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 panelId = res.result.panelId as string expect(panelId).toBeTruthy() @@ -37,8 +38,8 @@ test('run_in_terminal runs the command in a live PTY', async () => { .toContain('42_CATEOK') }) -test('open_panel(terminal, command) runs the command', async () => { - const res = await cate(page, 'open_panel', { type: 'terminal', target: { command: 'echo $((8*8))_CATEOPEN' } }) +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 panelId = res.result.panelId as string await expect @@ -49,8 +50,8 @@ test('open_panel(terminal, command) runs the command', async () => { .toContain('64_CATEOPEN') }) -test('set_markdown_preview toggles the editor into preview mode', async () => { - const opened = await cate(page, 'open_panel', { type: 'editor', target: { path: 'CATE_NOTES.md' } }) +test('panel preview toggles the editor into preview mode', async () => { + const opened = await cate(page, 'panel', { op: 'open', type: 'editor', target: { path: 'CATE_NOTES.md' } }) expect(opened.ok).toBe(true) const panelId = opened.result.panelId as string const nodeId = await page.evaluate( @@ -65,14 +66,14 @@ test('set_markdown_preview toggles the editor into preview mode', async () => { await expect(page.locator(`${nodeSel} button:has-text("Preview")`)).toBeVisible() // Turn preview on through the tool. - const pv = await cate(page, 'set_markdown_preview', { panelId, preview: true }) + const pv = await cate(page, 'panel', { op: 'preview', panelId, preview: true }) expect(pv.ok).toBe(true) // After: the toggle flips to "Source" — preview is now active. await expect(page.locator(`${nodeSel} button:has-text("Source")`)).toBeVisible() }) -test('set_markdown_preview rejects a non-existent panel', async () => { - const res = await cate(page, 'set_markdown_preview', { panelId: 'does-not-exist', preview: true }) +test('panel preview rejects a non-existent panel', async () => { + const res = await cate(page, 'panel', { op: 'preview', panelId: 'does-not-exist', preview: true }) expect(res.ok).toBe(false) }) diff --git a/src/agent/extensions/cate-control/index.ts b/src/agent/extensions/cate-control/index.ts index e5e3d56c..5b8829c5 100644 --- a/src/agent/extensions/cate-control/index.ts +++ b/src/agent/extensions/cate-control/index.ts @@ -36,36 +36,61 @@ export default function (pi: ExtensionAPI) { }, }) - tool("cate_get_layout", "Read Cate layout", - "Return the current canvas: open panels (id, type, title, position, size, focused, isSelf) and viewport.", - Type.Object({}), "get_layout") + tool("cate_layout", "Read or arrange the canvas", + [ + "Inspect or rearrange the whole canvas. Choose `op` (default 'get'):", + "- 'get': return the canvas — open panels (id, type, title, position, size, focused, isSelf) and viewport.", + "- 'arrange': lay panels out. {style: tile|grid|cascade|focus-one, panelIds? (limit scope)}.", + ].join("\n"), + Type.Object({ + op: Type.Optional(Type.Union([Type.Literal("get"), Type.Literal("arrange")])), + style: Type.Optional(Type.String()), + panelIds: Type.Optional(Type.Array(Type.String())), + }), "layout") - tool("cate_open_panel", "Open a panel", - "Open (or re-focus) a panel on the canvas, then center the view on it. type: editor|terminal|browser|git|fileExplorer|document. target: {path,line?,preview?} for editor (preview:true opens a markdown file straight into rendered preview); {url} for browser; {cwd?,command?} for terminal. Optional semantic placement.", + tool("cate_panel", "Open or manage a panel", + [ + "Open or manage a single canvas panel. Choose `op`:", + "- 'open': create/open a panel, focus + center it. {type: editor|terminal|browser|git|fileExplorer|document, target?, placement?}. target: {path,line?,column?,preview?} for editor (preview:true = open a markdown file straight into rendered preview); {url} for browser; {cwd?,command?} for terminal.", + "- 'focus' | 'close': {panelId}.", + "- 'move': {panelId, placement:{relativeTo,position}}.", + "- 'resize': {panelId, preset: small|medium|large} or {panelId, size:{width,height}}.", + "- 'preview': toggle a markdown editor's rendered preview. {panelId, preview?:bool (default true)}.", + "(To navigate a browser panel use cate_browser; to lay out many panels use cate_layout op:'arrange'.)", + ].join("\n"), Type.Object({ - type: Type.String(), + op: Type.Union([ + Type.Literal("open"), Type.Literal("focus"), Type.Literal("move"), + Type.Literal("resize"), Type.Literal("close"), Type.Literal("preview"), + ]), + panelId: Type.Optional(Type.String()), + 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, - }), "open_panel") + preset: Type.Optional(Type.String()), + size: Type.Optional(Type.Object({ width: Type.Number(), height: Type.Number() })), + preview: Type.Optional(Type.Boolean()), + }), "panel") + + tool("cate_browser", "Navigate a browser panel", + "Point a browser panel at a url (opens a new browser panel if no panelId). {panelId?, url}.", + Type.Object({ panelId: Type.Optional(Type.String()), url: Type.String() }), "browser") - tool("cate_close_panel", "Close a panel", "Close a panel by id.", Type.Object({ panelId: Type.String() }), "close_panel") - tool("cate_focus_panel", "Focus a panel", "Focus a panel and center it in view.", Type.Object({ panelId: Type.String() }), "focus_panel") - tool("cate_move_panel", "Move a panel", "Move a panel using semantic placement.", Type.Object({ panelId: Type.String(), placement: Placement }), "move_panel") - tool("cate_resize_panel", "Resize a panel", "Resize a panel by preset (small|medium|large) or explicit {width,height}.", - Type.Object({ panelId: Type.String(), preset: Type.Optional(Type.String()), size: Type.Optional(Type.Object({ width: Type.Number(), height: Type.Number() })) }), "resize_panel") - tool("cate_arrange", "Arrange panels", "Arrange panels: tile|grid|cascade|focus-one. Optional panelIds to limit scope.", - Type.Object({ layout: Type.String(), panelIds: Type.Optional(Type.Array(Type.String())) }), "arrange") - tool("cate_run_in_terminal", "Run in terminal", "Run a shell command in a terminal panel (opens one if newPanel). Use cate_read_terminal afterwards to read the output.", - Type.Object({ panelId: Type.Optional(Type.String()), command: Type.String(), newPanel: Type.Optional(Type.Boolean()) }), "run_in_terminal") - tool("cate_read_terminal", "Read terminal output", - "Read the recent visible + scrollback output of a terminal panel as plain text (for inspecting command results). lines = how many trailing lines to return (default 50, max 1000).", - Type.Object({ panelId: Type.String(), lines: Type.Optional(Type.Number()) }), "read_terminal") - tool("cate_open_url", "Open a URL", "Open or navigate a browser panel to a URL.", - Type.Object({ panelId: Type.Optional(Type.String()), url: Type.String() }), "open_url") - tool("cate_set_markdown_preview", "Toggle markdown preview", "Show (preview:true) or hide (preview:false) the rendered markdown preview for an open editor panel. Markdown files only.", - Type.Object({ panelId: Type.String(), preview: Type.Optional(Type.Boolean()) }), "set_markdown_preview") + tool("cate_terminal", "Run or read a terminal", + [ + "Drive a terminal panel. Choose `op`:", + "- 'run': run a shell command. {command, panelId? (reuse an existing terminal), newPanel?:bool (force a fresh one)}.", + "- 'read': read recent output (visible screen + scrollback) as text. {panelId, lines?:number (trailing lines, default 50, max 1000)}.", + ].join("\n"), + Type.Object({ + op: Type.Union([Type.Literal("run"), Type.Literal("read")]), + panelId: 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/renderer/cateControl.test.ts b/src/agent/renderer/cateControl.test.ts index 2e5bf76b..4ae2b64d 100644 --- a/src/agent/renderer/cateControl.test.ts +++ b/src/agent/renderer/cateControl.test.ts @@ -31,13 +31,13 @@ describe('dispatchCateRequest', () => { it('errors when the feature is globally disabled', async () => { useSettingsStore.setState({ cateControlEnabled: false } as any) registerCateContext('k1', { workspaceId: 'w1', hostPanelId: 'p1', canvasStore: fakeCanvasStore() }) - const res = await dispatchCateRequest('k1', { action: 'get_layout', params: {} }) + 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: 'get_layout', params: {} }) + const res = await dispatchCateRequest('unknown', { action: 'layout', params: {} }) expect(res.ok).toBe(false) expect(res.error).toMatch(/not registered|no context/i) }) @@ -45,8 +45,8 @@ describe('dispatchCateRequest', () => { it('runs safe actions immediately without approval', async () => { registerCateContext('k1', { workspaceId: 'w1', hostPanelId: 'p1', canvasStore: fakeCanvasStore() }) const exec = vi.fn().mockResolvedValue({ ok: true, result: { panels: [] } }) - __setExecutorsForTest({ get_layout: exec } as any) - const res = await dispatchCateRequest('k1', { action: 'get_layout', params: {} }) + __setExecutorsForTest({ layout: exec } as any) + const res = await dispatchCateRequest('k1', { action: 'layout', params: {} }) expect(exec).toHaveBeenCalledTimes(1) expect(res).toEqual({ ok: true, result: { panels: [] } }) }) @@ -55,8 +55,8 @@ describe('dispatchCateRequest', () => { registerCateContext('k1', { workspaceId: 'w1', hostPanelId: 'p1', canvasStore: fakeCanvasStore() }) useAgentStore.getState().setCateControlMode('k1', 'auto') const exec = vi.fn().mockResolvedValue({ ok: true }) - __setExecutorsForTest({ close_panel: exec } as any) - const res = await dispatchCateRequest('k1', { action: 'close_panel', params: { panelId: 'x' } }) + __setExecutorsForTest({ panel: exec } as any) + const res = await dispatchCateRequest('k1', { action: 'panel', params: { op: 'close', panelId: 'x' } }) expect(exec).toHaveBeenCalledTimes(1) expect(res.ok).toBe(true) }) @@ -66,17 +66,17 @@ describe('dispatchCateRequest', () => { registerCateContext('k1', { workspaceId: 'w1', hostPanelId: 'p1', canvasStore: fakeCanvasStore(), requestApproval }) useAgentStore.getState().setCateControlMode('k1', 'guarded') const exec = vi.fn().mockResolvedValue({ ok: true }) - __setExecutorsForTest({ close_panel: exec } as any) - const res = await dispatchCateRequest('k1', { action: 'close_panel', params: { panelId: 'x' } }) - expect(requestApproval).toHaveBeenCalledWith('close_panel', { panelId: 'x' }) + __setExecutorsForTest({ panel: exec } as any) + const res = await dispatchCateRequest('k1', { action: 'panel', params: { op: 'close', panelId: 'x' } }) + expect(requestApproval).toHaveBeenCalledWith('panel', { op: 'close', panelId: 'x' }) expect(exec).not.toHaveBeenCalled() expect(res).toEqual({ ok: false, denied: true }) }) it('catches executor errors and returns them', async () => { registerCateContext('k1', { workspaceId: 'w1', hostPanelId: 'p1', canvasStore: fakeCanvasStore() }) - __setExecutorsForTest({ get_layout: vi.fn().mockRejectedValue(new Error('boom')) } as any) - const res = await dispatchCateRequest('k1', { action: 'get_layout', params: {} }) + __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/cateExecutors.test.tsx b/src/agent/renderer/cateExecutors.test.tsx index 59526d98..a4b2ebe6 100644 --- a/src/agent/renderer/cateExecutors.test.tsx +++ b/src/agent/renderer/cateExecutors.test.tsx @@ -117,6 +117,7 @@ describe('execGetLayout', () => { import { execFocusPanel, execResizePanel, execArrange } from './cateExecutors' import { execRunInTerminal, execOpenUrl, execReadTerminal } from './cateExecutors' +import { execLayout, execPanel, execBrowser, execTerminal } from './cateExecutors' vi.mock('../../renderer/lib/terminalRegistry', () => ({ terminalRegistry: { getEntry: vi.fn(() => ({ ptyId: 'pty-1' })) }, @@ -260,3 +261,109 @@ describe('markdown preview (Issue 2 fix)', () => { expect(mod.__created.find((c: any[]) => c[0] === 'mdpreview')).toEqual(['mdpreview', 'w1', 'panel-ed', true]) }) }) + +// --------------------------------------------------------------------------- +// Consolidated op routers (4-tool surface: layout / panel / browser / terminal) +// --------------------------------------------------------------------------- + +describe('execLayout (op router)', () => { + it("defaults to reading the canvas layout", async () => { + const store = createCanvasStore() + store.getState().addNode('panel-ed', 'editor', { x: 0, y: 0 }, { width: 100, height: 100 }) + const res = await execLayout({}, ctxWith(store), 'k1') + expect(res.ok).toBe(true) + expect((res.result as any).panels).toBeDefined() + }) + + it("routes op:'arrange' to arrange panels with the given style", async () => { + const store = createCanvasStore() + store.getState().addNode('p1', 'editor', { x: 0, y: 0 }, { width: 100, height: 100 }) + store.getState().addNode('p2', 'editor', { x: 300, y: 0 }, { width: 100, height: 100 }) + const res = await execLayout({ op: 'arrange', style: 'grid' }, ctxWith(store), 'k1') + expect(res.ok).toBe(true) + expect((res.result as any).layout).toBe('grid') + }) +}) + +describe('execBrowser', () => { + beforeEach(async () => { + const mod: any = await import('../../renderer/stores/appStore') + mod.__created.length = 0 + }) + + it('opens a browser at a url when no panelId is given', async () => { + const res = await execBrowser({ url: 'https://example.com' }, ctxWith(), 'k1') + expect(res.ok).toBe(true) + expect((res.result as any).panelId).toBe('panel-br') + }) + + it('rejects a non-url', async () => { + const res = await execBrowser({ url: 'not a url' }, ctxWith(), 'k1') + expect(res.ok).toBe(false) + }) +}) + +describe('execPanel (op router)', () => { + beforeEach(async () => { + const mod: any = await import('../../renderer/stores/appStore') + mod.__created.length = 0 + mod.__closed.length = 0 + }) + + it("routes op:'open' to open a panel", 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).panelId).toBe('panel-ed') + }) + + it("routes op:'close' to close a panel", async () => { + const res = await execPanel({ op: 'close', panelId: 'panel-ed' }, ctxWith(), 'k1') + expect(res.ok).toBe(true) + const mod: any = await import('../../renderer/stores/appStore') + expect(mod.__closed[0]).toEqual(['w1', 'panel-ed']) + }) + + it("routes op:'focus' to focus a node", async () => { + const store = createCanvasStore() + store.getState().addNode('panel-ed', 'editor', { x: 0, y: 0 }, { width: 100, height: 100 }) + const res = await execPanel({ op: 'focus', panelId: 'panel-ed' }, ctxWith(store), 'k1') + expect(res.ok).toBe(true) + expect(store.getState().focusedNodeId).toBe(store.getState().nodeForPanel('panel-ed')) + }) + + it('rejects an unknown op', async () => { + const res = await execPanel({ op: 'teleport', panelId: 'x' }, ctxWith(), 'k1') + expect(res.ok).toBe(false) + expect(res.error).toMatch(/unknown op/i) + }) +}) + +describe('execTerminal (op router)', () => { + beforeEach(() => { + ;(window.electronAPI as any).terminalWrite = vi.fn() + vi.mocked(terminalRegistry.getEntry).mockReturnValue({ ptyId: 'pty-1' } as any) + }) + + it("routes op:'run' to run a command", async () => { + 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("routes op:'read' to read the terminal buffer", 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', panelId: 'panel-tm' }, ctxWith(), 'k1') + expect(res.ok).toBe(true) + expect((res.result as any).text).toBe('output line') + }) + + it('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 index 9ae35a4b..cfc631ff 100644 --- a/src/agent/renderer/cateExecutors.ts +++ b/src/agent/renderer/cateExecutors.ts @@ -229,7 +229,8 @@ export const execResizePanel: CateExecutor = async (params, ctx) => { } export const execArrange: CateExecutor = async (params, ctx) => { - const layout = String(params.layout ?? 'tile') as 'tile' | 'grid' | 'cascade' | 'focus-one' + // `layout` tool exposes the style as `style`; accept legacy `layout` too. + const layout = String(params.style ?? params.layout ?? 'tile') as 'tile' | 'grid' | 'cascade' | 'focus-one' const st = ctx.canvasStore.getState() const all = Object.values(st.nodes).filter((n: any) => n.panelId !== ctx.hostPanelId) // self-protection const requested = Array.isArray(params.panelIds) ? (params.panelIds as string[]) : null @@ -251,7 +252,7 @@ export const execArrange: CateExecutor = async (params, ctx) => { export const execRunInTerminal: CateExecutor = async (params, ctx) => { const command = String(params.command ?? '') - if (!command.trim()) return fail('run_in_terminal requires a non-empty command.') + if (!command.trim()) return fail('terminal run requires a non-empty command.') const app = useAppStore.getState() let panelId = typeof params.panelId === 'string' ? params.panelId : '' if (!panelId || params.newPanel) { @@ -264,7 +265,7 @@ export const execRunInTerminal: CateExecutor = async (params, ctx) => { export const execOpenUrl: CateExecutor = async (params, ctx) => { const url = String(params.url ?? '') - if (!/^(https?|file):\/\//i.test(url)) return fail('open_url requires an http(s) or file URL.') + if (!/^(https?|file):\/\//i.test(url)) return fail('browser navigate requires an http(s) or file URL.') const app = useAppStore.getState() let panelId = typeof params.panelId === 'string' ? params.panelId : '' if (!panelId) { panelId = app.createBrowser(ctx.workspaceId, url); return ok({ panelId, url }) } @@ -277,7 +278,7 @@ export const execOpenUrl: CateExecutor = async (params, ctx) => { * non-markdown editors but still records the flag. */ export const execSetMarkdownPreview: CateExecutor = async (params, ctx) => { const panelId = String(params.panelId ?? '') - if (!panelId) return fail('set_markdown_preview requires a panelId.') + if (!panelId) return fail('panel preview requires a panelId.') const app = useAppStore.getState() const ws = app.workspaces.find((w: any) => w.id === ctx.workspaceId) const panel = ws?.panels?.[panelId] @@ -289,12 +290,12 @@ export const execSetMarkdownPreview: CateExecutor = async (params, ctx) => { } /** Read the recent buffer (visible screen + scrollback) of a terminal panel as - * plain text. Lets an agent inspect command output it ran via run_in_terminal — + * plain text. Lets an agent inspect command output it ran via terminal run — * the other half of terminal orchestration. Reads straight from the live xterm * buffer; no PTY round-trip. Safe (read-only). */ export const execReadTerminal: CateExecutor = async (params) => { const panelId = String(params.panelId ?? '') - if (!panelId) return fail('read_terminal requires a panelId.') + if (!panelId) return fail('terminal read requires a 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 panel ${panelId}.`) @@ -313,17 +314,55 @@ export const execReadTerminal: CateExecutor = async (params) => { return ok({ panelId, lineCount: collected.length, text: collected.join('\n') }) } -// Register everything with the dispatcher. +// --------------------------------------------------------------------------- +// Consolidated op-routers — the agent sees four tools (layout / panel / browser +// / terminal); each dispatches to the focused executors above by `op`. Keeps the +// tool surface (and its token cost) small while preserving per-op behavior + +// self-protection. +// --------------------------------------------------------------------------- + +/** Canvas-wide: read the layout (default) or rearrange panels. */ +export const execLayout: CateExecutor = async (params, ctx, agentKey) => { + return String(params.op ?? 'get') === 'arrange' + ? execArrange(params, ctx, agentKey) + : execGetLayout(params, ctx, agentKey) +} + +/** 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 'focus': return execFocusPanel(params, ctx, agentKey) + case 'move': return execMovePanel(params, ctx, agentKey) + case 'resize': return execResizePanel(params, ctx, agentKey) + case 'close': return execClosePanel(params, ctx, agentKey) + case 'preview': return execSetMarkdownPreview(params, ctx, agentKey) + default: + return fail(`panel: unknown op "${op}". Expected open|focus|move|resize|close|preview.`) + } +} + +/** Browser content: navigate a browser panel to a url (creates one if needed). */ +export const execBrowser: CateExecutor = async (params, ctx, agentKey) => { + return execOpenUrl(params, ctx, agentKey) +} + +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. The routers delegate to the +// focused executors above. setCateExecutors({ - get_layout: execGetLayout, - open_panel: execOpenPanel, - close_panel: execClosePanel, - focus_panel: execFocusPanel, - move_panel: execMovePanel, - resize_panel: execResizePanel, - arrange: execArrange, - run_in_terminal: execRunInTerminal, - read_terminal: execReadTerminal, - open_url: execOpenUrl, - set_markdown_preview: execSetMarkdownPreview, + layout: execLayout, + panel: execPanel, + browser: execBrowser, + terminal: execTerminal, }) diff --git a/src/agent/renderer/cateToolDisplay.test.ts b/src/agent/renderer/cateToolDisplay.test.ts index 7b442beb..1399d8e4 100644 --- a/src/agent/renderer/cateToolDisplay.test.ts +++ b/src/agent/renderer/cateToolDisplay.test.ts @@ -3,40 +3,73 @@ import { cateToolDisplay, cateActionName } from './cateToolDisplay' describe('cateActionName', () => { it('strips the cate: prefix', () => { - expect(cateActionName('cate:open_panel')).toBe('open_panel') - expect(cateActionName('open_panel')).toBe('open_panel') + expect(cateActionName('cate:panel')).toBe('panel') + expect(cateActionName('panel')).toBe('panel') }) }) describe('cateToolDisplay', () => { - it('summarises open_panel with the panel type and target', () => { - const d = cateToolDisplay('open_panel', { type: 'editor', target: { path: 'src/main/index.ts' } }) + it('reads the canvas for layout with no op', () => { + const d = cateToolDisplay('layout', {}) + expect(d.verb).toBe('Read') + expect(d.summary).toBe('canvas layout') + }) + + it('summarises a layout arrange with its style', () => { + const d = cateToolDisplay('layout', { op: 'arrange', style: 'grid' }) + expect(d.verb).toBe('Arranged') + expect(d.summary).toBe('panels · grid') + }) + + it('summarises a browser navigate with its url', () => { + const d = cateToolDisplay('browser', { panelId: 'p1', url: 'https://example.com' }) + expect(d.verb).toBe('Navigated') + expect(d.request).toBe('navigate') + expect(d.summary).toBe('https://example.com') + }) + + 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 run_in_terminal', () => { - const d = cateToolDisplay('run_in_terminal', { command: 'npm test' }) + 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 open with its command', () => { - const d = cateToolDisplay('open_panel', { type: 'terminal', target: { command: 'ls -la' } }) + it('summarises a terminal read by panelId', () => { + const d = cateToolDisplay('terminal', { op: 'read', panelId: 'p1' }) + expect(d.verb).toBe('Read') + expect(d.summary).toBe('terminal p1') + }) + + 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('open_panel', { type: 'git' }) + const d = cateToolDisplay('panel', { op: 'open', type: 'git' }) expect(d.summary).toBe('git') }) - it('describes resize with its preset', () => { - const d = cateToolDisplay('resize_panel', { panelId: 'p1', preset: 'large' }) + it('describes a resize op with its preset', () => { + const d = cateToolDisplay('panel', { op: 'resize', panelId: 'p1', preset: 'large' }) + expect(d.verb).toBe('Resized') expect(d.summary).toBe('p1 → large') }) + it('describes a close op', () => { + const d = cateToolDisplay('panel', { op: 'close', panelId: 'p1' }) + expect(d.verb).toBe('Closed') + expect(d.request).toBe('close') + expect(d.summary).toBe('p1') + }) + it('always returns a usable icon + verb + summary, even for unknown actions', () => { const d = cateToolDisplay('not_a_real_action', {}) expect(d.Icon).toBeTruthy() diff --git a/src/agent/renderer/cateToolDisplay.ts b/src/agent/renderer/cateToolDisplay.ts index 437a1dbc..c4938962 100644 --- a/src/agent/renderer/cateToolDisplay.ts +++ b/src/agent/renderer/cateToolDisplay.ts @@ -57,47 +57,50 @@ export function cateToolDisplay( params: Record = {}, ): CateToolDisplay { const p = params ?? {} - const target = (p.target ?? {}) as Record - switch (action) { - case 'get_layout': - return { Icon: Stack, verb: 'Read', request: 'read', summary: 'canvas layout' } - case 'open_panel': { - 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_panel': - return { Icon: X, verb: 'Closed', request: 'close', summary: str(p.panelId) || 'panel' } - case 'focus_panel': - return { Icon: Crosshair, verb: 'Focused', request: 'focus', summary: str(p.panelId) || 'panel' } - case 'move_panel': - return { Icon: ArrowsOutCardinal, verb: 'Moved', request: 'move', summary: str(p.panelId) || 'panel' } - case 'resize_panel': { - const size = str(p.preset) || (p.size && typeof p.size === 'object' ? 'custom' : '') - const panelId = str(p.panelId) || 'panel' - return { Icon: CornersOut, verb: 'Resized', request: 'resize', summary: size ? `${panelId} → ${size}` : panelId } + if (action === 'layout') { + if (str(p.op) === 'arrange') { + return { Icon: GridFour, verb: 'Arranged', request: 'arrange', summary: `panels · ${str(p.style) || str(p.layout) || 'tile'}` } } - case 'arrange': - return { Icon: GridFour, verb: 'Arranged', request: 'arrange', summary: `panels · ${str(p.layout) || 'tile'}` } - case 'run_in_terminal': - return { Icon: Terminal, verb: 'Ran', request: 'run', summary: str(p.command) || 'command' } - case 'read_terminal': + return { Icon: Stack, verb: 'Read', request: 'read', summary: 'canvas layout' } + } + if (action === 'browser') { + return { Icon: Globe, verb: 'Navigated', request: 'navigate', summary: str(p.url) || str(p.panelId) || 'browser' } + } + if (action === 'terminal') { + if (str(p.op) === 'read') { return { Icon: Terminal, verb: 'Read', request: 'read', summary: `terminal ${str(p.panelId)}`.trim() } - case 'open_url': - return { Icon: Globe, verb: 'Opened URL', request: 'open', summary: str(p.url) || 'url' } - case 'set_markdown_preview': - return { - Icon: Eye, - verb: p.preview === false ? 'Hid preview' : 'Previewed', - request: p.preview === false ? 'hide preview for' : 'preview', - summary: str(p.panelId) || '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 } } - default: - return { Icon: SquaresFour, verb: 'Used', request: 'run', summary: action } + case 'focus': + return { Icon: Crosshair, verb: 'Focused', request: 'focus', summary: str(p.panelId) || 'panel' } + case 'move': + return { Icon: ArrowsOutCardinal, verb: 'Moved', request: 'move', summary: str(p.panelId) || 'panel' } + case 'resize': { + const size = str(p.preset) || (p.size && typeof p.size === 'object' ? 'custom' : '') + const panelId = str(p.panelId) || 'panel' + return { Icon: CornersOut, verb: 'Resized', request: 'resize', summary: size ? `${panelId} → ${size}` : panelId } + } + case 'close': + return { Icon: X, verb: 'Closed', request: 'close', summary: str(p.panelId) || 'panel' } + case 'preview': + return { + Icon: Eye, + verb: p.preview === false ? 'Hid preview' : 'Previewed', + request: p.preview === false ? 'hide preview for' : 'preview', + summary: str(p.panelId) || 'panel', + } + default: + return { Icon: SquaresFour, verb: 'Panel', request: 'manage', summary: str(p.op) || str(p.panelId) || 'panel' } + } } + return { Icon: SquaresFour, verb: 'Used', request: 'run', summary: action } } diff --git a/src/shared/cateControl.test.ts b/src/shared/cateControl.test.ts index af626ece..0a49e1ab 100644 --- a/src/shared/cateControl.test.ts +++ b/src/shared/cateControl.test.ts @@ -2,26 +2,31 @@ import { describe, it, expect } from 'vitest' import { classifyCateAction, CATE_SENTINEL } from './cateControl' describe('classifyCateAction', () => { - it('marks queries and layout ops as safe', () => { - expect(classifyCateAction('get_layout', {})).toBe('safe') - expect(classifyCateAction('focus_panel', { panelId: 'p' })).toBe('safe') - expect(classifyCateAction('move_panel', { panelId: 'p' })).toBe('safe') - expect(classifyCateAction('resize_panel', { panelId: 'p' })).toBe('safe') - expect(classifyCateAction('arrange', { layout: 'tile' })).toBe('safe') - expect(classifyCateAction('read_terminal', { panelId: 'p' })).toBe('safe') + it('marks reads, focus and pure layout ops as safe', () => { + expect(classifyCateAction('layout', {})).toBe('safe') + expect(classifyCateAction('layout', { op: 'arrange', style: 'tile' })).toBe('safe') + expect(classifyCateAction('terminal', { op: 'read', panelId: 'p' })).toBe('safe') + expect(classifyCateAction('panel', { op: 'focus', panelId: 'p' })).toBe('safe') + expect(classifyCateAction('panel', { op: 'move', panelId: 'p' })).toBe('safe') + expect(classifyCateAction('panel', { op: 'resize', panelId: 'p' })).toBe('safe') + expect(classifyCateAction('panel', { op: 'preview', panelId: 'p' })).toBe('safe') }) - it('marks destructive and network/content ops as side-effect', () => { - expect(classifyCateAction('close_panel', { panelId: 'p' })).toBe('side-effect') - expect(classifyCateAction('run_in_terminal', { command: 'ls' })).toBe('side-effect') - expect(classifyCateAction('open_url', { url: 'https://x.com' })).toBe('side-effect') + it('marks destructive and outbound ops as side-effect', () => { + expect(classifyCateAction('panel', { op: 'close', panelId: 'p' })).toBe('side-effect') + expect(classifyCateAction('browser', { panelId: 'p', url: 'https://x.com' })).toBe('side-effect') + expect(classifyCateAction('terminal', { op: 'run', command: 'ls' })).toBe('side-effect') }) - it('treats open_panel as safe unless it carries an auto-run command or a remote url', () => { - expect(classifyCateAction('open_panel', { type: 'editor' })).toBe('safe') - expect(classifyCateAction('open_panel', { type: 'terminal', target: { command: 'npm test' } })).toBe('side-effect') - expect(classifyCateAction('open_panel', { type: 'browser', target: { url: 'https://x.com' } })).toBe('side-effect') - expect(classifyCateAction('open_panel', { type: 'browser', target: { url: 'file:///tmp/x.html' } })).toBe('safe') + it('treats panel open as safe unless it carries an auto-run command or a remote url', () => { + expect(classifyCateAction('panel', { op: 'open', type: 'editor' })).toBe('safe') + expect(classifyCateAction('panel', { op: 'open', type: 'terminal', target: { command: 'npm test' } })).toBe('side-effect') + expect(classifyCateAction('panel', { op: 'open', type: 'browser', target: { url: 'https://x.com' } })).toBe('side-effect') + expect(classifyCateAction('panel', { op: 'open', type: 'browser', target: { url: 'file:///tmp/x.html' } })).toBe('safe') + }) + + it('treats a browser navigate to a local file url as safe', () => { + expect(classifyCateAction('browser', { panelId: 'p', url: 'file:///tmp/x.html' })).toBe('safe') }) it('exposes a stable sentinel string', () => { diff --git a/src/shared/cateControl.ts b/src/shared/cateControl.ts index e6b2bac9..23c7af82 100644 --- a/src/shared/cateControl.ts +++ b/src/shared/cateControl.ts @@ -9,17 +9,25 @@ export const CATE_SENTINEL = '@@cate-control@@' export type CateControlAction = - | 'get_layout' - | 'open_panel' - | 'close_panel' - | 'focus_panel' - | 'move_panel' - | 'resize_panel' - | 'arrange' - | 'run_in_terminal' - | 'read_terminal' - | 'open_url' - | 'set_markdown_preview' + | 'layout' + | 'panel' + | 'browser' + | 'terminal' + +/** Sub-operations of the canvas-wide `layout` tool (read + rearrange). */ +export type LayoutOp = 'get' | 'arrange' + +/** Sub-operations of the per-panel `panel` tool. */ +export type PanelOp = + | 'open' + | 'focus' + | 'move' + | 'resize' + | 'close' + | 'preview' + +/** 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 { @@ -44,24 +52,33 @@ function isRemoteUrl(url: unknown): boolean { return /^https?:\/\//i.test(url) } -/** Static classification + per-call escalation for open_panel. Drives whether - * guarded mode requires approval. Pure — no side effects. */ +/** Static classification + per-call escalation. Drives whether guarded mode + * requires approval. Pure — no side effects. Only destructive (close) and + * outbound (run a command, open/navigate a remote url) ops escalate; reads, + * focus, and pure layout stay safe. */ export function classifyCateAction( action: CateControlAction, params: Record, ): CateActionClass { switch (action) { - case 'close_panel': - case 'run_in_terminal': - case 'open_url': - return 'side-effect' - case 'open_panel': { - const target = (params.target ?? {}) as Record - if (typeof target.command === 'string' && target.command.trim()) return 'side-effect' - if (isRemoteUrl(target.url)) return 'side-effect' + case 'terminal': + // run a command = side-effect; read output = safe. + return String(params.op ?? '') === 'read' ? 'safe' : 'side-effect' + case 'browser': + // navigating to a remote url sends a request; a local file:// preview is safe. + return isRemoteUrl(params.url) ? 'side-effect' : 'safe' + case 'panel': { + const op = String(params.op ?? '') + if (op === 'open') { + const target = (params.target ?? {}) as Record + if (typeof target.command === 'string' && target.command.trim()) return 'side-effect' + if (isRemoteUrl(target.url)) return 'side-effect' + return 'safe' + } + if (op === 'close') return 'side-effect' return 'safe' } default: - return 'safe' + return 'safe' // layout (get / arrange) — never destructive or outbound } } From 29116777b89d0137b0044e1e35a908c31d06d20b Mon Sep 17 00:00:00 2001 From: Artur Karapetyan Date: Sun, 31 May 2026 20:52:05 +0700 Subject: [PATCH 18/22] fix(agent): refresh installed cate-control extension when it changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extension is copied into each workspace's pi-agent extensions dir, where pi loads it at agent start. installCateControl used copyIfMissing (skip-if-exists), so once installed the copy never refreshed — after the toolset was consolidated the agent kept loading the OLD extension and emitted action names (open_panel, close_panel, …) the renderer dispatcher no longer handles, so every cate tool call failed with "Unknown or unimplemented action". This is also a latent prod bug: shipping a new extension version would never reach users who already had an older copy installed. Fix: copyIfChanged overwrites the installed copy whenever its bytes differ from the bundled source. The extension's action protocol is coupled to the renderer, so the bundled copy is authoritative — there's no user-customization to preserve. Adds a unit test (missing → write, differing → overwrite, identical → skip). Note: the e2e suite drives the renderer dispatcher directly (window.__cateE2E), bypassing the installed extension, which is why it didn't catch the skew. --- src/agent/main/installCateControl.test.ts | 53 +++++++++++++++++++++++ src/agent/main/installCateControl.ts | 30 ++++++++----- 2 files changed, 73 insertions(+), 10 deletions(-) create mode 100644 src/agent/main/installCateControl.test.ts 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 index 8c002fe8..01159d18 100644 --- a/src/agent/main/installCateControl.ts +++ b/src/agent/main/installCateControl.ts @@ -1,6 +1,6 @@ // ============================================================================= // installCateControl — copy the bundled cate-control extension into a -// workspace's pi-agent extensions dir on first use, where pi auto-discovers it. +// 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. @@ -10,7 +10,13 @@ // electron-builder.yml `extraResources`, so we resolve from // process.resourcesPath there. // -// Skip-if-exists: never overwrite a user's modified copy. +// 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' @@ -35,14 +41,18 @@ function sourceDir(): string | null { return null } -async function copyIfMissing(src: string, dest: string): Promise { +/** 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 { - await fsp.access(dest) - return // already present - } catch { /* fall through */ } + 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.copyFile(src, dest) - log.info('[installCateControl] installed %s', dest) + await fsp.writeFile(dest, srcData) + log.info('[installCateControl] installed/updated %s', dest) } /** Idempotent — safe to call from AgentManager.create() on every session. */ @@ -57,8 +67,8 @@ export async function installCateControlExtension(cwd: string): Promise { return } const destDir = path.join(home, 'extensions', 'cate-control') - await copyIfMissing(path.join(src, 'index.ts'), path.join(destDir, 'index.ts')) - await copyIfMissing(path.join(src, 'package.json'), path.join(destDir, 'package.json')) + 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) } From ba7bbcb15136a8a928d18e37bd5069b2f8e08219 Mon Sep 17 00:00:00 2001 From: Artur Karapetyan Date: Mon, 1 Jun 2026 00:32:21 +0700 Subject: [PATCH 19/22] fix(agent): drop removed git/fileExplorer panel types from cate-control PR #226 removed git, fileExplorer, projectList from PanelType and deleted createGit/createFileExplorer from AppStore. Remove them from cateExecutors OPENABLE list and execOpenPanel switch to fix typecheck. --- src/agent/renderer/cateExecutors.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/agent/renderer/cateExecutors.ts b/src/agent/renderer/cateExecutors.ts index cfc631ff..2afb8d11 100644 --- a/src/agent/renderer/cateExecutors.ts +++ b/src/agent/renderer/cateExecutors.ts @@ -13,7 +13,7 @@ import { computePlacement, type Rect } from '../../renderer/lib/cateControlLayou import { openFileAsPanel } from '../../renderer/lib/fileRouting' import { setPendingReveal } from '../../renderer/lib/editorReveal' -const OPENABLE: PanelType[] = ['editor', 'terminal', 'browser', 'git', 'fileExplorer', 'document'] +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 } } @@ -98,12 +98,6 @@ export const execOpenPanel: CateExecutor = async (params, ctx) => { case 'browser': panelId = app.createBrowser(wsId, typeof target.url === 'string' ? target.url : undefined) break - case 'git': - panelId = app.createGit(wsId) - break - case 'fileExplorer': - panelId = app.createFileExplorer(wsId) - break case 'document': panelId = typeof target.path === 'string' ? openFileAsPanel(wsId, target.path) : app.createEditor(wsId) break From f997cc4dfe554e793e124e2465f2d0fb96eab237 Mon Sep 17 00:00:00 2001 From: Artur Karapetyan Date: Mon, 1 Jun 2026 00:36:25 +0700 Subject: [PATCH 20/22] refactor(agent): purge removed git/fileExplorer panel types from cate-control PR #226 dropped git/fileExplorer/projectList panels. Beyond the executor switch, clean up the rest of cate-control's references to them: - tool schema description no longer advertises git|fileExplorer as openable - cateToolDisplay drops their icon entries (+ unused GitBranch/TreeStructure imports) - tests drop the dead createGit/createFileExplorer mocks and the git example --- src/agent/extensions/cate-control/index.ts | 2 +- src/agent/renderer/cateExecutors.test.tsx | 2 -- src/agent/renderer/cateToolDisplay.test.ts | 4 ++-- src/agent/renderer/cateToolDisplay.ts | 4 ---- 4 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/agent/extensions/cate-control/index.ts b/src/agent/extensions/cate-control/index.ts index 5b8829c5..eb38b947 100644 --- a/src/agent/extensions/cate-control/index.ts +++ b/src/agent/extensions/cate-control/index.ts @@ -51,7 +51,7 @@ export default function (pi: ExtensionAPI) { tool("cate_panel", "Open or manage a panel", [ "Open or manage a single canvas panel. Choose `op`:", - "- 'open': create/open a panel, focus + center it. {type: editor|terminal|browser|git|fileExplorer|document, target?, placement?}. target: {path,line?,column?,preview?} for editor (preview:true = open a markdown file straight into rendered preview); {url} for browser; {cwd?,command?} for terminal.", + "- 'open': create/open a panel, focus + center it. {type: editor|terminal|browser|document, target?, placement?}. target: {path,line?,column?,preview?} for editor (preview:true = open a markdown file straight into rendered preview); {url} for browser; {cwd?,command?} for terminal.", "- 'focus' | 'close': {panelId}.", "- 'move': {panelId, placement:{relativeTo,position}}.", "- 'resize': {panelId, preset: small|medium|large} or {panelId, size:{width,height}}.", diff --git a/src/agent/renderer/cateExecutors.test.tsx b/src/agent/renderer/cateExecutors.test.tsx index a4b2ebe6..8cd88050 100644 --- a/src/agent/renderer/cateExecutors.test.tsx +++ b/src/agent/renderer/cateExecutors.test.tsx @@ -32,8 +32,6 @@ vi.mock('../../renderer/stores/appStore', () => { createTerminal: (...a: any[]) => { created.push(['terminal', ...a]); return 'panel-tm' }, createBrowser: (...a: any[]) => { created.push(['browser', ...a]); return 'panel-br' }, createDocument: (...a: any[]) => { created.push(['document', ...a]); return 'panel-doc' }, - createGit: (...a: any[]) => { created.push(['git', ...a]); return 'panel-gt' }, - createFileExplorer: (...a: any[]) => { created.push(['fileExplorer', ...a]); return 'panel-fe' }, closePanel: (...a: any[]) => { closed.push(a) }, updatePanelUrl: (...a: any[]) => { created.push(['url', ...a]) }, setPanelMarkdownPreview: (...a: any[]) => { created.push(['mdpreview', ...a]) }, diff --git a/src/agent/renderer/cateToolDisplay.test.ts b/src/agent/renderer/cateToolDisplay.test.ts index 1399d8e4..f5dd4b82 100644 --- a/src/agent/renderer/cateToolDisplay.test.ts +++ b/src/agent/renderer/cateToolDisplay.test.ts @@ -53,8 +53,8 @@ describe('cateToolDisplay', () => { }) it('falls back to the panel type when no target detail is present', () => { - const d = cateToolDisplay('panel', { op: 'open', type: 'git' }) - expect(d.summary).toBe('git') + const d = cateToolDisplay('panel', { op: 'open', type: 'document' }) + expect(d.summary).toBe('document') }) it('describes a resize op with its preset', () => { diff --git a/src/agent/renderer/cateToolDisplay.ts b/src/agent/renderer/cateToolDisplay.ts index c4938962..526ed1ec 100644 --- a/src/agent/renderer/cateToolDisplay.ts +++ b/src/agent/renderer/cateToolDisplay.ts @@ -12,8 +12,6 @@ import { FileCode, Terminal, Globe, - GitBranch, - TreeStructure, SquaresFour, X, Crosshair, @@ -42,8 +40,6 @@ const PANEL_ICONS: Record = { editor: FileCode, terminal: Terminal, browser: Globe, - git: GitBranch, - fileExplorer: TreeStructure, document: FileText, } From 3beeca055c6e6075d40b3d566dbc9c789abf9265 Mon Sep 17 00:00:00 2001 From: Anton Date: Sun, 31 May 2026 22:27:02 +0200 Subject: [PATCH 21/22] wip(agent): cate-control rendering, executors, and shared types Checkpoint of in-progress cate-control work before merging origin/main. --- e2e/cate-control.spec.ts | 60 +- src/agent/extensions/cate-control/index.ts | 91 +-- .../extensions/cate-control/package.json | 2 +- src/agent/main/agentManager.ts | 7 + src/agent/main/installCateControl.ts | 12 +- src/agent/renderer/AgentChatInput.tsx | 16 +- src/agent/renderer/AgentPanel.tsx | 35 +- src/agent/renderer/ChatThread.tsx | 185 ++++--- .../renderer/agentStore.cateControl.test.ts | 31 -- src/agent/renderer/agentStore.ts | 46 +- src/agent/renderer/cateControl.test.ts | 27 +- src/agent/renderer/cateControl.ts | 27 +- src/agent/renderer/cateExecutors.test.tsx | 333 +++++------ src/agent/renderer/cateExecutors.ts | 523 +++++++++++------- src/agent/renderer/cateToolDisplay.test.ts | 82 ++- src/agent/renderer/cateToolDisplay.ts | 123 ++-- src/renderer/lib/cateControlLayout.test.ts | 27 +- src/renderer/lib/cateControlLayout.ts | 32 +- src/renderer/lib/e2eHarness.ts | 17 +- src/renderer/lib/portalRegistry.ts | 7 + src/renderer/stores/appStore.ts | 39 +- src/shared/cateControl.test.ts | 31 +- src/shared/cateControl.ts | 67 +-- src/shared/types.ts | 11 +- 24 files changed, 993 insertions(+), 838 deletions(-) delete mode 100644 src/agent/renderer/agentStore.cateControl.test.ts diff --git a/e2e/cate-control.spec.ts b/e2e/cate-control.spec.ts index 34ea5c90..4e1dba75 100644 --- a/e2e/cate-control.spec.ts +++ b/e2e/cate-control.spec.ts @@ -1,9 +1,9 @@ -// E2E coverage for the cate-control agent feature — drives the real renderer +// 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 consolidated 4-tool surface -// (layout{op} / panel{op} / browser / terminal{op}). Focused on: +// 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. markdown preview can be toggled (panel preview) +// 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' @@ -23,57 +23,55 @@ async function cate(p: Page, action: string, params: Record): P ) } +// 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 panelId = res.result.panelId as string - expect(panelId).toBeTruthy() + 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(() => page.evaluate((pid) => window.__cateE2E!.terminalText(pid), panelId), { - timeout: 15_000, - intervals: [250], - }) + .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 panelId = res.result.panelId as string + const title = res.result.title as string await expect - .poll(() => page.evaluate((pid) => window.__cateE2E!.terminalText(pid), panelId), { - timeout: 15_000, - intervals: [250], - }) + .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 preview toggles the editor into preview mode', async () => { - const opened = await cate(page, 'panel', { op: 'open', type: 'editor', target: { path: 'CATE_NOTES.md' } }) +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 panelId = opened.result.panelId as string + const pid = await panelId(page, opened.result.title as string) + expect(pid).toBeTruthy() const nodeId = await page.evaluate( - (pid) => window.__cateE2E!.nodes().find((n) => n.panelId === pid)?.id ?? null, - panelId, + (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) - - // Before: a markdown file shows the "Preview" toggle (source mode). - await expect(page.locator(`${nodeSel} button:has-text("Preview")`)).toBeVisible() - - // Turn preview on through the tool. - const pv = await cate(page, 'panel', { op: 'preview', panelId, preview: true }) - expect(pv.ok).toBe(true) - - // After: the toggle flips to "Source" — preview is now active. + // Preview active → the toggle reads "Source" (click to go back to source). await expect(page.locator(`${nodeSel} button:has-text("Source")`)).toBeVisible() }) -test('panel preview rejects a non-existent panel', async () => { - const res = await cate(page, 'panel', { op: 'preview', panelId: 'does-not-exist', preview: true }) +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 index eb38b947..54bfb85a 100644 --- a/src/agent/extensions/cate-control/index.ts +++ b/src/agent/extensions/cate-control/index.ts @@ -23,11 +23,30 @@ function toResult(action: string, res: CateResponse) { } const Placement = Type.Optional(Type.Object({ - relativeTo: Type.Optional(Type.String({ description: "panelId or 'self'" })), + relativeTo: Type.Optional(Type.String({ description: "panel id (e.g. \"p1\") 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, @@ -36,34 +55,21 @@ export default function (pi: ExtensionAPI) { }, }) - tool("cate_layout", "Read or arrange the canvas", - [ - "Inspect or rearrange the whole canvas. Choose `op` (default 'get'):", - "- 'get': return the canvas — open panels (id, type, title, position, size, focused, isSelf) and viewport.", - "- 'arrange': lay panels out. {style: tile|grid|cascade|focus-one, panelIds? (limit scope)}.", - ].join("\n"), - Type.Object({ - op: Type.Optional(Type.Union([Type.Literal("get"), Type.Literal("arrange")])), - style: Type.Optional(Type.String()), - panelIds: Type.Optional(Type.Array(Type.String())), - }), "layout") + 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. \"p1\") - 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 or manage a panel", + tool("cate_panel", "Open, close, or move a panel", [ - "Open or manage a single canvas panel. Choose `op`:", - "- 'open': create/open a panel, focus + center it. {type: editor|terminal|browser|document, target?, placement?}. target: {path,line?,column?,preview?} for editor (preview:true = open a markdown file straight into rendered preview); {url} for browser; {cwd?,command?} for terminal.", - "- 'focus' | 'close': {panelId}.", - "- 'move': {panelId, placement:{relativeTo,position}}.", - "- 'resize': {panelId, preset: small|medium|large} or {panelId, size:{width,height}}.", - "- 'preview': toggle a markdown editor's rendered preview. {panelId, preview?:bool (default true)}.", - "(To navigate a browser panel use cate_browser; to lay out many panels use cate_layout op:'arrange'.)", + "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 \"p1\" (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("focus"), Type.Literal("move"), - Type.Literal("resize"), Type.Literal("close"), Type.Literal("preview"), - ]), - panelId: Type.Optional(Type.String()), + 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()), @@ -71,24 +77,41 @@ export default function (pi: ExtensionAPI) { preview: Type.Optional(Type.Boolean()), })), placement: Placement, - preset: Type.Optional(Type.String()), - size: Type.Optional(Type.Object({ width: Type.Number(), height: Type.Number() })), - preview: Type.Optional(Type.Boolean()), }), "panel") - tool("cate_browser", "Navigate a browser panel", - "Point a browser panel at a url (opens a new browser panel if no panelId). {panelId?, url}.", - Type.Object({ panelId: Type.Optional(Type.String()), url: Type.String() }), "browser") + 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. \"p1\", 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. \"p1\"" }), + 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, panelId? (reuse an existing terminal), newPanel?:bool (force a fresh one)}.", - "- 'read': read recent output (visible screen + scrollback) as text. {panelId, lines?:number (trailing lines, default 50, max 1000)}.", + "- '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. \"p1\").", ].join("\n"), Type.Object({ op: Type.Union([Type.Literal("run"), Type.Literal("read")]), - panelId: Type.Optional(Type.String()), + panel: Type.Optional(Type.String()), command: Type.Optional(Type.String()), newPanel: Type.Optional(Type.Boolean()), lines: Type.Optional(Type.Number()), diff --git a/src/agent/extensions/cate-control/package.json b/src/agent/extensions/cate-control/package.json index 3a9c4d41..670df561 100644 --- a/src/agent/extensions/cate-control/package.json +++ b/src/agent/extensions/cate-control/package.json @@ -1,6 +1,6 @@ { "name": "cate-control", - "description": "Control Cate panels: open, arrange, focus, manage layout.", + "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 4c23cb88..a721c71c 100644 --- a/src/agent/main/agentManager.ts +++ b/src/agent/main/agentManager.ts @@ -41,6 +41,7 @@ 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' @@ -93,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() @@ -192,6 +197,8 @@ 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[] = [] diff --git a/src/agent/main/installCateControl.ts b/src/agent/main/installCateControl.ts index 01159d18..7024e951 100644 --- a/src/agent/main/installCateControl.ts +++ b/src/agent/main/installCateControl.ts @@ -1,5 +1,5 @@ // ============================================================================= -// installCateControl — copy the bundled cate-control extension into a +// 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 @@ -16,7 +16,7 @@ // 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.) +// after any extension update - dev or app upgrade.) // ============================================================================= import fs from 'fs' @@ -48,14 +48,14 @@ 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 */ } + 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. */ +/** 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 @@ -63,7 +63,7 @@ export async function installCateControlExtension(cwd: string): Promise { try { const src = sourceDir() if (!src) { - log.warn('[installCateControl] source dir not found — cate-control not installed') + log.warn('[installCateControl] source dir not found - cate-control not installed') return } const destDir = path.join(home, 'extensions', 'cate-control') diff --git a/src/agent/renderer/AgentChatInput.tsx b/src/agent/renderer/AgentChatInput.tsx index f96dd740..4c3861fd 100644 --- a/src/agent/renderer/AgentChatInput.tsx +++ b/src/agent/renderer/AgentChatInput.tsx @@ -50,8 +50,8 @@ export function ChatInput({ compactionActive, planModeActive, onTogglePlanMode, - cateControlMode, - onToggleCateControlMode, + cateControlEnabled, + onToggleCateControl, placeholder: placeholderOverride, }: { draft: string @@ -76,8 +76,8 @@ export function ChatInput({ compactionActive: boolean planModeActive: boolean onTogglePlanMode: () => void - cateControlMode?: 'guarded' | 'auto' - onToggleCateControlMode?: () => void + cateControlEnabled?: boolean + onToggleCateControl?: () => void placeholder?: string }) { useEffect(() => { @@ -230,15 +230,15 @@ export function ChatInput({ s.cateControlEnabled) const uiRequests = slice?.uiRequests ?? [] const currentUiRequest = uiRequests[0] @@ -500,12 +503,6 @@ export default function AgentPanel({ panelId, workspaceId }: PanelProps) { const handleApproval = useCallback( async (toolCallId: string, decision: 'allow' | 'deny') => { if (!activeAgentKey) return - // cate-control approvals resolve a local Promise in the dispatcher — there - // is no pi tool call awaiting them, so don't hit the pi IPC channel. - if (toolCallId.startsWith('cate:')) { - useAgentStore.getState().resolveCateApproval(activeAgentKey, toolCallId, decision === 'allow') - return - } useAgentStore.getState().resolveApproval(activeAgentKey, toolCallId) try { await window.electronAPI.agentToolDecision(activeAgentKey, toolCallId, decision) @@ -520,8 +517,7 @@ export default function AgentPanel({ panelId, workspaceId }: PanelProps) { ) // Register this chat's cate-control context so the dispatcher can resolve its - // workspace/canvas and route guarded-mode approvals through the panel's - // approval card. Re-registers whenever the active chat or workspace changes. + // workspace/canvas. Re-registers whenever the active chat or workspace changes. useEffect(() => { if (!activeAgentKey) return const key = activeAgentKey @@ -529,8 +525,6 @@ export default function AgentPanel({ panelId, workspaceId }: PanelProps) { workspaceId, hostPanelId: panelId, canvasStore: canvasStoreApi, - requestApproval: (action, params) => - useAgentStore.getState().requestCateApproval(key, action, params), }) return () => unregisterCateContext(key) }, [activeAgentKey, workspaceId, panelId, canvasStoreApi]) @@ -738,10 +732,15 @@ export default function AgentPanel({ panelId, workspaceId }: PanelProps) { catch (err) { log.warn('[AgentPanel] toggle plan mode failed', err) } }, [activeAgentKey]) - const handleToggleCateControlMode = useCallback(() => { + 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 - const next = useAgentStore.getState().getCateControlMode(activeAgentKey) === 'auto' ? 'guarded' : 'auto' - useAgentStore.getState().setCateControlMode(activeAgentKey, next) + 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 () => { @@ -969,8 +968,8 @@ export default function AgentPanel({ panelId, workspaceId }: PanelProps) { compactionActive={compaction.active} planModeActive={planModeActive} onTogglePlanMode={handleTogglePlanMode} - cateControlMode={cateControlMode} - onToggleCateControlMode={handleToggleCateControlMode} + cateControlEnabled={cateControlEnabled} + onToggleCateControl={handleToggleCateControl} placeholder={ !selectedModel ? 'Pick a model to start…' : !selectedProviderConnected ? `Connect ${selectedModel.provider} to start…` @@ -1028,8 +1027,8 @@ export default function AgentPanel({ panelId, workspaceId }: PanelProps) { compactionActive={compaction.active} planModeActive={planModeActive} onTogglePlanMode={handleTogglePlanMode} - cateControlMode={cateControlMode} - onToggleCateControlMode={handleToggleCateControlMode} + cateControlEnabled={cateControlEnabled} + onToggleCateControl={handleToggleCateControl} /> )} diff --git a/src/agent/renderer/ChatThread.tsx b/src/agent/renderer/ChatThread.tsx index a4a92322..92c9508a 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, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useRenderCount } from '../../renderer/lib/perf/perfClient' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' @@ -32,7 +32,7 @@ import type { ToolMessage, } from './agentStore' import { deriveDiff } from './agentStore' -import { cateToolDisplay, cateActionName } from './cateToolDisplay' +import { cateToolDisplay, cateActionName, cateToolFields, type CateField } from './cateToolDisplay' interface ChatThreadProps { messages: AgentMessage[] @@ -525,54 +525,119 @@ function toolVerb(msg: ToolMessage): string { } // ----------------------------------------------------------------------------- -// Cate-control card — custom rendering for the agent's canvas actions (open / -// move / arrange panels, run+read terminals, …). Accent-tinted to read as "Cate -// touched the workspace", with an expandable params/result body. Mirrors the -// approval card so the two share one visual language (see cateToolDisplay). +// 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 — type + title (never the +// raw panelId), with focused/self tags. +function CatePanelList({ panels }: { panels: Array> }) { + if (!panels.length) return
No open panels.
+ return ( +
+ {panels.map((p, i) => ( +
+ {String(p.type ?? 'panel')} + {String(p.title || '(untitled)')} + {p.focused === true && focused} + {p.isSelf === true && self} +
+ ))} +
+ ) +} + +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 { Icon, verb, summary } = useMemo(() => cateToolDisplay(action, params), [action, params]) + 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 (!rec && typeof result === 'string' && result) { + return ( +
+          {result}
+        
+ ) + } + return null + }, [action, result]) const isRunning = msg.status === 'running' || msg.status === 'pending' - const isError = msg.status === 'error' const isDenied = msg.status === 'denied' - const hasExtras = msg.args != null || !!msg.result || !!msg.error - - const accent = isError - ? 'border-rose-500/30 bg-rose-500/[0.06]' - : isDenied - ? 'border-white/10 bg-white/[0.03]' - : 'border-agent/25 bg-agent/[0.07]' - const iconColor = isError ? 'text-rose-300' : isDenied ? 'text-muted' : 'text-agent-light' + const hasExtras = fields.length > 0 || !!resultNode || !!msg.error || isDenied return ( -
+
{expanded && hasExtras && ( -
- {msg.args != null && ( -
-              {prettyArgs(msg.args)}
-            
+
+ {fields.length > 0 && } + {resultNode && ( +
0 ? 'border-t border-white/5 pt-1.5' : undefined}> + {resultNode} +
)} - {msg.result && ( -
-              {msg.result}
-            
+ {isDenied && ( +
Denied by user
)} {msg.error && (
@@ -1171,45 +1236,6 @@ function ApprovalCard({
   req: { toolCallId: string; toolName: string; args: unknown }
   onDecide: (decision: 'allow' | 'deny') => void
 }) {
-  const isCate = req.toolName.startsWith('cate:')
-  const buttons = (
-    
- - -
- ) - - // cate-control: custom rendering matching the in-thread CateToolCard (icon + - // human-readable request) instead of a raw `cate:` + JSON dump. - if (isCate) { - const { Icon, request, summary } = cateToolDisplay( - cateActionName(req.toolName), - (req.args ?? {}) as Record, - ) - return ( -
-
- - - Let Cate {request}{' '} - {summary}? - -
- {buttons} -
- ) - } - return (
@@ -1221,7 +1247,20 @@ function ApprovalCard({
         {prettyArgs(req.args)}
       
- {buttons} +
+ + +
) } diff --git a/src/agent/renderer/agentStore.cateControl.test.ts b/src/agent/renderer/agentStore.cateControl.test.ts deleted file mode 100644 index 5252155c..00000000 --- a/src/agent/renderer/agentStore.cateControl.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest' - -// agentStore imports 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 WorkspaceTab.test.tsx / terminalRegistry.test.ts). -vi.mock('../../renderer/lib/logger', () => ({ - default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, -})) - -import { useAgentStore } from './agentStore' - -describe('agentStore cateControlMode', () => { - beforeEach(() => { - useAgentStore.setState({ panels: {} }) - }) - - it('defaults to guarded when read for an unknown panel', () => { - expect(useAgentStore.getState().getCateControlMode('k1')).toBe('guarded') - }) - - it('setCateControlMode creates the panel slice and stores the mode', () => { - useAgentStore.getState().setCateControlMode('k1', 'auto') - expect(useAgentStore.getState().getCateControlMode('k1')).toBe('auto') - }) - - it('toggles back to guarded', () => { - useAgentStore.getState().setCateControlMode('k1', 'auto') - useAgentStore.getState().setCateControlMode('k1', 'guarded') - expect(useAgentStore.getState().getCateControlMode('k1')).toBe('guarded') - }) -}) diff --git a/src/agent/renderer/agentStore.ts b/src/agent/renderer/agentStore.ts index ab0a2600..708c647b 100644 --- a/src/agent/renderer/agentStore.ts +++ b/src/agent/renderer/agentStore.ts @@ -211,9 +211,6 @@ export interface PanelAgentState { /** Optional session display name (mirrors pi's `set_session_name`). */ sessionName?: string sessionFile?: string - /** Renderer-side control mode for the cate-control feature. Defaults to - * 'guarded' (side-effects need approval); 'auto' executes immediately. */ - cateControlMode?: 'guarded' | 'auto' } interface AgentStoreState { @@ -237,8 +234,6 @@ interface AgentStoreActions { setModel: (panelId: string, model: AgentModelRef | null) => void addApproval: (panelId: string, req: AgentToolApprovalRequest) => void resolveApproval: (panelId: string, toolCallId: string) => void - requestCateApproval: (panelId: string, action: string, params: Record) => Promise - resolveCateApproval: (panelId: string, toolCallId: string, allow: boolean) => void appendSystem: (panelId: string, text: string, kind?: SystemMessage['kind']) => void loadMessages: (panelId: string, messages: AgentMessage[]) => void clearMessages: (panelId: string) => void @@ -251,8 +246,6 @@ interface AgentStoreActions { setRetry: (panelId: string, next: Partial) => void setQueues: (panelId: string, steering: string[], followUp: string[]) => void setExtensionStatus: (panelId: string, key: string, text?: string) => void - setCateControlMode: (panelId: string, mode: 'guarded' | 'auto') => void - getCateControlMode: (panelId: string) => 'guarded' | 'auto' setExtensionWidget: ( panelId: string, key: string, @@ -308,10 +301,6 @@ function withPanel( return { panels: { ...state.panels, [panelId]: next } } } -// In-flight cate-control approvals, keyed by a synthetic toolCallId. The -// dispatcher awaits these Promises; the AgentPanel approval card resolves them. -const pendingCateApprovals = new Map void>() -let cateApprovalSeq = 0 // 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 @@ -510,23 +499,6 @@ export const useAgentStore = create((set, get) => ({ ) }, - requestCateApproval(panelId, action, params) { - const toolCallId = `cate:${action}:${cateApprovalSeq++}` - return new Promise((resolve) => { - pendingCateApprovals.set(toolCallId, resolve) - get().addApproval(panelId, { panelId, toolCallId, toolName: `cate:${action}`, args: params }) - }) - }, - - resolveCateApproval(panelId, toolCallId, allow) { - const resolver = pendingCateApprovals.get(toolCallId) - if (resolver) { - pendingCateApprovals.delete(toolCallId) - resolver(allow) - } - get().resolveApproval(panelId, toolCallId) - }, - setStats(panelId, stats) { set((state) => withPanel(state, panelId, (p) => ({ ...p, stats }))) }, @@ -591,17 +563,6 @@ export const useAgentStore = create((set, get) => ({ ) }, - setCateControlMode(panelId, mode) { - set((state) => { - const current = state.panels[panelId] ?? emptyPanel() - return { panels: { ...state.panels, [panelId]: { ...current, cateControlMode: mode } } } - }) - }, - - getCateControlMode(panelId) { - return get().panels[panelId]?.cateControlMode ?? 'guarded' - }, - setExtensionWidget(panelId, key, lines, placement) { set((state) => withPanel(state, panelId, (p) => { @@ -875,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 @@ -1046,7 +1012,7 @@ function handleEvent(panelId: string, event: { type: string; [key: string]: unkn ? response.result : JSON.stringify(response.result, null, 2) useAgentStore.getState().updateToolCall(panelId, callId, { - status: response.denied ? 'denied' : response.ok ? 'success' : 'error', + status: response.ok ? 'success' : 'error', result, error: response.error, }) diff --git a/src/agent/renderer/cateControl.test.ts b/src/agent/renderer/cateControl.test.ts index 4ae2b64d..7c988410 100644 --- a/src/agent/renderer/cateControl.test.ts +++ b/src/agent/renderer/cateControl.test.ts @@ -13,7 +13,6 @@ import { dispatchCateRequest, __setExecutorsForTest, } from './cateControl' -import { useAgentStore } from './agentStore' import { useSettingsStore } from '../../renderer/stores/settingsStore' function fakeCanvasStore() { @@ -22,13 +21,12 @@ function fakeCanvasStore() { describe('dispatchCateRequest', () => { beforeEach(() => { - useAgentStore.setState({ panels: {} }) useSettingsStore.setState({ cateControlEnabled: true } as any) unregisterCateContext('k1') __setExecutorsForTest(null) }) - it('errors when the feature is globally disabled', async () => { + 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: {} }) @@ -42,7 +40,7 @@ describe('dispatchCateRequest', () => { expect(res.error).toMatch(/not registered|no context/i) }) - it('runs safe actions immediately without approval', async () => { + 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) @@ -51,26 +49,21 @@ describe('dispatchCateRequest', () => { expect(res).toEqual({ ok: true, result: { panels: [] } }) }) - it('auto mode runs side-effect actions without approval', async () => { + it('runs side-effect actions immediately (no guard)', async () => { registerCateContext('k1', { workspaceId: 'w1', hostPanelId: 'p1', canvasStore: fakeCanvasStore() }) - useAgentStore.getState().setCateControlMode('k1', 'auto') const exec = vi.fn().mockResolvedValue({ ok: true }) __setExecutorsForTest({ panel: exec } as any) - const res = await dispatchCateRequest('k1', { action: 'panel', params: { op: 'close', panelId: 'x' } }) + const res = await dispatchCateRequest('k1', { action: 'panel', params: { op: 'close', panel: 'x' } }) expect(exec).toHaveBeenCalledTimes(1) expect(res.ok).toBe(true) }) - it('guarded mode asks for approval and denies when the resolver says deny', async () => { - const requestApproval = vi.fn().mockResolvedValue(false) - registerCateContext('k1', { workspaceId: 'w1', hostPanelId: 'p1', canvasStore: fakeCanvasStore(), requestApproval }) - useAgentStore.getState().setCateControlMode('k1', 'guarded') - const exec = vi.fn().mockResolvedValue({ ok: true }) - __setExecutorsForTest({ panel: exec } as any) - const res = await dispatchCateRequest('k1', { action: 'panel', params: { op: 'close', panelId: 'x' } }) - expect(requestApproval).toHaveBeenCalledWith('panel', { op: 'close', panelId: 'x' }) - expect(exec).not.toHaveBeenCalled() - expect(res).toEqual({ ok: false, denied: 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 () => { diff --git a/src/agent/renderer/cateControl.ts b/src/agent/renderer/cateControl.ts index 6761c473..07c9453d 100644 --- a/src/agent/renderer/cateControl.ts +++ b/src/agent/renderer/cateControl.ts @@ -1,15 +1,15 @@ // ============================================================================= // 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, gates side-effects per the chat's mode, and runs an -// executor. Returns a CateControlResponse the extension reads back. +// 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 { classifyCateAction, type CateControlRequest, type CateControlResponse, type CateControlAction } from '../../shared/cateControl' -import { useAgentStore } from './agentStore' +import { type CateControlRequest, type CateControlResponse, type CateControlAction } from '../../shared/cateControl' import { useSettingsStore } from '../../renderer/stores/settingsStore' import log from '../../renderer/lib/logger' @@ -20,9 +20,6 @@ export interface CateControlContext { hostPanelId: string /** The canvas store for this chat's workspace. */ canvasStore: StoreApi - /** Renders an inline approval card and resolves true=allow / false=deny. - * Injected by AgentPanel; in tests a stub is supplied. */ - requestApproval?: (action: CateControlAction, params: Record) => Promise } export type CateExecutor = ( @@ -68,23 +65,11 @@ export async function dispatchCateRequest( ): Promise { try { if (!useSettingsStore.getState().cateControlEnabled) { - return { ok: false, error: 'Cate control is disabled in settings.' } + 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.' } - // Guard: only the active workspace can be controlled in v1. - // (Resolution of non-active workspaces is deferred — spec §11.) - - const klass = classifyCateAction(req.action, req.params) - if (klass === 'side-effect') { - const mode = useAgentStore.getState().getCateControlMode(agentKey) - if (mode === 'guarded') { - const allowed = ctx.requestApproval ? await ctx.requestApproval(req.action, req.params) : false - if (!allowed) return { ok: false, denied: true } - } - } - 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) diff --git a/src/agent/renderer/cateExecutors.test.tsx b/src/agent/renderer/cateExecutors.test.tsx index 8cd88050..e56ec559 100644 --- a/src/agent/renderer/cateExecutors.test.tsx +++ b/src/agent/renderer/cateExecutors.test.tsx @@ -1,8 +1,14 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' -import { execGetLayout, execOpenPanel, execClosePanel } from './cateExecutors' 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. @@ -18,8 +24,12 @@ vi.mock('../../renderer/lib/fileRouting', () => ({ 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. +// 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[] = [] @@ -35,7 +45,13 @@ vi.mock('../../renderer/stores/appStore', () => { closePanel: (...a: any[]) => { closed.push(a) }, updatePanelUrl: (...a: any[]) => { created.push(['url', ...a]) }, setPanelMarkdownPreview: (...a: any[]) => { created.push(['mdpreview', ...a]) }, - workspaces: [{ id: 'w1', panels: { 'panel-ed': { id: 'panel-ed', type: 'editor', title: 'a.ts', filePath: 'a.ts' } } }], + ensurePanelAgentId: (_wsId: string, panelId: string) => + (({ 'panel-ed': 'p1', 'panel-tm': 'p2', 'panel-br': 'p3' } as Record)[panelId] ?? `p-${panelId}`), + workspaces: [{ id: 'w1', panels: { + 'panel-ed': { id: 'panel-ed', type: 'editor', title: 'a.ts', filePath: 'a.ts', agentId: 'p1' }, + 'panel-tm': { id: 'panel-tm', type: 'terminal', title: 'Terminal 1', agentId: 'p2' }, + 'panel-br': { id: 'panel-br', type: 'browser', title: 'Browser', agentId: 'p3' }, + } }], selectedWorkspaceId: 'w1', }), }, @@ -46,18 +62,35 @@ function ctxWith(store = createCanvasStore()): CateControlContext { return { workspaceId: 'w1', hostPanelId: 'host', canvasStore: store } } -describe('execOpenPanel', () => { - beforeEach(async () => { - const mod: any = await import('../../renderer/stores/appStore') - mod.__created.length = 0 - mod.__closed.length = 0 - vi.mocked(openFileAsPanel).mockClear() +// 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 'panel-br'). + browserEvalResult = '' + portalRegistry.register('panel-br', { + getWebContentsId: () => 1, + getURL: () => 'https://example.com', + getTitle: () => 'Example', + loadURL: () => {}, + goBack: () => {}, goForward: () => {}, canGoBack: () => true, canGoForward: () => false, + reload: () => {}, stop: () => {}, + executeJavaScript: async () => browserEvalResult, }) +}) - it('opens an editor with a file path via openFileAsPanel and returns the panelId', async () => { +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).panelId).toBe('panel-ed') + expect((res.result as any).id).toBe('p1') + expect((res.result as any).title).toBe('a.ts') + expect((res.result as any).type).toBe('editor') expect(openFileAsPanel).toHaveBeenCalledWith('w1', 'a.ts') }) @@ -73,83 +106,108 @@ describe('execOpenPanel', () => { expect(res.error).toMatch(/type/i) }) - it('focuses + centers the panel it opens so it lands in view (open-focus fix)', async () => { + it('focuses the panel it opens so it lands in view (open-focus fix)', async () => { const store = createCanvasStore() - // The app adds a canvas node for the new panel; simulate it for the id the - // mocked createBrowser returns so focusAndCenter has a node to act on. store.getState().addNode('panel-br', '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('panel-br')) }) + + 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('panel-br', '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('panel-br')! + 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', 'panel-ed', true]) + }) }) describe('execClosePanel', () => { - it('errors when the panel is not found', async () => { - const res = await execClosePanel({ panelId: 'nope' }, ctxWith(), 'k1') + 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(/not found/i) + expect(res.error).toMatch(/no panel with id/i) }) - it('closes a known panel', async () => { - const res = await execClosePanel({ panelId: 'panel-ed' }, ctxWith(), 'k1') + 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', 'panel-ed']) }) -}) -describe('execGetLayout', () => { - it('returns panels with isSelf flag and viewport', async () => { - const store = createCanvasStore() - store.getState().addNode('host', 'agent', { x: 0, y: 0 }, { width: 200, height: 200 }) - store.getState().addNode('panel-ed', 'editor', { x: 300, y: 0 }, { width: 200, height: 200 }) - const res = await execGetLayout({}, ctxWith(store), 'k1') + it('closes a panel addressed by its stable id (p1)', async () => { + const res = await execClosePanel({ panel: 'p1' }, ctxWith(), 'k1') expect(res.ok).toBe(true) - const panels = (res.result as any).panels as any[] - const self = panels.find((p) => p.panelId === 'host') - expect(self.isSelf).toBe(true) - expect((res.result as any).viewport).toBeDefined() + const mod: any = await import('../../renderer/stores/appStore') + expect(mod.__closed[0]).toEqual(['w1', 'panel-ed']) }) -}) - -import { execFocusPanel, execResizePanel, execArrange } from './cateExecutors' -import { execRunInTerminal, execOpenUrl, execReadTerminal } from './cateExecutors' -import { execLayout, execPanel, execBrowser, execTerminal } from './cateExecutors' - -vi.mock('../../renderer/lib/terminalRegistry', () => ({ - terminalRegistry: { getEntry: vi.fn(() => ({ ptyId: 'pty-1' })) }, -})) -describe('management executors', () => { - it('focus errors on unknown panel', async () => { - const res = await execFocusPanel({ panelId: 'nope' }, ctxWith(), 'k1') + 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) }) +}) - it('focuses a known node', async () => { +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('panel-ed', 'editor', { x: 0, y: 0 }, { width: 100, height: 100 }) - const res = await execFocusPanel({ panelId: 'panel-ed' }, ctxWith(store), 'k1') + store.getState().addNode('panel-tm', '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(store.getState().focusedNodeId).toBe(store.getState().nodeForPanel('panel-ed')) + 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('panel-ed')! + 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) }) +}) - it('resize applies a preset size', async () => { +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('panel-ed', 'editor', { x: 0, y: 0 }, { width: 100, height: 100 }) - const res = await execResizePanel({ panelId: 'panel-ed', preset: 'large' }, ctxWith(store), 'k1') + store.getState().addNode('host', 'agent', { x: 0, y: 0 }, { width: 200, height: 200 }) + store.getState().addNode('panel-ed', 'editor', { x: 300, y: 0 }, { width: 200, height: 200 }) + const res = await execGetLayout({}, ctxWith(store), 'k1') expect(res.ok).toBe(true) - const node = store.getState().nodeForPanel('panel-ed')! - expect(store.getState().nodes[node].size.width).toBeGreaterThan(100) + 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') + // stable agent handle is exposed as `id` (e.g. "p1") for targeting. + expect(panels.find((p) => p.title === 'a.ts')?.id).toBe('p1') + // 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 the PTY (newPanel)', async () => { + 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') }) @@ -158,18 +216,47 @@ describe('content executors', () => { expect(res.ok).toBe(false) }) - it('open_url creates a browser panel when no panelId given', async () => { - const res = await execOpenUrl({ url: 'https://example.com' }, ctxWith(), 'k1') + 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).panelId).toBe('panel-br') + 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', 'panel-br', 'https://example.com']) }) - it('open_url rejects a non-url', async () => { - const res = await execOpenUrl({ url: 'not a url' }, ctxWith(), 'k1') + 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('read_terminal returns the trailing buffer lines as text', async () => { + 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', @@ -178,38 +265,28 @@ describe('content executors', () => { getLine: (i: number) => ({ translateToString: () => lines[i] }), } } }, } as any) - const res = await execReadTerminal({ panelId: 'panel-tm' }, ctxWith(), 'k1') + const res = await execReadTerminal({ panel: 'Terminal 1' }, ctxWith(), 'k1') expect(res.ok).toBe(true) - // Trailing blank rows are trimmed. 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({ panelId: 'gone' }, ctxWith(), 'k1') + const res = await execReadTerminal({ panel: 'Terminal 1' }, ctxWith(), 'k1') expect(res.ok).toBe(false) expect(res.error).toMatch(/no live terminal/i) }) }) -// --------------------------------------------------------------------------- -// Regression fixes from live testing (2026-05-31) -// --------------------------------------------------------------------------- -import { terminalRegistry } from '../../renderer/lib/terminalRegistry' -import { execSetMarkdownPreview } from './cateExecutors' - -describe('terminal command reliability (Issue 1 fix)', () => { - beforeEach(async () => { +describe('terminal command reliability', () => { + beforeEach(() => { ;(window.electronAPI as any).terminalWrite = vi.fn() vi.mocked(terminalRegistry.getEntry).mockReturnValue({ ptyId: 'pty-1' } as any) - const mod: any = await import('../../renderer/stores/appStore') - mod.__created.length = 0 }) it('run_in_terminal polls until the PTY registers, then writes the command', async () => { - // Not ready for the first two polls (no entry, then entry with empty ptyId), - // then the PTY spawns — proves condition-based waiting, not a fixed delay. let calls = 0 vi.mocked(terminalRegistry.getEntry).mockImplementation(() => { calls += 1 @@ -227,139 +304,85 @@ describe('terminal command reliability (Issue 1 fix)', () => { 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') - // createTerminal must NOT receive the command as initialInput (that arg is a no-op via the store). const mod: any = await import('../../renderer/stores/appStore') const termCreate = mod.__created.find((c: any[]) => c[0] === 'terminal') expect(termCreate?.[2]).toBeUndefined() }) }) -describe('markdown preview (Issue 2 fix)', () => { - beforeEach(async () => { - const mod: any = await import('../../renderer/stores/appStore') - mod.__created.length = 0 - }) - - it('set_markdown_preview toggles preview on an editor panel', async () => { - const res = await execSetMarkdownPreview({ panelId: 'panel-ed', 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', 'panel-ed', true]) - }) - - it('set_markdown_preview errors when the panel is missing', async () => { - const res = await execSetMarkdownPreview({ panelId: 'nope', preview: true }, ctxWith(), 'k1') - expect(res.ok).toBe(false) - }) - - it('open_panel editor with target.preview:true turns on markdown preview', 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', 'panel-ed', true]) - }) -}) - // --------------------------------------------------------------------------- -// Consolidated op routers (4-tool surface: layout / panel / browser / terminal) +// Op routers (4-tool surface: layout / panel / browser / terminal) // --------------------------------------------------------------------------- -describe('execLayout (op router)', () => { - it("defaults to reading the canvas layout", async () => { +describe('op routers', () => { + it('layout reads the canvas', async () => { const store = createCanvasStore() store.getState().addNode('panel-ed', 'editor', { x: 0, y: 0 }, { width: 100, height: 100 }) - const res = await execLayout({}, ctxWith(store), 'k1') + const res = await execGetLayout({}, ctxWith(store), 'k1') expect(res.ok).toBe(true) expect((res.result as any).panels).toBeDefined() }) - it("routes op:'arrange' to arrange panels with the given style", async () => { - const store = createCanvasStore() - store.getState().addNode('p1', 'editor', { x: 0, y: 0 }, { width: 100, height: 100 }) - store.getState().addNode('p2', 'editor', { x: 300, y: 0 }, { width: 100, height: 100 }) - const res = await execLayout({ op: 'arrange', style: 'grid' }, ctxWith(store), 'k1') + 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).layout).toBe('grid') + expect((res.result as any).title).toBe('a.ts') }) -}) -describe('execBrowser', () => { - beforeEach(async () => { - const mod: any = await import('../../renderer/stores/appStore') - mod.__created.length = 0 - }) - - it('opens a browser at a url when no panelId is given', async () => { - const res = await execBrowser({ url: 'https://example.com' }, ctxWith(), 'k1') + it("execPanel routes op:'close' (by title)", async () => { + const res = await execPanel({ op: 'close', panel: 'a.ts' }, ctxWith(), 'k1') expect(res.ok).toBe(true) - expect((res.result as any).panelId).toBe('panel-br') - }) - - it('rejects a non-url', async () => { - const res = await execBrowser({ url: 'not a url' }, ctxWith(), 'k1') - expect(res.ok).toBe(false) - }) -}) - -describe('execPanel (op router)', () => { - beforeEach(async () => { const mod: any = await import('../../renderer/stores/appStore') - mod.__created.length = 0 - mod.__closed.length = 0 + expect(mod.__closed[0]).toEqual(['w1', 'panel-ed']) }) - it("routes op:'open' to open a panel", async () => { - const res = await execPanel({ op: 'open', type: 'editor', target: { path: 'a.ts' } }, ctxWith(), 'k1') + it("execPanel routes op:'move'", async () => { + const store = createCanvasStore() + store.getState().setContainerSize({ width: 1000, height: 1000 }) + store.getState().addNode('panel-ed', '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).panelId).toBe('panel-ed') + expect((res.result as any).moved).toBe('a.ts') }) - it("routes op:'close' to close a panel", async () => { - const res = await execPanel({ op: 'close', panelId: 'panel-ed' }, ctxWith(), 'k1') - expect(res.ok).toBe(true) - const mod: any = await import('../../renderer/stores/appStore') - expect(mod.__closed[0]).toEqual(['w1', 'panel-ed']) + 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("routes op:'focus' to focus a node", async () => { - const store = createCanvasStore() - store.getState().addNode('panel-ed', 'editor', { x: 0, y: 0 }, { width: 100, height: 100 }) - const res = await execPanel({ op: 'focus', panelId: 'panel-ed' }, ctxWith(store), 'k1') + 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(store.getState().focusedNodeId).toBe(store.getState().nodeForPanel('panel-ed')) + expect((res.result as any).browser).toBe('Browser') }) - it('rejects an unknown op', async () => { - const res = await execPanel({ op: 'teleport', panelId: 'x' }, ctxWith(), 'k1') + 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) }) -}) -describe('execTerminal (op router)', () => { - beforeEach(() => { + it("execTerminal routes op:'run'", async () => { ;(window.electronAPI as any).terminalWrite = vi.fn() vi.mocked(terminalRegistry.getEntry).mockReturnValue({ ptyId: 'pty-1' } as any) - }) - - it("routes op:'run' to run a command", async () => { 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("routes op:'read' to read the terminal buffer", async () => { + 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', panelId: 'panel-tm' }, ctxWith(), 'k1') + 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('rejects an unknown op', async () => { + 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 index 2afb8d11..a9053381 100644 --- a/src/agent/renderer/cateExecutors.ts +++ b/src/agent/renderer/cateExecutors.ts @@ -2,6 +2,10 @@ // 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 TITLE (e.g. "Terminal 2", "a.ts"), never by the +// internal UUID - resolvePanelRef() maps a title to a panelId, and results report +// titles back. 'self' refers to the agent's own host panel. // ============================================================================= import type { CateControlResponse } from '../../shared/cateControl' @@ -12,12 +16,55 @@ 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 +} + +/** The panel's stable agent handle ("p1", "p2", …), assigning one if needed. + * This is what the agent uses to target a panel - titles change, handles don't. */ +function agentIdFor(ctx: CateControlContext, panelId: string): string { + return useAppStore.getState().ensurePanelAgentId(ctx.workspaceId, panelId) +} + +/** Resolve a panel reference - the stable agent handle ("p1"), or 'self' for the + * agent's host panel - to its panelId. A raw panelId and an exact title are + * accepted as fallbacks, but the handle 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 "p1").' } + if (s === 'self') return { panelId: ctx.hostPanelId } + const panels = workspacePanels(ctx) + // Primary: the stable agent handle. + const byAgent = Object.keys(panels).filter((id) => panels[id]?.agentId === s) + if (byAgent.length === 1) return { panelId: byAgent[0] } + // Fallbacks: a raw panelId, then an exact (but possibly stale/ambiguous) title. + if (panels[s]) return { panelId: s } + 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. "p1") 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() @@ -45,28 +92,88 @@ function readCanvasGeometry(ctx: CateControlContext): { occupied: Rect[]; viewpo 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 app = useAppStore.getState() - const ws = app.workspaces.find((w: any) => w.id === ctx.workspaceId) + const panels = workspacePanels(ctx) const st = ctx.canvasStore.getState() - const panels = Object.values(st.nodes).map((node: any) => { - const panel = ws?.panels?.[node.panelId] + const out = Object.values(st.nodes).map((node: any) => { + const panel = panels[node.panelId] return { - panelId: node.panelId, - type: panel?.type ?? 'unknown', + id: agentIdFor(ctx, node.panelId), title: panel?.title ?? '', - x: node.origin.x, y: node.origin.y, width: node.size.width, height: node.size.height, + type: panel?.type ?? 'unknown', focused: st.focusedNodeId === node.id, isSelf: node.panelId === ctx.hostPanelId, } }) - return ok({ - workspaceId: ctx.workspaceId, - viewport: { zoom: st.zoomLevel, offset: st.viewportOffset }, - panels, - }) + 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)}`) @@ -74,225 +181,229 @@ export const execOpenPanel: CateExecutor = async (params, ctx) => { const app = useAppStore.getState() const wsId = ctx.workspaceId - let panelId: string - // A terminal `command` is run after the panel mounts (see below) — NOT passed + // 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 - switch (type) { - case 'editor': { - const path = typeof target.path === 'string' ? target.path : undefined - panelId = path ? openFileAsPanel(wsId, path) : app.createEditor(wsId) - if (path && (typeof target.line === 'number')) { - setPendingReveal(panelId, { line: target.line as number, column: typeof target.column === 'number' ? (target.column as number) : 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 } - // Convenience: open straight into rendered markdown preview (markdown files only). - if (target.preview === true) { - app.setPanelMarkdownPreview(wsId, panelId, true) + 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 } - break + 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 '' } - case 'terminal': - panelId = app.createTerminal(wsId, undefined, undefined, undefined, typeof target.cwd === 'string' ? target.cwd : undefined) - pendingTerminalCommand = typeof target.command === 'string' && target.command.trim() ? target.command : undefined - break - case 'browser': - panelId = app.createBrowser(wsId, typeof target.url === 'string' ? target.url : undefined) - break - case 'document': - panelId = typeof target.path === 'string' ? openFileAsPanel(wsId, target.path) : app.createEditor(wsId) - break - default: - return fail(`Unsupported panel type: ${type}`) - } + }) + if (!panelId) return fail(`Unsupported panel type: ${String(params.type)}`) - // Apply semantic placement if requested (move the freshly-created node). - const placement = (params.placement ?? {}) as Record - if (placement.position || placement.relativeTo) { - const { occupied, viewportCenter, nodesByPanel } = readCanvasGeometry(ctx) - const size = PANEL_DEFINITIONS[type].defaultSize - const relPanelId = placement.relativeTo === 'self' ? ctx.hostPanelId : (typeof placement.relativeTo === 'string' ? placement.relativeTo : undefined) - const relativeTo = relPanelId ? nodesByPanel.get(relPanelId)?.rect : undefined - const rect = computePlacement({ - size, - relativeTo, - position: placement.position as any, - occupied, - viewportCenter, - }) - const node = ctx.canvasStore.getState().nodeForPanel(panelId) - if (node) { - ctx.canvasStore.getState().moveNode(node, { x: rect.x, y: rect.y }) - } - } + // 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) - // Focus + center the freshly opened panel so it lands in view. Without this the - // viewport stayed where it was and a newly-opened panel could appear off-screen - // (read as "panned to a random location"). - if (node) ctx.canvasStore.getState().focusAndCenter(node) - const frame = node ? ctx.canvasStore.getState().nodes[node] : undefined - return ok({ panelId, x: frame?.origin.x, y: frame?.origin.y, width: frame?.size.width, height: frame?.size.height }) + if (node) ctx.canvasStore.getState().focusNode(node) + return ok({ id: agentIdFor(ctx, panelId), title: titleFor(ctx, panelId), type }) } export const execClosePanel: CateExecutor = async (params, ctx) => { - const panelId = String(params.panelId ?? '') - const app = useAppStore.getState() - const ws = app.workspaces.find((w: any) => w.id === ctx.workspaceId) - if (!ws?.panels?.[panelId]) return fail(`Panel not found: ${panelId}`) + 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.') - app.closePanel(ctx.workspaceId, panelId) - return ok({ closed: panelId }) -} - -import { computeArrange } from '../../renderer/lib/cateControlLayout' -import { terminalRegistry } from '../../renderer/lib/terminalRegistry' -import { setCateExecutors } from './cateControl' - -const SIZE_PRESETS: Record = { - small: { width: 400, height: 300 }, - medium: { width: 640, height: 480 }, - large: { width: 960, height: 720 }, -} - -function requireNode(ctx: CateControlContext, panelId: string): string | null { - return ctx.canvasStore.getState().nodeForPanel(panelId) -} - -/** 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)) - } -} - -export const execFocusPanel: CateExecutor = async (params, ctx) => { - const panelId = String(params.panelId ?? '') - const node = requireNode(ctx, panelId) - if (!node) return fail(`Panel not found on canvas: ${panelId}`) - ctx.canvasStore.getState().focusAndCenter(node) - return ok({ focused: panelId }) + const title = titleFor(ctx, panelId) + useAppStore.getState().closePanel(ctx.workspaceId, panelId) + return ok({ closed: title }) } export const execMovePanel: CateExecutor = async (params, ctx) => { - const panelId = String(params.panelId ?? '') - if (panelId === ctx.hostPanelId && !params.placement) return fail('Refusing to move the host agent panel without an explicit placement.') - const node = requireNode(ctx, panelId) - if (!node) return fail(`Panel not found on canvas: ${panelId}`) + 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 relPanelId = placement.relativeTo === 'self' ? ctx.hostPanelId : (typeof placement.relativeTo === 'string' ? placement.relativeTo : undefined) - const relativeTo = relPanelId ? nodesByPanel.get(relPanelId)?.rect : undefined - // Exclude the node being moved from its own obstacle set (by identity, not index). + 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({ panelId, 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) } } -export const execResizePanel: CateExecutor = async (params, ctx) => { - const panelId = String(params.panelId ?? '') - const node = requireNode(ctx, panelId) - if (!node) return fail(`Panel not found on canvas: ${panelId}`) - let size: { width: number; height: number } | undefined - if (typeof params.preset === 'string') size = SIZE_PRESETS[params.preset] - else if (params.size && typeof params.size === 'object') { - const s = params.size as Record - if (typeof s.width === 'number' && typeof s.height === 'number') size = { width: s.width, height: s.height } +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}".`) } - if (!size) return fail('resize requires a valid `preset` (small|medium|large) or `size` {width,height}.') - ctx.canvasStore.getState().resizeNode(node, size) - return ok({ panelId, ...size }) + return ok({ browser: b.title, op }) } -export const execArrange: CateExecutor = async (params, ctx) => { - // `layout` tool exposes the style as `style`; accept legacy `layout` too. - const layout = String(params.style ?? params.layout ?? 'tile') as 'tile' | 'grid' | 'cascade' | 'focus-one' - const st = ctx.canvasStore.getState() - const all = Object.values(st.nodes).filter((n: any) => n.panelId !== ctx.hostPanelId) // self-protection - const requested = Array.isArray(params.panelIds) ? (params.panelIds as string[]) : null - const targets = requested - ? all.filter((n: any) => requested.includes(n.panelId)) - : all - if (!targets.length) return ok({ arranged: 0 }) - // Frame: union viewport of current nodes (canvas-space). - const minX = Math.min(...targets.map((n: any) => n.origin.x)) - const minY = Math.min(...targets.map((n: any) => n.origin.y)) - const viewport: Rect = { x: minX, y: minY, width: 1200, height: 900 } - const rects = computeArrange(layout, targets.length, viewport) - targets.forEach((n: any, i) => { - st.moveNode(n.id, { x: rects[i].x, y: rects[i].y }) - st.resizeNode(n.id, { width: rects[i].width, height: rects[i].height }) +/** 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(), }) - return ok({ arranged: targets.length, layout }) } -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 = typeof params.panelId === 'string' ? params.panelId : '' - if (!panelId || params.newPanel) { - panelId = app.createTerminal(ctx.workspaceId) +/** 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 } - const sent = await writeToTerminalWhenReady(panelId, command) - if (!sent) return fail(`Terminal ${panelId} did not become ready to receive input (timed out).`) - return ok({ panelId, command }) + return ok({ browser: b.title, result }) } -export const execOpenUrl: 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 app = useAppStore.getState() - let panelId = typeof params.panelId === 'string' ? params.panelId : '' - if (!panelId) { panelId = app.createBrowser(ctx.workspaceId, url); return ok({ panelId, url }) } - app.updatePanelUrl(ctx.workspaceId, panelId, url) - return ok({ panelId, url }) +/** 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 }) } -/** Toggle the rendered markdown preview for an open editor panel. The app gates - * the actual render to .md files (EditorPanel), so this is a no-op visually for - * non-markdown editors but still records the flag. */ -export const execSetMarkdownPreview: CateExecutor = async (params, ctx) => { - const panelId = String(params.panelId ?? '') - if (!panelId) return fail('panel preview requires a panelId.') +// --------------------------------------------------------------------------- +// 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() - const ws = app.workspaces.find((w: any) => w.id === ctx.workspaceId) - const panel = ws?.panels?.[panelId] - if (!panel) return fail(`Panel not found: ${panelId}`) - if (panel.type !== 'editor') return fail(`Panel ${panelId} is a ${panel.type}; markdown preview applies to editor panels.`) - const preview = params.preview !== false - app.setPanelMarkdownPreview(ctx.workspaceId, panelId, preview) - return ok({ panelId, preview }) + 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({ 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 — - * the other half of terminal orchestration. Reads straight from the live xterm - * buffer; no PTY round-trip. Safe (read-only). */ -export const execReadTerminal: CateExecutor = async (params) => { - const panelId = String(params.panelId ?? '') - if (!panelId) return fail('terminal read requires a panelId.') + * 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 panel ${panelId}.`) + 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)) @@ -305,41 +416,42 @@ export const execReadTerminal: CateExecutor = async (params) => { } // Drop trailing blank rows (an idle terminal pads the screen with empties). while (collected.length && collected[collected.length - 1] === '') collected.pop() - return ok({ panelId, lineCount: collected.length, text: collected.join('\n') }) + return ok({ terminal: titleFor(ctx, panelId), lineCount: collected.length, text: collected.join('\n') }) } // --------------------------------------------------------------------------- -// Consolidated op-routers — the agent sees four tools (layout / panel / browser -// / terminal); each dispatches to the focused executors above by `op`. Keeps the -// tool surface (and its token cost) small while preserving per-op behavior + -// self-protection. +// Op-routers - the agent sees four tools (layout / panel / browser / terminal); +// each dispatches to the focused executors above by `op`. // --------------------------------------------------------------------------- -/** Canvas-wide: read the layout (default) or rearrange panels. */ -export const execLayout: CateExecutor = async (params, ctx, agentKey) => { - return String(params.op ?? 'get') === 'arrange' - ? execArrange(params, ctx, agentKey) - : execGetLayout(params, ctx, agentKey) -} - /** 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 'focus': return execFocusPanel(params, ctx, agentKey) - case 'move': return execMovePanel(params, ctx, agentKey) - case 'resize': return execResizePanel(params, ctx, agentKey) case 'close': return execClosePanel(params, ctx, agentKey) - case 'preview': return execSetMarkdownPreview(params, ctx, agentKey) + case 'move': return execMovePanel(params, ctx, agentKey) default: - return fail(`panel: unknown op "${op}". Expected open|focus|move|resize|close|preview.`) + return fail(`panel: unknown op "${op}". Expected open|close|move.`) } } -/** Browser content: navigate a browser panel to a url (creates one if needed). */ +/** Browser control: drive an existing browser panel (opening is the panel tool). */ export const execBrowser: CateExecutor = async (params, ctx, agentKey) => { - return execOpenUrl(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) => { @@ -352,10 +464,9 @@ export const execTerminal: CateExecutor = async (params, ctx, agentKey) => { } } -// Register the 4-tool surface with the dispatcher. The routers delegate to the -// focused executors above. +// Register the 4-tool surface with the dispatcher. setCateExecutors({ - layout: execLayout, + layout: execGetLayout, panel: execPanel, browser: execBrowser, terminal: execTerminal, diff --git a/src/agent/renderer/cateToolDisplay.test.ts b/src/agent/renderer/cateToolDisplay.test.ts index f5dd4b82..48b4e690 100644 --- a/src/agent/renderer/cateToolDisplay.test.ts +++ b/src/agent/renderer/cateToolDisplay.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { cateToolDisplay, cateActionName } from './cateToolDisplay' +import { cateToolDisplay, cateActionName, cateToolFields } from './cateToolDisplay' describe('cateActionName', () => { it('strips the cate: prefix', () => { @@ -9,25 +9,27 @@ describe('cateActionName', () => { }) describe('cateToolDisplay', () => { - it('reads the canvas for layout with no op', () => { + it('reads the canvas for layout', () => { const d = cateToolDisplay('layout', {}) expect(d.verb).toBe('Read') expect(d.summary).toBe('canvas layout') }) - it('summarises a layout arrange with its style', () => { - const d = cateToolDisplay('layout', { op: 'arrange', style: 'grid' }) - expect(d.verb).toBe('Arranged') - expect(d.summary).toBe('panels · grid') - }) - it('summarises a browser navigate with its url', () => { - const d = cateToolDisplay('browser', { panelId: 'p1', url: 'https://example.com' }) + 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') @@ -41,10 +43,10 @@ describe('cateToolDisplay', () => { expect(d.summary).toBe('npm test') }) - it('summarises a terminal read by panelId', () => { - const d = cateToolDisplay('terminal', { op: 'read', panelId: 'p1' }) + 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 p1') + expect(d.summary).toBe('Terminal 2') }) it('summarises a terminal panel open with its command', () => { @@ -57,17 +59,17 @@ describe('cateToolDisplay', () => { expect(d.summary).toBe('document') }) - it('describes a resize op with its preset', () => { - const d = cateToolDisplay('panel', { op: 'resize', panelId: 'p1', preset: 'large' }) - expect(d.verb).toBe('Resized') - expect(d.summary).toBe('p1 → large') + 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', () => { - const d = cateToolDisplay('panel', { op: 'close', panelId: 'p1' }) + 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('p1') + expect(d.summary).toBe('Terminal 2') }) it('always returns a usable icon + verb + summary, even for unknown actions', () => { @@ -77,3 +79,45 @@ describe('cateToolDisplay', () => { 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 index 526ed1ec..e4c43c60 100644 --- a/src/agent/renderer/cateToolDisplay.ts +++ b/src/agent/renderer/cateToolDisplay.ts @@ -1,8 +1,9 @@ // ============================================================================= -// Presentation mapping for cate-control tool calls — turns an (action, params) +// 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 and the guarded-mode ApprovalCard so the agent panel -// renders Cate's canvas actions as compact custom cards instead of raw JSON. +// 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. // ============================================================================= @@ -14,11 +15,7 @@ import { Globe, SquaresFour, X, - Crosshair, ArrowsOutCardinal, - CornersOut, - GridFour, - Eye, type Icon as PhosphorIcon, } from '@phosphor-icons/react' @@ -28,7 +25,7 @@ export interface CateToolDisplay { verb: string /** Lowercase present-tense verb for an approval prompt ("open", "run", …). */ request: string - /** Short human label describing the target (path, command, url, panelId, …). */ + /** Short human label describing the target (title, path, command, url, …). */ summary: string } @@ -36,6 +33,73 @@ 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, @@ -53,18 +117,29 @@ export function cateToolDisplay( params: Record = {}, ): CateToolDisplay { const p = params ?? {} + const panel = (): string => str(p.panel) || 'panel' if (action === 'layout') { - if (str(p.op) === 'arrange') { - return { Icon: GridFour, verb: 'Arranged', request: 'arrange', summary: `panels · ${str(p.style) || str(p.layout) || 'tile'}` } - } return { Icon: Stack, verb: 'Read', request: 'read', summary: 'canvas layout' } } if (action === 'browser') { - return { Icon: Globe, verb: 'Navigated', request: 'navigate', summary: str(p.url) || str(p.panelId) || '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: `terminal ${str(p.panelId)}`.trim() } + return { Icon: Terminal, verb: 'Read', request: 'read', summary: panel() } } return { Icon: Terminal, verb: 'Ran', request: 'run', summary: str(p.command) || 'command' } } @@ -76,26 +151,12 @@ export function cateToolDisplay( 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 'focus': - return { Icon: Crosshair, verb: 'Focused', request: 'focus', summary: str(p.panelId) || 'panel' } - case 'move': - return { Icon: ArrowsOutCardinal, verb: 'Moved', request: 'move', summary: str(p.panelId) || 'panel' } - case 'resize': { - const size = str(p.preset) || (p.size && typeof p.size === 'object' ? 'custom' : '') - const panelId = str(p.panelId) || 'panel' - return { Icon: CornersOut, verb: 'Resized', request: 'resize', summary: size ? `${panelId} → ${size}` : panelId } - } case 'close': - return { Icon: X, verb: 'Closed', request: 'close', summary: str(p.panelId) || 'panel' } - case 'preview': - return { - Icon: Eye, - verb: p.preview === false ? 'Hid preview' : 'Previewed', - request: p.preview === false ? 'hide preview for' : 'preview', - summary: str(p.panelId) || 'panel', - } + 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) || str(p.panelId) || 'panel' } + 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/renderer/lib/cateControlLayout.test.ts b/src/renderer/lib/cateControlLayout.test.ts index 2d4a971d..799bb481 100644 --- a/src/renderer/lib/cateControlLayout.test.ts +++ b/src/renderer/lib/cateControlLayout.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { computePlacement, computeArrange, type Rect } from './cateControlLayout' +import { computePlacement, type Rect } from './cateControlLayout' const GAP = 40 @@ -41,28 +41,3 @@ describe('computePlacement', () => { expect(r.y).toBe(300) }) }) - -describe('computeArrange', () => { - const viewport = { x: 0, y: 0, width: 1000, height: 800 } - - it('tiles 4 rects into a 2x2 grid filling the viewport', () => { - const out = computeArrange('tile', 4, viewport) - expect(out).toHaveLength(4) - expect(out[0]).toEqual({ x: 0, y: 0, width: 500, height: 400 }) - expect(out[1]).toEqual({ x: 500, y: 0, width: 500, height: 400 }) - expect(out[2]).toEqual({ x: 0, y: 400, width: 500, height: 400 }) - expect(out[3]).toEqual({ x: 500, y: 400, width: 500, height: 400 }) - }) - - it('cascades rects with a fixed offset', () => { - const out = computeArrange('cascade', 3, viewport) - expect(out[0]).toEqual({ x: 0, y: 0, width: 600, height: 480 }) - expect(out[1]).toEqual({ x: 40, y: 40, width: 600, height: 480 }) - expect(out[2]).toEqual({ x: 80, y: 80, width: 600, height: 480 }) - }) - - it('returns one full-viewport rect for focus-one', () => { - const out = computeArrange('focus-one', 1, viewport) - expect(out).toEqual([{ x: 0, y: 0, width: 1000, height: 800 }]) - }) -}) diff --git a/src/renderer/lib/cateControlLayout.ts b/src/renderer/lib/cateControlLayout.ts index 9d369c02..de994c76 100644 --- a/src/renderer/lib/cateControlLayout.ts +++ b/src/renderer/lib/cateControlLayout.ts @@ -1,5 +1,5 @@ // ============================================================================= -// Pure placement geometry for the cate-control feature. No store / DOM access — +// 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. // ============================================================================= @@ -9,10 +9,8 @@ export interface Size { width: number; height: number } export interface Point { x: number; y: number } export type RelPosition = 'right' | 'left' | 'above' | 'below' -export type ArrangeLayout = 'tile' | 'grid' | 'cascade' | 'focus-one' const GAP = 40 -const CASCADE_STEP = 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 @@ -54,31 +52,3 @@ export function computePlacement(input: PlacementInput): Rect { } return candidate } - -/** Lay out `count` panels within the given viewport rect. */ -export function computeArrange(layout: ArrangeLayout, count: number, viewport: Rect): Rect[] { - if (count <= 0) return [] - if (layout === 'focus-one') { - return [{ x: viewport.x, y: viewport.y, width: viewport.width, height: viewport.height }] - } - if (layout === 'cascade') { - const w = Math.round(viewport.width * 0.6) - const h = Math.round(viewport.height * 0.6) - return Array.from({ length: count }, (_, i) => ({ - x: viewport.x + i * CASCADE_STEP, - y: viewport.y + i * CASCADE_STEP, - width: w, - height: h, - })) - } - // tile / grid: square-ish grid. - const cols = Math.ceil(Math.sqrt(count)) - const rows = Math.ceil(count / cols) - const cellW = Math.floor(viewport.width / cols) - const cellH = Math.floor(viewport.height / rows) - return Array.from({ length: count }, (_, i) => { - const col = i % cols - const row = Math.floor(i / cols) - return { x: viewport.x + col * cellW, y: viewport.y + row * cellH, width: cellW, height: cellH } - }) -} diff --git a/src/renderer/lib/e2eHarness.ts b/src/renderer/lib/e2eHarness.ts index 763eedd2..731f6a5c 100644 --- a/src/renderer/lib/e2eHarness.ts +++ b/src/renderer/lib/e2eHarness.ts @@ -29,12 +29,14 @@ declare global { 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. Side-effects are auto-approved. */ + * exactly as an agent tool call would. */ cateControl( action: string, params: Record, - ): Promise<{ ok: boolean; result?: unknown; error?: string; denied?: boolean }> + ): Promise<{ ok: boolean; result?: unknown; error?: string }> dragSnapshot(): { isDragging: boolean sourceKind: string | null @@ -152,6 +154,15 @@ export function installE2EHarness(): void { 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. @@ -165,7 +176,6 @@ export function installE2EHarness(): void { workspaceId: wsId, hostPanelId: 'e2e-host', canvasStore: cs!, - requestApproval: async () => true, }) return dispatchCateRequest('e2e-agent', { action: action as never, params }) } @@ -194,6 +204,7 @@ export function installE2EHarness(): void { 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/stores/appStore.ts b/src/renderer/stores/appStore.ts index 1b9d5629..672f35e5 100644 --- a/src/renderer/stores/appStore.ts +++ b/src/renderer/stores/appStore.ts @@ -318,6 +318,10 @@ interface AppStoreActions { * no longer fight the chosen name. */ renamePanelByUser: (workspaceId: string, panelId: string, title: string) => void updatePanelUrl: (workspaceId: string, panelId: string, url: string) => void + /** Return the panel's stable agent handle ("p1", "p2", …), assigning the next + * one (and persisting it) if the panel doesn't have one yet. Handles are + * monotonic per workspace and never reused. */ + ensurePanelAgentId: (workspaceId: string, panelId: string) => string updatePanelFilePath: (workspaceId: string, panelId: string, filePath: string) => void setPanelDirty: (workspaceId: string, panelId: string, dirty: boolean) => void setPanelMarkdownPreview: (workspaceId: string, panelId: string, preview: boolean) => void @@ -401,11 +405,19 @@ 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 + // Assign the next stable agent handle at creation, so handles follow + // creation order ("p1" = first panel opened). Restored panels already + // carry their persisted handle and skip this. + const seq = (ws.agentSeq ?? 0) + 1 + const placed: PanelState = panel.agentId ? panel : { ...panel, agentId: `p${seq}` } + return { + ...ws, + agentSeq: panel.agentId ? ws.agentSeq : seq, + panels: { ...ws.panels, [panel.id]: placed }, + } + }), })) try { placePanel(panel.id, panel.type, placement, position, workspaceId === get().selectedWorkspaceId) @@ -899,6 +911,23 @@ export const useAppStore = create((set, get) => ({ setPanelField(set, workspaceId, panelId, (panel) => ({ ...panel, url })) }, + ensurePanelAgentId(workspaceId, panelId) { + const ws0 = get().workspaces.find((w) => w.id === workspaceId) + const existing = ws0?.panels[panelId]?.agentId + if (existing) return existing + const next = (ws0?.agentSeq ?? 0) + 1 + const assigned = `p${next}` + set((state) => ({ + workspaces: state.workspaces.map((ws) => { + if (ws.id !== workspaceId) return ws + const panel = ws.panels[panelId] + if (!panel || panel.agentId) return ws + return { ...ws, agentSeq: next, panels: { ...ws.panels, [panelId]: { ...panel, agentId: assigned } } } + }), + })) + return get().workspaces.find((w) => w.id === workspaceId)?.panels[panelId]?.agentId ?? assigned + }, + updatePanelFilePath(workspaceId, panelId, filePath) { setPanelField(set, workspaceId, panelId, (panel) => ({ ...panel, filePath })) }, diff --git a/src/shared/cateControl.test.ts b/src/shared/cateControl.test.ts index 0a49e1ab..8ff0dd1e 100644 --- a/src/shared/cateControl.test.ts +++ b/src/shared/cateControl.test.ts @@ -1,34 +1,7 @@ import { describe, it, expect } from 'vitest' -import { classifyCateAction, CATE_SENTINEL } from './cateControl' - -describe('classifyCateAction', () => { - it('marks reads, focus and pure layout ops as safe', () => { - expect(classifyCateAction('layout', {})).toBe('safe') - expect(classifyCateAction('layout', { op: 'arrange', style: 'tile' })).toBe('safe') - expect(classifyCateAction('terminal', { op: 'read', panelId: 'p' })).toBe('safe') - expect(classifyCateAction('panel', { op: 'focus', panelId: 'p' })).toBe('safe') - expect(classifyCateAction('panel', { op: 'move', panelId: 'p' })).toBe('safe') - expect(classifyCateAction('panel', { op: 'resize', panelId: 'p' })).toBe('safe') - expect(classifyCateAction('panel', { op: 'preview', panelId: 'p' })).toBe('safe') - }) - - it('marks destructive and outbound ops as side-effect', () => { - expect(classifyCateAction('panel', { op: 'close', panelId: 'p' })).toBe('side-effect') - expect(classifyCateAction('browser', { panelId: 'p', url: 'https://x.com' })).toBe('side-effect') - expect(classifyCateAction('terminal', { op: 'run', command: 'ls' })).toBe('side-effect') - }) - - it('treats panel open as safe unless it carries an auto-run command or a remote url', () => { - expect(classifyCateAction('panel', { op: 'open', type: 'editor' })).toBe('safe') - expect(classifyCateAction('panel', { op: 'open', type: 'terminal', target: { command: 'npm test' } })).toBe('side-effect') - expect(classifyCateAction('panel', { op: 'open', type: 'browser', target: { url: 'https://x.com' } })).toBe('side-effect') - expect(classifyCateAction('panel', { op: 'open', type: 'browser', target: { url: 'file:///tmp/x.html' } })).toBe('safe') - }) - - it('treats a browser navigate to a local file url as safe', () => { - expect(classifyCateAction('browser', { panelId: 'p', url: 'file:///tmp/x.html' })).toBe('safe') - }) +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 index 23c7af82..5c8e57e2 100644 --- a/src/shared/cateControl.ts +++ b/src/shared/cateControl.ts @@ -14,17 +14,21 @@ export type CateControlAction = | 'browser' | 'terminal' -/** Sub-operations of the canvas-wide `layout` tool (read + rearrange). */ -export type LayoutOp = 'get' | 'arrange' - /** Sub-operations of the per-panel `panel` tool. */ -export type PanelOp = - | 'open' - | 'focus' - | 'move' - | 'resize' - | 'close' - | 'preview' +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' @@ -35,50 +39,9 @@ export interface CateControlRequest { params: Record } -/** Returned to the extension as the input() value (JSON-stringified). - * Invariant: `denied: true` implies `ok: false` and `result` is undefined. */ +/** Returned to the extension as the input() value (JSON-stringified). */ export interface CateControlResponse { ok: boolean result?: unknown error?: string - denied?: boolean -} - -export type CateActionClass = 'safe' | 'side-effect' - -/** A url that does not hit the network (local preview) stays safe. */ -function isRemoteUrl(url: unknown): boolean { - if (typeof url !== 'string') return false - return /^https?:\/\//i.test(url) -} - -/** Static classification + per-call escalation. Drives whether guarded mode - * requires approval. Pure — no side effects. Only destructive (close) and - * outbound (run a command, open/navigate a remote url) ops escalate; reads, - * focus, and pure layout stay safe. */ -export function classifyCateAction( - action: CateControlAction, - params: Record, -): CateActionClass { - switch (action) { - case 'terminal': - // run a command = side-effect; read output = safe. - return String(params.op ?? '') === 'read' ? 'safe' : 'side-effect' - case 'browser': - // navigating to a remote url sends a request; a local file:// preview is safe. - return isRemoteUrl(params.url) ? 'side-effect' : 'safe' - case 'panel': { - const op = String(params.op ?? '') - if (op === 'open') { - const target = (params.target ?? {}) as Record - if (typeof target.command === 'string' && target.command.trim()) return 'side-effect' - if (isRemoteUrl(target.url)) return 'side-effect' - return 'safe' - } - if (op === 'close') return 'side-effect' - return 'safe' - } - default: - return 'safe' // layout (get / arrange) — never destructive or outbound - } } diff --git a/src/shared/types.ts b/src/shared/types.ts index 3711b8a2..0b59b2ca 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -95,6 +95,11 @@ export interface PanelState { id: string type: PanelType title: string + /** Stable, short, human-friendly handle the Cate agent uses to target this + * panel (e.g. "p1", "p2"). Assigned once at creation and never changed - + * unlike `title`, which tracks the page/file and is only a display label. + * Persisted so a handle keeps pointing at the same panel across restarts. */ + agentId?: string isDirty: boolean filePath?: string url?: string @@ -330,6 +335,10 @@ export interface WorkspaceState { rootPathError?: string | null isRootPathPending?: boolean panels: Record + /** Monotonic high-water counter for `PanelState.agentId` handles. Bumped on + * every assignment and never decremented, so a closed panel's handle is + * never reused within the workspace (an "p3" always means the same panel). */ + agentSeq?: number // Primary canvas state (current behavior) canvasNodes: Record regions: Record @@ -946,7 +955,7 @@ export const DEFAULT_SETTINGS: AppSettings = { notifyOnlyWhenUnfocused: true, // Agent - cateControlEnabled: true, + cateControlEnabled: false, // Privacy crashReportingEnabled: true, From 0424c8419631ae670f5b7b46aa8dac0a49bca653 Mon Sep 17 00:00:00 2001 From: Anton Date: Mon, 1 Jun 2026 00:22:56 +0200 Subject: [PATCH 22/22] feat(agent): stable short-UUID panel ids for cate-control Make the handle the Cate agent uses to address panels survive a restart, and derive it from the panel's own UUID instead of a parallel counter. - Preserve a canvas panel's UUID across session restore (thread an optional id into the create* factories and reuse nodeSnap.panelId) so panel identity no longer changes on reload. The id was already persisted in workspace.json. - Expose the first 6 chars of the UUID as the panel id in cate_layout and the open/run results; resolvePanelRef resolves by exact id, then 6-char prefix, then exact title, and returns an error on an ambiguous prefix so the agent retries with a longer one. - cate_terminal run now returns {id, title} as its schema already promised. - Remove the p1/p2 agentId + agentSeq machinery and ensurePanelAgentId. --- src/agent/extensions/cate-control/index.ts | 12 +-- src/agent/main/sessionFiles.test.ts | 87 ++++++++++++++++++++++ src/agent/main/sessionFiles.ts | 29 ++++++++ src/agent/renderer/ChatThread.tsx | 67 +++++++++++++---- src/agent/renderer/cateExecutors.test.tsx | 64 ++++++++-------- src/agent/renderer/cateExecutors.ts | 51 +++++++------ src/agent/renderer/cateToolDisplay.test.ts | 18 ++++- src/agent/renderer/cateToolDisplay.ts | 11 ++- src/renderer/lib/session.ts | 10 +-- src/renderer/stores/appStore.ts | 67 +++++------------ src/shared/types.ts | 9 --- 11 files changed, 287 insertions(+), 138 deletions(-) create mode 100644 src/agent/main/sessionFiles.test.ts diff --git a/src/agent/extensions/cate-control/index.ts b/src/agent/extensions/cate-control/index.ts index 54bfb85a..ceb039f6 100644 --- a/src/agent/extensions/cate-control/index.ts +++ b/src/agent/extensions/cate-control/index.ts @@ -23,7 +23,7 @@ function toResult(action: string, res: CateResponse) { } const Placement = Type.Optional(Type.Object({ - relativeTo: Type.Optional(Type.String({ description: "panel id (e.g. \"p1\") or 'self'" })), + 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")])), })) @@ -56,7 +56,7 @@ export default function (pi: ExtensionAPI) { }) 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. \"p1\") - it is stable. `title` is only a display label and changes (a browser's title becomes the page title, etc.).", + "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", @@ -65,7 +65,7 @@ export default function (pi: ExtensionAPI) { "- '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 \"p1\" (from cate_layout or an open result), or 'self' for your own panel. This never pans or zooms the user's view.", + "`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")]), @@ -88,7 +88,7 @@ export default function (pi: ExtensionAPI) { "- '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. \"p1\", from cate_layout or the open result).", + "`panel` is a panel id (e.g. \"a1b2c3\", from cate_layout or the open result).", ].join("\n"), Type.Object({ op: Type.Union([ @@ -96,7 +96,7 @@ export default function (pi: ExtensionAPI) { 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. \"p1\"" }), + 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" })), @@ -107,7 +107,7 @@ export default function (pi: ExtensionAPI) { "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. \"p1\").", + "`panel` is a panel id (e.g. \"a1b2c3\").", ].join("\n"), Type.Object({ op: Type.Union([Type.Literal("run"), Type.Literal("read")]), 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/ChatThread.tsx b/src/agent/renderer/ChatThread.tsx index 76ca886e..72b117eb 100644 --- a/src/agent/renderer/ChatThread.tsx +++ b/src/agent/renderer/ChatThread.tsx @@ -33,7 +33,7 @@ import type { ToolMessage, } from './agentStore' import { deriveDiff } from './agentStore' -import { cateToolDisplay, cateActionName, cateToolFields, type CateField } from './cateToolDisplay' +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). @@ -374,7 +374,7 @@ function MessageRow({ : 'text-muted' return
{msg.text}
} - if (msg.type === 'tool' && msg.name.startsWith('cate:')) { + if (msg.type === 'tool' && isCateTool(msg.name)) { return } if (msg.type === 'tool' && msg.name === 'subagent') { @@ -632,22 +632,23 @@ function CateTerminalOutput({ text, lineCount }: { text: string; lineCount?: num {typeof lineCount === 'number' && (
{lineCount} line{lineCount === 1 ? '' : 's'}
)} -
+      
         {text || '(no output)'}
       
) } -// `layout get` result: one compact row per open panel — type + title (never the -// raw panelId), with focused/self tags. +// `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.type ?? 'panel')} +
+ {String(p.id ?? '')} + {String(p.type ?? 'panel')} {String(p.title || '(untitled)')} {p.focused === true && focused} {p.isSelf === true && self} @@ -657,6 +658,47 @@ function CatePanelList({ panels }: { panels: Array> }) { ) } +// 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 } @@ -684,6 +726,9 @@ function CateToolCard({ msg, shimmer }: { msg: ToolMessage; shimmer?: boolean }) if (action === 'layout' && Array.isArray(rec?.panels)) { return >} /> } + if (action === 'browser' && rec) { + return + } if (!rec && typeof result === 'string' && result) { return (
@@ -692,7 +737,7 @@ function CateToolCard({ msg, shimmer }: { msg: ToolMessage; shimmer?: boolean })
       )
     }
     return null
-  }, [action, result])
+  }, [action, result, params])
 
   const isRunning = msg.status === 'running' || msg.status === 'pending'
   const isDenied = msg.status === 'denied'
@@ -710,11 +755,7 @@ function CateToolCard({ msg, shimmer }: { msg: ToolMessage; shimmer?: boolean })
       {expanded && hasExtras && (
         
{fields.length > 0 && } - {resultNode && ( -
0 ? 'border-t border-white/5 pt-1.5' : undefined}> - {resultNode} -
- )} + {resultNode} {isDenied && (
Denied by user
)} diff --git a/src/agent/renderer/cateExecutors.test.tsx b/src/agent/renderer/cateExecutors.test.tsx index e56ec559..6b483e72 100644 --- a/src/agent/renderer/cateExecutors.test.tsx +++ b/src/agent/renderer/cateExecutors.test.tsx @@ -19,7 +19,7 @@ vi.mock('../../renderer/lib/logger', () => ({ // execOpenPanel routes file opens through openFileAsPanel (not createEditor directly). vi.mock('../../renderer/lib/fileRouting', () => ({ - openFileAsPanel: vi.fn(() => 'panel-ed'), + openFileAsPanel: vi.fn(() => 'ed-panel'), })) vi.mock('../../renderer/lib/editorReveal', () => ({ setPendingReveal: vi.fn(), @@ -38,19 +38,19 @@ vi.mock('../../renderer/stores/appStore', () => { __closed: closed, useAppStore: { getState: () => ({ - createEditor: (...a: any[]) => { created.push(['editor', ...a]); return 'panel-ed' }, - createTerminal: (...a: any[]) => { created.push(['terminal', ...a]); return 'panel-tm' }, - createBrowser: (...a: any[]) => { created.push(['browser', ...a]); return 'panel-br' }, - createDocument: (...a: any[]) => { created.push(['document', ...a]); return 'panel-doc' }, + 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]) }, - ensurePanelAgentId: (_wsId: string, panelId: string) => - (({ 'panel-ed': 'p1', 'panel-tm': 'p2', 'panel-br': 'p3' } as Record)[panelId] ?? `p-${panelId}`), workspaces: [{ id: 'w1', panels: { - 'panel-ed': { id: 'panel-ed', type: 'editor', title: 'a.ts', filePath: 'a.ts', agentId: 'p1' }, - 'panel-tm': { id: 'panel-tm', type: 'terminal', title: 'Terminal 1', agentId: 'p2' }, - 'panel-br': { id: 'panel-br', type: 'browser', title: 'Browser', agentId: 'p3' }, + // 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', }), @@ -71,9 +71,9 @@ beforeEach(async () => { 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 'panel-br'). + // Stand in for the live of the mock 'Browser' panel (id 'br-panel'). browserEvalResult = '' - portalRegistry.register('panel-br', { + portalRegistry.register('br-panel', { getWebContentsId: () => 1, getURL: () => 'https://example.com', getTitle: () => 'Example', @@ -88,7 +88,7 @@ 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('p1') + 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') @@ -108,22 +108,22 @@ describe('execOpenPanel', () => { it('focuses the panel it opens so it lands in view (open-focus fix)', async () => { const store = createCanvasStore() - store.getState().addNode('panel-br', 'browser', { x: 800, y: 800 }, { width: 100, height: 100 }) + 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('panel-br')) + 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('panel-br', 'browser', { x: 5000, y: 5000 }, { width: 100, height: 100 }) + 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('panel-br')! + const nodeId = store.getState().nodeForPanel('br-panel')! expect(store.getState().nodes[nodeId].origin).toEqual({ x: 450, y: 450 }) }) @@ -131,7 +131,7 @@ describe('execOpenPanel', () => { 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', 'panel-ed', true]) + expect(mod.__created.find((c: any[]) => c[0] === 'mdpreview')).toEqual(['mdpreview', 'w1', 'ed-panel', true]) }) }) @@ -147,14 +147,14 @@ describe('execClosePanel', () => { 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', 'panel-ed']) + expect(mod.__closed[0]).toEqual(['w1', 'ed-panel']) }) - it('closes a panel addressed by its stable id (p1)', async () => { - const res = await execClosePanel({ panel: 'p1' }, ctxWith(), 'k1') + 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', 'panel-ed']) + expect(mod.__closed[0]).toEqual(['w1', 'ed-panel']) }) it("refuses to close the agent's own panel", async () => { @@ -168,13 +168,13 @@ 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('panel-ed', 'editor', { x: 0, y: 0 }, { width: 100, height: 100 }) - store.getState().addNode('panel-tm', 'terminal', { x: 600, y: 0 }, { width: 100, height: 100 }) + 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('panel-ed')! + const node = store.getState().nodeForPanel('ed-panel')! expect(store.getState().nodes[node].origin.x).toBe(740) }) @@ -189,14 +189,14 @@ 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('panel-ed', 'editor', { x: 300, 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') - // stable agent handle is exposed as `id` (e.g. "p1") for targeting. - expect(panels.find((p) => p.title === 'a.ts')?.id).toBe('p1') + // 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) }) @@ -221,7 +221,7 @@ describe('content executors', () => { 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', 'panel-br', 'https://example.com']) + 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 () => { @@ -317,7 +317,7 @@ describe('terminal command reliability', () => { describe('op routers', () => { it('layout reads the canvas', async () => { const store = createCanvasStore() - store.getState().addNode('panel-ed', 'editor', { x: 0, y: 0 }, { width: 100, height: 100 }) + 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() @@ -333,13 +333,13 @@ describe('op routers', () => { 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', 'panel-ed']) + 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('panel-ed', 'editor', { x: 0, y: 0 }, { width: 100, height: 100 }) + 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') diff --git a/src/agent/renderer/cateExecutors.ts b/src/agent/renderer/cateExecutors.ts index a9053381..2b3e2727 100644 --- a/src/agent/renderer/cateExecutors.ts +++ b/src/agent/renderer/cateExecutors.ts @@ -3,9 +3,11 @@ // and returns a CateControlResponse. Pure-ish: side effects go through appStore / // canvasStore / terminalRegistry. Geometry comes from cateControlLayout. // -// The agent addresses panels by TITLE (e.g. "Terminal 2", "a.ts"), never by the -// internal UUID - resolvePanelRef() maps a title to a panelId, and results report -// titles back. 'self' refers to the agent's own host panel. +// 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' @@ -29,9 +31,9 @@ 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 { +function workspacePanels(ctx: CateControlContext): Record { const ws = useAppStore.getState().workspaces.find((w: any) => w.id === ctx.workspaceId) - return (ws?.panels ?? {}) as Record + return (ws?.panels ?? {}) as Record } /** Human title for a panelId (falls back to the id if the panel is gone). */ @@ -39,30 +41,35 @@ function titleFor(ctx: CateControlContext, panelId: string): string { return workspacePanels(ctx)[panelId]?.title || panelId } -/** The panel's stable agent handle ("p1", "p2", …), assigning one if needed. - * This is what the agent uses to target a panel - titles change, handles don't. */ -function agentIdFor(ctx: CateControlContext, panelId: string): string { - return useAppStore.getState().ensurePanelAgentId(ctx.workspaceId, 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 stable agent handle ("p1"), or 'self' for the - * agent's host panel - to its panelId. A raw panelId and an exact title are - * accepted as fallbacks, but the handle is the canonical, stable way to target - * a panel (titles track the page/file and change underfoot). */ +/** 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 "p1").' } + if (!s) return { error: 'missing `panel` (expected a panel id like "a1b2c3").' } if (s === 'self') return { panelId: ctx.hostPanelId } const panels = workspacePanels(ctx) - // Primary: the stable agent handle. - const byAgent = Object.keys(panels).filter((id) => panels[id]?.agentId === s) - if (byAgent.length === 1) return { panelId: byAgent[0] } - // Fallbacks: a raw panelId, then an exact (but possibly stale/ambiguous) title. + // 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. "p1") instead.` } + 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. */ @@ -160,7 +167,7 @@ export const execGetLayout: CateExecutor = async (_params, ctx) => { const out = Object.values(st.nodes).map((node: any) => { const panel = panels[node.panelId] return { - id: agentIdFor(ctx, node.panelId), + id: shortId(node.panelId), title: panel?.title ?? '', type: panel?.type ?? 'unknown', focused: st.focusedNodeId === node.id, @@ -226,7 +233,7 @@ export const execOpenPanel: CateExecutor = async (params, ctx) => { // 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: agentIdFor(ctx, panelId), title: titleFor(ctx, panelId), type }) + return ok({ id: shortId(panelId), title: titleFor(ctx, panelId), type }) } export const execClosePanel: CateExecutor = async (params, ctx) => { @@ -391,7 +398,7 @@ export const execRunInTerminal: CateExecutor = async (params, ctx) => { } 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({ terminal: titleFor(ctx, panelId), command }) + return ok({ id: shortId(panelId), terminal: titleFor(ctx, panelId), command }) } /** Read the recent buffer (visible screen + scrollback) of a terminal panel as diff --git a/src/agent/renderer/cateToolDisplay.test.ts b/src/agent/renderer/cateToolDisplay.test.ts index 48b4e690..679aa74d 100644 --- a/src/agent/renderer/cateToolDisplay.test.ts +++ b/src/agent/renderer/cateToolDisplay.test.ts @@ -1,11 +1,25 @@ import { describe, it, expect } from 'vitest' -import { cateToolDisplay, cateActionName, cateToolFields } from './cateToolDisplay' +import { cateToolDisplay, cateActionName, cateToolFields, isCateTool } from './cateToolDisplay' describe('cateActionName', () => { - it('strips the cate: prefix', () => { + 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', () => { diff --git a/src/agent/renderer/cateToolDisplay.ts b/src/agent/renderer/cateToolDisplay.ts index e4c43c60..c31c660f 100644 --- a/src/agent/renderer/cateToolDisplay.ts +++ b/src/agent/renderer/cateToolDisplay.ts @@ -107,9 +107,16 @@ const PANEL_ICONS: Record = { document: FileText, } -/** Strip the `cate:` prefix from a synthetic tool name, if present. */ +/** 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 toolName.startsWith('cate:') ? toolName.slice('cate:'.length) : toolName + return isCateTool(toolName) ? toolName.slice('cate:'.length) : toolName } export function cateToolDisplay( 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 672f35e5..5efc6ac5 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) => 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 @@ -318,10 +321,6 @@ interface AppStoreActions { * no longer fight the chosen name. */ renamePanelByUser: (workspaceId: string, panelId: string, title: string) => void updatePanelUrl: (workspaceId: string, panelId: string, url: string) => void - /** Return the panel's stable agent handle ("p1", "p2", …), assigning the next - * one (and persisting it) if the panel doesn't have one yet. Handles are - * monotonic per workspace and never reused. */ - ensurePanelAgentId: (workspaceId: string, panelId: string) => string updatePanelFilePath: (workspaceId: string, panelId: string, filePath: string) => void setPanelDirty: (workspaceId: string, panelId: string, dirty: boolean) => void setPanelMarkdownPreview: (workspaceId: string, panelId: string, preview: boolean) => void @@ -407,16 +406,7 @@ function addAndPlacePanel( set((state) => ({ workspaces: state.workspaces.map((ws) => { if (ws.id !== workspaceId) return ws - // Assign the next stable agent handle at creation, so handles follow - // creation order ("p1" = first panel opened). Restored panels already - // carry their persisted handle and skip this. - const seq = (ws.agentSeq ?? 0) + 1 - const placed: PanelState = panel.agentId ? panel : { ...panel, agentId: `p${seq}` } - return { - ...ws, - agentSeq: panel.agentId ? ws.agentSeq : seq, - panels: { ...ws.panels, [panel.id]: placed }, - } + return { ...ws, panels: { ...ws.panels, [panel.id]: panel } } }), })) try { @@ -738,8 +728,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. @@ -765,8 +755,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', @@ -777,8 +767,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, @@ -790,8 +780,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, @@ -829,9 +819,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, @@ -911,23 +901,6 @@ export const useAppStore = create((set, get) => ({ setPanelField(set, workspaceId, panelId, (panel) => ({ ...panel, url })) }, - ensurePanelAgentId(workspaceId, panelId) { - const ws0 = get().workspaces.find((w) => w.id === workspaceId) - const existing = ws0?.panels[panelId]?.agentId - if (existing) return existing - const next = (ws0?.agentSeq ?? 0) + 1 - const assigned = `p${next}` - set((state) => ({ - workspaces: state.workspaces.map((ws) => { - if (ws.id !== workspaceId) return ws - const panel = ws.panels[panelId] - if (!panel || panel.agentId) return ws - return { ...ws, agentSeq: next, panels: { ...ws.panels, [panelId]: { ...panel, agentId: assigned } } } - }), - })) - return get().workspaces.find((w) => w.id === workspaceId)?.panels[panelId]?.agentId ?? assigned - }, - updatePanelFilePath(workspaceId, panelId, filePath) { setPanelField(set, workspaceId, panelId, (panel) => ({ ...panel, filePath })) }, diff --git a/src/shared/types.ts b/src/shared/types.ts index 0b59b2ca..625141be 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -95,11 +95,6 @@ export interface PanelState { id: string type: PanelType title: string - /** Stable, short, human-friendly handle the Cate agent uses to target this - * panel (e.g. "p1", "p2"). Assigned once at creation and never changed - - * unlike `title`, which tracks the page/file and is only a display label. - * Persisted so a handle keeps pointing at the same panel across restarts. */ - agentId?: string isDirty: boolean filePath?: string url?: string @@ -335,10 +330,6 @@ export interface WorkspaceState { rootPathError?: string | null isRootPathPending?: boolean panels: Record - /** Monotonic high-water counter for `PanelState.agentId` handles. Bumped on - * every assignment and never decremented, so a closed panel's handle is - * never reused within the workspace (an "p3" always means the same panel). */ - agentSeq?: number // Primary canvas state (current behavior) canvasNodes: Record regions: Record