├── fleetcode.png ├── tailwind.config.js ├── jest.config.js ├── tsconfig.json ├── types.ts ├── .github └── workflows │ ├── test.yml │ └── release.yml ├── terminal-utils.ts ├── git-utils.ts ├── package.json ├── .gitignore ├── simple-claude.ts ├── README.md ├── git-utils.test.ts ├── styles.css ├── index.html ├── main.ts └── renderer.ts /fleetcode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/built-by-as/FleetCode/HEAD/fleetcode.png -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | "./index.html", 5 | "./renderer.ts", 6 | ], 7 | theme: { 8 | extend: {}, 9 | }, 10 | plugins: [], 11 | } 12 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | testMatch: ['**/*.test.ts'], 5 | collectCoverageFrom: [ 6 | '**/*.ts', 7 | '!**/*.test.ts', 8 | '!dist/**', 9 | '!node_modules/**', 10 | ], 11 | }; 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "lib": ["ES2020"], 6 | "outDir": "./dist", 7 | "rootDir": "./", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "resolveJsonModule": true, 13 | "moduleResolution": "node" 14 | }, 15 | "include": ["*.ts"], 16 | "exclude": ["node_modules", "dist"] 17 | } 18 | -------------------------------------------------------------------------------- /types.ts: -------------------------------------------------------------------------------- 1 | export enum SessionType { 2 | WORKTREE = "worktree", 3 | LOCAL = "local" 4 | } 5 | 6 | export interface SessionConfig { 7 | projectDir: string; 8 | sessionType: SessionType; 9 | parentBranch?: string; 10 | branchName?: string; 11 | codingAgent: string; 12 | skipPermissions: boolean; 13 | setupCommands?: string[]; 14 | } 15 | 16 | export interface PersistedSession { 17 | id: string; 18 | number: number; 19 | name: string; 20 | config: SessionConfig; 21 | worktreePath?: string; 22 | createdAt: number; 23 | sessionUuid: string; 24 | mcpConfigPath?: string; 25 | gitBranch?: string; 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Check out Git repository 17 | uses: actions/checkout@v4 18 | 19 | - name: Install Node.js 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: 20 23 | 24 | - name: Install dependencies 25 | run: npm install 26 | 27 | - name: Run tests 28 | run: npm test 29 | 30 | - name: Build 31 | run: npm run build 32 | -------------------------------------------------------------------------------- /terminal-utils.ts: -------------------------------------------------------------------------------- 1 | // Terminal escape sequences used for detecting terminal state 2 | 3 | // Bracketed paste mode enable - indicates terminal is ready for input 4 | export const BRACKETED_PASTE_MODE_ENABLE = "\x1b[?2004h"; 5 | 6 | // Pattern that indicates Claude interactive session is done and waiting for input 7 | // Looks for: >\r\n (empty prompt with no space, no suggestion text) 8 | const CLAUDE_READY_PROMPT_PATTERN = />\r\n/; 9 | 10 | // Check if a normal shell terminal is ready for input 11 | // Used during terminal initialization in main.ts 12 | export function isTerminalReady(buffer: string, startPos: number = 0): boolean { 13 | return buffer.includes(BRACKETED_PASTE_MODE_ENABLE, startPos); 14 | } 15 | 16 | // Check if Claude interactive session is done and ready for input 17 | // Used for unread indicator detection in renderer.ts 18 | export function isClaudeSessionReady(buffer: string): boolean { 19 | return CLAUDE_READY_PROMPT_PATTERN.test(buffer); 20 | } 21 | -------------------------------------------------------------------------------- /git-utils.ts: -------------------------------------------------------------------------------- 1 | import {simpleGit, SimpleGit} from "simple-git"; 2 | 3 | /** 4 | * Sort branches with main/master appearing first 5 | */ 6 | export function sortBranchesWithMainFirst(branches: string[]): string[] { 7 | return branches.sort((a, b) => { 8 | const aIsMainOrMaster = a === 'main' || a === 'master'; 9 | const bIsMainOrMaster = b === 'main' || b === 'master'; 10 | 11 | if (aIsMainOrMaster && !bIsMainOrMaster) return -1; 12 | if (!aIsMainOrMaster && bIsMainOrMaster) return 1; 13 | 14 | return 0; 15 | }); 16 | } 17 | 18 | /** 19 | * Get git branches from a directory, sorted with main/master first 20 | */ 21 | export async function getBranches(dirPath: string): Promise { 22 | try { 23 | const git = simpleGit(dirPath); 24 | const branchSummary = await git.branch(); 25 | const branches = branchSummary.all; 26 | return sortBranchesWithMainFirst(branches); 27 | } catch (error) { 28 | console.error("Error getting branches:", error); 29 | return []; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fleetcode", 3 | "version": "1.0.1-beta.8", 4 | "description": "Run multiple CLI coding agents at once", 5 | "main": "dist/main.js", 6 | "scripts": { 7 | "build:css": "npx tailwindcss -i styles.css -o dist/styles.css", 8 | "build:ts": "tsc", 9 | "build": "npm run build:css && npm run build:ts", 10 | "start": "npm run build && electron .", 11 | "dev": "npm run build && electron .", 12 | "pack": "npm run build && electron-builder --dir", 13 | "dist": "npm run build && electron-builder", 14 | "test": "jest" 15 | }, 16 | "author": { 17 | "name": "built-by-as", 18 | "email": "hello@example.com" 19 | }, 20 | "license": "ISC", 21 | "devDependencies": { 22 | "@electron/rebuild": "^4.0.1", 23 | "@types/jest": "^30.0.0", 24 | "@types/node": "^24.6.2", 25 | "@types/uuid": "^10.0.0", 26 | "autoprefixer": "^10.4.21", 27 | "electron": "^38.2.0", 28 | "electron-builder": "^26.0.12", 29 | "electron-rebuild": "^3.2.9", 30 | "jest": "^30.2.0", 31 | "postcss": "^8.5.6", 32 | "tailwindcss": "^3.4.18", 33 | "ts-jest": "^29.4.5", 34 | "ts-node": "^10.9.2", 35 | "typescript": "^5.9.3" 36 | }, 37 | "dependencies": { 38 | "@xterm/addon-fit": "^0.10.0", 39 | "electron-store": "^11.0.0", 40 | "node-pty": "^1.0.0", 41 | "simple-git": "^3.28.0", 42 | "uuid": "^13.0.0", 43 | "xterm": "^5.3.0" 44 | }, 45 | "build": { 46 | "appId": "com.fleetcode.app", 47 | "productName": "FleetCode", 48 | "files": [ 49 | "dist/**/*", 50 | "index.html", 51 | "node_modules/**/*" 52 | ], 53 | "publish": [ 54 | { 55 | "provider": "github", 56 | "releaseType": "release" 57 | } 58 | ], 59 | "mac": { 60 | "category": "public.app-category.developer-tools", 61 | "target": [ 62 | "dmg", 63 | "zip" 64 | ] 65 | }, 66 | "linux": { 67 | "target": [ 68 | "AppImage", 69 | "deb" 70 | ], 71 | "category": "Development" 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | create-release: 13 | runs-on: ubuntu-latest 14 | outputs: 15 | upload_url: ${{ steps.create_release.outputs.upload_url }} 16 | steps: 17 | - name: Check out Git repository 18 | uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 0 21 | 22 | - name: Generate release notes 23 | id: release_notes 24 | run: | 25 | # Get the previous tag 26 | PREVIOUS_TAG=$(git tag --sort=-version:refname | grep -v "$(git describe --tags --abbrev=0)" | head -n 1) 27 | 28 | # Generate changelog 29 | if [ -z "$PREVIOUS_TAG" ]; then 30 | CHANGELOG=$(git log --pretty=format:"- %s (%h)" ${{ github.ref_name }}) 31 | else 32 | CHANGELOG=$(git log --pretty=format:"- %s (%h)" ${PREVIOUS_TAG}..${{ github.ref_name }}) 33 | fi 34 | 35 | # Save to output 36 | echo "CHANGELOG<> $GITHUB_OUTPUT 37 | echo "$CHANGELOG" >> $GITHUB_OUTPUT 38 | echo "EOF" >> $GITHUB_OUTPUT 39 | 40 | - name: Create Release 41 | id: create_release 42 | uses: actions/create-release@v1 43 | env: 44 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | with: 46 | tag_name: ${{ github.ref_name }} 47 | release_name: Release ${{ github.ref_name }} 48 | body: | 49 | ## Changes 50 | ${{ steps.release_notes.outputs.CHANGELOG }} 51 | draft: false 52 | prerelease: ${{ contains(github.ref_name, 'beta') || contains(github.ref_name, 'alpha') }} 53 | 54 | build: 55 | needs: create-release 56 | runs-on: ${{ matrix.os }} 57 | 58 | strategy: 59 | matrix: 60 | os: [macos-latest, ubuntu-latest] 61 | 62 | steps: 63 | - name: Check out Git repository 64 | uses: actions/checkout@v4 65 | 66 | - name: Install Node.js 67 | uses: actions/setup-node@v4 68 | with: 69 | node-version: 20 70 | 71 | - name: Install dependencies 72 | run: npm install 73 | 74 | - name: Build and publish 75 | run: npm run dist 76 | env: 77 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 78 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # Snowpack dependency directory (https://snowpack.dev/) 45 | web_modules/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Optional stylelint cache 57 | .stylelintcache 58 | 59 | # Optional REPL history 60 | .node_repl_history 61 | 62 | # Output of 'npm pack' 63 | *.tgz 64 | 65 | # Yarn Integrity file 66 | .yarn-integrity 67 | 68 | # dotenv environment variable files 69 | .env 70 | .env.* 71 | !.env.example 72 | 73 | # parcel-bundler cache (https://parceljs.org/) 74 | .cache 75 | .parcel-cache 76 | 77 | # Next.js build output 78 | .next 79 | out 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | .output 85 | 86 | # Gatsby files 87 | .cache/ 88 | # Comment in the public line in if your project uses Gatsby and not Next.js 89 | # https://nextjs.org/blog/next-9-1#public-directory-support 90 | # public 91 | 92 | # vuepress build output 93 | .vuepress/dist 94 | 95 | # vuepress v2.x temp and cache directory 96 | .temp 97 | .cache 98 | 99 | # Sveltekit cache directory 100 | .svelte-kit/ 101 | 102 | # vitepress build output 103 | **/.vitepress/dist 104 | 105 | # vitepress cache directory 106 | **/.vitepress/cache 107 | 108 | # Docusaurus cache and generated files 109 | .docusaurus 110 | 111 | # Serverless directories 112 | .serverless/ 113 | 114 | # FuseBox cache 115 | .fusebox/ 116 | 117 | # DynamoDB Local files 118 | .dynamodb/ 119 | 120 | # Firebase cache directory 121 | .firebase/ 122 | 123 | # TernJS port file 124 | .tern-port 125 | 126 | # Stores VSCode versions used for testing VSCode extensions 127 | .vscode-test 128 | 129 | # yarn v3 130 | .pnp.* 131 | .yarn/* 132 | !.yarn/patches 133 | !.yarn/plugins 134 | !.yarn/releases 135 | !.yarn/sdks 136 | !.yarn/versions 137 | 138 | # Vite files 139 | vite.config.js.timestamp-* 140 | vite.config.ts.timestamp-* 141 | .vite/ -------------------------------------------------------------------------------- /simple-claude.ts: -------------------------------------------------------------------------------- 1 | import { exec } from "child_process"; 2 | import { promisify } from "util"; 3 | 4 | const execAsync = promisify(exec); 5 | 6 | export interface McpServer { 7 | name: string; 8 | command?: string; 9 | args?: string[]; 10 | env?: Record; 11 | url?: string; 12 | type?: "stdio" | "sse"; 13 | } 14 | 15 | export class SimpleClaude { 16 | private claudePath: string; 17 | 18 | constructor(claudePath: string = "claude") { 19 | this.claudePath = claudePath; 20 | } 21 | 22 | async listMcpServers(): Promise { 23 | try { 24 | const { stdout } = await execAsync(`${this.claudePath} mcp list`); 25 | 26 | // If no servers configured, return empty array 27 | if (stdout.includes("No MCP servers configured")) { 28 | return []; 29 | } 30 | 31 | // Parse the output - format is typically a list of server names 32 | // We'll need to get details for each server 33 | const lines = stdout.trim().split("\n").filter(line => line.trim()); 34 | const servers: McpServer[] = []; 35 | 36 | for (const line of lines) { 37 | // Skip header lines 38 | if (line.includes("MCP servers") || line.includes("---")) { 39 | continue; 40 | } 41 | 42 | const serverName = line.trim(); 43 | if (serverName) { 44 | try { 45 | const details = await this.getMcpServer(serverName); 46 | servers.push(details); 47 | } catch (error) { 48 | // If we can't get details, just add the name 49 | servers.push({ name: serverName }); 50 | } 51 | } 52 | } 53 | 54 | return servers; 55 | } catch (error) { 56 | console.error("Error listing MCP servers:", error); 57 | return []; 58 | } 59 | } 60 | 61 | async getMcpServer(name: string): Promise { 62 | try { 63 | const { stdout } = await execAsync(`${this.claudePath} mcp get "${name}"`); 64 | 65 | // Try to parse as JSON if possible 66 | try { 67 | const parsed = JSON.parse(stdout); 68 | return { name, ...parsed }; 69 | } catch { 70 | // If not JSON, return basic info 71 | return { name }; 72 | } 73 | } catch (error) { 74 | throw new Error(`Failed to get MCP server ${name}: ${error}`); 75 | } 76 | } 77 | 78 | async addMcpServer(name: string, command: string, args: string[] = []): Promise { 79 | try { 80 | const argsStr = args.join(" "); 81 | await execAsync(`${this.claudePath} mcp add "${name}" ${command} ${argsStr}`); 82 | } catch (error) { 83 | throw new Error(`Failed to add MCP server ${name}: ${error}`); 84 | } 85 | } 86 | 87 | async removeMcpServer(name: string): Promise { 88 | try { 89 | await execAsync(`${this.claudePath} mcp remove "${name}"`); 90 | } catch (error) { 91 | throw new Error(`Failed to remove MCP server ${name}: ${error}`); 92 | } 93 | } 94 | } 95 | 96 | export function simpleClaude(claudePath?: string): SimpleClaude { 97 | return new SimpleClaude(claudePath); 98 | } 99 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FleetCode 2 | 3 | ![FleetCode](fleetcode.png) 4 | 5 | A desktop terminal application for running multiple CLI coding agents simultaneously, each in isolated git worktrees. 6 | 7 | ## Features 8 | 9 | - **Multiple Sessions**: Run multiple coding agent sessions (Claude, Codex) in parallel 10 | - **Git Worktree Isolation**: Each session runs in its own git worktree, keeping work isolated 11 | - **Persistent Sessions**: Sessions persist across app restarts with automatic resumption 12 | - **Terminal Theming**: Choose from preset themes (macOS Light/Dark, Solarized Dark, Dracula, One Dark, GitHub Dark) 13 | - **Setup Commands**: Configure shell commands to run before the coding agent starts 14 | - **MCP Server Management**: Add and configure Model Context Protocol (MCP) servers 15 | - **Session Management**: Rename, close, and delete sessions with automatic worktree cleanup 16 | 17 | ## Prerequisites 18 | 19 | - Node.js 16+ 20 | - Git 21 | - Claude CLI (`npm install -g @anthropic-ai/claude-cli`) or Codex 22 | 23 | ## Installation 24 | 25 | ```bash 26 | npm install 27 | ``` 28 | 29 | ## Usage 30 | 31 | ### Development 32 | 33 | ```bash 34 | npm run dev 35 | ``` 36 | 37 | ### Production Build 38 | 39 | ```bash 40 | npm run build 41 | npm start 42 | ``` 43 | 44 | ## How It Works 45 | 46 | ### Session Creation 47 | 48 | 1. Select a project directory (must be a git repository) 49 | 2. Choose a parent branch for the worktree 50 | 3. Select your coding agent (Claude or Codex) 51 | 4. Optionally add setup commands (e.g., environment variables, source files) 52 | 5. FleetCode creates a new git worktree and spawns a terminal session 53 | 54 | ### Session Management 55 | 56 | - **New Sessions**: Use `--session-id ` for first-time Claude sessions 57 | - **Reopened Sessions**: Automatically resume with `--resume ` 58 | - **Worktrees**: Each session gets its own isolated git worktree 59 | - **Persistence**: Sessions are saved and can be reopened after closing the app 60 | 61 | ### Terminal Settings 62 | 63 | Access settings via the gear icon (⚙️) in the sidebar: 64 | 65 | - **Font Family**: Choose from common monospace fonts 66 | - **Font Size**: Adjust terminal text size 67 | - **Theme**: Select from preset color themes 68 | - **Cursor Blink**: Toggle cursor blinking 69 | 70 | ### MCP Servers 71 | 72 | Configure Model Context Protocol servers for enhanced agent capabilities: 73 | 74 | - **stdio**: Direct process communication 75 | - **SSE**: Server-sent events via HTTP 76 | 77 | ## Troubleshooting 78 | 79 | ### macOS: "App can't be opened because it is from an unidentified developer" 80 | 81 | If you encounter a quarantine warning when trying to open the app on macOS, run: 82 | 83 | ```bash 84 | xattr -cr /path/to/FleetCode.app 85 | ``` 86 | 87 | This removes the quarantine attribute that prevents the app from opening. 88 | 89 | ### Claude Code: Working Directory Issues 90 | 91 | If you're using Claude Code and it's reading/writing files from the wrong directory instead of the worktree, disable "Auto connect to IDE" in your Claude Code settings: 92 | 93 | ```bash 94 | claude config 95 | ``` 96 | 97 | Set `autoConnectToIde` to `false`. This ensures Claude Code operates within the correct worktree directory. 98 | 99 | ## License 100 | 101 | ISC 102 | -------------------------------------------------------------------------------- /git-utils.test.ts: -------------------------------------------------------------------------------- 1 | import {getBranches, sortBranchesWithMainFirst} from './git-utils'; 2 | import {simpleGit} from 'simple-git'; 3 | 4 | // Mock simple-git 5 | jest.mock('simple-git'); 6 | 7 | describe('sortBranchesWithMainFirst', () => { 8 | it('should place main branch first', () => { 9 | const branches = ['feature/test', 'develop', 'main', 'bugfix/issue']; 10 | const sorted = sortBranchesWithMainFirst(branches); 11 | expect(sorted[0]).toBe('main'); 12 | }); 13 | 14 | it('should place master branch first', () => { 15 | const branches = ['feature/test', 'develop', 'master', 'bugfix/issue']; 16 | const sorted = sortBranchesWithMainFirst(branches); 17 | expect(sorted[0]).toBe('master'); 18 | }); 19 | 20 | it('should place both main and master at the top', () => { 21 | const branches = ['feature/test', 'master', 'main', 'develop']; 22 | const sorted = sortBranchesWithMainFirst(branches); 23 | expect(['main', 'master']).toContain(sorted[0]); 24 | expect(['main', 'master']).toContain(sorted[1]); 25 | }); 26 | 27 | it('should handle branches with main as substring', () => { 28 | const branches = ['feature/main-feature', 'main', 'main-branch']; 29 | const sorted = sortBranchesWithMainFirst(branches); 30 | expect(sorted[0]).toBe('main'); 31 | expect(sorted).toContain('feature/main-feature'); 32 | expect(sorted).toContain('main-branch'); 33 | }); 34 | 35 | it('should preserve order of non-main/master branches', () => { 36 | const branches = ['feature/a', 'feature/b', 'feature/c']; 37 | const sorted = sortBranchesWithMainFirst([...branches]); 38 | expect(sorted).toEqual(branches); 39 | }); 40 | 41 | it('should handle empty array', () => { 42 | const sorted = sortBranchesWithMainFirst([]); 43 | expect(sorted).toEqual([]); 44 | }); 45 | 46 | it('should handle array with only main', () => { 47 | const sorted = sortBranchesWithMainFirst(['main']); 48 | expect(sorted).toEqual(['main']); 49 | }); 50 | 51 | it('should handle array with only master', () => { 52 | const sorted = sortBranchesWithMainFirst(['master']); 53 | expect(sorted).toEqual(['master']); 54 | }); 55 | }); 56 | 57 | describe('getBranches', () => { 58 | const mockGit = { 59 | branch: jest.fn(), 60 | }; 61 | 62 | beforeEach(() => { 63 | jest.clearAllMocks(); 64 | (simpleGit as jest.Mock).mockReturnValue(mockGit); 65 | }); 66 | 67 | it('should fetch and sort branches with main first', async () => { 68 | mockGit.branch.mockResolvedValue({ 69 | all: ['feature/test', 'develop', 'main', 'bugfix/issue'], 70 | branches: {}, 71 | current: 'main', 72 | detached: false, 73 | }); 74 | 75 | const branches = await getBranches('/test/path'); 76 | 77 | expect(simpleGit).toHaveBeenCalledWith('/test/path'); 78 | expect(mockGit.branch).toHaveBeenCalled(); 79 | expect(branches[0]).toBe('main'); 80 | expect(branches).toHaveLength(4); 81 | }); 82 | 83 | it('should fetch and sort branches with master first', async () => { 84 | mockGit.branch.mockResolvedValue({ 85 | all: ['feature/test', 'master', 'develop'], 86 | branches: {}, 87 | current: 'master', 88 | detached: false, 89 | }); 90 | 91 | const branches = await getBranches('/test/path'); 92 | 93 | expect(branches[0]).toBe('master'); 94 | expect(branches).toHaveLength(3); 95 | }); 96 | 97 | it('should handle errors and return empty array', async () => { 98 | mockGit.branch.mockRejectedValue(new Error('Git error')); 99 | 100 | const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); 101 | const branches = await getBranches('/test/path'); 102 | 103 | expect(branches).toEqual([]); 104 | expect(consoleErrorSpy).toHaveBeenCalledWith( 105 | 'Error getting branches:', 106 | expect.any(Error) 107 | ); 108 | 109 | consoleErrorSpy.mockRestore(); 110 | }); 111 | 112 | it('should handle empty branch list', async () => { 113 | mockGit.branch.mockResolvedValue({ 114 | all: [], 115 | branches: {}, 116 | current: '', 117 | detached: false, 118 | }); 119 | 120 | const branches = await getBranches('/test/path'); 121 | 122 | expect(branches).toEqual([]); 123 | }); 124 | 125 | it('should sort both main and master to the top', async () => { 126 | mockGit.branch.mockResolvedValue({ 127 | all: ['feature/a', 'master', 'feature/b', 'main', 'develop'], 128 | branches: {}, 129 | current: 'main', 130 | detached: false, 131 | }); 132 | 133 | const branches = await getBranches('/test/path'); 134 | 135 | expect(['main', 'master']).toContain(branches[0]); 136 | expect(['main', 'master']).toContain(branches[1]); 137 | }); 138 | }); 139 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer components { 6 | /* Sidebar Styles */ 7 | .sidebar { 8 | @apply w-64 bg-gray-800 border-r border-gray-700 flex flex-col; 9 | } 10 | 11 | .sidebar-header { 12 | @apply p-4 border-b border-gray-700 flex items-center justify-between; 13 | } 14 | 15 | .sidebar-title { 16 | @apply text-white font-semibold text-lg; 17 | } 18 | 19 | .settings-btn { 20 | @apply text-gray-400 hover:text-white hover:bg-gray-700 text-lg w-8 h-8 flex items-center justify-center rounded transition-all; 21 | } 22 | 23 | .sidebar-content { 24 | @apply flex-1 overflow-y-auto; 25 | } 26 | 27 | .sidebar-section { 28 | @apply p-2; 29 | } 30 | 31 | .sidebar-section-header { 32 | @apply flex items-center justify-between px-2 py-1 mb-1; 33 | } 34 | 35 | .sidebar-section-title { 36 | @apply text-xs text-gray-400; 37 | } 38 | 39 | .section-add-btn { 40 | @apply text-gray-400 hover:text-white hover:bg-gray-700 text-sm w-5 h-5 flex items-center justify-center rounded transition-all; 41 | } 42 | 43 | .loading-spinner { 44 | @apply inline-block w-3 h-3 border-2 border-gray-400 border-t-transparent rounded-full; 45 | animation: spin 0.6s linear infinite; 46 | } 47 | 48 | @keyframes spin { 49 | to { 50 | transform: rotate(360deg); 51 | } 52 | } 53 | 54 | /* Session List Item */ 55 | .session-list-item { 56 | @apply mb-3 px-3 py-2 rounded cursor-pointer hover:bg-gray-700 text-gray-300 text-sm flex items-center justify-between; 57 | } 58 | 59 | .session-list-item.active { 60 | @apply bg-gray-700; 61 | } 62 | 63 | .session-indicator { 64 | @apply w-2 h-2 rounded-full bg-gray-600; 65 | transition: background-color 0.2s; 66 | } 67 | 68 | .session-indicator.active { 69 | @apply bg-green-500; 70 | } 71 | 72 | .session-indicator.disconnected { 73 | @apply bg-red-500; 74 | } 75 | 76 | .session-menu-btn { 77 | @apply text-gray-500 hover:text-gray-300 hover:bg-gray-600 ml-2 transition-all; 78 | font-size: 14px; 79 | line-height: 0.8; 80 | writing-mode: vertical-lr; 81 | letter-spacing: -2px; 82 | padding: 6px 3px; 83 | border-radius: 6px; 84 | display: inline-flex; 85 | align-items: center; 86 | justify-content: center; 87 | } 88 | 89 | .session-menu { 90 | @apply absolute right-0 mt-1 bg-gray-700 border border-gray-600 rounded shadow-lg z-10 py-1 min-w-[120px]; 91 | } 92 | 93 | .session-menu-item { 94 | @apply w-full text-left px-3 py-2 text-sm text-gray-300 hover:bg-gray-600 hover:text-white transition-colors; 95 | } 96 | 97 | .session-menu-item.delete-session-btn { 98 | @apply hover:bg-red-600 hover:text-white; 99 | } 100 | 101 | .session-name-container { 102 | min-width: 0; 103 | } 104 | 105 | .session-name-text { 106 | @apply text-gray-300; 107 | } 108 | 109 | .session-name-input { 110 | @apply bg-gray-700 border border-gray-600 rounded px-1 py-0 text-gray-100 text-sm focus:outline-none focus:border-blue-500; 111 | min-width: 80px; 112 | } 113 | 114 | /* Tabs */ 115 | .tabs-container { 116 | @apply flex bg-gray-900 border-b border-gray-700 overflow-x-auto; 117 | } 118 | 119 | .tab { 120 | @apply px-4 py-2 border-r border-gray-700 cursor-pointer hover:bg-gray-800 flex items-center space-x-2 min-w-max; 121 | } 122 | 123 | .tab.active { 124 | @apply bg-gray-800 border-b-2 border-blue-500; 125 | } 126 | 127 | .unread-indicator { 128 | display: none; 129 | width: 10px; 130 | height: 10px; 131 | border-radius: 50%; 132 | margin-right: 6px; 133 | } 134 | 135 | .tab.unread .unread-indicator { 136 | display: inline-block; 137 | background-color: #3b82f6; 138 | } 139 | 140 | .tab.activity .unread-indicator { 141 | display: inline-block; 142 | border: 2px solid #eab308; 143 | border-top-color: transparent; 144 | background-color: transparent; 145 | animation: spin 1s linear infinite; 146 | } 147 | 148 | .tab-name { 149 | @apply text-sm text-gray-300; 150 | } 151 | 152 | .tab-close-btn { 153 | @apply text-gray-500 hover:text-red-500; 154 | } 155 | 156 | /* Button */ 157 | .btn-primary { 158 | @apply bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded text-sm font-medium transition; 159 | } 160 | 161 | /* Session Container */ 162 | .session-wrapper { 163 | @apply absolute inset-0 hidden; 164 | } 165 | 166 | .session-wrapper.active { 167 | @apply block; 168 | } 169 | 170 | /* Modal */ 171 | .modal-overlay { 172 | @apply fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50; 173 | } 174 | 175 | .modal-overlay.hidden { 176 | display: none; 177 | } 178 | 179 | .modal { 180 | @apply bg-gray-800 rounded-lg shadow-xl w-full max-w-md p-6 max-h-[90vh] overflow-y-auto; 181 | } 182 | 183 | .modal-title { 184 | @apply text-xl font-semibold text-white mb-4; 185 | } 186 | 187 | .form-group { 188 | @apply mb-4; 189 | } 190 | 191 | .form-label { 192 | @apply block text-sm font-medium text-gray-300 mb-2; 193 | } 194 | 195 | .form-input { 196 | @apply w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white focus:outline-none focus:border-blue-500; 197 | font-family: inherit; 198 | resize: vertical; 199 | } 200 | 201 | .form-select { 202 | @apply w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white focus:outline-none focus:border-blue-500; 203 | } 204 | 205 | .form-checkbox { 206 | @apply w-4 h-4 rounded border-gray-600 bg-gray-700 text-blue-600 focus:ring-blue-500 focus:ring-2 cursor-pointer; 207 | } 208 | 209 | .color-input { 210 | @apply bg-gray-700 border border-gray-600 rounded px-2 py-1 h-10 w-20 cursor-pointer; 211 | } 212 | 213 | .btn-secondary { 214 | @apply bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded text-sm font-medium transition; 215 | } 216 | 217 | .btn-danger { 218 | @apply bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded text-sm font-medium transition; 219 | } 220 | 221 | .btn-group { 222 | @apply flex justify-end space-x-3 mt-6; 223 | } 224 | 225 | .directory-input-group { 226 | @apply flex space-x-2; 227 | } 228 | 229 | .directory-input-group .form-input { 230 | @apply flex-1; 231 | } 232 | 233 | .btn-icon { 234 | @apply bg-gray-600 hover:bg-gray-700 text-white px-3 py-2 rounded text-sm font-medium transition; 235 | } 236 | 237 | .btn-primary.loading { 238 | @apply opacity-75 cursor-not-allowed; 239 | } 240 | 241 | .btn-primary.loading .loading-spinner { 242 | @apply border-white border-t-transparent; 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | FleetCode Terminal 6 | 7 | 8 | 15 | 16 | 17 |
18 | 19 | 43 | 44 | 45 |
46 | 47 |
48 | 49 | 50 |
51 |
52 |
53 | 54 | 55 | 120 | 121 | 122 | 136 | 137 | 138 | 197 | 198 | 199 | 261 | 262 | 263 | 284 | 285 | 288 | 289 | 290 | -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import {exec} from "child_process"; 2 | import {app, BrowserWindow, dialog, ipcMain} from "electron"; 3 | import Store from "electron-store"; 4 | import * as fs from "fs"; 5 | import * as pty from "node-pty"; 6 | import * as os from "os"; 7 | import * as path from "path"; 8 | import {simpleGit} from "simple-git"; 9 | import {promisify} from "util"; 10 | import {v4 as uuidv4} from "uuid"; 11 | import {getBranches} from "./git-utils"; 12 | import {isTerminalReady} from "./terminal-utils"; 13 | import {PersistedSession, SessionConfig, SessionType} from "./types"; 14 | 15 | const execAsync = promisify(exec); 16 | 17 | let mainWindow: BrowserWindow; 18 | const activePtyProcesses = new Map(); 19 | const mcpPollerPtyProcesses = new Map(); 20 | let claudeCommandRunnerPty: pty.IPty | null = null; 21 | const store = new Store(); 22 | 23 | // Helper functions for session management 24 | function getPersistedSessions(): PersistedSession[] { 25 | const sessions = (store as any).get("sessions", []) as PersistedSession[]; 26 | 27 | // Migrate old sessions that don't have sessionType field 28 | return sessions.map(session => { 29 | if (!session.config.sessionType) { 30 | // If session has a worktreePath, it's a worktree session; otherwise local 31 | session.config.sessionType = session.worktreePath ? SessionType.WORKTREE : SessionType.LOCAL; 32 | } 33 | return session; 34 | }); 35 | } 36 | 37 | function getWorktreeBaseDir(): string { 38 | const settings = (store as any).get("terminalSettings"); 39 | if (settings && settings.worktreeDir) { 40 | return settings.worktreeDir; 41 | } 42 | return path.join(os.homedir(), "worktrees"); 43 | } 44 | 45 | function savePersistedSessions(sessions: PersistedSession[]) { 46 | (store as any).set("sessions", sessions); 47 | } 48 | 49 | function getNextSessionNumber(): number { 50 | const sessions = getPersistedSessions(); 51 | if (sessions.length === 0) return 1; 52 | return Math.max(...sessions.map(s => s.number)) + 1; 53 | } 54 | 55 | // Extract MCP config for a project from ~/.claude.json 56 | function extractProjectMcpConfig(projectDir: string): any { 57 | try { 58 | const claudeConfigPath = path.join(os.homedir(), ".claude.json"); 59 | 60 | if (!fs.existsSync(claudeConfigPath)) { 61 | return {}; 62 | } 63 | 64 | const claudeConfig = JSON.parse(fs.readFileSync(claudeConfigPath, "utf8")); 65 | 66 | if (!claudeConfig.projects || !claudeConfig.projects[projectDir]) { 67 | return {}; 68 | } 69 | 70 | return claudeConfig.projects[projectDir].mcpServers || {}; 71 | } catch (error) { 72 | console.error("Error extracting MCP config:", error); 73 | return {}; 74 | } 75 | } 76 | 77 | // Get a safe directory name from project path, with collision handling 78 | function getProjectWorktreeDirName(projectDir: string): string { 79 | const baseName = path.basename(projectDir); 80 | const worktreesBaseDir = getWorktreeBaseDir(); 81 | const candidatePath = path.join(worktreesBaseDir, baseName); 82 | 83 | // If directory doesn't exist or points to the same project, use base name 84 | if (!fs.existsSync(candidatePath)) { 85 | return baseName; 86 | } 87 | 88 | // Check if existing directory is for the same project by reading a marker file 89 | const markerFile = path.join(candidatePath, ".fleetcode-project"); 90 | if (fs.existsSync(markerFile)) { 91 | const existingProjectPath = fs.readFileSync(markerFile, "utf-8").trim(); 92 | if (existingProjectPath === projectDir) { 93 | return baseName; 94 | } 95 | } 96 | 97 | // Collision detected - append short hash 98 | const crypto = require("crypto"); 99 | const hash = crypto.createHash("md5").update(projectDir).digest("hex").substring(0, 6); 100 | return `${baseName}-${hash}`; 101 | } 102 | 103 | // Write MCP config file for a project (shared across all sessions) 104 | function writeMcpConfigFile(projectDir: string, mcpServers: any): string | null { 105 | try { 106 | const projectDirName = getProjectWorktreeDirName(projectDir); 107 | const worktreesDir = getWorktreeBaseDir(); 108 | if (!fs.existsSync(worktreesDir)) { 109 | fs.mkdirSync(worktreesDir, { recursive: true }); 110 | } 111 | 112 | const configFilePath = path.join(worktreesDir, projectDirName, "mcp-config.json"); 113 | const configContent = JSON.stringify({ mcpServers }, null, 2); 114 | 115 | // Ensure project worktree directory exists 116 | const projectWorktreeDir = path.join(worktreesDir, projectDirName); 117 | if (!fs.existsSync(projectWorktreeDir)) { 118 | fs.mkdirSync(projectWorktreeDir, { recursive: true }); 119 | } 120 | 121 | fs.writeFileSync(configFilePath, configContent, "utf8"); 122 | 123 | return configFilePath; 124 | } catch (error) { 125 | console.error("Error writing MCP config file:", error); 126 | return null; 127 | } 128 | } 129 | 130 | // Spawn headless PTY for MCP polling 131 | function spawnMcpPoller(sessionId: string, projectDir: string) { 132 | const shell = os.platform() === "darwin" ? "zsh" : "bash"; 133 | const ptyProcess = pty.spawn(shell, ["-l"], { 134 | name: "xterm-color", 135 | cols: 80, 136 | rows: 30, 137 | cwd: projectDir, 138 | env: process.env, 139 | }); 140 | 141 | mcpPollerPtyProcesses.set(sessionId, ptyProcess); 142 | 143 | let outputBuffer = ""; 144 | const serverMap = new Map(); 145 | 146 | ptyProcess.onData((data) => { 147 | 148 | // Accumulate output without displaying it 149 | outputBuffer += data; 150 | 151 | // Parse output whenever we have MCP server entries 152 | // Match lines like: "servername: url (type) - ✓ Connected" or "servername: command (stdio) - ✓ Connected" 153 | // Pattern handles SSE, stdio, and HTTP types (case-insensitive) with success (✓), warning (⚠), or failure (✗) status 154 | const mcpServerLineRegex = /^[\w-]+:.+\((?:SSE|sse|stdio|HTTP)\)\s+-\s+[✓⚠✗]/mi; 155 | 156 | if (mcpServerLineRegex.test(data) || data.includes("No MCP servers configured")) { 157 | try { 158 | const servers = parseMcpOutput(outputBuffer); 159 | 160 | // Clear and replace the server map with current results 161 | serverMap.clear(); 162 | servers.forEach(server => { 163 | serverMap.set(server.name, server); 164 | }); 165 | 166 | const allServers = Array.from(serverMap.values()); 167 | 168 | if (mainWindow && !mainWindow.isDestroyed()) { 169 | mainWindow.webContents.send("mcp-servers-updated", sessionId, allServers); 170 | } 171 | } catch (error) { 172 | console.error(`[MCP Poller ${sessionId}] Error parsing:`, error); 173 | } 174 | } 175 | 176 | // Clear buffer when we see the shell prompt (command finished) 177 | if ((data.includes("% ") || data.includes("$ ") || data.includes("➜ ")) && 178 | outputBuffer.includes("claude mcp list")) { 179 | outputBuffer = ""; 180 | } 181 | }); 182 | 183 | // Start polling immediately and then every 60 seconds 184 | const pollMcp = () => { 185 | if (mcpPollerPtyProcesses.has(sessionId)) { 186 | // Notify renderer that polling is starting 187 | if (mainWindow && !mainWindow.isDestroyed()) { 188 | mainWindow.webContents.send("mcp-polling-started", sessionId); 189 | } 190 | 191 | const command = `claude mcp list`; 192 | ptyProcess.write(command + "\r"); 193 | setTimeout(pollMcp, 60000); 194 | } 195 | }; 196 | 197 | // Wait briefly for shell to be ready before first poll 198 | setTimeout(() => { 199 | pollMcp(); 200 | }, 500); 201 | } 202 | 203 | // Parse MCP server list output 204 | function parseMcpOutput(output: string): any[] { 205 | const servers = []; 206 | 207 | if (output.includes("No MCP servers configured")) { 208 | return []; 209 | } 210 | 211 | const lines = output.trim().split("\n").filter(line => line.trim()); 212 | 213 | for (const line of lines) { 214 | // Skip header lines, empty lines, and status messages 215 | if (line.includes("MCP servers") || 216 | line.includes("---") || 217 | line.includes("Checking") || 218 | line.includes("health") || 219 | line.includes("claude mcp list") || 220 | !line.trim()) { 221 | continue; 222 | } 223 | 224 | // Only parse lines that match the MCP server format 225 | // Must have: "name: something (SSE|stdio|HTTP) - status" 226 | const serverMatch = line.match(/^([\w-]+):.+\((?:SSE|sse|stdio|HTTP)\)\s+-\s+[✓⚠✗]/i); 227 | if (serverMatch) { 228 | const serverName = serverMatch[1]; 229 | const isConnected = line.includes("✓") || line.includes("Connected"); 230 | 231 | servers.push({ 232 | name: serverName, 233 | connected: isConnected 234 | }); 235 | } 236 | } 237 | 238 | return servers; 239 | } 240 | 241 | // Helper function to spawn PTY and setup coding agent 242 | function spawnSessionPty( 243 | sessionId: string, 244 | workingDirectory: string, 245 | config: SessionConfig, 246 | sessionUuid: string, 247 | isNewSession: boolean, 248 | mcpConfigPath?: string, 249 | projectDir?: string 250 | ) { 251 | const shell = os.platform() === "darwin" ? "zsh" : "bash"; 252 | const ptyProcess = pty.spawn(shell, ["-l"], { 253 | name: "xterm-color", 254 | cols: 80, 255 | rows: 30, 256 | cwd: workingDirectory, 257 | env: process.env, 258 | }); 259 | 260 | activePtyProcesses.set(sessionId, ptyProcess); 261 | 262 | let terminalReady = false; 263 | let readyChecksCompleted = 0; 264 | let lastReadyCheckPos = 0; 265 | let setupCommandsIdx = 0; 266 | let dataBuffer = ""; 267 | 268 | ptyProcess.onData((data) => { 269 | // Only send data if window still exists and is not destroyed 270 | if (mainWindow && !mainWindow.isDestroyed()) { 271 | mainWindow.webContents.send("session-output", sessionId, data); 272 | } 273 | 274 | // Detect when terminal is ready 275 | if (!terminalReady) { 276 | dataBuffer += data; 277 | 278 | if (isTerminalReady(dataBuffer, lastReadyCheckPos)) { 279 | readyChecksCompleted++; 280 | lastReadyCheckPos = dataBuffer.length; 281 | 282 | if (config.setupCommands && setupCommandsIdx < config.setupCommands.length) { 283 | const cmd = config.setupCommands[setupCommandsIdx]; 284 | ptyProcess.write(cmd + "\r"); 285 | setupCommandsIdx++; 286 | } else { 287 | terminalReady = true; 288 | 289 | // Auto-run the selected coding agent 290 | if (config.codingAgent === "claude") { 291 | const sessionFlag = isNewSession 292 | ? `--session-id ${sessionUuid}` 293 | : `--resume ${sessionUuid}`; 294 | const skipPermissionsFlag = config.skipPermissions ? "--dangerously-skip-permissions" : ""; 295 | const mcpConfigFlag = mcpConfigPath ? `--mcp-config ${mcpConfigPath}` : ""; 296 | const flags = [sessionFlag, skipPermissionsFlag, mcpConfigFlag].filter(f => f).join(" "); 297 | const claudeCmd = `claude ${flags}`; 298 | ptyProcess.write(claudeCmd + "\r"); 299 | 300 | // Start MCP poller immediately (auth is handled by shell environment) 301 | if (!mcpPollerPtyProcesses.has(sessionId) && projectDir) { 302 | spawnMcpPoller(sessionId, projectDir); 303 | } 304 | } else if (config.codingAgent === "codex") { 305 | ptyProcess.write("codex\r"); 306 | } 307 | } 308 | } 309 | } 310 | }); 311 | 312 | return ptyProcess; 313 | } 314 | 315 | // Git worktree helper functions 316 | // No longer needed since worktrees are in ~/worktrees, not in project directory 317 | 318 | async function createWorktree(projectDir: string, parentBranch: string, sessionNumber: number, sessionUuid: string, customBranchName?: string): Promise<{ worktreePath: string; branchName: string }> { 319 | const git = simpleGit(projectDir); 320 | 321 | const projectDirName = getProjectWorktreeDirName(projectDir); 322 | const worktreesBaseDir = getWorktreeBaseDir(); 323 | const projectWorktreeDir = path.join(worktreesBaseDir, projectDirName); 324 | const worktreeName = customBranchName || `session${sessionNumber}`; 325 | let worktreePath = path.join(projectWorktreeDir, worktreeName); 326 | 327 | // Use custom branch name if provided, otherwise generate default 328 | let branchName: string; 329 | const shortUuid = sessionUuid.split('-')[0]; 330 | if (customBranchName) { 331 | branchName = customBranchName; 332 | } else { 333 | // Include short UUID to ensure branch uniqueness across deletes/recreates 334 | branchName = `fleetcode/${worktreeName}-${shortUuid}`; 335 | } 336 | 337 | // Create worktrees directory if it doesn't exist 338 | if (!fs.existsSync(projectWorktreeDir)) { 339 | fs.mkdirSync(projectWorktreeDir, { recursive: true }); 340 | 341 | // Write marker file to track which project this directory belongs to 342 | const markerFile = path.join(projectWorktreeDir, ".fleetcode-project"); 343 | fs.writeFileSync(markerFile, projectDir, "utf-8"); 344 | } 345 | 346 | // Append short UUID to worktree path to ensure uniqueness 347 | if (fs.existsSync(worktreePath)) { 348 | worktreePath += `-${shortUuid}`; 349 | } 350 | 351 | // Create new worktree with a new branch from parent branch 352 | // This creates a new branch named "fleetcode/session" starting from the parent branch 353 | await git.raw(["worktree", "add", "-b", branchName, worktreePath, parentBranch]); 354 | 355 | return { worktreePath, branchName }; 356 | } 357 | 358 | async function removeWorktree(projectDir: string, worktreePath: string) { 359 | const git = simpleGit(projectDir); 360 | try { 361 | await git.raw(["worktree", "remove", worktreePath, "--force"]); 362 | } catch (error) { 363 | console.error("Error removing worktree:", error); 364 | } 365 | } 366 | 367 | async function removeGitBranch(projectDir: string, branchName: string) { 368 | const git = simpleGit(projectDir); 369 | try { 370 | await git.raw(["branch", "-D", branchName]); 371 | } catch (error) { 372 | console.error("Error removing git branch:", error); 373 | } 374 | } 375 | 376 | // Open directory picker 377 | ipcMain.handle("select-directory", async () => { 378 | const result = await dialog.showOpenDialog(mainWindow, { 379 | properties: ["openDirectory"], 380 | }); 381 | 382 | if (result.canceled || result.filePaths.length === 0) { 383 | return null; 384 | } 385 | 386 | return result.filePaths[0]; 387 | }); 388 | 389 | // Get git branches from directory 390 | ipcMain.handle("get-branches", async (_event, dirPath: string) => { 391 | return getBranches(dirPath); 392 | }); 393 | 394 | // Get last used settings 395 | ipcMain.handle("get-last-settings", () => { 396 | return (store as any).get("lastSessionConfig", { 397 | projectDir: "", 398 | sessionType: SessionType.WORKTREE, 399 | parentBranch: "", 400 | codingAgent: "claude", 401 | skipPermissions: true, 402 | }); 403 | }); 404 | 405 | // Save settings 406 | ipcMain.on("save-settings", (_event, config: SessionConfig) => { 407 | (store as any).set("lastSessionConfig", config); 408 | }); 409 | 410 | // Create new session 411 | ipcMain.on("create-session", async (event, config: SessionConfig) => { 412 | try { 413 | const sessionNumber = getNextSessionNumber(); 414 | const sessionId = `session-${Date.now()}`; 415 | 416 | // Use custom branch name as session name if provided, otherwise default 417 | const sessionName = config.branchName || `Session ${sessionNumber}`; 418 | 419 | // Generate UUID for this session 420 | const sessionUuid = uuidv4(); 421 | 422 | let worktreePath: string | undefined; 423 | let workingDirectory: string; 424 | let branchName: string | undefined; 425 | let mcpConfigPath: string | undefined; 426 | 427 | if (config.sessionType === SessionType.WORKTREE) { 428 | // Validate that parentBranch is provided for worktree sessions 429 | if (!config.parentBranch) { 430 | throw new Error("Parent branch is required for worktree sessions"); 431 | } 432 | 433 | // Create git worktree with custom or default branch name 434 | const worktreeResult = await createWorktree(config.projectDir, config.parentBranch, sessionNumber, sessionUuid, config.branchName); 435 | worktreePath = worktreeResult.worktreePath; 436 | workingDirectory = worktreeResult.worktreePath; 437 | branchName = worktreeResult.branchName; 438 | 439 | // Extract and write MCP config 440 | const mcpServers = extractProjectMcpConfig(config.projectDir); 441 | mcpConfigPath = writeMcpConfigFile(config.projectDir, mcpServers) || undefined; 442 | } else { 443 | // For local sessions, use the project directory directly (no worktree) 444 | worktreePath = undefined; 445 | workingDirectory = config.projectDir; 446 | branchName = undefined; 447 | mcpConfigPath = undefined; 448 | } 449 | 450 | // Create persisted session metadata 451 | const persistedSession: PersistedSession = { 452 | id: sessionId, 453 | number: sessionNumber, 454 | name: sessionName, 455 | config, 456 | worktreePath, 457 | createdAt: Date.now(), 458 | sessionUuid, 459 | mcpConfigPath, 460 | gitBranch: branchName, 461 | }; 462 | 463 | // Save to store 464 | const sessions = getPersistedSessions(); 465 | sessions.push(persistedSession); 466 | savePersistedSessions(sessions); 467 | 468 | // Spawn PTY in the appropriate directory 469 | spawnSessionPty(sessionId, workingDirectory, config, sessionUuid, true, mcpConfigPath, config.projectDir); 470 | 471 | event.reply("session-created", sessionId, persistedSession); 472 | } catch (error) { 473 | console.error("Error creating session:", error); 474 | const errorMessage = error instanceof Error ? error.message : String(error); 475 | event.reply("session-error", errorMessage); 476 | } 477 | }); 478 | 479 | // Handle session input 480 | ipcMain.on("session-input", (_event, sessionId: string, data: string) => { 481 | const ptyProcess = activePtyProcesses.get(sessionId); 482 | 483 | if (ptyProcess) { 484 | ptyProcess.write(data); 485 | } 486 | }); 487 | 488 | // Handle session resize 489 | ipcMain.on("session-resize", (_event, sessionId: string, cols: number, rows: number) => { 490 | const ptyProcess = activePtyProcesses.get(sessionId); 491 | if (ptyProcess) { 492 | ptyProcess.resize(cols, rows); 493 | } 494 | }); 495 | 496 | // Reopen session (spawn new PTY for existing session) 497 | ipcMain.on("reopen-session", (event, sessionId: string) => { 498 | // Check if PTY already active 499 | if (activePtyProcesses.has(sessionId)) { 500 | event.reply("session-reopened", sessionId); 501 | return; 502 | } 503 | 504 | // Find persisted session 505 | const sessions = getPersistedSessions(); 506 | const session = sessions.find(s => s.id === sessionId); 507 | 508 | if (!session) { 509 | console.error("Session not found:", sessionId); 510 | return; 511 | } 512 | 513 | // For non-worktree sessions, use project directory; otherwise use worktree path 514 | const workingDir = session.worktreePath || session.config.projectDir; 515 | 516 | // Spawn new PTY in the appropriate directory 517 | spawnSessionPty(sessionId, workingDir, session.config, session.sessionUuid, false, session.mcpConfigPath, session.config.projectDir); 518 | 519 | event.reply("session-reopened", sessionId); 520 | }); 521 | 522 | // Close session (kill PTY but keep session) 523 | ipcMain.on("close-session", (_event, sessionId: string) => { 524 | const ptyProcess = activePtyProcesses.get(sessionId); 525 | 526 | if (ptyProcess) { 527 | ptyProcess.kill(); 528 | activePtyProcesses.delete(sessionId); 529 | } 530 | 531 | // Kill MCP poller if active 532 | const mcpPoller = mcpPollerPtyProcesses.get(sessionId); 533 | if (mcpPoller) { 534 | mcpPoller.kill(); 535 | mcpPollerPtyProcesses.delete(sessionId); 536 | } 537 | }); 538 | 539 | // Delete session (kill PTY, remove worktree, delete from store) 540 | ipcMain.on("delete-session", async (_event, sessionId: string) => { 541 | // Kill PTY if active 542 | const ptyProcess = activePtyProcesses.get(sessionId); 543 | if (ptyProcess) { 544 | ptyProcess.kill(); 545 | activePtyProcesses.delete(sessionId); 546 | } 547 | 548 | // Kill MCP poller if active 549 | const mcpPoller = mcpPollerPtyProcesses.get(sessionId); 550 | if (mcpPoller) { 551 | mcpPoller.kill(); 552 | mcpPollerPtyProcesses.delete(sessionId); 553 | } 554 | 555 | // Find and remove from persisted sessions 556 | const sessions = getPersistedSessions(); 557 | const sessionIndex = sessions.findIndex(s => s.id === sessionId); 558 | 559 | if (sessionIndex === -1) { 560 | console.error("Session not found:", sessionId); 561 | return; 562 | } 563 | 564 | const session = sessions[sessionIndex]; 565 | 566 | // Only clean up git worktree and branch for worktree sessions 567 | if (session.config.sessionType === SessionType.WORKTREE && session.worktreePath) { 568 | // Remove git worktree 569 | await removeWorktree(session.config.projectDir, session.worktreePath); 570 | 571 | // Remove git branch if it exists 572 | if (session.gitBranch) { 573 | await removeGitBranch(session.config.projectDir, session.gitBranch); 574 | } 575 | } 576 | 577 | // Remove from store 578 | sessions.splice(sessionIndex, 1); 579 | savePersistedSessions(sessions); 580 | 581 | mainWindow.webContents.send("session-deleted", sessionId); 582 | }); 583 | 584 | // Get all persisted sessions 585 | ipcMain.handle("get-all-sessions", () => { 586 | return getPersistedSessions(); 587 | }); 588 | 589 | // Rename session 590 | ipcMain.on("rename-session", (_event, sessionId: string, newName: string) => { 591 | const sessions = getPersistedSessions(); 592 | const session = sessions.find(s => s.id === sessionId); 593 | 594 | if (session) { 595 | session.name = newName; 596 | savePersistedSessions(sessions); 597 | } 598 | }); 599 | 600 | // Terminal settings handlers 601 | ipcMain.handle("get-terminal-settings", () => { 602 | return (store as any).get("terminalSettings"); 603 | }); 604 | 605 | ipcMain.handle("save-terminal-settings", (_event, settings: any) => { 606 | (store as any).set("terminalSettings", settings); 607 | }); 608 | 609 | // Get app version 610 | ipcMain.handle("get-app-version", () => { 611 | return app.getVersion(); 612 | }); 613 | 614 | // Apply session changes to project 615 | ipcMain.handle("apply-session-to-project", async (_event, sessionId: string) => { 616 | try { 617 | const sessions = getPersistedSessions(); 618 | const session = sessions.find(s => s.id === sessionId); 619 | 620 | if (!session) { 621 | return { success: false, error: "Session not found" }; 622 | } 623 | 624 | if (session.config.sessionType !== SessionType.WORKTREE) { 625 | return { success: false, error: "Only worktree sessions can be applied to project" }; 626 | } 627 | 628 | if (!session.worktreePath || !session.config.parentBranch) { 629 | return { success: false, error: "Session missing worktree path or parent branch" }; 630 | } 631 | 632 | const projectDir = session.config.projectDir; 633 | const worktreePath = session.worktreePath; 634 | const parentBranch = session.config.parentBranch; 635 | const patchFilename = `fleetcode-patch-${Date.now()}.patch`; 636 | const patchPath = path.join("/tmp", patchFilename); 637 | 638 | // Generate patch file from parent branch to current state (includes commits + unstaged changes) 639 | // Using diff against parent branch to capture all changes 640 | const { stdout: patchContent } = await execAsync( 641 | `git diff ${parentBranch}`, 642 | { cwd: worktreePath } 643 | ); 644 | 645 | // If patch is empty, there are no changes to apply 646 | if (!patchContent.trim()) { 647 | return { success: false, error: "No changes to apply" }; 648 | } 649 | 650 | // Write patch to temp file 651 | fs.writeFileSync(patchPath, patchContent); 652 | 653 | // Apply patch to original project directory 654 | try { 655 | await execAsync(`git apply "${patchPath}"`, { cwd: projectDir }); 656 | 657 | // Clean up patch file on success 658 | fs.unlinkSync(patchPath); 659 | 660 | return { success: true }; 661 | } catch (applyError: any) { 662 | // Clean up patch file on error 663 | if (fs.existsSync(patchPath)) { 664 | fs.unlinkSync(patchPath); 665 | } 666 | 667 | return { 668 | success: false, 669 | error: `Failed to apply patch: ${applyError.message || applyError}` 670 | }; 671 | } 672 | } catch (error: any) { 673 | return { 674 | success: false, 675 | error: error.message || String(error) 676 | }; 677 | } 678 | }); 679 | 680 | // MCP Server management functions 681 | async function listMcpServers() { 682 | try { 683 | const { stdout } = await execAsync("claude mcp list"); 684 | 685 | if (stdout.includes("No MCP servers configured")) { 686 | return []; 687 | } 688 | 689 | const lines = stdout.trim().split("\n").filter(line => line.trim()); 690 | const servers = []; 691 | 692 | for (const line of lines) { 693 | // Skip header lines, empty lines, and status messages 694 | if (line.includes("MCP servers") || 695 | line.includes("---") || 696 | line.includes("Checking") || 697 | line.includes("health") || 698 | !line.trim()) { 699 | continue; 700 | } 701 | 702 | // Parse format: "name: url (type) - status" or just "name" 703 | // Extract server name (before the colon) and status 704 | const colonIndex = line.indexOf(":"); 705 | const serverName = colonIndex > 0 ? line.substring(0, colonIndex).trim() : line.trim(); 706 | 707 | // Check if server is connected (✓ Connected or similar) 708 | const isConnected = line.includes("✓") || line.includes("Connected"); 709 | 710 | if (serverName) { 711 | servers.push({ 712 | name: serverName, 713 | connected: isConnected 714 | }); 715 | } 716 | } 717 | 718 | return servers; 719 | } catch (error) { 720 | console.error("Error listing MCP servers:", error); 721 | return []; 722 | } 723 | } 724 | 725 | // Spawn a dedicated PTY for running claude commands 726 | function spawnClaudeCommandRunner() { 727 | if (claudeCommandRunnerPty) { 728 | return; 729 | } 730 | 731 | const shell = os.platform() === "darwin" ? "zsh" : "bash"; 732 | claudeCommandRunnerPty = pty.spawn(shell, ["-l"], { 733 | name: "xterm-color", 734 | cols: 80, 735 | rows: 30, 736 | cwd: os.homedir(), 737 | env: process.env, 738 | }); 739 | } 740 | 741 | // Execute claude command in dedicated PTY 742 | async function execClaudeCommand(command: string): Promise { 743 | return new Promise((resolve, reject) => { 744 | if (!claudeCommandRunnerPty) { 745 | reject(new Error("Claude command runner PTY not initialized")); 746 | return; 747 | } 748 | 749 | const pty = claudeCommandRunnerPty; 750 | let outputBuffer = ""; 751 | let timeoutId: NodeJS.Timeout; 752 | let disposed = false; 753 | 754 | const dataHandler = pty.onData((data: string) => { 755 | if (disposed) return; 756 | 757 | outputBuffer += data; 758 | 759 | // Check if command completed (prompt returned) 760 | if (isTerminalReady(data)) { 761 | disposed = true; 762 | clearTimeout(timeoutId); 763 | dataHandler.dispose(); 764 | 765 | // Extract just the command output (remove the command echo and prompt lines) 766 | const lines = outputBuffer.split('\n'); 767 | const output = lines.slice(1, -1).join('\n').trim(); 768 | resolve(output); 769 | } 770 | }); 771 | 772 | // Set timeout 773 | timeoutId = setTimeout(() => { 774 | if (!disposed) { 775 | disposed = true; 776 | dataHandler.dispose(); 777 | reject(new Error("Command timeout")); 778 | } 779 | }, 10000); 780 | 781 | pty.write(command + "\r"); 782 | }); 783 | } 784 | 785 | async function addMcpServer(name: string, config: any) { 786 | // Use add-json to support full configuration including env vars, headers, etc. 787 | const jsonConfig = JSON.stringify(config).replace(/'/g, "'\\''"); // Escape single quotes for shell 788 | const command = `claude mcp add-json --scope user "${name}" '${jsonConfig}'`; 789 | await execClaudeCommand(command); 790 | } 791 | 792 | async function removeMcpServer(name: string) { 793 | const command = `claude mcp remove "${name}"`; 794 | await execClaudeCommand(command); 795 | } 796 | 797 | async function getMcpServerDetails(name: string) { 798 | try { 799 | const output = await execClaudeCommand(`claude mcp get "${name}"`); 800 | 801 | // Parse the output to extract details 802 | const details: any = { name }; 803 | const lines = output.split("\n"); 804 | 805 | for (const line of lines) { 806 | const trimmed = line.trim(); 807 | if (trimmed.includes("Scope:")) { 808 | details.scope = trimmed.replace("Scope:", "").trim(); 809 | } else if (trimmed.includes("Status:")) { 810 | details.status = trimmed.replace("Status:", "").trim(); 811 | } else if (trimmed.includes("Type:")) { 812 | details.type = trimmed.replace("Type:", "").trim(); 813 | } else if (trimmed.includes("URL:")) { 814 | details.url = trimmed.replace("URL:", "").trim(); 815 | } else if (trimmed.includes("Command:")) { 816 | details.command = trimmed.replace("Command:", "").trim(); 817 | } else if (trimmed.includes("Args:")) { 818 | details.args = trimmed.replace("Args:", "").trim(); 819 | } 820 | } 821 | 822 | return details; 823 | } catch (error) { 824 | console.error("Error getting MCP server details:", error); 825 | throw error; 826 | } 827 | } 828 | 829 | ipcMain.handle("list-mcp-servers", async (_event, sessionId: string) => { 830 | try { 831 | // Trigger an immediate MCP list command in the session's poller 832 | const mcpPoller = mcpPollerPtyProcesses.get(sessionId); 833 | if (mcpPoller) { 834 | mcpPoller.write("claude mcp list\r"); 835 | } 836 | // Return empty array - actual results will come via mcp-servers-updated event 837 | return []; 838 | } catch (error) { 839 | console.error("Error listing MCP servers:", error); 840 | return []; 841 | } 842 | }); 843 | 844 | ipcMain.handle("add-mcp-server", async (_event, name: string, config: any) => { 845 | try { 846 | await addMcpServer(name, config); 847 | } catch (error) { 848 | console.error("Error adding MCP server:", error); 849 | throw error; 850 | } 851 | }); 852 | 853 | ipcMain.handle("remove-mcp-server", async (_event, name: string) => { 854 | try { 855 | await removeMcpServer(name); 856 | } catch (error) { 857 | console.error("Error removing MCP server:", error); 858 | throw error; 859 | } 860 | }); 861 | 862 | ipcMain.handle("get-mcp-server-details", async (_event, name: string) => { 863 | try { 864 | return await getMcpServerDetails(name); 865 | } catch (error) { 866 | console.error("Error getting MCP server details:", error); 867 | throw error; 868 | } 869 | }); 870 | 871 | const createWindow = () => { 872 | mainWindow = new BrowserWindow({ 873 | width: 1400, 874 | height: 900, 875 | webPreferences: { 876 | nodeIntegration: true, 877 | contextIsolation: false, 878 | }, 879 | }); 880 | 881 | mainWindow.loadFile("index.html"); 882 | 883 | // Load persisted sessions once window is ready 884 | mainWindow.webContents.on("did-finish-load", () => { 885 | const sessions = getPersistedSessions(); 886 | mainWindow.webContents.send("load-persisted-sessions", sessions); 887 | }); 888 | 889 | // Clean up PTY processes when window is closed 890 | mainWindow.on("closed", () => { 891 | // Kill all active PTY processes 892 | activePtyProcesses.forEach((ptyProcess, sessionId) => { 893 | try { 894 | ptyProcess.kill(); 895 | } catch (error) { 896 | console.error(`Error killing PTY for session ${sessionId}:`, error); 897 | } 898 | }); 899 | activePtyProcesses.clear(); 900 | 901 | // Kill all MCP poller processes 902 | mcpPollerPtyProcesses.forEach((ptyProcess, sessionId) => { 903 | try { 904 | ptyProcess.kill(); 905 | } catch (error) { 906 | console.error(`Error killing MCP poller for session ${sessionId}:`, error); 907 | } 908 | }); 909 | mcpPollerPtyProcesses.clear(); 910 | }); 911 | }; 912 | 913 | app.whenReady().then(() => { 914 | createWindow(); 915 | 916 | // Spawn claude command runner PTY early so it's ready when needed (fire-and-forget) 917 | spawnClaudeCommandRunner(); 918 | 919 | // Handles launch from dock on macos 920 | app.on("activate", () => { 921 | if (BrowserWindow.getAllWindows().length === 0) { 922 | createWindow(); 923 | } 924 | }); 925 | }); 926 | 927 | app.on("window-all-closed", () => { 928 | if (process.platform !== "darwin") { 929 | app.quit(); 930 | } 931 | }); 932 | -------------------------------------------------------------------------------- /renderer.ts: -------------------------------------------------------------------------------- 1 | import {FitAddon} from "@xterm/addon-fit"; 2 | import {ipcRenderer} from "electron"; 3 | import {Terminal} from "xterm"; 4 | import {PersistedSession, SessionConfig, SessionType} from "./types"; 5 | import {isClaudeSessionReady} from "./terminal-utils"; 6 | import * as path from "path"; 7 | 8 | interface Session { 9 | id: string; 10 | terminal: Terminal | null; 11 | fitAddon: FitAddon | null; 12 | element: HTMLDivElement | null; 13 | name: string; 14 | config: SessionConfig; 15 | worktreePath?: string; 16 | hasActivePty: boolean; 17 | } 18 | 19 | interface McpServer { 20 | name: string; 21 | connected?: boolean; 22 | command?: string; 23 | args?: string[]; 24 | env?: Record; 25 | url?: string; 26 | type?: "stdio" | "sse"; 27 | } 28 | 29 | interface TerminalSettings { 30 | fontFamily: string; 31 | fontSize: number; 32 | theme: string; // Theme preset name 33 | cursorBlink: boolean; 34 | worktreeDir: string; 35 | } 36 | 37 | interface ThemeColors { 38 | background: string; 39 | foreground: string; 40 | cursor?: string; 41 | cursorAccent?: string; 42 | selection?: string; 43 | black?: string; 44 | red?: string; 45 | green?: string; 46 | yellow?: string; 47 | blue?: string; 48 | magenta?: string; 49 | cyan?: string; 50 | white?: string; 51 | brightBlack?: string; 52 | brightRed?: string; 53 | brightGreen?: string; 54 | brightYellow?: string; 55 | brightBlue?: string; 56 | brightMagenta?: string; 57 | brightCyan?: string; 58 | brightWhite?: string; 59 | } 60 | 61 | // Theme presets 62 | const THEME_PRESETS: Record = { 63 | "macos-light": { 64 | background: "#ffffff", 65 | foreground: "#000000", 66 | cursor: "#000000", 67 | selection: "#b4d5fe", 68 | black: "#000000", 69 | red: "#c23621", 70 | green: "#25bc24", 71 | yellow: "#adad27", 72 | blue: "#492ee1", 73 | magenta: "#d338d3", 74 | cyan: "#33bbc8", 75 | white: "#cbcccd", 76 | brightBlack: "#818383", 77 | brightRed: "#fc391f", 78 | brightGreen: "#31e722", 79 | brightYellow: "#eaec23", 80 | brightBlue: "#5833ff", 81 | brightMagenta: "#f935f8", 82 | brightCyan: "#14f0f0", 83 | brightWhite: "#e9ebeb", 84 | }, 85 | "macos-dark": { 86 | background: "#000000", 87 | foreground: "#ffffff", 88 | cursor: "#ffffff", 89 | selection: "#4d4d4d", 90 | black: "#000000", 91 | red: "#c23621", 92 | green: "#25bc24", 93 | yellow: "#adad27", 94 | blue: "#492ee1", 95 | magenta: "#d338d3", 96 | cyan: "#33bbc8", 97 | white: "#cbcccd", 98 | brightBlack: "#818383", 99 | brightRed: "#fc391f", 100 | brightGreen: "#31e722", 101 | brightYellow: "#eaec23", 102 | brightBlue: "#5833ff", 103 | brightMagenta: "#f935f8", 104 | brightCyan: "#14f0f0", 105 | brightWhite: "#e9ebeb", 106 | }, 107 | "solarized-dark": { 108 | background: "#002b36", 109 | foreground: "#839496", 110 | cursor: "#839496", 111 | selection: "#073642", 112 | black: "#073642", 113 | red: "#dc322f", 114 | green: "#859900", 115 | yellow: "#b58900", 116 | blue: "#268bd2", 117 | magenta: "#d33682", 118 | cyan: "#2aa198", 119 | white: "#eee8d5", 120 | brightBlack: "#002b36", 121 | brightRed: "#cb4b16", 122 | brightGreen: "#586e75", 123 | brightYellow: "#657b83", 124 | brightBlue: "#839496", 125 | brightMagenta: "#6c71c4", 126 | brightCyan: "#93a1a1", 127 | brightWhite: "#fdf6e3", 128 | }, 129 | "dracula": { 130 | background: "#282a36", 131 | foreground: "#f8f8f2", 132 | cursor: "#f8f8f2", 133 | selection: "#44475a", 134 | black: "#21222c", 135 | red: "#ff5555", 136 | green: "#50fa7b", 137 | yellow: "#f1fa8c", 138 | blue: "#bd93f9", 139 | magenta: "#ff79c6", 140 | cyan: "#8be9fd", 141 | white: "#f8f8f2", 142 | brightBlack: "#6272a4", 143 | brightRed: "#ff6e6e", 144 | brightGreen: "#69ff94", 145 | brightYellow: "#ffffa5", 146 | brightBlue: "#d6acff", 147 | brightMagenta: "#ff92df", 148 | brightCyan: "#a4ffff", 149 | brightWhite: "#ffffff", 150 | }, 151 | "one-dark": { 152 | background: "#282c34", 153 | foreground: "#abb2bf", 154 | cursor: "#528bff", 155 | selection: "#3e4451", 156 | black: "#282c34", 157 | red: "#e06c75", 158 | green: "#98c379", 159 | yellow: "#e5c07b", 160 | blue: "#61afef", 161 | magenta: "#c678dd", 162 | cyan: "#56b6c2", 163 | white: "#abb2bf", 164 | brightBlack: "#5c6370", 165 | brightRed: "#e06c75", 166 | brightGreen: "#98c379", 167 | brightYellow: "#e5c07b", 168 | brightBlue: "#61afef", 169 | brightMagenta: "#c678dd", 170 | brightCyan: "#56b6c2", 171 | brightWhite: "#ffffff", 172 | }, 173 | "github-dark": { 174 | background: "#0d1117", 175 | foreground: "#c9d1d9", 176 | cursor: "#58a6ff", 177 | selection: "#163c61", 178 | black: "#484f58", 179 | red: "#ff7b72", 180 | green: "#3fb950", 181 | yellow: "#d29922", 182 | blue: "#58a6ff", 183 | magenta: "#bc8cff", 184 | cyan: "#39c5cf", 185 | white: "#b1bac4", 186 | brightBlack: "#6e7681", 187 | brightRed: "#ffa198", 188 | brightGreen: "#56d364", 189 | brightYellow: "#e3b341", 190 | brightBlue: "#79c0ff", 191 | brightMagenta: "#d2a8ff", 192 | brightCyan: "#56d4dd", 193 | brightWhite: "#f0f6fc", 194 | }, 195 | }; 196 | 197 | // Detect system theme 198 | function getSystemTheme(): "light" | "dark" { 199 | return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; 200 | } 201 | 202 | // Default settings - macOS Terminal matching system theme 203 | const DEFAULT_SETTINGS: TerminalSettings = { 204 | fontFamily: "Menlo, Monaco, 'Courier New', monospace", 205 | fontSize: 11, 206 | theme: getSystemTheme() === "dark" ? "macos-dark" : "macos-light", 207 | cursorBlink: false, 208 | worktreeDir: require("path").join(require("os").homedir(), "worktrees"), 209 | }; 210 | 211 | const sessions = new Map(); 212 | let activeSessionId: string | null = null; 213 | let mcpServers: McpServer[] = []; 214 | let mcpPollerActive = false; 215 | let terminalSettings: TerminalSettings = { ...DEFAULT_SETTINGS }; 216 | 217 | // Track activity timers for each session 218 | const activityTimers = new Map(); 219 | 220 | async function loadAndPopulateBranches( 221 | directory: string, 222 | selectedBranch?: string 223 | ): Promise { 224 | const branches = await ipcRenderer.invoke("get-branches", directory); 225 | existingBranches = branches; 226 | parentBranchSelect.innerHTML = ""; 227 | 228 | if (branches.length === 0) { 229 | parentBranchSelect.innerHTML = ''; 230 | } else { 231 | branches.forEach((branch: string) => { 232 | const option = document.createElement("option"); 233 | option.value = branch; 234 | option.textContent = branch; 235 | if (branch === selectedBranch) { 236 | option.selected = true; 237 | } 238 | parentBranchSelect.appendChild(option); 239 | }); 240 | } 241 | } 242 | 243 | function createTerminalUI(sessionId: string) { 244 | const themeColors = THEME_PRESETS[terminalSettings.theme] || THEME_PRESETS["macos-dark"]; 245 | 246 | const term = new Terminal({ 247 | cursorBlink: terminalSettings.cursorBlink, 248 | fontSize: terminalSettings.fontSize, 249 | fontFamily: terminalSettings.fontFamily, 250 | theme: themeColors, 251 | }); 252 | 253 | const fitAddon = new FitAddon(); 254 | term.loadAddon(fitAddon); 255 | 256 | const sessionElement = document.createElement("div"); 257 | sessionElement.className = "session-wrapper"; 258 | sessionElement.id = `session-${sessionId}`; 259 | 260 | const container = document.getElementById("session-container"); 261 | if (container) { 262 | container.appendChild(sessionElement); 263 | } 264 | 265 | term.open(sessionElement); 266 | 267 | term.onData((data) => { 268 | ipcRenderer.send("session-input", sessionId, data); 269 | }); 270 | 271 | // Listen for bell character to mark unread activity 272 | term.onBell(() => { 273 | if (activeSessionId !== sessionId) { 274 | markSessionAsUnread(sessionId); 275 | } 276 | }); 277 | 278 | const resizeHandler = () => { 279 | if (activeSessionId === sessionId) { 280 | const proposedDimensions = fitAddon.proposeDimensions(); 281 | if (proposedDimensions) { 282 | fitAddon.fit(); 283 | ipcRenderer.send("session-resize", sessionId, proposedDimensions.cols, proposedDimensions.rows); 284 | } 285 | } 286 | }; 287 | window.addEventListener("resize", resizeHandler); 288 | 289 | return { terminal: term, fitAddon, element: sessionElement }; 290 | } 291 | 292 | function addSession(persistedSession: PersistedSession, hasActivePty: boolean) { 293 | const session: Session = { 294 | id: persistedSession.id, 295 | terminal: null, 296 | fitAddon: null, 297 | element: null, 298 | name: persistedSession.name, 299 | config: persistedSession.config, 300 | worktreePath: persistedSession.worktreePath, 301 | hasActivePty, 302 | }; 303 | 304 | sessions.set(persistedSession.id, session); 305 | 306 | // Add to sidebar 307 | addToSidebar(persistedSession.id, persistedSession.name, hasActivePty, persistedSession.config); 308 | 309 | // Only add tab if terminal is active 310 | if (hasActivePty) { 311 | addTab(persistedSession.id, persistedSession.name); 312 | } 313 | 314 | return session; 315 | } 316 | 317 | function activateSession(sessionId: string) { 318 | const session = sessions.get(sessionId); 319 | if (!session) return; 320 | 321 | // If terminal UI doesn't exist yet, create it 322 | if (!session.terminal) { 323 | const ui = createTerminalUI(sessionId); 324 | session.terminal = ui.terminal; 325 | session.fitAddon = ui.fitAddon; 326 | session.element = ui.element; 327 | } 328 | 329 | session.hasActivePty = true; 330 | updateSessionState(sessionId, true); 331 | 332 | // Add tab if it doesn't exist 333 | if (!document.getElementById(`tab-${sessionId}`)) { 334 | addTab(sessionId, session.name); 335 | } 336 | 337 | // Switch to this session 338 | switchToSession(sessionId); 339 | } 340 | 341 | function updateSessionState(sessionId: string, isActive: boolean) { 342 | const sidebarItem = document.getElementById(`sidebar-${sessionId}`); 343 | const indicator = sidebarItem?.querySelector(".session-indicator"); 344 | 345 | if (indicator) { 346 | if (isActive) { 347 | indicator.classList.add("active"); 348 | } else { 349 | indicator.classList.remove("active"); 350 | } 351 | } 352 | } 353 | 354 | function addToSidebar(sessionId: string, name: string, hasActivePty: boolean, config: SessionConfig) { 355 | const list = document.getElementById("session-list"); 356 | if (!list) return; 357 | 358 | const isWorktree = config.sessionType === SessionType.WORKTREE; 359 | const applyMenuItem = isWorktree ? `` : ''; 360 | 361 | const item = document.createElement("div"); 362 | item.id = `sidebar-${sessionId}`; 363 | item.className = "session-list-item"; 364 | item.innerHTML = ` 365 |
366 | 367 | ${name} 368 | 369 |
370 |
371 | 372 | 377 |
378 | `; 379 | 380 | // Handle input blur and enter key 381 | const nameInput = item.querySelector(".session-name-input") as HTMLInputElement; 382 | nameInput?.addEventListener("blur", () => { 383 | finishEditingSessionName(sessionId); 384 | }); 385 | 386 | nameInput?.addEventListener("keydown", (e) => { 387 | if (e.key === "Enter") { 388 | finishEditingSessionName(sessionId); 389 | } else if (e.key === "Escape") { 390 | cancelEditingSessionName(sessionId); 391 | } 392 | }); 393 | 394 | // Click on item to activate session 395 | item.addEventListener("click", (e) => { 396 | const target = e.target as HTMLElement; 397 | if (!target.classList.contains("session-menu-btn") && 398 | !target.classList.contains("session-menu-item") && 399 | !target.classList.contains("session-name-input") && 400 | !target.closest(".session-menu")) { 401 | handleSessionClick(sessionId); 402 | } 403 | }); 404 | 405 | // Menu button toggle 406 | const menuBtn = item.querySelector(".session-menu-btn"); 407 | const menu = item.querySelector(".session-menu") as HTMLElement; 408 | 409 | menuBtn?.addEventListener("click", (e) => { 410 | e.stopPropagation(); 411 | 412 | // Close all other menus 413 | document.querySelectorAll(".session-menu").forEach(m => { 414 | if (m !== menu) m.classList.add("hidden"); 415 | }); 416 | 417 | // Toggle this menu 418 | menu?.classList.toggle("hidden"); 419 | }); 420 | 421 | // Rename button 422 | const renameBtn = item.querySelector(".rename-session-btn"); 423 | renameBtn?.addEventListener("click", (e) => { 424 | e.stopPropagation(); 425 | menu?.classList.add("hidden"); 426 | startEditingSessionName(sessionId); 427 | }); 428 | 429 | // Delete button 430 | const deleteBtn = item.querySelector(".delete-session-btn"); 431 | deleteBtn?.addEventListener("click", (e) => { 432 | e.stopPropagation(); 433 | menu?.classList.add("hidden"); 434 | deleteSession(sessionId); 435 | }); 436 | 437 | // Apply to project button (only for worktree sessions) 438 | const applyBtn = item.querySelector(".apply-to-project-btn"); 439 | applyBtn?.addEventListener("click", (e) => { 440 | e.stopPropagation(); 441 | menu?.classList.add("hidden"); 442 | showApplyToProjectDialog(sessionId); 443 | }); 444 | 445 | list.appendChild(item); 446 | } 447 | 448 | function startEditingSessionName(sessionId: string) { 449 | const sidebarItem = document.getElementById(`sidebar-${sessionId}`); 450 | const nameText = sidebarItem?.querySelector(".session-name-text"); 451 | const nameInput = sidebarItem?.querySelector(".session-name-input") as HTMLInputElement; 452 | 453 | if (nameText && nameInput) { 454 | nameText.classList.add("hidden"); 455 | nameInput.classList.remove("hidden"); 456 | nameInput.focus(); 457 | nameInput.select(); 458 | } 459 | } 460 | 461 | function finishEditingSessionName(sessionId: string) { 462 | const sidebarItem = document.getElementById(`sidebar-${sessionId}`); 463 | const nameText = sidebarItem?.querySelector(".session-name-text"); 464 | const nameInput = sidebarItem?.querySelector(".session-name-input") as HTMLInputElement; 465 | const session = sessions.get(sessionId); 466 | 467 | if (nameText && nameInput && session) { 468 | const newName = nameInput.value.trim(); 469 | if (newName && newName !== session.name) { 470 | // Update session name 471 | session.name = newName; 472 | nameText.textContent = newName; 473 | 474 | // Update tab name if exists 475 | const tab = document.getElementById(`tab-${sessionId}`); 476 | const tabName = tab?.querySelector(".tab-name"); 477 | if (tabName) { 478 | tabName.textContent = newName; 479 | } 480 | 481 | // Save to backend 482 | ipcRenderer.send("rename-session", sessionId, newName); 483 | } 484 | 485 | nameInput.classList.add("hidden"); 486 | nameText.classList.remove("hidden"); 487 | } 488 | } 489 | 490 | function cancelEditingSessionName(sessionId: string) { 491 | const sidebarItem = document.getElementById(`sidebar-${sessionId}`); 492 | const nameText = sidebarItem?.querySelector(".session-name-text"); 493 | const nameInput = sidebarItem?.querySelector(".session-name-input") as HTMLInputElement; 494 | const session = sessions.get(sessionId); 495 | 496 | if (nameText && nameInput && session) { 497 | // Reset to original name 498 | nameInput.value = session.name; 499 | nameInput.classList.add("hidden"); 500 | nameText.classList.remove("hidden"); 501 | } 502 | } 503 | 504 | function handleSessionClick(sessionId: string) { 505 | const session = sessions.get(sessionId); 506 | if (!session) return; 507 | 508 | if (session.hasActivePty) { 509 | // Just switch to it 510 | switchToSession(sessionId); 511 | } else { 512 | // Reopen the session 513 | ipcRenderer.send("reopen-session", sessionId); 514 | } 515 | } 516 | 517 | function addTab(sessionId: string, name: string) { 518 | const tabsContainer = document.getElementById("tabs"); 519 | if (!tabsContainer) return; 520 | 521 | const tab = document.createElement("div"); 522 | tab.id = `tab-${sessionId}`; 523 | tab.className = "tab"; 524 | tab.innerHTML = ` 525 | 526 | ${name} 527 | 528 | `; 529 | 530 | tab.addEventListener("click", (e) => { 531 | if (!(e.target as HTMLElement).classList.contains("tab-close-btn")) { 532 | switchToSession(sessionId); 533 | } 534 | }); 535 | 536 | const closeBtn = tab.querySelector(".tab-close-btn"); 537 | closeBtn?.addEventListener("click", (e) => { 538 | e.stopPropagation(); 539 | closeSession(sessionId); 540 | }); 541 | 542 | tabsContainer.appendChild(tab); 543 | } 544 | 545 | function markSessionAsUnread(sessionId: string) { 546 | const session = sessions.get(sessionId); 547 | if (!session) return; 548 | 549 | // Add unread indicator to tab 550 | const tab = document.getElementById(`tab-${sessionId}`); 551 | if (tab) { 552 | tab.classList.add("unread"); 553 | } 554 | } 555 | 556 | function clearUnreadStatus(sessionId: string) { 557 | const session = sessions.get(sessionId); 558 | if (!session) return; 559 | 560 | // Remove unread indicator from tab 561 | const tab = document.getElementById(`tab-${sessionId}`); 562 | if (tab) { 563 | tab.classList.remove("unread"); 564 | } 565 | } 566 | 567 | function markSessionActivity(sessionId: string) { 568 | const session = sessions.get(sessionId); 569 | if (!session) return; 570 | 571 | // Add activity indicator to tab 572 | const tab = document.getElementById(`tab-${sessionId}`); 573 | if (tab) { 574 | tab.classList.add("activity"); 575 | tab.classList.remove("unread"); 576 | } 577 | 578 | // Clear any existing timer 579 | const existingTimer = activityTimers.get(sessionId); 580 | if (existingTimer) { 581 | clearTimeout(existingTimer); 582 | } 583 | 584 | // Set a new timer to remove activity after 1 second of no output 585 | const timer = setTimeout(() => { 586 | clearActivityStatus(sessionId); 587 | }, 1000); 588 | 589 | activityTimers.set(sessionId, timer); 590 | } 591 | 592 | function clearActivityStatus(sessionId: string) { 593 | const session = sessions.get(sessionId); 594 | if (!session) return; 595 | 596 | // Remove activity indicator from tab, but keep unread if it's set 597 | const tab = document.getElementById(`tab-${sessionId}`); 598 | if (tab) { 599 | tab.classList.remove("activity"); 600 | // If there's no unread status, transition to unread after activity ends 601 | if (!tab.classList.contains("unread") && activeSessionId !== sessionId) { 602 | tab.classList.add("unread"); 603 | } 604 | } 605 | 606 | // Clear the timer 607 | activityTimers.delete(sessionId); 608 | } 609 | 610 | function switchToSession(sessionId: string) { 611 | // Hide all sessions 612 | sessions.forEach((session, id) => { 613 | if (session.element) { 614 | session.element.classList.remove("active"); 615 | } 616 | document.getElementById(`tab-${id}`)?.classList.remove("active"); 617 | document.getElementById(`sidebar-${id}`)?.classList.remove("active"); 618 | }); 619 | 620 | // Show active session 621 | const session = sessions.get(sessionId); 622 | if (session && session.element && session.terminal && session.fitAddon) { 623 | session.element.classList.add("active"); 624 | document.getElementById(`tab-${sessionId}`)?.classList.add("active"); 625 | document.getElementById(`sidebar-${sessionId}`)?.classList.add("active"); 626 | activeSessionId = sessionId; 627 | 628 | // Show MCP section when a session is active 629 | const mcpSection = document.getElementById("mcp-section"); 630 | if (mcpSection) { 631 | mcpSection.style.display = "block"; 632 | } 633 | 634 | // Clear MCP servers from previous session and re-render 635 | mcpServers = []; 636 | renderMcpServers(); 637 | 638 | // Load MCP servers for this session 639 | loadMcpServers(); 640 | 641 | // Clear unread and activity status when switching to this session 642 | clearUnreadStatus(sessionId); 643 | clearActivityStatus(sessionId); 644 | 645 | // Focus and resize 646 | session.terminal.focus(); 647 | // Dispatch resize event to trigger terminal resize 648 | window.dispatchEvent(new Event("resize")); 649 | } 650 | } 651 | 652 | function closeSession(sessionId: string) { 653 | const session = sessions.get(sessionId); 654 | if (!session) return; 655 | 656 | // Remove terminal UI 657 | if (session.element) { 658 | session.element.remove(); 659 | } 660 | if (session.terminal) { 661 | session.terminal.dispose(); 662 | } 663 | 664 | // Remove tab 665 | document.getElementById(`tab-${sessionId}`)?.remove(); 666 | 667 | // Update session state 668 | session.terminal = null; 669 | session.fitAddon = null; 670 | session.element = null; 671 | session.hasActivePty = false; 672 | 673 | // Update UI indicator 674 | updateSessionState(sessionId, false); 675 | 676 | // Close PTY in main process 677 | ipcRenderer.send("close-session", sessionId); 678 | 679 | // Switch to another active session 680 | if (activeSessionId === sessionId) { 681 | const activeSessions = Array.from(sessions.values()).filter(s => s.hasActivePty); 682 | if (activeSessions.length > 0) { 683 | switchToSession(activeSessions[0].id); 684 | } else { 685 | activeSessionId = null; 686 | // Hide MCP section when no sessions are active 687 | const mcpSection = document.getElementById("mcp-section"); 688 | if (mcpSection) { 689 | mcpSection.style.display = "none"; 690 | } 691 | } 692 | } 693 | } 694 | 695 | function deleteSession(sessionId: string) { 696 | const session = sessions.get(sessionId); 697 | if (!session) return; 698 | 699 | // Confirm deletion with different message based on session type 700 | const isWorktree = session.config.sessionType === SessionType.WORKTREE; 701 | const message = isWorktree 702 | ? `Delete ${session.name}? This will remove the git worktree.` 703 | : `Delete ${session.name}? This will remove the session.`; 704 | 705 | if (!confirm(message)) { 706 | return; 707 | } 708 | 709 | // Remove from UI 710 | if (session.element) { 711 | session.element.remove(); 712 | } 713 | if (session.terminal) { 714 | session.terminal.dispose(); 715 | } 716 | document.getElementById(`tab-${sessionId}`)?.remove(); 717 | document.getElementById(`sidebar-${sessionId}`)?.remove(); 718 | 719 | // Remove from sessions map 720 | sessions.delete(sessionId); 721 | 722 | // Delete in main process (handles worktree removal) 723 | ipcRenderer.send("delete-session", sessionId); 724 | 725 | // Switch to another session 726 | if (activeSessionId === sessionId) { 727 | const remainingSessions = Array.from(sessions.values()).filter(s => s.hasActivePty); 728 | if (remainingSessions.length > 0) { 729 | switchToSession(remainingSessions[0].id); 730 | } else { 731 | activeSessionId = null; 732 | // Hide MCP section when no sessions are active 733 | const mcpSection = document.getElementById("mcp-section"); 734 | if (mcpSection) { 735 | mcpSection.style.display = "none"; 736 | } 737 | } 738 | } 739 | } 740 | 741 | function showApplyToProjectDialog(sessionId: string) { 742 | const session = sessions.get(sessionId); 743 | if (!session) return; 744 | 745 | const modal = document.getElementById("apply-to-project-modal"); 746 | const branchName = document.getElementById("apply-branch-name"); 747 | const parentBranch = document.getElementById("apply-parent-branch"); 748 | 749 | if (branchName && session.config.branchName) { 750 | branchName.textContent = session.config.branchName; 751 | } 752 | if (parentBranch && session.config.parentBranch) { 753 | parentBranch.textContent = session.config.parentBranch; 754 | } 755 | 756 | // Store session ID for later use in confirm handler 757 | modal?.setAttribute("data-session-id", sessionId); 758 | 759 | modal?.classList.remove("hidden"); 760 | } 761 | 762 | // Handle session output 763 | ipcRenderer.on("session-output", (_event, sessionId: string, data: string) => { 764 | const session = sessions.get(sessionId); 765 | if (session && session.terminal) { 766 | // Filter out [3J (clear scrollback) to prevent viewport resets during interactive menus 767 | // Keep [2J (clear screen) which is needed for the menu redraw 768 | const filteredData = data.replace(/\x1b\[3J/g, ''); 769 | 770 | session.terminal.write(filteredData); 771 | 772 | // Only mark as unread/activity if this is not the active session 773 | if (activeSessionId !== sessionId && session.hasActivePty) { 774 | // Show activity spinner while output is coming in 775 | markSessionActivity(sessionId); 776 | 777 | // Check if Claude session is ready for input 778 | if (isClaudeSessionReady(filteredData)) { 779 | // Clear activity timer and set unread 780 | const existingTimer = activityTimers.get(sessionId); 781 | if (existingTimer) { 782 | clearTimeout(existingTimer); 783 | activityTimers.delete(sessionId); 784 | } 785 | 786 | const tab = document.getElementById(`tab-${sessionId}`); 787 | if (tab) { 788 | tab.classList.remove("activity"); 789 | tab.classList.add("unread"); 790 | } 791 | } 792 | } 793 | } 794 | }); 795 | 796 | // Handle session created 797 | ipcRenderer.on("session-created", (_event, sessionId: string, persistedSession: any) => { 798 | const session = addSession(persistedSession, true); 799 | activateSession(sessionId); 800 | 801 | // Reset button state and close modal 802 | const createBtn = document.getElementById("create-session") as HTMLButtonElement; 803 | const modal = document.getElementById("config-modal"); 804 | const projectDirInput = document.getElementById("project-dir") as HTMLInputElement; 805 | const parentBranchSelect = document.getElementById("parent-branch") as HTMLSelectElement; 806 | const branchNameInput = document.getElementById("branch-name") as HTMLInputElement; 807 | const setupCommandsTextarea = document.getElementById("setup-commands") as HTMLTextAreaElement; 808 | 809 | if (createBtn) { 810 | createBtn.disabled = false; 811 | createBtn.textContent = "Create Session"; 812 | createBtn.classList.remove("loading"); 813 | } 814 | 815 | modal?.classList.add("hidden"); 816 | 817 | // Reset form 818 | projectDirInput.value = ""; 819 | selectedDirectory = ""; 820 | parentBranchSelect.innerHTML = ''; 821 | if (branchNameInput) { 822 | branchNameInput.value = ""; 823 | } 824 | if (setupCommandsTextarea) { 825 | setupCommandsTextarea.value = ""; 826 | } 827 | 828 | // Reset validation state 829 | const branchNameError = document.getElementById("branch-name-error"); 830 | const branchNameHelp = document.getElementById("branch-name-help"); 831 | branchNameError?.classList.add("hidden"); 832 | branchNameHelp?.classList.remove("hidden"); 833 | existingBranches = []; 834 | }); 835 | 836 | // Handle session reopened 837 | ipcRenderer.on("session-reopened", (_event, sessionId: string) => { 838 | activateSession(sessionId); 839 | }); 840 | 841 | // Handle session deleted 842 | ipcRenderer.on("session-deleted", (_event, sessionId: string) => { 843 | const session = sessions.get(sessionId); 844 | if (session) { 845 | if (session.element) session.element.remove(); 846 | if (session.terminal) session.terminal.dispose(); 847 | document.getElementById(`tab-${sessionId}`)?.remove(); 848 | document.getElementById(`sidebar-${sessionId}`)?.remove(); 849 | sessions.delete(sessionId); 850 | 851 | if (activeSessionId === sessionId) { 852 | const remainingSessions = Array.from(sessions.values()).filter(s => s.hasActivePty); 853 | if (remainingSessions.length > 0) { 854 | switchToSession(remainingSessions[0].id); 855 | } else { 856 | activeSessionId = null; 857 | } 858 | } 859 | } 860 | }); 861 | 862 | // Load persisted sessions on startup 863 | ipcRenderer.on("load-persisted-sessions", (_event, persistedSessions: PersistedSession[]) => { 864 | persistedSessions.forEach(ps => { 865 | addSession(ps, false); 866 | }); 867 | }); 868 | 869 | // Modal handling 870 | const modal = document.getElementById("config-modal"); 871 | const projectDirInput = document.getElementById("project-dir") as HTMLInputElement; 872 | const parentBranchSelect = document.getElementById("parent-branch") as HTMLSelectElement; 873 | const codingAgentSelect = document.getElementById("coding-agent") as HTMLSelectElement; 874 | const skipPermissionsCheckbox = document.getElementById("skip-permissions") as HTMLInputElement; 875 | const skipPermissionsGroup = skipPermissionsCheckbox?.parentElement?.parentElement; 876 | const sessionTypeSelect = document.getElementById("session-type") as HTMLSelectElement; 877 | const parentBranchGroup = document.getElementById("parent-branch-group"); 878 | const branchNameGroup = document.getElementById("branch-name-group"); 879 | const worktreeDescription = document.getElementById("worktree-description"); 880 | const localDescription = document.getElementById("local-description"); 881 | const browseDirBtn = document.getElementById("browse-dir"); 882 | const cancelBtn = document.getElementById("cancel-session"); 883 | const createBtn = document.getElementById("create-session") as HTMLButtonElement; 884 | const branchNameInput = document.getElementById("branch-name") as HTMLInputElement; 885 | const branchNameError = document.getElementById("branch-name-error"); 886 | const branchNameHelp = document.getElementById("branch-name-help"); 887 | 888 | let selectedDirectory = ""; 889 | let existingBranches: string[] = []; 890 | 891 | // Validate branch name 892 | function validateBranchName(): boolean { 893 | const branchName = branchNameInput?.value.trim(); 894 | 895 | if (!branchName) { 896 | // Empty branch name is allowed (it's optional) 897 | branchNameError?.classList.add("hidden"); 898 | branchNameHelp?.classList.remove("hidden"); 899 | return true; 900 | } 901 | 902 | // Check if branch already exists 903 | const branchExists = existingBranches.some(branch => 904 | branch === branchName || branch === `origin/${branchName}` 905 | ); 906 | 907 | if (branchExists) { 908 | branchNameError?.classList.remove("hidden"); 909 | branchNameHelp?.classList.add("hidden"); 910 | return false; 911 | } else { 912 | branchNameError?.classList.add("hidden"); 913 | branchNameHelp?.classList.remove("hidden"); 914 | return true; 915 | } 916 | } 917 | 918 | // Add input event listener for branch name validation 919 | branchNameInput?.addEventListener("input", () => { 920 | validateBranchName(); 921 | }); 922 | 923 | // Toggle skip permissions checkbox visibility based on coding agent 924 | codingAgentSelect?.addEventListener("change", () => { 925 | if (codingAgentSelect.value === "claude") { 926 | skipPermissionsGroup?.classList.remove("hidden"); 927 | } else { 928 | skipPermissionsGroup?.classList.add("hidden"); 929 | } 930 | }); 931 | 932 | // Toggle parent branch and branch name visibility based on session type 933 | sessionTypeSelect?.addEventListener("change", () => { 934 | const isWorktree = sessionTypeSelect.value === SessionType.WORKTREE; 935 | if (isWorktree) { 936 | parentBranchGroup?.classList.remove("hidden"); 937 | branchNameGroup?.classList.remove("hidden"); 938 | worktreeDescription?.style.setProperty("display", "block"); 939 | localDescription?.style.setProperty("display", "none"); 940 | } else { 941 | parentBranchGroup?.classList.add("hidden"); 942 | branchNameGroup?.classList.add("hidden"); 943 | worktreeDescription?.style.setProperty("display", "none"); 944 | localDescription?.style.setProperty("display", "block"); 945 | } 946 | }); 947 | 948 | // New session button - opens modal 949 | document.getElementById("new-session")?.addEventListener("click", async () => { 950 | modal?.classList.remove("hidden"); 951 | 952 | // Load last used settings 953 | const lastSettings = await ipcRenderer.invoke("get-last-settings"); 954 | 955 | if (lastSettings.projectDir) { 956 | selectedDirectory = lastSettings.projectDir; 957 | // Show last part of path in parentheses before full path 958 | const dirName = path.basename(lastSettings.projectDir); 959 | projectDirInput.value = `(${dirName}) ${lastSettings.projectDir}`; 960 | 961 | // Load git branches for the last directory 962 | await loadAndPopulateBranches(lastSettings.projectDir, lastSettings.parentBranch); 963 | } 964 | 965 | // Set last used session type (default to worktree if not set) 966 | if (lastSettings.sessionType) { 967 | sessionTypeSelect.value = lastSettings.sessionType; 968 | } else { 969 | sessionTypeSelect.value = SessionType.WORKTREE; 970 | } 971 | 972 | // Show/hide parent branch, branch name, and descriptions based on session type 973 | const isWorktree = sessionTypeSelect.value === SessionType.WORKTREE; 974 | if (isWorktree) { 975 | parentBranchGroup?.classList.remove("hidden"); 976 | branchNameGroup?.classList.remove("hidden"); 977 | worktreeDescription?.style.setProperty("display", "block"); 978 | localDescription?.style.setProperty("display", "none"); 979 | } else { 980 | parentBranchGroup?.classList.add("hidden"); 981 | branchNameGroup?.classList.add("hidden"); 982 | worktreeDescription?.style.setProperty("display", "none"); 983 | localDescription?.style.setProperty("display", "block"); 984 | } 985 | 986 | // Set last used coding agent 987 | if (lastSettings.codingAgent) { 988 | codingAgentSelect.value = lastSettings.codingAgent; 989 | } 990 | 991 | // Set last used skip permissions setting and visibility 992 | if (lastSettings.skipPermissions !== undefined) { 993 | skipPermissionsCheckbox.checked = lastSettings.skipPermissions; 994 | } 995 | 996 | // Set last used setup commands 997 | const setupCommandsTextarea = document.getElementById("setup-commands") as HTMLTextAreaElement; 998 | if (lastSettings.setupCommands && setupCommandsTextarea) { 999 | setupCommandsTextarea.value = lastSettings.setupCommands.join("\n"); 1000 | } 1001 | 1002 | // Show/hide skip permissions based on coding agent 1003 | if (lastSettings.codingAgent === "codex") { 1004 | skipPermissionsGroup?.classList.add("hidden"); 1005 | } else { 1006 | skipPermissionsGroup?.classList.remove("hidden"); 1007 | } 1008 | }); 1009 | 1010 | // Browse directory 1011 | browseDirBtn?.addEventListener("click", async () => { 1012 | const dir = await ipcRenderer.invoke("select-directory"); 1013 | if (dir) { 1014 | selectedDirectory = dir; 1015 | // Show last part of path in parentheses before full path 1016 | const dirName = path.basename(dir); 1017 | projectDirInput.value = `(${dirName}) ${dir}`; 1018 | 1019 | // Load git branches 1020 | await loadAndPopulateBranches(dir); 1021 | } 1022 | }); 1023 | 1024 | // Cancel button 1025 | cancelBtn?.addEventListener("click", () => { 1026 | modal?.classList.add("hidden"); 1027 | projectDirInput.value = ""; 1028 | selectedDirectory = ""; 1029 | parentBranchSelect.innerHTML = ''; 1030 | branchNameInput.value = ""; 1031 | branchNameError?.classList.add("hidden"); 1032 | branchNameHelp?.classList.remove("hidden"); 1033 | existingBranches = []; 1034 | }); 1035 | 1036 | // Create session button 1037 | createBtn?.addEventListener("click", () => { 1038 | if (!selectedDirectory) { 1039 | alert("Please select a project directory"); 1040 | return; 1041 | } 1042 | 1043 | const sessionType = sessionTypeSelect.value as SessionType; 1044 | 1045 | // Validate parent branch is selected for worktree sessions 1046 | if (sessionType === SessionType.WORKTREE && !parentBranchSelect.value) { 1047 | alert("Please select a parent branch for worktree session"); 1048 | return; 1049 | } 1050 | 1051 | // Validate branch name doesn't already exist for worktree sessions 1052 | if (sessionType === SessionType.WORKTREE && !validateBranchName()) { 1053 | alert("Cannot create worktree: branch already exists"); 1054 | return; 1055 | } 1056 | 1057 | const setupCommandsTextarea = document.getElementById("setup-commands") as HTMLTextAreaElement; 1058 | const setupCommandsText = setupCommandsTextarea?.value.trim(); 1059 | const setupCommands = setupCommandsText 1060 | ? setupCommandsText.split("\n").filter(cmd => cmd.trim()) 1061 | : undefined; 1062 | 1063 | const branchNameInput = document.getElementById("branch-name") as HTMLInputElement; 1064 | const branchName = branchNameInput?.value.trim() || undefined; 1065 | 1066 | const config: SessionConfig = { 1067 | projectDir: selectedDirectory, 1068 | sessionType, 1069 | parentBranch: sessionType === SessionType.WORKTREE ? parentBranchSelect.value : undefined, 1070 | branchName, 1071 | codingAgent: codingAgentSelect.value, 1072 | skipPermissions: codingAgentSelect.value === "claude" ? skipPermissionsCheckbox.checked : false, 1073 | setupCommands, 1074 | }; 1075 | 1076 | // Show loading state 1077 | if (createBtn) { 1078 | createBtn.disabled = true; 1079 | createBtn.innerHTML = ' Creating...'; 1080 | createBtn.classList.add("loading"); 1081 | } 1082 | 1083 | // Save settings for next time 1084 | ipcRenderer.send("save-settings", config); 1085 | 1086 | // Create the session 1087 | ipcRenderer.send("create-session", config); 1088 | }); 1089 | 1090 | // MCP Server management functions 1091 | async function loadMcpServers() { 1092 | // Only load MCP servers if there's an active session 1093 | if (!activeSessionId) { 1094 | return; 1095 | } 1096 | 1097 | try { 1098 | await ipcRenderer.invoke("list-mcp-servers", activeSessionId); 1099 | // Results will come via mcp-servers-updated event 1100 | } catch (error) { 1101 | console.error("Failed to load MCP servers:", error); 1102 | } 1103 | } 1104 | 1105 | // Force immediate refresh of MCP servers after add/remove operations 1106 | async function refreshMcpServers() { 1107 | if (!activeSessionId) { 1108 | return; 1109 | } 1110 | 1111 | // Show loading state on add button 1112 | const addMcpServerBtn = document.getElementById("add-mcp-server"); 1113 | if (addMcpServerBtn) { 1114 | addMcpServerBtn.innerHTML = ''; 1115 | addMcpServerBtn.classList.add("pointer-events-none"); 1116 | } 1117 | 1118 | try { 1119 | // Trigger MCP list command 1120 | await ipcRenderer.invoke("list-mcp-servers", activeSessionId); 1121 | // Wait a bit for the poller to process and send results 1122 | await new Promise(resolve => setTimeout(resolve, 500)); 1123 | } catch (error) { 1124 | console.error("Failed to refresh MCP servers:", error); 1125 | } finally { 1126 | // Restore add button will happen via mcp-servers-updated event 1127 | } 1128 | } 1129 | 1130 | function renderMcpServers() { 1131 | const list = document.getElementById("mcp-server-list"); 1132 | if (!list) return; 1133 | 1134 | list.innerHTML = ""; 1135 | 1136 | mcpServers.forEach(server => { 1137 | const item = document.createElement("div"); 1138 | item.className = "session-list-item"; 1139 | const indicatorClass = server.connected ? "active" : "disconnected"; 1140 | item.innerHTML = ` 1141 |
1142 | 1143 | ${server.name} 1144 |
1145 | 1146 | `; 1147 | 1148 | // Click to show details 1149 | item.addEventListener("click", async (e) => { 1150 | const target = e.target as HTMLElement; 1151 | if (!target.classList.contains("mcp-remove-btn")) { 1152 | await showMcpServerDetails(server.name); 1153 | } 1154 | }); 1155 | 1156 | const removeBtn = item.querySelector(".mcp-remove-btn"); 1157 | removeBtn?.addEventListener("click", async (e) => { 1158 | e.stopPropagation(); 1159 | if (confirm(`Remove MCP server "${server.name}"?`)) { 1160 | // Optimistically remove from UI 1161 | mcpServers = mcpServers.filter(s => s.name !== server.name); 1162 | renderMcpServers(); 1163 | 1164 | try { 1165 | await ipcRenderer.invoke("remove-mcp-server", server.name); 1166 | } catch (error) { 1167 | alert(`Failed to remove server: ${error}`); 1168 | // Refresh to restore correct state on error 1169 | await refreshMcpServers(); 1170 | } 1171 | } 1172 | }); 1173 | 1174 | list.appendChild(item); 1175 | }); 1176 | } 1177 | 1178 | async function showMcpServerDetails(name: string) { 1179 | const detailsModal = document.getElementById("mcp-details-modal"); 1180 | const detailsTitle = document.getElementById("mcp-details-title"); 1181 | const detailsContent = document.getElementById("mcp-details-content"); 1182 | 1183 | // Show modal immediately with loading state 1184 | if (detailsTitle) { 1185 | detailsTitle.textContent = name; 1186 | } 1187 | 1188 | if (detailsContent) { 1189 | detailsContent.innerHTML = '
'; 1190 | } 1191 | 1192 | detailsModal?.classList.remove("hidden"); 1193 | 1194 | try { 1195 | const details = await ipcRenderer.invoke("get-mcp-server-details", name); 1196 | 1197 | if (detailsContent) { 1198 | let html = ""; 1199 | if (details.scope) { 1200 | html += `
Scope: ${details.scope}
`; 1201 | } 1202 | if (details.status) { 1203 | html += `
Status: ${details.status}
`; 1204 | } 1205 | if (details.type) { 1206 | html += `
Type: ${details.type}
`; 1207 | } 1208 | if (details.url) { 1209 | html += `
URL: ${details.url}
`; 1210 | } 1211 | if (details.command) { 1212 | html += `
Command: ${details.command}
`; 1213 | } 1214 | if (details.args) { 1215 | html += `
Args: ${details.args}
`; 1216 | } 1217 | 1218 | detailsContent.innerHTML = html; 1219 | } 1220 | 1221 | // Store current server name for remove button 1222 | const removeMcpDetailsBtn = document.getElementById("remove-mcp-details") as HTMLButtonElement; 1223 | if (removeMcpDetailsBtn) { 1224 | removeMcpDetailsBtn.dataset.serverName = name; 1225 | } 1226 | } catch (error) { 1227 | console.error("Failed to get server details:", error); 1228 | if (detailsContent) { 1229 | detailsContent.innerHTML = `
Failed to load server details
`; 1230 | } 1231 | } 1232 | } 1233 | 1234 | // MCP Modal handling 1235 | const mcpModal = document.getElementById("mcp-modal"); 1236 | const mcpNameInput = document.getElementById("mcp-name") as HTMLInputElement; 1237 | const mcpTypeSelect = document.getElementById("mcp-type") as HTMLSelectElement; 1238 | const mcpCommandInput = document.getElementById("mcp-command") as HTMLInputElement; 1239 | const mcpArgsInput = document.getElementById("mcp-args") as HTMLInputElement; 1240 | const mcpEnvInput = document.getElementById("mcp-env") as HTMLTextAreaElement; 1241 | const mcpUrlInput = document.getElementById("mcp-url") as HTMLInputElement; 1242 | const mcpHeadersInput = document.getElementById("mcp-headers") as HTMLTextAreaElement; 1243 | const mcpAlwaysAllowInput = document.getElementById("mcp-always-allow") as HTMLInputElement; 1244 | const localFields = document.getElementById("local-fields"); 1245 | const remoteFields = document.getElementById("remote-fields"); 1246 | const cancelMcpBtn = document.getElementById("cancel-mcp"); 1247 | const addMcpBtn = document.getElementById("add-mcp") as HTMLButtonElement; 1248 | 1249 | // Toggle fields based on server type 1250 | mcpTypeSelect?.addEventListener("change", () => { 1251 | if (mcpTypeSelect.value === "local") { 1252 | localFields!.style.display = "block"; 1253 | remoteFields!.style.display = "none"; 1254 | } else { 1255 | localFields!.style.display = "none"; 1256 | remoteFields!.style.display = "block"; 1257 | } 1258 | }); 1259 | 1260 | // Add MCP server button - opens modal 1261 | document.getElementById("add-mcp-server")?.addEventListener("click", () => { 1262 | mcpModal?.classList.remove("hidden"); 1263 | mcpNameInput.value = ""; 1264 | mcpTypeSelect.value = "local"; 1265 | mcpCommandInput.value = ""; 1266 | mcpArgsInput.value = ""; 1267 | mcpEnvInput.value = ""; 1268 | mcpUrlInput.value = ""; 1269 | mcpHeadersInput.value = ""; 1270 | mcpAlwaysAllowInput.value = ""; 1271 | localFields!.style.display = "block"; 1272 | remoteFields!.style.display = "none"; 1273 | }); 1274 | 1275 | // Cancel MCP button 1276 | cancelMcpBtn?.addEventListener("click", () => { 1277 | mcpModal?.classList.add("hidden"); 1278 | }); 1279 | 1280 | // Add MCP button 1281 | addMcpBtn?.addEventListener("click", async () => { 1282 | const name = mcpNameInput.value.trim(); 1283 | const serverType = mcpTypeSelect.value; 1284 | 1285 | if (!name) { 1286 | alert("Please enter a server name"); 1287 | return; 1288 | } 1289 | 1290 | const config: any = {}; 1291 | 1292 | if (serverType === "local") { 1293 | config.type = "stdio"; 1294 | 1295 | const command = mcpCommandInput.value.trim(); 1296 | const argsInput = mcpArgsInput.value.trim(); 1297 | 1298 | if (!command) { 1299 | alert("Please enter a command"); 1300 | return; 1301 | } 1302 | 1303 | config.command = command; 1304 | if (argsInput) { 1305 | config.args = argsInput.split(" ").filter(a => a.trim()); 1306 | } 1307 | 1308 | // Parse environment variables if provided 1309 | const envInput = mcpEnvInput.value.trim(); 1310 | if (envInput) { 1311 | try { 1312 | config.env = JSON.parse(envInput); 1313 | } catch (error) { 1314 | alert("Invalid JSON for environment variables"); 1315 | return; 1316 | } 1317 | } 1318 | } else { 1319 | // Remote server 1320 | config.type = "sse"; 1321 | 1322 | const url = mcpUrlInput.value.trim(); 1323 | 1324 | if (!url) { 1325 | alert("Please enter a server URL"); 1326 | return; 1327 | } 1328 | 1329 | config.url = url; 1330 | 1331 | // Parse headers if provided 1332 | const headersInput = mcpHeadersInput.value.trim(); 1333 | if (headersInput) { 1334 | try { 1335 | config.headers = JSON.parse(headersInput); 1336 | } catch (error) { 1337 | alert("Invalid JSON for headers"); 1338 | return; 1339 | } 1340 | } 1341 | } 1342 | 1343 | // Parse always allow tools 1344 | const alwaysAllowInput = mcpAlwaysAllowInput.value.trim(); 1345 | if (alwaysAllowInput) { 1346 | config.alwaysAllow = alwaysAllowInput.split(",").map(t => t.trim()).filter(t => t); 1347 | } 1348 | 1349 | // Show loading state 1350 | const originalText = addMcpBtn.innerHTML; 1351 | addMcpBtn.innerHTML = ' Adding...'; 1352 | addMcpBtn.disabled = true; 1353 | addMcpBtn.classList.add("opacity-50", "cursor-not-allowed"); 1354 | 1355 | try { 1356 | await ipcRenderer.invoke("add-mcp-server", name, config); 1357 | mcpModal?.classList.add("hidden"); 1358 | // Force immediate refresh of MCP servers 1359 | await refreshMcpServers(); 1360 | } catch (error) { 1361 | console.error("Error adding server:", error); 1362 | alert(`Failed to add server: ${error}`); 1363 | } finally { 1364 | // Reset button state 1365 | addMcpBtn.innerHTML = originalText; 1366 | addMcpBtn.disabled = false; 1367 | addMcpBtn.classList.remove("opacity-50", "cursor-not-allowed"); 1368 | } 1369 | }); 1370 | 1371 | // MCP Details Modal handling 1372 | const closeMcpDetailsBtn = document.getElementById("close-mcp-details"); 1373 | const removeMcpDetailsBtn = document.getElementById("remove-mcp-details") as HTMLButtonElement; 1374 | const mcpDetailsModal = document.getElementById("mcp-details-modal"); 1375 | 1376 | closeMcpDetailsBtn?.addEventListener("click", () => { 1377 | mcpDetailsModal?.classList.add("hidden"); 1378 | }); 1379 | 1380 | removeMcpDetailsBtn?.addEventListener("click", async () => { 1381 | const serverName = removeMcpDetailsBtn.dataset.serverName; 1382 | if (!serverName) return; 1383 | 1384 | if (confirm(`Remove MCP server "${serverName}"?`)) { 1385 | // Close modal immediately 1386 | mcpDetailsModal?.classList.add("hidden"); 1387 | 1388 | // Optimistically remove from UI 1389 | mcpServers = mcpServers.filter(s => s.name !== serverName); 1390 | renderMcpServers(); 1391 | 1392 | try { 1393 | await ipcRenderer.invoke("remove-mcp-server", serverName); 1394 | } catch (error) { 1395 | alert(`Failed to remove server: ${error}`); 1396 | // Refresh to restore correct state on error 1397 | await refreshMcpServers(); 1398 | } 1399 | } 1400 | }); 1401 | 1402 | // Listen for MCP polling started event 1403 | ipcRenderer.on("mcp-polling-started", (_event, sessionId: string) => { 1404 | if (sessionId === activeSessionId) { 1405 | const addMcpServerBtn = document.getElementById("add-mcp-server"); 1406 | if (addMcpServerBtn) { 1407 | addMcpServerBtn.innerHTML = ''; 1408 | addMcpServerBtn.classList.add("pointer-events-none"); 1409 | } 1410 | } 1411 | }); 1412 | 1413 | // Listen for MCP server updates from main process 1414 | ipcRenderer.on("mcp-servers-updated", (_event, sessionId: string, servers: McpServer[]) => { 1415 | // Only update if this is for the active session 1416 | if (sessionId === activeSessionId) { 1417 | mcpServers = servers; 1418 | renderMcpServers(); 1419 | 1420 | // Restore add button 1421 | const addMcpServerBtn = document.getElementById("add-mcp-server"); 1422 | if (addMcpServerBtn) { 1423 | addMcpServerBtn.innerHTML = '+'; 1424 | addMcpServerBtn.classList.remove("pointer-events-none"); 1425 | } 1426 | } 1427 | }); 1428 | 1429 | // Settings Modal handling 1430 | const settingsModal = document.getElementById("settings-modal"); 1431 | const openSettingsBtn = document.getElementById("open-settings"); 1432 | const cancelSettingsBtn = document.getElementById("cancel-settings"); 1433 | const resetSettingsBtn = document.getElementById("reset-settings"); 1434 | const saveSettingsBtn = document.getElementById("save-settings"); 1435 | 1436 | const settingsTheme = document.getElementById("settings-theme") as HTMLSelectElement; 1437 | const settingsFontFamily = document.getElementById("settings-font-family") as HTMLSelectElement; 1438 | const settingsFontSize = document.getElementById("settings-font-size") as HTMLInputElement; 1439 | const settingsCursorBlink = document.getElementById("settings-cursor-blink") as HTMLInputElement; 1440 | const settingsWorktreeDir = document.getElementById("settings-worktree-dir") as HTMLInputElement; 1441 | const browseWorktreeDirBtn = document.getElementById("browse-worktree-dir"); 1442 | 1443 | // Load saved settings on startup 1444 | async function loadSettings() { 1445 | const savedSettings = await ipcRenderer.invoke("get-terminal-settings"); 1446 | if (savedSettings) { 1447 | terminalSettings = { ...DEFAULT_SETTINGS, ...savedSettings }; 1448 | } 1449 | } 1450 | 1451 | // Populate settings form 1452 | function populateSettingsForm() { 1453 | // Set theme 1454 | settingsTheme.value = terminalSettings.theme; 1455 | 1456 | // Set font family - match against dropdown options 1457 | const fontOptions = Array.from(settingsFontFamily.options); 1458 | const matchingOption = fontOptions.find(opt => opt.value === terminalSettings.fontFamily); 1459 | if (matchingOption) { 1460 | settingsFontFamily.value = matchingOption.value; 1461 | } else { 1462 | // Default to first option (Menlo) if no match 1463 | settingsFontFamily.selectedIndex = 0; 1464 | } 1465 | 1466 | settingsFontSize.value = terminalSettings.fontSize.toString(); 1467 | settingsCursorBlink.checked = terminalSettings.cursorBlink; 1468 | settingsWorktreeDir.value = terminalSettings.worktreeDir; 1469 | } 1470 | 1471 | // Apply settings to all existing terminals 1472 | function applySettingsToAllTerminals() { 1473 | const themeColors = THEME_PRESETS[terminalSettings.theme] || THEME_PRESETS["macos-dark"]; 1474 | 1475 | sessions.forEach((session) => { 1476 | if (session.terminal) { 1477 | session.terminal.options.fontFamily = terminalSettings.fontFamily; 1478 | session.terminal.options.fontSize = terminalSettings.fontSize; 1479 | session.terminal.options.cursorBlink = terminalSettings.cursorBlink; 1480 | session.terminal.options.theme = themeColors; 1481 | 1482 | // Refresh terminal to apply changes 1483 | if (session.fitAddon) { 1484 | session.fitAddon.fit(); 1485 | } 1486 | } 1487 | }); 1488 | } 1489 | 1490 | // Open settings modal 1491 | openSettingsBtn?.addEventListener("click", async () => { 1492 | populateSettingsForm(); 1493 | 1494 | // Load and display app version 1495 | const version = await ipcRenderer.invoke("get-app-version"); 1496 | const versionElement = document.getElementById("app-version"); 1497 | if (versionElement) { 1498 | versionElement.textContent = `FleetCode v${version}`; 1499 | } 1500 | 1501 | settingsModal?.classList.remove("hidden"); 1502 | }); 1503 | 1504 | // Cancel settings 1505 | cancelSettingsBtn?.addEventListener("click", () => { 1506 | settingsModal?.classList.add("hidden"); 1507 | }); 1508 | 1509 | // Reset settings to default 1510 | resetSettingsBtn?.addEventListener("click", () => { 1511 | terminalSettings = { ...DEFAULT_SETTINGS }; 1512 | populateSettingsForm(); 1513 | }); 1514 | 1515 | // Browse worktree directory 1516 | browseWorktreeDirBtn?.addEventListener("click", async () => { 1517 | const dir = await ipcRenderer.invoke("select-directory"); 1518 | if (dir) { 1519 | settingsWorktreeDir.value = dir; 1520 | } 1521 | }); 1522 | 1523 | // Save settings 1524 | saveSettingsBtn?.addEventListener("click", async () => { 1525 | // Read values from form 1526 | terminalSettings.theme = settingsTheme.value; 1527 | terminalSettings.fontFamily = settingsFontFamily.value || DEFAULT_SETTINGS.fontFamily; 1528 | terminalSettings.fontSize = parseInt(settingsFontSize.value) || DEFAULT_SETTINGS.fontSize; 1529 | terminalSettings.cursorBlink = settingsCursorBlink.checked; 1530 | terminalSettings.worktreeDir = settingsWorktreeDir.value || DEFAULT_SETTINGS.worktreeDir; 1531 | 1532 | // Save to electron-store 1533 | await ipcRenderer.invoke("save-terminal-settings", terminalSettings); 1534 | 1535 | // Apply to all existing terminals 1536 | applySettingsToAllTerminals(); 1537 | 1538 | // Close modal 1539 | settingsModal?.classList.add("hidden"); 1540 | }); 1541 | 1542 | // Load settings on startup 1543 | loadSettings(); 1544 | 1545 | // Apply to Project Modal 1546 | const applyToProjectModal = document.getElementById("apply-to-project-modal"); 1547 | const cancelApplyToProjectBtn = document.getElementById("cancel-apply-to-project"); 1548 | const confirmApplyToProjectBtn = document.getElementById("confirm-apply-to-project"); 1549 | 1550 | cancelApplyToProjectBtn?.addEventListener("click", () => { 1551 | applyToProjectModal?.classList.add("hidden"); 1552 | }); 1553 | 1554 | confirmApplyToProjectBtn?.addEventListener("click", async () => { 1555 | const sessionId = applyToProjectModal?.getAttribute("data-session-id"); 1556 | if (!sessionId) return; 1557 | 1558 | // Disable button during operation 1559 | if (confirmApplyToProjectBtn) { 1560 | confirmApplyToProjectBtn.textContent = "Applying..."; 1561 | confirmApplyToProjectBtn.setAttribute("disabled", "true"); 1562 | } 1563 | 1564 | try { 1565 | const result = await ipcRenderer.invoke("apply-session-to-project", sessionId); 1566 | 1567 | if (result.success) { 1568 | alert(`Changes applied successfully to project directory.`); 1569 | applyToProjectModal?.classList.add("hidden"); 1570 | } else { 1571 | alert(`Failed to apply changes: ${result.error}`); 1572 | } 1573 | } catch (error) { 1574 | alert(`Error applying changes: ${error}`); 1575 | } finally { 1576 | // Re-enable button 1577 | if (confirmApplyToProjectBtn) { 1578 | confirmApplyToProjectBtn.textContent = "Apply Changes"; 1579 | confirmApplyToProjectBtn.removeAttribute("disabled"); 1580 | } 1581 | } 1582 | }); 1583 | 1584 | // Close session menus when clicking outside 1585 | document.addEventListener("click", (e) => { 1586 | const target = e.target as HTMLElement; 1587 | if (!target.closest(".session-menu") && !target.classList.contains("session-menu-btn")) { 1588 | document.querySelectorAll(".session-menu").forEach(menu => { 1589 | menu.classList.add("hidden"); 1590 | }); 1591 | } 1592 | }); 1593 | --------------------------------------------------------------------------------