From 86b09bf4b7f2d8c4ff841e2ea4643926f00d2fc0 Mon Sep 17 00:00:00 2001 From: Daniel Klevebring Date: Sat, 4 Apr 2026 16:39:05 +0200 Subject: [PATCH 1/5] feat: add profiles data model - Add CommandProfile type (executable, extraArgs, envVars, profileName) - Extend SessionInfo with binaryName, extraArgs, profileName, envVars - DB migrations v5-v7: binary_name, extra_args, preset_name, env_vars columns - MIGRATIONS map changed from single strings to string[] for multi-statement safety --- src/main/db/connection.ts | 27 ++++++++++++++++----------- src/main/db/sessions.ts | 18 +++++++++++++++++- src/shared/agent-types.ts | 4 ++++ src/shared/types.ts | 10 ++++++++++ 4 files changed, 47 insertions(+), 12 deletions(-) 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.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/shared/agent-types.ts b/src/shared/agent-types.ts index 2a61c3c..96559c8 100644 --- a/src/shared/agent-types.ts +++ b/src/shared/agent-types.ts @@ -20,6 +20,10 @@ export interface SessionInfo { name: string; createdAt: string; lastActiveAt: string; + binaryName: string | null; + extraArgs: string | null; + profileName: string | null; + envVars: string | null; } export type AgentEventType = diff --git a/src/shared/types.ts b/src/shared/types.ts index ab9268e..9775583 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,5 +1,13 @@ import type { AgentConfig, AgentEvent, AgentType, ChangedFile, SessionInfo, SessionStatus } from "./agent-types"; +export interface CommandProfile { + id: string; + name: string; + executable: string; + extraArgs: string; + envVars: string; // newline-separated KEY=VALUE pairs +} + export interface RepoInfo { path: string; name: string; @@ -30,6 +38,7 @@ export interface AppSettings { appIcon?: string; /** Base directory for worktrees. Defaults to sibling of repo (--). */ worktreeBaseDir?: string; + commandProfiles?: CommandProfile[]; } export interface CreateSessionOptions { @@ -39,6 +48,7 @@ export interface CreateSessionOptions { name?: string; baseBranch?: string; fetchFirst?: boolean; + profileId?: string; } export interface ElectronAPI { From 8e8ea867665f7bcc90730f69e676c1ac38849391 Mon Sep 17 00:00:00 2001 From: Daniel Klevebring Date: Sat, 4 Apr 2026 16:39:12 +0200 Subject: [PATCH 2/5] feat: source user shell env and support profile-level overrides in PTY - shell-env.ts: load user login+interactive env once via zsh -l -c, so PATH, API keys, and .zshrc vars are available to agent processes - PtyManager: accept shellEnvProvider (injectable for tests) and envVarsStr (newline-separated KEY=VALUE pairs from profile) - ClaudeAdapter / agent-registry: thread extraArgs through to buildStartArgs and buildResumeArgs - session-lifecycle: use session.binaryName for spawn, parse extraArgs --- src/main/agents/agent-registry.ts | 2 ++ src/main/agents/claude-adapter.test.ts | 37 +++++++++++++++++++ src/main/agents/claude-adapter.ts | 5 +++ src/main/services/pty-manager.test.ts | 49 +++++++++++++++++++++++++- src/main/services/pty-manager.ts | 29 +++++++++++---- src/main/services/session-lifecycle.ts | 13 +++++-- src/main/shell-env.test.ts | 41 +++++++++++++++++++++ src/main/shell-env.ts | 41 +++++++++++++++++++++ 8 files changed, 206 insertions(+), 11 deletions(-) create mode 100644 src/main/shell-env.test.ts create mode 100644 src/main/shell-env.ts 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/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; +} From 161c0b65b413e2e3bf07fce6cc459a50877f23ff Mon Sep 17 00:00:00 2001 From: Daniel Klevebring Date: Sat, 4 Apr 2026 16:39:16 +0200 Subject: [PATCH 3/5] feat: wire profile selection through IPC and session store - sessions:create resolves profileId -> CommandProfile, stores binaryName/extraArgs/profileName/envVars in DB - pty:create reads profile fields from session record and passes to PtyManager - sessionStore and Sidebar forward profileId through onConfirm chain --- src/main/ipc-handlers.ts | 32 ++++++++++++++++++--- src/renderer/components/Sidebar/Sidebar.tsx | 3 +- src/renderer/stores/sessionStore.ts | 1 + 3 files changed, 31 insertions(+), 5 deletions(-) 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/renderer/components/Sidebar/Sidebar.tsx b/src/renderer/components/Sidebar/Sidebar.tsx index b2ac6a9..9fc24b2 100644 --- a/src/renderer/components/Sidebar/Sidebar.tsx +++ b/src/renderer/components/Sidebar/Sidebar.tsx @@ -110,7 +110,7 @@ export function Sidebar() { }, [addRepoViaDialog, setPendingNewSessionRepo]); const handleNewSessionConfirm = useCallback( - async (options: { branchName?: string; baseBranch?: string; fetchFirst?: boolean }) => { + async (options: { branchName?: string; baseBranch?: string; fetchFirst?: boolean; profileId?: string }) => { if (!pendingNewSessionRepo) return; await createSession({ repoPath: pendingNewSessionRepo.path, @@ -118,6 +118,7 @@ export function Sidebar() { branchName: options.branchName, baseBranch: options.baseBranch, fetchFirst: options.fetchFirst, + profileId: options.profileId, }); setPendingNewSessionRepo(null); }, diff --git a/src/renderer/stores/sessionStore.ts b/src/renderer/stores/sessionStore.ts index e87c5ad..8f6dc3b 100644 --- a/src/renderer/stores/sessionStore.ts +++ b/src/renderer/stores/sessionStore.ts @@ -21,6 +21,7 @@ interface SessionState { branchName?: string; baseBranch?: string; fetchFirst?: boolean; + profileId?: string; }) => Promise; setActiveSession: (sessionId: string | null) => void; deleteSession: (sessionId: string) => Promise; From 951ca1a77dbf71a7d3e5f1797935171ad201c29f Mon Sep 17 00:00:00 2001 From: Daniel Klevebring Date: Sat, 4 Apr 2026 16:39:23 +0200 Subject: [PATCH 4/5] feat: add Profiles UI in settings and session creation - SettingsPanel: Profiles section with add/edit/delete, fields for executable, extra args, and env vars (newline-separated KEY=VALUE) - NewSessionDialog: profile dropdown (loads independently of branch fetch so a git error can't hide it), forwards profileId on confirm - SessionListItem: show profile name in accent color on sub-line, branch truncates to make room - TerminalView: silence pty:input errors on dead PTY sessions --- .../components/SessionView/TerminalView.tsx | 4 +- .../SettingsPanel/SettingsPanel.tsx | 238 +++++++++++++++++- .../components/Sidebar/NewSessionDialog.tsx | 48 +++- .../components/Sidebar/SessionListItem.tsx | 8 +- 4 files changed, 285 insertions(+), 13 deletions(-) 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" + /> +