From f9d60ec6c706ac46934a09e9b14afe5ba8285bf4 Mon Sep 17 00:00:00 2001 From: Khang Date: Mon, 27 Apr 2026 18:35:33 -0400 Subject: [PATCH 1/4] feat(pi): open plan mode docs in Fleet modal --- .../2026-04-27-fleet-bridge-handler-type.md | 16 ++++ .../pi-extensions/fleet-plan-mode-policy.ts | 13 +-- resources/pi-extensions/fleet-plan-mode.ts | 45 +++++++--- src/main/__tests__/fleet-cli.test.ts | 17 ++++ .../fleet-plan-mode-extension.test.ts | 13 ++- src/main/fleet-cli.ts | 50 ++++++++++- src/main/index.ts | 26 +++++- src/main/socket-server.ts | 7 ++ src/main/socket-supervisor.ts | 4 + src/preload/index.ts | 3 + src/renderer/src/App.tsx | 13 +++ src/renderer/src/components/PiPlanModal.tsx | 88 +++++++++++++++++++ src/shared/ipc-api.ts | 4 + src/shared/ipc-channels.ts | 1 + 14 files changed, 264 insertions(+), 36 deletions(-) create mode 100644 docs/learnings/2026-04-27-fleet-bridge-handler-type.md create mode 100644 src/renderer/src/components/PiPlanModal.tsx diff --git a/docs/learnings/2026-04-27-fleet-bridge-handler-type.md b/docs/learnings/2026-04-27-fleet-bridge-handler-type.md new file mode 100644 index 00000000..b24dc43b --- /dev/null +++ b/docs/learnings/2026-04-27-fleet-bridge-handler-type.md @@ -0,0 +1,16 @@ +# Fleet bridge handler return type and lint + +## Symptom + +While adding the Pi plan-open bridge command, changing `FleetBridgeServer`'s `RequestHandler` from `Promise` to `unknown | Promise` made lint report: + +- `@typescript-eslint/no-redundant-type-constituents` because `unknown` absorbs the union +- `@typescript-eslint/await-thenable` at the call site that awaited the handler result + +## Fix + +Keep `RequestHandler` as `Promise` and keep bridge request handlers `async`. If a handler is currently synchronous, use a real async boundary rather than changing the framework type. + +## Lesson + +Do not use `unknown | Promise` for async callback return types in this codebase. It looks flexible, but lint treats `unknown` as overriding the promise branch and then flags awaited values as non-thenable. diff --git a/resources/pi-extensions/fleet-plan-mode-policy.ts b/resources/pi-extensions/fleet-plan-mode-policy.ts index d5548350..5f43b1a1 100644 --- a/resources/pi-extensions/fleet-plan-mode-policy.ts +++ b/resources/pi-extensions/fleet-plan-mode-policy.ts @@ -1,5 +1,3 @@ -const PLAN_PREVIEW_LINES = 60; - const BLOCKED_IN_PLAN = new Set(['write', 'edit', 'bash', 'fleet_run']); const REQUIRED_PLAN_MODE_TOOLS = ['read', 'grep', 'find', 'ls', 'fleet_open', 'exit_plan_mode']; @@ -15,13 +13,6 @@ export function getPlanModeActiveTools(currentActiveTools: string[]): string[] { return next; } -function previewPlan(plan: string): string { - const lines = plan.split('\n'); - if (lines.length <= PLAN_PREVIEW_LINES) return plan; - const remaining = lines.length - PLAN_PREVIEW_LINES; - return `${lines.slice(0, PLAN_PREVIEW_LINES).join('\n')}\n\n(${remaining} more lines)`; -} - -export function buildPlanApprovalMessage(planPath: string, plan: string): string { - return `Path: ${planPath}\n\n---\n\n${previewPlan(plan)}`; +export function buildPlanApprovalMessage(planPath: string): string { + return `Plan written to:\n${planPath}\n\nReview it in the Fleet plan modal, then approve when ready.`; } diff --git a/resources/pi-extensions/fleet-plan-mode.ts b/resources/pi-extensions/fleet-plan-mode.ts index d9fed79f..104afdbd 100644 --- a/resources/pi-extensions/fleet-plan-mode.ts +++ b/resources/pi-extensions/fleet-plan-mode.ts @@ -6,7 +6,7 @@ * swapped to hide write/exec tools via pi.setActiveTools, with a * tool_call blocker as a final policy gate. The LLM * produces a markdown plan via the exit_plan_mode tool; the plan is - * written to docs/plans/YYYY-MM-DD-.md after the user approves. + * written to docs/plans/YYYY-MM-DD-.md and opened in Fleet's plan modal. * * Also registers pi's built-in grep/find/ls tools, which are not in * the default toolset. @@ -28,6 +28,16 @@ import { shouldBlockPlanModeTool } from './fleet-plan-mode-policy.ts'; +type FleetBridgeClient = { + send: (type: string, payload: Record) => Promise; + onEvent: (handler: (type: string, payload: Record) => void) => void; + isConnected: () => boolean; +}; + +declare global { + var __fleetBridge: FleetBridgeClient | null; // eslint-disable-line no-var +} + const PLAN_MODE_STATUS_KEY = 'plan-mode'; const PLAN_MODE_STATUS_LABEL = 'πŸ“‹ Plan Mode'; @@ -49,7 +59,7 @@ You are in plan mode. Only read-only tools are available (write, edit, bash, fle 7. YAGNI. Plan only what's asked. No speculative features, flags, or abstractions. -When you have enough that another engineer could execute without asking questions, call exit_plan_mode.`; +When you have enough that another engineer could execute without asking questions, call exit_plan_mode. The plan will be written to a markdown file and opened in Fleet for review; do not include the full plan in a normal assistant message.`; const PLAN_MODE_BLOCK_REASON = 'Plan mode is active β€” this tool is disabled. Use read-only tools to investigate, then call exit_plan_mode with your plan.'; @@ -171,7 +181,7 @@ export default function (pi: ExtensionAPI): void { name: 'exit_plan_mode', label: 'Exit Plan Mode', description: - 'Call this when you have a complete plan ready for the user. Writes the plan to docs/plans/YYYY-MM-DD-.md after the user approves it, then exits plan mode so you can begin executing. Pass the plan as markdown in `plan` and a short kebab-case topic in `topic`.', + 'Call this when you have a complete plan ready for the user. Writes the plan to docs/plans/YYYY-MM-DD-.md, opens it in Fleet for review, then exits plan mode after approval so you can begin executing. Pass the plan as markdown in `plan` and a short kebab-case topic in `topic`.', parameters: ExitPlanModeParams, async execute(_toolCallId, params, _signal, _onUpdate, ctx) { @@ -200,27 +210,38 @@ export default function (pi: ExtensionAPI): void { } const planPath = resolvePlanPath(ctx.cwd, params.topic); - const approved = await ctx.ui.confirm( - 'Approve plan?', - buildPlanApprovalMessage(planPath, params.plan) - ); + const dir = join(ctx.cwd, 'docs', 'plans'); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + writeFileSync(planPath, params.plan, 'utf-8'); + + const bridge = globalThis.__fleetBridge; + if (bridge?.isConnected()) { + try { + await bridge.send('pi.plan_open', { path: planPath }); + } catch (err) { + ctx.ui.notify( + `Plan written, but Fleet could not open it: ${err instanceof Error ? err.message : String(err)}`, + 'warning' + ); + } + } else { + ctx.ui.notify('Plan written, but Fleet bridge is not connected.', 'warning'); + } + + const approved = await ctx.ui.confirm('Approve plan?', buildPlanApprovalMessage(planPath)); if (!approved) { return { content: [ { type: 'text' as const, - text: 'User rejected the plan. Revise based on their feedback and call exit_plan_mode again when ready.' + text: `User rejected the plan written to ${planPath}. Revise based on their feedback and call exit_plan_mode again when ready.` } ], details: undefined }; } - const dir = join(ctx.cwd, 'docs', 'plans'); - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); - writeFileSync(planPath, params.plan, 'utf-8'); - leavePlanMode(ctx); return { diff --git a/src/main/__tests__/fleet-cli.test.ts b/src/main/__tests__/fleet-cli.test.ts index 86c218ac..41a509b2 100644 --- a/src/main/__tests__/fleet-cli.test.ts +++ b/src/main/__tests__/fleet-cli.test.ts @@ -204,6 +204,23 @@ describe('--help via runCLI', () => { }); }); +// ── fleet pi plan_open tests ───────────────────────────────────────────────── + +describe('fleet pi plan_open', () => { + it('requires a path before opening the socket', async () => { + const out = await runCLI(['pi', 'plan_open'], '/tmp/no-socket.sock'); + expect(out).toBe('Usage: fleet pi plan_open '); + }); + + it('validates the plan file exists before opening the socket', async () => { + const out = await runCLI( + ['pi', 'plan_open', '/tmp/fleet-missing-plan.md'], + '/tmp/no-socket.sock' + ); + expect(out).toContain('file not found'); + }); +}); + // ── FleetCLI.send basic tests ───────────────────────────────────────────────── describe('FleetCLI.send', () => { diff --git a/src/main/__tests__/fleet-plan-mode-extension.test.ts b/src/main/__tests__/fleet-plan-mode-extension.test.ts index dea6902c..9009eb9f 100644 --- a/src/main/__tests__/fleet-plan-mode-extension.test.ts +++ b/src/main/__tests__/fleet-plan-mode-extension.test.ts @@ -27,14 +27,11 @@ describe('fleet plan mode extension helpers', () => { expect(shouldBlockPlanModeTool('read')).toBe(false); }); - it('builds approval text with target path and a 60-line plan preview', () => { - const plan = Array.from({ length: 62 }, (_, index) => `Line ${index + 1}`).join('\n'); - const message = buildPlanApprovalMessage('/repo/docs/plans/2026-04-25-demo.md', plan); + it('builds approval text with target path but no plan body', () => { + const message = buildPlanApprovalMessage('/repo/docs/plans/2026-04-25-demo.md'); - expect(message).toContain('Path: /repo/docs/plans/2026-04-25-demo.md'); - expect(message).toContain('Line 1'); - expect(message).toContain('Line 60'); - expect(message).not.toContain('Line 61'); - expect(message).toContain('(2 more lines)'); + expect(message).toContain('/repo/docs/plans/2026-04-25-demo.md'); + expect(message).toContain('Review it in the Fleet plan modal'); + expect(message).not.toContain('Line 1'); }); }); diff --git a/src/main/fleet-cli.ts b/src/main/fleet-cli.ts index 4e5e520a..8371a4f2 100644 --- a/src/main/fleet-cli.ts +++ b/src/main/fleet-cli.ts @@ -356,6 +356,7 @@ Manage images and open files from the terminal. | images | Generate, edit, and transform AI images. | | open | Open files or images in Fleet tabs. | | annotate | Visually annotate web page elements for AI agents. | +| pi | Open Pi agent tabs and Pi plan documents. | ## Examples @@ -391,6 +392,27 @@ Supports code files and common image formats (png, jpg, gif, webp, svg). fleet open src/main.ts fleet open screenshot.png diagram.svg fleet open ./README.md ../other-repo/notes.txt +\`\`\``, + + pi: `# fleet pi + +Open Pi agent tabs and Pi plan documents. + +## Usage + + fleet pi + fleet pi plan_open + +## Commands + + fleet pi Open a Pi agent tab for the current directory. + fleet pi plan_open Open a markdown plan in Fleet's plan modal. + +## Examples + +\`\`\`bash +fleet pi +fleet pi plan_open docs/plans/2026-04-27-my-plan.md \`\`\``, annotate: `# fleet annotate @@ -583,9 +605,29 @@ export async function runCLI( // ── Top-level "pi" command ─────────────────────────────────────────────── if (group === 'pi') { - const cwd = process.cwd(); - const command = 'pi.open'; - const args: Record = { cwd }; + const command = action === 'plan_open' ? 'pi.plan_open' : 'pi.open'; + const args: Record = {}; + let successMessage = 'Opening Pi agent in Fleet'; + + if (command === 'pi.plan_open') { + const rawPath = rest[0]; + if (!rawPath) return 'Usage: fleet pi plan_open '; + + const resolved = resolve(rawPath); + if (!existsSync(resolved)) return `Error: file not found: ${rawPath}`; + if (statSync(resolved).isDirectory()) { + return `Error: directories not supported, use a file path: ${rawPath}`; + } + if (isBinaryBlockedFilePath(resolved)) { + return `Error: unsupported binary file: ${rawPath}`; + } + + args.path = resolved; + successMessage = `Opened plan in Fleet: ${resolved}`; + } else { + if (action) return `Unknown pi command: ${action}\n\nUsage: fleet pi [plan_open ]`; + args.cwd = process.cwd(); + } const cli = new FleetCLI(sockPath); try { @@ -595,7 +637,7 @@ export async function runCLI( if (!response.ok) { return `Error: ${response.error ?? 'Unknown error'}`; } - return 'Opening Pi agent in Fleet'; + return successMessage; } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes('ECONNREFUSED') || msg.includes('ENOENT')) { diff --git a/src/main/index.ts b/src/main/index.ts index 69df5421..73b559d9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -349,6 +349,11 @@ void app.whenReady().then(async () => { mainWindow.webContents.send(IPC_CHANNELS.PI_OPEN, payload); } }); + socketSupervisor.on('pi-plan-open', (payload: unknown) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(IPC_CHANNELS.PI_PLAN_OPEN, payload); + } + }); socketSupervisor.start().catch((err: unknown) => { log.error('socket-supervisor failed to start', { error: err instanceof Error ? err.message : String(err) @@ -356,7 +361,8 @@ void app.whenReady().then(async () => { }); // Start Fleet bridge for Pi agent extensions - fleetBridge.onRequest(async (type, payload, _paneId) => { + fleetBridge.onRequest(async (type, payload) => { + await Promise.resolve(); switch (type) { case 'file.open': { const rawPath = typeof payload.path === 'string' ? payload.path : ''; @@ -379,6 +385,24 @@ void app.whenReady().then(async () => { } return { ok: true, paneType }; } + case 'pi.plan_open': { + const rawPath = typeof payload.path === 'string' ? payload.path : ''; + if (!rawPath) throw new Error('pi.plan_open requires a path'); + + const planPath = resolve(rawPath); + if (!existsSync(planPath)) throw new Error(`file not found: ${planPath}`); + if (statSync(planPath).isDirectory()) { + throw new Error(`directories not supported, use a file path: ${planPath}`); + } + if (isBinaryBlockedFilePath(planPath)) { + throw new Error(`unsupported binary file: ${planPath}`); + } + + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(IPC_CHANNELS.PI_PLAN_OPEN, { path: planPath }); + } + return { ok: true }; + } default: throw new Error(`Unknown bridge command: ${type}`); } diff --git a/src/main/socket-server.ts b/src/main/socket-server.ts index 3ed08745..e1988e6d 100644 --- a/src/main/socket-server.ts +++ b/src/main/socket-server.ts @@ -379,6 +379,13 @@ export class SocketServer extends EventEmitter { return { ok: true }; } + case 'pi.plan_open': { + const planPath = typeof args.path === 'string' ? args.path : undefined; + if (!planPath) throw new CodedError('pi.plan_open requires a path', 'BAD_REQUEST'); + this.emit('pi-plan-open', { path: planPath }); + return { ok: true }; + } + default: { throw new CodedError(`Unknown command: ${command}`, 'NOT_FOUND'); } diff --git a/src/main/socket-supervisor.ts b/src/main/socket-supervisor.ts index d3fd879b..6f06fa58 100644 --- a/src/main/socket-supervisor.ts +++ b/src/main/socket-supervisor.ts @@ -101,6 +101,10 @@ export class SocketSupervisor extends EventEmitter { this.emit('pi-open', ...args); }); + server.on('pi-plan-open', (...args: unknown[]) => { + this.emit('pi-plan-open', ...args); + }); + server.on('server-error', (err: Error) => { log.error('Server error detected', { error: err.message }); this.restart().catch((e) => diff --git a/src/preload/index.ts b/src/preload/index.ts index 0178eac4..2aa985af 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -29,6 +29,7 @@ import type { WorktreeCreateResponse, WorktreeRemoveRequest, PiOpenPayload, + PiPlanOpenPayload, PiLaunchConfig } from '../shared/ipc-api'; import type { @@ -332,6 +333,8 @@ const fleetApi = { pi: { onOpen: (callback: (payload: PiOpenPayload) => void): Unsubscribe => onChannel(IPC_CHANNELS.PI_OPEN, callback), + onPlanOpen: (callback: (payload: PiPlanOpenPayload) => void): Unsubscribe => + onChannel(IPC_CHANNELS.PI_PLAN_OPEN, callback), getLaunchConfig: async (paneId: string): Promise => typedInvoke(IPC_CHANNELS.PI_LAUNCH_CONFIG, { paneId }), getVersion: async (): Promise<{ version: string | null; installed: boolean }> => diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index ac52a59d..963c4cb2 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -28,6 +28,7 @@ import { TelescopeModal } from './components/Telescope/TelescopeModal'; import { ImageGallery } from './components/ImageGallery/ImageGallery'; import { AnnotateTab } from './components/AnnotateTab'; import { PiTab } from './components/PiTab'; +import { PiPlanModal } from './components/PiPlanModal'; import { AnnotateModal } from './components/AnnotateModal'; import { ToastContainer } from './components/ToastContainer'; @@ -130,6 +131,7 @@ export function App(): React.JSX.Element { const [fileSearchOpen, setFileSearchOpen] = useState(false); const [clipboardHistoryOpen, setClipboardHistoryOpen] = useState(false); const [telescopeOpen, setTelescopeOpen] = useState(false); + const [planModalPath, setPlanModalPath] = useState(null); const [updateReady, setUpdateReady] = useState(false); // Load settings on startup @@ -266,6 +268,16 @@ export function App(): React.JSX.Element { }; }, []); + // Open Pi plan document in modal via IPC (fleet pi plan_open / Pi extension bridge) + useEffect(() => { + const cleanup = window.fleet.pi.onPlanOpen((payload) => { + setPlanModalPath(payload.path); + }); + return () => { + cleanup(); + }; + }, []); + // Auto-updater useEffect(() => { const cleanup = window.fleet.updates.onUpdateStatus((status) => { @@ -859,6 +871,7 @@ export function App(): React.JSX.Element { cwd={focusedPaneCwd ?? window.fleet.homeDir} /> {}} /> + setPlanModalPath(null)} /> ); diff --git a/src/renderer/src/components/PiPlanModal.tsx b/src/renderer/src/components/PiPlanModal.tsx new file mode 100644 index 00000000..9d108b2f --- /dev/null +++ b/src/renderer/src/components/PiPlanModal.tsx @@ -0,0 +1,88 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { ExternalLink, X } from 'lucide-react'; +import { MarkdownPane } from './MarkdownPane'; +import { useWorkspaceStore } from '../store/workspace-store'; +import { getPaneTypeForFilePath } from '../../../shared/file-open'; + +type PiPlanModalProps = { + filePath: string | null; + onClose: () => void; +}; + +export function PiPlanModal({ filePath, onClose }: PiPlanModalProps): React.JSX.Element | null { + const modalRef = useRef(null); + const paneIdRef = useRef(`pi-plan-modal-${crypto.randomUUID()}`); + const openFileInTab = useWorkspaceStore((s) => s.openFileInTab); + + useEffect(() => { + if (!filePath) return; + modalRef.current?.focus(); + }, [filePath]); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key !== 'Escape') return; + event.preventDefault(); + event.stopPropagation(); + onClose(); + }, + [onClose] + ); + + const handleOpenInTab = useCallback(() => { + if (!filePath) return; + openFileInTab([ + { + path: filePath, + paneType: getPaneTypeForFilePath(filePath), + label: filePath.split('/').pop() ?? filePath + } + ]); + }, [filePath, openFileInTab]); + + if (!filePath) return null; + + return ( +
+
event.stopPropagation()} + className="bg-neutral-900 border border-neutral-700 rounded-lg shadow-xl flex flex-col outline-none overflow-hidden" + style={{ width: 'calc(100vw - 64px)', height: 'calc(100vh - 48px)' }} + > +
+
+
Pi Plan
+
+ {filePath} +
+
+
+ + +
+
+
+ +
+
+
+ ); +} diff --git a/src/shared/ipc-api.ts b/src/shared/ipc-api.ts index 62669446..7b9a68fa 100644 --- a/src/shared/ipc-api.ts +++ b/src/shared/ipc-api.ts @@ -26,6 +26,10 @@ export type PiOpenPayload = { cwd: string; }; +export type PiPlanOpenPayload = { + path: string; +}; + export type PiLaunchConfig = { cmd: string; }; diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index 5703dc41..e6247c46 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -93,6 +93,7 @@ export const IPC_CHANNELS = { ANNOTATE_DELETE: 'annotate:delete', // Pi Agent PI_OPEN: 'pi:open', + PI_PLAN_OPEN: 'pi:plan-open', PI_LAUNCH_CONFIG: 'pi:launch-config', PI_VERSION: 'pi:version', PI_CHECK_UPDATES: 'pi:check-updates', From 54f5415c60ffea1867b0576a170c96be591d04a6 Mon Sep 17 00:00:00 2001 From: Khang Date: Mon, 27 Apr 2026 18:41:26 -0400 Subject: [PATCH 2/4] feat(pi): respond to plan modal actions --- resources/pi-extensions/fleet-plan-mode.ts | 83 +++++++++++++++-- src/main/index.ts | 9 +- src/main/ipc-handlers.ts | 14 ++- src/preload/index.ts | 3 + src/renderer/src/App.tsx | 7 +- src/renderer/src/components/PiPlanModal.tsx | 99 ++++++++++++++++++--- src/shared/ipc-api.ts | 11 +++ src/shared/ipc-channels.ts | 1 + 8 files changed, 205 insertions(+), 22 deletions(-) diff --git a/resources/pi-extensions/fleet-plan-mode.ts b/resources/pi-extensions/fleet-plan-mode.ts index 104afdbd..903f4213 100644 --- a/resources/pi-extensions/fleet-plan-mode.ts +++ b/resources/pi-extensions/fleet-plan-mode.ts @@ -20,6 +20,7 @@ import { type ExtensionContext } from '@mariozechner/pi-coding-agent'; import { Type } from '@sinclair/typebox'; +import { randomUUID } from 'node:crypto'; import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { @@ -66,6 +67,51 @@ const PLAN_MODE_BLOCK_REASON = const TOPIC_PATTERN = /^[a-z0-9][a-z0-9-]*$/; +type PlanReviewAction = 'approve' | 'reject' | 'continue'; + +type PlanReviewResponse = { + action: PlanReviewAction; + feedback?: string; +}; + +type PendingPlanResponse = { + resolve: (response: PlanReviewResponse) => void; +}; + +const pendingPlanResponses = new Map(); + +function isPlanReviewAction(value: unknown): value is PlanReviewAction { + return value === 'approve' || value === 'reject' || value === 'continue'; +} + +function createPlanResponseWaiter(requestId: string, signal: AbortSignal | undefined) { + let abortHandler: (() => void) | undefined; + let cleanup = () => { + pendingPlanResponses.delete(requestId); + }; + + const promise = new Promise((resolve) => { + cleanup = () => { + pendingPlanResponses.delete(requestId); + if (abortHandler) signal?.removeEventListener('abort', abortHandler); + }; + const finish = (response: PlanReviewResponse) => { + cleanup(); + resolve(response); + }; + abortHandler = () => finish({ action: 'continue' }); + + pendingPlanResponses.set(requestId, { resolve: finish }); + if (signal?.aborted) { + abortHandler(); + return; + } + signal?.addEventListener('abort', abortHandler, { once: true }); + }); + + return { promise, cancel: cleanup }; +} + const ExitPlanModeParams = Type.Object({ plan: Type.String({ description: @@ -105,6 +151,18 @@ export default function (pi: ExtensionAPI): void { pi.registerTool(createFindToolDefinition(cwd)); pi.registerTool(createLsToolDefinition(cwd)); + globalThis.__fleetBridge?.onEvent((type, payload) => { + if (type !== 'pi.plan_response') return; + const requestId = typeof payload.requestId === 'string' ? payload.requestId : ''; + const action = payload.action; + if (!requestId || !isPlanReviewAction(action)) return; + + pendingPlanResponses.get(requestId)?.resolve({ + action, + feedback: typeof payload.feedback === 'string' ? payload.feedback : undefined + }); + }); + function enterPlanMode(ctx: ExtensionContext): void { const activeTools = pi.getActiveTools(); savedActiveTools = activeTools; @@ -161,6 +219,10 @@ export default function (pi: ExtensionAPI): void { }); pi.on('session_shutdown', async (_event, ctx) => { + for (const pending of pendingPlanResponses.values()) { + pending.resolve({ action: 'continue' }); + } + pendingPlanResponses.clear(); if (planMode || savedActiveTools) leavePlanMode(ctx); }); @@ -184,7 +246,7 @@ export default function (pi: ExtensionAPI): void { 'Call this when you have a complete plan ready for the user. Writes the plan to docs/plans/YYYY-MM-DD-.md, opens it in Fleet for review, then exits plan mode after approval so you can begin executing. Pass the plan as markdown in `plan` and a short kebab-case topic in `topic`.', parameters: ExitPlanModeParams, - async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + async execute(_toolCallId, params, signal, _onUpdate, ctx) { if (!planMode) { return { content: [ @@ -214,11 +276,16 @@ export default function (pi: ExtensionAPI): void { if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); writeFileSync(planPath, params.plan, 'utf-8'); + let review: PlanReviewResponse | null = null; const bridge = globalThis.__fleetBridge; if (bridge?.isConnected()) { + const requestId = randomUUID(); + const responseWaiter = createPlanResponseWaiter(requestId, signal); try { - await bridge.send('pi.plan_open', { path: planPath }); + await bridge.send('pi.plan_open', { path: planPath, requestId }); + review = await responseWaiter.promise; } catch (err) { + responseWaiter.cancel(); ctx.ui.notify( `Plan written, but Fleet could not open it: ${err instanceof Error ? err.message : String(err)}`, 'warning' @@ -228,14 +295,20 @@ export default function (pi: ExtensionAPI): void { ctx.ui.notify('Plan written, but Fleet bridge is not connected.', 'warning'); } - const approved = await ctx.ui.confirm('Approve plan?', buildPlanApprovalMessage(planPath)); + if (!review) { + const approved = await ctx.ui.confirm('Approve plan?', buildPlanApprovalMessage(planPath)); + review = { action: approved ? 'approve' : 'reject' }; + } - if (!approved) { + if (review.action !== 'approve') { + const feedback = review.feedback?.trim(); + const actionText = + review.action === 'reject' ? 'rejected the plan' : 'requested more planning'; return { content: [ { type: 'text' as const, - text: `User rejected the plan written to ${planPath}. Revise based on their feedback and call exit_plan_mode again when ready.` + text: `User ${actionText} for ${planPath}.${feedback ? ` Feedback: ${feedback}` : ''} Revise based on their feedback and call exit_plan_mode again when ready.` } ], details: undefined diff --git a/src/main/index.ts b/src/main/index.ts index 73b559d9..3ebdbadf 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -361,7 +361,7 @@ void app.whenReady().then(async () => { }); // Start Fleet bridge for Pi agent extensions - fleetBridge.onRequest(async (type, payload) => { + fleetBridge.onRequest(async (type, payload, paneId) => { await Promise.resolve(); switch (type) { case 'file.open': { @@ -387,6 +387,7 @@ void app.whenReady().then(async () => { } case 'pi.plan_open': { const rawPath = typeof payload.path === 'string' ? payload.path : ''; + const requestId = typeof payload.requestId === 'string' ? payload.requestId : undefined; if (!rawPath) throw new Error('pi.plan_open requires a path'); const planPath = resolve(rawPath); @@ -399,7 +400,11 @@ void app.whenReady().then(async () => { } if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send(IPC_CHANNELS.PI_PLAN_OPEN, { path: planPath }); + mainWindow.webContents.send(IPC_CHANNELS.PI_PLAN_OPEN, { + path: planPath, + paneId, + requestId + }); } return { ok: true }; } diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index 668596cb..cdeadf76 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -26,7 +26,8 @@ import type { FileGrepRequest, LogEntry, WorktreeCreateRequest, - WorktreeRemoveRequest + WorktreeRemoveRequest, + PiPlanResponseRequest } from '../shared/ipc-api'; import type { Workspace } from '../shared/types'; import type { PtyManager } from './pty-manager'; @@ -548,6 +549,17 @@ export function registerIpcHandlers( installed: piAgentManager.isInstalled() })); + ipcMain.handle(IPC_CHANNELS.PI_PLAN_RESPOND, (_event, req: PiPlanResponseRequest) => { + fleetBridge.sendEvent(req.paneId, { + type: 'pi.plan_response', + payload: { + requestId: req.requestId, + action: req.action, + feedback: req.feedback ?? '' + } + }); + }); + ipcMain.handle(IPC_CHANNELS.PI_CHECK_UPDATES, async () => { return piAgentManager.checkForUpdates(); }); diff --git a/src/preload/index.ts b/src/preload/index.ts index 2aa985af..8ffd7f83 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -30,6 +30,7 @@ import type { WorktreeRemoveRequest, PiOpenPayload, PiPlanOpenPayload, + PiPlanResponseRequest, PiLaunchConfig } from '../shared/ipc-api'; import type { @@ -335,6 +336,8 @@ const fleetApi = { onChannel(IPC_CHANNELS.PI_OPEN, callback), onPlanOpen: (callback: (payload: PiPlanOpenPayload) => void): Unsubscribe => onChannel(IPC_CHANNELS.PI_PLAN_OPEN, callback), + respondToPlan: async (req: PiPlanResponseRequest): Promise => + typedInvoke(IPC_CHANNELS.PI_PLAN_RESPOND, req), getLaunchConfig: async (paneId: string): Promise => typedInvoke(IPC_CHANNELS.PI_LAUNCH_CONFIG, { paneId }), getVersion: async (): Promise<{ version: string | null; installed: boolean }> => diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 963c4cb2..5f64bb56 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -31,6 +31,7 @@ import { PiTab } from './components/PiTab'; import { PiPlanModal } from './components/PiPlanModal'; import { AnnotateModal } from './components/AnnotateModal'; import { ToastContainer } from './components/ToastContainer'; +import type { PiPlanOpenPayload } from '../../shared/ipc-api'; function MiniSidebarTooltip({ label, @@ -131,7 +132,7 @@ export function App(): React.JSX.Element { const [fileSearchOpen, setFileSearchOpen] = useState(false); const [clipboardHistoryOpen, setClipboardHistoryOpen] = useState(false); const [telescopeOpen, setTelescopeOpen] = useState(false); - const [planModalPath, setPlanModalPath] = useState(null); + const [planModal, setPlanModal] = useState(null); const [updateReady, setUpdateReady] = useState(false); // Load settings on startup @@ -271,7 +272,7 @@ export function App(): React.JSX.Element { // Open Pi plan document in modal via IPC (fleet pi plan_open / Pi extension bridge) useEffect(() => { const cleanup = window.fleet.pi.onPlanOpen((payload) => { - setPlanModalPath(payload.path); + setPlanModal(payload); }); return () => { cleanup(); @@ -871,7 +872,7 @@ export function App(): React.JSX.Element { cwd={focusedPaneCwd ?? window.fleet.homeDir} /> {}} /> - setPlanModalPath(null)} /> + setPlanModal(null)} /> ); diff --git a/src/renderer/src/components/PiPlanModal.tsx b/src/renderer/src/components/PiPlanModal.tsx index 9d108b2f..792e3d5c 100644 --- a/src/renderer/src/components/PiPlanModal.tsx +++ b/src/renderer/src/components/PiPlanModal.tsx @@ -1,32 +1,71 @@ -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ExternalLink, X } from 'lucide-react'; import { MarkdownPane } from './MarkdownPane'; import { useWorkspaceStore } from '../store/workspace-store'; import { getPaneTypeForFilePath } from '../../../shared/file-open'; +import type { PiPlanAction, PiPlanOpenPayload } from '../../../shared/ipc-api'; type PiPlanModalProps = { - filePath: string | null; + plan: PiPlanOpenPayload | null; onClose: () => void; }; -export function PiPlanModal({ filePath, onClose }: PiPlanModalProps): React.JSX.Element | null { +export function PiPlanModal({ plan, onClose }: PiPlanModalProps): React.JSX.Element | null { const modalRef = useRef(null); const paneIdRef = useRef(`pi-plan-modal-${crypto.randomUUID()}`); + const [feedback, setFeedback] = useState(''); + const [submittingAction, setSubmittingAction] = useState(null); const openFileInTab = useWorkspaceStore((s) => s.openFileInTab); + const filePath = plan?.path ?? null; + const canRespond = Boolean(plan?.paneId && plan.requestId); + useEffect(() => { if (!filePath) return; + setFeedback(''); + setSubmittingAction(null); modalRef.current?.focus(); - }, [filePath]); + }, [filePath, plan?.requestId]); + + const respond = useCallback( + async (action: PiPlanAction) => { + if (!plan?.paneId || !plan.requestId) { + onClose(); + return; + } + + setSubmittingAction(action); + try { + await window.fleet.pi.respondToPlan({ + paneId: plan.paneId, + requestId: plan.requestId, + action, + feedback: feedback.trim() || undefined + }); + } finally { + setSubmittingAction(null); + onClose(); + } + }, + [feedback, onClose, plan?.paneId, plan?.requestId] + ); + + const closeOrContinue = useCallback(() => { + if (canRespond) { + void respond('continue'); + return; + } + onClose(); + }, [canRespond, onClose, respond]); const handleKeyDown = useCallback( (event: React.KeyboardEvent) => { if (event.key !== 'Escape') return; event.preventDefault(); event.stopPropagation(); - onClose(); + closeOrContinue(); }, - [onClose] + [closeOrContinue] ); const handleOpenInTab = useCallback(() => { @@ -40,13 +79,15 @@ export function PiPlanModal({ filePath, onClose }: PiPlanModalProps): React.JSX. ]); }, [filePath, openFileInTab]); + const footerHint = useMemo(() => { + if (!canRespond) return 'Opened from Fleet CLI. This modal is read-only.'; + return 'Approve exits plan mode. Reject or Continue keeps Pi in plan mode with your feedback.'; + }, [canRespond]); + if (!filePath) return null; return ( -
+