diff --git a/src/App.tsx b/src/App.tsx index 4662d59..80602ce 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,10 @@ import { mcpStatus as mcpStatusCmd, mcpRegenerateToken, mcpUpdateState, + onMcpRequest, + mcpActionReply, + mcpPolicyLoad, + mcpPolicySave, writeToPane, killPane, type PaneId, @@ -20,7 +24,10 @@ import { type McpMirror, type McpMirroredLeaf, type McpMirroredHost, + type McpActionRequest, + type McpAuditEntry, } from "./ipc"; +import { listen } from "@tauri-apps/api/event"; import { type TreeNode, type NodeId, @@ -64,6 +71,7 @@ import Palette from "./components/Palette"; import HostManager from "./components/HostManager"; import Help from "./components/Help"; import McpPanel from "./components/McpPanel"; +import McpConfirm, { type McpConfirmSpec } from "./components/McpConfirm"; import "./App.css"; import "./lib/layout/Gutter.css"; @@ -728,6 +736,154 @@ export default function App() { ); }, [mcpStatus.running, tree, hosts, activeLeafId]); + // ---- MCP audit log ------------------------------------------------------- + // Subscribed at App level so events received while the panel is closed (or + // while the user is on another tab) still land in the ring buffer. The + // McpPanel consumes the entries via props. + const AUDIT_RING_CAP = 200; + const [auditEntries, setAuditEntries] = useState([]); + const clearAudit = useCallback(() => setAuditEntries([]), []); + + useEffect(() => { + // StrictMode double-mounts effects in dev; the listen() Promise may + // resolve AFTER the first cleanup runs. Guard with a cancelled flag so + // a late-resolving subscription gets immediately torn down instead of + // leaking into the second mount (which would double every event). + let cancelled = false; + let unlisten: (() => void) | undefined; + void listen("mcp://audit", (e) => { + setAuditEntries((prev) => { + const next = [e.payload, ...prev]; + return next.length > AUDIT_RING_CAP ? next.slice(0, AUDIT_RING_CAP) : next; + }); + }).then((fn) => { + if (cancelled) fn(); + else unlisten = fn; + }); + return () => { + cancelled = true; + if (unlisten) unlisten(); + }; + }, []); + + // ---- MCP action dispatcher ----------------------------------------------- + // Backend tools that mutate workspace state emit "mcp://request" events with + // a requestId + args. We resolve them via mcp_action_reply(requestId, Ok|Err). + // When needsConfirm is true, a modal queues up first; user Accept = run, + // Reject = reply with Err. Per-tool handlers live in `runMcpHandler` below. + + interface ConfirmEntry extends McpConfirmSpec { + resolve: (accept: boolean) => void; + } + const [confirmQueue, setConfirmQueue] = useState([]); + + const requestConfirm = useCallback( + (spec: McpConfirmSpec): Promise => + new Promise((resolve) => + setConfirmQueue((q) => [...q, { ...spec, resolve }]), + ), + [], + ); + + const dismissConfirm = useCallback((accept: boolean) => { + setConfirmQueue((q) => { + const [head, ...rest] = q; + if (head) head.resolve(accept); + return rest; + }); + }, []); + + // "Always allow" from the confirm modal: appends the bare tool name to + // the policy's allow bucket so future calls of this tool skip the prompt. + // Idempotent — duplicate adds are no-ops. + const alwaysAllowTool = useCallback(async (tool: string) => { + try { + const policy = await mcpPolicyLoad(); + if (!policy.permissions.allow.includes(tool)) { + policy.permissions.allow.push(tool); + await mcpPolicySave(policy); + notify(`Added "${tool}" to MCP allow list — future calls won't prompt`); + } + } catch (e) { + notify(`Failed to update MCP policy: ${e}`); + } + }, [notify]); + + // Per-tool handler. Each branch validates args, applies the mutation via the + // same setters the UI uses, and returns a JSON-serialisable payload. Throws + // on validation failure — caller converts to Err reply. + const runMcpHandler = useCallback( + async (tool: string, args: unknown): Promise<{ payload: unknown; summary: string }> => { + switch (tool) { + case "set_label": { + const a = args as { leafId?: string; label?: string }; + if (typeof a.leafId !== "string") throw new Error("missing leafId"); + if (typeof a.label !== "string") throw new Error("missing label"); + const leaf = findLeaf(treeRef.current, a.leafId); + if (!leaf || leaf.kind !== "leaf") throw new Error(`leaf not found: ${a.leafId}`); + const before = leaf.label ?? "(unlabelled)"; + setLabel(a.leafId, a.label || undefined); + const after = a.label || "(cleared)"; + return { + payload: { leafId: a.leafId, label: a.label }, + summary: `Rename pane "${before}" → "${after}"`, + }; + } + default: + throw new Error(`unsupported MCP tool: ${tool}`); + } + }, + [setLabel], + ); + + // The summary string for the confirm modal needs access to the leaf + // metadata, so we compute it up-front by partially running the handler + // logic (without mutating). For now we just rebuild it inline per tool; + // when more tools land this should split out. + const buildConfirmSummary = useCallback((tool: string, args: unknown): string => { + if (tool === "set_label") { + const a = args as { leafId?: string; label?: string }; + const leaf = a.leafId ? findLeaf(treeRef.current, a.leafId) : null; + const before = leaf && leaf.kind === "leaf" ? (leaf.label ?? "(unlabelled)") : "(unknown)"; + const after = a.label || "(cleared)"; + return `Rename pane "${before}" → "${after}"`; + } + return `Run ${tool}`; + }, []); + + useEffect(() => { + let cancelled = false; + let unlisten: (() => void) | undefined; + void onMcpRequest(async (req: McpActionRequest) => { + try { + if (req.needsConfirm) { + const summary = buildConfirmSummary(req.tool, req.args); + const ok = await requestConfirm({ + tool: req.tool, + args: req.args, + reason: req.reason, + summary, + }); + if (!ok) { + await mcpActionReply(req.requestId, { Err: "user rejected" }); + return; + } + } + const { payload } = await runMcpHandler(req.tool, req.args); + await mcpActionReply(req.requestId, { Ok: payload }); + } catch (err) { + await mcpActionReply(req.requestId, { Err: String(err) }); + } + }).then((fn) => { + if (cancelled) fn(); + else unlisten = fn; + }); + return () => { + cancelled = true; + if (unlisten) unlisten(); + }; + }, [runMcpHandler, requestConfirm, buildConfirmSummary]); + const applyPreset = useCallback( (make: (d: Partial) => TreeNode) => { const { tree: nextTree, dropped } = reshapeToPreset( @@ -1070,6 +1226,20 @@ export default function App() { onClose={() => setMcpPanelOpen(false)} allowedPaneCount={allowedPaneCount} totalPaneCount={leafCount(tree)} + auditEntries={auditEntries} + onClearAudit={clearAudit} + /> + )} + + {confirmQueue.length > 0 && ( + dismissConfirm(true)} + onReject={() => dismissConfirm(false)} + onAlwaysAllow={async () => { + await alwaysAllowTool(confirmQueue[0].tool); + dismissConfirm(true); + }} /> )} diff --git a/src/components/AuditTab.tsx b/src/components/AuditTab.tsx index 5b2acc1..9ade1c4 100644 --- a/src/components/AuditTab.tsx +++ b/src/components/AuditTab.tsx @@ -1,9 +1,5 @@ -import { useEffect, useState } from "react"; -import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import type { McpAuditEntry } from "../ipc"; -const RING_CAP = 200; - function fmtTime(tsMs: number): string { const d = new Date(tsMs); const hh = String(d.getHours()).padStart(2, "0"); @@ -38,49 +34,18 @@ function rowClass(result: McpAuditEntry["result"]): string { } interface AuditTabProps { - /** Called when there are unread entries (tab not active). */ - onUnread?: () => void; - /** True when this tab is the currently visible tab — clears unread. */ - active?: boolean; + /** Audit ring, owned by App so it persists across panel open/close. */ + entries: McpAuditEntry[]; + onClear: () => void; } -export default function AuditTab({ onUnread, active }: AuditTabProps) { - const [entries, setEntries] = useState([]); - const [unread, setUnread] = useState(0); - - useEffect(() => { - let unlisten: UnlistenFn | undefined; - void listen("mcp://audit", (e) => { - setEntries((prev) => { - const next = [e.payload, ...prev]; - return next.length > RING_CAP ? next.slice(0, RING_CAP) : next; - }); - if (!active) { - setUnread((n) => n + 1); - onUnread?.(); - } - }).then((fn) => { - unlisten = fn; - }); - return () => { - if (unlisten) unlisten(); - }; - }, [active, onUnread]); - - // Clear unread badge when tab becomes active. - useEffect(() => { - if (active) setUnread(0); - }, [active]); - +export default function AuditTab({ entries, onClear }: AuditTabProps) { return (
- {unread > 0 && !active && ( - {unread} new - )} + + + +
+ + ); +} diff --git a/src/components/McpPanel.css b/src/components/McpPanel.css index 4d620d8..d8bf492 100644 --- a/src/components/McpPanel.css +++ b/src/components/McpPanel.css @@ -596,3 +596,117 @@ margin: 8px 0 0; line-height: 1.4; } + +/* ---- Confirm modal ------------------------------------------------------ */ + +.mcp-confirm { + position: fixed; + top: 20vh; + left: 50%; + transform: translateX(-50%); + width: min(520px, 92vw); + max-height: 60vh; + background: #161616; + color: #ccc; + border: 1px solid #c09040; + border-radius: 8px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.7); + z-index: 200; + display: flex; + flex-direction: column; + overflow: hidden; + font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace; +} + +.mcp-confirm-header { + padding: 10px 14px; + border-bottom: 1px solid #2a2a2a; + background: linear-gradient(180deg, #2a2010, #161616); +} +.mcp-confirm-title { font-size: 13px; font-weight: 600; } +.mcp-confirm-title code { + color: #c09040; + background: transparent; + font-size: 12px; +} + +.mcp-confirm-body { + padding: 14px 16px; + overflow-y: auto; + font-size: 12px; + line-height: 1.5; + scrollbar-width: thin; + scrollbar-color: #2a2a2a transparent; +} +.mcp-confirm-body::-webkit-scrollbar { width: 8px; } +.mcp-confirm-body::-webkit-scrollbar-thumb { + background: #2a2a2a; + border-radius: 4px; + border: 1px solid #1a1a1a; +} + +.mcp-confirm-summary { margin: 0 0 8px; color: #ddd; } +.mcp-confirm-reason { margin: 0 0 8px; color: #888; font-size: 11px; } +.mcp-confirm-reason em { color: #c09040; font-style: normal; } + +.mcp-confirm-args { + margin-top: 10px; + font-size: 11px; +} +.mcp-confirm-args summary { + color: #888; + cursor: pointer; + user-select: none; + padding: 2px 0; +} +.mcp-confirm-args summary:hover { color: #aaa; } +.mcp-confirm-args pre { + background: #0c0c0c; + border: 1px solid #2a2a2a; + border-radius: 3px; + padding: 8px; + margin: 6px 0 0; + color: #aaa; + font-size: 11px; + overflow-x: auto; + white-space: pre-wrap; +} + +.mcp-confirm-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 10px 14px; + border-top: 1px solid #2a2a2a; + background: #111; +} +.mcp-confirm-reject, +.mcp-confirm-accept { + font: inherit; + font-size: 12px; + padding: 5px 14px; + border-radius: 3px; + cursor: pointer; + border: 1px solid #2a2a3a; +} +.mcp-confirm-reject { background: #1a1a1a; color: #aaa; } +.mcp-confirm-reject:hover { background: #2a1a1a; color: #e08080; border-color: #4a2020; } +.mcp-confirm-accept { background: #1a2a1a; color: #80c080; border-color: #2a4a2a; } +.mcp-confirm-accept:hover { background: #2a4a2a; color: #a0e0a0; } + +.mcp-confirm-always { + font: inherit; + font-size: 12px; + padding: 5px 14px; + border-radius: 3px; + cursor: pointer; + background: #1a1a2a; + color: #aac; + border: 1px solid #2a2a4a; + margin-right: auto; +} +.mcp-confirm-always:hover { + background: #2a2a4a; + color: #ccd; + border-color: #4488cc; +} diff --git a/src/components/McpPanel.tsx b/src/components/McpPanel.tsx index cf46ec2..4edb3c4 100644 --- a/src/components/McpPanel.tsx +++ b/src/components/McpPanel.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, useCallback } from "react"; import { writeText as clipboardWriteText, } from "@tauri-apps/plugin-clipboard-manager"; -import type { McpStatus } from "../ipc"; +import type { McpStatus, McpAuditEntry } from "../ipc"; import AuditTab from "./AuditTab"; import PolicyTab from "./PolicyTab"; import "./McpPanel.css"; @@ -18,6 +18,9 @@ interface McpPanelProps { allowedPaneCount: number; /** Total pane count for context. */ totalPaneCount: number; + /** Persistent audit log, owned by App so it survives panel close. */ + auditEntries: McpAuditEntry[]; + onClearAudit: () => void; } type TabId = "config" | "audit" | "policy"; @@ -30,12 +33,17 @@ export default function McpPanel({ onClose, allowedPaneCount, totalPaneCount, + auditEntries, + onClearAudit, }: McpPanelProps) { const [busy, setBusy] = useState(false); const [revealToken, setRevealToken] = useState(false); const [regenBusy, setRegenBusy] = useState(false); const [tab, setTab] = useState("config"); - const [auditUnread, setAuditUnread] = useState(false); + // Unread badge on Audit tab: count of entries arrived since the user last + // visited Audit. Tracked via a baseline count, reset on switch-to-audit. + const [auditSeenCount, setAuditSeenCount] = useState(auditEntries.length); + const auditUnread = auditEntries.length > auditSeenCount; useEffect(() => { function onKey(e: KeyboardEvent) { @@ -81,7 +89,7 @@ export default function McpPanel({ function switchTab(id: TabId) { setTab(id); - if (id === "audit") setAuditUnread(false); + if (id === "audit") setAuditSeenCount(auditEntries.length); } return ( @@ -286,10 +294,7 @@ export default function McpPanel({ )} {tab === "audit" && ( - setAuditUnread(true)} - /> + )} {tab === "policy" && } diff --git a/src/ipc.ts b/src/ipc.ts index 9b58fb9..1a851d4 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -173,5 +173,18 @@ export const mcpPolicyLoad = (): Promise => export const mcpPolicySave = (policy: McpPolicy): Promise => invoke("mcp_policy_save", { policy }); -// (No JS wrapper for mcp_action_reply or events — App.tsx wires those -// directly in the integration step.) +/** Subscribe to MCP action requests from the backend. Each request is a + * tool call the frontend must handle (mutate state) and reply to via + * {@link mcpActionReply}. */ +export const onMcpRequest = ( + cb: (req: McpActionRequest) => void, +): Promise => + listen("mcp://request", (e) => cb(e.payload)); + +/** Reply to an MCP action request. The Rust side expects an externally- + * tagged Result — `{ Ok: }` on success, `{ Err: }` on + * failure or user rejection. */ +export const mcpActionReply = ( + requestId: string, + result: { Ok: unknown } | { Err: string }, +): Promise => invoke("mcp_action_reply", { requestId, result });