From cb6a174ae3ecf98e1cc9644e6a2a38559f86b98f Mon Sep 17 00:00:00 2001 From: Sun Qing Date: Sun, 31 May 2026 15:10:05 +0800 Subject: [PATCH 1/4] feat: add ToolsConfig panel with individual tool toggles persisted to settings.json - New ToolsConfig component with per-tool toggle switches (built-in + extensions) - New /api/tools endpoint for tool enumeration and config persistence - Tools button in left sidebar replaces old wrench-icon preset dropdown - Config stored in settings.json 'activeTools' field, shared with pi-cli - startRpcSession reads saved config on session creation - getPresetFromTools uses superset matching for extension compatibility --- app/api/tools/route.ts | 112 ++++++++++++++ components/AppShell.tsx | 18 +++ components/ChatInput.tsx | 80 +--------- components/ToolPanel.tsx | 12 +- components/ToolsConfig.tsx | 291 +++++++++++++++++++++++++++++++++++++ hooks/useAgentSession.ts | 16 +- lib/rpc-manager.ts | 58 ++++++-- 7 files changed, 485 insertions(+), 102 deletions(-) create mode 100644 app/api/tools/route.ts create mode 100644 components/ToolsConfig.tsx diff --git a/app/api/tools/route.ts b/app/api/tools/route.ts new file mode 100644 index 00000000..26e6a8fd --- /dev/null +++ b/app/api/tools/route.ts @@ -0,0 +1,112 @@ +import { NextResponse } from "next/server"; +import { existsSync, readFileSync, writeFileSync } from "fs"; +import { join } from "path"; +import { + getAgentDir, + createAgentSession, + SessionManager, +} from "@earendil-works/pi-coding-agent"; + +const SETTINGS_FILE = "settings.json"; +const ACTIVE_TOOLS_KEY = "activeTools"; + +function getSettingsPath(): string { + return join(getAgentDir(), SETTINGS_FILE); +} + +function readActiveTools(): string[] | null { + const path = getSettingsPath(); + if (!existsSync(path)) return null; + try { + const settings = JSON.parse(readFileSync(path, "utf8")); + const tools = settings[ACTIVE_TOOLS_KEY]; + return Array.isArray(tools) && tools.length > 0 ? tools : null; + } catch { + return null; + } +} + +function writeActiveTools(activeTools: string[]): void { + const path = getSettingsPath(); + let settings: Record = {}; + if (existsSync(path)) { + try { + settings = JSON.parse(readFileSync(path, "utf8")); + } catch {} + } + if (activeTools.length > 0) { + settings[ACTIVE_TOOLS_KEY] = activeTools; + } else { + delete settings[ACTIVE_TOOLS_KEY]; + } + writeFileSync(path, JSON.stringify(settings, null, 2) + "\n", "utf8"); +} + +// Enumerate all available tools (built-in + extensions) by creating a temp session +async function enumerateTools(cwd: string) { + if (!cwd || !existsSync(cwd)) return []; + + const agentDir = getAgentDir(); + const sessionManager = SessionManager.create(cwd, undefined); + const { session } = await createAgentSession({ + cwd, + agentDir, + sessionManager, + }); + + const allTools: { name: string; description: string; active: boolean }[] = []; + const toolEntries = session.getAllTools?.() ?? []; + const savedActive = readActiveTools(); + const activeSet = savedActive + ? new Set(savedActive) + : new Set(session.getActiveToolNames?.() ?? []); + + for (const t of toolEntries) { + allTools.push({ + name: t.name, + description: t.description ?? "", + active: activeSet.has(t.name), + }); + } + + session.dispose?.(); + return allTools; +} + +// GET /api/tools?cwd=xxx — returns saved config + tool list +export async function GET(req: Request) { + const { searchParams } = new URL(req.url); + const cwd = searchParams.get("cwd"); + + const activeTools = readActiveTools(); + let tools: { name: string; description: string; active: boolean }[] = []; + + if (cwd) { + try { + tools = await enumerateTools(cwd); + } catch (e) { + console.error("Failed to enumerate tools:", e); + } + } + + return NextResponse.json({ config: { activeTools }, tools }); +} + +// POST /api/tools — saves active tools to settings.json +// body: { activeTools: string[] } +export async function POST(req: Request) { + try { + const body = await req.json() as { activeTools: string[] }; + const { activeTools } = body; + if (!Array.isArray(activeTools)) { + return NextResponse.json( + { error: "activeTools must be an array" }, + { status: 400 } + ); + } + writeActiveTools(activeTools); + return NextResponse.json({ success: true }); + } catch (e) { + return NextResponse.json({ error: String(e) }, { status: 500 }); + } +} diff --git a/components/AppShell.tsx b/components/AppShell.tsx index e123cdff..cf94ec56 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -8,6 +8,7 @@ import { FileViewer } from "./FileViewer"; import { TabBar, type Tab } from "./TabBar"; import { ModelsConfig } from "./ModelsConfig"; import { SkillsConfig } from "./SkillsConfig"; +import { ToolsConfig } from "./ToolsConfig"; import { BranchNavigator } from "./BranchNavigator"; import { useTheme } from "@/hooks/useTheme"; import type { SessionInfo, SessionTreeNode } from "@/lib/types"; @@ -26,6 +27,7 @@ export function AppShell() { const [modelsConfigOpen, setModelsConfigOpen] = useState(false); const [modelsRefreshKey, setModelsRefreshKey] = useState(0); const [skillsConfigOpen, setSkillsConfigOpen] = useState(false); + const [toolsConfigOpen, setToolsConfigOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(true); const chatInputRef = useRef(null); const topBarRef = useRef(null); @@ -270,6 +272,16 @@ export function AppShell() { ), }, + { + label: "Tools", + onClick: () => setToolsConfigOpen(true), + disabled: false, + icon: ( + + + + ), + }, ] as { label: string; onClick: () => void; disabled: boolean; icon: React.ReactNode }[]).map(({ label, onClick, disabled, icon }) => ( - {toolDropdownOpen && ( -
- {TOOL_PRESETS.map((lvl) => { - const preset = TOOL_PRESET_MAP[lvl]; - const isActive = (toolPreset ?? "default") === preset; - const desc = lvl === "off" ? "无工具,纯聊天" : lvl === "default" ? "4 项内置工具" : "全部内置工具"; - return ( - - ); - })} -
- )} - - )} + {/* Tool preset removed — use sidebar Tools button instead */} {!isStreaming && onCompact && (
diff --git a/components/ToolPanel.tsx b/components/ToolPanel.tsx index 4dee6fd1..a7b5df21 100644 --- a/components/ToolPanel.tsx +++ b/components/ToolPanel.tsx @@ -14,11 +14,13 @@ export const PRESET_DEFAULT: string[] = ["read", "bash", "edit", "write"]; export const PRESET_FULL: string[] = ["bash", "read", "edit", "write", "grep", "find", "ls"]; export function getPresetFromTools(tools: ToolEntry[]): ToolPreset { - const active = tools.filter(t => t.active).map(t => t.name).sort().join(","); - if (active === "") return "none"; - if (active === [...PRESET_DEFAULT].sort().join(",")) return "default"; - if (active === [...PRESET_FULL].sort().join(",")) return "full"; - return "default"; // closest match + const active = new Set(tools.filter(t => t.active).map(t => t.name)); + if (active.size === 0) return "none"; + // Check superset: all "full" tools active -> High + if (PRESET_FULL.every(t => active.has(t))) return "full"; + // All "default" tools active (but not all "full") -> Low + if (PRESET_DEFAULT.every(t => active.has(t))) return "default"; + return "none"; } interface Props { diff --git a/components/ToolsConfig.tsx b/components/ToolsConfig.tsx new file mode 100644 index 00000000..f21f57f6 --- /dev/null +++ b/components/ToolsConfig.tsx @@ -0,0 +1,291 @@ +"use client"; + +import { useState, useEffect } from "react"; +import type { ToolEntry } from "./ToolPanel"; + +interface Props { + cwd: string | null; + onClose: () => void; +} + +export function ToolsConfig({ cwd, onClose }: Props) { + const [tools, setTools] = useState([]); + const [active, setActive] = useState>(new Set()); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + // Fetch tools via the tools API (doesn't need active session) + useEffect(() => { + if (!cwd) { + setLoading(false); + return; + } + (async () => { + try { + const res = await fetch(`/api/tools?cwd=${encodeURIComponent(cwd)}`); + const d = await res.json(); + if (d.tools && Array.isArray(d.tools) && d.tools.length > 0) { + setTools(d.tools); + setActive(new Set(d.tools.filter((t: ToolEntry) => t.active).map((t: ToolEntry) => t.name))); + } + } catch (e) { + console.error("Failed to fetch tools:", e); + } finally { + setLoading(false); + } + })(); + }, [cwd]); + + // Group tools by category + const builtinTools = tools.filter(t => ["read", "bash", "edit", "write", "grep", "find", "ls"].includes(t.name)); + const extensionTools = tools.filter(t => !["read", "bash", "edit", "write", "grep", "find", "ls"].includes(t.name)); + + const toggle = (name: string) => { + setActive(prev => { + const next = new Set(prev); + if (next.has(name)) next.delete(name); + else next.add(name); + return next; + }); + }; + + const setAll = (enabled: boolean) => { + if (enabled) { + setActive(new Set(tools.map(t => t.name))); + } else { + setActive(new Set()); + } + }; + + const handleSave = async () => { + setSaving(true); + try { + const names = [...active]; + // Save to server config + await fetch("/api/tools", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ activeTools: names }), + }); + // Note: tools take effect on the NEXT new session. + // The saved config is read by startRpcSession on session creation. + onClose(); + } catch (e) { + console.error("Failed to save tools config:", e); + } finally { + setSaving(false); + } + }; + + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + > +
+ {/* Header */} +
+ + Tools + + {tools.length > 0 && ( +
+ + +
+ )} +
+ + {/* Tool list */} +
+ {loading ? ( +
+ Loading tools... +
+ ) : tools.length === 0 ? ( +
+ No tools available. Select a workspace directory first. +
+ ) : ( + <> + {builtinTools.length > 0 && ( + <> +
+ Built-in +
+ {builtinTools.map(tool => ( + + ))} + + )} + {extensionTools.length > 0 && ( + <> +
+ Extensions +
+ {extensionTools.map(tool => ( + + ))} + + )} + + )} +
+ + {/* Footer */} +
+ + Takes effect on next new session + + + +
+
+
+ ); +} + +function ToolRow({ tool, active, onToggle }: { tool: ToolEntry; active: boolean; onToggle: (name: string) => void }) { + return ( +
onToggle(tool.name)} + style={{ + display: "flex", + alignItems: "center", + gap: 10, + padding: "6px 18px", + cursor: "pointer", + transition: "background 0.1s", + }} + onMouseEnter={(e) => { e.currentTarget.style.background = "var(--bg-hover)"; }} + onMouseLeave={(e) => { e.currentTarget.style.background = "none"; }} + > + {/* Toggle switch */} +
+
+
+ {/* Name */} + + {tool.name} + + {/* Description */} + + {tool.description} + +
+ ); +} diff --git a/hooks/useAgentSession.ts b/hooks/useAgentSession.ts index fa7181dc..dc2e4fbc 100644 --- a/hooks/useAgentSession.ts +++ b/hooks/useAgentSession.ts @@ -352,8 +352,20 @@ export function useAgentSession(opts: UseAgentSessionOptions) { if (isNew && newSessionCwd) { const selectedModel = newSessionModel; if (selectedModel) setPendingModel(selectedModel); - const { PRESET_NONE, PRESET_DEFAULT, PRESET_FULL } = await import("@/components/ToolPanel"); - const toolNames = toolPreset === "none" ? PRESET_NONE : toolPreset === "default" ? PRESET_DEFAULT : PRESET_FULL; + // Read saved tool config if available, otherwise use preset + let toolNames: string[]; + try { + const savedRes = await fetch("/api/tools"); + const saved = await savedRes.json() as { config?: { activeTools?: string[] } }; + if (saved.config?.activeTools && saved.config.activeTools.length > 0) { + toolNames = saved.config.activeTools; + } else { + throw new Error("no saved config"); + } + } catch { + const { PRESET_NONE, PRESET_DEFAULT, PRESET_FULL } = await import("@/components/ToolPanel"); + toolNames = toolPreset === "none" ? PRESET_NONE : toolPreset === "default" ? PRESET_DEFAULT : PRESET_FULL; + } const res = await fetch("/api/agent/new", { method: "POST", headers: { "Content-Type": "application/json" }, diff --git a/lib/rpc-manager.ts b/lib/rpc-manager.ts index 86c4aaae..c47413c1 100644 --- a/lib/rpc-manager.ts +++ b/lib/rpc-manager.ts @@ -1,5 +1,7 @@ import { createAgentSession, SessionManager } from "@earendil-works/pi-coding-agent"; import { cacheSessionPath } from "./session-reader"; +import { existsSync, readFileSync } from "fs"; +import { join } from "path"; import type { AgentSessionLike, ToolInfo } from "./pi-types"; // ============================================================================ @@ -208,7 +210,21 @@ export class AgentSessionWrapper { } case "set_tools": { - this.inner.setActiveToolsByName(command.toolNames as string[]); + const names = command.toolNames as string[]; + const exact = command.exact as boolean | undefined; + if (names.length === 0) { + // Disable all tools + this.inner.setActiveToolsByName([]); + } else if (exact) { + // From ToolsConfig: set EXACTLY these tools (user's explicit choice) + this.inner.setActiveToolsByName(names); + } else { + // From old preset: preserve all registered tools (extensions + built-in) + const allTools = this.inner.getAllTools(); + const activeNames = new Set(names); + for (const t of allTools) activeNames.add(t.name); + this.inner.setActiveToolsByName([...activeNames]); + } return null; } @@ -293,13 +309,25 @@ export async function startRpcSession( ? SessionManager.open(sessionFile, undefined) : SessionManager.create(cwd, undefined); - // Determine which tools to pass based on requested toolNames. - // Since v0.68.0, createAgentSession expects string[] tool names instead of Tool[] instances. - // Pass all built-in coding tool names by default; for "all off", pass empty array. - const allCodingToolNames = ["read", "bash", "edit", "write", "grep", "find", "ls"]; + // Determine which tools to pass based on requested toolNames or saved config. + // Priority: + // 1. Frontend explicitly passed toolNames — use it + // 2. Saved activeTools in settings.json — use that (persisted across sessions) + // 3. Default: let core's includeAllExtensionTools handle it (all tools active) let toolsOption: string[] | undefined; if (toolNames !== undefined) { - toolsOption = toolNames.length === 0 ? [] : allCodingToolNames; + toolsOption = toolNames.length === 0 ? [] : toolNames; + } else { + // Read saved activeTools from settings.json + try { + const settingsPath = join(agentDir, "settings.json"); + if (existsSync(settingsPath)) { + const settings = JSON.parse(readFileSync(settingsPath, "utf8")); + if (Array.isArray(settings.activeTools) && settings.activeTools.length > 0) { + toolsOption = settings.activeTools; + } + } + } catch {} } const { session: inner } = await createAgentSession({ @@ -309,16 +337,14 @@ export async function startRpcSession( ...(toolsOption !== undefined ? { tools: toolsOption } : {}), }); - // If specific tool names were requested (non-empty), narrow active tools now - if (toolNames && toolNames.length > 0) { - inner.setActiveToolsByName(toolNames); - } - - // When all tools are disabled, clear the system prompt entirely. - // pi's buildSystemPrompt always produces a non-empty prompt even with no tools; - // the only way to truly clear it is to call agent.setSystemPrompt directly. - if (toolNames?.length === 0) { - inner.agent.state.systemPrompt = ""; + // Apply exact tool list after creation, overriding includeAllExtensionTools. + if (toolsOption !== undefined) { + if (toolsOption.length === 0) { + inner.setActiveToolsByName([]); + inner.agent.state.systemPrompt = ""; + } else { + inner.setActiveToolsByName(toolsOption); + } } const wrapper = new AgentSessionWrapper(inner); From 3900f3342fa06d869fefd3da383d6968f7059c8c Mon Sep 17 00:00:00 2001 From: Sun Qing Date: Fri, 5 Jun 2026 08:55:47 +0800 Subject: [PATCH 2/4] fix: persist empty activeTools array to prevent re-enabling defaults after Disable all --- app/api/tools/route.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/app/api/tools/route.ts b/app/api/tools/route.ts index 26e6a8fd..50a818ed 100644 --- a/app/api/tools/route.ts +++ b/app/api/tools/route.ts @@ -20,7 +20,7 @@ function readActiveTools(): string[] | null { try { const settings = JSON.parse(readFileSync(path, "utf8")); const tools = settings[ACTIVE_TOOLS_KEY]; - return Array.isArray(tools) && tools.length > 0 ? tools : null; + return Array.isArray(tools) && tools.length > 0 ? tools : (Array.isArray(tools) ? [] : null); } catch { return null; } @@ -34,11 +34,7 @@ function writeActiveTools(activeTools: string[]): void { settings = JSON.parse(readFileSync(path, "utf8")); } catch {} } - if (activeTools.length > 0) { - settings[ACTIVE_TOOLS_KEY] = activeTools; - } else { - delete settings[ACTIVE_TOOLS_KEY]; - } + settings[ACTIVE_TOOLS_KEY] = activeTools; writeFileSync(path, JSON.stringify(settings, null, 2) + "\n", "utf8"); } From 4d4426ed87c356d74c34cb2c7e627ddd306b4628 Mon Sep 17 00:00:00 2001 From: Sun Qing Date: Fri, 5 Jun 2026 08:56:12 +0800 Subject: [PATCH 3/4] fix: also handle empty activeTools array in rpc-manager.ts to respect Disable all --- lib/rpc-manager.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/rpc-manager.ts b/lib/rpc-manager.ts index c47413c1..d0aa09ce 100644 --- a/lib/rpc-manager.ts +++ b/lib/rpc-manager.ts @@ -323,8 +323,8 @@ export async function startRpcSession( const settingsPath = join(agentDir, "settings.json"); if (existsSync(settingsPath)) { const settings = JSON.parse(readFileSync(settingsPath, "utf8")); - if (Array.isArray(settings.activeTools) && settings.activeTools.length > 0) { - toolsOption = settings.activeTools; + if (Array.isArray(settings.activeTools)) { + toolsOption = settings.activeTools.length > 0 ? settings.activeTools : []; } } } catch {} From eeaf43ddee8162faf8c0d59ced731f039defc8f6 Mon Sep 17 00:00:00 2001 From: Sun Qing Date: Fri, 5 Jun 2026 09:06:29 +0800 Subject: [PATCH 4/4] fix: also respect empty activeTools in frontend when creating new session --- hooks/useAgentSession.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hooks/useAgentSession.ts b/hooks/useAgentSession.ts index dc2e4fbc..2e7d625f 100644 --- a/hooks/useAgentSession.ts +++ b/hooks/useAgentSession.ts @@ -357,7 +357,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { try { const savedRes = await fetch("/api/tools"); const saved = await savedRes.json() as { config?: { activeTools?: string[] } }; - if (saved.config?.activeTools && saved.config.activeTools.length > 0) { + if (saved.config?.activeTools && Array.isArray(saved.config.activeTools)) { toolNames = saved.config.activeTools; } else { throw new Error("no saved config");