diff --git a/app/api/tools/route.ts b/app/api/tools/route.ts new file mode 100644 index 00000000..50a818ed --- /dev/null +++ b/app/api/tools/route.ts @@ -0,0 +1,108 @@ +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 : (Array.isArray(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 {} + } + settings[ACTIVE_TOOLS_KEY] = activeTools; + 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..2e7d625f 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 && Array.isArray(saved.config.activeTools)) { + 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..d0aa09ce 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)) { + toolsOption = settings.activeTools.length > 0 ? 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);