Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions app/api/tools/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {};
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 });
}
}
18 changes: 18 additions & 0 deletions components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<ChatInputHandle | null>(null);
const topBarRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -270,6 +272,16 @@ export function AppShell() {
</svg>
),
},
{
label: "Tools",
onClick: () => setToolsConfigOpen(true),
disabled: false,
icon: (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</svg>
),
},
] as { label: string; onClick: () => void; disabled: boolean; icon: React.ReactNode }[]).map(({ label, onClick, disabled, icon }) => (
<button
key={label}
Expand Down Expand Up @@ -648,6 +660,12 @@ export function AppShell() {
{skillsConfigOpen && (activeCwd ?? selectedSession?.cwd ?? newSessionCwd) && (
<SkillsConfig cwd={(activeCwd ?? selectedSession?.cwd ?? newSessionCwd)!} onClose={() => setSkillsConfigOpen(false)} />
)}
{toolsConfigOpen && (
<ToolsConfig
cwd={selectedSession?.cwd ?? newSessionCwd ?? activeCwd ?? null}
onClose={() => setToolsConfigOpen(false)}
/>
)}
</>
);
}
80 changes: 1 addition & 79 deletions components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,14 +69,12 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({
const [value, setValue] = useState("");
const [modelDropdownOpen, setModelDropdownOpen] = useState(false);
const [modelDropdownRect, setModelDropdownRect] = useState<{ top: number; left: number; width: number } | null>(null);
const [toolDropdownOpen, setToolDropdownOpen] = useState(false);
const [thinkingDropdownOpen, setThinkingDropdownOpen] = useState(false);
const [attachedImages, setAttachedImages] = useState<AttachedImage[]>([]);

const textareaRef = useRef<HTMLTextAreaElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const modelDropdownPanelRef = useRef<HTMLDivElement>(null);
const toolDropdownRef = useRef<HTMLDivElement>(null);
const thinkingDropdownRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);

Expand Down Expand Up @@ -249,9 +247,6 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({
) {
setModelDropdownOpen(false);
}
if (toolDropdownRef.current && !toolDropdownRef.current.contains(e.target as Node)) {
setToolDropdownOpen(false);
}
if (thinkingDropdownRef.current && !thinkingDropdownRef.current.contains(e.target as Node)) {
setThinkingDropdownOpen(false);
}
Expand Down Expand Up @@ -689,80 +684,7 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({
)}
</div>
)}
{!isStreaming && onToolPresetChange && (
<div ref={toolDropdownRef} style={{ position: "relative" }}>
<button
onClick={() => !isStreaming && setToolDropdownOpen((v) => !v)}
disabled={isStreaming}
title="切换工具预设"
style={{
display: "flex", alignItems: "center", gap: 5,
padding: "8px 12px",
height: 32,
background: toolDropdownOpen ? "var(--bg-hover)" : "none",
border: "none",
borderRadius: 9,
color: "var(--text-muted)",
cursor: isStreaming ? "not-allowed" : "pointer",
fontSize: 12,
opacity: isStreaming ? 0.5 : 1,
transition: "background 0.12s, color 0.12s",
}}
onMouseEnter={(e) => {
if (isStreaming) return;
e.currentTarget.style.background = "var(--bg-hover)";
e.currentTarget.style.color = "var(--text)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = toolDropdownOpen ? "var(--bg-hover)" : "none";
e.currentTarget.style.color = "var(--text-muted)";
}}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</svg>
<span>{Object.entries(TOOL_PRESET_MAP).find(([, v]) => v === (toolPreset ?? "default"))?.[0] ?? "default"}</span>
</button>
{toolDropdownOpen && (
<div style={{
position: "absolute", bottom: "calc(100% + 6px)", right: 0,
zIndex: 100, background: "var(--bg)", border: "1px solid var(--border)",
borderRadius: 8, boxShadow: "0 -4px 16px rgba(0,0,0,0.10)",
overflow: "hidden", minWidth: 120,
}}>
{TOOL_PRESETS.map((lvl) => {
const preset = TOOL_PRESET_MAP[lvl];
const isActive = (toolPreset ?? "default") === preset;
const desc = lvl === "off" ? "无工具,纯聊天" : lvl === "default" ? "4 项内置工具" : "全部内置工具";
return (
<button
key={lvl}
onClick={() => { setToolDropdownOpen(false); if (!isActive) onToolPresetChange(preset); }}
style={{
display: "flex", alignItems: "center", gap: 8,
width: "100%", padding: "7px 12px",
background: isActive ? "var(--bg-selected)" : "none",
border: "none",
color: isActive ? "var(--text)" : "var(--text-muted)",
cursor: "pointer", fontSize: 12, textAlign: "left",
fontWeight: isActive ? 600 : 400,
whiteSpace: "nowrap",
}}
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = "var(--bg-hover)"; }}
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.background = "none"; }}
>
{isActive
? <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="var(--accent)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><polyline points="1.5 5 4 7.5 8.5 2.5" /></svg>
: <span style={{ width: 10, flexShrink: 0 }} />}
<span style={{ flex: 1 }}>{lvl}</span>
<span style={{ fontSize: 11, color: "var(--text-dim)", marginLeft: 8 }}>{desc}</span>
</button>
);
})}
</div>
)}
</div>
)}
{/* Tool preset removed — use sidebar Tools button instead */}

{!isStreaming && onCompact && (
<div style={{ position: "relative" }}>
Expand Down
12 changes: 7 additions & 5 deletions components/ToolPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading