diff --git a/src/components/settings/AgentsSection.tsx b/src/components/settings/AgentsSection.tsx index ea432c94e..9cb50e587 100644 --- a/src/components/settings/AgentsSection.tsx +++ b/src/components/settings/AgentsSection.tsx @@ -22,7 +22,7 @@ import { AppDialog } from "@/components/ui/Dialog"; import { Tip } from "@/components/ui/Tooltip"; import { Trash2, Plus, Check, AlertTriangle, RotateCcw, Copy, ChevronRight, ChevronDown, Info } from "lucide-react"; import { AgentAccountsRow, AgentAccountsAction } from "@/components/settings/AgentAccountsRow"; -import { CliIcon, CLI_BRAND_COLOR, resolveIconId } from "@/icons/cli"; +import { CliIcon, CLI_BRAND_COLOR, resolveIconId, TOOL_ICONS } from "@/icons/cli"; import { SignalInspector } from "./SignalInspector"; import { AgentHookSource } from "./AgentHookSource"; import { RecentHooks } from "./RecentHooks"; @@ -33,6 +33,7 @@ import { Toggle } from "@/components/settings/Controls"; import { usePrefs, type LauncherVisibility } from "@/store/prefs"; import { launcherVisibilityOf } from "@/lib/launcherUsage"; import { DropdownRoot, DropdownTrigger, DropdownMenu, DropdownItem, DropdownSeparator } from "@/components/ui/Dropdown"; +import { PopoverRoot, PopoverTrigger, PopoverContent } from "@/components/ui/Popover"; import { footerReports } from "@/lib/agentContext"; export function AgentsSection() { @@ -285,7 +286,7 @@ export function AgentsSection() { display_name: t("agents.newTerminalName", { n }), command: "", args: [], - icon_id: "lucide:terminal", + icon_id: "terminal", color: "#9aa0a6", builtin: false, kind: "terminal", @@ -569,22 +570,49 @@ function AgentsTabs({ const { t } = useTranslation("settings"); // PATH-detection results (keyed by agent id) drive the install badge. const detectedClis = useApp(s => s.detectedClis); + // Category filter switch: All / AI Agents / CLI Tools & Terminals + const [filter, setFilter] = useState<"all" | "agents" | "terminals">("all"); // Default to first agent; when the list churns (delete current, - // add new) drift to a sensible neighbor instead of going blank. + // add new) drift to a sensible neighbor in the active filter category. useEffect(() => { if (agents.length === 0) { setActiveId(null); return; } if (!activeId || !agents.some(a => a.id === activeId)) { - setActiveId(agents[0].id); + if (filter === "terminals") { + const firstTerm = agents.find(isTerminalEntry); + if (firstTerm) { + setActiveId(firstTerm.id); + } else { + setFilter("all"); + setActiveId(agents[0].id); + } + } else if (filter === "agents") { + const firstAgent = agents.find(a => !isTerminalEntry(a)); + if (firstAgent) { + setActiveId(firstAgent.id); + } else { + setFilter("all"); + setActiveId(agents[0].id); + } + } else { + setActiveId(agents[0].id); + } } - }, [agents, activeId]); + }, [agents, activeId, filter, setActiveId]); // Auto-jump to a freshly added agent so the user lands on its editor. // Waits for the agent to EXIST. A deep link is applied before the async // agent load finishes, and the effect above then resets the selection to // `agents[0]` the moment the list arrives, landing the user on the wrong // card. Depending on `agents` re-applies it once the target is really there. useEffect(() => { - if (autoFocusId && agents.some(a => a.id === autoFocusId)) setActiveId(autoFocusId); - }, [autoFocusId, agents]); + if (autoFocusId && agents.some(a => a.id === autoFocusId)) { + setActiveId(autoFocusId); + const target = agents.find(a => a.id === autoFocusId); + if (target) { + if (isTerminalEntry(target) && filter === "agents") setFilter("all"); + if (!isTerminalEntry(target) && filter === "terminals") setFilter("all"); + } + } + }, [autoFocusId, agents, filter, setActiveId]); // Drag-to-reorder — same pointer-based pattern as TabBar (no HTML5 DnD; // WKWebView's native drag is unreliable and Tauri intercepts it). @@ -710,6 +738,8 @@ function AgentsTabs({ // and drag-reorder stays within a group (see maybeReorder). const agentEntries = agents.filter(a => !isTerminalEntry(a)); const termEntries = agents.filter(isTerminalEntry); + const displayedAgents = filter === "terminals" ? [] : agentEntries; + const displayedTerms = filter === "agents" ? [] : termEntries; const pill = (a: Agent, first: boolean) => ( + + + + + {/* Tab strip — mirrors the Repository sub-tab style: bottom border under inactive tabs, accent underline beneath the active one. Keeps the visual language consistent across @@ -776,39 +851,45 @@ function AgentsTabs({ style={stripMask ? { maskImage: stripMask, WebkitMaskImage: stripMask } : undefined} className="no-scrollbar flex items-center gap-1 overflow-x-auto overflow-y-hidden" > - {agentEntries.map((a, idx) => pill(a, idx === 0))} - {termEntries.length > 0 && ( + {displayedAgents.map((a, idx) => pill(a, idx === 0))} + {filter === "all" && termEntries.length > 0 && agentEntries.length > 0 && ( {t("agents.terminalsGroup")} )} - {termEntries.map(a => pill(a, false))} + {displayedTerms.map((a, idx) => pill(a, displayedAgents.length === 0 && idx === 0))} {/* Active agent card. Mount-keyed by id so internal state (refs, drafts) resets cleanly when the user switches tabs. */}
- patchAgent(active.id, p)} - dockerSandboxOn={dockerSandboxOn} - onCommitId={(newDisplayName) => onCommitId(active.id, newDisplayName)} - onPatchCaps={(p) => patchCaps(active.id, p)} - onRemove={() => requestRemoveAgent(active.id)} - onClone={() => cloneAgent(active.id)} - extendsName={active.extends ? (agents.find(a => a.id === active.extends)?.display_name ?? active.extends) : undefined} - overrideCount={agentOverrides(agents, active.id).length} - inherited={inherited} - yoloNote={yoloArgsNote(builtinBaseId(active.id, agents))} - resetOverrides={resetOverrides} - autoFocus={autoFocusId === active.id} - onAutoFocusConsumed={onAutoFocusConsumed} - modified={isModified(active)} - onReset={defaults.find(d => d.id === active.id) ? () => resetAgent(active.id) : undefined} - /> + {filter === "terminals" && termEntries.length === 0 ? ( +
+ {t("agents.noTerminals")} +
+ ) : ( + patchAgent(active.id, p)} + dockerSandboxOn={dockerSandboxOn} + onCommitId={(newDisplayName) => onCommitId(active.id, newDisplayName)} + onPatchCaps={(p) => patchCaps(active.id, p)} + onRemove={() => requestRemoveAgent(active.id)} + onClone={() => cloneAgent(active.id)} + extendsName={active.extends ? (agents.find(a => a.id === active.extends)?.display_name ?? active.extends) : undefined} + overrideCount={agentOverrides(agents, active.id).length} + inherited={inherited} + yoloNote={yoloArgsNote(builtinBaseId(active.id, agents))} + resetOverrides={resetOverrides} + autoFocus={autoFocusId === active.id} + onAutoFocusConsumed={onAutoFocusConsumed} + modified={isModified(active)} + onReset={defaults.find(d => d.id === active.id) ? () => resetAgent(active.id) : undefined} + /> + )}
); @@ -900,6 +981,7 @@ function AgentCard({ agent, detected, onPatch, onCommitId, onPatchCaps, onRemove // of sight (the e2e case that removes from the card caught it). const hooksInstalled = useApp(s => s.agentHooksInstalled[agent.id] === true); const [patternsOpen, setPatternsOpen] = useState(null); + const [iconPickerOpen, setIconPickerOpen] = useState(false); useEffect(() => { setPatternsOpen(null); }, [hooksInstalled]); const showPatterns = patternsOpen ?? !hooksInstalled; const tracked = agent.work_done !== false; @@ -914,9 +996,59 @@ function AgentCard({ agent, detected, onPatch, onCommitId, onPatchCaps, onRemove "BUILT-IN" over two rows. */}
- - - + {isTerminal || !agent.builtin ? ( + + + + + +
+ {t("agents.iconPicker")} +
+
+ {TOOL_ICONS.map(icon => { + const activeIcon = (agent.icon_id || (isTerminal ? "terminal" : agent.id)) === icon.id; + return ( + + ); + })} +
+
+
+ ) : ( + + + + )} ({ listen: vi.fn().mockResolvedValue(() => {}) })); +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn().mockResolvedValue(undefined) })); +vi.mock("@/lib/ipc", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + settingsLoad: vi.fn().mockResolvedValue({ + agents: [ + { id: "claude", display_name: "Claude Code", command: "claude", args: [], builtin: true, icon_id: "claude", color: "#d97706" }, + { id: "term-db", display_name: "DB Client", command: "harlequin", args: [], builtin: false, kind: "terminal", icon_id: "database", color: "#f59e0b" }, + ], + }), + agentsDefaults: vi.fn().mockResolvedValue([]), + agentsSave: vi.fn().mockResolvedValue(undefined), + projectUpdate: vi.fn().mockResolvedValue(undefined), + agentHooksAutoGet: vi.fn().mockResolvedValue(false), + }; +}); + +import { act, createElement } from "react"; +import { createRoot } from "react-dom/client"; +import { TOOL_ICONS, CLI_BRAND_COLOR, resolveIconId, CliIcon } from "@/icons/cli"; +import { isTerminalEntry } from "@/lib/agents"; +import type { Agent } from "@/lib/types"; +import { i18n } from "@/lib/i18n"; +import { AgentsSection } from "./AgentsSection"; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +describe("Tool Icons and CLI Brand Colors", () => { + it("exports a curated list of tool icons including database and server", () => { + expect(TOOL_ICONS.length).toBeGreaterThanOrEqual(10); + const ids = TOOL_ICONS.map(i => i.id); + expect(ids).toContain("terminal"); + expect(ids).toContain("database"); + expect(ids).toContain("server"); + expect(ids).toContain("globe"); + expect(ids).toContain("cpu"); + expect(ids).toContain("box"); + expect(ids).toContain("git"); + }); + + it("assigns distinct brand colors for tool icons", () => { + expect(CLI_BRAND_COLOR.database).toBe("text-amber-500"); + expect(CLI_BRAND_COLOR.server).toBe("text-emerald-500"); + expect(CLI_BRAND_COLOR.globe).toBe("text-sky-500"); + expect(CLI_BRAND_COLOR.cpu).toBe("text-purple-500"); + expect(CLI_BRAND_COLOR.box).toBe("text-orange-500"); + }); + + it("resolves custom icon_id on custom terminals", () => { + const agents: Agent[] = [ + { id: "term-db", display_name: "DB Client", command: "harlequin", args: [], builtin: false, kind: "terminal", icon_id: "database", color: "#f59e0b" } as Agent, + { id: "term-server", display_name: "Web Server", command: "npm run dev", args: [], builtin: false, kind: "terminal", icon_id: "server", color: "#10b981" } as Agent, + { id: "claude", display_name: "Claude Code", command: "claude", args: [], builtin: true, icon_id: "claude", color: "#d97706" } as Agent, + ]; + + expect(resolveIconId("term-db", agents)).toBe("database"); + expect(resolveIconId("term-server", agents)).toBe("server"); + expect(resolveIconId("claude", agents)).toBe("claude"); + }); + + it("distinguishes between AI agents and terminal tools", () => { + const agent: Agent = { id: "claude", display_name: "Claude", command: "claude", args: [], builtin: true, icon_id: "claude", color: "#d97706" } as Agent; + const terminal: Agent = { id: "term-1", display_name: "DB", command: "psql", args: [], builtin: false, kind: "terminal", icon_id: "database", color: "#f59e0b" } as Agent; + + expect(isTerminalEntry(agent)).toBe(false); + expect(isTerminalEntry(terminal)).toBe(true); + }); + + it("renders AgentsSection without hook errors during initial mount and load", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render(createElement(AgentsSection)); + }); + + // Allow async load effects to resolve and re-render + await act(async () => { + await new Promise(r => setTimeout(r, 20)); + }); + + const filterAll = container.querySelector('[data-testid="filter-all"]'); + const filterAgents = container.querySelector('[data-testid="filter-agents"]'); + const filterTerminals = container.querySelector('[data-testid="filter-terminals"]'); + expect(filterAll).not.toBeNull(); + expect(filterAgents).not.toBeNull(); + expect(filterTerminals).not.toBeNull(); + + await act(async () => { + root.unmount(); + }); + container.remove(); + }); +}); diff --git a/src/components/task/AuxTerminal.tsx b/src/components/task/AuxTerminal.tsx index abde6e5cc..e9677b054 100644 --- a/src/components/task/AuxTerminal.tsx +++ b/src/components/task/AuxTerminal.tsx @@ -27,6 +27,7 @@ import { resyncViewportAfterReveal } from "@/lib/xtermViewportSync"; import { registerTerminalDropTarget } from "@/lib/terminalDrop"; import { attachCopyOnSelect } from "@/lib/terminalSelection"; import { setupImeReplacementBridge } from "@/lib/ime"; +import { loadTaskDotEnv } from "@/lib/dotenv"; import * as ipc from "@/lib/ipc"; import { loginShell, loginShellArgs } from "@/lib/loginShell"; import { TerminalExitedBanner } from "@/components/task/TerminalExitedBanner"; @@ -273,12 +274,14 @@ export function AuxTerminal({ taskId, tabId, taskPath, active, autoFocus, onExit const shell = await loginShell(); if (cancelled) return; try { + const taskDotEnv = await loadTaskDotEnv(taskPath); + if (cancelled) return; const { id: ptyId } = await ipc.ptySpawn({ cwd: taskPath, cmd: shell, args: loginShellArgs(shell), // Signal terminal theme so prompts / status bars that honor // COLORFGBG (oh-my-zsh themes, starship, etc.) pick the right - // colors for the current chrome. - env: { COLORFGBG: currentColorFgBg() }, + // colors for the current chrome, merged with task .env vars. + env: { COLORFGBG: currentColorFgBg(), ...taskDotEnv }, // NEVER pass task_id here. The aux shell is a scratch // zsh for the user's own git/grep/etc work - sandboxing it // would block exactly the moves the user opened it for diff --git a/src/components/task/TerminalPane.tsx b/src/components/task/TerminalPane.tsx index 14a5035bc..2e7bbdf60 100644 --- a/src/components/task/TerminalPane.tsx +++ b/src/components/task/TerminalPane.tsx @@ -64,6 +64,7 @@ import { SudoTouchIdBanner } from "@/components/task/SudoTouchIdBanner"; import { TerminalFindBar } from "@/components/task/TerminalFindBar"; import { isTerminalCloseCombo, isTerminalFindCombo } from "@/lib/terminalFind"; import * as ipc from "@/lib/ipc"; +import { wrapWithDotEnv, loadTaskDotEnv } from "@/lib/dotenv"; import { maybeRebuildDockerImageForLaunch } from "@/lib/dockerDailyRebuild"; import { commandShell, loginShell, loginShellArgs } from "@/lib/loginShell"; import { usePrefs, useResolvedThemeFull, currentTerminalStack, currentTerminalTheme, currentColorFgBg, currentMinimumContrastRatio } from "@/store/prefs"; @@ -2883,6 +2884,10 @@ const captureArmedRef = useRef(false); let launchCmd = isCustom && tab.command ? tab.command : isRegistryTerminal ? terminalLaunchCommand(tab.cli, task) : undefined; + if (launchCmd) { + const cliOverrides = isAgent || isRegistryTerminal ? envForCli(tab.cli) : undefined; + launchCmd = wrapWithDotEnv(launchCmd, cliOverrides); + } // Spotlight: the host Run tab executes at the REPO ROOT while this // task is spotlighted (the root serves the synced changes). // Decided at spawn time, not tab-creation time, so restarting after @@ -2944,16 +2949,19 @@ const captureArmedRef = useRef(false); // away from while this awaited. if (isAgent) await maybeRebuildDockerImageForLaunch(task); if (cancelled) return; + const taskDotEnv = !isAgent ? await loadTaskDotEnv(task?.path) : {}; + if (cancelled) return; const spawn = await ipc.ptySpawn({ cwd: task.path, cmd: spawnCmd, args: spawnArgs, // Order matters: base TERMIC_*/COLORFGBG block first, then - // the user's per-agent env block (Settings → Agents). The - // per-agent values win on key collision so a power user can - // override e.g. COLORFGBG or even TERMIC_PORT if they really - // want to. Rust merges this overlay AFTER the inherited - // parent env, so anything set here always trumps a system env. + // task .env/.env.local variables, then the user's per-agent + // env block (Settings → Agents). The per-agent values win on + // key collision so a power user can override e.g. COLORFGBG or + // even TERMIC_PORT if they really want to. Rust merges this + // overlay AFTER the inherited parent env, so anything set + // here always trumps a system env. env: { TERMIC_PORT: String(task.port), // Extra named ports (GH #196): frozen name→port pairs under @@ -2965,6 +2973,7 @@ const captureArmedRef = useRef(false); ), TERMIC_WORKSPACE_NAME: task.name, COLORFGBG: currentColorFgBg(), + ...taskDotEnv, // Registry entries (agents AND terminal-kind) carry a // user-configured env block; sentinel shell/custom tabs don't. ...(isAgent || isRegistryTerminal ? envForCli(tab.cli) : {}), diff --git a/src/icons/cli.tsx b/src/icons/cli.tsx index d72ea6933..b7354daf6 100644 --- a/src/icons/cli.tsx +++ b/src/icons/cli.tsx @@ -165,6 +165,136 @@ export function OmpIcon({ className }: Props) { ); } +export function DatabaseIcon({ className }: Props) { + return ( + + + + + + ); +} + +export function ServerIcon({ className }: Props) { + return ( + + + + + + + ); +} + +export function GlobeIcon({ className }: Props) { + return ( + + + + + + ); +} + +export function CpuIcon({ className }: Props) { + return ( + + + + + + ); +} + +export function BoxIcon({ className }: Props) { + return ( + + + + + ); +} + +export function GitBranchIcon({ className }: Props) { + return ( + + + + + + + ); +} + +export function BugIcon({ className }: Props) { + return ( + + + + + ); +} + +export function FlameIcon({ className }: Props) { + return ( + + + + ); +} + +export function ShieldIcon({ className }: Props) { + return ( + + + + ); +} + +export function WrenchIcon({ className }: Props) { + return ( + + + + ); +} + +export function LayersIcon({ className }: Props) { + return ( + + + + + + ); +} + +export const TOOL_ICONS: Array<{ id: string; label: string }> = [ + { id: "terminal", label: "Terminal" }, + { id: "database", label: "Database" }, + { id: "server", label: "Server" }, + { id: "globe", label: "Network / Web" }, + { id: "cpu", label: "CPU / Build" }, + { id: "box", label: "Container / Package" }, + { id: "git", label: "Git" }, + { id: "bug", label: "Debug" }, + { id: "flame", label: "Performance" }, + { id: "shield", label: "Security" }, + { id: "wrench", label: "Utility" }, + { id: "layers", label: "Stack / Layers" }, +]; + /** Pick the right icon for a CLI name; falls back to a generic terminal glyph. */ export function CliIcon({ cli, className }: { cli: string; className?: string }) { switch (cli) { @@ -179,6 +309,19 @@ export function CliIcon({ cli, className }: { cli: string; className?: string }) case "cursor": return ; case "devin": return ; case "copilot": return ; + case "database": return ; + case "server": return ; + case "globe": return ; + case "cpu": return ; + case "box": return ; + case "git": return ; + case "bug": return ; + case "flame": return ; + case "shield": return ; + case "wrench": return ; + case "layers": return ; + case "terminal": + case "lucide:terminal": case "shell": return ; case "custom": return ; // `data-cli-icon="fallback"` marks the generic glyph so a spec can assert @@ -208,6 +351,18 @@ export const CLI_BRAND_COLOR: Record = { muse: "text-[var(--color-cli-muse)]", cursor: "text-[var(--color-cli-cursor)]", devin: "text-[var(--color-cli-devin)]", + database: "text-amber-500", + server: "text-emerald-500", + globe: "text-sky-500", + cpu: "text-purple-500", + box: "text-orange-500", + git: "text-rose-500", + bug: "text-red-500", + flame: "text-amber-600", + shield: "text-blue-500", + wrench: "text-cyan-500", + layers: "text-indigo-500", + terminal: "text-[var(--color-fg-dim)]", }; /** Resolve an agent's stable ID to its icon_id using the live agent registry. diff --git a/src/lib/dotenv.test.ts b/src/lib/dotenv.test.ts new file mode 100644 index 000000000..eeddadba1 --- /dev/null +++ b/src/lib/dotenv.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect, vi } from "vitest"; +import { parseDotEnv, wrapWithDotEnv, loadTaskDotEnv } from "./dotenv"; + +vi.mock("./ipc", () => ({ + fileReadExternal: vi.fn(async (path: string) => { + if (path.endsWith(".env.local")) { + return "STARROCKS_USER=override_user\nLOCAL_ONLY=true\n"; + } + if (path.endsWith(".env")) { + return ` +# Database Configuration +STARROCKS_USER=default_user +STARROCKS_PASSWORD="secret_pass_123" +export DB_PORT=9030 +# Empty and single-quoted +FOO='bar baz' +EMPTY= + `; + } + throw new Error("File not found"); + }), +})); + +describe("dotenv support", () => { + it("parses .env key-value pairs including quotes, inline comments, and export prefix", () => { + const raw = ` +# Comment +STARROCKS_USER=my_user # username for connection +STARROCKS_PASSWORD="super secret # password" +export HOST=localhost +SINGLE='quoted # value' +EMPTY= +COMMENT_ONLY=#only comment +INVALID LINE WITHOUT EQUALS +# Another comment + `; + const res = parseDotEnv(raw); + expect(res).toEqual({ + STARROCKS_USER: "my_user", + STARROCKS_PASSWORD: "super secret # password", + HOST: "localhost", + SINGLE: "quoted # value", + EMPTY: "", + COMMENT_ONLY: "", + }); + }); + + it("wraps a command to source .env and .env.local before running", () => { + const wrapped = wrapWithDotEnv("harlequin"); + expect(wrapped).toContain("[ -f .env ] && set -a && . ./.env 2>/dev/null && set +a;"); + expect(wrapped).toContain("[ -f .env.local ] && set -a && . ./.env.local 2>/dev/null && set +a;"); + expect(wrapped.endsWith(" harlequin")).toBe(true); + }); + + it("applies explicit environment overrides after sourcing", () => { + const wrapped = wrapWithDotEnv("harlequin", { STARROCKS_USER: "custom_user" }); + expect(wrapped).toContain('export STARROCKS_USER="custom_user";'); + expect(wrapped.endsWith(" harlequin")).toBe(true); + }); + + it("does not double-wrap an already wrapped command", () => { + const first = wrapWithDotEnv("harlequin"); + const second = wrapWithDotEnv(first); + expect(second).toBe(first); + }); + + it("loads and merges .env and .env.local for a task path", async () => { + const env = await loadTaskDotEnv("/path/to/task"); + expect(env.STARROCKS_USER).toBe("override_user"); + expect(env.STARROCKS_PASSWORD).toBe("secret_pass_123"); + expect(env.DB_PORT).toBe("9030"); + expect(env.LOCAL_ONLY).toBe("true"); + expect(env.FOO).toBe("bar baz"); + }); +}); diff --git a/src/lib/dotenv.ts b/src/lib/dotenv.ts new file mode 100644 index 000000000..7d9e25437 --- /dev/null +++ b/src/lib/dotenv.ts @@ -0,0 +1,60 @@ +import * as ipc from "./ipc"; + +/** Parse a simple .env file content into key-value environment variables. + * Supports unquoted, single-quoted, double-quoted values, inline comments (#), and lines prefixed with "export ". */ +export function parseDotEnv(content: string): Record { + const out: Record = {}; + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const clean = trimmed.startsWith("export ") ? trimmed.slice(7).trim() : trimmed; + const eqIdx = clean.indexOf("="); + if (eqIdx <= 0) continue; + const key = clean.slice(0, eqIdx).trim(); + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) continue; + let val = clean.slice(eqIdx + 1).trim(); + if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { + val = val.slice(1, -1); + } else { + // Strip unquoted inline comments like `val # comment` + const commentIdx = val.search(/\s+#/); + if (commentIdx !== -1) { + val = val.slice(0, commentIdx).trim(); + } else if (val.startsWith("#")) { + val = ""; + } + } + out[key] = val; + } + return out; +} + +/** Prefix a shell command to source .env and .env.local if present in the worktree, + * re-applying any explicit CLI environment overrides so settings trumps .env. */ +export function wrapWithDotEnv(cmd: string, overrides?: Record): string { + if (cmd.startsWith("[ -f .env ]")) return cmd; + let prefix = "[ -f .env ] && set -a && . ./.env 2>/dev/null && set +a; [ -f .env.local ] && set -a && . ./.env.local 2>/dev/null && set +a;"; + if (overrides && Object.keys(overrides).length > 0) { + const exports = Object.entries(overrides) + .map(([k, v]) => `export ${k}="${String(v).replace(/"/g, '\\"')}";`) + .join(" "); + prefix = `${prefix} ${exports};`; + } + return `${prefix} ${cmd}`; +} + +/** Load .env and .env.local from a task path and return parsed variables. */ +export async function loadTaskDotEnv(taskPath?: string): Promise> { + if (!taskPath) return {}; + const env: Record = {}; + for (const filename of [".env", ".env.local"]) { + try { + const fullPath = `${taskPath}/${filename}`; + const text = await ipc.fileReadExternal(fullPath); + Object.assign(env, parseDotEnv(text)); + } catch { + // file does not exist or unreadable, ignore + } + } + return env; +} diff --git a/src/locales/en/settings.ts b/src/locales/en/settings.ts index 55ce41802..9411a7d34 100644 --- a/src/locales/en/settings.ts +++ b/src/locales/en/settings.ts @@ -981,6 +981,11 @@ export default { pinnedOne: "It is the default CLI for <1>{{name}}, which will switch to <3>{{fallback}}.", pinnedMany: "It is the default CLI for {{count}} projects, which will switch to <1>{{fallback}}.", terminalsGroup: "Terminals", + filterAll: "All", + filterAgents: "AI Agents", + filterTerminals: "CLI Tools & Terminals", + noTerminals: "No custom terminals configured yet. Use \"+ Add terminal\" above to add one.", + iconPicker: "Choose icon", newAgentName: "New agent {{n}}", newTerminalName: "New terminal {{n}}", allTerminals: "All terminals", diff --git a/src/locales/zh-CN/settings.ts b/src/locales/zh-CN/settings.ts index f611d1f3b..93b257235 100644 --- a/src/locales/zh-CN/settings.ts +++ b/src/locales/zh-CN/settings.ts @@ -959,6 +959,11 @@ export default { pinnedOne: "它是 <1>{{name}} 的默认 CLI,之后会切换为 <3>{{fallback}}。", pinnedMany: "它是 {{count}} 个项目的默认 CLI,之后会切换为 <1>{{fallback}}。", terminalsGroup: "终端", + filterAll: "全部", + filterAgents: "AI 智能体", + filterTerminals: "CLI 工具与终端", + noTerminals: "尚未配置自定义终端。使用上方的“+ 添加终端”添加一个。", + iconPicker: "选择图标", newAgentName: "新智能体 {{n}}", newTerminalName: "新终端 {{n}}", allTerminals: "所有终端",