diff --git a/src/main/agents/agent-registry.ts b/src/main/agents/agent-registry.ts index 9a464c7..2f52331 100644 --- a/src/main/agents/agent-registry.ts +++ b/src/main/agents/agent-registry.ts @@ -6,6 +6,7 @@ interface CreateAdapterOptions { sessionId: string; worktreePath: string; additionalDirs?: string[]; + extraArgs?: string[]; } export function createAdapter(options: CreateAdapterOptions): ClaudeAdapter { @@ -15,6 +16,7 @@ export function createAdapter(options: CreateAdapterOptions): ClaudeAdapter { sessionId: options.sessionId, worktreePath: options.worktreePath, additionalDirs: options.additionalDirs, + extraArgs: options.extraArgs, }); default: throw new Error(`No adapter available for agent type: ${options.agentType}`); diff --git a/src/main/agents/claude-adapter.test.ts b/src/main/agents/claude-adapter.test.ts index 534d8b0..a709054 100644 --- a/src/main/agents/claude-adapter.test.ts +++ b/src/main/agents/claude-adapter.test.ts @@ -44,6 +44,28 @@ describe("ClaudeAdapter", () => { expect(args).not.toContain("--resume"); expect(args).not.toContain("--continue"); }); + + it("appends extraArgs to the argument list", () => { + const adapterWithExtra = new ClaudeAdapter({ + sessionId: "test-session-1", + worktreePath: "/tmp/worktree", + extraArgs: ["--model", "claude-opus-4-5", "--max-turns", "10"], + }); + const args = adapterWithExtra.buildStartArgs("Hello"); + expect(args).toContain("--model"); + expect(args).toContain("claude-opus-4-5"); + expect(args).toContain("--max-turns"); + expect(args).toContain("10"); + // extraArgs should come after the standard args + const modelIndex = args.indexOf("--model"); + const verboseIndex = args.indexOf("--verbose"); + expect(modelIndex).toBeGreaterThan(verboseIndex); + }); + + it("works without extraArgs (empty by default)", () => { + const args = adapter.buildStartArgs("Hello"); + expect(args).not.toContain("--model"); + }); }); describe("buildResumeArgs", () => { @@ -67,6 +89,21 @@ describe("ClaudeAdapter", () => { const args = adapter.buildResumeArgs("Continue"); expect(args).not.toContain("--session-id"); }); + + it("appends extraArgs to the resume argument list", () => { + const adapterWithExtra = new ClaudeAdapter({ + sessionId: "test-session-1", + worktreePath: "/tmp/worktree", + extraArgs: ["--model", "claude-opus-4-5"], + }); + adapterWithExtra.setAgentSessionId("claude-session-abc"); + const args = adapterWithExtra.buildResumeArgs("Continue"); + expect(args).toContain("--model"); + expect(args).toContain("claude-opus-4-5"); + const modelIndex = args.indexOf("--model"); + const resumeIndex = args.indexOf("--resume"); + expect(modelIndex).toBeGreaterThan(resumeIndex); + }); }); describe("parseLine", () => { diff --git a/src/main/agents/claude-adapter.ts b/src/main/agents/claude-adapter.ts index da95d98..e060891 100644 --- a/src/main/agents/claude-adapter.ts +++ b/src/main/agents/claude-adapter.ts @@ -4,6 +4,7 @@ interface ClaudeAdapterOptions { sessionId: string; worktreePath: string; additionalDirs?: string[]; + extraArgs?: string[]; } export class ClaudeAdapter { @@ -11,11 +12,13 @@ export class ClaudeAdapter { private worktreePath: string; private agentSessionId: string | null = null; private additionalDirs: string[]; + private extraArgs: string[]; constructor(options: ClaudeAdapterOptions) { this.sessionId = options.sessionId; this.worktreePath = options.worktreePath; this.additionalDirs = options.additionalDirs ?? []; + this.extraArgs = options.extraArgs ?? []; } buildStartArgs(prompt: string): string[] { @@ -23,6 +26,7 @@ export class ClaudeAdapter { for (const dir of this.additionalDirs) { args.push("--add-dir", dir); } + args.push(...this.extraArgs); return args; } @@ -34,6 +38,7 @@ export class ClaudeAdapter { for (const dir of this.additionalDirs) { args.push("--add-dir", dir); } + args.push(...this.extraArgs); return args; } diff --git a/src/main/db/connection.test.ts b/src/main/db/connection.test.ts index 4f6ec7e..a3eaf7f 100644 --- a/src/main/db/connection.test.ts +++ b/src/main/db/connection.test.ts @@ -64,4 +64,56 @@ describe("createDatabase", () => { db.exec("SELECT 1 FROM messages LIMIT 1"); }).not.toThrow(); }); + + it("migration v5 adds binary_name and extra_args columns to sessions", () => { + const dbPath = path.join(tempDir, "migrate-v5.db"); + db = createDatabase(dbPath); + // Both columns should be queryable on a fresh database + expect(() => { + db.exec("SELECT binary_name, extra_args FROM sessions LIMIT 1"); + }).not.toThrow(); + }); + + it("migration v5 does not break existing rows (columns default to null)", () => { + db = createDatabase(":memory:"); + // Insert a repo first (required FK) + db.exec("INSERT INTO repos (path, name) VALUES ('/repo', 'repo')"); + db.exec( + "INSERT INTO sessions (id, repo_path, worktree_path, agent_type, name, sort_order) VALUES ('s1', '/repo', '/repo', 'claude', 'test', 0)", + ); + const row = db.prepare("SELECT binary_name, extra_args FROM sessions WHERE id = 's1'").get() as { + binary_name: string | null; + extra_args: string | null; + }; + expect(row.binary_name).toBeNull(); + expect(row.extra_args).toBeNull(); + }); + + it("migration v6 adds preset_name column to sessions", () => { + db = createDatabase(":memory:"); + expect(() => { + db.exec("SELECT preset_name FROM sessions LIMIT 1"); + }).not.toThrow(); + }); + + it("migration v7 adds env_vars column to sessions", () => { + db = createDatabase(":memory:"); + expect(() => { + db.exec("SELECT env_vars FROM sessions LIMIT 1"); + }).not.toThrow(); + }); + + it("all profile columns default to null for new rows", () => { + db = createDatabase(":memory:"); + db.exec("INSERT INTO repos (path, name) VALUES ('/repo', 'repo')"); + db.exec( + "INSERT INTO sessions (id, repo_path, worktree_path, agent_type, name, sort_order) VALUES ('s1', '/repo', '/repo', 'claude', 'test', 0)", + ); + const row = db.prepare("SELECT preset_name, env_vars FROM sessions WHERE id = 's1'").get() as { + preset_name: string | null; + env_vars: string | null; + }; + expect(row.preset_name).toBeNull(); + expect(row.env_vars).toBeNull(); + }); }); diff --git a/src/main/db/connection.ts b/src/main/db/connection.ts index 0e6cfae..2190461 100644 --- a/src/main/db/connection.ts +++ b/src/main/db/connection.ts @@ -1,6 +1,6 @@ import Database from "better-sqlite3"; -export const SCHEMA_VERSION = 4; +export const SCHEMA_VERSION = 7; const SCHEMA = ` CREATE TABLE IF NOT EXISTS repos ( @@ -41,10 +41,13 @@ const SCHEMA = ` CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp); `; -const MIGRATIONS: Record = { - 2: "ALTER TABLE messages ADD COLUMN thinking TEXT", - 3: "ALTER TABLE sessions ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0", - 4: "ALTER TABLE sessions ADD COLUMN branch_name TEXT", +const MIGRATIONS: Record = { + 2: ["ALTER TABLE messages ADD COLUMN thinking TEXT"], + 3: ["ALTER TABLE sessions ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0"], + 4: ["ALTER TABLE sessions ADD COLUMN branch_name TEXT"], + 5: ["ALTER TABLE sessions ADD COLUMN binary_name TEXT", "ALTER TABLE sessions ADD COLUMN extra_args TEXT"], + 6: ["ALTER TABLE sessions ADD COLUMN preset_name TEXT"], + 7: ["ALTER TABLE sessions ADD COLUMN env_vars TEXT"], }; export function createDatabase(dbPath: string): Database.Database { @@ -58,12 +61,14 @@ export function createDatabase(dbPath: string): Database.Database { // Run migrations for existing databases const currentVersion = (db.pragma("user_version", { simple: true }) as number) ?? 0; for (let version = currentVersion + 1; version <= SCHEMA_VERSION; version++) { - const migration = MIGRATIONS[version]; - if (migration) { - try { - db.exec(migration); - } catch { - // Column may already exist if schema was created fresh + const statements = MIGRATIONS[version]; + if (statements) { + for (const statement of statements) { + try { + db.exec(statement); + } catch { + // Column may already exist if schema was created fresh + } } } } diff --git a/src/main/db/sessions.test.ts b/src/main/db/sessions.test.ts index 3d34b82..028cc1e 100644 --- a/src/main/db/sessions.test.ts +++ b/src/main/db/sessions.test.ts @@ -43,6 +43,76 @@ describe("createSession", () => { }); }); +describe("createSession with binaryName and extraArgs", () => { + it("stores and returns binaryName when provided", () => { + const session = createSession(db, { + repoPath: "/Users/dan/project", + worktreePath: "/Users/dan/project", + agentType: "claude", + name: "test", + binaryName: "claude-work", + }); + expect(session.binaryName).toBe("claude-work"); + }); + + it("stores and returns extraArgs when provided", () => { + const session = createSession(db, { + repoPath: "/Users/dan/project", + worktreePath: "/Users/dan/project", + agentType: "claude", + name: "test", + extraArgs: "--model claude-opus-4-5 --max-turns 10", + }); + expect(session.extraArgs).toBe("--model claude-opus-4-5 --max-turns 10"); + }); + + it("defaults binaryName and extraArgs to null when not provided", () => { + const session = createSession(db, { + repoPath: "/Users/dan/project", + worktreePath: "/Users/dan/project", + agentType: "claude", + name: "test", + }); + expect(session.binaryName).toBeNull(); + expect(session.extraArgs).toBeNull(); + }); +}); + +describe("createSession with profileName and envVars", () => { + it("stores and returns profileName when provided", () => { + const session = createSession(db, { + repoPath: "/Users/dan/project", + worktreePath: "/Users/dan/project", + agentType: "claude", + name: "test", + profileName: "home", + }); + expect(session.profileName).toBe("home"); + }); + + it("stores and returns envVars when provided", () => { + const session = createSession(db, { + repoPath: "/Users/dan/project", + worktreePath: "/Users/dan/project", + agentType: "claude", + name: "test", + envVars: "CLAUDE_CONFIG_DIR=/Users/dan/.claude-home\nANTHROPIC_MODEL=claude-opus-4-5", + }); + expect(session.envVars).toBe("CLAUDE_CONFIG_DIR=/Users/dan/.claude-home\nANTHROPIC_MODEL=claude-opus-4-5"); + }); + + it("defaults profileName and envVars to null when not provided", () => { + const session = createSession(db, { + repoPath: "/Users/dan/project", + worktreePath: "/Users/dan/project", + agentType: "claude", + name: "test", + }); + expect(session.profileName).toBeNull(); + expect(session.envVars).toBeNull(); + }); +}); + describe("createSession with branchName", () => { it("stores and returns branchName", () => { const session = createSession(db, { diff --git a/src/main/db/sessions.ts b/src/main/db/sessions.ts index ffb159c..403f93e 100644 --- a/src/main/db/sessions.ts +++ b/src/main/db/sessions.ts @@ -14,6 +14,10 @@ interface SessionRow { created_at: string; last_active_at: string; sort_order: number; + binary_name: string | null; + extra_args: string | null; + preset_name: string | null; + env_vars: string | null; } function rowToSession(row: SessionRow): SessionInfo { @@ -28,6 +32,10 @@ function rowToSession(row: SessionRow): SessionInfo { name: row.name, createdAt: row.created_at, lastActiveAt: row.last_active_at, + binaryName: row.binary_name, + extraArgs: row.extra_args, + profileName: row.preset_name, + envVars: row.env_vars, }; } @@ -37,6 +45,10 @@ interface CreateSessionParams { branchName?: string | null; agentType: AgentType; name: string; + binaryName?: string | null; + extraArgs?: string | null; + profileName?: string | null; + envVars?: string | null; } export function createSession(db: Database.Database, params: CreateSessionParams): SessionInfo { @@ -45,7 +57,7 @@ export function createSession(db: Database.Database, params: CreateSessionParams db.prepare("SELECT COALESCE(MAX(sort_order), -1) as max_order FROM sessions").get() as { max_order: number } ).max_order; db.prepare( - "INSERT INTO sessions (id, repo_path, worktree_path, branch_name, agent_type, name, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO sessions (id, repo_path, worktree_path, branch_name, agent_type, name, sort_order, binary_name, extra_args, preset_name, env_vars) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ).run( id, params.repoPath, @@ -54,6 +66,10 @@ export function createSession(db: Database.Database, params: CreateSessionParams params.agentType, params.name, maxOrder + 1, + params.binaryName ?? null, + params.extraArgs ?? null, + params.profileName ?? null, + params.envVars ?? null, ); return getSession(db, id) as SessionInfo; } diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index 5cb6a4b..cf1921c 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -120,15 +120,17 @@ export function registerIpcHandlers(options: RegisterHandlersOptions): void { name?: string; baseBranch?: string; fetchFirst?: boolean; + profileId?: string; }, ) => { - const { repoPath, agentType, branchName, name, baseBranch, fetchFirst } = options; + const { repoPath, agentType, branchName, name, baseBranch, fetchFirst, profileId } = options; const sessionName = name || `Session ${Date.now()}`; let worktreePath = repoPath; let resolvedBranch: string | null = null; + const settings = readSettings(settingsPath); + if (branchName) { - const settings = readSettings(settingsPath); worktreePath = createWorktree({ repoPath, branchName, @@ -139,7 +141,19 @@ export function registerIpcHandlers(options: RegisterHandlersOptions): void { resolvedBranch = branchName; } - return createSession(db, { repoPath, worktreePath, branchName: resolvedBranch, agentType, name: sessionName }); + const profile = profileId ? settings.commandProfiles?.find((p) => p.id === profileId) : undefined; + + return createSession(db, { + repoPath, + worktreePath, + branchName: resolvedBranch, + agentType, + name: sessionName, + binaryName: profile?.executable ?? null, + extraArgs: profile?.extraArgs ?? null, + profileName: profile?.name ?? null, + envVars: profile?.envVars ?? null, + }); }, ); @@ -201,7 +215,17 @@ export function registerIpcHandlers(options: RegisterHandlersOptions): void { // Legacy sessions stored "used" as a boolean marker — treat those as new sessions. const sessionRecord = getSession(db, sessionId); const storedSessionId = sessionRecord?.agentSessionId === "used" ? null : (sessionRecord?.agentSessionId ?? null); - ptyManager.create(sessionId, agentType, worktreePath, cols, rows, storedSessionId); + ptyManager.create( + sessionId, + agentType, + worktreePath, + cols, + rows, + storedSessionId, + sessionRecord?.binaryName, + sessionRecord?.extraArgs, + sessionRecord?.envVars, + ); // Store the session ID so future launches use --resume if (!storedSessionId) { diff --git a/src/main/services/pty-manager.test.ts b/src/main/services/pty-manager.test.ts index 62c6cff..e9ae134 100644 --- a/src/main/services/pty-manager.test.ts +++ b/src/main/services/pty-manager.test.ts @@ -32,7 +32,7 @@ describe("PtyManager", () => { lastMockPty = createMockPty(); return lastMockPty; }); - const manager = new PtyManager(spawnFn); + const manager = new PtyManager(spawnFn, () => ({})); return { manager, spawnFn, getLastPty: () => lastMockPty as MockPty }; } @@ -60,6 +60,26 @@ describe("PtyManager", () => { expect(callOptions.env.TERM).toBe("xterm-256color"); }); + it("merges shell env vars into the PTY environment", () => { + const spawnFn = vi.fn((_f: string, _a: string[], _o: unknown) => createMockPty()); + const shellEnvProvider = () => ({ MY_TOKEN: "secret123", MY_PATH: "/custom/bin:/usr/bin" }); + const manager = new PtyManager(spawnFn, shellEnvProvider); + manager.create("session-1", "claude", "/repo", 80, 24); + + const callOptions = spawnFn.mock.calls[0][2] as { env: Record }; + expect(callOptions.env.MY_TOKEN).toBe("secret123"); + expect(callOptions.env.MY_PATH).toBe("/custom/bin:/usr/bin"); + }); + + it("does not pass CLAUDECODE to the PTY environment", () => { + const spawnFn = vi.fn((_f: string, _a: string[], _o: unknown) => createMockPty()); + const manager = new PtyManager(spawnFn, () => ({ CLAUDECODE: "1" })); + manager.create("session-1", "claude", "/repo", 80, 24); + + const callOptions = spawnFn.mock.calls[0][2] as { env: Record }; + expect(callOptions.env.CLAUDECODE).toBeUndefined(); + }); + it("passes --session-id for first launch of a claude session", () => { const { manager, spawnFn } = createManager(); manager.create("session-1", "claude", "/repo", 80, 24); @@ -88,6 +108,33 @@ describe("PtyManager", () => { expect(spawnFn).toHaveBeenCalledWith("gemini", [], expect.objectContaining({ cwd: "/repo" })); }); + + it("uses binaryNameOverride instead of agentType when provided", () => { + const { manager, spawnFn } = createManager(); + manager.create("session-1", "claude", "/repo", 80, 24, null, "claude-work"); + + expect(spawnFn).toHaveBeenCalledWith("claude-work", expect.any(Array), expect.objectContaining({ cwd: "/repo" })); + }); + + it("appends parsed extraArgsStr to the args", () => { + const { manager, spawnFn } = createManager(); + manager.create("session-1", "claude", "/repo", 80, 24, null, null, "--model claude-opus-4-5 --max-turns 10"); + + const calledArgs = spawnFn.mock.calls[0][1] as string[]; + expect(calledArgs).toContain("--model"); + expect(calledArgs).toContain("claude-opus-4-5"); + expect(calledArgs).toContain("--max-turns"); + expect(calledArgs).toContain("10"); + }); + + it("ignores empty extraArgsStr", () => { + const { manager, spawnFn } = createManager(); + manager.create("session-1", "claude", "/repo", 80, 24, null, null, " "); + + const calledArgs = spawnFn.mock.calls[0][1] as string[]; + // Should only have --session-id args, no extra flags + expect(calledArgs).toEqual(["--session-id", "session-1"]); + }); }); describe("write", () => { diff --git a/src/main/services/pty-manager.ts b/src/main/services/pty-manager.ts index ca6e828..bb955dc 100644 --- a/src/main/services/pty-manager.ts +++ b/src/main/services/pty-manager.ts @@ -1,7 +1,10 @@ import { EventEmitter } from "node:events"; import type { AgentType } from "../../shared/agent-types.js"; +import { getShellEnv, parseEnvOutput } from "../shell-env.js"; import { SidebandDetector } from "./sideband-detector.js"; +type ShellEnvProvider = () => Record; + interface PtyLike { onData: (callback: (data: string) => void) => { dispose: () => void }; onExit: (callback: (exitInfo: { exitCode: number }) => void) => { dispose: () => void }; @@ -26,10 +29,12 @@ interface PtySession { export class PtyManager extends EventEmitter { private sessions = new Map(); private spawnFn: SpawnFn; + private shellEnvProvider: ShellEnvProvider; - constructor(spawnFn: SpawnFn) { + constructor(spawnFn: SpawnFn, shellEnvProvider: ShellEnvProvider = getShellEnv) { super(); this.spawnFn = spawnFn; + this.shellEnvProvider = shellEnvProvider; } create( @@ -39,11 +44,14 @@ export class PtyManager extends EventEmitter { cols: number, rows: number, agentSessionId?: string | null, + binaryNameOverride?: string | null, + extraArgsStr?: string | null, + envVarsStr?: string | null, ): void { // Idempotent — skip if PTY already exists (React strict mode double-mounts in dev) if (this.sessions.has(sessionId)) return; - const binaryName = agentType; + const binaryName = binaryNameOverride ?? agentType; // Build args — for Claude, pin each Codez session to a specific // Claude session ID so multiple sessions in the same repo don't collide. @@ -57,12 +65,19 @@ export class PtyManager extends EventEmitter { args.push("--session-id", sessionId); } } + if (extraArgsStr) { + const parsedExtra = extraArgsStr.trim().split(/\s+/).filter(Boolean); + args.push(...parsedExtra); + } - const cleanEnv: Record = {}; - for (const [key, value] of Object.entries(process.env)) { - if (value !== undefined) { - cleanEnv[key] = value; - } + // Start with the user's login+interactive shell env so PATH, API keys, + // and other vars from .zshrc/.zprofile are available to the agent. + // The shell was spawned from this process so it inherits process.env too, + // meaning shellEnv is already a superset — use it directly. + const cleanEnv: Record = { ...this.shellEnvProvider() }; + // Inject preset-level env vars last so they take priority over shell env. + if (envVarsStr) { + Object.assign(cleanEnv, parseEnvOutput(envVarsStr)); } delete cleanEnv.CLAUDECODE; cleanEnv.TERM = "xterm-256color"; diff --git a/src/main/services/session-lifecycle.ts b/src/main/services/session-lifecycle.ts index ccb5d66..6249330 100644 --- a/src/main/services/session-lifecycle.ts +++ b/src/main/services/session-lifecycle.ts @@ -16,6 +16,10 @@ interface SessionLifecycleOptions { getAdditionalDirs?: () => string[]; } +function parseExtraArgs(extraArgsStr: string): string[] { + return extraArgsStr.trim().split(/\s+/).filter(Boolean); +} + interface StartSessionParams { repoPath: string; worktreePath: string; @@ -60,12 +64,13 @@ export class SessionLifecycle extends EventEmitter { sessionId: session.id, worktreePath: params.worktreePath, additionalDirs: this.getAdditionalDirs(), + extraArgs: parseExtraArgs(session.extraArgs ?? ""), }); const parser = new StreamParser(); const args = adapter.buildStartArgs(params.prompt); - const proc = this.spawnFn("claude", args, { cwd: params.worktreePath }); + const proc = this.spawnFn(session.binaryName ?? params.agentType, args, { cwd: params.worktreePath }); const activeSession: ActiveSession = { adapter, @@ -110,6 +115,7 @@ export class SessionLifecycle extends EventEmitter { sessionId: session.id, worktreePath: session.worktreePath, additionalDirs: this.getAdditionalDirs(), + extraArgs: parseExtraArgs(session.extraArgs ?? ""), }); adapter.setAgentSessionId(session.agentSessionId ?? ""); @@ -132,7 +138,7 @@ export class SessionLifecycle extends EventEmitter { this.emit("statusChanged", sessionId, "running"); const args = active.adapter.buildResumeArgs(prompt); - const proc = this.spawnFn("claude", args, { cwd: active.worktreePath }); + const proc = this.spawnFn(session.binaryName ?? session.agentType, args, { cwd: active.worktreePath }); active.process = proc; active.parser.reset(); @@ -160,11 +166,12 @@ export class SessionLifecycle extends EventEmitter { sessionId: session.id, worktreePath: session.worktreePath, additionalDirs: this.getAdditionalDirs(), + extraArgs: parseExtraArgs(session.extraArgs ?? ""), }); const parser = new StreamParser(); const args = adapter.buildStartArgs(prompt); - const proc = this.spawnFn("claude", args, { cwd: session.worktreePath }); + const proc = this.spawnFn(session.binaryName ?? session.agentType, args, { cwd: session.worktreePath }); const activeSession: ActiveSession = { adapter, diff --git a/src/main/shell-env.test.ts b/src/main/shell-env.test.ts new file mode 100644 index 0000000..bd2095c --- /dev/null +++ b/src/main/shell-env.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { parseEnvOutput } from "./shell-env"; + +describe("parseEnvOutput", () => { + it("parses simple KEY=VALUE lines", () => { + const output = "PATH=/usr/local/bin:/usr/bin\nHOME=/Users/dan\n"; + expect(parseEnvOutput(output)).toEqual({ + PATH: "/usr/local/bin:/usr/bin", + HOME: "/Users/dan", + }); + }); + + it("preserves equals signs in values", () => { + const output = "SOME_VAR=foo=bar=baz\n"; + expect(parseEnvOutput(output)).toEqual({ SOME_VAR: "foo=bar=baz" }); + }); + + it("skips lines without an equals sign", () => { + const output = "not_an_env_var\nKEY=value\n"; + expect(parseEnvOutput(output)).toEqual({ KEY: "value" }); + }); + + it("skips lines that look like shell output noise", () => { + const output = "[oh-my-zsh] loading...\nPATH=/usr/bin\n indented=bad\n"; + expect(parseEnvOutput(output)).toEqual({ PATH: "/usr/bin" }); + }); + + it("handles empty values", () => { + const output = "EMPTY=\n"; + expect(parseEnvOutput(output)).toEqual({ EMPTY: "" }); + }); + + it("handles empty input", () => { + expect(parseEnvOutput("")).toEqual({}); + }); + + it("allows lowercase and mixed-case keys", () => { + const output = "myVar=value\nMY_VAR=other\n"; + expect(parseEnvOutput(output)).toEqual({ myVar: "value", MY_VAR: "other" }); + }); +}); diff --git a/src/main/shell-env.ts b/src/main/shell-env.ts new file mode 100644 index 0000000..ca93129 --- /dev/null +++ b/src/main/shell-env.ts @@ -0,0 +1,41 @@ +import { execSync } from "node:child_process"; +import { homedir } from "node:os"; + +// Only include lines that look like valid env var assignments (KEY=value). +// This filters out any stdout noise from .zshrc initialization. +const ENV_LINE_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*=/; + +export function parseEnvOutput(output: string): Record { + const env: Record = {}; + for (const line of output.split("\n")) { + if (!ENV_LINE_PATTERN.test(line)) continue; + const eqIndex = line.indexOf("="); + env[line.slice(0, eqIndex)] = line.slice(eqIndex + 1); + } + return env; +} + +let cachedEnv: Record | null = null; + +export function getShellEnv(): Record { + if (cachedEnv !== null) return cachedEnv; + + const shell = process.env.SHELL ?? "/bin/zsh"; + const rcFile = `${homedir()}/.zshrc`; + // Login shell sources .zprofile (PATH, brew, etc.). + // Explicitly source .zshrc for interactive env vars — silencing errors + // since some .zshrc hooks fail outside a true interactive terminal. + const command = `source ${rcFile} 2>/dev/null; printenv`; + try { + const output = execSync(`${shell} -l -c '${command}'`, { + timeout: 10_000, + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + }); + cachedEnv = parseEnvOutput(output); + } catch { + cachedEnv = {}; + } + + return cachedEnv; +} diff --git a/src/renderer/components/SessionView/TerminalView.tsx b/src/renderer/components/SessionView/TerminalView.tsx index 8bc6efa..951da04 100644 --- a/src/renderer/components/SessionView/TerminalView.tsx +++ b/src/renderer/components/SessionView/TerminalView.tsx @@ -134,7 +134,7 @@ export function TerminalView({ sessionId, agentType, worktreePath, isActive }: T terminal.attachCustomKeyEventHandler((event) => { if (event.key === "Enter" && event.shiftKey) { if (event.type === "keydown") { - window.electronAPI.ptyInput(sessionId, "\n"); + window.electronAPI.ptyInput(sessionId, "\n").catch(() => {}); } return false; } @@ -143,7 +143,7 @@ export function TerminalView({ sessionId, agentType, worktreePath, isActive }: T // Forward keystrokes to PTY terminal.onData((data) => { - window.electronAPI.ptyInput(sessionId, data); + window.electronAPI.ptyInput(sessionId, data).catch(() => {}); }); // Subscribe to PTY output — buffer writes when terminal is hidden diff --git a/src/renderer/components/SettingsPanel/SettingsPanel.tsx b/src/renderer/components/SettingsPanel/SettingsPanel.tsx index 430b79d..fae542e 100644 --- a/src/renderer/components/SettingsPanel/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel/SettingsPanel.tsx @@ -1,4 +1,4 @@ -import type { FontInfo } from "@shared/types"; +import type { CommandProfile, FontInfo } from "@shared/types"; import { useCallback, useEffect, useState } from "react"; import { useFontStore } from "../../stores/fontStore"; import { useThemeStore } from "../../stores/themeStore"; @@ -38,6 +38,13 @@ export function SettingsPanel() { const [iconDataUrls, setIconDataUrls] = useState>({}); const [appVersion, setAppVersion] = useState(""); const [worktreeBaseDir, setWorktreeBaseDir] = useState(); + const [commandProfiles, setCommandProfiles] = useState([]); + const [showAddProfile, setShowAddProfile] = useState(false); + const [editingProfileId, setEditingProfileId] = useState(null); + const [newProfileName, setNewProfileName] = useState(""); + const [newProfileExecutable, setNewProfileExecutable] = useState("claude"); + const [newProfileExtraArgs, setNewProfileExtraArgs] = useState(""); + const [newProfileEnvVars, setNewProfileEnvVars] = useState(""); const [updateState, setUpdateState] = useState("idle"); const [updateInfo, setUpdateInfo] = useState({}); @@ -48,6 +55,7 @@ export function SettingsPanel() { window.electronAPI.getSettings().then((settings) => { setActiveIcon(settings.appIcon ?? "icon-01"); setWorktreeBaseDir(settings.worktreeBaseDir); + setCommandProfiles(settings.commandProfiles ?? []); }); window.electronAPI.getIconDataUrls().then(setIconDataUrls); window.electronAPI.listFonts().then(setFonts); @@ -135,6 +143,75 @@ export function SettingsPanel() { await window.electronAPI.saveSettings({ worktreeBaseDir: undefined }); }, []); + const resetProfileForm = useCallback(() => { + setNewProfileName(""); + setNewProfileExecutable("claude"); + setNewProfileExtraArgs(""); + setNewProfileEnvVars(""); + setEditingProfileId(null); + setShowAddProfile(false); + }, []); + + const handleEditProfile = useCallback((preset: CommandProfile) => { + setEditingProfileId(preset.id); + setNewProfileName(preset.name); + setNewProfileExecutable(preset.executable); + setNewProfileExtraArgs(preset.extraArgs ?? ""); + setNewProfileEnvVars(preset.envVars ?? ""); + setShowAddProfile(false); + }, []); + + const handleSaveProfile = useCallback(async () => { + if (!window.electronAPI || !editingProfileId || !newProfileName.trim() || !newProfileExecutable.trim()) return; + const updated = commandProfiles.map((p) => + p.id === editingProfileId + ? { + ...p, + name: newProfileName.trim(), + executable: newProfileExecutable.trim(), + extraArgs: newProfileExtraArgs.trim(), + envVars: newProfileEnvVars.trim(), + } + : p, + ); + setCommandProfiles(updated); + await window.electronAPI.saveSettings({ commandProfiles: updated }); + resetProfileForm(); + }, [ + commandProfiles, + editingProfileId, + newProfileName, + newProfileExecutable, + newProfileExtraArgs, + newProfileEnvVars, + resetProfileForm, + ]); + + const handleAddProfile = useCallback(async () => { + if (!window.electronAPI || !newProfileName.trim() || !newProfileExecutable.trim()) return; + const preset: CommandProfile = { + id: crypto.randomUUID(), + name: newProfileName.trim(), + executable: newProfileExecutable.trim(), + extraArgs: newProfileExtraArgs.trim(), + envVars: newProfileEnvVars.trim(), + }; + const updated = [...commandProfiles, preset]; + setCommandProfiles(updated); + await window.electronAPI.saveSettings({ commandProfiles: updated }); + resetProfileForm(); + }, [commandProfiles, newProfileName, newProfileExecutable, newProfileExtraArgs, newProfileEnvVars, resetProfileForm]); + + const handleDeleteProfile = useCallback( + async (id: string) => { + if (!window.electronAPI) return; + const updated = commandProfiles.filter((p) => p.id !== id); + setCommandProfiles(updated); + await window.electronAPI.saveSettings({ commandProfiles: updated }); + }, + [commandProfiles], + ); + const handleEscape = useCallback( (event: KeyboardEvent) => { if (event.key === "Escape") { @@ -207,6 +284,89 @@ export function SettingsPanel() { + {/* Profiles section */} +
+

Profiles

+
+

+ Configure named CLI variants to select when starting a session. Values containing spaces are not supported + in Extra Args. +

+ {commandProfiles.length > 0 && ( +
+ {commandProfiles.map((preset) => + editingProfileId === preset.id ? ( + + ) : ( +
+
+ {preset.name} + {preset.executable} + {preset.extraArgs && ( + {preset.extraArgs} + )} + {preset.envVars && [env]} +
+ + +
+ ), + )} +
+ )} + {showAddProfile ? ( + + ) : ( + !editingProfileId && ( + + ) + )} +
+
+ {/* Fonts section */}

Fonts

@@ -358,3 +518,79 @@ export function SettingsPanel() {
); } + +function ProfileForm({ + name, + executable, + extraArgs, + envVars, + onName, + onExecutable, + onExtraArgs, + onEnvVars, + onSave, + onCancel, + saveLabel, +}: { + name: string; + executable: string; + extraArgs: string; + envVars: string; + onName: (v: string) => void; + onExecutable: (v: string) => void; + onExtraArgs: (v: string) => void; + onEnvVars: (v: string) => void; + onSave: () => void; + onCancel: () => void; + saveLabel: string; +}) { + return ( +
+ onName(e.target.value)} + placeholder="Name (e.g. Work Account)" + className="w-full px-2 py-1 bg-input border border-border rounded-md text-[12px] text-text-primary placeholder:text-text-muted/50 focus:outline-none focus:ring-1 focus:ring-accent/50" + /> + onExecutable(e.target.value)} + placeholder="Executable (e.g. claude)" + className="w-full px-2 py-1 bg-input border border-border rounded-md text-[12px] text-text-primary placeholder:text-text-muted/50 focus:outline-none focus:ring-1 focus:ring-accent/50 font-mono" + /> + onExtraArgs(e.target.value)} + placeholder="Extra args (e.g. --model claude-opus-4-5)" + className="w-full px-2 py-1 bg-input border border-border rounded-md text-[12px] text-text-primary placeholder:text-text-muted/50 focus:outline-none focus:ring-1 focus:ring-accent/50 font-mono" + /> +