Add MCP server (v1 read-only): toggle, per-pane gate, panel UI
This commit is contained in:
parent
6068522ee3
commit
83d8932c98
15 changed files with 1235 additions and 7 deletions
|
|
@ -56,6 +56,11 @@
|
|||
background: #2a2010;
|
||||
color: #c98a1f;
|
||||
}
|
||||
.palette-btn.mcp-btn.on {
|
||||
background: #1a3a1a;
|
||||
color: #80e080;
|
||||
border-color: #2a6a2a;
|
||||
}
|
||||
.preset-btn {
|
||||
min-width: 28px;
|
||||
text-align: center;
|
||||
|
|
|
|||
123
src/App.tsx
123
src/App.tsx
|
|
@ -7,10 +7,18 @@ import {
|
|||
saveSshHosts,
|
||||
setHostPassword,
|
||||
deleteHostPassword,
|
||||
mcpStart,
|
||||
mcpStop,
|
||||
mcpStatus as mcpStatusCmd,
|
||||
mcpUpdateState,
|
||||
writeToPane,
|
||||
killPane,
|
||||
type PaneId,
|
||||
type SshHost,
|
||||
type McpStatus,
|
||||
type McpMirror,
|
||||
type McpMirroredLeaf,
|
||||
type McpMirroredHost,
|
||||
} from "./ipc";
|
||||
import {
|
||||
type TreeNode,
|
||||
|
|
@ -27,6 +35,7 @@ import {
|
|||
setLeafShell,
|
||||
changeLabel,
|
||||
toggleBroadcast as toggleBroadcastInTree,
|
||||
toggleMcpAllow as toggleMcpAllowInTree,
|
||||
setAllBroadcast,
|
||||
adjustFontSize,
|
||||
adjustAllFontSizes,
|
||||
|
|
@ -53,6 +62,7 @@ import Notifications, { type Toast } from "./components/Notifications";
|
|||
import Palette from "./components/Palette";
|
||||
import HostManager from "./components/HostManager";
|
||||
import Help from "./components/Help";
|
||||
import McpPanel from "./components/McpPanel";
|
||||
import "./App.css";
|
||||
import "./lib/layout/Gutter.css";
|
||||
|
||||
|
|
@ -86,6 +96,12 @@ export default function App() {
|
|||
const [hosts, setHosts] = useState<SshHost[]>([]);
|
||||
const [hostManagerOpen, setHostManagerOpen] = useState(false);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const [mcpStatus, setMcpStatus] = useState<McpStatus>({
|
||||
running: false,
|
||||
url: null,
|
||||
token: null,
|
||||
});
|
||||
const [mcpPanelOpen, setMcpPanelOpen] = useState(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [notifications, setNotifications] = useState<Toast[]>([]);
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
|
|
@ -261,6 +277,46 @@ export default function App() {
|
|||
setTree((t) => toggleBroadcastInTree(t, leafId));
|
||||
}, []);
|
||||
|
||||
const toggleMcpAllow = useCallback((leafId: NodeId) => {
|
||||
setTree((t) => toggleMcpAllowInTree(t, leafId));
|
||||
}, []);
|
||||
|
||||
// ---- MCP server lifecycle ------------------------------------------------
|
||||
const refreshMcpStatus = useCallback(async () => {
|
||||
try {
|
||||
const st = await mcpStatusCmd();
|
||||
setMcpStatus(st);
|
||||
} catch (e) {
|
||||
console.warn("mcpStatus failed:", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startMcp = useCallback(async () => {
|
||||
try {
|
||||
const st = await mcpStart();
|
||||
setMcpStatus(st);
|
||||
notify("MCP server started — see panel for URL + token");
|
||||
} catch (e) {
|
||||
notify(`MCP start failed: ${e}`);
|
||||
}
|
||||
}, [notify]);
|
||||
|
||||
const stopMcp = useCallback(async () => {
|
||||
try {
|
||||
const st = await mcpStop();
|
||||
setMcpStatus(st);
|
||||
notify("MCP server stopped");
|
||||
} catch (e) {
|
||||
notify(`MCP stop failed: ${e}`);
|
||||
}
|
||||
}, [notify]);
|
||||
|
||||
// On mount, sync our local mcpStatus with whatever's already running
|
||||
// (the backend persists state across HMR reloads).
|
||||
useEffect(() => {
|
||||
void refreshMcpStatus();
|
||||
}, [refreshMcpStatus]);
|
||||
|
||||
// Ctrl+Shift+P: pop the active leaf out one level. The keyboard
|
||||
// replacement for the (removed) drag-past-sibling gesture. No-op with a
|
||||
// toast if the leaf is at the root or its parent shares orientation
|
||||
|
|
@ -532,6 +588,7 @@ export default function App() {
|
|||
setShell,
|
||||
setLabel,
|
||||
toggleBroadcast,
|
||||
toggleMcpAllow,
|
||||
openHostManager,
|
||||
setActive,
|
||||
registerPaneId,
|
||||
|
|
@ -553,6 +610,7 @@ export default function App() {
|
|||
setShell,
|
||||
setLabel,
|
||||
toggleBroadcast,
|
||||
toggleMcpAllow,
|
||||
openHostManager,
|
||||
setActive,
|
||||
registerPaneId,
|
||||
|
|
@ -567,6 +625,47 @@ export default function App() {
|
|||
],
|
||||
);
|
||||
|
||||
// ---- MCP mirror push -----------------------------------------------------
|
||||
// Whenever the tree, hosts, or active selection change AND the MCP server
|
||||
// is running, push a fresh mirror down to the backend. Per-leaf mcpAllow
|
||||
// gates whether each leaf appears in the mirror (default-deny).
|
||||
const allowedPaneCount = useMemo(
|
||||
() => Array.from(walkLeaves(tree)).filter((l) => l.mcpAllow).length,
|
||||
[tree],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!mcpStatus.running) return;
|
||||
const leaves: Record<string, McpMirroredLeaf> = {};
|
||||
for (const leaf of walkLeaves(tree)) {
|
||||
if (!leaf.mcpAllow) continue;
|
||||
leaves[leaf.id] = {
|
||||
paneId: paneIdByLeafRef.current.get(leaf.id) ?? null,
|
||||
label: leaf.label,
|
||||
shellKind: leaf.shellKind,
|
||||
distro: leaf.distro,
|
||||
sshHostId: leaf.sshHostId,
|
||||
broadcast: !!leaf.broadcast,
|
||||
active: activeLeafId === leaf.id,
|
||||
};
|
||||
}
|
||||
const mirroredHosts: McpMirroredHost[] = hosts.map((h) => ({
|
||||
id: h.id,
|
||||
label: h.label,
|
||||
hostname: h.hostname,
|
||||
user: h.user,
|
||||
port: h.port,
|
||||
hasPassword: !!h.hasPassword,
|
||||
}));
|
||||
const mirror: McpMirror = {
|
||||
layoutJson: serialize(tree),
|
||||
leaves,
|
||||
hosts: mirroredHosts,
|
||||
};
|
||||
mcpUpdateState(mirror).catch((e) =>
|
||||
console.warn("mcpUpdateState failed:", e),
|
||||
);
|
||||
}, [mcpStatus.running, tree, hosts, activeLeafId]);
|
||||
|
||||
const applyPreset = useCallback(
|
||||
(make: (d: Partial<LeafNode>) => TreeNode) => {
|
||||
const { tree: nextTree, dropped } = reshapeToPreset(
|
||||
|
|
@ -723,6 +822,19 @@ export default function App() {
|
|||
>
|
||||
🔔
|
||||
</button>
|
||||
<button
|
||||
className={`palette-btn mcp-btn${mcpStatus.running ? " on" : ""}`}
|
||||
onClick={() => setMcpPanelOpen(true)}
|
||||
title={
|
||||
mcpStatus.running
|
||||
? `MCP server running (${allowedPaneCount} of ${leafCount(tree)} panes visible) — click for details`
|
||||
: "MCP server is OFF — click to configure / start"
|
||||
}
|
||||
aria-label="MCP server"
|
||||
aria-pressed={mcpStatus.running ? "true" : "false"}
|
||||
>
|
||||
🤖
|
||||
</button>
|
||||
<button
|
||||
className="palette-btn"
|
||||
onClick={() => setHelpOpen(true)}
|
||||
|
|
@ -794,6 +906,17 @@ export default function App() {
|
|||
)}
|
||||
|
||||
{helpOpen && <Help onClose={() => setHelpOpen(false)} />}
|
||||
|
||||
{mcpPanelOpen && (
|
||||
<McpPanel
|
||||
status={mcpStatus}
|
||||
onStart={startMcp}
|
||||
onStop={stopMcp}
|
||||
onClose={() => setMcpPanelOpen(false)}
|
||||
allowedPaneCount={allowedPaneCount}
|
||||
totalPaneCount={leafCount(tree)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
191
src/components/McpPanel.css
Normal file
191
src/components/McpPanel.css
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
.mcp-panel {
|
||||
position: fixed;
|
||||
top: 8vh;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: min(680px, 92vw);
|
||||
max-height: 84vh;
|
||||
background: #161616;
|
||||
color: #ccc;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
|
||||
}
|
||||
|
||||
.mcp-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
}
|
||||
.mcp-title { font-weight: 600; font-size: 13px; }
|
||||
.mcp-close {
|
||||
background: transparent; border: none; color: #888;
|
||||
font-size: 18px; line-height: 1; padding: 2px 8px;
|
||||
cursor: pointer; border-radius: 3px;
|
||||
}
|
||||
.mcp-close:hover { background: #2a2a2a; color: #ddd; }
|
||||
|
||||
.mcp-body {
|
||||
padding: 14px 18px;
|
||||
overflow-y: auto;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.mcp-blurb {
|
||||
color: #aaa;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.mcp-toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.mcp-toggle {
|
||||
font: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 6px 14px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
background: #222;
|
||||
color: #999;
|
||||
border: 1px solid #2a2a2a;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.mcp-toggle:hover:not(:disabled) { background: #2a2a2a; color: #ddd; }
|
||||
.mcp-toggle:disabled { opacity: 0.5; cursor: progress; }
|
||||
.mcp-toggle.on {
|
||||
background: #1a3a1a;
|
||||
color: #80e080;
|
||||
border-color: #2a6a2a;
|
||||
}
|
||||
.mcp-dot {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #555;
|
||||
}
|
||||
.mcp-toggle.on .mcp-dot {
|
||||
background: #80e080;
|
||||
box-shadow: 0 0 6px rgba(128, 224, 128, 0.6);
|
||||
}
|
||||
.mcp-allow-count {
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
}
|
||||
.mcp-allow-warn {
|
||||
color: #d8a040;
|
||||
}
|
||||
|
||||
.mcp-field {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.mcp-field label {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #777;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.mcp-field-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.mcp-field input {
|
||||
flex: 1 1 auto;
|
||||
font: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
color: #e6e6e6;
|
||||
background: #0c0c0c;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 3px;
|
||||
padding: 4px 8px;
|
||||
outline: none;
|
||||
}
|
||||
.mcp-field button {
|
||||
font: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
background: #222;
|
||||
color: #aac;
|
||||
border: 1px solid #2a2a3a;
|
||||
border-radius: 3px;
|
||||
padding: 0 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mcp-field button:hover {
|
||||
background: #2a2a3a;
|
||||
color: #ccd;
|
||||
}
|
||||
|
||||
.mcp-snippet {
|
||||
font: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
background: #0c0c0c;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 3px;
|
||||
padding: 8px 10px;
|
||||
margin: 0 0 6px;
|
||||
color: #cce6ff;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.mcp-tips {
|
||||
background: #1a2030;
|
||||
border: 1px solid #2a3040;
|
||||
border-radius: 4px;
|
||||
padding: 10px 12px;
|
||||
color: #aac;
|
||||
font-size: 11px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.mcp-tips strong { color: #cce6ff; }
|
||||
.mcp-tips code {
|
||||
background: #0c0c0c;
|
||||
padding: 1px 4px;
|
||||
border-radius: 2px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.mcp-tips pre {
|
||||
font: inherit;
|
||||
font-family: inherit;
|
||||
background: #0c0c0c;
|
||||
padding: 6px 8px;
|
||||
border-radius: 3px;
|
||||
margin: 4px 0;
|
||||
color: #cce6ff;
|
||||
}
|
||||
|
||||
.mcp-off-hint {
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
margin: 8px 0 12px;
|
||||
}
|
||||
|
||||
.mcp-security {
|
||||
margin: 12px 0 0;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #2a2a2a;
|
||||
color: #888;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.mcp-security strong { color: #d8a040; }
|
||||
.mcp-security em { color: #d88; font-style: normal; }
|
||||
191
src/components/McpPanel.tsx
Normal file
191
src/components/McpPanel.tsx
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
writeText as clipboardWriteText,
|
||||
} from "@tauri-apps/plugin-clipboard-manager";
|
||||
import type { McpStatus } from "../ipc";
|
||||
import "./McpPanel.css";
|
||||
|
||||
interface McpPanelProps {
|
||||
status: McpStatus;
|
||||
onStart: () => Promise<void>;
|
||||
onStop: () => Promise<void>;
|
||||
onClose: () => void;
|
||||
/** Count of leaves with mcpAllow=true — shown so the user knows whether
|
||||
* enabling the server will actually expose anything. */
|
||||
allowedPaneCount: number;
|
||||
/** Total pane count for context. */
|
||||
totalPaneCount: number;
|
||||
}
|
||||
|
||||
export default function McpPanel({
|
||||
status,
|
||||
onStart,
|
||||
onStop,
|
||||
onClose,
|
||||
allowedPaneCount,
|
||||
totalPaneCount,
|
||||
}: McpPanelProps) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [revealToken, setRevealToken] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const toggle = useCallback(async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
if (status.running) await onStop();
|
||||
else await onStart();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [busy, status.running, onStart, onStop]);
|
||||
|
||||
const copy = useCallback((s: string) => {
|
||||
void clipboardWriteText(s).catch((e) =>
|
||||
console.warn("clipboard write failed:", e),
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button className="backdrop" onClick={onClose} aria-label="Close" />
|
||||
<div className="mcp-panel" role="dialog" aria-label="MCP server">
|
||||
<header className="mcp-header">
|
||||
<span className="mcp-title">MCP server</span>
|
||||
<button className="mcp-close" onClick={onClose} aria-label="Close">×</button>
|
||||
</header>
|
||||
|
||||
<div className="mcp-body">
|
||||
<p className="mcp-blurb">
|
||||
Lets a Claude session on the same machine inspect this workspace
|
||||
via Model Context Protocol — see which panes are running, read
|
||||
their scrollback, wait for commands to settle. Read-only in v1;
|
||||
Claude can't send keystrokes or reshape the layout yet.
|
||||
</p>
|
||||
|
||||
<div className="mcp-toggle-row">
|
||||
<button
|
||||
className={`mcp-toggle${status.running ? " on" : ""}`}
|
||||
onClick={toggle}
|
||||
disabled={busy}
|
||||
>
|
||||
<span className="mcp-dot" />
|
||||
{status.running ? "Server: ON" : "Server: OFF"}
|
||||
</button>
|
||||
<span className="mcp-allow-count">
|
||||
{allowedPaneCount} of {totalPaneCount} pane
|
||||
{totalPaneCount === 1 ? "" : "s"} allow-listed
|
||||
{allowedPaneCount === 0 && status.running && (
|
||||
<span className="mcp-allow-warn">
|
||||
{" "}
|
||||
— Claude will see nothing until you toggle 🤖 on at least
|
||||
one pane.
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{status.running && status.url && status.token && (
|
||||
<>
|
||||
<div className="mcp-field">
|
||||
<label>URL</label>
|
||||
<div className="mcp-field-row">
|
||||
<input readOnly value={status.url} onFocus={(e) => e.currentTarget.select()} />
|
||||
<button onClick={() => copy(status.url!)}>Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mcp-field">
|
||||
<label>Bearer token</label>
|
||||
<div className="mcp-field-row">
|
||||
<input
|
||||
readOnly
|
||||
type={revealToken ? "text" : "password"}
|
||||
value={status.token}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
<button onClick={() => setRevealToken((r) => !r)}>
|
||||
{revealToken ? "Hide" : "Show"}
|
||||
</button>
|
||||
<button onClick={() => copy(status.token!)}>Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mcp-field">
|
||||
<label>Claude config snippet</label>
|
||||
<pre className="mcp-snippet">
|
||||
{`{
|
||||
"mcpServers": {
|
||||
"tiletopia": {
|
||||
"url": "${status.url}",
|
||||
"headers": { "Authorization": "Bearer ${status.token}" }
|
||||
}
|
||||
}
|
||||
}`}
|
||||
</pre>
|
||||
<button
|
||||
onClick={() =>
|
||||
copy(
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
tiletopia: {
|
||||
url: status.url,
|
||||
headers: {
|
||||
Authorization: `Bearer ${status.token}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
Copy config snippet
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mcp-tips">
|
||||
<strong>WSL connectivity:</strong> for Claude running inside
|
||||
WSL to reach this server, enable mirrored networking in your
|
||||
<code> %UserProfile%\.wslconfig</code> (Win11 22H2+):
|
||||
<pre>{`[wsl2]
|
||||
networkingMode=mirrored`}</pre>
|
||||
Then <code>127.0.0.1</code> in WSL routes to this Windows
|
||||
host. Without mirrored mode you'll need to use the WSL
|
||||
gateway IP.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!status.running && (
|
||||
<p className="mcp-off-hint">
|
||||
Server is off — no port is open. Token is generated when you
|
||||
start. Each pane needs the 🤖 chip toggled on for Claude to
|
||||
see it.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="mcp-security">
|
||||
<strong>Security:</strong> bound to 127.0.0.1 only. Anyone on
|
||||
this machine running as you can read the bearer token if they
|
||||
see it (e.g. via this UI or by guessing the localhost port).
|
||||
Treat MCP access as equivalent to terminal access. Saved SSH
|
||||
passwords are <em>never</em> exposed through MCP.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
42
src/ipc.ts
42
src/ipc.ts
|
|
@ -90,3 +90,45 @@ export const deleteHostPassword = (hostId: string): Promise<void> =>
|
|||
|
||||
export const hasHostPassword = (hostId: string): Promise<boolean> =>
|
||||
invoke("has_host_password", { hostId });
|
||||
|
||||
// ---- MCP server -----------------------------------------------------------
|
||||
|
||||
export interface McpStatus {
|
||||
running: boolean;
|
||||
url: string | null;
|
||||
token: string | null;
|
||||
}
|
||||
|
||||
/** Shape of the cached mirror we push to the backend on every workspace
|
||||
* change. Mirrors src-tauri/src/mcp.rs `McpMirror`. */
|
||||
export interface McpMirror {
|
||||
layoutJson: string;
|
||||
/** Only includes leaves with mcpAllow === true. */
|
||||
leaves: Record<string, McpMirroredLeaf>;
|
||||
hosts: McpMirroredHost[];
|
||||
}
|
||||
|
||||
export interface McpMirroredLeaf {
|
||||
paneId: number | null;
|
||||
label?: string;
|
||||
shellKind: "wsl" | "powershell" | "ssh";
|
||||
distro?: string;
|
||||
sshHostId?: string;
|
||||
broadcast: boolean;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface McpMirroredHost {
|
||||
id: string;
|
||||
label: string;
|
||||
hostname: string;
|
||||
user?: string;
|
||||
port?: number;
|
||||
hasPassword: boolean;
|
||||
}
|
||||
|
||||
export const mcpStart = (): Promise<McpStatus> => invoke("mcp_start");
|
||||
export const mcpStop = (): Promise<McpStatus> => invoke("mcp_stop");
|
||||
export const mcpStatus = (): Promise<McpStatus> => invoke("mcp_status");
|
||||
export const mcpUpdateState = (mirror: McpMirror): Promise<void> =>
|
||||
invoke("mcp_update_state", { mirror });
|
||||
|
|
|
|||
|
|
@ -123,6 +123,12 @@
|
|||
color: #f0c060;
|
||||
border-color: #c98a1f;
|
||||
}
|
||||
.bcast-chip.mcp-chip.on {
|
||||
/* Green for MCP-allowed — clearly distinct from broadcast's orange. */
|
||||
background: #1a3a1a;
|
||||
color: #80e080;
|
||||
border-color: #2a6a2a;
|
||||
}
|
||||
|
||||
.distro-menu {
|
||||
position: absolute;
|
||||
|
|
|
|||
|
|
@ -418,6 +418,22 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
|||
📡
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`bcast-chip mcp-chip${leaf.mcpAllow ? " on" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
orch.toggleMcpAllow(leaf.id);
|
||||
}}
|
||||
title={
|
||||
leaf.mcpAllow
|
||||
? "MCP can see this pane — click to revoke"
|
||||
: "MCP cannot see this pane — click to allow (only matters when the MCP server is on)"
|
||||
}
|
||||
aria-pressed={leaf.mcpAllow ? "true" : "false"}
|
||||
>
|
||||
🤖
|
||||
</button>
|
||||
|
||||
{isIdle && statusOk ? (
|
||||
<span className="pane-status idle" title={`No output for ${IDLE_THRESHOLD_MS / 1000}s+`}>
|
||||
idle
|
||||
|
|
|
|||
|
|
@ -31,6 +31,9 @@ export interface Orchestration {
|
|||
setShell: (leafId: NodeId, spec: LeafShellSpec) => void;
|
||||
setLabel: (leafId: NodeId, label: string | undefined) => void;
|
||||
toggleBroadcast: (leafId: NodeId) => void;
|
||||
/** Flip the per-pane mcpAllow flag. Default-deny; chip in the pane
|
||||
* toolbar drives this. */
|
||||
toggleMcpAllow: (leafId: NodeId) => void;
|
||||
|
||||
// SSH host management
|
||||
openHostManager: () => void;
|
||||
|
|
|
|||
|
|
@ -108,4 +108,8 @@ export const TIPS: TipSpec[] = [
|
|||
title: "Workspace persistence",
|
||||
body: "Layout, labels, distro choices, and SSH hosts auto-save to %APPDATA%/com.megaproxy.tiletopia (debounced 500ms). Closed panes don't come back — only the structure is restored, shells spawn fresh on next launch.",
|
||||
},
|
||||
{
|
||||
title: "MCP server (let Claude drive the workspace)",
|
||||
body: "Titlebar 🤖 opens the MCP control panel — start the server, copy the URL + bearer token into your Claude client config, and Claude can read scrollback / wait for commands to settle. Default-deny per pane: toggle 🤖 on each pane's toolbar to make it visible to MCP. Read-only in v1 (no spawn or write yet). For Claude inside WSL, enable mirrored networking in .wslconfig.",
|
||||
},
|
||||
];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue