From 22736bee0d467cc577260263dfa431fb03ec7ae6 Mon Sep 17 00:00:00 2001 From: Vinodkumar Naidu <7994336+nvkvin@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:49:34 +0530 Subject: [PATCH 1/3] feat(settings): add custom terminal icon picker and agent/terminal grouping --- src/components/settings/AgentsSection.tsx | 169 ++++++++++++++---- src/components/settings/TerminalIcons.test.ts | 100 +++++++++++ src/icons/cli.tsx | 155 ++++++++++++++++ src/locales/en/settings.ts | 5 + src/locales/zh-CN/settings.ts | 5 + 5 files changed, 404 insertions(+), 30 deletions(-) create mode 100644 src/components/settings/TerminalIcons.test.ts diff --git a/src/components/settings/AgentsSection.tsx b/src/components/settings/AgentsSection.tsx index ea432c94e..72ab7c67e 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"; @@ -285,7 +285,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,6 +569,8 @@ 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. useEffect(() => { @@ -583,8 +585,15 @@ function AgentsTabs({ // `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 +719,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 +832,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} + /> + )}
); @@ -914,9 +976,56 @@ 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/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/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: "所有终端", From ed0f2c1b076b8656398c5dee57b6a449d3971d8f Mon Sep 17 00:00:00 2001 From: Vinodkumar Naidu <7994336+nvkvin@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:55:54 +0530 Subject: [PATCH 2/3] feat(terminal): inject task .env variables into terminal sessions and custom commands --- src/components/task/AuxTerminal.tsx | 7 ++- src/components/task/TerminalPane.tsx | 18 +++++--- src/lib/dotenv.test.ts | 67 ++++++++++++++++++++++++++++ src/lib/dotenv.ts | 45 +++++++++++++++++++ 4 files changed, 130 insertions(+), 7 deletions(-) create mode 100644 src/lib/dotenv.test.ts create mode 100644 src/lib/dotenv.ts 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..660930e07 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,9 @@ const captureArmedRef = useRef(false); let launchCmd = isCustom && tab.command ? tab.command : isRegistryTerminal ? terminalLaunchCommand(tab.cli, task) : undefined; + if (launchCmd) { + launchCmd = wrapWithDotEnv(launchCmd); + } // 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 +2948,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 +2972,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/lib/dotenv.test.ts b/src/lib/dotenv.test.ts new file mode 100644 index 000000000..b84b28781 --- /dev/null +++ b/src/lib/dotenv.test.ts @@ -0,0 +1,67 @@ +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 and export prefix", () => { + const raw = ` +# Comment +STARROCKS_USER=my_user +STARROCKS_PASSWORD="super secret password" +export HOST=localhost +SINGLE='quoted value' +EMPTY= +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: "", + }); + }); + + 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("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..ff8d0facd --- /dev/null +++ b/src/lib/dotenv.ts @@ -0,0 +1,45 @@ +import * as ipc from "./ipc"; + +/** Parse a simple .env file content into key-value environment variables. + * Supports unquoted, single-quoted, double-quoted values, 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); + } + out[key] = val; + } + return out; +} + +/** Prefix a shell command to source .env and .env.local if present in the worktree. */ +export function wrapWithDotEnv(cmd: string): string { + if (cmd.startsWith("[ -f .env ]")) return cmd; + const envPrefix = "[ -f .env ] && set -a && . ./.env 2>/dev/null && set +a; [ -f .env.local ] && set -a && . ./.env.local 2>/dev/null && set +a;"; + return `${envPrefix} ${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; +} From 37fa9b05d488ddfd45cf2eecf13ec71a09ba0a8a Mon Sep 17 00:00:00 2001 From: Vinodkumar Naidu <7994336+nvkvin@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:09:50 +0530 Subject: [PATCH 3/3] fix(settings): address review feedback on icon picker and .env handling --- src/components/settings/AgentsSection.tsx | 43 +++++++++++++++++------ src/components/task/TerminalPane.tsx | 3 +- src/lib/dotenv.test.ts | 20 +++++++---- src/lib/dotenv.ts | 25 ++++++++++--- 4 files changed, 69 insertions(+), 22 deletions(-) diff --git a/src/components/settings/AgentsSection.tsx b/src/components/settings/AgentsSection.tsx index 72ab7c67e..9cb50e587 100644 --- a/src/components/settings/AgentsSection.tsx +++ b/src/components/settings/AgentsSection.tsx @@ -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() { @@ -572,13 +573,31 @@ function AgentsTabs({ // 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 @@ -962,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; @@ -977,8 +997,8 @@ function AgentCard({ agent, detected, onPatch, onCommitId, onPatchCaps, onRemove
{isTerminal || !agent.builtin ? ( - - + + - - + +
{t("agents.iconPicker")}
@@ -1004,7 +1024,10 @@ function AgentCard({ agent, detected, onPatch, onCommitId, onPatchCaps, onRemove key={icon.id} type="button" data-testid={`icon-pick-${icon.id}`} - onClick={() => onPatch({ icon_id: icon.id })} + onClick={() => { + onPatch({ icon_id: icon.id }); + setIconPickerOpen(false); + }} title={icon.label} className={cn( "flex h-8 w-8 items-center justify-center rounded transition-colors cursor-pointer", @@ -1019,8 +1042,8 @@ function AgentCard({ agent, detected, onPatch, onCommitId, onPatchCaps, onRemove ); })}
- - + + ) : ( diff --git a/src/components/task/TerminalPane.tsx b/src/components/task/TerminalPane.tsx index 660930e07..2e7bbdf60 100644 --- a/src/components/task/TerminalPane.tsx +++ b/src/components/task/TerminalPane.tsx @@ -2885,7 +2885,8 @@ const captureArmedRef = useRef(false); : isRegistryTerminal ? terminalLaunchCommand(tab.cli, task) : undefined; if (launchCmd) { - launchCmd = wrapWithDotEnv(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). diff --git a/src/lib/dotenv.test.ts b/src/lib/dotenv.test.ts index b84b28781..eeddadba1 100644 --- a/src/lib/dotenv.test.ts +++ b/src/lib/dotenv.test.ts @@ -22,24 +22,26 @@ EMPTY= })); describe("dotenv support", () => { - it("parses .env key-value pairs including quotes and export prefix", () => { + it("parses .env key-value pairs including quotes, inline comments, and export prefix", () => { const raw = ` # Comment -STARROCKS_USER=my_user -STARROCKS_PASSWORD="super secret password" +STARROCKS_USER=my_user # username for connection +STARROCKS_PASSWORD="super secret # password" export HOST=localhost -SINGLE='quoted value' +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", + STARROCKS_PASSWORD: "super secret # password", HOST: "localhost", - SINGLE: "quoted value", + SINGLE: "quoted # value", EMPTY: "", + COMMENT_ONLY: "", }); }); @@ -50,6 +52,12 @@ INVALID LINE WITHOUT EQUALS 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); diff --git a/src/lib/dotenv.ts b/src/lib/dotenv.ts index ff8d0facd..7d9e25437 100644 --- a/src/lib/dotenv.ts +++ b/src/lib/dotenv.ts @@ -1,7 +1,7 @@ import * as ipc from "./ipc"; /** Parse a simple .env file content into key-value environment variables. - * Supports unquoted, single-quoted, double-quoted values, and lines prefixed with "export ". */ + * 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")) { @@ -15,17 +15,32 @@ export function parseDotEnv(content: string): Record { 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. */ -export function wrapWithDotEnv(cmd: string): string { +/** 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; - const envPrefix = "[ -f .env ] && set -a && . ./.env 2>/dev/null && set +a; [ -f .env.local ] && set -a && . ./.env.local 2>/dev/null && set +a;"; - return `${envPrefix} ${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. */