[
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: ''\nassignees: ''\ntype: Bug\n\n---\n\n**Describe the bug**\nA clear and concise description of what the bug is.\n\n**To Reproduce**\nSteps to reproduce the behavior:\n1. Go to '...'\n2. Click on '....'\n3. Scroll down to '....'\n4. See error\n\n**Expected behavior**\nA clear and concise description of what you expected to happen.\n\n**Screenshots**\nIf applicable, add screenshots to help explain your problem.\n\n**Error message**\nIf applicable, add the error message you see to help explain your problem.\n\n**Desktop (please complete the following information):**\n - OS: [e.g. iOS]\n - Browser [e.g. chrome, safari]\n - Version [e.g. 22]\n\n**Smartphone (please complete the following information):**\n - Device: [e.g. iPhone6]\n - OS: [e.g. iOS8.1]\n - Browser [e.g. stock browser, safari]\n\n**Additional context**\nAdd any other context about the problem here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: \"[Feature]\"\nlabels: ''\nassignees: ''\ntype: Feature\n\n---\n\n**Is your feature request related to a problem? Please describe.**\nA clear and concise description of what the problem is. Ex. I'm always frustrated when [...]\n\n**Describe the solution you'd like**\nA clear and concise description of what you want to happen.\n\n\n**Additional context**\nAdd any other context or screenshots about the feature request here.\n"
  },
  {
    "path": ".github/workflows/discord-release.yml",
    "content": "name: Discord Release Notification\n\non:\n  release:\n    types: [published]\n\njobs:\n  github-releases-to-discord:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v3\n      - name: Github Releases To Discord\n        uses: SethCohen/github-releases-to-discord@v1.19.0\n        with:\n          webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }}\n          color: \"2105893\"\n          username: \"Release Changelog\"\n          avatar_url: \"https://cdn.discordapp.com/avatars/487431320314576937/bd64361e4ba6313d561d54e78c9e7171.png\"\n          content: \"||@everyone||\"\n          footer_title: \"Changelog\"\n          reduce_headings: true\n"
  },
  {
    "path": ".gitignore",
    "content": "# Dependencies\nnode_modules/\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\n# Build outputs\ndist/\ndist-ssr/\nbuild/\nout/\n\n# Environment variables\n.env\n.env.local\n.env.development.local\n.env.test.local\n.env.production.local\n\n# IDE and editor files\n.vscode/\n.idea/\n*.swp\n*.swo\n*~\n\n# OS generated files\n.DS_Store\n.DS_Store?\n._*\n.Spotlight-V100\n.Trashes\nehthumbs.db\nThumbs.db\n\n# Logs\n*.log\nlogs/\n\n# Runtime data\npids\n*.pid\n*.seed\n*.pid.lock\n\n# Coverage directory used by tools like istanbul\ncoverage/\n*.lcov\n\n# nyc test coverage\n.nyc_output\n\n# Dependency directories\njspm_packages/\n\n# Optional npm cache directory\n.npm\n\n# Optional eslint cache\n.eslintcache\n\n# Optional REPL history\n.node_repl_history\n\n# Output of 'npm pack'\n*.tgz\n\n# Yarn Integrity file\n.yarn-integrity\n\n# dotenv environment variables file\n.env.test\n\n# parcel-bundler cache (https://parceljs.org/)\n.cache\n.parcel-cache\n\n# Next.js build output\n.next\n\n# Nuxt.js build / generate output\n.nuxt\n\n# Storybook build outputs\n.out\n.storybook-out\n\n# Temporary folders\ntmp/\ntemp/\n.tmp/\n\n# Vite\n.vite/\n\n# Local Netlify folder\n.netlify\n\n# AI specific\n.claude/\n.cursor/\n.roo/\n.taskmaster/\n.cline/\n.windsurf/\n.serena/\nCLAUDE.md\n.mcp.json\n.gemini/\n\n# Database files\n*.db\n*.sqlite\n*.sqlite3 \n\nlogs\ndev-debug.log\n# Editor directories and files\n.idea\n.vscode\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n# OS specific\n\n# Task files\ntasks.json\ntasks/ \n\n# Translations\n!src/i18n/locales/en/tasks.json\n!src/i18n/locales/ja/tasks.json\n!src/i18n/locales/ru/tasks.json\n!src/i18n/locales/de/tasks.json\n\n# Git worktrees\n.worktrees/"
  },
  {
    "path": ".gitmodules",
    "content": "[submodule \"plugins/starter\"]\n\tpath = plugins/starter\n\turl = https://github.com/cloudcli-ai/cloudcli-plugin-starter.git\n"
  },
  {
    "path": ".husky/commit-msg",
    "content": "npx commitlint --edit $1\n"
  },
  {
    "path": ".husky/pre-commit",
    "content": "npx lint-staged\n"
  },
  {
    "path": ".npmignore",
    "content": "\n*.md\n!README.md\n.env*\n.gitignore\n.nvmrc\n.release-it.json\nrelease.sh\npostcss.config.js\nvite.config.js\ntailwind.config.js\n\n# Database files\nauthdb/\n*.db\n*.sqlite\n*.sqlite3\n\n\n# IDE and editor files\n.vscode/\n.idea/\n*.swp\n*.swo\n*~\n\n# OS generated files\n.DS_Store\n.DS_Store?\n._*\n.Spotlight-V100\n.Trashes\nehthumbs.db\nThumbs.db\n\n# AI specific\n.claude/\n.cursor/\n.roo/\n.taskmaster/\n.cline/\n.windsurf/\n.serena/\nCLAUDE.md\n.mcp.json\n\n\n# Task files\ntasks.json\ntasks/\n\n# Environment variables\n.env\n.env.local\n.env.development.local\n.env.test.local\n.env.production.local\n"
  },
  {
    "path": ".nvmrc",
    "content": "v22"
  },
  {
    "path": ".release-it.json",
    "content": "{\n  \"git\": {\n    \"commitMessage\": \"chore(release): v${version}\",\n    \"tagName\": \"v${version}\",\n    \"requireBranch\": \"main\",\n    \"requireCleanWorkingDir\": true\n  },\n  \"npm\": {\n    \"publish\": true\n  },\n  \"github\": {\n    \"release\": true,\n    \"releaseName\": \"CloudCLI UI v${version}\"\n  },\n  \"hooks\": {\n    \"before:init\": [\"npm run build\"]\n  },\n  \"plugins\": {\n    \"@release-it/conventional-changelog\": {\n      \"infile\": \"CHANGELOG.md\",\n      \"header\": \"# Changelog\\n\\nAll notable changes to CloudCLI UI will be documented in this file.\\n\",\n      \"preset\": {\n        \"name\": \"conventionalcommits\",\n        \"types\": [\n          { \"type\": \"feat\", \"section\": \"New Features\" },\n          { \"type\": \"feature\", \"section\": \"New Features\" },\n          { \"type\": \"fix\", \"section\": \"Bug Fixes\" },\n          { \"type\": \"perf\", \"section\": \"Performance\" },\n          { \"type\": \"refactor\", \"section\": \"Refactoring\" },\n          { \"type\": \"docs\", \"section\": \"Documentation\" },\n          { \"type\": \"style\", \"section\": \"Styling\" },\n          { \"type\": \"chore\", \"section\": \"Maintenance\" },\n          { \"type\": \"ci\", \"section\": \"CI/CD\" },\n          { \"type\": \"test\", \"section\": \"Tests\" },\n          { \"type\": \"build\", \"section\": \"Build\" }\n        ]\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Changelog\n\nAll notable changes to CloudCLI UI will be documented in this file.\n\n\n## [1.26.0](https://github.com/siteboon/claudecodeui/compare/v1.25.2...v1.26.0) (2026-03-20)\n\n### New Features\n\n* add German (Deutsch) language support ([#525](https://github.com/siteboon/claudecodeui/issues/525)) ([a7299c6](https://github.com/siteboon/claudecodeui/commit/a7299c68237908c752d504c2e8eea91570a30203))\n* add WebSocket proxy for plugin backends ([#553](https://github.com/siteboon/claudecodeui/issues/553)) ([88c60b7](https://github.com/siteboon/claudecodeui/commit/88c60b70b031798d51ce26c8f080a0f64d824b05))\n* Browser autofill support for login form ([#521](https://github.com/siteboon/claudecodeui/issues/521)) ([72ff134](https://github.com/siteboon/claudecodeui/commit/72ff134b315b7a1d602f3cc7dd60d47c1c1c34af))\n* git panel redesign ([#535](https://github.com/siteboon/claudecodeui/issues/535)) ([adb3a06](https://github.com/siteboon/claudecodeui/commit/adb3a06d7e66a6d2dbcdfb501615e617178314af))\n* introduce notification system and claude notifications ([#450](https://github.com/siteboon/claudecodeui/issues/450)) ([45e71a0](https://github.com/siteboon/claudecodeui/commit/45e71a0e73b368309544165e4dcf8b7fd014e8dd))\n* **refactor:** move plugins to typescript ([#557](https://github.com/siteboon/claudecodeui/issues/557)) ([612390d](https://github.com/siteboon/claudecodeui/commit/612390db536417e2f68c501329bfccf5c6795e45))\n* unified message architecture with provider adapters and session store ([#558](https://github.com/siteboon/claudecodeui/issues/558)) ([a4632dc](https://github.com/siteboon/claudecodeui/commit/a4632dc4cec228a8febb7c5bae4807c358963678))\n\n### Bug Fixes\n\n* detect Claude auth from settings env ([#527](https://github.com/siteboon/claudecodeui/issues/527)) ([95bcee0](https://github.com/siteboon/claudecodeui/commit/95bcee0ec459f186d52aeffe100ac1a024e92909))\n* remove /exit command from claude login flow during onboarding ([#552](https://github.com/siteboon/claudecodeui/issues/552)) ([4de8b78](https://github.com/siteboon/claudecodeui/commit/4de8b78c6db5d8c2c402afce0f0b4cc16d5b6496))\n\n### Documentation\n\n* add German language link to all README files ([#534](https://github.com/siteboon/claudecodeui/issues/534)) ([1d31c3e](https://github.com/siteboon/claudecodeui/commit/1d31c3ec8309b433a041f3099955addc8c136c35))\n* **readme:** hotfix and improve for README.jp.md ([#550](https://github.com/siteboon/claudecodeui/issues/550)) ([7413c2c](https://github.com/siteboon/claudecodeui/commit/7413c2c78422c308ac949e6a83c3e9216b24b649))\n* **README:** update translations with CloudCLI branding and feature restructuring ([#544](https://github.com/siteboon/claudecodeui/issues/544)) ([14aef73](https://github.com/siteboon/claudecodeui/commit/14aef73cc6085fbb519fe64aea7cac80b7d51285))\n\n## [1.25.2](https://github.com/siteboon/claudecodeui/compare/v1.25.0...v1.25.2) (2026-03-11)\n\n### New Features\n\n* **i18n:** localize plugin settings for all languages ([#515](https://github.com/siteboon/claudecodeui/issues/515)) ([621853c](https://github.com/siteboon/claudecodeui/commit/621853cbfb4233b34cb8cc2e1ed10917ba424352))\n\n### Bug Fixes\n\n* codeql user value provided path validation ([aaa14b9](https://github.com/siteboon/claudecodeui/commit/aaa14b9fc0b9b51c4fb9d1dba40fada7cbbe0356))\n* numerous bugs ([#528](https://github.com/siteboon/claudecodeui/issues/528)) ([a77f213](https://github.com/siteboon/claudecodeui/commit/a77f213dd5d0b2538dea091ab8da6e55d2002f2f))\n* **security:** disable executable gray-matter frontmatter in commands ([b9c902b](https://github.com/siteboon/claudecodeui/commit/b9c902b016f411a942c8707dd07d32b60bad087c))\n* session reconnect catch-up, always-on input, frozen session recovery ([#524](https://github.com/siteboon/claudecodeui/issues/524)) ([4d8fb6e](https://github.com/siteboon/claudecodeui/commit/4d8fb6e30aa03d7cdb92bd62b7709422f9d08e32))\n\n### Refactoring\n\n* new settings page design and new pill component ([8ddeeb0](https://github.com/siteboon/claudecodeui/commit/8ddeeb0ce8d0642560bd3fa149236011dc6e3707))\n\n## [1.25.0](https://github.com/siteboon/claudecodeui/compare/v1.24.0...v1.25.0) (2026-03-10)\n\n### New Features\n\n* add copy as text or markdown feature for assistant messages ([#519](https://github.com/siteboon/claudecodeui/issues/519)) ([1dc2a20](https://github.com/siteboon/claudecodeui/commit/1dc2a205dc2a3cbf960625d7669c7c63a2b6905f))\n* add full Russian language support; update Readme.md files, and .gitignore update ([#514](https://github.com/siteboon/claudecodeui/issues/514)) ([c7dcba8](https://github.com/siteboon/claudecodeui/commit/c7dcba8d9117e84db8aac7d8a7bf6a3aa683e115))\n* new plugin system ([#489](https://github.com/siteboon/claudecodeui/issues/489)) ([8afb46a](https://github.com/siteboon/claudecodeui/commit/8afb46af2e5514c9284030367281793fbb014e4f))\n\n### Bug Fixes\n\n* resolve duplicate key issue when rendering model options ([#520](https://github.com/siteboon/claudecodeui/issues/520)) ([9bceab9](https://github.com/siteboon/claudecodeui/commit/9bceab9e1a6e063b0b4f934ed2d9f854fcc9c6a4))\n\n### Maintenance\n\n* add plugins section in readme ([e581a0e](https://github.com/siteboon/claudecodeui/commit/e581a0e1ccd59fd7ec7306ca76a13e73d7c674c1))\n\n## [1.24.0](https://github.com/siteboon/claudecodeui/compare/v1.23.2...v1.24.0) (2026-03-09)\n\n### New Features\n\n* add full-text search across conversations ([#482](https://github.com/siteboon/claudecodeui/issues/482)) ([3950c0e](https://github.com/siteboon/claudecodeui/commit/3950c0e47f41e93227af31494690818d45c8bc7a))\n\n### Bug Fixes\n\n* **git:** prevent shell injection in git routes ([86c33c1](https://github.com/siteboon/claudecodeui/commit/86c33c1c0cb34176725a38f46960213714fc3e04))\n* replace getDatabase with better-sqlite3 db in getGithubTokenById ([#501](https://github.com/siteboon/claudecodeui/issues/501)) ([cb4fd79](https://github.com/siteboon/claudecodeui/commit/cb4fd795c938b1cc86d47f401973bfccdd68fdee))\n\n## [1.23.2](https://github.com/siteboon/claudecodeui/compare/v1.22.1...v1.23.2) (2026-03-06)\n\n### New Features\n\n* add clickable overlay buttons for CLI prompts in Shell terminal ([#480](https://github.com/siteboon/claudecodeui/issues/480)) ([2444209](https://github.com/siteboon/claudecodeui/commit/2444209723701dda2b881cea2501b239e64e51c1)), closes [#427](https://github.com/siteboon/claudecodeui/issues/427)\n* add terminal shortcuts panel for mobile ([#411](https://github.com/siteboon/claudecodeui/issues/411)) ([b0a3fdf](https://github.com/siteboon/claudecodeui/commit/b0a3fdf95ffdb961261194d10400267251e42f17))\n* implement session rename with SQLite storage ([#413](https://github.com/siteboon/claudecodeui/issues/413)) ([198e3da](https://github.com/siteboon/claudecodeui/commit/198e3da89b353780f53a91888384da9118995e81)), closes [#72](https://github.com/siteboon/claudecodeui/issues/72) [#358](https://github.com/siteboon/claudecodeui/issues/358)\n\n### Bug Fixes\n\n* **chat:** finalize terminal lifecycle to prevent stuck processing/thinking UI ([#483](https://github.com/siteboon/claudecodeui/issues/483)) ([0590c5c](https://github.com/siteboon/claudecodeui/commit/0590c5c178f4791e2b039d525ecca4d220c3dcae))\n* **codex-history:** prevent AGENTS.md/internal prompt leakage when reloading Codex sessions ([#488](https://github.com/siteboon/claudecodeui/issues/488)) ([64a96b2](https://github.com/siteboon/claudecodeui/commit/64a96b24f853acb802f700810b302f0f5cf00898))\n* preserve pending permission requests across WebSocket reconnections ([#462](https://github.com/siteboon/claudecodeui/issues/462)) ([4ee88f0](https://github.com/siteboon/claudecodeui/commit/4ee88f0eb0c648b54b05f006c6796fb7b09b0fae))\n* prevent React 18 batching from losing messages during session sync ([#461](https://github.com/siteboon/claudecodeui/issues/461)) ([688d734](https://github.com/siteboon/claudecodeui/commit/688d73477a50773e43c85addc96212aa6290aea5))\n* release it script ([dcea8a3](https://github.com/siteboon/claudecodeui/commit/dcea8a329c7d68437e1e72c8c766cf33c74637e9))\n\n### Styling\n\n* improve UI for processing banner ([#477](https://github.com/siteboon/claudecodeui/issues/477)) ([2320e1d](https://github.com/siteboon/claudecodeui/commit/2320e1d74b59c65b5b7fc4fa8b05fd9208f4898c))\n\n### Maintenance\n\n* remove logging of received WebSocket messages in production ([#487](https://github.com/siteboon/claudecodeui/issues/487)) ([9193feb](https://github.com/siteboon/claudecodeui/commit/9193feb6dc83041f3c365204648a88468bdc001b))\n\n## [1.22.0](https://github.com/siteboon/claudecodeui/compare/v1.21.0...v1.22.0) (2026-03-03)\n\n### New Features\n\n* add community button in the app ([84d4634](https://github.com/siteboon/claudecodeui/commit/84d4634735f9ee13ac1c20faa0e7e31f1b77cae8))\n* Advanced file editor and file tree improvements ([#444](https://github.com/siteboon/claudecodeui/issues/444)) ([9768958](https://github.com/siteboon/claudecodeui/commit/97689588aa2e8240ba4373da5f42ab444c772e72))\n* update document title based on selected project ([#448](https://github.com/siteboon/claudecodeui/issues/448)) ([9e22f42](https://github.com/siteboon/claudecodeui/commit/9e22f42a3d3a781f448ddac9d133292fe103bb8c))\n\n### Bug Fixes\n\n* **claude:** correct project encoded path ([#451](https://github.com/siteboon/claudecodeui/issues/451)) ([9c0e864](https://github.com/siteboon/claudecodeui/commit/9c0e864532dcc5ce7ee890d3b4db722872db2b54)), closes [#447](https://github.com/siteboon/claudecodeui/issues/447)\n* **claude:** move model usage log to result message only ([#454](https://github.com/siteboon/claudecodeui/issues/454)) ([506d431](https://github.com/siteboon/claudecodeui/commit/506d43144b3ec3155c3e589e7e803862c4a8f83a))\n* missing translation label ([855e22f](https://github.com/siteboon/claudecodeui/commit/855e22f9176a71daa51de716370af7f19d55bfb4))\n\n### Maintenance\n\n* add Gemini-CLI support to README ([#453](https://github.com/siteboon/claudecodeui/issues/453)) ([503c384](https://github.com/siteboon/claudecodeui/commit/503c3846850fb843781979b0c0e10a24b07e1a4b))\n\n## [1.21.0](https://github.com/siteboon/claudecodeui/compare/v1.20.1...v1.21.0) (2026-02-27)\n\n### New Features\n\n* add copy icon for user messages ([#449](https://github.com/siteboon/claudecodeui/issues/449)) ([b359c51](https://github.com/siteboon/claudecodeui/commit/b359c515277b4266fde2fb9a29b5356949c07c4f))\n* Google's gemini-cli integration ([#422](https://github.com/siteboon/claudecodeui/issues/422)) ([a367edd](https://github.com/siteboon/claudecodeui/commit/a367edd51578608b3281373cb4a95169dbf17f89))\n* persist active tab across reloads via localStorage ([#414](https://github.com/siteboon/claudecodeui/issues/414)) ([e3b6892](https://github.com/siteboon/claudecodeui/commit/e3b689214f11d549ffe1b3a347476d58f25c5aca)), closes [#387](https://github.com/siteboon/claudecodeui/issues/387)\n\n### Bug Fixes\n\n* add support for Codex in the shell ([#424](https://github.com/siteboon/claudecodeui/issues/424)) ([23801e9](https://github.com/siteboon/claudecodeui/commit/23801e9cc15d2b8d1bfc6e39aee2fae93226d1ad))\n\n### Maintenance\n\n* upgrade @anthropic-ai/claude-agent-sdk to version 0.2.59 and add model usage logging ([#446](https://github.com/siteboon/claudecodeui/issues/446)) ([917c353](https://github.com/siteboon/claudecodeui/commit/917c353115653ee288bf97be01f62fad24123cbc))\n* upgrade better-sqlite to latest version to support node 25 ([#445](https://github.com/siteboon/claudecodeui/issues/445)) ([4ab94fc](https://github.com/siteboon/claudecodeui/commit/4ab94fce4257e1e20370fa83fa4c0f6fadbb8a2b))\n\n## [1.20.1](https://github.com/siteboon/claudecodeui/compare/v1.19.1...v1.20.1) (2026-02-23)\n\n### New Features\n\n* implement install mode detection and update commands in version upgrade process ([f986004](https://github.com/siteboon/claudecodeui/commit/f986004319207b068431f9f6adf338a8ce8decfc))\n* migrate legacy database to new location and improve last login update handling ([50e097d](https://github.com/siteboon/claudecodeui/commit/50e097d4ac498aa9f1803ef3564843721833dc19))\n\n## [1.19.1](https://github.com/siteboon/claudecodeui/compare/v1.19.0...v1.19.1) (2026-02-23)\n\n### Bug Fixes\n\n* add prepublishOnly script to build before publishing ([82efac4](https://github.com/siteboon/claudecodeui/commit/82efac4704cab11ed8d1a05fe84f41312140b223))\n\n## [1.19.0](https://github.com/siteboon/claudecodeui/compare/v1.18.2...v1.19.0) (2026-02-23)\n\n### New Features\n\n* add HOST environment variable for configurable bind address ([#360](https://github.com/siteboon/claudecodeui/issues/360)) ([cccd915](https://github.com/siteboon/claudecodeui/commit/cccd915c336192216b6e6f68e2b5f3ece0ccf966))\n* subagent tool grouping ([#398](https://github.com/siteboon/claudecodeui/issues/398)) ([0207a1f](https://github.com/siteboon/claudecodeui/commit/0207a1f3a3c87f1c6c1aee8213be999b23289386))\n\n### Bug Fixes\n\n* **macos:** fix node-pty posix_spawnp error with postinstall script ([#347](https://github.com/siteboon/claudecodeui/issues/347)) ([38a593c](https://github.com/siteboon/claudecodeui/commit/38a593c97fdb2bb7f051e09e8e99c16035448655)), closes [#284](https://github.com/siteboon/claudecodeui/issues/284)\n* slash commands with arguments bypass command execution ([#392](https://github.com/siteboon/claudecodeui/issues/392)) ([597e9c5](https://github.com/siteboon/claudecodeui/commit/597e9c54b76e7c6cd1947299c668c78d24019cab))\n\n### Refactoring\n\n* **releases:** Create a contributing guide and proper release notes using a release-it plugin ([fc369d0](https://github.com/siteboon/claudecodeui/commit/fc369d047e13cba9443fe36c0b6bb2ce3beaf61c))\n\n### Maintenance\n\n* update @anthropic-ai/claude-agent-sdk to version 0.1.77 in package-lock.json ([#410](https://github.com/siteboon/claudecodeui/issues/410)) ([7ccbc8d](https://github.com/siteboon/claudecodeui/commit/7ccbc8d92d440e18c157b656c9ea2635044a64f6))\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to CloudCLI UI\n\nThanks for your interest in contributing to CloudCLI UI! Before you start, please take a moment to read through this guide.\n\n## Before You Start\n\n- **Search first.** Check [existing issues](https://github.com/siteboon/claudecodeui/issues) and [pull requests](https://github.com/siteboon/claudecodeui/pulls) to avoid duplicating work.\n- **Discuss first** for new features. Open an [issue](https://github.com/siteboon/claudecodeui/issues/new) to discuss your idea before investing time in implementation. We may already have plans or opinions on how it should work.\n- **Bug fixes are always welcome.** If you spot a bug, feel free to open a PR directly.\n\n## Prerequisites\n\n- [Node.js](https://nodejs.org/) 22 or later\n- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and configured\n\n## Getting Started\n\n1. Fork the repository\n2. Clone your fork:\n   ```bash\n   git clone https://github.com/<your-username>/claudecodeui.git\n   cd claudecodeui\n   ```\n3. Install dependencies:\n   ```bash\n   npm install\n   ```\n4. Start the development server:\n   ```bash\n   npm run dev\n   ```\n5. Create a branch for your changes:\n   ```bash\n   git checkout -b feat/your-feature-name\n   ```\n\n## Project Structure\n\n```\nclaudecodeui/\n├── src/              # React frontend (Vite + Tailwind)\n│   ├── components/   # UI components\n│   ├── contexts/     # React context providers\n│   ├── hooks/        # Custom React hooks\n│   ├── i18n/         # Internationalization and translations\n│   ├── lib/          # Shared frontend libraries\n│   ├── types/        # TypeScript type definitions\n│   └── utils/        # Frontend utilities\n├── server/           # Express backend\n│   ├── routes/       # API route handlers\n│   ├── middleware/    # Express middleware\n│   ├── database/     # SQLite database layer\n│   └── tools/        # CLI tool integrations\n├── shared/           # Code shared between client and server\n└── public/           # Static assets, icons, PWA manifest\n```\n\n## Development Workflow\n\n- `npm run dev` — Start both the frontend and backend in development mode\n- `npm run build` — Create a production build\n- `npm run server` — Start only the backend server\n- `npm run client` — Start only the Vite dev server\n\n## Making Changes\n\n### Bug Fixes\n\n- Reference the issue number in your PR if one exists\n- Describe how to reproduce the bug in your PR description\n- Add a screenshot or recording for visual bugs\n\n### New Features\n\n- Keep the scope focused — one feature per PR\n- Include screenshots or recordings for UI changes\n\n### Documentation\n\n- Documentation improvements are always welcome\n- Keep language clear and concise\n\n## Commit Convention\n\nWe follow [Conventional Commits](https://conventionalcommits.org/) to generate release notes automatically. Every commit message should follow this format:\n\n```\n<type>(optional scope): <description>\n```\n\nUse imperative, present tense: \"add feature\" not \"added feature\" or \"adds feature\".\n\n### Types\n\n| Type | Description |\n|------|-------------|\n| `feat` | A new feature |\n| `fix` | A bug fix |\n| `perf` | A performance improvement |\n| `refactor` | Code change that neither fixes a bug nor adds a feature |\n| `docs` | Documentation only |\n| `style` | CSS, formatting, visual changes |\n| `chore` | Maintenance, dependencies, config |\n| `ci` | CI/CD pipeline changes |\n| `test` | Adding or updating tests |\n| `build` | Build system changes |\n\n### Examples\n\n```bash\nfeat: add conversation search\nfeat(i18n): add Japanese language support\nfix: redirect unauthenticated users to login\nfix(editor): syntax highlighting for .env files\nperf: lazy load code editor component\nrefactor(chat): extract message list component\ndocs: update API configuration guide\n```\n\n### Breaking Changes\n\nAdd `!` after the type or include `BREAKING CHANGE:` in the commit footer:\n\n```bash\nfeat!: redesign settings page layout\n```\n\n## Pull Requests\n\n- Give your PR a clear, descriptive title following the commit convention above\n- Fill in the PR description with what changed and why\n- Link any related issues\n- Include screenshots for UI changes\n- Make sure the build passes (`npm run build`)\n- Keep PRs focused — avoid unrelated changes\n\n## Releases\n\nReleases are managed by maintainers using [release-it](https://github.com/release-it/release-it) with the [conventional changelog plugin](https://github.com/release-it/conventional-changelog).\n\n```bash\nnpm run release           # interactive (prompts for version bump)\nnpm run release -- patch  # patch release\nnpm run release -- minor  # minor release\n```\n\nThis automatically:\n- Bumps the version based on commit types (`feat` = minor, `fix` = patch)\n- Generates categorized release notes\n- Updates `CHANGELOG.md`\n- Creates a git tag and GitHub Release\n- Publishes to npm\n\n## License\n\nBy contributing, you agree that your contributions will be licensed under the [GPL-3.0 License](LICENSE)."
  },
  {
    "path": "LICENSE",
    "content": "# GNU GENERAL PUBLIC LICENSE\n\nVersion 3, 29 June 2007\n\nCopyright (C) 2007 Free Software Foundation, Inc.\n<https://fsf.org/>\n\nEveryone is permitted to copy and distribute verbatim copies of this\nlicense document, but changing it is not allowed.\n\n## Preamble\n\nThe GNU General Public License is a free, copyleft license for\nsoftware and other kinds of works.\n\nThe licenses for most software and other practical works are designed\nto take away your freedom to share and change the works. By contrast,\nthe GNU General Public License is intended to guarantee your freedom\nto share and change all versions of a program--to make sure it remains\nfree software for all its users. We, the Free Software Foundation, use\nthe GNU General Public License for most of our software; it applies\nalso to any other work released this way by its authors. You can apply\nit to your programs, too.\n\nWhen we speak of free software, we are referring to freedom, not\nprice. Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\nTo protect your rights, we need to prevent others from denying you\nthese rights or asking you to surrender the rights. Therefore, you\nhave certain responsibilities if you distribute copies of the\nsoftware, or if you modify it: responsibilities to respect the freedom\nof others.\n\nFor example, if you distribute copies of such a program, whether\ngratis or for a fee, you must pass on to the recipients the same\nfreedoms that you received. You must make sure that they, too, receive\nor can get the source code. And you must show them these terms so they\nknow their rights.\n\nDevelopers that use the GNU GPL protect your rights with two steps:\n(1) assert copyright on the software, and (2) offer you this License\ngiving you legal permission to copy, distribute and/or modify it.\n\nFor the developers' and authors' protection, the GPL clearly explains\nthat there is no warranty for this free software. For both users' and\nauthors' sake, the GPL requires that modified versions be marked as\nchanged, so that their problems will not be attributed erroneously to\nauthors of previous versions.\n\nSome devices are designed to deny users access to install or run\nmodified versions of the software inside them, although the\nmanufacturer can do so. This is fundamentally incompatible with the\naim of protecting users' freedom to change the software. The\nsystematic pattern of such abuse occurs in the area of products for\nindividuals to use, which is precisely where it is most unacceptable.\nTherefore, we have designed this version of the GPL to prohibit the\npractice for those products. If such problems arise substantially in\nother domains, we stand ready to extend this provision to those\ndomains in future versions of the GPL, as needed to protect the\nfreedom of users.\n\nFinally, every program is threatened constantly by software patents.\nStates should not allow patents to restrict development and use of\nsoftware on general-purpose computers, but in those that do, we wish\nto avoid the special danger that patents applied to a free program\ncould make it effectively proprietary. To prevent this, the GPL\nassures that patents cannot be used to render the program non-free.\n\nThe precise terms and conditions for copying, distribution and\nmodification follow.\n\n## TERMS AND CONDITIONS\n\n### 0. Definitions.\n\n\"This License\" refers to version 3 of the GNU General Public License.\n\n\"Copyright\" also means copyright-like laws that apply to other kinds\nof works, such as semiconductor masks.\n\n\"The Program\" refers to any copyrightable work licensed under this\nLicense. Each licensee is addressed as \"you\". \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\nTo \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of\nan exact copy. The resulting work is called a \"modified version\" of\nthe earlier work or a work \"based on\" the earlier work.\n\nA \"covered work\" means either the unmodified Program or a work based\non the Program.\n\nTo \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy. Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\nTo \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies. Mere interaction with a user\nthrough a computer network, with no transfer of a copy, is not\nconveying.\n\nAn interactive user interface displays \"Appropriate Legal Notices\" to\nthe extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License. If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n### 1. Source Code.\n\nThe \"source code\" for a work means the preferred form of the work for\nmaking modifications to it. \"Object code\" means any non-source form of\na work.\n\nA \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\nThe \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form. A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\nThe \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities. However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work. For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\nThe Corresponding Source need not include anything that users can\nregenerate automatically from other parts of the Corresponding Source.\n\nThe Corresponding Source for a work in source code form is that same\nwork.\n\n### 2. Basic Permissions.\n\nAll rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met. This License explicitly affirms your unlimited\npermission to run the unmodified Program. The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work. This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\nYou may make, run and propagate covered works that you do not convey,\nwithout conditions so long as your license otherwise remains in force.\nYou may convey covered works to others for the sole purpose of having\nthem make modifications exclusively for you, or provide you with\nfacilities for running those works, provided that you comply with the\nterms of this License in conveying all material for which you do not\ncontrol copyright. Those thus making or running the covered works for\nyou must do so exclusively on your behalf, under your direction and\ncontrol, on terms that prohibit them from making any copies of your\ncopyrighted material outside their relationship with you.\n\nConveying under any other circumstances is permitted solely under the\nconditions stated below. Sublicensing is not allowed; section 10 makes\nit unnecessary.\n\n### 3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\nNo covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\nWhen you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such\ncircumvention is effected by exercising rights under this License with\nrespect to the covered work, and you disclaim any intention to limit\noperation or modification of the work as a means of enforcing, against\nthe work's users, your or third parties' legal rights to forbid\ncircumvention of technological measures.\n\n### 4. Conveying Verbatim Copies.\n\nYou may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\nYou may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n### 5. Conveying Modified Source Versions.\n\nYou may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these\nconditions:\n\n-   a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n-   b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under\n    section 7. This requirement modifies the requirement in section 4\n    to \"keep intact all notices\".\n-   c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy. This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged. This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n-   d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\nA compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit. Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n### 6. Conveying Non-Source Forms.\n\nYou may convey a covered work in object code form under the terms of\nsections 4 and 5, provided that you also convey the machine-readable\nCorresponding Source under the terms of this License, in one of these\nways:\n\n-   a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n-   b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the Corresponding\n    Source from a network server at no charge.\n-   c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source. This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n-   d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge. You need not require recipients to copy the\n    Corresponding Source along with the object code. If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source. Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n-   e) Convey the object code using peer-to-peer transmission,\n    provided you inform other peers where the object code and\n    Corresponding Source of the work are being offered to the general\n    public at no charge under subsection 6d.\n\nA separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\nA \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal,\nfamily, or household purposes, or (2) anything designed or sold for\nincorporation into a dwelling. In determining whether a product is a\nconsumer product, doubtful cases shall be resolved in favor of\ncoverage. For a particular product received by a particular user,\n\"normally used\" refers to a typical or common use of that class of\nproduct, regardless of the status of the particular user or of the way\nin which the particular user actually uses, or expects or is expected\nto use, the product. A product is a consumer product regardless of\nwhether the product has substantial commercial, industrial or\nnon-consumer uses, unless such uses represent the only significant\nmode of use of the product.\n\n\"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to\ninstall and execute modified versions of a covered work in that User\nProduct from a modified version of its Corresponding Source. The\ninformation must suffice to ensure that the continued functioning of\nthe modified object code is in no case prevented or interfered with\nsolely because modification has been made.\n\nIf you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information. But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\nThe requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or\nupdates for a work that has been modified or installed by the\nrecipient, or for the User Product in which it has been modified or\ninstalled. Access to a network may be denied when the modification\nitself materially and adversely affects the operation of the network\nor violates the rules and protocols for communication across the\nnetwork.\n\nCorresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n### 7. Additional Terms.\n\n\"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law. If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\nWhen you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit. (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.) You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\nNotwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders\nof that material) supplement the terms of this License with terms:\n\n-   a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n-   b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n-   c) Prohibiting misrepresentation of the origin of that material,\n    or requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n-   d) Limiting the use for publicity purposes of names of licensors\n    or authors of the material; or\n-   e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n-   f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions\n    of it) with contractual assumptions of liability to the recipient,\n    for any liability that these contractual assumptions directly\n    impose on those licensors and authors.\n\nAll other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10. If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term. If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\nIf you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\nAdditional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions; the\nabove requirements apply either way.\n\n### 8. Termination.\n\nYou may not propagate or modify a covered work except as expressly\nprovided under this License. Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\nHowever, if you cease all violation of this License, then your license\nfrom a particular copyright holder is reinstated (a) provisionally,\nunless and until the copyright holder explicitly and finally\nterminates your license, and (b) permanently, if the copyright holder\nfails to notify you of the violation by some reasonable means prior to\n60 days after the cessation.\n\nMoreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\nTermination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License. If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n### 9. Acceptance Not Required for Having Copies.\n\nYou are not required to accept this License in order to receive or run\na copy of the Program. Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance. However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work. These actions infringe copyright if you do\nnot accept this License. Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n### 10. Automatic Licensing of Downstream Recipients.\n\nEach time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License. You are not responsible\nfor enforcing compliance by third parties with this License.\n\nAn \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations. If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\nYou may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License. For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n### 11. Patents.\n\nA \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based. The\nwork thus licensed is called the contributor's \"contributor version\".\n\nA contributor's \"essential patent claims\" are all patent claims owned\nor controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version. For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\nEach contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\nIn the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement). To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\nIf you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients. \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\nIf, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\nA patent license is \"discriminatory\" if it does not include within the\nscope of its coverage, prohibits the exercise of, or is conditioned on\nthe non-exercise of one or more of the rights that are specifically\ngranted under this License. You may not convey a covered work if you\nare a party to an arrangement with a third party that is in the\nbusiness of distributing software, under which you make payment to the\nthird party based on the extent of your activity of conveying the\nwork, and under which the third party grants, to any of the parties\nwho would receive the covered work from you, a discriminatory patent\nlicense (a) in connection with copies of the covered work conveyed by\nyou (or copies made from those copies), or (b) primarily for and in\nconnection with specific products or compilations that contain the\ncovered work, unless you entered into that arrangement, or that patent\nlicense was granted, prior to 28 March 2007.\n\nNothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n### 12. No Surrender of Others' Freedom.\n\nIf conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License. If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under\nthis License and any other pertinent obligations, then as a\nconsequence you may not convey it at all. For example, if you agree to\nterms that obligate you to collect a royalty for further conveying\nfrom those to whom you convey the Program, the only way you could\nsatisfy both those terms and this License would be to refrain entirely\nfrom conveying the Program.\n\n### 13. Use with the GNU Affero General Public License.\n\nNotwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU Affero General Public License into a single\ncombined work, and to convey the resulting work. The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the special requirements of the GNU Affero General Public License,\nsection 13, concerning interaction through a network will apply to the\ncombination as such.\n\n### 14. Revised Versions of this License.\n\nThe Free Software Foundation may publish revised and/or new versions\nof the GNU General Public License from time to time. Such new versions\nwill be similar in spirit to the present version, but may differ in\ndetail to address new problems or concerns.\n\nEach version is given a distinguishing version number. If the Program\nspecifies that a certain numbered version of the GNU General Public\nLicense \"or any later version\" applies to it, you have the option of\nfollowing the terms and conditions either of that numbered version or\nof any later version published by the Free Software Foundation. If the\nProgram does not specify a version number of the GNU General Public\nLicense, you may choose any version ever published by the Free\nSoftware Foundation.\n\nIf the Program specifies that a proxy can decide which future versions\nof the GNU General Public License can be used, that proxy's public\nstatement of acceptance of a version permanently authorizes you to\nchoose that version for the Program.\n\nLater license versions may give you additional or different\npermissions. However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n### 15. Disclaimer of Warranty.\n\nTHERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT\nWARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND\nPERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE\nDEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR\nCORRECTION.\n\n### 16. Limitation of Liability.\n\nIN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR\nCONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,\nINCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES\nARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT\nNOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR\nLOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM\nTO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER\nPARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.\n\n### 17. Interpretation of Sections 15 and 16.\n\nIf the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\nEND OF TERMS AND CONDITIONS\n\n## How to Apply These Terms to Your New Programs\n\nIf you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these\nterms.\n\nTo do so, attach the following notices to the program. It is safest to\nattach them to the start of each source file to most effectively state\nthe exclusion of warranty; and each file should have at least the\n\"copyright\" line and a pointer to where the full notice is found.\n\n        <one line to give the program's name and a brief idea of what it does.>\n        Copyright (C) <year>  <name of author>\n\n        This program is free software: you can redistribute it and/or modify\n        it under the terms of the GNU General Public License as published by\n        the Free Software Foundation, either version 3 of the License, or\n        (at your option) any later version.\n\n        This program is distributed in the hope that it will be useful,\n        but WITHOUT ANY WARRANTY; without even the implied warranty of\n        MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n        GNU General Public License for more details.\n\n        You should have received a copy of the GNU General Public License\n        along with this program.  If not, see <https://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper\nmail.\n\nIf the program does terminal interaction, make it output a short\nnotice like this when it starts in an interactive mode:\n\n        <program>  Copyright (C) <year>  <name of author>\n        This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.\n        This is free software, and you are welcome to redistribute it\n        under certain conditions; type `show c' for details.\n\nThe hypothetical commands \\`show w' and \\`show c' should show the\nappropriate parts of the General Public License. Of course, your\nprogram's commands might be different; for a GUI interface, you would\nuse an \"about box\".\n\nYou should also get your employer (if you work as a programmer) or\nschool, if any, to sign a \"copyright disclaimer\" for the program, if\nnecessary. For more information on this, and how to apply and follow\nthe GNU GPL, see <https://www.gnu.org/licenses/>.\n\nThe GNU General Public License does not permit incorporating your\nprogram into proprietary programs. If your program is a subroutine\nlibrary, you may consider it more useful to permit linking proprietary\napplications with the library. If this is what you want to do, use the\nGNU Lesser General Public License instead of this License. But first,\nplease read <https://www.gnu.org/licenses/why-not-lgpl.html>."
  },
  {
    "path": "README.de.md",
    "content": "<div align=\"center\">\n  <img src=\"public/logo.svg\" alt=\"CloudCLI UI\" width=\"64\" height=\"64\">\n  <h1>Cloud CLI (auch bekannt als Claude Code UI)</h1>\n  <p>Eine Desktop- und Mobile-Oberfläche für <a href=\"https://docs.anthropic.com/en/docs/claude-code\">Claude Code</a>, <a href=\"https://docs.cursor.com/en/cli/overview\">Cursor CLI</a>, <a href=\"https://developers.openai.com/codex\">Codex</a> und <a href=\"https://geminicli.com/\">Gemini-CLI</a>.<br>Lokal oder remote nutzbar – verwalte deine aktiven Projekte und Sitzungen von überall.</p>\n</div>\n\n<p align=\"center\">\n  <a href=\"https://cloudcli.ai\">CloudCLI Cloud</a> · <a href=\"https://cloudcli.ai/docs\">Dokumentation</a> · <a href=\"https://discord.gg/buxwujPNRE\">Discord</a> · <a href=\"https://github.com/siteboon/claudecodeui/issues\">Fehler melden</a> · <a href=\"CONTRIBUTING.md\">Mitwirken</a>\n</p>\n\n<p align=\"center\">\n  <a href=\"https://cloudcli.ai\"><img src=\"https://img.shields.io/badge/☁️_CloudCLI_Cloud-Try_Now-0066FF?style=for-the-badge\" alt=\"CloudCLI Cloud\"></a>\n  <a href=\"https://discord.gg/buxwujPNRE\"><img src=\"https://img.shields.io/badge/Discord-Join_Community-5865F2?style=for-the-badge&logo=discord&logoColor=white\" alt=\"Join Community\"></a>\n  <br><br>\n  <a href=\"https://trendshift.io/repositories/15586\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/15586\" alt=\"siteboon%2Fclaudecodeui | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n</p>\n\n<div align=\"right\"><i><a href=\"./README.md\">English</a> · <a href=\"./README.ru.md\">Русский</a> · <b>Deutsch</b> · <a href=\"./README.ko.md\">한국어</a> · <a href=\"./README.zh-CN.md\">中文</a> · <a href=\"./README.ja.md\">日本語</a></i></div>\n\n---\n\n## Screenshots\n\n<div align=\"center\">\n\n<table>\n<tr>\n<td align=\"center\">\n<h3>Desktop-Ansicht</h3>\n<img src=\"public/screenshots/desktop-main.png\" alt=\"Desktop-Oberfläche\" width=\"400\">\n<br>\n<em>Hauptoberfläche mit Projektübersicht und Chat</em>\n</td>\n<td align=\"center\">\n<h3>Mobile-Erfahrung</h3>\n<img src=\"public/screenshots/mobile-chat.png\" alt=\"Mobile-Oberfläche\" width=\"250\">\n<br>\n<em>Responsives mobiles Design mit Touch-Navigation</em>\n</td>\n</tr>\n<tr>\n<td align=\"center\" colspan=\"2\">\n<h3>CLI-Auswahl</h3>\n<img src=\"public/screenshots/cli-selection.png\" alt=\"CLI-Auswahl\" width=\"400\">\n<br>\n<em>Wähle zwischen Claude Code, Gemini, Cursor CLI und Codex</em>\n</td>\n</tr>\n</table>\n\n\n\n</div>\n\n## Funktionen\n\n- **Responsives Design** – Funktioniert nahtlos auf Desktop, Tablet und Mobilgerät, sodass du Agents auch vom Smartphone aus nutzen kannst\n- **Interaktives Chat-Interface** – Eingebaute Chat-Oberfläche für die reibungslose Kommunikation mit den Agents\n- **Integriertes Shell-Terminal** – Direkter Zugriff auf die Agents CLI über die eingebaute Shell-Funktionalität\n- **Datei-Explorer** – Interaktiver Dateibaum mit Syntaxhervorhebung und Live-Bearbeitung\n- **Git-Explorer** – Änderungen anzeigen, stagen und committen. Branches wechseln ebenfalls möglich\n- **Sitzungsverwaltung** – Gespräche fortsetzen, mehrere Sitzungen verwalten und Verlauf nachverfolgen\n- **Plugin-System** – CloudCLI mit eigenen Plugins erweitern – neue Tabs, Backend-Dienste und Integrationen hinzufügen. [Eigenes Plugin erstellen →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)\n- **TaskMaster AI Integration** *(Optional)* – Erweitertes Projektmanagement mit KI-gestützter Aufgabenplanung, PRD-Parsing und Workflow-Automatisierung\n- **Modell-Kompatibilität** – Funktioniert mit Claude, GPT und Gemini (vollständige Liste unterstützter Modelle in [`shared/modelConstants.js`](shared/modelConstants.js))\n\n\n## Schnellstart\n\n### CloudCLI Cloud (Empfohlen)\n\nDer schnellste Einstieg – keine lokale Einrichtung erforderlich. Erhalte eine vollständig verwaltete, containerisierte Entwicklungsumgebung, die über Web, Mobile App, API oder deine bevorzugte IDE erreichbar ist.\n\n**[Mit CloudCLI Cloud starten](https://cloudcli.ai)**\n\n\n### Self-Hosted (Open Source)\n\nCloudCLI UI sofort mit **npx** ausprobieren (erfordert **Node.js** v22+):\n\n```bash\nnpx @siteboon/claude-code-ui\n```\n\nOder **global** installieren für regelmäßige Nutzung:\n\n```bash\nnpm install -g @siteboon/claude-code-ui\ncloudcli\n```\n\nÖffne `http://localhost:3001` – alle vorhandenen Sitzungen werden automatisch erkannt.\n\nDie **[Dokumentation →](https://cloudcli.ai/docs)** enthält weitere Konfigurationsoptionen, PM2, Remote-Server-Einrichtung und mehr.\n\n\n---\n\n## Welche Option passt zu dir?\n\nCloudCLI UI ist die Open-Source-UI-Schicht, die CloudCLI Cloud antreibt. Du kannst es auf deinem eigenen Rechner selbst hosten oder CloudCLI Cloud nutzen, das darauf aufbaut und eine vollständig verwaltete Cloud-Umgebung, Team-Funktionen und tiefere Integrationen bietet.\n\n| | CloudCLI UI (Self-hosted) | CloudCLI Cloud |\n|---|---|---|\n| **Am besten für** | Entwickler:innen, die eine vollständige UI für lokale Agent-Sitzungen auf ihrem eigenen Rechner möchten | Teams und Entwickler:innen, die Agents in der Cloud betreiben möchten, überall erreichbar |\n| **Zugriff** | Browser via `[deineIP]:port` | Browser, jede IDE, REST API, n8n |\n| **Einrichtung** | `npx @siteboon/claude-code-ui` | Keine Einrichtung erforderlich |\n| **Rechner muss laufen** | Ja | Nein |\n| **Mobiler Zugriff** | Jeder Browser im Netzwerk | Jedes Gerät, native App in Entwicklung |\n| **Verfügbare Sitzungen** | Alle Sitzungen automatisch aus `~/.claude` erkannt | Alle Sitzungen in deiner Cloud-Umgebung |\n| **Unterstützte Agents** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI |\n| **Datei-Explorer und Git** | Ja, direkt in der UI | Ja, direkt in der UI |\n| **MCP-Konfiguration** | Über UI verwaltet, synchronisiert mit lokalem `~/.claude` | Über UI verwaltet |\n| **IDE-Zugriff** | Deine lokale IDE | Jede IDE, die mit deiner Cloud-Umgebung verbunden ist |\n| **REST API** | Ja | Ja |\n| **n8n-Node** | Nein | Ja |\n| **Team-Sharing** | Nein | Ja |\n| **Plattformkosten** | Kostenlos, Open Source | Ab $7/Monat |\n\n> Beide Optionen verwenden deine eigenen KI-Abonnements (Claude, Cursor usw.) – CloudCLI stellt die Umgebung bereit, nicht die KI.\n\n---\n\n## Sicherheit & Tool-Konfiguration\n\n**🔒 Wichtiger Hinweis**: Alle Claude Code Tools sind **standardmäßig deaktiviert**. Dies verhindert, dass potenziell schädliche Operationen automatisch ausgeführt werden.\n\n### Tools aktivieren\n\nUm den vollen Funktionsumfang von Claude Code zu nutzen, müssen Tools manuell aktiviert werden:\n\n1. **Tool-Einstellungen öffnen** – Klicke auf das Zahnrad-Symbol in der Seitenleiste\n2. **Selektiv aktivieren** – Nur die benötigten Tools einschalten\n3. **Einstellungen übernehmen** – Deine Einstellungen werden lokal gespeichert\n\n<div align=\"center\">\n\n![Tool-Einstellungen Modal](public/screenshots/tools-modal.png)\n*Tool-Einstellungen – nur aktivieren, was benötigt wird*\n\n</div>\n\n**Empfohlene Vorgehensweise**: Mit grundlegenden Tools starten und bei Bedarf weitere hinzufügen. Die Einstellungen können jederzeit angepasst werden.\n\n---\n\n## Plugins\n\nCloudCLI verfügt über ein Plugin-System, mit dem benutzerdefinierte Tabs mit eigener Frontend-UI und optionalem Node.js-Backend hinzugefügt werden können. Plugins können direkt in **Einstellungen > Plugins** aus Git-Repos installiert oder selbst entwickelt werden.\n\n### Verfügbare Plugins\n\n| Plugin | Beschreibung |\n|---|---|\n| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Zeigt Dateianzahl, Codezeilen, Dateityp-Aufschlüsselung, größte Dateien und zuletzt geänderte Dateien des aktuellen Projekts |\n\n### Eigenes Plugin erstellen\n\n**[Plugin-Starter-Vorlage →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** – Forke dieses Repository, um ein eigenes Plugin zu erstellen. Es enthält ein funktionierendes Beispiel mit Frontend-Rendering, Live-Kontext-Updates und RPC-Kommunikation zu einem Backend-Server.\n\n**[Plugin-Dokumentation →](https://cloudcli.ai/docs/plugin-overview)** – Vollständige Anleitung zur Plugin-API, zum Manifest-Format, zum Sicherheitsmodell und mehr.\n\n---\n## FAQ\n\n<details>\n<summary>Wie unterscheidet sich das von Claude Code Remote Control?</summary>\n\nClaude Code Remote Control ermöglicht es, Nachrichten an eine bereits im lokalen Terminal laufende Sitzung zu senden. Der Rechner muss eingeschaltet bleiben, das Terminal muss offen bleiben, und Sitzungen laufen nach etwa 10 Minuten ohne Netzwerkverbindung ab.\n\nCloudCLI UI und CloudCLI Cloud erweitern Claude Code, anstatt neben ihm zu laufen – MCP-Server, Berechtigungen, Einstellungen und Sitzungen sind exakt dieselben, die Claude Code nativ verwendet. Nichts wird dupliziert oder separat verwaltet.\n\nDas bedeutet in der Praxis:\n\n- **Alle Sitzungen, nicht nur eine** – CloudCLI UI erkennt automatisch jede Sitzung aus dem `~/.claude`-Ordner. Remote Control stellt nur die einzelne aktive Sitzung bereit, um sie in der Claude Mobile App verfügbar zu machen.\n- **Deine Einstellungen sind deine Einstellungen** – MCP-Server, Tool-Berechtigungen und Projektkonfiguration, die in CloudCLI UI geändert werden, werden direkt in die Claude Code-Konfiguration geschrieben und treten sofort in Kraft – und umgekehrt.\n- **Funktioniert mit mehr Agents** – Claude Code, Cursor CLI, Codex und Gemini CLI, nicht nur Claude Code.\n- **Vollständige UI, nicht nur ein Chat-Fenster** – Datei-Explorer, Git-Integration, MCP-Verwaltung und ein Shell-Terminal sind alle eingebaut.\n- **CloudCLI Cloud läuft in der Cloud** – Laptop zuklappen, der Agent läuft weiter. Kein Terminal zu überwachen, kein Rechner, der laufen muss.\n\n</details>\n\n<details>\n<summary>Muss ich ein KI-Abonnement separat bezahlen?</summary>\n\nJa. CloudCLI stellt die Umgebung bereit, nicht die KI. Du bringst dein eigenes Claude-, Cursor-, Codex- oder Gemini-Abonnement mit. CloudCLI Cloud beginnt bei $7/Monat für die gehostete Umgebung zusätzlich dazu.\n\n</details>\n\n<details>\n<summary>Kann ich CloudCLI UI auf meinem Smartphone nutzen?</summary>\n\nJa. Bei Self-Hosted: Server auf dem eigenen Rechner starten und `[deineIP]:port` in einem beliebigen Browser im Netzwerk öffnen. Bei CloudCLI Cloud: Von jedem Gerät aus öffnen – kein VPN, keine Portweiterleitung, keine Einrichtung. Eine native App ist ebenfalls in Entwicklung.\n\n</details>\n\n<details>\n<summary>Wirken sich Änderungen in der UI auf mein lokales Claude Code-Setup aus?</summary>\n\nJa, bei Self-Hosted. CloudCLI UI liest aus und schreibt in dieselbe `~/.claude`-Konfiguration, die Claude Code nativ verwendet. MCP-Server, die über die UI hinzugefügt werden, erscheinen sofort in Claude Code und umgekehrt.\n\n</details>\n\n---\n\n## Community & Support\n\n- **[Dokumentation](https://cloudcli.ai/docs)** — Installation, Konfiguration, Funktionen und Fehlerbehebung\n- **[Discord](https://discord.gg/buxwujPNRE)** — Hilfe erhalten und mit anderen Nutzer:innen in Kontakt treten\n- **[GitHub Issues](https://github.com/siteboon/claudecodeui/issues)** — Fehlerberichte und Feature-Anfragen\n- **[Beitragsrichtlinien](CONTRIBUTING.md)** — So kannst du zum Projekt beitragen\n\n## Lizenz\n\nGNU General Public License v3.0 – siehe [LICENSE](LICENSE)-Datei für Details.\n\nDieses Projekt ist Open Source und kann unter der GPL v3-Lizenz kostenlos genutzt, modifiziert und verteilt werden.\n\n## Danksagungen\n\n### Erstellt mit\n- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropics offizielle CLI\n- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursors offizielle CLI\n- **[Codex](https://developers.openai.com/codex)** - OpenAI Codex\n- **[Gemini-CLI](https://geminicli.com/)** - Google Gemini CLI\n- **[React](https://react.dev/)** - UI-Bibliothek\n- **[Vite](https://vitejs.dev/)** - Schnelles Build-Tool und Dev-Server\n- **[Tailwind CSS](https://tailwindcss.com/)** - Utility-first CSS-Framework\n- **[CodeMirror](https://codemirror.net/)** - Erweiterter Code-Editor\n- **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** *(Optional)* - KI-gestütztes Projektmanagement und Aufgabenplanung\n\n\n### Sponsoren\n- [Siteboon - KI-gestützter Website-Builder](https://siteboon.ai)\n---\n\n<div align=\"center\">\n  <strong>Mit Sorgfalt für die Claude Code-, Cursor- und Codex-Community erstellt.</strong>\n</div>\n"
  },
  {
    "path": "README.ja.md",
    "content": "<div align=\"center\">\n  <img src=\"public/logo.svg\" alt=\"CloudCLI UI\" width=\"64\" height=\"64\">\n  <h1>Cloud CLI（別名 Claude Code UI）</h1>\n  <p><a href=\"https://docs.anthropic.com/en/docs/claude-code\">Claude Code</a>、<a href=\"https://docs.cursor.com/en/cli/overview\">Cursor CLI</a>、<a href=\"https://developers.openai.com/codex\">Codex</a>、<a href=\"https://geminicli.com/\">Gemini-CLI</a> のためのデスクトップ／モバイル UI。<br>ローカルでもリモートでも使え、アクティブなプロジェクトとセッションをどこからでも閲覧できます。</p>\n</div>\n\n<p align=\"center\">\n  <a href=\"https://cloudcli.ai\">CloudCLI Cloud</a> · <a href=\"https://cloudcli.ai/docs\">ドキュメント</a> · <a href=\"https://discord.gg/buxwujPNRE\">Discord</a> · <a href=\"https://github.com/siteboon/claudecodeui/issues\">バグ報告</a> · <a href=\"CONTRIBUTING.md\">コントリビュート</a>\n</p>\n\n<p align=\"center\">\n  <a href=\"https://cloudcli.ai\"><img src=\"https://img.shields.io/badge/☁️_CloudCLI_Cloud-Try_Now-0066FF?style=for-the-badge\" alt=\"CloudCLI Cloud\"></a>\n  <a href=\"https://discord.gg/buxwujPNRE\"><img src=\"https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=for-the-badge&logo=discord&logoColor=white\" alt=\"Discord コミュニティに参加\"></a>\n  <br><br>\n  <a href=\"https://trendshift.io/repositories/15586\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/15586\" alt=\"siteboon%2Fclaudecodeui | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n</p>\n\n<div align=\"right\"><i><a href=\"./README.md\">English</a> · <a href=\"./README.ru.md\">Русский</a> · <a href=\"./README.de.md\">Deutsch</a> · <a href=\"./README.ko.md\">한국어</a> · <a href=\"./README.zh-CN.md\">中文</a> · <b>日本語</b></i></div>\n\n---\n\n## スクリーンショット\n\n<div align=\"center\">\n\n<table>\n<tr>\n<td align=\"center\">\n<h3>デスクトップビュー</h3>\n<img src=\"public/screenshots/desktop-main.png\" alt=\"デスクトップインターフェース\" width=\"400\">\n<br>\n<em>プロジェクト概要とチャットを表示するメイン画面</em>\n</td>\n<td align=\"center\">\n<h3>モバイル体験</h3>\n<img src=\"public/screenshots/mobile-chat.png\" alt=\"モバイルインターフェース\" width=\"250\">\n<br>\n<em>タッチ操作に対応したレスポンシブなモバイルデザイン</em>\n</td>\n</tr>\n<tr>\n<td align=\"center\" colspan=\"2\">\n<h3>CLI 選択</h3>\n<img src=\"public/screenshots/cli-selection.png\" alt=\"CLI 選択\" width=\"400\">\n<br>\n<em>Claude Code、Gemini、Cursor CLI、Codex から選択</em>\n</td>\n</tr>\n</table>\n\n\n\n</div>\n\n## 機能\n\n- **レスポンシブデザイン** - デスクトップ／タブレット／モバイルでシームレスに動作し、モバイルからも Agents を利用可能\n- **インタラクティブチャット UI** - Agents とスムーズにやり取りできる内蔵チャット UI\n- **統合シェルターミナル** - 内蔵シェル機能で Agents の CLI に直接アクセス\n- **ファイルエクスプローラー** - シンタックスハイライトとライブ編集に対応したインタラクティブなファイルツリー\n- **Git エクスプローラー** - 変更の表示、ステージ、コミット。ブランチ切り替えも可能\n- **セッション管理** - 会話の再開、複数セッションの管理、履歴の追跡\n- **プラグインシステム** - カスタムプラグインで CloudCLI を拡張 — 新しいタブ、バックエンドサービス、連携を追加できます。[自分で構築する →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)\n\n## クイックスタート\n\n### CloudCLI Cloud（推奨）\n\n最速で始める方法 — ローカルのセットアップは不要です。Web、モバイルアプリ、API、またはお気に入りの IDE からアクセスできる、フルマネージドでコンテナ化された開発環境を利用できます。\n\n**[CloudCLI Cloud を始める](https://cloudcli.ai)**\n\n### セルフホスト（オープンソース）\n\n**npx** で今すぐ CloudCLI UI を試せます（**Node.js** v22+ が必要）：\n\n```bash\nnpx @siteboon/claude-code-ui\n```\n\nまたは、普段使いするなら **グローバル** にインストール：\n\n```bash\nnpm install -g @siteboon/claude-code-ui\ncloudcli\n```\n\n`http://localhost:3001` を開いてください — 既存のセッションは自動的に検出されます。\n\nより詳細な設定オプション、PM2、リモートサーバー設定などについては **[ドキュメントはこちら →](https://cloudcli.ai/docs)** を参照してください。\n\n\n---\n\n## どちらの選択肢が適していますか？\n\nCloudCLI UI は、CloudCLI Cloud を支えるオープンソースの UI レイヤーです。自分のマシンにセルフホストすることも、フルマネージドのクラウド環境、チーム機能、より深い統合を備えた CloudCLI Cloud を使うこともできます。\n\n| | CloudCLI UI（セルフホスト） | CloudCLI Cloud |\n|---|---|---|\n| **対象ユーザー** | 自分のマシン上でローカルの agent セッションに対してフル UI を使いたい開発者 | クラウド上で動く agents をどこからでも利用したいチーム／開発者 |\n| **アクセス方法** | ブラウザ（`[yourip]:port`） | ブラウザ、任意の IDE、REST API、n8n |\n| **セットアップ** | `npx @siteboon/claude-code-ui` | セットアップ不要 |\n| **マシンの稼働継続** | はい | いいえ |\n| **モバイルアクセス** | 同一ネットワーク内の任意のブラウザ | 任意のデバイス（ネイティブアプリも準備中） |\n| **利用可能なセッション** | `~/.claude` から全セッションを自動検出 | クラウド環境内の全セッション |\n| **対応エージェント** | Claude Code、Cursor CLI、Codex、Gemini CLI | Claude Code、Cursor CLI、Codex、Gemini CLI |\n| **ファイルエクスプローラとGit** | はい（UI に内蔵） | はい（UI に内蔵） |\n| **MCP設定** | UI で管理し、ローカルの `~/.claude` 設定と同期 | UI で管理 |\n| **IDEアクセス** | ローカル IDE | クラウド環境に接続された任意の IDE |\n| **REST API** | はい | はい |\n| **n8n ノード** | いいえ | はい |\n| **チーム共有** | いいえ | はい |\n| **料金プラン** | 無料（オープンソース） | 月 $7〜 |\n\n> どちらの選択肢でも、AI のサブスクリプション（Claude、Cursor など）はご自身のものを使用します — CloudCLI が提供するのは環境であり、AI そのものではありません。\n\n---\n\n## セキュリティとツール設定\n\n**🔒 重要なお知らせ** すべての Claude Code ツールは **デフォルトで無効** です。これにより、潜在的に有害な操作が自動的に実行されることを防ぎます。\n\n### ツールの有効化\n\n1. **ツール設定を開く** - サイドバーの歯車アイコンをクリック\n2. **必要なツールだけを選んで有効化** - 本当に使うものだけをオンにする\n3. **設定を適用** - 設定内容はローカルに保存されます\n\n<div align=\"center\">\n\n![ツール設定モーダル](public/screenshots/tools-modal.png)\n*Tools 設定画面 - 必要なものだけを有効にしてください*\n\n</div>\n\n**推奨アプローチ**: まずは基本ツールだけを有効にし、必要に応じて追加してください。これらの設定は後からいつでも調整できます。\n\n---\n\n## プラグイン\n\nCloudCLI にはプラグインシステムがあり、独自のフロントエンド UI と（必要に応じて）Node.js バックエンドを持つカスタムタブを追加できます。プラグインは **Settings > Plugins** から git リポジトリを直接指定してインストールするか、自作できます。\n\n### 利用可能なプラグイン\n\n| プラグイン | 説明 |\n|---|---|\n| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 現在のプロジェクトについて、ファイル数、コード行数、ファイル種別の内訳、最大ファイル、最近変更されたファイルを表示 |\n\n### 自作する\n\n**[Plugin Starter Template →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** — このリポジトリを fork して独自プラグインを作れます。フロントエンド描画、ライブコンテキスト更新、バックエンドサーバーへの RPC 通信を含む動作例が入っています。\n\n**[プラグインのドキュメント →](https://cloudcli.ai/docs/plugin-overview)** — プラグイン API、manifest 形式、セキュリティモデルなどの完全ガイド。\n\n---\n## FAQ\n\n<details>\n<summary>Claude Code Remote Control とはどう違いますか？</summary>\n\nClaude Code Remote Control は、ローカル端末で既に動作しているセッションへメッセージを送れる仕組みです。マシンを起動したままにし、端末も開いたままにする必要があり、ネットワーク接続がない状態が約 10 分続くとセッションがタイムアウトします。\n\nCloudCLI UI と CloudCLI Cloud は、Claude Code の横に別物として存在するのではなく、Claude Code を拡張します — MCP サーバー、権限、設定、セッションは Claude Code がネイティブに使うものと完全に同一です。複製したり、別系統で管理したりしません。\n\n- **すべてのセッションにアクセス** — CloudCLI UI は `~/.claude` フォルダのすべてのセッションを自動検出します。Remote Control は、Claude モバイルアプリで利用可能にするため、1つのアクティブセッションだけを公開します。\n- **設定はあなたの設定** — CloudCLI UI で変更した MCP サーバー、ツール権限、プロジェクト構成は、Claude Code の設定に直接書き込まれて即座に反映され、その逆（Claude Code での変更が UI に反映）も同様です。\n- **対応エージェントがさらに充実** — Claude Code に加えて Cursor CLI、Codex、Gemini CLI にも対応しています。\n- **チャット窓だけではない完全な UI** — ファイルエクスプローラー、Git 統合、MCP 管理、シェル端末などがすべて組み込まれています。\n- **CloudCLI Cloud はクラウド上で稼働** — ノートパソコンを閉じてもエージェントは動き続けます。監視が要る端末も、スリープ防止も不要です。\n\n</details>\n\n<details>\n<summary>AI のサブスクリプションは別途支払いが必要ですか？</summary>\n\nはい。CloudCLI は環境を提供するものであり、AI は含まれません。Claude、Cursor、Codex、または Gemini のサブスクリプションはご自身でご用意ください。CloudCLI Cloud のホスティング環境はそれに加えて月額 $7 から提供されます。\n\n</details>\n\n<details>\n<summary>CloudCLI UI をスマホで使えますか？</summary>\n\nはい。セルフホストの場合は、自身のマシンでサーバーを起動し、ネットワーク内のブラウザで `[yourip]:port` を開いてください。CloudCLI Cloud を使う場合は、任意のデバイスからアクセスできます。VPN もポートフォワーディングも不要で、セットアップも不要です。ネイティブアプリも開発中です。\n\n</details>\n\n<details>\n<summary>UI で加えた変更はローカルの Claude Code 設定に影響しますか？</summary>\n\nはい、セルフホストの場合です。CloudCLI UI は Claude Code がネイティブに使う `~/.claude` 設定を読み書きします。UI から追加した MCP サーバーは即座に Claude Code に反映され、その逆も同様です。\n\n</details>\n\n---\n\n## コミュニティとサポート\n\n- **[ドキュメント](https://cloudcli.ai/docs)** — インストール、設定、機能、トラブルシューティング\n- **[Discord](https://discord.gg/buxwujPNRE)** — ヘルプを得たり、ユーザー同士で交流したりできます\n- **[GitHub Issues](https://github.com/siteboon/claudecodeui/issues)** — バグ報告と機能要望\n- **[コントリビューションガイド](CONTRIBUTING.md)** — プロジェクトへの貢献方法\n\n## ライセンス\n\nGNU General Public License v3.0 - 詳細は [LICENSE](LICENSE) ファイルを参照してください。\n\nこのプロジェクトはオープンソースであり、GPL v3 ライセンスの下で無料で使用、修正、再配布できます。\n\n## 謝辞\n\n### 使用技術\n\n- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropic の公式 CLI\n- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursor の公式 CLI\n- **[Codex](https://developers.openai.com/codex)** - OpenAI Codex\n- **[Gemini-CLI](https://geminicli.com/)** - Google Gemini CLI\n- **[React](https://react.dev/)** - ユーザーインターフェースライブラリ\n- **[Vite](https://vitejs.dev/)** - 高速ビルドツールと開発サーバー\n- **[Tailwind CSS](https://tailwindcss.com/)** - ユーティリティファーストの CSS フレームワーク\n- **[CodeMirror](https://codemirror.net/)** - 高度なコードエディタ\n- **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** *(オプション)* - AI を活用したプロジェクト管理とタスク計画\n\n## スポンサー\n- [Siteboon - AI を活用したウェブサイトビルダー](https://siteboon.ai)\n---\n\n<div align=\"center\">\n  <strong>Claude Code、Cursor、Codex コミュニティのために心を込めて作りました。</strong>\n</div>\n"
  },
  {
    "path": "README.ko.md",
    "content": "<div align=\"center\">\n  <img src=\"public/logo.svg\" alt=\"CloudCLI UI\" width=\"64\" height=\"64\">\n  <h1>Cloud CLI (일명 Claude Code UI)</h1>\n  <p><a href=\"https://docs.anthropic.com/en/docs/claude-code\">Claude Code</a>, <a href=\"https://docs.cursor.com/en/cli/overview\">Cursor CLI</a>, <a href=\"https://developers.openai.com/codex\">Codex</a>, <a href=\"https://geminicli.com/\">Gemini-CLI</a> 용 데스크톱 및 모바일 UI입니다.<br>로컬 또는 원격에서 실행하여 어디서나 활성 프로젝트와 세션을 확인하세요.</p>\n</div>\n\n<p align=\"center\">\n  <a href=\"https://cloudcli.ai\">CloudCLI Cloud</a> · <a href=\"https://cloudcli.ai/docs\">문서</a> · <a href=\"https://discord.gg/buxwujPNRE\">Discord</a> · <a href=\"https://github.com/siteboon/claudecodeui/issues\">버그 신고</a> · <a href=\"CONTRIBUTING.md\">기여 안내</a>\n</p>\n\n<p align=\"center\">\n  <a href=\"https://cloudcli.ai\"><img src=\"https://img.shields.io/badge/☁️_CloudCLI_Cloud-Try_Now-0066FF?style=for-the-badge\" alt=\"CloudCLI Cloud\"></a>\n  <a href=\"https://discord.gg/buxwujPNRE\"><img src=\"https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=for-the-badge&logo=discord&logoColor=white\" alt=\"Discord 커뮤니티\"></a>\n  <br><br>\n  <a href=\"https://trendshift.io/repositories/15586\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/15586\" alt=\"siteboon%2Fclaudecodeui | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n</p>\n\n<div align=\"right\"><i><a href=\"./README.md\">English</a> · <a href=\"./README.ru.md\">Русский</a> · <a href=\"./README.de.md\">Deutsch</a> · <b>한국어</b> · <a href=\"./README.zh-CN.md\">中文</a> · <a href=\"./README.ja.md\">日本語</a></i></div>\n\n---\n\n## 스크린샷\n\n<div align=\"center\">\n\n<table>\n<tr>\n<td align=\"center\">\n<h3>데스크톱 보기</h3>\n<img src=\"public/screenshots/desktop-main.png\" alt=\"데스크톱 인터페이스\" width=\"400\">\n<br>\n<em>프로젝트 개요와 채팅을 보여주는 메인 인터페이스</em>\n</td>\n<td align=\"center\">\n<h3>모바일 경험</h3>\n<img src=\"public/screenshots/mobile-chat.png\" alt=\"모바일 인터페이스\" width=\"250\">\n<br>\n<em>터치 내비게이션이 포함된 반응형 모바일 디자인</em>\n</td>\n</tr>\n<tr>\n<td align=\"center\" colspan=\"2\">\n<h3>CLI 선택</h3>\n<img src=\"public/screenshots/cli-selection.png\" alt=\"CLI 선택\" width=\"400\">\n<br>\n<em>Claude Code, Gemini, Cursor CLI 및 Codex 중 선택</em>\n</td>\n</tr>\n</table>\n\n</div>\n\n## 기능\n\n- **반응형 디자인** - 데스크톱, 태블릿, 모바일을 아우르는 매끄러운 경험으로 어디서든 Agents를 사용할 수 있습니다\n- **대화형 채팅 인터페이스** - 내장된 채팅 UI를 통해 에이전트와 자연스럽게 소통\n- **통합 셸 터미널** - 셸 기능을 통해 Agents CLI에 직접 접근\n- **파일 탐색기** - 구문 강조 및 실시간 편집을 갖춘 인터랙티브 파일 트리\n- **Git 탐색기** - 변경 사항 보기, 스테이징 및 커밋. 브랜치 전환 기능 포함\n- **세션 관리** - 대화를 재개하고, 여러 세션을 관리하며 기록을 추적\n- **플러그인 시스템** - 커스텀 탭, 백엔드 서비스, 통합을 추가하여 CloudCLI 확장. [직접 빌드 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)\n- **TaskMaster AI 통합** *(선택사항)* - AI 중심의 작업 계획, PRD 파싱, 워크플로 자동화를 통한 고급 프로젝트 관리\n- **모델 호환성** - Claude, GPT, Gemini 모델 계열에서 작동 (`shared/modelConstants.js`에서 전체 지원 모델 확인)\n\n## 빠른 시작\n\n### CloudCLI Cloud (추천)\n\n가장 빠르게 시작하는 방법 — 로컬 설정 없이도 가능합니다. 웹, 모바일 앱, API 또는 선호하는 IDE에서 이용할 수 있는 완전 관리형 컨테이너화된 개발 환경을 제공합니다.\n\n**[CloudCLI Cloud 시작하기](https://cloudcli.ai)**\n\n### 셀프 호스트 (오픈 소스)\n\n**npx**로 즉시 CloudCLI UI를 실행하세요 (Node.js v22+ 필요):\n\n```bash\nnpx @siteboon/claude-code-ui\n```\n\n**정기적으로 사용한다면 전역 설치:**\n\n```bash\nnpm install -g @siteboon/claude-code-ui\ncloudcli\n```\n\n`http://localhost:3001`을 열면 기존 세션이 자동으로 발견됩니다.\n\n자세한 구성 옵션, PM2, 원격 서버 설정 등은 **[문서 →](https://cloudcli.ai/docs)**를 참고하세요\n\n---\n\n## 어느 옵션이 적합한가요?\n\nCloudCLI UI는 CloudCLI Cloud를 구동하는 오픈 소스 UI 계층입니다. 로컬 머신에서 직접 셀프 호스트하거나, CloudCLI Cloud(완전 관리형 클라우드 환경, 팀 기능, 심화 통합 제공)를 사용할 수 있습니다.\n\n| | CloudCLI UI (셀프 호스트) | CloudCLI Cloud |\n|---|---|---|\n| **적합한 대상** | 로컬 에이전트 세션을 위한 전체 UI가 필요한 개발자 | 어디서든 접근 가능한 클라우드에서 에이전트를 운영하고자 하는 팀 및 개발자 |\n| **접근 방법** | `[yourip]:port`를 통해 브라우저 접속 | 브라우저, IDE, REST API, n8n |\n| **설정** | `npx @siteboon/claude-code-ui` | 설정 불필요 |\n| **기기 유지 필요 여부** | 예 (머신 켜둬야 함) | 아니오 |\n| **모바일 접근** | 네트워크 내 브라우저 | 모든 기기 (네이티브 앱 예정) |\n| **세션 접근** | `~/.claude`에서 자동 발견 | 클라우드 환경 내 세션 |\n| **지원 에이전트** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI |\n| **파일 탐색기 및 Git** | UI에 통합됨 | UI에 통합됨 |\n| **MCP 구성** | UI에서 관리, 로컬 `~/.claude` 설정과 동기화됨 | UI에서 관리 |\n| **IDE 접근** | 로컬 IDE | 클라우드 환경에 연결된 모든 IDE |\n| **REST API** | 예 | 예 |\n| **n8n 노드** | 아니오 | 예 |\n| **팀 공유** | 아니오 | 예 |\n| **플랫폼 비용** | 무료, 오픈 소스 | 월 $7부터 |\n\n> 둘 다 자체 AI 구독(Claude, Cursor 등)을 그대로 사용합니다 — CloudCLI는 환경만 제공합니다.\n\n---\n\n## 보안 및 도구 구성\n\n**🔒 중요 공지**: 모든 Claude Code 도구는 **기본적으로 비활성화**되어 있습니다. 이는 잠재적인 유해 작업이 자동 실행되는 것을 방지하기 위한 조치입니다.\n\n### 도구 활성화\n\n1. **도구 설정 열기** - 사이드바의 톱니바퀴 아이콘 클릭\n2. **선택적으로 활성화** - 필요한 도구만 켜기\n3. **설정 적용** - 선호도는 로컬에 저장됨\n\n<div align=\"center\">\n\n![도구 설정 모달](public/screenshots/tools-modal.png)\n*도구 설정 인터페이스 - 필요한 것만 켜세요*\n\n</div>\n\n**권장 방법**: 기본 도구를 먼저 켜고 필요할 때 추가하세요. 언제든지 조정 가능합니다.\n\n---\n\n## 플러그인\n\nCloudCLI는 커스텀 탭과 선택적 Node.js 백엔드가 포함된 플러그인 시스템을 제공합니다. Settings > Plugins에서 Git 저장소에서 플러그인을 설치하거나 직접 빌드할 수 있습니다.\n\n### 이용 가능한 플러그인\n\n| 플러그인 | 설명 |\n|---|---|\n| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 현재 프로젝트의 파일 수, 코드 줄 수, 파일 유형 분포, 가장 큰 파일, 최근 수정 파일을 표시 |\n\n### 직접 만들기\n\n**[Plugin Starter Template →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** — 이 저장소를 포크하여 플러그인 구축. 프런트엔드 렌더링, 실시간 컨텍스트 업데이트, RPC 통신 예제 포함.\n\n**[플러그인 문서 →](https://cloudcli.ai/docs/plugin-overview)** — 플러그인 API, 매니페스트 포맷, 보안 모델 등을 설명.\n\n---\n\n## FAQ\n\n<details>\n<summary>Claude Code Remote Control과 어떻게 다른가요?</summary>\n\nClaude Code Remote Control은 이미 로컬 터미널에서 실행 중인 세션으로 메시지를 전송합니다. 이 경우 기계가 켜져 있어야 하고 터미널을 열어 둬야 하며, 네트워크 연결 없이 약 10분 후 타임아웃됩니다.\n\nCloudCLI UI와 CloudCLI Cloud는 Claude Code를 확장하며 별도로 존재하지 않습니다 — MCP 서버, 권한, 설정, 세션은 Claude Code에서 그대로 사용됩니다.\n\n- **모든 세션을 다룬다** — CloudCLI UI는 `~/.claude` 폴더에서 모든 세션을 자동 발견합니다. Remote Control은 단일 활성 세션만 노출합니다.\n- **설정은 그대로** — CloudCLI UI에서 변경한 MCP, 도구 권한, 프로젝트 설정은 Claude Code에 즉시 반영됩니다.\n- **지원 에이전트가 더 많음** — Claude Code, Cursor CLI, Codex, Gemini CLI 지원.\n- **전체 UI 제공** — 단일 채팅 창이 아닌 파일 탐색기, Git 통합, MCP 관리 및 셸 터미널 포함.\n- **CloudCLI Cloud는 클라우드에서 실행** — 노트북을 닫아도 에이전트가 실행됩니다. 터미널을 계속 확인할 필요 없음.\n\n</details>\n\n<details>\n<summary>AI 구독을 별도로 결제해야 하나요?</summary>\n\n네. CloudCLI는 환경만 제공합니다. Claude, Cursor, Codex, Gemini 구독 비용은 별도로 부과됩니다. CloudCLI Cloud는 관리형 환경을 월 $7부터 제공합니다.\n\n</details>\n\n<details>\n<summary>CloudCLI UI를 휴대폰에서 사용할 수 있나요?</summary>\n\n네. 셀프 호스트인 경우 기계에서 서버를 실행하고 네트워크의 아무 브라우저에서 `[yourip]:port`를 열면 됩니다. CloudCLI Cloud는 어떤 기기에서도 열 수 있으며, 네이티브 앱도 준비 중입니다.\n\n</details>\n\n<details>\n<summary>UI에서 변경하면 로컬 Claude Code 설정에 영향을 주나요?</summary>\n\n네, 셀프 호스트에서는 그렇습니다. CloudCLI UI는 Claude Code가 사용하는 동일한 `~/.claude` 설정을 읽고 씁니다. UI에서 추가한 MCP 서버가 Claude Code에 즉시 나타납니다.\n\n</details>\n\n---\n\n## 커뮤니티 및 지원\n\n- **[문서](https://cloudcli.ai/docs)** — 설치, 구성, 기능, 문제 해결 안내\n- **[Discord](https://discord.gg/buxwujPNRE)** — 도움 및 커뮤니티 참여\n- **[GitHub Issues](https://github.com/siteboon/claudecodeui/issues)** — 버그 보고 및 기능 요청\n- **[기여 안내](CONTRIBUTING.md)** — 프로젝트 참여 방법\n\n## 라이선스\n\nGNU General Public License v3.0 - 자세한 내용은 [LICENSE](LICENSE) 파일 참조.\n\n이 프로젝트는 GPL v3 라이선스 하에 오픈 소스로 공개되어 있으며 자유롭게 사용, 수정, 배포할 수 있습니다.\n\n## 감사의 말\n\n### 사용 기술\n- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropic 공식 CLI\n- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursor 공식 CLI\n- **[Codex](https://developers.openai.com/codex)** - OpenAI Codex\n- **[Gemini-CLI](https://geminicli.com/)** - Google Gemini CLI\n- **[React](https://react.dev/)** - 사용자 인터페이스 라이브러리\n- **[Vite](https://vitejs.dev/)** - 빠른 빌드 도구 및 개발 서버\n- **[Tailwind CSS](https://tailwindcss.com/)** - 유틸리티 우선 CSS 프레임워크\n- **[CodeMirror](https://codemirror.net/)** - 고급 코드 에디터\n- **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** *(선택사항)* - AI 기반 프로젝트 관리 및 작업 계획\n\n### 스폰서\n- [Siteboon - AI powered website builder](https://siteboon.ai)\n---\n\n<div align=\"center\">\n  <strong>Claude Code, Cursor, Codex 커뮤니티를 위해 정성껏 제작되었습니다.</strong>\n</div>\n"
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\">\n  <img src=\"public/logo.svg\" alt=\"CloudCLI UI\" width=\"64\" height=\"64\">\n  <h1>Cloud CLI (aka Claude Code UI)</h1>\n  <p>A desktop and mobile UI for <a href=\"https://docs.anthropic.com/en/docs/claude-code\">Claude Code</a>, <a href=\"https://docs.cursor.com/en/cli/overview\">Cursor CLI</a>, <a href=\"https://developers.openai.com/codex\">Codex</a>, and <a href=\"https://geminicli.com/\">Gemini-CLI</a>.<br>Use it locally or remotely to view your active projects and sessions from everywhere.</p>\n</div>\n\n<p align=\"center\">\n  <a href=\"https://cloudcli.ai\">CloudCLI Cloud</a> · <a href=\"https://cloudcli.ai/docs\">Documentation</a> · <a href=\"https://discord.gg/buxwujPNRE\">Discord</a> · <a href=\"https://github.com/siteboon/claudecodeui/issues\">Bug Reports</a> · <a href=\"CONTRIBUTING.md\">Contributing</a>\n</p>\n\n<p align=\"center\">\n  <a href=\"https://cloudcli.ai\"><img src=\"https://img.shields.io/badge/☁️_CloudCLI_Cloud-Try_Now-0066FF?style=for-the-badge\" alt=\"CloudCLI Cloud\"></a>\n  <a href=\"https://discord.gg/buxwujPNRE\"><img src=\"https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=for-the-badge&logo=discord&logoColor=white\" alt=\"Join our Discord\"></a>\n  <br><br>\n  <a href=\"https://trendshift.io/repositories/15586\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/15586\" alt=\"siteboon%2Fclaudecodeui | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n</p>\n\n<div align=\"right\"><i><b>English</b> · <a href=\"./README.ru.md\">Русский</a> · <a href=\"./README.de.md\">Deutsch</a> · <a href=\"./README.ko.md\">한국어</a> · <a href=\"./README.zh-CN.md\">中文</a> · <a href=\"./README.ja.md\">日本語</a></i></div>\n\n---\n\n## Screenshots\n\n<div align=\"center\">\n  \n<table>\n<tr>\n<td align=\"center\">\n<h3>Desktop View</h3>\n<img src=\"public/screenshots/desktop-main.png\" alt=\"Desktop Interface\" width=\"400\">\n<br>\n<em>Main interface showing project overview and chat</em>\n</td>\n<td align=\"center\">\n<h3>Mobile Experience</h3>\n<img src=\"public/screenshots/mobile-chat.png\" alt=\"Mobile Interface\" width=\"250\">\n<br>\n<em>Responsive mobile design with touch navigation</em>\n</td>\n</tr>\n<tr>\n<td align=\"center\" colspan=\"2\">\n<h3>CLI Selection</h3>\n<img src=\"public/screenshots/cli-selection.png\" alt=\"CLI Selection\" width=\"400\">\n<br>\n<em>Select between Claude Code, Gemini, Cursor CLI and Codex</em>\n</td>\n</tr>\n</table>\n\n\n\n</div>\n\n## Features\n\n- **Responsive Design** - Works seamlessly across desktop, tablet, and mobile so you can also use Agents from mobile \n- **Interactive Chat Interface** - Built-in chat interface for seamless communication with the Agents\n- **Integrated Shell Terminal** - Direct access to the Agents CLI through built-in shell functionality\n- **File Explorer** - Interactive file tree with syntax highlighting and live editing\n- **Git Explorer** - View, stage and commit your changes. You can also switch branches \n- **Session Management** - Resume conversations, manage multiple sessions, and track history\n- **Plugin System** - Extend CloudCLI with custom plugins — add new tabs, backend services, and integrations. [Build your own →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)\n- **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation\n- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (see [`shared/modelConstants.js`](shared/modelConstants.js) for the full list of supported models)\n\n\n## Quick Start\n\n### CloudCLI Cloud (Recommended)\n\nThe fastest way to get started — no local setup required. Get a fully managed, containerized development environment accessible from the web, mobile app, API, or your favorite IDE.\n\n**[Get started with CloudCLI Cloud](https://cloudcli.ai)**\n\n\n### Self-Hosted (Open source)\n\nTry CloudCLI UI instantly with **npx** (requires **Node.js** v22+):\n\n```\nnpx @siteboon/claude-code-ui\n```\n\nOr install **globally** for regular use:\n\n```\nnpm install -g @siteboon/claude-code-ui\ncloudcli\n```\n\nOpen `http://localhost:3001` — all your existing sessions are discovered automatically.\n\nVisit the **[documentation →](https://cloudcli.ai/docs)** for more full configuration options, PM2, remote server setup and more\n\n\n---\n\n## Which option is right for you?\n\nCloudCLI UI is the open source UI layer that powers CloudCLI Cloud. You can self-host it on your own machine, or use CloudCLI Cloud which builds on top of it with a full managed cloud environment, team features, and deeper integrations.\n\n| | CloudCLI UI (Self-hosted) | CloudCLI Cloud |\n|---|---|---|\n| **Best for** | Developers who want a full UI for local agent sessions on their own machine | Teams and developers who want agents running in the cloud, accessible from anywhere |\n| **How you access it** | Browser via `[yourip]:port` | Browser, any IDE, REST API, n8n |\n| **Setup** | `npx @siteboon/claude-code-ui` | No setup required |\n| **Machine needs to stay on** | Yes | No |\n| **Mobile access** | Any browser on your network | Any device, native app coming |\n| **Sessions available** | All sessions auto-discovered from `~/.claude` | All sessions within your cloud environment |\n| **Agents supported** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI |\n| **File explorer and Git** | Yes, built into the UI | Yes, built into the UI |\n| **MCP configuration** | Managed via UI, synced with your local `~/.claude` config | Managed via UI |\n| **IDE access** | Your local IDE | Any IDE connected to your cloud environment |\n| **REST API** | Yes | Yes |\n| **n8n node** | No | Yes |\n| **Team sharing** | No | Yes |\n| **Platform cost** | Free, open source | Starts at $7/month |\n\n> Both options use your own AI subscriptions (Claude, Cursor, etc.) — CloudCLI provides the environment, not the AI.\n\n---\n\n## Security & Tools Configuration\n\n**🔒 Important Notice**: All Claude Code tools are **disabled by default**. This prevents potentially harmful operations from running automatically.\n\n### Enabling Tools\n\nTo use Claude Code's full functionality, you'll need to manually enable tools:\n\n1. **Open Tools Settings** - Click the gear icon in the sidebar\n2. **Enable Selectively** - Turn on only the tools you need\n3. **Apply Settings** - Your preferences are saved locally\n\n<div align=\"center\">\n\n![Tools Settings Modal](public/screenshots/tools-modal.png)\n*Tools Settings interface - enable only what you need*\n\n</div>\n\n**Recommended approach**: Start with basic tools enabled and add more as needed. You can always adjust these settings later.\n\n---\n\n## Plugins\n\nCloudCLI has a plugin system that lets you add custom tabs with their own frontend UI and optional Node.js backend. Install plugins from git repos directly in **Settings > Plugins**, or build your own.\n\n### Available Plugins\n\n| Plugin | Description |\n|---|---|\n| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Shows file counts, lines of code, file-type breakdown, largest files, and recently modified files for your current project |\n| **[Web Terminal](https://github.com/cloudcli-ai/cloudcli-plugin-terminal)** | Full xterm.js terminal with multi-tab support|\n\n### Build Your Own\n\n**[Plugin Starter Template →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** — fork this repo to create your own plugin. It includes a working example with frontend rendering, live context updates, and RPC communication to a backend server.\n\n**[Plugin Documentation →](https://cloudcli.ai/docs/plugin-overview)** — full guide to the plugin API, manifest format, security model, and more.\n\n---\n## FAQ\n\n<details>\n<summary>How is this different from Claude Code Remote Control?</summary>\n\nClaude Code Remote Control lets you send messages to a session already running in your local terminal. Your machine has to stay on, your terminal has to stay open, and sessions time out after roughly 10 minutes without a network connection.\n\nCloudCLI UI and CloudCLI Cloud extend Claude Code rather than sit alongside it — your MCP servers, permissions, settings, and sessions are the exact same ones Claude Code uses natively. Nothing is duplicated or managed separately.\n\nHere's what that means in practice:\n\n- **All your sessions, not just one** — CloudCLI UI auto-discovers every session from your `~/.claude` folder. Remote Control only exposes the single active session to make it available in the Claude mobile app.\n- **Your settings are your settings** — MCP servers, tool permissions, and project config you change in CloudCLI UI are written directly to your Claude Code config and take effect immediately, and vice versa.\n- **Works with more agents** — Claude Code, Cursor CLI, Codex, and Gemini CLI, not just Claude Code.\n- **Full UI, not just a chat window** — file explorer, Git integration, MCP management, and a shell terminal are all built in.\n- **CloudCLI Cloud runs in the cloud** — close your laptop, the agent keeps running. No terminal to babysit, no machine to keep awake.\n\n</details>\n\n<details>\n<summary>Do I need to pay for an AI subscription separately?</summary>\n\nYes. CloudCLI provides the environment, not the AI. You bring your own Claude, Cursor, Codex, or Gemini subscription. CloudCLI Cloud starts at $7/month for the hosted environment on top of that.\n\n</details>\n\n<details>\n<summary>Can I use CloudCLI UI on my phone?</summary>\n\nYes. For self-hosted, run the server on your machine and open `[yourip]:port` in any browser on your network. For CloudCLI Cloud, open it from any device — no VPN, no port forwarding, no setup. A native app is also in the works.\n\n</details>\n\n<details>\n<summary>Will changes I make in the UI affect my local Claude Code setup?</summary>\n\nYes, for self-hosted. CloudCLI UI reads from and writes to the same `~/.claude` config that Claude Code uses natively. MCP servers you add via the UI show up in Claude Code immediately and vice versa.\n\n</details>\n\n---\n\n## Community & Support\n\n- **[Documentation](https://cloudcli.ai/docs)** — installation, configuration, features, and troubleshooting\n- **[Discord](https://discord.gg/buxwujPNRE)** — get help and connect with other users\n- **[GitHub Issues](https://github.com/siteboon/claudecodeui/issues)** — bug reports and feature requests\n- **[Contributing Guide](CONTRIBUTING.md)** — how to contribute to the project\n\n## License\n\nGNU General Public License v3.0 - see [LICENSE](LICENSE) file for details.\n\nThis project is open source and free to use, modify, and distribute under the GPL v3 license.\n\n## Acknowledgments\n\n### Built With\n- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropic's official CLI\n- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursor's official CLI\n- **[Codex](https://developers.openai.com/codex)** - OpenAI Codex\n- **[Gemini-CLI](https://geminicli.com/)** - Google Gemini CLI\n- **[React](https://react.dev/)** - User interface library\n- **[Vite](https://vitejs.dev/)** - Fast build tool and dev server\n- **[Tailwind CSS](https://tailwindcss.com/)** - Utility-first CSS framework\n- **[CodeMirror](https://codemirror.net/)** - Advanced code editor\n- **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** *(Optional)* - AI-powered project management and task planning\n\n\n### Sponsors\n- [Siteboon - AI powered website builder](https://siteboon.ai)\n---\n\n<div align=\"center\">\n  <strong>Made with care for the Claude Code, Cursor and Codex community.</strong>\n</div>\n"
  },
  {
    "path": "README.ru.md",
    "content": "<div align=\"center\">\n  <img src=\"public/logo.svg\" alt=\"CloudCLI UI\" width=\"64\" height=\"64\">\n  <h1>Cloud CLI (aka Claude Code UI)</h1>\n  <p>Десктопный и мобильный UI для <a href=\"https://docs.anthropic.com/en/docs/claude-code\">Claude Code</a>, <a href=\"https://docs.cursor.com/en/cli/overview\">Cursor CLI</a>, <a href=\"https://developers.openai.com/codex\">Codex</a> и <a href=\"https://geminicli.com/\">Gemini-CLI</a>.<br>Используйте локально или удалённо, чтобы просматривать активные проекты и сессии отовсюду.</p>\n</div>\n\n<p align=\"center\">\n  <a href=\"https://cloudcli.ai\">CloudCLI Cloud</a> · <a href=\"https://cloudcli.ai/docs\">Документация</a> · <a href=\"https://discord.gg/buxwujPNRE\">Discord</a> · <a href=\"https://github.com/siteboon/claudecodeui/issues\">Сообщить об ошибке</a> · <a href=\"CONTRIBUTING.md\">Участие в разработке</a>\n</p>\n\n<p align=\"center\">\n  <a href=\"https://cloudcli.ai\"><img src=\"https://img.shields.io/badge/☁️_CloudCLI_Cloud-Try_Now-0066FF?style=for-the-badge\" alt=\"CloudCLI Cloud\"></a>\n  <a href=\"https://discord.gg/buxwujPNRE\"><img src=\"https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=for-the-badge&logo=discord&logoColor=white\" alt=\"Join our Discord\"></a>\n  <br><br>\n  <a href=\"https://trendshift.io/repositories/15586\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/15586\" alt=\"siteboon%2Fclaudecodeui | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n</p>\n\n<div align=\"right\"><i><a href=\"./README.md\">English</a> · <b>Русский</b> · <a href=\"./README.de.md\">Deutsch</a> · <a href=\"./README.ko.md\">한국어</a> · <a href=\"./README.zh-CN.md\">中文</a> · <a href=\"./README.ja.md\">日本語</a></i></div>\n\n---\n\n## Скриншоты\n\n<div align=\"center\">\n\n<table>\n<tr>\n<td align=\"center\">\n<h3>Версия для десктопа</h3>\n<img src=\"public/screenshots/desktop-main.png\" alt=\"Desktop Interface\" width=\"400\">\n<br>\n<em>Основной интерфейс с обзором проекта и чатом</em>\n</td>\n<td align=\"center\">\n<h3>Мобильный режим</h3>\n<img src=\"public/screenshots/mobile-chat.png\" alt=\"Mobile Interface\" width=\"250\">\n<br>\n<em>Адаптивный мобильный дизайн с сенсорной навигацией</em>\n</td>\n</tr>\n<tr>\n<td align=\"center\" colspan=\"2\">\n<h3>Выбор CLI</h3>\n<img src=\"public/screenshots/cli-selection.png\" alt=\"CLI Selection\" width=\"400\">\n<br>\n<em>Выбирайте между Claude Code, Gemini, Cursor CLI и Codex</em>\n</td>\n</tr>\n</table>\n\n\n\n</div>\n\n## Возможности\n\n- **Адаптивный дизайн** - одинаково хорошо работает на десктопе, планшете и телефоне, поэтому можно пользоваться агентами и с мобильных устройств\n- **Интерактивный чат-интерфейс** - встроенный чат для бесшовного общения с агентами\n- **Интегрированный shell-терминал** - прямой доступ к CLI агентов через встроенную оболочку\n- **Проводник файлов** - интерактивное дерево файлов с подсветкой синтаксиса и редактированием в реальном времени\n- **Git Explorer** - просмотр, stage и commit изменений. Также можно переключать ветки\n- **Управление сессиями** - возобновляйте диалоги, управляйте несколькими сессиями и отслеживайте историю\n- **Система плагинов** - расширяйте CloudCLI кастомными плагинами — добавляйте новые вкладки, бэкенд-сервисы и интеграции. [Создать свой →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)\n- **Интеграция с TaskMaster AI** *(опционально)* - продвинутое управление проектами с планированием задач на базе AI, разбором PRD и автоматизацией workflow\n- **Совместимость с моделями** - работает с семействами моделей Claude, GPT и Gemini (см. [`shared/modelConstants.js`](shared/modelConstants.js) для полного списка поддерживаемых моделей)\n\n\n## Быстрый старт\n\n### CloudCLI Cloud (рекомендуется)\n\nСамый быстрый способ начать — локальная настройка не требуется. Получите полностью управляемую контейнеризированную среду разработки с доступом из веба, мобильного приложения, API или вашей любимой IDE.\n\n**[Начать с CloudCLI Cloud](https://cloudcli.ai)**\n\n\n### Self-Hosted (Open source)\n\nПопробовать CloudCLI UI можно сразу через **npx** (требуется **Node.js** v22+):\n\n```bash\nnpx @siteboon/claude-code-ui\n```\n\nИли установить **глобально** для регулярного использования:\n\n```bash\nnpm install -g @siteboon/claude-code-ui\ncloudcli\n```\n\nОткройте `http://localhost:3001` — все ваши существующие сессии будут обнаружены автоматически.\n\nПосетите **[документацию →](https://cloudcli.ai/docs)**, чтобы узнать про дополнительные варианты конфигурации, PM2, настройку удалённого сервера и многое другое\n\n\n---\n\n## Какой вариант подходит вам?\n\nCloudCLI UI — это open source UI-слой, на котором построен CloudCLI Cloud. Вы можете развернуть его на своей машине или использовать CloudCLI Cloud, который добавляет полностью управляемую облачную среду, командные функции и более глубокие интеграции.\n\n| | CloudCLI UI (Self-hosted) | CloudCLI Cloud |\n|---|---|---|\n| **Лучше всего подходит для** | Разработчиков, которым нужен полноценный UI для локальных агентских сессий на своей машине | Команд и разработчиков, которым нужны агенты в облаке с доступом откуда угодно |\n| **Как вы получаете доступ** | Браузер через `[yourip]:port` | Браузер, любая IDE, REST API, n8n |\n| **Настройка** | `npx @siteboon/claude-code-ui` | Настройка не требуется |\n| **Машина должна оставаться включённой** | Да | Нет |\n| **Доступ с мобильных устройств** | Любой браузер в вашей сети | Любое устройство, нативное приложение в разработке |\n| **Доступные сессии** | Все сессии автоматически обнаруживаются из `~/.claude` | Все сессии внутри вашей облачной среды |\n| **Поддерживаемые агенты** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI |\n| **Проводник файлов и Git** | Да, встроены в UI | Да, встроены в UI |\n| **Конфигурация MCP** | Управляется через UI, синхронизируется с вашим локальным конфигом `~/.claude` | Управляется через UI |\n| **Доступ из IDE** | Ваша локальная IDE | Любая IDE, подключенная к вашей облачной среде |\n| **REST API** | Да | Да |\n| **n8n node** | Нет | Да |\n| **Совместная работа** | Нет | Да |\n| **Стоимость платформы** | Бесплатно, open source | От $7/месяц |\n\n> В обоих вариантах используются ваши собственные AI-подписки (Claude, Cursor и т.д.) — CloudCLI предоставляет среду, а не сам AI.\n\n---\n\n## Безопасность и конфигурация инструментов\n\n**🔒 Важное примечание**: все инструменты Claude Code **по умолчанию отключены**. Это предотвращает автоматический запуск потенциально опасных операций.\n\n### Включение инструментов\n\nЧтобы использовать всю функциональность Claude Code, вам нужно вручную включить инструменты:\n\n1. **Откройте настройки инструментов** - нажмите на иконку шестерёнки в боковой панели\n2. **Включайте выборочно** - активируйте только те инструменты, которые вам нужны\n3. **Примените настройки** - ваши предпочтения сохраняются локально\n\n<div align=\"center\">\n\n![Tools Settings Modal](public/screenshots/tools-modal.png)\n*Интерфейс настройки инструментов — включайте только то, что вам нужно*\n\n</div>\n\n**Рекомендуемый подход**: начните с базовых инструментов и добавляйте остальные по мере необходимости. Эти настройки всегда можно изменить позже.\n\n---\n\n## Плагины\n\nУ CloudCLI есть система плагинов, которая позволяет добавлять кастомные вкладки со своим frontend UI и (опционально) Node.js бэкендом. Устанавливайте плагины напрямую из git-репозиториев в **Settings > Plugins** или создавайте свои.\n\n### Доступные плагины\n\n| Плагин | Описание |\n|---|---|\n| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Показывает количество файлов, строки кода, разбивку по типам файлов, самые большие файлы и недавно изменённые файлы для текущего проекта |\n\n### Создать свой\n\n**[Plugin Starter Template →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** — сделайте форк этого репозитория, чтобы создать свой плагин. В шаблоне есть рабочий пример с рендерингом на фронтенде, live-обновлением контекста и RPC-коммуникацией с бэкенд-сервером.\n\n**[Plugin Documentation →](https://cloudcli.ai/docs/plugin-overview)** — полный гайд по plugin API, формату манифеста, модели безопасности и другому.\n\n---\n## FAQ\n\n<details>\n<summary>Чем это отличается от Claude Code Remote Control?</summary>\n\nClaude Code Remote Control позволяет отправлять сообщения в сессию, которая уже запущена в вашем локальном терминале. Ваша машина должна оставаться включённой, терминал — открытым, а сессии завершаются примерно через 10 минут без сетевого соединения.\n\nCloudCLI UI и CloudCLI Cloud расширяют Claude Code, а не работают рядом с ним — ваши MCP-серверы, разрешения, настройки и сессии остаются теми же самыми, что и в нативном Claude Code. Ничего не дублируется и не управляется отдельно.\n\nВот что это означает на практике:\n\n- **Все ваши сессии, а не одна** — CloudCLI UI автоматически находит каждую сессию из папки `~/.claude`. Remote Control предоставляет только одну активную сессию, чтобы сделать её доступной в мобильном приложении Claude.\n- **Ваши настройки — это ваши настройки** — MCP-серверы, права инструментов и конфигурация проекта, изменённые в CloudCLI UI, записываются напрямую в конфиг Claude Code и вступают в силу сразу же, и наоборот.\n- **Работает с большим числом агентов** — Claude Code, Cursor CLI, Codex и Gemini CLI, а не только Claude Code.\n- **Полноценный UI, а не просто окно чата** — проводник файлов, Git-интеграция, управление MCP и shell-терминал — всё встроено.\n- **CloudCLI Cloud работает в облаке** — закройте ноутбук, и агент продолжит работать. Не нужно следить за терминалом и держать машину постоянно активной.\n\n</details>\n\n<details>\n<summary>Нужно ли отдельно платить за AI-подписку?</summary>\n\nДа. CloudCLI предоставляет среду, а не сам AI. Вы приносите свою подписку Claude, Cursor, Codex или Gemini. CloudCLI Cloud начинается от $7/месяц за хостируемую среду поверх этого.\n\n</details>\n\n<details>\n<summary>Можно ли пользоваться CloudCLI UI с телефона?</summary>\n\nДа. Для self-hosted запустите сервер на своей машине и откройте `[yourip]:port` в любом браузере в вашей сети. Для CloudCLI Cloud откройте сервис с любого устройства — без VPN, проброса портов и дополнительной настройки. Нативное приложение тоже в разработке.\n\n</details>\n\n<details>\n<summary>Повлияют ли изменения, сделанные в UI, на мой локальный Claude Code?</summary>\n\nДа, в self-hosted режиме. CloudCLI UI читает и записывает тот же конфиг `~/.claude`, который Claude Code использует нативно. MCP-серверы, добавленные через UI, сразу появляются в Claude Code, и наоборот.\n\n</details>\n\n---\n\n## Сообщество и поддержка\n\n- **[Документация](https://cloudcli.ai/docs)** — установка, настройка, возможности и устранение неполадок\n- **[Discord](https://discord.gg/buxwujPNRE)** — помощь и общение с другими пользователями\n- **[GitHub Issues](https://github.com/siteboon/claudecodeui/issues)** — сообщения об ошибках и запросы новых функций\n- **[Руководство для контрибьюторов](CONTRIBUTING.md)** — как участвовать в развитии проекта\n\n## Лицензия\n\nGNU General Public License v3.0 - подробности в файле [LICENSE](LICENSE).\n\nЭтот проект open source и бесплатен для использования, модификации и распространения в рамках лицензии GPL v3.\n\n## Благодарности\n\n### Используется\n- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - официальный CLI от Anthropic\n- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - официальный CLI от Cursor\n- **[Codex](https://developers.openai.com/codex)** - OpenAI Codex\n- **[Gemini-CLI](https://geminicli.com/)** - Google Gemini CLI\n- **[React](https://react.dev/)** - библиотека пользовательских интерфейсов\n- **[Vite](https://vitejs.dev/)** - быстрый инструмент сборки и dev-сервер\n- **[Tailwind CSS](https://tailwindcss.com/)** - utility-first CSS framework\n- **[CodeMirror](https://codemirror.net/)** - продвинутый редактор кода\n- **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** *(опционально)* - AI-управление проектами и планирование задач\n\n\n### Спонсоры\n- [Siteboon - AI powered website builder](https://siteboon.ai)\n---\n\n<div align=\"center\">\n  <strong>Сделано с заботой для сообщества Claude Code, Cursor и Codex.</strong>\n</div>\n"
  },
  {
    "path": "README.zh-CN.md",
    "content": "<div align=\"center\">\n  <img src=\"public/logo.svg\" alt=\"CloudCLI UI\" width=\"64\" height=\"64\">\n  <h1>Cloud CLI（又名 Claude Code UI）</h1>\n  <p><a href=\"https://docs.anthropic.com/en/docs/claude-code\">Claude Code</a>、<a href=\"https://docs.cursor.com/en/cli/overview\">Cursor CLI</a>、<a href=\"https://developers.openai.com/codex\">Codex</a> 和 <a href=\"https://geminicli.com/\">Gemini-CLI</a> 的桌面和移动端 UI。可在本地或远程使用，从任何地方查看激活的项目与会话。</p>\n</div>\n\n<p align=\"center\">\n  <a href=\"https://cloudcli.ai\">CloudCLI Cloud</a> · <a href=\"https://cloudcli.ai/docs\">文档</a> · <a href=\"https://discord.gg/buxwujPNRE\">Discord</a> · <a href=\"https://github.com/siteboon/claudecodeui/issues\">Bug 报告</a> · <a href=\"CONTRIBUTING.md\">贡献指南</a>\n</p>\n\n<p align=\"center\">\n  <a href=\"https://cloudcli.ai\"><img src=\"https://img.shields.io/badge/☁️_CloudCLI_Cloud-Try_Now-0066FF?style=for-the-badge\" alt=\"CloudCLI Cloud\"></a>\n  <a href=\"https://discord.gg/buxwujPNRE\"><img src=\"https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=for-the-badge&logo=discord&logoColor=white\" alt=\"加入 Discord 社区\"></a>\n  <br><br>\n  <a href=\"https://trendshift.io/repositories/15586\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/15586\" alt=\"siteboon%2Fclaudecodeui | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n</p>\n\n<div align=\"right\"><i><a href=\"./README.md\">English</a> · <a href=\"./README.ru.md\">Русский</a> · <a href=\"./README.de.md\">Deutsch</a> · <a href=\"./README.ko.md\">한국어</a> · <b>中文</b> · <a href=\"./README.ja.md\">日本語</a></i></div>\n\n---\n\n## 截图\n\n<div align=\"center\">\n\n<table>\n<tr>\n<td align=\"center\">\n<h3>桌面视图</h3>\n<img src=\"public/screenshots/desktop-main.png\" alt=\"桌面界面\" width=\"400\">\n<br>\n<em>显示项目概览和聊天的主界面</em>\n</td>\n<td align=\"center\">\n<h3>移动体验</h3>\n<img src=\"public/screenshots/mobile-chat.png\" alt=\"移动界面\" width=\"250\">\n<br>\n<em>具有触控导航的响应式移动设计</em>\n</td>\n</tr>\n<tr>\n<td align=\"center\" colspan=\"2\">\n<h3>CLI 选择</h3>\n<img src=\"public/screenshots/cli-selection.png\" alt=\"CLI 选择\" width=\"400\">\n<br>\n<em>在 Claude Code、Gemini、Cursor CLI 与 Codex 之间进行选择</em>\n</td>\n</tr>\n</table>\n\n</div>\n\n## 功能\n\n- **响应式设计** - 在桌面、平板和移动设备上无缝运行，让您随时随地使用 Agents\n- **交互聊天界面** - 内置聊天 UI，轻松与 Agents 交流\n- **集成 Shell 终端** - 通过内置 shell 功能直接访问 Agents CLI\n- **文件浏览器** - 交互式文件树，支持语法高亮与实时编辑\n- **Git 浏览器** - 查看、暂存并提交更改，还可切换分支\n- **会话管理** - 恢复对话、管理多个会话并跟踪历史记录\n- **插件系统** - 通过自定义选项卡、后端服务与集成扩展 CloudCLI。 [开始构建 →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)\n- **TaskMaster AI 集成** *(可选)* - 结合 AI 任务规划、PRD 分析与工作流自动化，实现高级项目管理\n- **模型兼容性** - 支持 Claude、GPT、Gemini 模型家族（完整支持列表见 [`shared/modelConstants.js`](shared/modelConstants.js)）\n\n## 快速开始\n\n### CloudCLI Cloud（推荐）\n\n无需本地设置即可快速启动。提供可通过网络浏览器、移动应用、API 或喜欢的 IDE 访问的完全集装式托管开发环境。\n\n**[立即开始 CloudCLI Cloud](https://cloudcli.ai)**\n\n### 自托管（开源）\n\n启动 CloudCLI UI，只需一行 `npx`（需要 Node.js v22+）：\n\n```bash\nnpx @siteboon/claude-code-ui\n```\n\n或进行全局安装，便于日常使用：\n\n```bash\nnpm install -g @siteboon/claude-code-ui\ncloudcli\n```\n\n打开 `http://localhost:3001`，系统会自动发现所有现有会话。\n\n更多配置选项、PM2、远程服务器设置等，请参阅 **[文档 →](https://cloudcli.ai/docs)**\n\n---\n\n## 哪个选项更适合你？\n\nCloudCLI UI 是 CloudCLI Cloud 的开源 UI 层。你可以在本地机器上自托管它，也可以使用提供团队功能与深入集成的 CloudCLI Cloud。\n\n| | CloudCLI UI（自托管） | CloudCLI Cloud |\n|---|---|---|\n| **适合对象** | 需要为本地代理会话提供完整 UI 的开发者 | 需要部署在云端，随时从任何地方访问代理的团队与开发者 |\n| **访问方式** | 通过 `[yourip]:port` 在浏览器中访问 | 浏览器、任意 IDE、REST API、n8n |\n| **设置** | `npx @siteboon/claude-code-ui` | 无需设置 |\n| **机器需保持开机吗** | 是 | 否 |\n| **移动端访问** | 网络内任意浏览器 | 任意设备（原生应用即将推出） |\n| **可用会话** | 自动发现 `~/.claude` 中的所有会话 | 云端环境内的会话 |\n| **支持的 Agents** | Claude Code、Cursor CLI、Codex、Gemini CLI | Claude Code、Cursor CLI、Codex、Gemini CLI |\n| **文件浏览与 Git** | 内置于 UI | 内置于 UI |\n| **MCP 配置** | UI 管理，与本地 `~/.claude` 配置同步 | UI 管理 |\n| **IDE 访问** | 本地 IDE | 任何连接到云环境的 IDE |\n| **REST API** | 是 | 是 |\n| **n8n 节点** | 否 | 是 |\n| **团队共享** | 否 | 是 |\n| **平台费用** | 免费开源 | 起价 $7/月 |\n\n> 两种方式都使用你自己的 AI 订阅（Claude、Cursor 等）— CloudCLI 提供环境，而非 AI。\n\n---\n\n## 安全与工具配置\n\n**🔒 重要提示**: 所有 Claude Code 工具默认**禁用**，可防止潜在的有害操作自动运行。\n\n### 启用工具\n\n1. **打开工具设置** - 点击侧边栏齿轮图标\n2. **选择性启用** - 仅启用所需工具\n3. **应用设置** - 偏好设置保存在本地\n\n<div align=\"center\">\n\n![工具设置弹窗](public/screenshots/tools-modal.png)\n*工具设置界面 - 只启用你需要的内容*\n\n</div>\n\n**推荐做法**: 先启用基础工具，再根据需要添加其他工具。随时可以调整。\n\n---\n\n## 插件\n\nCloudCLI 配备插件系统，允许你添加带自定义前端 UI 和可选 Node.js 后端的选项卡。在 Settings > Plugins 中直接从 Git 仓库安装插件，或自行开发。\n\n### 可用插件\n\n| 插件 | 描述 |\n|---|---|\n| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | 展示当前项目的文件数、代码行数、文件类型分布、最大文件以及最近修改的文件 |\n\n### 自行构建\n\n**[Plugin Starter Template →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** — Fork 该仓库以构建自己的插件。示例包括前端渲染、实时上下文更新和 RPC 通信。\n\n**[插件文档 →](https://cloudcli.ai/docs/plugin-overview)** — 提供插件 API、清单格式、安全模型等完整指南。\n\n---\n\n## 常见问题\n\n<details>\n<summary>与 Claude Code Remote Control 有何不同？</summary>\n\nClaude Code Remote Control 让你发送消息到本地终端中已经运行的会话。该方式要求你的机器保持开机，终端保持开启，断开网络后约 10 分钟会话会超时。\n\nCloudCLI UI 与 CloudCLI Cloud 是对 Claude Code 的扩展，而非旁观 — MCP 服务器、权限、设置、会话与 Claude Code 完全一致。\n\n- **覆盖全部会话** — CloudCLI UI 会自动扫描 `~/.claude` 文件夹中的每个会话。Remote Control 只暴露当前活动的会话。\n- **设置统一** — 在 CloudCLI UI 中修改的 MCP、工具权限等设置会立即写入 Claude Code。\n- **支持更多 Agents** — Claude Code、Cursor CLI、Codex、Gemini CLI。\n- **完整 UI** — 除了聊天界面，还包括文件浏览器、Git 集成、MCP 管理和 Shell 终端。\n- **CloudCLI Cloud 保持运行于云端** — 关闭本地设备也不会中断代理运行，无需监控终端。\n\n</details>\n\n<details>\n<summary>需要额外购买 AI 订阅吗？</summary>\n\n需要。CloudCLI 只提供环境。你仍需自行获取 Claude、Cursor、Codex 或 Gemini 订阅。CloudCLI Cloud 从 $7/月起提供托管环境。\n\n</details>\n\n<details>\n<summary>能在手机上使用 CloudCLI UI 吗？</summary>\n\n可以。自托管时，在你的设备上运行服务器，然后在网络中的任意浏览器打开 `[yourip]:port`。CloudCLI Cloud 可从任意设备访问，内置原生应用也在开发中。\n\n</details>\n\n<details>\n<summary>UI 中的更改会影响本地 Claude Code 配置吗？</summary>\n\n会的。自托管模式下，CloudCLI UI 读取并写入 Claude Code 使用的 `~/.claude` 配置。通过 UI 添加的 MCP 服务器会立即在 Claude Code 中可见。\n\n</details>\n\n---\n\n## 社区与支持\n\n- **[文档](https://cloudcli.ai/docs)** — 安装、配置、功能与故障排除指南\n- **[Discord](https://discord.gg/buxwujPNRE)** — 获取帮助并与社区交流\n- **[GitHub Issues](https://github.com/siteboon/claudecodeui/issues)** — 报告 Bug 与建议功能\n- **[贡献指南](CONTRIBUTING.md)** — 如何参与项目贡献\n\n## 许可证\n\nGNU 通用公共许可证 v3.0 - 详见 [LICENSE](LICENSE) 文件。\n\n该项目为开源软件，在 GPL v3 许可证下可自由使用、修改与分发。\n\n## 致谢\n\n### 使用技术\n- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - Anthropic 官方 CLI\n- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - Cursor 官方 CLI\n- **[Codex](https://developers.openai.com/codex)** - OpenAI Codex\n- **[Gemini-CLI](https://geminicli.com/)** - Google Gemini CLI\n- **[React](https://react.dev/)** - 用户界面库\n- **[Vite](https://vitejs.dev/)** - 快速构建工具与开发服务器\n- **[Tailwind CSS](https://tailwindcss.com/)** - 实用先行 CSS 框架\n- **[CodeMirror](https://codemirror.net/)** - 高级代码编辑器\n- **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** *(可选)* - AI 驱动的项目管理与任务规划\n\n### 赞助商\n- [Siteboon - AI powered website builder](https://siteboon.ai)\n---\n\n<div align=\"center\">\n  <strong>为 Claude Code、Cursor 和 Codex 社区精心打造。</strong>\n</div>\n"
  },
  {
    "path": "commitlint.config.js",
    "content": "export default {\n  extends: [\"@commitlint/config-conventional\"],\n};\n"
  },
  {
    "path": "eslint.config.js",
    "content": "import js from \"@eslint/js\";\nimport tseslint from \"typescript-eslint\";\nimport react from \"eslint-plugin-react\";\nimport reactHooks from \"eslint-plugin-react-hooks\";\nimport reactRefresh from \"eslint-plugin-react-refresh\";\nimport importX from \"eslint-plugin-import-x\";\nimport tailwindcss from \"eslint-plugin-tailwindcss\";\nimport unusedImports from \"eslint-plugin-unused-imports\";\nimport globals from \"globals\";\n\nexport default tseslint.config(\n  {\n    ignores: [\"dist/**\", \"node_modules/**\", \"public/**\"],\n  },\n  {\n    files: [\"src/**/*.{ts,tsx,js,jsx}\"],\n    extends: [js.configs.recommended, ...tseslint.configs.recommended],\n    plugins: {\n      react,\n      \"react-hooks\": reactHooks, // for following React rules such as dependencies in hooks, keys in lists, etc.\n      \"react-refresh\": reactRefresh, // for Vite HMR compatibility\n      \"import-x\": importX, // for import order/sorting. It also detercts circular dependencies and duplicate imports.\n      tailwindcss, // for detecting invalid Tailwind classnames and enforcing classname order\n      \"unused-imports\": unusedImports, // for detecting unused imports\n    },\n    languageOptions: {\n      globals: {\n        ...globals.browser,\n      },\n      parserOptions: {\n        ecmaFeatures: { jsx: true },\n      },\n    },\n    settings: {\n      react: { version: \"detect\" },\n    },\n    rules: {\n      // --- Unused imports/vars ---\n      \"unused-imports/no-unused-imports\": \"warn\",\n      \"unused-imports/no-unused-vars\": [\n        \"warn\",\n        {\n          vars: \"all\",\n          varsIgnorePattern: \"^_\",\n          args: \"after-used\",\n          argsIgnorePattern: \"^_\",\n        },\n      ],\n      \"no-unused-vars\": \"off\",\n      \"@typescript-eslint/no-unused-vars\": \"off\",\n\n      // --- React ---\n      \"react/jsx-key\": \"warn\",\n      \"react/jsx-no-duplicate-props\": \"error\",\n      \"react/jsx-no-undef\": \"error\",\n      \"react/no-children-prop\": \"warn\",\n      \"react/no-danger-with-children\": \"error\",\n      \"react/no-direct-mutation-state\": \"error\",\n      \"react/no-unknown-property\": \"warn\",\n      \"react/react-in-jsx-scope\": \"off\",\n\n      // --- React Hooks ---\n      \"react-hooks/rules-of-hooks\": \"error\",\n      \"react-hooks/exhaustive-deps\": \"warn\",\n\n      // --- React Refresh (Vite HMR) ---\n      \"react-refresh/only-export-components\": [\n        \"warn\",\n        { allowConstantExport: true },\n      ],\n\n      // --- Import ordering & hygiene ---\n      \"import-x/no-duplicates\": \"warn\",\n      \"import-x/order\": [\n        \"warn\",\n        {\n          groups: [\n            \"builtin\",\n            \"external\",\n            \"internal\",\n            \"parent\",\n            \"sibling\",\n            \"index\",\n          ],\n          \"newlines-between\": \"never\",\n        },\n      ],\n\n      // --- Tailwind CSS ---\n      \"tailwindcss/classnames-order\": \"warn\",\n      \"tailwindcss/no-contradicting-classname\": \"warn\",\n      \"tailwindcss/no-unnecessary-arbitrary-value\": \"warn\",\n\n      // --- Disabled base rules ---\n      \"@typescript-eslint/no-explicit-any\": \"off\",\n      \"@typescript-eslint/no-require-imports\": \"off\",\n      \"no-case-declarations\": \"off\",\n      \"no-control-regex\": \"off\",\n      \"no-useless-escape\": \"off\",\n    },\n  }\n);\n"
  },
  {
    "path": "index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicon.svg\" />\n    <link rel=\"icon\" type=\"image/png\" href=\"/favicon.png\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover\" />\n    <title>CloudCLI UI</title>\n    \n    <!-- PWA Manifest -->\n    <link rel=\"manifest\" href=\"/manifest.json\" crossorigin=\"use-credentials\"  />\n    \n    <!-- iOS Safari PWA Meta Tags -->\n    <meta name=\"mobile-web-app-capable\" content=\"yes\" />\n    <meta name=\"apple-mobile-web-app-status-bar-style\" content=\"black-translucent\" />\n    <meta name=\"apple-mobile-web-app-title\" content=\"Claude UI\" />\n    \n    <!-- iOS Safari Icons -->\n    <link rel=\"apple-touch-icon\" sizes=\"152x152\" href=\"/icons/icon-152x152.png\" />\n    <link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"/icons/icon-192x192.png\" />\n    \n    <!-- Theme Color -->\n    <meta name=\"theme-color\" content=\"#ffffff\" />\n    <meta name=\"msapplication-TileColor\" content=\"#ffffff\" />\n    \n    <!-- Prevent zoom on iOS -->\n    <meta name=\"format-detection\" content=\"telephone=no\" />\n  </head>\n  <body>\n    <div id=\"root\"></div>\n    <script type=\"module\" src=\"/src/main.jsx\"></script>\n    \n    <!-- Service Worker Registration -->\n    <script>\n      if ('serviceWorker' in navigator) {\n        window.addEventListener('load', () => {\n          navigator.serviceWorker.register('/sw.js')\n            .then(registration => {\n              console.log('SW registered: ', registration);\n            })\n            .catch(registrationError => {\n              console.log('SW registration failed: ', registrationError);\n            });\n        });\n      }\n    </script>\n  </body>\n</html>\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"@siteboon/claude-code-ui\",\n  \"version\": \"1.26.0\",\n  \"description\": \"A web-based UI for Claude Code CLI\",\n  \"type\": \"module\",\n  \"main\": \"server/index.js\",\n  \"bin\": {\n    \"claude-code-ui\": \"server/cli.js\",\n    \"cloudcli\": \"server/cli.js\"\n  },\n  \"files\": [\n    \"server/\",\n    \"shared/\",\n    \"dist/\",\n    \"scripts/\",\n    \"README.md\"\n  ],\n  \"homepage\": \"https://cloudcli.ai\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/siteboon/claudecodeui.git\"\n  },\n  \"bugs\": {\n    \"url\": \"https://github.com/siteboon/claudecodeui/issues\"\n  },\n  \"scripts\": {\n    \"dev\": \"concurrently --kill-others \\\"npm run server\\\" \\\"npm run client\\\"\",\n    \"server\": \"node server/index.js\",\n    \"client\": \"vite\",\n    \"build\": \"vite build\",\n    \"preview\": \"vite preview\",\n    \"typecheck\": \"tsc --noEmit -p tsconfig.json\",\n    \"lint\": \"eslint src/\",\n    \"lint:fix\": \"eslint src/ --fix\",\n    \"start\": \"npm run build && npm run server\",\n    \"release\": \"./release.sh\",\n    \"prepublishOnly\": \"npm run build\",\n    \"postinstall\": \"node scripts/fix-node-pty.js\",\n    \"prepare\": \"husky\"\n  },\n  \"keywords\": [\n    \"claude code\",\n    \"ai\",\n    \"anthropic\",\n    \"ui\",\n    \"mobile\"\n  ],\n  \"author\": \"CloudCLI UI Contributors\",\n  \"license\": \"GPL-3.0\",\n  \"dependencies\": {\n    \"@anthropic-ai/claude-agent-sdk\": \"^0.2.59\",\n    \"@codemirror/lang-css\": \"^6.3.1\",\n    \"@codemirror/lang-html\": \"^6.4.9\",\n    \"@codemirror/lang-javascript\": \"^6.2.4\",\n    \"@codemirror/lang-json\": \"^6.0.1\",\n    \"@codemirror/lang-markdown\": \"^6.3.3\",\n    \"@codemirror/lang-python\": \"^6.2.1\",\n    \"@codemirror/merge\": \"^6.11.1\",\n    \"@codemirror/theme-one-dark\": \"^6.1.2\",\n    \"@iarna/toml\": \"^2.2.5\",\n    \"@octokit/rest\": \"^22.0.0\",\n    \"@openai/codex-sdk\": \"^0.101.0\",\n    \"@replit/codemirror-minimap\": \"^0.5.2\",\n    \"@tailwindcss/typography\": \"^0.5.16\",\n    \"@uiw/react-codemirror\": \"^4.23.13\",\n    \"@xterm/addon-clipboard\": \"^0.1.0\",\n    \"@xterm/addon-fit\": \"^0.10.0\",\n    \"@xterm/addon-web-links\": \"^0.11.0\",\n    \"@xterm/addon-webgl\": \"^0.18.0\",\n    \"@xterm/xterm\": \"^5.5.0\",\n    \"bcrypt\": \"^6.0.0\",\n    \"better-sqlite3\": \"^12.6.2\",\n    \"chokidar\": \"^4.0.3\",\n    \"class-variance-authority\": \"^0.7.1\",\n    \"clsx\": \"^2.1.1\",\n    \"cors\": \"^2.8.5\",\n    \"cross-spawn\": \"^7.0.3\",\n    \"express\": \"^4.18.2\",\n    \"fuse.js\": \"^7.0.0\",\n    \"gray-matter\": \"^4.0.3\",\n    \"i18next\": \"^25.7.4\",\n    \"i18next-browser-languagedetector\": \"^8.2.0\",\n    \"jsonwebtoken\": \"^9.0.2\",\n    \"jszip\": \"^3.10.1\",\n    \"katex\": \"^0.16.25\",\n    \"lucide-react\": \"^0.515.0\",\n    \"mime-types\": \"^3.0.1\",\n    \"multer\": \"^2.0.1\",\n    \"node-fetch\": \"^2.7.0\",\n    \"node-pty\": \"^1.1.0-beta34\",\n    \"react\": \"^18.2.0\",\n    \"react-dom\": \"^18.2.0\",\n    \"react-dropzone\": \"^14.2.3\",\n    \"react-error-boundary\": \"^4.1.2\",\n    \"react-i18next\": \"^16.5.3\",\n    \"react-markdown\": \"^10.1.0\",\n    \"react-router-dom\": \"^6.8.1\",\n    \"react-syntax-highlighter\": \"^15.6.1\",\n    \"rehype-katex\": \"^7.0.1\",\n    \"rehype-raw\": \"^7.0.0\",\n    \"remark-gfm\": \"^4.0.0\",\n    \"remark-math\": \"^6.0.0\",\n    \"sqlite\": \"^5.1.1\",\n    \"sqlite3\": \"^5.1.7\",\n    \"tailwind-merge\": \"^3.3.1\",\n    \"web-push\": \"^3.6.7\",\n    \"ws\": \"^8.14.2\"\n  },\n  \"devDependencies\": {\n    \"@commitlint/cli\": \"^20.4.3\",\n    \"@commitlint/config-conventional\": \"^20.4.3\",\n    \"@eslint/js\": \"^9.39.3\",\n    \"@release-it/conventional-changelog\": \"^10.0.5\",\n    \"@types/node\": \"^22.19.7\",\n    \"@types/react\": \"^18.2.43\",\n    \"@types/react-dom\": \"^18.2.17\",\n    \"@vitejs/plugin-react\": \"^4.6.0\",\n    \"auto-changelog\": \"^2.5.0\",\n    \"autoprefixer\": \"^10.4.16\",\n    \"concurrently\": \"^8.2.2\",\n    \"eslint\": \"^9.39.3\",\n    \"eslint-plugin-import-x\": \"^4.16.1\",\n    \"eslint-plugin-react\": \"^7.37.5\",\n    \"eslint-plugin-react-hooks\": \"^7.0.1\",\n    \"eslint-plugin-react-refresh\": \"^0.5.2\",\n    \"eslint-plugin-tailwindcss\": \"^3.18.2\",\n    \"eslint-plugin-unused-imports\": \"^4.4.1\",\n    \"globals\": \"^17.4.0\",\n    \"husky\": \"^9.1.7\",\n    \"lint-staged\": \"^16.3.2\",\n    \"node-gyp\": \"^10.0.0\",\n    \"postcss\": \"^8.4.32\",\n    \"release-it\": \"^19.0.5\",\n    \"sharp\": \"^0.34.2\",\n    \"tailwindcss\": \"^3.4.0\",\n    \"typescript\": \"^5.9.3\",\n    \"typescript-eslint\": \"^8.56.1\",\n    \"vite\": \"^7.0.4\"\n  },\n  \"lint-staged\": {\n    \"src/**/*.{ts,tsx,js,jsx}\": \"eslint\"\n  }\n}\n"
  },
  {
    "path": "postcss.config.js",
    "content": "export default {\n  plugins: {\n    tailwindcss: {},\n    autoprefixer: {},\n  },\n}"
  },
  {
    "path": "public/api-docs.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>Claude Code UI - API Documentation</title>\n    <link rel=\"icon\" type=\"image/svg+xml\" href=\"/favicon.svg\" />\n    <link rel=\"icon\" type=\"image/png\" href=\"/favicon.png\" />\n    \n    <!-- Prism.js for syntax highlighting -->\n    <link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css\">\n    \n    <style>\n        * {\n            margin: 0;\n            padding: 0;\n            box-sizing: border-box;\n        }\n\n        :root {\n            --gray-50: #f9fafb;\n            --gray-100: #f3f4f6;\n            --gray-200: #e5e7eb;\n            --gray-600: #4b5563;\n            --gray-700: #374151;\n            --gray-800: #1f2937;\n            --gray-900: #111827;\n            --primary: #2563eb;\n            --primary-dark: #1d4ed8;\n            --green: #10b981;\n            --red: #ef4444;\n        }\n\n        body {\n            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n            line-height: 1.6;\n            color: var(--gray-900);\n            background: var(--gray-50);\n            margin: 0;\n        }\n\n        header {\n            background: white;\n            border-bottom: 1px solid var(--gray-200);\n            padding: 1.5rem 0;\n            position: sticky;\n            top: 0;\n            z-index: 100;\n        }\n\n        .header-content {\n            display: flex;\n            align-items: center;\n            justify-content: space-between;\n            padding: 0 2rem;\n        }\n\n        .brand {\n            display: flex;\n            align-items: center;\n            gap: 0.75rem;\n        }\n\n        .brand-icon {\n            width: 32px;\n            height: 32px;\n            background: var(--primary);\n            border-radius: 8px;\n            display: flex;\n            align-items: center;\n            justify-content: center;\n        }\n\n        .brand-icon svg {\n            width: 16px;\n            height: 16px;\n            stroke: white;\n        }\n\n        .brand-text h1 {\n            font-size: 1.25rem;\n            font-weight: 700;\n            color: var(--gray-900);\n        }\n\n        .brand-text .subtitle {\n            font-size: 0.875rem;\n            color: var(--gray-600);\n        }\n\n        .back-link {\n            display: flex;\n            align-items: center;\n            gap: 0.5rem;\n            padding: 0.5rem 1rem;\n            background: var(--primary);\n            color: white;\n            text-decoration: none;\n            border-radius: 6px;\n            font-size: 0.875rem;\n            font-weight: 500;\n            transition: background 0.2s;\n        }\n\n        .back-link:hover {\n            background: var(--primary-dark);\n        }\n\n        .back-link svg {\n            width: 16px;\n            height: 16px;\n        }\n\n        .main-layout {\n            display: flex;\n        }\n\n        .sidebar {\n            width: 240px;\n            background: white;\n            border-right: 1px solid var(--gray-200);\n            padding: 2rem 0;\n            position: sticky;\n            top: 73px;\n            height: calc(100vh - 73px);\n            overflow-y: auto;\n            flex-shrink: 0;\n        }\n\n        .sidebar-title {\n            font-size: 0.75rem;\n            font-weight: 600;\n            text-transform: uppercase;\n            color: var(--gray-600);\n            padding: 0 1.5rem;\n            margin: 1.5rem 0 0.5rem;\n        }\n\n        .sidebar a {\n            display: block;\n            padding: 0.625rem 1.5rem;\n            color: var(--gray-700);\n            text-decoration: none;\n            font-size: 0.875rem;\n            transition: all 0.15s;\n            border-left: 3px solid transparent;\n        }\n\n        .sidebar a:hover {\n            background: var(--gray-50);\n            color: var(--primary);\n            border-left-color: var(--primary);\n        }\n\n        .content-wrapper {\n            flex: 1;\n            display: flex;\n            flex-direction: column;\n            min-height: calc(100vh - 73px);\n        }\n\n        .section-row {\n            display: grid;\n            grid-template-columns: 1fr 600px;\n        }\n\n        .docs-section {\n            padding: 3rem 3rem;\n            background: white;\n            border-right: 1px solid var(--gray-200);\n        }\n\n        .examples-section {\n            padding: 3rem 2rem;\n            background: #0d1117;\n            color: #e6edf3;\n        }\n\n        .examples-section h4 {\n            color: #e6edf3;\n            font-size: 0.875rem;\n            font-weight: 600;\n            margin-bottom: 1rem;\n            text-transform: uppercase;\n            letter-spacing: 0.05em;\n        }\n\n        h2 {\n            font-size: 2rem;\n            font-weight: 700;\n            margin-bottom: 1rem;\n            color: var(--gray-900);\n        }\n\n        h3 {\n            font-size: 1.375rem;\n            font-weight: 600;\n            margin: 2.5rem 0 1rem;\n            color: var(--gray-900);\n        }\n\n        h4 {\n            font-size: 1rem;\n            font-weight: 600;\n            margin: 1.5rem 0 0.75rem;\n            color: var(--gray-700);\n        }\n\n        p {\n            margin-bottom: 1rem;\n            color: var(--gray-600);\n        }\n\n        .intro {\n            background: linear-gradient(135deg, rgba(37, 99, 235, 0.08) 0%, rgba(59, 130, 246, 0.08) 100%);\n            border: 1px solid rgba(37, 99, 235, 0.2);\n            border-radius: 8px;\n            padding: 1.5rem;\n            margin-bottom: 2rem;\n        }\n\n        .intro p {\n            color: var(--gray-700);\n            margin: 0;\n        }\n\n        .endpoint {\n            margin: 2rem 0;\n            padding: 1.5rem;\n            background: var(--gray-50);\n            border-radius: 8px;\n            border: 1px solid var(--gray-200);\n        }\n\n        .endpoint-header {\n            display: flex;\n            align-items: center;\n            gap: 1rem;\n            margin-bottom: 1rem;\n        }\n\n        .method {\n            padding: 0.375rem 0.875rem;\n            border-radius: 6px;\n            font-weight: 700;\n            font-size: 0.75rem;\n            text-transform: uppercase;\n        }\n\n        .method-post {\n            background: var(--green);\n            color: white;\n        }\n\n        .endpoint-path {\n            font-family: 'Monaco', 'Menlo', monospace;\n            font-size: 0.9375rem;\n            font-weight: 600;\n        }\n\n        table {\n            width: 100%;\n            border-collapse: collapse;\n            margin: 1rem 0;\n            font-size: 0.875rem;\n        }\n\n        th {\n            text-align: left;\n            padding: 0.875rem;\n            background: var(--gray-100);\n            border: 1px solid var(--gray-200);\n            font-weight: 600;\n            color: var(--gray-800);\n        }\n\n        td {\n            padding: 0.875rem;\n            border: 1px solid var(--gray-200);\n            color: var(--gray-700);\n        }\n\n        code {\n            background: rgba(37, 99, 235, 0.08);\n            padding: 0.1875rem 0.5rem;\n            border-radius: 4px;\n            font-family: 'Monaco', 'Menlo', monospace;\n            font-size: 0.875em;\n            color: var(--primary-dark);\n        }\n\n        .api-url {\n            color: #60a5fa;\n        }\n\n        .badge {\n            display: inline-block;\n            padding: 0.1875rem 0.625rem;\n            border-radius: 12px;\n            font-size: 0.6875rem;\n            font-weight: 600;\n            text-transform: uppercase;\n        }\n\n        .badge-required {\n            background: var(--red);\n            color: white;\n        }\n\n        .badge-optional {\n            background: var(--gray-200);\n            color: var(--gray-700);\n        }\n\n        .note {\n            padding: 1.25rem;\n            background: rgba(37, 99, 235, 0.05);\n            border-left: 4px solid var(--primary);\n            border-radius: 8px;\n            margin: 1rem 0;\n            font-size: 0.875rem;\n        }\n\n        /* Code tabs in side panel */\n        .tab-buttons {\n            display: flex;\n            gap: 0.5rem;\n            margin-bottom: 1rem;\n        }\n\n        .tab-button {\n            padding: 0.5rem 1rem;\n            background: transparent;\n            border: 1px solid #30363d;\n            cursor: pointer;\n            font-size: 0.8125rem;\n            font-weight: 500;\n            color: #7d8590;\n            border-radius: 6px;\n            transition: all 0.2s;\n        }\n\n        .tab-button:hover {\n            color: #e6edf3;\n            border-color: #58a6ff;\n        }\n\n        .tab-button.active {\n            color: #e6edf3;\n            background: #1f6feb;\n            border-color: #1f6feb;\n        }\n\n        .tab-content {\n            display: none;\n        }\n\n        .tab-content.active {\n            display: block;\n        }\n\n        pre[class*=\"language-\"] {\n            margin: 0 0 1.5rem 0;\n            border-radius: 6px;\n            font-size: 0.8125rem;\n        }\n\n        .example-block {\n            margin-bottom: 2rem;\n        }\n\n        @media (max-width: 1400px) {\n            .section-row {\n                grid-template-columns: 1fr 500px;\n            }\n        }\n\n        @media (max-width: 1200px) {\n            .section-row {\n                grid-template-columns: 1fr;\n            }\n\n            .examples-section {\n                border-top: 1px solid #30363d;\n            }\n        }\n\n        @media (max-width: 768px) {\n            .main-layout {\n                flex-direction: column;\n            }\n            \n            .sidebar {\n                width: 100%;\n                position: relative;\n                height: auto;\n                border-right: none;\n                border-bottom: 1px solid var(--gray-200);\n            }\n            \n            .docs-section {\n                padding: 2rem 1.5rem;\n            }\n\n            .examples-section {\n                padding: 2rem 1.5rem;\n            }\n        }\n    </style>\n</head>\n<body>\n    <header>\n        <div class=\"header-content\">\n            <div class=\"brand\">\n                <div class=\"brand-icon\">\n                    <svg fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                        <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z\"/>\n                    </svg>\n                </div>\n                <div class=\"brand-text\">\n                    <h1>Claude Code UI</h1>\n                    <div class=\"subtitle\">API Documentation</div>\n                </div>\n            </div>\n            <a href=\"/\" class=\"back-link\">\n                <svg fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M10 19l-7-7m0 0l7-7m-7 7h18\"/>\n                </svg>\n                Back to App\n            </a>\n        </div>\n    </header>\n\n    <div class=\"main-layout\">\n        <nav class=\"sidebar\">\n            <div class=\"sidebar-title\">Getting Started</div>\n            <a href=\"#authentication\">Authentication</a>\n            <a href=\"#credentials\">GitHub Credentials</a>\n\n            <div class=\"sidebar-title\">API Reference</div>\n            <a href=\"#agent\">Agent</a>\n\n            <div class=\"sidebar-title\">Examples</div>\n            <a href=\"#usage-examples\">Usage Patterns</a>\n        </nav>\n\n        <div class=\"content-wrapper\">\n            <!-- Intro Section -->\n            <div class=\"section-row\">\n                <div class=\"docs-section\">\n                    <div class=\"intro\">\n                        <p><strong>Programmatically trigger AI agents to work on projects.</strong> Clone GitHub repositories or use existing project paths. Perfect for CI/CD pipelines, automated code reviews, and bulk processing.</p>\n                    </div>\n\n                    <section id=\"authentication\">\n                        <h2>Authentication</h2>\n                        <p>All API requests require authentication using an API key in the <code>X-API-Key</code> header.</p>\n\n                        <p>Generate API keys in Settings → API & Tokens.</p>\n                    </section>\n\n                    <section id=\"credentials\">\n                        <h3>GitHub Credentials</h3>\n                        <p>For private repositories, store a GitHub token in settings or pass it with each request.</p>\n\n                        <div class=\"note\">\n                            <strong>Note:</strong> GitHub tokens in the request override stored tokens.\n                        </div>\n                    </section>\n                </div>\n\n                <div class=\"examples-section\">\n                    <div class=\"example-block\">\n                        <h4>Authentication Header</h4>\n                        <pre><code class=\"language-http\">X-API-Key: ck_your_api_key_here</code></pre>\n                    </div>\n                </div>\n            </div>\n\n            <!-- Agent API Section -->\n            <div class=\"section-row\">\n                <div class=\"docs-section\">\n                    <section id=\"agent\">\n                        <h2>Agent</h2>\n\n                        <div class=\"endpoint\">\n                            <div class=\"endpoint-header\">\n                                <span class=\"method method-post\">POST</span>\n                                <span class=\"endpoint-path\"><span class=\"api-url\">http://localhost:3001</span>/api/agent</span>\n                            </div>\n\n                            <p>Trigger an AI agent (Claude, Cursor, or Codex) to work on a project.</p>\n\n                            <h4>Request Body Parameters</h4>\n                            <table>\n                                <thead>\n                                    <tr>\n                                        <th>Parameter</th>\n                                        <th>Type</th>\n                                        <th>Required</th>\n                                        <th>Description</th>\n                                    </tr>\n                                </thead>\n                                <tbody>\n                                    <tr>\n                                        <td><code>githubUrl</code></td>\n                                        <td>string</td>\n                                        <td><span class=\"badge badge-optional\">Conditional</span></td>\n                                        <td>GitHub repository URL to clone. If path exists with same repo, reuses it. If path exists with different repo, returns error.</td>\n                                    </tr>\n                                    <tr>\n                                        <td><code>projectPath</code></td>\n                                        <td>string</td>\n                                        <td><span class=\"badge badge-optional\">Conditional</span></td>\n                                        <td>Path to existing project OR destination for cloning. If omitted with <code>githubUrl</code>, auto-generates path. If used alone, must point to existing project directory.</td>\n                                    </tr>\n                                    <tr>\n                                        <td><code>message</code></td>\n                                        <td>string</td>\n                                        <td><span class=\"badge badge-required\">Required</span></td>\n                                        <td>Task for the AI agent</td>\n                                    </tr>\n                                    <tr>\n                                        <td><code>provider</code></td>\n                                        <td>string</td>\n                                        <td><span class=\"badge badge-optional\">Optional</span></td>\n                                        <td><code>claude</code>, <code>cursor</code>, or <code>codex</code> (default: <code>claude</code>)</td>\n                                    </tr>\n                                    <tr>\n                                        <td><code>stream</code></td>\n                                        <td>boolean</td>\n                                        <td><span class=\"badge badge-optional\">Optional</span></td>\n                                        <td>Enable streaming (default: <code>true</code>)</td>\n                                    </tr>\n                                    <tr>\n                                        <td><code>model</code></td>\n                                        <td>string</td>\n                                        <td><span class=\"badge badge-optional\">Optional</span></td>\n                                        <td id=\"model-options-cell\">\n                                            Model identifier for the AI provider (loading from constants...)\n                                        </td>\n                                    </tr>\n                                    <tr>\n                                        <td><code>cleanup</code></td>\n                                        <td>boolean</td>\n                                        <td><span class=\"badge badge-optional\">Optional</span></td>\n                                        <td>Auto-cleanup after completion (default: <code>true</code>). Only applies when cloning via <code>githubUrl</code>. Existing projects specified via <code>projectPath</code> are never cleaned up.</td>\n                                    </tr>\n                                    <tr>\n                                        <td><code>githubToken</code></td>\n                                        <td>string</td>\n                                        <td><span class=\"badge badge-optional\">Optional</span></td>\n                                        <td>GitHub token for private repos</td>\n                                    </tr>\n                                    <tr>\n                                        <td><code>branchName</code></td>\n                                        <td>string</td>\n                                        <td><span class=\"badge badge-optional\">Optional</span></td>\n                                        <td>Custom branch name to use. If provided, <code>createBranch</code> is automatically enabled. Branch names are validated against Git naming rules. Works with <code>githubUrl</code> or <code>projectPath</code> (if it has a GitHub remote).</td>\n                                    </tr>\n                                    <tr>\n                                        <td><code>createBranch</code></td>\n                                        <td>boolean</td>\n                                        <td><span class=\"badge badge-optional\">Optional</span></td>\n                                        <td>Create a new branch after successful completion (default: <code>false</code>). Automatically set to <code>true</code> if <code>branchName</code> is provided. Works with <code>githubUrl</code> or <code>projectPath</code> (if it has a GitHub remote).</td>\n                                    </tr>\n                                    <tr>\n                                        <td><code>createPR</code></td>\n                                        <td>boolean</td>\n                                        <td><span class=\"badge badge-optional\">Optional</span></td>\n                                        <td>Create a pull request after successful completion (default: <code>false</code>). PR title and description auto-generated from commit messages. Works with <code>githubUrl</code> or <code>projectPath</code> (if it has a GitHub remote).</td>\n                                    </tr>\n                                </tbody>\n                            </table>\n\n                            <div class=\"note\">\n                                <strong>Path Handling Behavior:</strong><br><br>\n                                <strong>Scenario 1:</strong> Only <code>githubUrl</code> → Clones to auto-generated temporary path<br>\n                                <strong>Scenario 2:</strong> Only <code>projectPath</code> → Uses existing project at specified path<br>\n                                <strong>Scenario 3:</strong> Both provided → Clones <code>githubUrl</code> to <code>projectPath</code><br><br>\n                                <strong>Validation:</strong> If <code>projectPath</code> exists and contains a git repository, the remote URL is compared with <code>githubUrl</code>. If URLs match, the existing repo is reused. If URLs differ, an error is returned.\n                            </div>\n\n                            <h4>Response (Streaming)</h4>\n                            <p>Server-sent events (SSE) format with real-time updates. Content-Type: <code>text/event-stream</code></p>\n\n                            <h4>Response (Non-Streaming)</h4>\n                            <p>JSON object containing session details, assistant messages only (filtered), and token usage summary. Content-Type: <code>application/json</code></p>\n\n                            <h4>Error Response</h4>\n                            <p>Returns error details with appropriate HTTP status code.</p>\n                        </div>\n                    </section>\n                </div>\n\n                <div class=\"examples-section\">\n                    <div class=\"example-block\">\n                        <h4>Basic Request</h4>\n                        <div class=\"tab-buttons\">\n                            <button class=\"tab-button active\" onclick=\"showTab('curl-basic')\">cURL</button>\n                            <button class=\"tab-button\" onclick=\"showTab('js-basic')\">JavaScript</button>\n                            <button class=\"tab-button\" onclick=\"showTab('python-basic')\">Python</button>\n                        </div>\n\n                        <div class=\"tab-content active\" id=\"curl-basic\">\n<pre><code class=\"language-bash\">curl -X POST <span class=\"api-url\">http://localhost:3001</span>/api/agent \\\n  -H \"Content-Type: application/json\" \\\n  -H \"X-API-Key: ck_...\" \\\n  -d '{\n    \"githubUrl\": \"https://github.com/user/repo\",\n    \"message\": \"Add error handling to main.js\"\n  }'</code></pre>\n                        </div>\n\n                        <div class=\"tab-content\" id=\"js-basic\">\n<pre><code class=\"language-javascript\">const response = await fetch('<span class=\"api-url\">http://localhost:3001</span>/api/agent', {\n  method: 'POST',\n  headers: {\n    'Content-Type': 'application/json',\n    'X-API-Key': process.env.CLAUDE_API_KEY\n  },\n  body: JSON.stringify({\n    githubUrl: 'https://github.com/user/repo',\n    message: 'Add error handling',\n    stream: false\n  })\n});\n\nconst result = await response.json();</code></pre>\n                        </div>\n\n                        <div class=\"tab-content\" id=\"python-basic\">\n<pre><code class=\"language-python\">import requests\nimport os\n\nresponse = requests.post(\n    '<span class=\"api-url\">http://localhost:3001</span>/api/agent',\n    headers={\n        'Content-Type': 'application/json',\n        'X-API-Key': os.environ['CLAUDE_API_KEY']\n    },\n    json={\n        'githubUrl': 'https://github.com/user/repo',\n        'message': 'Add error handling',\n        'stream': False\n    }\n)\n\nprint(response.json())</code></pre>\n                        </div>\n                    </div>\n\n                    <div class=\"example-block\">\n                        <h4>Streaming Response</h4>\n<pre><code class=\"language-javascript\">data: {\"type\":\"status\",\"message\":\"Repository cloned\"}\ndata: {\"type\":\"thinking\",\"content\":\"Analyzing...\"}\ndata: {\"type\":\"tool_use\",\"tool\":\"read_file\"}\ndata: {\"type\":\"content\",\"content\":\"Done!\"}\ndata: {\"type\":\"done\"}</code></pre>\n                    </div>\n\n                    <div class=\"example-block\">\n                        <h4>Non-Streaming Response</h4>\n<pre><code class=\"language-json\">{\n  \"success\": true,\n  \"sessionId\": \"abc123\",\n  \"messages\": [\n    {\n      \"type\": \"assistant\",\n      \"message\": {\n        \"role\": \"assistant\",\n        \"content\": [\n          {\n            \"type\": \"text\",\n            \"text\": \"I've completed the task...\"\n          }\n        ],\n        \"usage\": {\n          \"input_tokens\": 150,\n          \"output_tokens\": 50\n        }\n      }\n    }\n  ],\n  \"tokens\": {\n    \"inputTokens\": 150,\n    \"outputTokens\": 50,\n    \"cacheReadTokens\": 0,\n    \"cacheCreationTokens\": 0,\n    \"totalTokens\": 200\n  },\n  \"projectPath\": \"/path/to/project\",\n  \"branch\": {\n    \"name\": \"fix-authentication-bug-abc123\",\n    \"url\": \"https://github.com/user/repo/tree/fix-authentication-bug-abc123\"\n  },\n  \"pullRequest\": {\n    \"number\": 42,\n    \"url\": \"https://github.com/user/repo/pull/42\"\n  }\n}</code></pre>\n                    </div>\n\n                    <div class=\"example-block\">\n                        <h4>Error Response</h4>\n<pre><code class=\"language-json\">{\n  \"success\": false,\n  \"error\": \"Directory exists with different repo\"\n}</code></pre>\n                    </div>\n                </div>\n            </div>\n\n            <!-- Usage Patterns Section -->\n            <div class=\"section-row\">\n                <div class=\"docs-section\">\n                    <section id=\"usage-examples\">\n                        <h2>Usage Patterns</h2>\n\n                        <h3>Clone and Process Repository</h3>\n                        <p>Clone a repository to an auto-generated temporary path and process it.</p>\n\n                        <h3>Use Existing Project</h3>\n                        <p>Work with an existing project at a specific path.</p>\n\n                        <h3>Clone to Specific Path</h3>\n                        <p>Clone a repository to a custom location for later reuse.</p>\n\n                        <h3>CI/CD Integration</h3>\n                        <p>Integrate with GitHub Actions or other CI/CD pipelines.</p>\n\n                        <h3>Create Branch and Pull Request</h3>\n                        <p>Automatically create a new branch and pull request after the agent completes its work. Branch names are auto-generated from the message, and PR title/description are auto-generated from commit messages.</p>\n                    </section>\n                </div>\n\n                <div class=\"examples-section\">\n                    <div class=\"example-block\">\n                        <h4>Use Existing Project</h4>\n<pre><code class=\"language-bash\">curl -X POST <span class=\"api-url\">http://localhost:3001</span>/api/agent \\\n  -H \"Content-Type: application/json\" \\\n  -H \"X-API-Key: ck_...\" \\\n  -d '{\n    \"projectPath\": \"/home/user/my-project\",\n    \"message\": \"Refactor database queries\"\n  }'</code></pre>\n                    </div>\n\n                    <div class=\"example-block\">\n                        <h4>Clone to Custom Path</h4>\n<pre><code class=\"language-bash\">curl -X POST <span class=\"api-url\">http://localhost:3001</span>/api/agent \\\n  -H \"Content-Type: application/json\" \\\n  -H \"X-API-Key: ck_...\" \\\n  -d '{\n    \"githubUrl\": \"https://github.com/user/repo\",\n    \"projectPath\": \"/tmp/my-location\",\n    \"message\": \"Review security\",\n    \"cleanup\": false\n  }'</code></pre>\n                    </div>\n\n                    <div class=\"example-block\">\n                        <h4>CI/CD (GitHub Actions)</h4>\n<pre><code class=\"language-yaml\">- name: Trigger Agent\n  run: |\n    curl -X POST ${{ secrets.API_URL }}/api/agent \\\n      -H \"X-API-Key: ${{ secrets.API_KEY }}\" \\\n      -H \"Content-Type: application/json\" \\\n      -d '{\n        \"githubUrl\": \"${{ github.repository }}\",\n        \"message\": \"Review for security\",\n        \"githubToken\": \"${{ secrets.GITHUB_TOKEN }}\"\n      }'</code></pre>\n                    </div>\n\n                    <div class=\"example-block\">\n                        <h4>Create Branch and PR</h4>\n<pre><code class=\"language-bash\">curl -X POST <span class=\"api-url\">http://localhost:3001</span>/api/agent \\\n  -H \"Content-Type: application/json\" \\\n  -H \"X-API-Key: ck_...\" \\\n  -d '{\n    \"githubUrl\": \"https://github.com/user/repo\",\n    \"message\": \"Fix authentication bug\",\n    \"createBranch\": true,\n    \"createPR\": true,\n    \"stream\": false\n  }'</code></pre>\n                    </div>\n\n                    <div class=\"example-block\">\n                        <h4>Custom Branch Name</h4>\n<pre><code class=\"language-bash\">curl -X POST <span class=\"api-url\">http://localhost:3001</span>/api/agent \\\n  -H \"Content-Type: application/json\" \\\n  -H \"X-API-Key: ck_...\" \\\n  -d '{\n    \"githubUrl\": \"https://github.com/user/repo\",\n    \"message\": \"Add user authentication\",\n    \"branchName\": \"feature/user-auth\",\n    \"createPR\": true,\n    \"stream\": false\n  }'</code></pre>\n                    </div>\n\n                    <div class=\"example-block\">\n                        <h4>Branch & PR Response</h4>\n<pre><code class=\"language-json\">{\n  \"success\": true,\n  \"branch\": {\n    \"name\": \"feature/user-auth\",\n    \"url\": \"https://github.com/user/repo/tree/feature/user-auth\"\n  },\n  \"pullRequest\": {\n    \"number\": 42,\n    \"url\": \"https://github.com/user/repo/pull/42\"\n  }\n}</code></pre>\n                    </div>\n                </div>\n            </div>\n        </div>\n    </div>\n\n    <script type=\"module\">\n        // Import model constants\n        import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '/shared/modelConstants.js';\n\n        // Dynamic URL replacement\n        const apiUrl = window.location.origin;\n        document.querySelectorAll('.api-url').forEach(el => {\n            el.textContent = apiUrl;\n        });\n\n        // Dynamically populate model documentation\n        window.addEventListener('DOMContentLoaded', () => {\n            const modelCell = document.getElementById('model-options-cell');\n            if (modelCell) {\n                const claudeModels = CLAUDE_MODELS.OPTIONS.map(m => `<code>${m.value}</code>`).join(', ');\n                const cursorModels = CURSOR_MODELS.OPTIONS.slice(0, 8).map(m => `<code>${m.value}</code>`).join(', ');\n                const codexModels = CODEX_MODELS.OPTIONS.map(m => `<code>${m.value}</code>`).join(', ');\n\n                modelCell.innerHTML = `\n                    Model identifier for the AI provider:<br><br>\n                    <strong>Claude:</strong> ${claudeModels} (default: <code>${CLAUDE_MODELS.DEFAULT}</code>)<br><br>\n                    <strong>Cursor:</strong> ${cursorModels}, and more (default: <code>${CURSOR_MODELS.DEFAULT}</code>)<br><br>\n                    <strong>Codex:</strong> ${codexModels} (default: <code>${CODEX_MODELS.DEFAULT}</code>)\n                `;\n            }\n        });\n\n        // Tab switching\n        window.showTab = function(tabName) {\n            const parentBlock = event.target.closest('.example-block');\n            if (!parentBlock) return;\n\n            parentBlock.querySelectorAll('.tab-content').forEach(tab => {\n                tab.classList.remove('active');\n            });\n            parentBlock.querySelectorAll('.tab-button').forEach(btn => {\n                btn.classList.remove('active');\n            });\n\n            const targetTab = parentBlock.querySelector('#' + tabName);\n            if (targetTab) {\n                targetTab.classList.add('active');\n                event.target.classList.add('active');\n            }\n        };\n    </script>\n\n    <!-- Prism.js -->\n    <script src=\"https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js\"></script>\n    <script src=\"https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-bash.min.js\"></script>\n    <script src=\"https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-javascript.min.js\"></script>\n    <script src=\"https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-python.min.js\"></script>\n    <script src=\"https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-json.min.js\"></script>\n    <script src=\"https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-yaml.min.js\"></script>\n    <script src=\"https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-http.min.js\"></script>\n</body>\n</html>\n"
  },
  {
    "path": "public/clear-cache.html",
    "content": "<!DOCTYPE html>\n<html>\n<head>\n    <title>Clear Cache - Claude Code UI</title>\n    <style>\n        body {\n            font-family: system-ui, -apple-system, sans-serif;\n            max-width: 600px;\n            margin: 50px auto;\n            padding: 20px;\n            line-height: 1.6;\n        }\n        .success { color: green; }\n        .error { color: red; }\n        button {\n            background: #007bff;\n            color: white;\n            border: none;\n            padding: 10px 20px;\n            border-radius: 5px;\n            cursor: pointer;\n            font-size: 16px;\n            margin: 10px 5px;\n        }\n        button:hover {\n            background: #0056b3;\n        }\n        #status {\n            margin-top: 20px;\n            padding: 15px;\n            border-radius: 5px;\n            background: #f0f0f0;\n        }\n    </style>\n</head>\n<body>\n    <h1>Clear Cache & Service Worker</h1>\n    <p>If you're seeing a blank page or old content, click the button below to clear all cached data.</p>\n\n    <button onclick=\"clearEverything()\">Clear Cache & Reload</button>\n\n    <div id=\"status\"></div>\n\n    <script>\n        async function clearEverything() {\n            const status = document.getElementById('status');\n            status.innerHTML = '<p>Clearing cache and service workers...</p>';\n\n            try {\n                // Unregister all service workers\n                if ('serviceWorker' in navigator) {\n                    const registrations = await navigator.serviceWorker.getRegistrations();\n                    for (let registration of registrations) {\n                        await registration.unregister();\n                        status.innerHTML += '<p class=\"success\">✓ Unregistered service worker</p>';\n                    }\n                }\n\n                // Clear all caches\n                if ('caches' in window) {\n                    const cacheNames = await caches.keys();\n                    for (let cacheName of cacheNames) {\n                        await caches.delete(cacheName);\n                        status.innerHTML += `<p class=\"success\">✓ Deleted cache: ${cacheName}</p>`;\n                    }\n                }\n\n                // Clear localStorage\n                localStorage.clear();\n                status.innerHTML += '<p class=\"success\">✓ Cleared localStorage</p>';\n\n                // Clear sessionStorage\n                sessionStorage.clear();\n                status.innerHTML += '<p class=\"success\">✓ Cleared sessionStorage</p>';\n\n                status.innerHTML += '<p class=\"success\"><strong>✓ All caches cleared!</strong></p>';\n                status.innerHTML += '<p>Cache cleared successfully. You can now close this tab or <a href=\"/\">go to home page</a>.</p>';\n\n            } catch (error) {\n                status.innerHTML += `<p class=\"error\">✗ Error: ${error.message}</p>`;\n            }\n        }\n    </script>\n</body>\n</html>\n"
  },
  {
    "path": "public/convert-icons.md",
    "content": "# Convert SVG Icons to PNG\n\nI've created SVG versions of the app icons that match the MessageSquare design from the sidebar. To convert them to PNG format, you can use one of these methods:\n\n## Method 1: Online Converter (Easiest)\n1. Go to https://cloudconvert.com/svg-to-png\n2. Upload each SVG file from the `/icons/` directory\n3. Download the PNG versions\n4. Replace the existing PNG files\n\n## Method 2: Using Node.js (if you have it)\n```bash\nnpm install sharp\nnode -e \"\nconst sharp = require('sharp');\nconst fs = require('fs');\nconst sizes = [72, 96, 128, 144, 152, 192, 384, 512];\nsizes.forEach(size => {\n  const svgPath = \\`./icons/icon-\\${size}x\\${size}.svg\\`;\n  const pngPath = \\`./icons/icon-\\${size}x\\${size}.png\\`;\n  if (fs.existsSync(svgPath)) {\n    sharp(svgPath).png().toFile(pngPath);\n    console.log(\\`Converted \\${svgPath} to \\${pngPath}\\`);\n  }\n});\n\"\n```\n\n## Method 3: Using ImageMagick (if installed)\n```bash\ncd public/icons\nfor size in 72 96 128 144 152 192 384 512; do\n  convert \"icon-${size}x${size}.svg\" \"icon-${size}x${size}.png\"\ndone\n```\n\n## Method 4: Using Inkscape (if installed)\n```bash\ncd public/icons\nfor size in 72 96 128 144 152 192 384 512; do\n  inkscape --export-type=png \"icon-${size}x${size}.svg\"\ndone\n```\n\n## Icon Design\nThe new icons feature:\n- Clean MessageSquare (chat bubble) design matching the sidebar\n- Primary color background with rounded corners\n- White stroke icon that's clearly visible\n- Consistent sizing and proportions across all sizes\n- Proper PWA-compliant format\n\nOnce converted, the PNG files will replace the existing ones and provide a consistent icon experience across all platforms."
  },
  {
    "path": "public/generate-icons.js",
    "content": "const fs = require('fs');\nconst path = require('path');\n\n// Icon sizes needed\nconst sizes = [72, 96, 128, 144, 152, 192, 384, 512];\n\n// SVG template function\nfunction createIconSVG(size) {\n  const cornerRadius = Math.round(size * 0.25); // 25% corner radius\n  const strokeWidth = Math.max(2, Math.round(size * 0.06)); // Scale stroke width\n  \n  // MessageSquare path scaled to size\n  const padding = Math.round(size * 0.25);\n  const iconSize = size - (padding * 2);\n  const startX = padding;\n  const startY = Math.round(padding * 0.7);\n  const endX = startX + iconSize;\n  const endY = startY + Math.round(iconSize * 0.6);\n  const tailX = startX;\n  const tailY = endY + Math.round(iconSize * 0.3);\n  \n  return `<svg width=\"${size}\" height=\"${size}\" viewBox=\"0 0 ${size} ${size}\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n  <!-- Background with rounded corners -->\n  <rect width=\"${size}\" height=\"${size}\" rx=\"${cornerRadius}\" fill=\"hsl(262.1 83.3% 57.8%)\"/>\n  \n  <!-- MessageSquare icon -->\n  <path d=\"M${startX} ${startY}C${startX} ${startY - 10} ${startX + 10} ${startY - 20} ${startX + 20} ${startY - 20}H${endX - 20}C${endX - 10} ${startY - 20} ${endX} ${startY - 10} ${endX} ${startY}V${endY - 20}C${endX} ${endY - 10} ${endX - 10} ${endY} ${endX - 20} ${endY}H${startX + Math.round(iconSize * 0.4)}L${tailX} ${tailY}V${startY}Z\" \n        stroke=\"white\" \n        stroke-width=\"${strokeWidth}\" \n        stroke-linecap=\"round\" \n        stroke-linejoin=\"round\" \n        fill=\"none\"/>\n</svg>`;\n}\n\n// Generate SVG files for each size\nsizes.forEach(size => {\n  const svgContent = createIconSVG(size);\n  const filename = `icon-${size}x${size}.svg`;\n  const filepath = path.join(__dirname, 'icons', filename);\n  \n  fs.writeFileSync(filepath, svgContent);\n  console.log(`Created ${filename}`);\n});\n\nconsole.log('\\nSVG icons created! To convert to PNG, you can use:');\nconsole.log('1. Online converter like cloudconvert.com');\nconsole.log('2. If you have ImageMagick: convert icon.svg icon.png');\nconsole.log('3. If you have Inkscape: inkscape --export-type=png icon.svg');"
  },
  {
    "path": "public/manifest.json",
    "content": "{\n  \"name\": \"CloudCLI UI\",\n  \"short_name\": \"CloudCLI UI\",\n  \"description\": \"CloudCLI UI web application\",\n  \"start_url\": \"/\",\n  \"display\": \"standalone\",\n  \"background_color\": \"#ffffff\",\n  \"theme_color\": \"#ffffff\",\n  \"orientation\": \"portrait-primary\",\n  \"scope\": \"/\",\n  \"icons\": [\n    {\n      \"src\": \"/icons/icon-72x72.png\",\n      \"sizes\": \"72x72\",\n      \"type\": \"image/png\",\n      \"purpose\": \"maskable any\"\n    },\n    {\n      \"src\": \"/icons/icon-96x96.png\",\n      \"sizes\": \"96x96\",\n      \"type\": \"image/png\",\n      \"purpose\": \"maskable any\"\n    },\n    {\n      \"src\": \"/icons/icon-128x128.png\",\n      \"sizes\": \"128x128\",\n      \"type\": \"image/png\",\n      \"purpose\": \"maskable any\"\n    },\n    {\n      \"src\": \"/icons/icon-144x144.png\",\n      \"sizes\": \"144x144\",\n      \"type\": \"image/png\",\n      \"purpose\": \"maskable any\"\n    },\n    {\n      \"src\": \"/icons/icon-152x152.png\",\n      \"sizes\": \"152x152\",\n      \"type\": \"image/png\",\n      \"purpose\": \"maskable any\"\n    },\n    {\n      \"src\": \"/icons/icon-192x192.png\",\n      \"sizes\": \"192x192\",\n      \"type\": \"image/png\",\n      \"purpose\": \"maskable any\"\n    },\n    {\n      \"src\": \"/icons/icon-384x384.png\",\n      \"sizes\": \"384x384\",\n      \"type\": \"image/png\",\n      \"purpose\": \"maskable any\"\n    },\n    {\n      \"src\": \"/icons/icon-512x512.png\",\n      \"sizes\": \"512x512\",\n      \"type\": \"image/png\",\n      \"purpose\": \"maskable any\"\n    }\n  ]\n}"
  },
  {
    "path": "public/sw.js",
    "content": "// Service Worker for Claude Code UI PWA\nconst CACHE_NAME = 'claude-ui-v1';\nconst urlsToCache = [\n  '/',\n  '/index.html',\n  '/manifest.json'\n];\n\n// Install event\nself.addEventListener('install', event => {\n  event.waitUntil(\n    caches.open(CACHE_NAME)\n      .then(cache => {\n        return cache.addAll(urlsToCache);\n      })\n  );\n  self.skipWaiting();\n});\n\n// Fetch event\nself.addEventListener('fetch', event => {\n  // Never cache API requests or WebSocket upgrades\n  if (event.request.url.includes('/api/') || event.request.url.includes('/ws')) {\n    return;\n  }\n\n  event.respondWith(\n    caches.match(event.request)\n      .then(response => {\n        if (response) {\n          return response;\n        }\n        return fetch(event.request);\n      }\n    )\n  );\n});\n\n// Activate event\nself.addEventListener('activate', event => {\n  event.waitUntil(\n    caches.keys().then(cacheNames => {\n      return Promise.all(\n        cacheNames.map(cacheName => {\n          if (cacheName !== CACHE_NAME) {\n            return caches.delete(cacheName);\n          }\n        })\n      );\n    })\n  );\n  self.clients.claim();\n});\n\n// Push notification event\nself.addEventListener('push', event => {\n  if (!event.data) return;\n\n  let payload;\n  try {\n    payload = event.data.json();\n  } catch {\n    payload = { title: 'Claude Code UI', body: event.data.text() };\n  }\n\n  const options = {\n    body: payload.body || '',\n    icon: '/logo-256.png',\n    badge: '/logo-128.png',\n    data: payload.data || {},\n    tag: payload.data?.tag || `${payload.data?.sessionId || 'global'}:${payload.data?.code || 'default'}`,\n    renotify: true\n  };\n\n  event.waitUntil(\n    self.registration.showNotification(payload.title || 'Claude Code UI', options)\n  );\n});\n\n// Notification click event\nself.addEventListener('notificationclick', event => {\n  event.notification.close();\n\n  const sessionId = event.notification.data?.sessionId;\n  const provider = event.notification.data?.provider || null;\n  const urlPath = sessionId ? `/session/${sessionId}` : '/';\n\n  event.waitUntil(\n    self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(async clientList => {\n      for (const client of clientList) {\n        if (client.url.includes(self.location.origin)) {\n          await client.focus();\n          client.postMessage({\n            type: 'notification:navigate',\n            sessionId: sessionId || null,\n            provider,\n            urlPath\n          });\n          return;\n        }\n      }\n      return self.clients.openWindow(urlPath);\n    })\n  );\n});\n"
  },
  {
    "path": "release.sh",
    "content": "#!/bin/bash\n# Load environment variables from .env\nexport $(grep -v '^#' .env | grep '^GITHUB_TOKEN=' | xargs)\nexec npx release-it \"$@\"\n"
  },
  {
    "path": "scripts/fix-node-pty.js",
    "content": "#!/usr/bin/env node\n/**\n * Fix node-pty spawn-helper permissions on macOS\n *\n * This script fixes a known issue with node-pty where the spawn-helper\n * binary is shipped without execute permissions, causing \"posix_spawnp failed\" errors.\n *\n * @see https://github.com/microsoft/node-pty/issues/850\n * @module scripts/fix-node-pty\n */\n\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport { fileURLToPath } from 'url';\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = path.dirname(__filename);\n\n/**\n * Fixes the spawn-helper binary permissions for node-pty on macOS.\n *\n * The node-pty package ships the spawn-helper binary without execute permissions\n * (644 instead of 755), which causes \"posix_spawnp failed\" errors when trying\n * to spawn terminal processes.\n *\n * This function:\n * 1. Checks if running on macOS (darwin)\n * 2. Locates spawn-helper binaries for both arm64 and x64 architectures\n * 3. Sets execute permissions (755) on each binary found\n *\n * @async\n * @function fixSpawnHelper\n * @returns {Promise<void>} Resolves when permissions are fixed or skipped\n * @example\n * // Run as postinstall script\n * await fixSpawnHelper();\n */\nasync function fixSpawnHelper() {\n  const nodeModulesPath = path.join(__dirname, '..', 'node_modules', 'node-pty', 'prebuilds');\n\n  // Only run on macOS\n  if (process.platform !== 'darwin') {\n    return;\n  }\n\n  const darwinDirs = ['darwin-arm64', 'darwin-x64'];\n\n  for (const dir of darwinDirs) {\n    const spawnHelperPath = path.join(nodeModulesPath, dir, 'spawn-helper');\n\n    try {\n      // Check if file exists\n      await fs.access(spawnHelperPath);\n\n      // Make it executable (755)\n      await fs.chmod(spawnHelperPath, 0o755);\n      console.log(`[postinstall] Fixed permissions for ${spawnHelperPath}`);\n    } catch (err) {\n      // File doesn't exist or other error - ignore\n      if (err.code !== 'ENOENT') {\n        console.warn(`[postinstall] Warning: Could not fix ${spawnHelperPath}: ${err.message}`);\n      }\n    }\n  }\n}\n\nfixSpawnHelper().catch(console.error);\n"
  },
  {
    "path": "server/claude-sdk.js",
    "content": "/**\n * Claude SDK Integration\n *\n * This module provides SDK-based integration with Claude using the @anthropic-ai/claude-agent-sdk.\n * It mirrors the interface of claude-cli.js but uses the SDK internally for better performance\n * and maintainability.\n *\n * Key features:\n * - Direct SDK integration without child processes\n * - Session management with abort capability\n * - Options mapping between CLI and SDK formats\n * - WebSocket message streaming\n */\n\nimport { query } from '@anthropic-ai/claude-agent-sdk';\nimport crypto from 'crypto';\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport os from 'os';\nimport { CLAUDE_MODELS } from '../shared/modelConstants.js';\nimport {\n  createNotificationEvent,\n  notifyRunFailed,\n  notifyRunStopped,\n  notifyUserIfEnabled\n} from './services/notification-orchestrator.js';\nimport { claudeAdapter } from './providers/claude/adapter.js';\nimport { createNormalizedMessage } from './providers/types.js';\n\nconst activeSessions = new Map();\nconst pendingToolApprovals = new Map();\n\nconst TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;\n\nconst TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion']);\n\nfunction createRequestId() {\n  if (typeof crypto.randomUUID === 'function') {\n    return crypto.randomUUID();\n  }\n  return crypto.randomBytes(16).toString('hex');\n}\n\nfunction waitForToolApproval(requestId, options = {}) {\n  const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel, metadata } = options;\n\n  return new Promise(resolve => {\n    let settled = false;\n\n    const finalize = (decision) => {\n      if (settled) return;\n      settled = true;\n      cleanup();\n      resolve(decision);\n    };\n\n    let timeout;\n\n    const cleanup = () => {\n      pendingToolApprovals.delete(requestId);\n      if (timeout) clearTimeout(timeout);\n      if (signal && abortHandler) {\n        signal.removeEventListener('abort', abortHandler);\n      }\n    };\n\n    // timeoutMs 0 = wait indefinitely (interactive tools)\n    if (timeoutMs > 0) {\n      timeout = setTimeout(() => {\n        onCancel?.('timeout');\n        finalize(null);\n      }, timeoutMs);\n    }\n\n    const abortHandler = () => {\n      onCancel?.('cancelled');\n      finalize({ cancelled: true });\n    };\n\n    if (signal) {\n      if (signal.aborted) {\n        onCancel?.('cancelled');\n        finalize({ cancelled: true });\n        return;\n      }\n      signal.addEventListener('abort', abortHandler, { once: true });\n    }\n\n    const resolver = (decision) => {\n      finalize(decision);\n    };\n    // Attach metadata for getPendingApprovalsForSession lookup\n    if (metadata) {\n      Object.assign(resolver, metadata);\n    }\n    pendingToolApprovals.set(requestId, resolver);\n  });\n}\n\nfunction resolveToolApproval(requestId, decision) {\n  const resolver = pendingToolApprovals.get(requestId);\n  if (resolver) {\n    resolver(decision);\n  }\n}\n\n// Match stored permission entries against a tool + input combo.\n// This only supports exact tool names and the Bash(command:*) shorthand\n// used by the UI; it intentionally does not implement full glob semantics,\n// introduced to stay consistent with the UI's \"Allow rule\" format.\nfunction matchesToolPermission(entry, toolName, input) {\n  if (!entry || !toolName) {\n    return false;\n  }\n\n  if (entry === toolName) {\n    return true;\n  }\n\n  const bashMatch = entry.match(/^Bash\\((.+):\\*\\)$/);\n  if (toolName === 'Bash' && bashMatch) {\n    const allowedPrefix = bashMatch[1];\n    let command = '';\n\n    if (typeof input === 'string') {\n      command = input.trim();\n    } else if (input && typeof input === 'object' && typeof input.command === 'string') {\n      command = input.command.trim();\n    }\n\n    if (!command) {\n      return false;\n    }\n\n    return command.startsWith(allowedPrefix);\n  }\n\n  return false;\n}\n\n/**\n * Maps CLI options to SDK-compatible options format\n * @param {Object} options - CLI options\n * @returns {Object} SDK-compatible options\n */\nfunction mapCliOptionsToSDK(options = {}) {\n  const { sessionId, cwd, toolsSettings, permissionMode } = options;\n\n  const sdkOptions = {};\n\n  // Map working directory\n  if (cwd) {\n    sdkOptions.cwd = cwd;\n  }\n\n  // Map permission mode\n  if (permissionMode && permissionMode !== 'default') {\n    sdkOptions.permissionMode = permissionMode;\n  }\n\n  // Map tool settings\n  const settings = toolsSettings || {\n    allowedTools: [],\n    disallowedTools: [],\n    skipPermissions: false\n  };\n\n  // Handle tool permissions\n  if (settings.skipPermissions && permissionMode !== 'plan') {\n    // When skipping permissions, use bypassPermissions mode\n    sdkOptions.permissionMode = 'bypassPermissions';\n  }\n\n  let allowedTools = [...(settings.allowedTools || [])];\n\n  // Add plan mode default tools\n  if (permissionMode === 'plan') {\n    const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch'];\n    for (const tool of planModeTools) {\n      if (!allowedTools.includes(tool)) {\n        allowedTools.push(tool);\n      }\n    }\n  }\n\n  sdkOptions.allowedTools = allowedTools;\n\n  // Use the tools preset to make all default built-in tools available (including AskUserQuestion).\n  // This was introduced in SDK 0.1.57. Omitting this preserves existing behavior (all tools available),\n  // but being explicit ensures forward compatibility and clarity.\n  sdkOptions.tools = { type: 'preset', preset: 'claude_code' };\n\n  sdkOptions.disallowedTools = settings.disallowedTools || [];\n\n  // Map model (default to sonnet)\n  // Valid models: sonnet, opus, haiku, opusplan, sonnet[1m]\n  sdkOptions.model = options.model || CLAUDE_MODELS.DEFAULT;\n  // Model logged at query start below\n\n  // Map system prompt configuration\n  sdkOptions.systemPrompt = {\n    type: 'preset',\n    preset: 'claude_code'  // Required to use CLAUDE.md\n  };\n\n  // Map setting sources for CLAUDE.md loading\n  // This loads CLAUDE.md from project, user (~/.config/claude/CLAUDE.md), and local directories\n  sdkOptions.settingSources = ['project', 'user', 'local'];\n\n  // Map resume session\n  if (sessionId) {\n    sdkOptions.resume = sessionId;\n  }\n\n  return sdkOptions;\n}\n\n/**\n * Adds a session to the active sessions map\n * @param {string} sessionId - Session identifier\n * @param {Object} queryInstance - SDK query instance\n * @param {Array<string>} tempImagePaths - Temp image file paths for cleanup\n * @param {string} tempDir - Temp directory for cleanup\n */\nfunction addSession(sessionId, queryInstance, tempImagePaths = [], tempDir = null, writer = null) {\n  activeSessions.set(sessionId, {\n    instance: queryInstance,\n    startTime: Date.now(),\n    status: 'active',\n    tempImagePaths,\n    tempDir,\n    writer\n  });\n}\n\n/**\n * Removes a session from the active sessions map\n * @param {string} sessionId - Session identifier\n */\nfunction removeSession(sessionId) {\n  activeSessions.delete(sessionId);\n}\n\n/**\n * Gets a session from the active sessions map\n * @param {string} sessionId - Session identifier\n * @returns {Object|undefined} Session data or undefined\n */\nfunction getSession(sessionId) {\n  return activeSessions.get(sessionId);\n}\n\n/**\n * Gets all active session IDs\n * @returns {Array<string>} Array of active session IDs\n */\nfunction getAllSessions() {\n  return Array.from(activeSessions.keys());\n}\n\n/**\n * Transforms SDK messages to WebSocket format expected by frontend\n * @param {Object} sdkMessage - SDK message object\n * @returns {Object} Transformed message ready for WebSocket\n */\nfunction transformMessage(sdkMessage) {\n  // Extract parent_tool_use_id for subagent tool grouping\n  if (sdkMessage.parent_tool_use_id) {\n    return {\n      ...sdkMessage,\n      parentToolUseId: sdkMessage.parent_tool_use_id\n    };\n  }\n  return sdkMessage;\n}\n\n/**\n * Extracts token usage from SDK result messages\n * @param {Object} resultMessage - SDK result message\n * @returns {Object|null} Token budget object or null\n */\nfunction extractTokenBudget(resultMessage) {\n  if (resultMessage.type !== 'result' || !resultMessage.modelUsage) {\n    return null;\n  }\n\n  // Get the first model's usage data\n  const modelKey = Object.keys(resultMessage.modelUsage)[0];\n  const modelData = resultMessage.modelUsage[modelKey];\n\n  if (!modelData) {\n    return null;\n  }\n\n  // Use cumulative tokens if available (tracks total for the session)\n  // Otherwise fall back to per-request tokens\n  const inputTokens = modelData.cumulativeInputTokens || modelData.inputTokens || 0;\n  const outputTokens = modelData.cumulativeOutputTokens || modelData.outputTokens || 0;\n  const cacheReadTokens = modelData.cumulativeCacheReadInputTokens || modelData.cacheReadInputTokens || 0;\n  const cacheCreationTokens = modelData.cumulativeCacheCreationInputTokens || modelData.cacheCreationInputTokens || 0;\n\n  // Total used = input + output + cache tokens\n  const totalUsed = inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens;\n\n  // Use configured context window budget from environment (default 160000)\n  // This is the user's budget limit, not the model's context window\n  const contextWindow = parseInt(process.env.CONTEXT_WINDOW) || 160000;\n\n  // Token calc logged via token-budget WS event\n\n  return {\n    used: totalUsed,\n    total: contextWindow\n  };\n}\n\n/**\n * Handles image processing for SDK queries\n * Saves base64 images to temporary files and returns modified prompt with file paths\n * @param {string} command - Original user prompt\n * @param {Array} images - Array of image objects with base64 data\n * @param {string} cwd - Working directory for temp file creation\n * @returns {Promise<Object>} {modifiedCommand, tempImagePaths, tempDir}\n */\nasync function handleImages(command, images, cwd) {\n  const tempImagePaths = [];\n  let tempDir = null;\n\n  if (!images || images.length === 0) {\n    return { modifiedCommand: command, tempImagePaths, tempDir };\n  }\n\n  try {\n    // Create temp directory in the project directory\n    const workingDir = cwd || process.cwd();\n    tempDir = path.join(workingDir, '.tmp', 'images', Date.now().toString());\n    await fs.mkdir(tempDir, { recursive: true });\n\n    // Save each image to a temp file\n    for (const [index, image] of images.entries()) {\n      // Extract base64 data and mime type\n      const matches = image.data.match(/^data:([^;]+);base64,(.+)$/);\n      if (!matches) {\n        console.error('Invalid image data format');\n        continue;\n      }\n\n      const [, mimeType, base64Data] = matches;\n      const extension = mimeType.split('/')[1] || 'png';\n      const filename = `image_${index}.${extension}`;\n      const filepath = path.join(tempDir, filename);\n\n      // Write base64 data to file\n      await fs.writeFile(filepath, Buffer.from(base64Data, 'base64'));\n      tempImagePaths.push(filepath);\n    }\n\n    // Include the full image paths in the prompt\n    let modifiedCommand = command;\n    if (tempImagePaths.length > 0 && command && command.trim()) {\n      const imageNote = `\\n\\n[Images provided at the following paths:]\\n${tempImagePaths.map((p, i) => `${i + 1}. ${p}`).join('\\n')}`;\n      modifiedCommand = command + imageNote;\n    }\n\n    // Images processed\n    return { modifiedCommand, tempImagePaths, tempDir };\n  } catch (error) {\n    console.error('Error processing images for SDK:', error);\n    return { modifiedCommand: command, tempImagePaths, tempDir };\n  }\n}\n\n/**\n * Cleans up temporary image files\n * @param {Array<string>} tempImagePaths - Array of temp file paths to delete\n * @param {string} tempDir - Temp directory to remove\n */\nasync function cleanupTempFiles(tempImagePaths, tempDir) {\n  if (!tempImagePaths || tempImagePaths.length === 0) {\n    return;\n  }\n\n  try {\n    // Delete individual temp files\n    for (const imagePath of tempImagePaths) {\n      await fs.unlink(imagePath).catch(err =>\n        console.error(`Failed to delete temp image ${imagePath}:`, err)\n      );\n    }\n\n    // Delete temp directory\n    if (tempDir) {\n      await fs.rm(tempDir, { recursive: true, force: true }).catch(err =>\n        console.error(`Failed to delete temp directory ${tempDir}:`, err)\n      );\n    }\n\n    // Temp files cleaned\n  } catch (error) {\n    console.error('Error during temp file cleanup:', error);\n  }\n}\n\n/**\n * Loads MCP server configurations from ~/.claude.json\n * @param {string} cwd - Current working directory for project-specific configs\n * @returns {Object|null} MCP servers object or null if none found\n */\nasync function loadMcpConfig(cwd) {\n  try {\n    const claudeConfigPath = path.join(os.homedir(), '.claude.json');\n\n    // Check if config file exists\n    try {\n      await fs.access(claudeConfigPath);\n    } catch (error) {\n      // File doesn't exist, return null\n      // No config file\n      return null;\n    }\n\n    // Read and parse config file\n    let claudeConfig;\n    try {\n      const configContent = await fs.readFile(claudeConfigPath, 'utf8');\n      claudeConfig = JSON.parse(configContent);\n    } catch (error) {\n      console.error('Failed to parse ~/.claude.json:', error.message);\n      return null;\n    }\n\n    // Extract MCP servers (merge global and project-specific)\n    let mcpServers = {};\n\n    // Add global MCP servers\n    if (claudeConfig.mcpServers && typeof claudeConfig.mcpServers === 'object') {\n      mcpServers = { ...claudeConfig.mcpServers };\n      // Global MCP servers loaded\n    }\n\n    // Add/override with project-specific MCP servers\n    if (claudeConfig.claudeProjects && cwd) {\n      const projectConfig = claudeConfig.claudeProjects[cwd];\n      if (projectConfig && projectConfig.mcpServers && typeof projectConfig.mcpServers === 'object') {\n        mcpServers = { ...mcpServers, ...projectConfig.mcpServers };\n        // Project MCP servers merged\n      }\n    }\n\n    // Return null if no servers found\n    if (Object.keys(mcpServers).length === 0) {\n      return null;\n    }\n    return mcpServers;\n  } catch (error) {\n    console.error('Error loading MCP config:', error.message);\n    return null;\n  }\n}\n\n/**\n * Executes a Claude query using the SDK\n * @param {string} command - User prompt/command\n * @param {Object} options - Query options\n * @param {Object} ws - WebSocket connection\n * @returns {Promise<void>}\n */\nasync function queryClaudeSDK(command, options = {}, ws) {\n  const { sessionId, sessionSummary } = options;\n  let capturedSessionId = sessionId;\n  let sessionCreatedSent = false;\n  let tempImagePaths = [];\n  let tempDir = null;\n\n  const emitNotification = (event) => {\n    notifyUserIfEnabled({\n      userId: ws?.userId || null,\n      writer: ws,\n      event\n    });\n  };\n\n  try {\n    // Map CLI options to SDK format\n    const sdkOptions = mapCliOptionsToSDK(options);\n\n    // Load MCP configuration\n    const mcpServers = await loadMcpConfig(options.cwd);\n    if (mcpServers) {\n      sdkOptions.mcpServers = mcpServers;\n    }\n\n    // Handle images - save to temp files and modify prompt\n    const imageResult = await handleImages(command, options.images, options.cwd);\n    const finalCommand = imageResult.modifiedCommand;\n    tempImagePaths = imageResult.tempImagePaths;\n    tempDir = imageResult.tempDir;\n\n    sdkOptions.hooks = {\n      Notification: [{\n        matcher: '',\n        hooks: [async (input) => {\n          const message = typeof input?.message === 'string' ? input.message : 'Claude requires your attention.';\n          emitNotification(createNotificationEvent({\n            provider: 'claude',\n            sessionId: capturedSessionId || sessionId || null,\n            kind: 'action_required',\n            code: 'agent.notification',\n            meta: { message, sessionName: sessionSummary },\n            severity: 'warning',\n            requiresUserAction: true,\n            dedupeKey: `claude:hook:notification:${capturedSessionId || sessionId || 'none'}:${message}`\n          }));\n          return {};\n        }]\n      }]\n    };\n\n    sdkOptions.canUseTool = async (toolName, input, context) => {\n      const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName);\n\n      if (!requiresInteraction) {\n        if (sdkOptions.permissionMode === 'bypassPermissions') {\n          return { behavior: 'allow', updatedInput: input };\n        }\n\n        const isDisallowed = (sdkOptions.disallowedTools || []).some(entry =>\n          matchesToolPermission(entry, toolName, input)\n        );\n        if (isDisallowed) {\n          return { behavior: 'deny', message: 'Tool disallowed by settings' };\n        }\n\n        const isAllowed = (sdkOptions.allowedTools || []).some(entry =>\n          matchesToolPermission(entry, toolName, input)\n        );\n        if (isAllowed) {\n          return { behavior: 'allow', updatedInput: input };\n        }\n      }\n\n      const requestId = createRequestId();\n      ws.send(createNormalizedMessage({ kind: 'permission_request', requestId, toolName, input, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));\n      emitNotification(createNotificationEvent({\n        provider: 'claude',\n        sessionId: capturedSessionId || sessionId || null,\n        kind: 'action_required',\n        code: 'permission.required',\n        meta: { toolName, sessionName: sessionSummary },\n        severity: 'warning',\n        requiresUserAction: true,\n        dedupeKey: `claude:permission:${capturedSessionId || sessionId || 'none'}:${requestId}`\n      }));\n\n      const decision = await waitForToolApproval(requestId, {\n        timeoutMs: requiresInteraction ? 0 : undefined,\n        signal: context?.signal,\n        metadata: {\n          _sessionId: capturedSessionId || sessionId || null,\n          _toolName: toolName,\n          _input: input,\n          _receivedAt: new Date(),\n        },\n        onCancel: (reason) => {\n          ws.send(createNormalizedMessage({ kind: 'permission_cancelled', requestId, reason, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));\n        }\n      });\n      if (!decision) {\n        return { behavior: 'deny', message: 'Permission request timed out' };\n      }\n\n      if (decision.cancelled) {\n        return { behavior: 'deny', message: 'Permission request cancelled' };\n      }\n\n      if (decision.allow) {\n        if (decision.rememberEntry && typeof decision.rememberEntry === 'string') {\n          if (!sdkOptions.allowedTools.includes(decision.rememberEntry)) {\n            sdkOptions.allowedTools.push(decision.rememberEntry);\n          }\n          if (Array.isArray(sdkOptions.disallowedTools)) {\n            sdkOptions.disallowedTools = sdkOptions.disallowedTools.filter(entry => entry !== decision.rememberEntry);\n          }\n        }\n        return { behavior: 'allow', updatedInput: decision.updatedInput ?? input };\n      }\n\n      return { behavior: 'deny', message: decision.message ?? 'User denied tool use' };\n    };\n\n    // Set stream-close timeout for interactive tools (Query constructor reads it synchronously). Claude Agent SDK has a default of 5s and this overrides it\n    const prevStreamTimeout = process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;\n    process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000';\n\n    let queryInstance;\n    try {\n      queryInstance = query({\n        prompt: finalCommand,\n        options: sdkOptions\n      });\n    } catch (hookError) {\n      // Older/newer SDK versions may not accept hook shapes yet.\n      // Keep notification behavior operational via runtime events even if hook registration fails.\n      console.warn('Failed to initialize Claude query with hooks, retrying without hooks:', hookError?.message || hookError);\n      delete sdkOptions.hooks;\n      queryInstance = query({\n        prompt: finalCommand,\n        options: sdkOptions\n      });\n    }\n\n    // Restore immediately — Query constructor already captured the value\n    if (prevStreamTimeout !== undefined) {\n      process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = prevStreamTimeout;\n    } else {\n      delete process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;\n    }\n\n    // Track the query instance for abort capability\n    if (capturedSessionId) {\n      addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, ws);\n    }\n\n    // Process streaming messages\n    console.log('Starting async generator loop for session:', capturedSessionId || 'NEW');\n    for await (const message of queryInstance) {\n      // Capture session ID from first message\n      if (message.session_id && !capturedSessionId) {\n\n        capturedSessionId = message.session_id;\n        addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, ws);\n\n        // Set session ID on writer\n        if (ws.setSessionId && typeof ws.setSessionId === 'function') {\n          ws.setSessionId(capturedSessionId);\n        }\n\n        // Send session-created event only once for new sessions\n        if (!sessionId && !sessionCreatedSent) {\n          sessionCreatedSent = true;\n          ws.send(createNormalizedMessage({ kind: 'session_created', newSessionId: capturedSessionId, sessionId: capturedSessionId, provider: 'claude' }));\n        }\n      } else {\n        // session_id already captured\n      }\n\n      // Transform and normalize message via adapter\n      const transformedMessage = transformMessage(message);\n      const sid = capturedSessionId || sessionId || null;\n\n      // Use adapter to normalize SDK events into NormalizedMessage[]\n      const normalized = claudeAdapter.normalizeMessage(transformedMessage, sid);\n      for (const msg of normalized) {\n        // Preserve parentToolUseId from SDK wrapper for subagent tool grouping\n        if (transformedMessage.parentToolUseId && !msg.parentToolUseId) {\n          msg.parentToolUseId = transformedMessage.parentToolUseId;\n        }\n        ws.send(msg);\n      }\n\n      // Extract and send token budget updates from result messages\n      if (message.type === 'result') {\n        const models = Object.keys(message.modelUsage || {});\n        if (models.length > 0) {\n          // Model info available in result message\n        }\n        const tokenBudgetData = extractTokenBudget(message);\n        if (tokenBudgetData) {\n          ws.send(createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: tokenBudgetData, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));\n        }\n      }\n    }\n\n    // Clean up session on completion\n    if (capturedSessionId) {\n      removeSession(capturedSessionId);\n    }\n\n    // Clean up temporary image files\n    await cleanupTempFiles(tempImagePaths, tempDir);\n\n    // Send completion event\n    ws.send(createNormalizedMessage({ kind: 'complete', exitCode: 0, isNewSession: !sessionId && !!command, sessionId: capturedSessionId, provider: 'claude' }));\n    notifyRunStopped({\n      userId: ws?.userId || null,\n      provider: 'claude',\n      sessionId: capturedSessionId || sessionId || null,\n      sessionName: sessionSummary,\n      stopReason: 'completed'\n    });\n    // Complete\n\n  } catch (error) {\n    console.error('SDK query error:', error);\n\n    // Clean up session on error\n    if (capturedSessionId) {\n      removeSession(capturedSessionId);\n    }\n\n    // Clean up temporary image files on error\n    await cleanupTempFiles(tempImagePaths, tempDir);\n\n    // Send error to WebSocket\n    ws.send(createNormalizedMessage({ kind: 'error', content: error.message, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));\n    notifyRunFailed({\n      userId: ws?.userId || null,\n      provider: 'claude',\n      sessionId: capturedSessionId || sessionId || null,\n      sessionName: sessionSummary,\n      error\n    });\n\n    throw error;\n  }\n}\n\n/**\n * Aborts an active SDK session\n * @param {string} sessionId - Session identifier\n * @returns {boolean} True if session was aborted, false if not found\n */\nasync function abortClaudeSDKSession(sessionId) {\n  const session = getSession(sessionId);\n\n  if (!session) {\n    console.log(`Session ${sessionId} not found`);\n    return false;\n  }\n\n  try {\n    console.log(`Aborting SDK session: ${sessionId}`);\n\n    // Call interrupt() on the query instance\n    await session.instance.interrupt();\n\n    // Update session status\n    session.status = 'aborted';\n\n    // Clean up temporary image files\n    await cleanupTempFiles(session.tempImagePaths, session.tempDir);\n\n    // Clean up session\n    removeSession(sessionId);\n\n    return true;\n  } catch (error) {\n    console.error(`Error aborting session ${sessionId}:`, error);\n    return false;\n  }\n}\n\n/**\n * Checks if an SDK session is currently active\n * @param {string} sessionId - Session identifier\n * @returns {boolean} True if session is active\n */\nfunction isClaudeSDKSessionActive(sessionId) {\n  const session = getSession(sessionId);\n  return session && session.status === 'active';\n}\n\n/**\n * Gets all active SDK session IDs\n * @returns {Array<string>} Array of active session IDs\n */\nfunction getActiveClaudeSDKSessions() {\n  return getAllSessions();\n}\n\n/**\n * Get pending tool approvals for a specific session.\n * @param {string} sessionId - The session ID\n * @returns {Array} Array of pending permission request objects\n */\nfunction getPendingApprovalsForSession(sessionId) {\n  const pending = [];\n  for (const [requestId, resolver] of pendingToolApprovals.entries()) {\n    if (resolver._sessionId === sessionId) {\n      pending.push({\n        requestId,\n        toolName: resolver._toolName || 'UnknownTool',\n        input: resolver._input,\n        context: resolver._context,\n        sessionId,\n        receivedAt: resolver._receivedAt || new Date(),\n      });\n    }\n  }\n  return pending;\n}\n\n/**\n * Reconnect a session's WebSocketWriter to a new raw WebSocket.\n * Called when client reconnects (e.g. page refresh) while SDK is still running.\n * @param {string} sessionId - The session ID\n * @param {Object} newRawWs - The new raw WebSocket connection\n * @returns {boolean} True if writer was successfully reconnected\n */\nfunction reconnectSessionWriter(sessionId, newRawWs) {\n  const session = getSession(sessionId);\n  if (!session?.writer?.updateWebSocket) return false;\n  session.writer.updateWebSocket(newRawWs);\n  console.log(`[RECONNECT] Writer swapped for session ${sessionId}`);\n  return true;\n}\n\n// Export public API\nexport {\n  queryClaudeSDK,\n  abortClaudeSDKSession,\n  isClaudeSDKSessionActive,\n  getActiveClaudeSDKSessions,\n  resolveToolApproval,\n  getPendingApprovalsForSession,\n  reconnectSessionWriter\n};\n"
  },
  {
    "path": "server/cli.js",
    "content": "#!/usr/bin/env node\n/**\n * Claude Code UI CLI\n *\n * Provides command-line utilities for managing Claude Code UI\n *\n * Commands:\n *   (no args)     - Start the server (default)\n *   start         - Start the server\n *   status        - Show configuration and data locations\n *   help          - Show help information\n *   version       - Show version information\n */\n\nimport fs from 'fs';\nimport path from 'path';\nimport os from 'os';\nimport { fileURLToPath } from 'url';\nimport { dirname } from 'path';\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = dirname(__filename);\n\n// ANSI color codes for terminal output\nconst colors = {\n    reset: '\\x1b[0m',\n    bright: '\\x1b[1m',\n    dim: '\\x1b[2m',\n\n    // Foreground colors\n    cyan: '\\x1b[36m',\n    green: '\\x1b[32m',\n    yellow: '\\x1b[33m',\n    blue: '\\x1b[34m',\n    magenta: '\\x1b[35m',\n    white: '\\x1b[37m',\n    gray: '\\x1b[90m',\n};\n\n// Helper to colorize text\nconst c = {\n    info: (text) => `${colors.cyan}${text}${colors.reset}`,\n    ok: (text) => `${colors.green}${text}${colors.reset}`,\n    warn: (text) => `${colors.yellow}${text}${colors.reset}`,\n    error: (text) => `${colors.yellow}${text}${colors.reset}`,\n    tip: (text) => `${colors.blue}${text}${colors.reset}`,\n    bright: (text) => `${colors.bright}${text}${colors.reset}`,\n    dim: (text) => `${colors.dim}${text}${colors.reset}`,\n};\n\n// Load package.json for version info\nconst packageJsonPath = path.join(__dirname, '../package.json');\nconst packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));\n\n// Load environment variables from .env file if it exists\nfunction loadEnvFile() {\n    try {\n        const envPath = path.join(__dirname, '../.env');\n        const envFile = fs.readFileSync(envPath, 'utf8');\n        envFile.split('\\n').forEach(line => {\n            const trimmedLine = line.trim();\n            if (trimmedLine && !trimmedLine.startsWith('#')) {\n                const [key, ...valueParts] = trimmedLine.split('=');\n                if (key && valueParts.length > 0 && !process.env[key]) {\n                    process.env[key] = valueParts.join('=').trim();\n                }\n            }\n        });\n    } catch (e) {\n        // .env file is optional\n    }\n}\n\n// Get the database path (same logic as db.js)\nfunction getDatabasePath() {\n    loadEnvFile();\n    return process.env.DATABASE_PATH || path.join(__dirname, 'database', 'auth.db');\n}\n\n// Get the installation directory\nfunction getInstallDir() {\n    return path.join(__dirname, '..');\n}\n\n// Show status command\nfunction showStatus() {\n    console.log(`\\n${c.bright('Claude Code UI - Status')}\\n`);\n    console.log(c.dim('═'.repeat(60)));\n\n    // Version info\n    console.log(`\\n${c.info('[INFO]')} Version: ${c.bright(packageJson.version)}`);\n\n    // Installation location\n    const installDir = getInstallDir();\n    console.log(`\\n${c.info('[INFO]')} Installation Directory:`);\n    console.log(`       ${c.dim(installDir)}`);\n\n    // Database location\n    const dbPath = getDatabasePath();\n    const dbExists = fs.existsSync(dbPath);\n    console.log(`\\n${c.info('[INFO]')} Database Location:`);\n    console.log(`       ${c.dim(dbPath)}`);\n    console.log(`       Status: ${dbExists ? c.ok('[OK] Exists') : c.warn('[WARN] Not created yet (will be created on first run)')}`);\n\n    if (dbExists) {\n        const stats = fs.statSync(dbPath);\n        console.log(`       Size: ${c.dim((stats.size / 1024).toFixed(2) + ' KB')}`);\n        console.log(`       Modified: ${c.dim(stats.mtime.toLocaleString())}`);\n    }\n\n    // Environment variables\n    console.log(`\\n${c.info('[INFO]')} Configuration:`);\n    console.log(`       SERVER_PORT: ${c.bright(process.env.SERVER_PORT || process.env.PORT || '3001')} ${c.dim(process.env.SERVER_PORT || process.env.PORT ? '' : '(default)')}`);\n    console.log(`       DATABASE_PATH: ${c.dim(process.env.DATABASE_PATH || '(using default location)')}`);\n    console.log(`       CLAUDE_CLI_PATH: ${c.dim(process.env.CLAUDE_CLI_PATH || 'claude (default)')}`);\n    console.log(`       CONTEXT_WINDOW: ${c.dim(process.env.CONTEXT_WINDOW || '160000 (default)')}`);\n\n    // Claude projects folder\n    const claudeProjectsPath = path.join(os.homedir(), '.claude', 'projects');\n    const projectsExists = fs.existsSync(claudeProjectsPath);\n    console.log(`\\n${c.info('[INFO]')} Claude Projects Folder:`);\n    console.log(`       ${c.dim(claudeProjectsPath)}`);\n    console.log(`       Status: ${projectsExists ? c.ok('[OK] Exists') : c.warn('[WARN] Not found')}`);\n\n    // Config file location\n    const envFilePath = path.join(__dirname, '../.env');\n    const envExists = fs.existsSync(envFilePath);\n    console.log(`\\n${c.info('[INFO]')} Configuration File:`);\n    console.log(`       ${c.dim(envFilePath)}`);\n    console.log(`       Status: ${envExists ? c.ok('[OK] Exists') : c.warn('[WARN] Not found (using defaults)')}`);\n\n    console.log('\\n' + c.dim('═'.repeat(60)));\n    console.log(`\\n${c.tip('[TIP]')} Hints:`);\n    console.log(`      ${c.dim('>')} Use ${c.bright('cloudcli --port 8080')} to run on a custom port`);\n    console.log(`      ${c.dim('>')} Use ${c.bright('cloudcli --database-path /path/to/db')} for custom database`);\n    console.log(`      ${c.dim('>')} Run ${c.bright('cloudcli help')} for all options`);\n    console.log(`      ${c.dim('>')} Access the UI at http://localhost:${process.env.SERVER_PORT || process.env.PORT || '3001'}\\n`);\n}\n\n// Show help\nfunction showHelp() {\n    console.log(`\n╔═══════════════════════════════════════════════════════════════╗\n║              Claude Code UI - Command Line Tool               ║\n╚═══════════════════════════════════════════════════════════════╝\n\nUsage:\n  claude-code-ui [command] [options]\n  cloudcli [command] [options]\n\nCommands:\n  start          Start the Claude Code UI server (default)\n  status         Show configuration and data locations\n  update         Update to the latest version\n  help           Show this help information\n  version        Show version information\n\nOptions:\n  -p, --port <port>           Set server port (default: 3001)\n  --database-path <path>      Set custom database location\n  -h, --help                  Show this help information\n  -v, --version               Show version information\n\nExamples:\n  $ cloudcli                        # Start with defaults\n  $ cloudcli --port 8080            # Start on port 8080\n  $ cloudcli -p 3000                # Short form for port\n  $ cloudcli start --port 4000      # Explicit start command\n  $ cloudcli status                 # Show configuration\n\nEnvironment Variables:\n  SERVER_PORT         Set server port (default: 3001)\n  PORT                Set server port (default: 3001) (LEGACY)\n  DATABASE_PATH       Set custom database location\n  CLAUDE_CLI_PATH     Set custom Claude CLI path\n  CONTEXT_WINDOW      Set context window size (default: 160000)\n\nDocumentation:\n  ${packageJson.homepage || 'https://github.com/siteboon/claudecodeui'}\n\nReport Issues:\n  ${packageJson.bugs?.url || 'https://github.com/siteboon/claudecodeui/issues'}\n`);\n}\n\n// Show version\nfunction showVersion() {\n    console.log(`${packageJson.version}`);\n}\n\n// Compare semver versions, returns true if v1 > v2\nfunction isNewerVersion(v1, v2) {\n    const parts1 = v1.split('.').map(Number);\n    const parts2 = v2.split('.').map(Number);\n    for (let i = 0; i < 3; i++) {\n        if (parts1[i] > parts2[i]) return true;\n        if (parts1[i] < parts2[i]) return false;\n    }\n    return false;\n}\n\n// Check for updates\nasync function checkForUpdates(silent = false) {\n    try {\n        const { execSync } = await import('child_process');\n        const latestVersion = execSync('npm show @siteboon/claude-code-ui version', { encoding: 'utf8' }).trim();\n        const currentVersion = packageJson.version;\n\n        if (isNewerVersion(latestVersion, currentVersion)) {\n            console.log(`\\n${c.warn('[UPDATE]')} New version available: ${c.bright(latestVersion)} (current: ${currentVersion})`);\n            console.log(`         Run ${c.bright('cloudcli update')} to update\\n`);\n            return { hasUpdate: true, latestVersion, currentVersion };\n        } else if (!silent) {\n            console.log(`${c.ok('[OK]')} You are on the latest version (${currentVersion})`);\n        }\n        return { hasUpdate: false, latestVersion, currentVersion };\n    } catch (e) {\n        if (!silent) {\n            console.log(`${c.warn('[WARN]')} Could not check for updates`);\n        }\n        return { hasUpdate: false, error: e.message };\n    }\n}\n\n// Update the package\nasync function updatePackage() {\n    try {\n        const { execSync } = await import('child_process');\n        console.log(`${c.info('[INFO]')} Checking for updates...`);\n\n        const { hasUpdate, latestVersion, currentVersion } = await checkForUpdates(true);\n\n        if (!hasUpdate) {\n            console.log(`${c.ok('[OK]')} Already on the latest version (${currentVersion})`);\n            return;\n        }\n\n        console.log(`${c.info('[INFO]')} Updating from ${currentVersion} to ${latestVersion}...`);\n        execSync('npm update -g @siteboon/claude-code-ui', { stdio: 'inherit' });\n        console.log(`${c.ok('[OK]')} Update complete! Restart cloudcli to use the new version.`);\n    } catch (e) {\n        console.error(`${c.error('[ERROR]')} Update failed: ${e.message}`);\n        console.log(`${c.tip('[TIP]')} Try running manually: npm update -g @siteboon/claude-code-ui`);\n    }\n}\n\n// Start the server\nasync function startServer() {\n    // Check for updates silently on startup\n    checkForUpdates(true);\n\n    // Import and run the server\n    await import('./index.js');\n}\n\n// Parse CLI arguments\nfunction parseArgs(args) {\n    const parsed = { command: 'start', options: {} };\n\n    for (let i = 0; i < args.length; i++) {\n        const arg = args[i];\n\n        if (arg === '--port' || arg === '-p') {\n            parsed.options.serverPort = args[++i];\n        } else if (arg.startsWith('--port=')) {\n            parsed.options.serverPort = arg.split('=')[1];\n        } else if (arg === '--database-path') {\n            parsed.options.databasePath = args[++i];\n        } else if (arg.startsWith('--database-path=')) {\n            parsed.options.databasePath = arg.split('=')[1];\n        } else if (arg === '--help' || arg === '-h') {\n            parsed.command = 'help';\n        } else if (arg === '--version' || arg === '-v') {\n            parsed.command = 'version';\n        } else if (!arg.startsWith('-')) {\n            parsed.command = arg;\n        }\n    }\n\n    return parsed;\n}\n\n// Main CLI handler\nasync function main() {\n    const args = process.argv.slice(2);\n    const { command, options } = parseArgs(args);\n\n    // Apply CLI options to environment variables\n    if (options.serverPort) {\n        process.env.SERVER_PORT = options.serverPort;\n    } else if (!process.env.SERVER_PORT && process.env.PORT) {\n        process.env.SERVER_PORT = process.env.PORT;\n    }\n    if (options.databasePath) {\n        process.env.DATABASE_PATH = options.databasePath;\n    }\n\n    switch (command) {\n        case 'start':\n            await startServer();\n            break;\n        case 'status':\n        case 'info':\n            showStatus();\n            break;\n        case 'help':\n        case '-h':\n        case '--help':\n            showHelp();\n            break;\n        case 'version':\n        case '-v':\n        case '--version':\n            showVersion();\n            break;\n        case 'update':\n            await updatePackage();\n            break;\n        default:\n            console.error(`\\n❌ Unknown command: ${command}`);\n            console.log('   Run \"cloudcli help\" for usage information.\\n');\n            process.exit(1);\n    }\n}\n\n// Run the CLI\nmain().catch(error => {\n    console.error('\\n❌ Error:', error.message);\n    process.exit(1);\n});\n"
  },
  {
    "path": "server/constants/config.js",
    "content": "/**\n * Environment Flag: Is Platform\n * Indicates if the app is running in Platform mode (hosted) or OSS mode (self-hosted)\n */\nexport const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true';"
  },
  {
    "path": "server/cursor-cli.js",
    "content": "import { spawn } from 'child_process';\nimport crossSpawn from 'cross-spawn';\nimport { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';\nimport { cursorAdapter } from './providers/cursor/adapter.js';\nimport { createNormalizedMessage } from './providers/types.js';\n\n// Use cross-spawn on Windows for better command execution\nconst spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;\n\nlet activeCursorProcesses = new Map(); // Track active processes by session ID\n\nconst WORKSPACE_TRUST_PATTERNS = [\n  /workspace trust required/i,\n  /do you trust the contents of this directory/i,\n  /working with untrusted contents/i,\n  /pass --trust,\\s*--yolo,\\s*or -f/i\n];\n\nfunction isWorkspaceTrustPrompt(text = '') {\n  if (!text || typeof text !== 'string') {\n    return false;\n  }\n\n  return WORKSPACE_TRUST_PATTERNS.some((pattern) => pattern.test(text));\n}\n\nasync function spawnCursor(command, options = {}, ws) {\n  return new Promise(async (resolve, reject) => {\n    const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model, sessionSummary } = options;\n    let capturedSessionId = sessionId; // Track session ID throughout the process\n    let sessionCreatedSent = false; // Track if we've already sent session-created event\n    let hasRetriedWithTrust = false;\n    let settled = false;\n\n    // Use tools settings passed from frontend, or defaults\n    const settings = toolsSettings || {\n      allowedShellCommands: [],\n      skipPermissions: false\n    };\n\n    // Build Cursor CLI command\n    const baseArgs = [];\n\n    // Build flags allowing both resume and prompt together (reply in existing session)\n    // Treat presence of sessionId as intention to resume, regardless of resume flag\n    if (sessionId) {\n      baseArgs.push('--resume=' + sessionId);\n    }\n\n    if (command && command.trim()) {\n      // Provide a prompt (works for both new and resumed sessions)\n      baseArgs.push('-p', command);\n\n      // Add model flag if specified (only meaningful for new sessions; harmless on resume)\n      if (!sessionId && model) {\n        baseArgs.push('--model', model);\n      }\n\n      // Request streaming JSON when we are providing a prompt\n      baseArgs.push('--output-format', 'stream-json');\n    }\n\n    // Add skip permissions flag if enabled\n    if (skipPermissions || settings.skipPermissions) {\n      baseArgs.push('-f');\n      console.log('Using -f flag (skip permissions)');\n    }\n\n    // Use cwd (actual project directory) instead of projectPath\n    const workingDir = cwd || projectPath || process.cwd();\n\n    // Store process reference for potential abort\n    const processKey = capturedSessionId || Date.now().toString();\n\n    const settleOnce = (callback) => {\n      if (settled) {\n        return;\n      }\n      settled = true;\n      callback();\n    };\n\n    const runCursorProcess = (args, runReason = 'initial') => {\n      const isTrustRetry = runReason === 'trust-retry';\n      let runSawWorkspaceTrustPrompt = false;\n      let stdoutLineBuffer = '';\n      let terminalNotificationSent = false;\n\n      const notifyTerminalState = ({ code = null, error = null } = {}) => {\n        if (terminalNotificationSent) {\n          return;\n        }\n\n        terminalNotificationSent = true;\n\n        const finalSessionId = capturedSessionId || sessionId || processKey;\n        if (code === 0 && !error) {\n          notifyRunStopped({\n            userId: ws?.userId || null,\n            provider: 'cursor',\n            sessionId: finalSessionId,\n            sessionName: sessionSummary,\n            stopReason: 'completed'\n          });\n          return;\n        }\n\n        notifyRunFailed({\n          userId: ws?.userId || null,\n          provider: 'cursor',\n          sessionId: finalSessionId,\n          sessionName: sessionSummary,\n          error: error || `Cursor CLI exited with code ${code}`\n        });\n      };\n\n      if (isTrustRetry) {\n        console.log('Retrying Cursor CLI with --trust after workspace trust prompt');\n      }\n\n      console.log('Spawning Cursor CLI:', 'cursor-agent', args.join(' '));\n      console.log('Working directory:', workingDir);\n      console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume);\n\n      const cursorProcess = spawnFunction('cursor-agent', args, {\n        cwd: workingDir,\n        stdio: ['pipe', 'pipe', 'pipe'],\n        env: { ...process.env } // Inherit all environment variables\n      });\n\n      activeCursorProcesses.set(processKey, cursorProcess);\n\n      const shouldSuppressForTrustRetry = (text) => {\n        if (hasRetriedWithTrust || args.includes('--trust')) {\n          return false;\n        }\n        if (!isWorkspaceTrustPrompt(text)) {\n          return false;\n        }\n\n        runSawWorkspaceTrustPrompt = true;\n        return true;\n      };\n\n      const processCursorOutputLine = (line) => {\n        if (!line || !line.trim()) {\n          return;\n        }\n\n        try {\n          const response = JSON.parse(line);\n          console.log('Parsed JSON response:', response);\n\n          // Handle different message types\n          switch (response.type) {\n            case 'system':\n              if (response.subtype === 'init') {\n                // Capture session ID\n                if (response.session_id && !capturedSessionId) {\n                  capturedSessionId = response.session_id;\n                  console.log('Captured session ID:', capturedSessionId);\n\n                  // Update process key with captured session ID\n                  if (processKey !== capturedSessionId) {\n                    activeCursorProcesses.delete(processKey);\n                    activeCursorProcesses.set(capturedSessionId, cursorProcess);\n                  }\n\n                  // Set session ID on writer (for API endpoint compatibility)\n                  if (ws.setSessionId && typeof ws.setSessionId === 'function') {\n                    ws.setSessionId(capturedSessionId);\n                  }\n\n                  // Send session-created event only once for new sessions\n                  if (!sessionId && !sessionCreatedSent) {\n                    sessionCreatedSent = true;\n                    ws.send(createNormalizedMessage({ kind: 'session_created', newSessionId: capturedSessionId, model: response.model, cwd: response.cwd, sessionId: capturedSessionId, provider: 'cursor' }));\n                  }\n                }\n\n                // System info — no longer needed by the frontend (session-lifecycle 'created' handles nav).\n              }\n              break;\n\n            case 'user':\n              // User messages are not displayed in the UI — skip.\n              break;\n\n            case 'assistant':\n              // Accumulate assistant message chunks\n              if (response.message && response.message.content && response.message.content.length > 0) {\n                const normalized = cursorAdapter.normalizeMessage(response, capturedSessionId || sessionId || null);\n                for (const msg of normalized) ws.send(msg);\n              }\n              break;\n\n            case 'result': {\n              // Session complete — send stream end + lifecycle complete with result payload\n              console.log('Cursor session result:', response);\n              const resultText = typeof response.result === 'string' ? response.result : '';\n              ws.send(createNormalizedMessage({\n                kind: 'complete',\n                exitCode: response.subtype === 'success' ? 0 : 1,\n                resultText,\n                isError: response.subtype !== 'success',\n                sessionId: capturedSessionId || sessionId, provider: 'cursor',\n              }));\n              break;\n            }\n\n            default:\n              // Unknown message types — ignore.\n          }\n        } catch (parseError) {\n          console.log('Non-JSON response:', line);\n\n          if (shouldSuppressForTrustRetry(line)) {\n            return;\n          }\n\n          // If not JSON, send as stream delta via adapter\n          const normalized = cursorAdapter.normalizeMessage(line, capturedSessionId || sessionId || null);\n          for (const msg of normalized) ws.send(msg);\n        }\n      };\n\n      // Handle stdout (streaming JSON responses)\n      cursorProcess.stdout.on('data', (data) => {\n        const rawOutput = data.toString();\n        console.log('Cursor CLI stdout:', rawOutput);\n\n        // Stream chunks can split JSON objects across packets; keep trailing partial line.\n        stdoutLineBuffer += rawOutput;\n        const completeLines = stdoutLineBuffer.split(/\\r?\\n/);\n        stdoutLineBuffer = completeLines.pop() || '';\n\n        completeLines.forEach((line) => {\n          processCursorOutputLine(line.trim());\n        });\n      });\n\n      // Handle stderr\n      cursorProcess.stderr.on('data', (data) => {\n        const stderrText = data.toString();\n        console.error('Cursor CLI stderr:', stderrText);\n\n        if (shouldSuppressForTrustRetry(stderrText)) {\n          return;\n        }\n\n        ws.send(createNormalizedMessage({ kind: 'error', content: stderrText, sessionId: capturedSessionId || sessionId || null, provider: 'cursor' }));\n      });\n\n      // Handle process completion\n      cursorProcess.on('close', async (code) => {\n        console.log(`Cursor CLI process exited with code ${code}`);\n\n        const finalSessionId = capturedSessionId || sessionId || processKey;\n        activeCursorProcesses.delete(finalSessionId);\n\n        // Flush any final unterminated stdout line before completion handling.\n        if (stdoutLineBuffer.trim()) {\n          processCursorOutputLine(stdoutLineBuffer.trim());\n          stdoutLineBuffer = '';\n        }\n\n        if (\n          runSawWorkspaceTrustPrompt &&\n          code !== 0 &&\n          !hasRetriedWithTrust &&\n          !args.includes('--trust')\n        ) {\n          hasRetriedWithTrust = true;\n          runCursorProcess([...args, '--trust'], 'trust-retry');\n          return;\n        }\n\n        ws.send(createNormalizedMessage({ kind: 'complete', exitCode: code, isNewSession: !sessionId && !!command, sessionId: finalSessionId, provider: 'cursor' }));\n\n        if (code === 0) {\n          notifyTerminalState({ code });\n          settleOnce(() => resolve());\n        } else {\n          notifyTerminalState({ code });\n          settleOnce(() => reject(new Error(`Cursor CLI exited with code ${code}`)));\n        }\n      });\n\n      // Handle process errors\n      cursorProcess.on('error', (error) => {\n        console.error('Cursor CLI process error:', error);\n\n        // Clean up process reference on error\n        const finalSessionId = capturedSessionId || sessionId || processKey;\n        activeCursorProcesses.delete(finalSessionId);\n\n        ws.send(createNormalizedMessage({ kind: 'error', content: error.message, sessionId: capturedSessionId || sessionId || null, provider: 'cursor' }));\n        notifyTerminalState({ error });\n\n        settleOnce(() => reject(error));\n      });\n\n      // Close stdin since Cursor doesn't need interactive input\n      cursorProcess.stdin.end();\n    };\n\n    runCursorProcess(baseArgs, 'initial');\n  });\n}\n\nfunction abortCursorSession(sessionId) {\n  const process = activeCursorProcesses.get(sessionId);\n  if (process) {\n    console.log(`Aborting Cursor session: ${sessionId}`);\n    process.kill('SIGTERM');\n    activeCursorProcesses.delete(sessionId);\n    return true;\n  }\n  return false;\n}\n\nfunction isCursorSessionActive(sessionId) {\n  return activeCursorProcesses.has(sessionId);\n}\n\nfunction getActiveCursorSessions() {\n  return Array.from(activeCursorProcesses.keys());\n}\n\nexport {\n  spawnCursor,\n  abortCursorSession,\n  isCursorSessionActive,\n  getActiveCursorSessions\n};\n"
  },
  {
    "path": "server/database/db.js",
    "content": "import Database from 'better-sqlite3';\nimport path from 'path';\nimport fs from 'fs';\nimport crypto from 'crypto';\nimport { fileURLToPath } from 'url';\nimport { dirname } from 'path';\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = dirname(__filename);\n\n// ANSI color codes for terminal output\nconst colors = {\n    reset: '\\x1b[0m',\n    bright: '\\x1b[1m',\n    cyan: '\\x1b[36m',\n    dim: '\\x1b[2m',\n};\n\nconst c = {\n    info: (text) => `${colors.cyan}${text}${colors.reset}`,\n    bright: (text) => `${colors.bright}${text}${colors.reset}`,\n    dim: (text) => `${colors.dim}${text}${colors.reset}`,\n};\n\n// Use DATABASE_PATH environment variable if set, otherwise use default location\nconst DB_PATH = process.env.DATABASE_PATH || path.join(__dirname, 'auth.db');\nconst INIT_SQL_PATH = path.join(__dirname, 'init.sql');\n\n// Ensure database directory exists if custom path is provided\nif (process.env.DATABASE_PATH) {\n  const dbDir = path.dirname(DB_PATH);\n  try {\n    if (!fs.existsSync(dbDir)) {\n      fs.mkdirSync(dbDir, { recursive: true });\n      console.log(`Created database directory: ${dbDir}`);\n    }\n  } catch (error) {\n    console.error(`Failed to create database directory ${dbDir}:`, error.message);\n    throw error;\n  }\n}\n\n// As part of 1.19.2 we are introducing a new location for auth.db. The below handles exisitng moving legacy database from install directory to new location\nconst LEGACY_DB_PATH = path.join(__dirname, 'auth.db');\nif (DB_PATH !== LEGACY_DB_PATH && !fs.existsSync(DB_PATH) && fs.existsSync(LEGACY_DB_PATH)) {\n  try {\n    fs.copyFileSync(LEGACY_DB_PATH, DB_PATH);\n    console.log(`[MIGRATION] Copied database from ${LEGACY_DB_PATH} to ${DB_PATH}`);\n    for (const suffix of ['-wal', '-shm']) {\n      if (fs.existsSync(LEGACY_DB_PATH + suffix)) {\n        fs.copyFileSync(LEGACY_DB_PATH + suffix, DB_PATH + suffix);\n      }\n    }\n  } catch (err) {\n    console.warn(`[MIGRATION] Could not copy legacy database: ${err.message}`);\n  }\n}\n\n// Create database connection\nconst db = new Database(DB_PATH);\n\n// app_config must exist before any other module imports (auth.js reads the JWT secret at load time).\n// runMigrations() also creates this table, but it runs too late for existing installations\n// where auth.js is imported before initializeDatabase() is called.\ndb.exec(`CREATE TABLE IF NOT EXISTS app_config (\n  key TEXT PRIMARY KEY,\n  value TEXT NOT NULL,\n  created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n)`);\n\n// Show app installation path prominently\nconst appInstallPath = path.join(__dirname, '../..');\nconsole.log('');\nconsole.log(c.dim('═'.repeat(60)));\nconsole.log(`${c.info('[INFO]')} App Installation: ${c.bright(appInstallPath)}`);\nconsole.log(`${c.info('[INFO]')} Database: ${c.dim(path.relative(appInstallPath, DB_PATH))}`);\nif (process.env.DATABASE_PATH) {\n  console.log(`       ${c.dim('(Using custom DATABASE_PATH from environment)')}`);\n}\nconsole.log(c.dim('═'.repeat(60)));\nconsole.log('');\n\nconst runMigrations = () => {\n  try {\n    const tableInfo = db.prepare(\"PRAGMA table_info(users)\").all();\n    const columnNames = tableInfo.map(col => col.name);\n\n    if (!columnNames.includes('git_name')) {\n      console.log('Running migration: Adding git_name column');\n      db.exec('ALTER TABLE users ADD COLUMN git_name TEXT');\n    }\n\n    if (!columnNames.includes('git_email')) {\n      console.log('Running migration: Adding git_email column');\n      db.exec('ALTER TABLE users ADD COLUMN git_email TEXT');\n    }\n\n    if (!columnNames.includes('has_completed_onboarding')) {\n      console.log('Running migration: Adding has_completed_onboarding column');\n      db.exec('ALTER TABLE users ADD COLUMN has_completed_onboarding BOOLEAN DEFAULT 0');\n    }\n\n    db.exec(`\n      CREATE TABLE IF NOT EXISTS user_notification_preferences (\n        user_id INTEGER PRIMARY KEY,\n        preferences_json TEXT NOT NULL,\n        updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n        FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE\n      )\n    `);\n\n    db.exec(`\n      CREATE TABLE IF NOT EXISTS vapid_keys (\n        id INTEGER PRIMARY KEY AUTOINCREMENT,\n        public_key TEXT NOT NULL,\n        private_key TEXT NOT NULL,\n        created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n      )\n    `);\n\n    db.exec(`\n      CREATE TABLE IF NOT EXISTS push_subscriptions (\n        id INTEGER PRIMARY KEY AUTOINCREMENT,\n        user_id INTEGER NOT NULL,\n        endpoint TEXT NOT NULL UNIQUE,\n        keys_p256dh TEXT NOT NULL,\n        keys_auth TEXT NOT NULL,\n        created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n        FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE\n      )\n    `);\n    // Create app_config table if it doesn't exist (for existing installations)\n    db.exec(`CREATE TABLE IF NOT EXISTS app_config (\n      key TEXT PRIMARY KEY,\n      value TEXT NOT NULL,\n      created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n    )`);\n\n    // Create session_names table if it doesn't exist (for existing installations)\n    db.exec(`CREATE TABLE IF NOT EXISTS session_names (\n      id INTEGER PRIMARY KEY AUTOINCREMENT,\n      session_id TEXT NOT NULL,\n      provider TEXT NOT NULL DEFAULT 'claude',\n      custom_name TEXT NOT NULL,\n      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n      updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n      UNIQUE(session_id, provider)\n    )`);\n    db.exec('CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider)');\n\n    console.log('Database migrations completed successfully');\n  } catch (error) {\n    console.error('Error running migrations:', error.message);\n    throw error;\n  }\n};\n\n// Initialize database with schema\nconst initializeDatabase = async () => {\n  try {\n    const initSQL = fs.readFileSync(INIT_SQL_PATH, 'utf8');\n    db.exec(initSQL);\n    console.log('Database initialized successfully');\n    runMigrations();\n  } catch (error) {\n    console.error('Error initializing database:', error.message);\n    throw error;\n  }\n};\n\n// User database operations\nconst userDb = {\n  // Check if any users exist\n  hasUsers: () => {\n    try {\n      const row = db.prepare('SELECT COUNT(*) as count FROM users').get();\n      return row.count > 0;\n    } catch (err) {\n      throw err;\n    }\n  },\n\n  // Create a new user\n  createUser: (username, passwordHash) => {\n    try {\n      const stmt = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)');\n      const result = stmt.run(username, passwordHash);\n      return { id: result.lastInsertRowid, username };\n    } catch (err) {\n      throw err;\n    }\n  },\n\n  // Get user by username\n  getUserByUsername: (username) => {\n    try {\n      const row = db.prepare('SELECT * FROM users WHERE username = ? AND is_active = 1').get(username);\n      return row;\n    } catch (err) {\n      throw err;\n    }\n  },\n\n  // Update last login time (non-fatal — logged but not thrown)\n  updateLastLogin: (userId) => {\n    try {\n      db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(userId);\n    } catch (err) {\n      console.warn('Failed to update last login:', err.message);\n    }\n  },\n\n  // Get user by ID\n  getUserById: (userId) => {\n    try {\n      const row = db.prepare('SELECT id, username, created_at, last_login FROM users WHERE id = ? AND is_active = 1').get(userId);\n      return row;\n    } catch (err) {\n      throw err;\n    }\n  },\n\n  getFirstUser: () => {\n    try {\n      const row = db.prepare('SELECT id, username, created_at, last_login FROM users WHERE is_active = 1 LIMIT 1').get();\n      return row;\n    } catch (err) {\n      throw err;\n    }\n  },\n\n  updateGitConfig: (userId, gitName, gitEmail) => {\n    try {\n      const stmt = db.prepare('UPDATE users SET git_name = ?, git_email = ? WHERE id = ?');\n      stmt.run(gitName, gitEmail, userId);\n    } catch (err) {\n      throw err;\n    }\n  },\n\n  getGitConfig: (userId) => {\n    try {\n      const row = db.prepare('SELECT git_name, git_email FROM users WHERE id = ?').get(userId);\n      return row;\n    } catch (err) {\n      throw err;\n    }\n  },\n\n  completeOnboarding: (userId) => {\n    try {\n      const stmt = db.prepare('UPDATE users SET has_completed_onboarding = 1 WHERE id = ?');\n      stmt.run(userId);\n    } catch (err) {\n      throw err;\n    }\n  },\n\n  hasCompletedOnboarding: (userId) => {\n    try {\n      const row = db.prepare('SELECT has_completed_onboarding FROM users WHERE id = ?').get(userId);\n      return row?.has_completed_onboarding === 1;\n    } catch (err) {\n      throw err;\n    }\n  }\n};\n\n// API Keys database operations\nconst apiKeysDb = {\n  // Generate a new API key\n  generateApiKey: () => {\n    return 'ck_' + crypto.randomBytes(32).toString('hex');\n  },\n\n  // Create a new API key\n  createApiKey: (userId, keyName) => {\n    try {\n      const apiKey = apiKeysDb.generateApiKey();\n      const stmt = db.prepare('INSERT INTO api_keys (user_id, key_name, api_key) VALUES (?, ?, ?)');\n      const result = stmt.run(userId, keyName, apiKey);\n      return { id: result.lastInsertRowid, keyName, apiKey };\n    } catch (err) {\n      throw err;\n    }\n  },\n\n  // Get all API keys for a user\n  getApiKeys: (userId) => {\n    try {\n      const rows = db.prepare('SELECT id, key_name, api_key, created_at, last_used, is_active FROM api_keys WHERE user_id = ? ORDER BY created_at DESC').all(userId);\n      return rows;\n    } catch (err) {\n      throw err;\n    }\n  },\n\n  // Validate API key and get user\n  validateApiKey: (apiKey) => {\n    try {\n      const row = db.prepare(`\n        SELECT u.id, u.username, ak.id as api_key_id\n        FROM api_keys ak\n        JOIN users u ON ak.user_id = u.id\n        WHERE ak.api_key = ? AND ak.is_active = 1 AND u.is_active = 1\n      `).get(apiKey);\n\n      if (row) {\n        // Update last_used timestamp\n        db.prepare('UPDATE api_keys SET last_used = CURRENT_TIMESTAMP WHERE id = ?').run(row.api_key_id);\n      }\n\n      return row;\n    } catch (err) {\n      throw err;\n    }\n  },\n\n  // Delete an API key\n  deleteApiKey: (userId, apiKeyId) => {\n    try {\n      const stmt = db.prepare('DELETE FROM api_keys WHERE id = ? AND user_id = ?');\n      const result = stmt.run(apiKeyId, userId);\n      return result.changes > 0;\n    } catch (err) {\n      throw err;\n    }\n  },\n\n  // Toggle API key active status\n  toggleApiKey: (userId, apiKeyId, isActive) => {\n    try {\n      const stmt = db.prepare('UPDATE api_keys SET is_active = ? WHERE id = ? AND user_id = ?');\n      const result = stmt.run(isActive ? 1 : 0, apiKeyId, userId);\n      return result.changes > 0;\n    } catch (err) {\n      throw err;\n    }\n  }\n};\n\n// User credentials database operations (for GitHub tokens, GitLab tokens, etc.)\nconst credentialsDb = {\n  // Create a new credential\n  createCredential: (userId, credentialName, credentialType, credentialValue, description = null) => {\n    try {\n      const stmt = db.prepare('INSERT INTO user_credentials (user_id, credential_name, credential_type, credential_value, description) VALUES (?, ?, ?, ?, ?)');\n      const result = stmt.run(userId, credentialName, credentialType, credentialValue, description);\n      return { id: result.lastInsertRowid, credentialName, credentialType };\n    } catch (err) {\n      throw err;\n    }\n  },\n\n  // Get all credentials for a user, optionally filtered by type\n  getCredentials: (userId, credentialType = null) => {\n    try {\n      let query = 'SELECT id, credential_name, credential_type, description, created_at, is_active FROM user_credentials WHERE user_id = ?';\n      const params = [userId];\n\n      if (credentialType) {\n        query += ' AND credential_type = ?';\n        params.push(credentialType);\n      }\n\n      query += ' ORDER BY created_at DESC';\n\n      const rows = db.prepare(query).all(...params);\n      return rows;\n    } catch (err) {\n      throw err;\n    }\n  },\n\n  // Get active credential value for a user by type (returns most recent active)\n  getActiveCredential: (userId, credentialType) => {\n    try {\n      const row = db.prepare('SELECT credential_value FROM user_credentials WHERE user_id = ? AND credential_type = ? AND is_active = 1 ORDER BY created_at DESC LIMIT 1').get(userId, credentialType);\n      return row?.credential_value || null;\n    } catch (err) {\n      throw err;\n    }\n  },\n\n  // Delete a credential\n  deleteCredential: (userId, credentialId) => {\n    try {\n      const stmt = db.prepare('DELETE FROM user_credentials WHERE id = ? AND user_id = ?');\n      const result = stmt.run(credentialId, userId);\n      return result.changes > 0;\n    } catch (err) {\n      throw err;\n    }\n  },\n\n  // Toggle credential active status\n  toggleCredential: (userId, credentialId, isActive) => {\n    try {\n      const stmt = db.prepare('UPDATE user_credentials SET is_active = ? WHERE id = ? AND user_id = ?');\n      const result = stmt.run(isActive ? 1 : 0, credentialId, userId);\n      return result.changes > 0;\n    } catch (err) {\n      throw err;\n    }\n  }\n};\n\nconst DEFAULT_NOTIFICATION_PREFERENCES = {\n  channels: {\n    inApp: false,\n    webPush: false\n  },\n  events: {\n    actionRequired: true,\n    stop: true,\n    error: true\n  }\n};\n\nconst normalizeNotificationPreferences = (value) => {\n  const source = value && typeof value === 'object' ? value : {};\n\n  return {\n    channels: {\n      inApp: source.channels?.inApp === true,\n      webPush: source.channels?.webPush === true\n    },\n    events: {\n      actionRequired: source.events?.actionRequired !== false,\n      stop: source.events?.stop !== false,\n      error: source.events?.error !== false\n    }\n  };\n};\n\nconst notificationPreferencesDb = {\n  getPreferences: (userId) => {\n    try {\n      const row = db.prepare('SELECT preferences_json FROM user_notification_preferences WHERE user_id = ?').get(userId);\n      if (!row) {\n        const defaults = normalizeNotificationPreferences(DEFAULT_NOTIFICATION_PREFERENCES);\n        db.prepare(\n          'INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)'\n        ).run(userId, JSON.stringify(defaults));\n        return defaults;\n      }\n\n      let parsed;\n      try {\n        parsed = JSON.parse(row.preferences_json);\n      } catch {\n        parsed = DEFAULT_NOTIFICATION_PREFERENCES;\n      }\n      return normalizeNotificationPreferences(parsed);\n    } catch (err) {\n      throw err;\n    }\n  },\n\n  updatePreferences: (userId, preferences) => {\n    try {\n      const normalized = normalizeNotificationPreferences(preferences);\n      db.prepare(\n        `INSERT INTO user_notification_preferences (user_id, preferences_json, updated_at)\n         VALUES (?, ?, CURRENT_TIMESTAMP)\n         ON CONFLICT(user_id) DO UPDATE SET\n           preferences_json = excluded.preferences_json,\n           updated_at = CURRENT_TIMESTAMP`\n      ).run(userId, JSON.stringify(normalized));\n      return normalized;\n    } catch (err) {\n      throw err;\n    }\n  }\n};\n\nconst pushSubscriptionsDb = {\n  saveSubscription: (userId, endpoint, keysP256dh, keysAuth) => {\n    try {\n      db.prepare(\n        `INSERT INTO push_subscriptions (user_id, endpoint, keys_p256dh, keys_auth)\n         VALUES (?, ?, ?, ?)\n         ON CONFLICT(endpoint) DO UPDATE SET\n           user_id = excluded.user_id,\n           keys_p256dh = excluded.keys_p256dh,\n           keys_auth = excluded.keys_auth`\n      ).run(userId, endpoint, keysP256dh, keysAuth);\n    } catch (err) {\n      throw err;\n    }\n  },\n\n  getSubscriptions: (userId) => {\n    try {\n      return db.prepare('SELECT endpoint, keys_p256dh, keys_auth FROM push_subscriptions WHERE user_id = ?').all(userId);\n    } catch (err) {\n      throw err;\n    }\n  },\n\n  removeSubscription: (endpoint) => {\n    try {\n      db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?').run(endpoint);\n    } catch (err) {\n      throw err;\n    }\n  },\n\n  removeAllForUser: (userId) => {\n    try {\n      db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(userId);\n    } catch (err) {\n      throw err;\n    }\n  }\n};\n\n// Session custom names database operations\nconst sessionNamesDb = {\n  // Set (insert or update) a custom session name\n  setName: (sessionId, provider, customName) => {\n    db.prepare(`\n      INSERT INTO session_names (session_id, provider, custom_name)\n      VALUES (?, ?, ?)\n      ON CONFLICT(session_id, provider)\n      DO UPDATE SET custom_name = excluded.custom_name, updated_at = CURRENT_TIMESTAMP\n    `).run(sessionId, provider, customName);\n  },\n\n  // Get a single custom session name\n  getName: (sessionId, provider) => {\n    const row = db.prepare(\n      'SELECT custom_name FROM session_names WHERE session_id = ? AND provider = ?'\n    ).get(sessionId, provider);\n    return row?.custom_name || null;\n  },\n\n  // Batch lookup — returns Map<sessionId, customName>\n  getNames: (sessionIds, provider) => {\n    if (!sessionIds.length) return new Map();\n    const placeholders = sessionIds.map(() => '?').join(',');\n    const rows = db.prepare(\n      `SELECT session_id, custom_name FROM session_names\n       WHERE session_id IN (${placeholders}) AND provider = ?`\n    ).all(...sessionIds, provider);\n    return new Map(rows.map(r => [r.session_id, r.custom_name]));\n  },\n\n  // Delete a custom session name\n  deleteName: (sessionId, provider) => {\n    return db.prepare(\n      'DELETE FROM session_names WHERE session_id = ? AND provider = ?'\n    ).run(sessionId, provider).changes > 0;\n  },\n};\n\n// Apply custom session names from the database (overrides CLI-generated summaries)\nfunction applyCustomSessionNames(sessions, provider) {\n  if (!sessions?.length) return;\n  try {\n    const ids = sessions.map(s => s.id);\n    const customNames = sessionNamesDb.getNames(ids, provider);\n    for (const session of sessions) {\n      const custom = customNames.get(session.id);\n      if (custom) session.summary = custom;\n    }\n  } catch (error) {\n    console.warn(`[DB] Failed to apply custom session names for ${provider}:`, error.message);\n  }\n}\n\n// App config database operations\nconst appConfigDb = {\n  get: (key) => {\n    try {\n      const row = db.prepare('SELECT value FROM app_config WHERE key = ?').get(key);\n      return row?.value || null;\n    } catch (err) {\n      return null;\n    }\n  },\n\n  set: (key, value) => {\n    db.prepare(\n      'INSERT INTO app_config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'\n    ).run(key, value);\n  },\n\n  getOrCreateJwtSecret: () => {\n    let secret = appConfigDb.get('jwt_secret');\n    if (!secret) {\n      secret = crypto.randomBytes(64).toString('hex');\n      appConfigDb.set('jwt_secret', secret);\n    }\n    return secret;\n  }\n};\n\n// Backward compatibility - keep old names pointing to new system\nconst githubTokensDb = {\n  createGithubToken: (userId, tokenName, githubToken, description = null) => {\n    return credentialsDb.createCredential(userId, tokenName, 'github_token', githubToken, description);\n  },\n  getGithubTokens: (userId) => {\n    return credentialsDb.getCredentials(userId, 'github_token');\n  },\n  getActiveGithubToken: (userId) => {\n    return credentialsDb.getActiveCredential(userId, 'github_token');\n  },\n  deleteGithubToken: (userId, tokenId) => {\n    return credentialsDb.deleteCredential(userId, tokenId);\n  },\n  toggleGithubToken: (userId, tokenId, isActive) => {\n    return credentialsDb.toggleCredential(userId, tokenId, isActive);\n  }\n};\n\nexport {\n  db,\n  initializeDatabase,\n  userDb,\n  apiKeysDb,\n  credentialsDb,\n  notificationPreferencesDb,\n  pushSubscriptionsDb,\n  sessionNamesDb,\n  applyCustomSessionNames,\n  appConfigDb,\n  githubTokensDb // Backward compatibility\n};\n"
  },
  {
    "path": "server/database/init.sql",
    "content": "-- Initialize authentication database\nPRAGMA foreign_keys = ON;\n\n-- Users table (single user system)\nCREATE TABLE IF NOT EXISTS users (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    username TEXT UNIQUE NOT NULL,\n    password_hash TEXT NOT NULL,\n    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n    last_login DATETIME,\n    is_active BOOLEAN DEFAULT 1,\n    git_name TEXT,\n    git_email TEXT,\n    has_completed_onboarding BOOLEAN DEFAULT 0\n);\n\n-- Indexes for performance\nCREATE INDEX IF NOT EXISTS idx_users_username ON users(username);\nCREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active);\n\n-- API Keys table for external API access\nCREATE TABLE IF NOT EXISTS api_keys (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    user_id INTEGER NOT NULL,\n    key_name TEXT NOT NULL,\n    api_key TEXT UNIQUE NOT NULL,\n    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n    last_used DATETIME,\n    is_active BOOLEAN DEFAULT 1,\n    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE\n);\n\nCREATE INDEX IF NOT EXISTS idx_api_keys_key ON api_keys(api_key);\nCREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys(user_id);\nCREATE INDEX IF NOT EXISTS idx_api_keys_active ON api_keys(is_active);\n\n-- User credentials table for storing various tokens/credentials (GitHub, GitLab, etc.)\nCREATE TABLE IF NOT EXISTS user_credentials (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    user_id INTEGER NOT NULL,\n    credential_name TEXT NOT NULL,\n    credential_type TEXT NOT NULL, -- 'github_token', 'gitlab_token', 'bitbucket_token', etc.\n    credential_value TEXT NOT NULL,\n    description TEXT,\n    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n    is_active BOOLEAN DEFAULT 1,\n    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE\n);\n\nCREATE INDEX IF NOT EXISTS idx_user_credentials_user_id ON user_credentials(user_id);\nCREATE INDEX IF NOT EXISTS idx_user_credentials_type ON user_credentials(credential_type);\nCREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active);\n\n-- User notification preferences (backend-owned, provider-agnostic)\nCREATE TABLE IF NOT EXISTS user_notification_preferences (\n    user_id INTEGER PRIMARY KEY,\n    preferences_json TEXT NOT NULL,\n    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE\n);\n\n-- VAPID key pair for Web Push notifications\nCREATE TABLE IF NOT EXISTS vapid_keys (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    public_key TEXT NOT NULL,\n    private_key TEXT NOT NULL,\n    created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n);\n\n-- Browser push subscriptions\nCREATE TABLE IF NOT EXISTS push_subscriptions (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    user_id INTEGER NOT NULL,\n    endpoint TEXT NOT NULL UNIQUE,\n    keys_p256dh TEXT NOT NULL,\n    keys_auth TEXT NOT NULL,\n    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE\n);\n\n-- Session custom names (provider-agnostic display name overrides)\nCREATE TABLE IF NOT EXISTS session_names (\n    id INTEGER PRIMARY KEY AUTOINCREMENT,\n    session_id TEXT NOT NULL,\n    provider TEXT NOT NULL DEFAULT 'claude',\n    custom_name TEXT NOT NULL,\n    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n    UNIQUE(session_id, provider)\n);\n\nCREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider);\n\n-- App configuration table (auto-generated secrets, settings, etc.)\nCREATE TABLE IF NOT EXISTS app_config (\n    key TEXT PRIMARY KEY,\n    value TEXT NOT NULL,\n    created_at DATETIME DEFAULT CURRENT_TIMESTAMP\n);\n"
  },
  {
    "path": "server/gemini-cli.js",
    "content": "import { spawn } from 'child_process';\nimport crossSpawn from 'cross-spawn';\n\n// Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js)\nconst spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport os from 'os';\nimport sessionManager from './sessionManager.js';\nimport GeminiResponseHandler from './gemini-response-handler.js';\nimport { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';\nimport { createNormalizedMessage } from './providers/types.js';\n\nlet activeGeminiProcesses = new Map(); // Track active processes by session ID\n\nasync function spawnGemini(command, options = {}, ws) {\n    const { sessionId, projectPath, cwd, toolsSettings, permissionMode, images, sessionSummary } = options;\n    let capturedSessionId = sessionId; // Track session ID throughout the process\n    let sessionCreatedSent = false; // Track if we've already sent session-created event\n    let assistantBlocks = []; // Accumulate the full response blocks including tools\n\n    // Use tools settings passed from frontend, or defaults\n    const settings = toolsSettings || {\n        allowedTools: [],\n        disallowedTools: [],\n        skipPermissions: false\n    };\n\n    // Build Gemini CLI command - start with print/resume flags first\n    const args = [];\n\n    // Add prompt flag with command if we have a command\n    if (command && command.trim()) {\n        args.push('--prompt', command);\n    }\n\n    // If we have a sessionId, we want to resume\n    if (sessionId) {\n        const session = sessionManager.getSession(sessionId);\n        if (session && session.cliSessionId) {\n            args.push('--resume', session.cliSessionId);\n        }\n    }\n\n    // Use cwd (actual project directory) instead of projectPath (Gemini's metadata directory)\n    // Clean the path by removing any non-printable characters\n    const cleanPath = (cwd || projectPath || process.cwd()).replace(/[^\\x20-\\x7E]/g, '').trim();\n    const workingDir = cleanPath;\n\n    // Handle images by saving them to temporary files and passing paths to Gemini\n    const tempImagePaths = [];\n    let tempDir = null;\n    if (images && images.length > 0) {\n        try {\n            // Create temp directory in the project directory so Gemini can access it\n            tempDir = path.join(workingDir, '.tmp', 'images', Date.now().toString());\n            await fs.mkdir(tempDir, { recursive: true });\n\n            // Save each image to a temp file\n            for (const [index, image] of images.entries()) {\n                // Extract base64 data and mime type\n                const matches = image.data.match(/^data:([^;]+);base64,(.+)$/);\n                if (!matches) {\n                    continue;\n                }\n\n                const [, mimeType, base64Data] = matches;\n                const extension = mimeType.split('/')[1] || 'png';\n                const filename = `image_${index}.${extension}`;\n                const filepath = path.join(tempDir, filename);\n\n                // Write base64 data to file\n                await fs.writeFile(filepath, Buffer.from(base64Data, 'base64'));\n                tempImagePaths.push(filepath);\n            }\n\n            // Include the full image paths in the prompt for Gemini to reference\n            // Gemini CLI can read images from file paths in the prompt\n            if (tempImagePaths.length > 0 && command && command.trim()) {\n                const imageNote = `\\n\\n[Images given: ${tempImagePaths.length} images are located at the following paths:]\\n${tempImagePaths.map((p, i) => `${i + 1}. ${p}`).join('\\n')}`;\n                const modifiedCommand = command + imageNote;\n\n                // Update the command in args\n                const promptIndex = args.indexOf('--prompt');\n                if (promptIndex !== -1 && args[promptIndex + 1] === command) {\n                    args[promptIndex + 1] = modifiedCommand;\n                } else if (promptIndex !== -1) {\n                    // If we're using context, update the full prompt\n                    args[promptIndex + 1] = args[promptIndex + 1] + imageNote;\n                }\n            }\n        } catch (error) {\n            console.error('Error processing images for Gemini:', error);\n        }\n    }\n\n    // Add basic flags for Gemini\n    if (options.debug) {\n        args.push('--debug');\n    }\n\n    // Add MCP config flag only if MCP servers are configured\n    try {\n        const geminiConfigPath = path.join(os.homedir(), '.gemini.json');\n        let hasMcpServers = false;\n\n        try {\n            await fs.access(geminiConfigPath);\n            const geminiConfigRaw = await fs.readFile(geminiConfigPath, 'utf8');\n            const geminiConfig = JSON.parse(geminiConfigRaw);\n\n            // Check global MCP servers\n            if (geminiConfig.mcpServers && Object.keys(geminiConfig.mcpServers).length > 0) {\n                hasMcpServers = true;\n            }\n\n            // Check project-specific MCP servers\n            if (!hasMcpServers && geminiConfig.geminiProjects) {\n                const currentProjectPath = process.cwd();\n                const projectConfig = geminiConfig.geminiProjects[currentProjectPath];\n                if (projectConfig && projectConfig.mcpServers && Object.keys(projectConfig.mcpServers).length > 0) {\n                    hasMcpServers = true;\n                }\n            }\n        } catch (e) {\n            // Ignore if file doesn't exist or isn't parsable\n        }\n\n        if (hasMcpServers) {\n            args.push('--mcp-config', geminiConfigPath);\n        }\n    } catch (error) {\n        // Ignore outer errors\n    }\n\n    // Add model for all sessions (both new and resumed)\n    let modelToUse = options.model || 'gemini-2.5-flash';\n    args.push('--model', modelToUse);\n    args.push('--output-format', 'stream-json');\n\n    // Handle approval modes and allowed tools\n    if (settings.skipPermissions || options.skipPermissions || permissionMode === 'yolo') {\n        args.push('--yolo');\n    } else if (permissionMode === 'auto_edit') {\n        args.push('--approval-mode', 'auto_edit');\n    } else if (permissionMode === 'plan') {\n        args.push('--approval-mode', 'plan');\n    }\n\n    if (settings.allowedTools && settings.allowedTools.length > 0) {\n        args.push('--allowed-tools', settings.allowedTools.join(','));\n    }\n\n    // Try to find gemini in PATH first, then fall back to environment variable\n    const geminiPath = process.env.GEMINI_PATH || 'gemini';\n    console.log('Spawning Gemini CLI:', geminiPath, args.join(' '));\n    console.log('Working directory:', workingDir);\n\n    let spawnCmd = geminiPath;\n    let spawnArgs = args;\n\n    // On non-Windows platforms, wrap the execution in a shell to avoid ENOEXEC\n    // which happens when the target is a script lacking a shebang.\n    if (os.platform() !== 'win32') {\n        spawnCmd = 'sh';\n        // Use exec to replace the shell process, ensuring signals hit gemini directly\n        spawnArgs = ['-c', 'exec \"$0\" \"$@\"', geminiPath, ...args];\n    }\n\n    return new Promise((resolve, reject) => {\n        const geminiProcess = spawnFunction(spawnCmd, spawnArgs, {\n            cwd: workingDir,\n            stdio: ['pipe', 'pipe', 'pipe'],\n            env: { ...process.env } // Inherit all environment variables\n        });\n        let terminalNotificationSent = false;\n        let terminalFailureReason = null;\n\n        const notifyTerminalState = ({ code = null, error = null } = {}) => {\n            if (terminalNotificationSent) {\n                return;\n            }\n\n            terminalNotificationSent = true;\n\n            const finalSessionId = capturedSessionId || sessionId || processKey;\n            if (code === 0 && !error) {\n                notifyRunStopped({\n                    userId: ws?.userId || null,\n                    provider: 'gemini',\n                    sessionId: finalSessionId,\n                    sessionName: sessionSummary,\n                    stopReason: 'completed'\n                });\n                return;\n            }\n\n            notifyRunFailed({\n                userId: ws?.userId || null,\n                provider: 'gemini',\n                sessionId: finalSessionId,\n                sessionName: sessionSummary,\n                error: error || terminalFailureReason || `Gemini CLI exited with code ${code}`\n            });\n        };\n\n        // Attach temp file info to process for cleanup later\n        geminiProcess.tempImagePaths = tempImagePaths;\n        geminiProcess.tempDir = tempDir;\n\n        // Store process reference for potential abort\n        const processKey = capturedSessionId || sessionId || Date.now().toString();\n        activeGeminiProcesses.set(processKey, geminiProcess);\n\n        // Store sessionId on the process object for debugging\n        geminiProcess.sessionId = processKey;\n\n        // Close stdin to signal we're done sending input\n        geminiProcess.stdin.end();\n\n        // Add timeout handler\n        const timeoutMs = 120000; // 120 seconds for slower models\n        let timeout;\n\n        const startTimeout = () => {\n            if (timeout) clearTimeout(timeout);\n            timeout = setTimeout(() => {\n                const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : (capturedSessionId || sessionId || processKey);\n                terminalFailureReason = `Gemini CLI timeout - no response received for ${timeoutMs / 1000} seconds`;\n                ws.send(createNormalizedMessage({ kind: 'error', content: terminalFailureReason, sessionId: socketSessionId, provider: 'gemini' }));\n                try {\n                    geminiProcess.kill('SIGTERM');\n                } catch (e) { }\n            }, timeoutMs);\n        };\n\n        startTimeout();\n\n        // Save user message to session when starting\n        if (command && capturedSessionId) {\n            sessionManager.addMessage(capturedSessionId, 'user', command);\n        }\n\n        // Create response handler for NDJSON buffering\n        let responseHandler;\n        if (ws) {\n            responseHandler = new GeminiResponseHandler(ws, {\n                onContentFragment: (content) => {\n                    if (assistantBlocks.length > 0 && assistantBlocks[assistantBlocks.length - 1].type === 'text') {\n                        assistantBlocks[assistantBlocks.length - 1].text += content;\n                    } else {\n                        assistantBlocks.push({ type: 'text', text: content });\n                    }\n                },\n                onToolUse: (event) => {\n                    assistantBlocks.push({\n                        type: 'tool_use',\n                        id: event.tool_id,\n                        name: event.tool_name,\n                        input: event.parameters\n                    });\n                },\n                onToolResult: (event) => {\n                    if (capturedSessionId) {\n                        if (assistantBlocks.length > 0) {\n                            sessionManager.addMessage(capturedSessionId, 'assistant', [...assistantBlocks]);\n                            assistantBlocks = [];\n                        }\n                        sessionManager.addMessage(capturedSessionId, 'user', [{\n                            type: 'tool_result',\n                            tool_use_id: event.tool_id,\n                            content: event.output === undefined ? null : event.output,\n                            is_error: event.status === 'error'\n                        }]);\n                    }\n                },\n                onInit: (event) => {\n                    if (capturedSessionId) {\n                        const sess = sessionManager.getSession(capturedSessionId);\n                        if (sess && !sess.cliSessionId) {\n                            sess.cliSessionId = event.session_id;\n                            sessionManager.saveSession(capturedSessionId);\n                        }\n                    }\n                }\n            });\n        }\n\n        // Handle stdout\n        geminiProcess.stdout.on('data', (data) => {\n            const rawOutput = data.toString();\n            startTimeout(); // Re-arm the timeout\n\n            // For new sessions, create a session ID FIRST\n            if (!sessionId && !sessionCreatedSent && !capturedSessionId) {\n                capturedSessionId = `gemini_${Date.now()}`;\n                sessionCreatedSent = true;\n\n                // Create session in session manager\n                sessionManager.createSession(capturedSessionId, cwd || process.cwd());\n\n                // Save the user message now that we have a session ID\n                if (command) {\n                    sessionManager.addMessage(capturedSessionId, 'user', command);\n                }\n\n                // Update process key with captured session ID\n                if (processKey !== capturedSessionId) {\n                    activeGeminiProcesses.delete(processKey);\n                    activeGeminiProcesses.set(capturedSessionId, geminiProcess);\n                }\n\n                ws.setSessionId && typeof ws.setSessionId === 'function' && ws.setSessionId(capturedSessionId);\n\n                ws.send(createNormalizedMessage({ kind: 'session_created', newSessionId: capturedSessionId, sessionId: capturedSessionId, provider: 'gemini' }));\n            }\n\n            if (responseHandler) {\n                responseHandler.processData(rawOutput);\n            } else if (rawOutput) {\n                // Fallback to direct sending for raw CLI mode without WS\n                if (assistantBlocks.length > 0 && assistantBlocks[assistantBlocks.length - 1].type === 'text') {\n                    assistantBlocks[assistantBlocks.length - 1].text += rawOutput;\n                } else {\n                    assistantBlocks.push({ type: 'text', text: rawOutput });\n                }\n                const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : (capturedSessionId || sessionId);\n                ws.send(createNormalizedMessage({ kind: 'stream_delta', content: rawOutput, sessionId: socketSessionId, provider: 'gemini' }));\n            }\n        });\n\n        // Handle stderr\n        geminiProcess.stderr.on('data', (data) => {\n            const errorMsg = data.toString();\n\n            // Filter out deprecation warnings and \"Loaded cached credentials\" message\n            if (errorMsg.includes('[DEP0040]') ||\n                errorMsg.includes('DeprecationWarning') ||\n                errorMsg.includes('--trace-deprecation') ||\n                errorMsg.includes('Loaded cached credentials')) {\n                return;\n            }\n\n            const socketSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : (capturedSessionId || sessionId);\n            ws.send(createNormalizedMessage({ kind: 'error', content: errorMsg, sessionId: socketSessionId, provider: 'gemini' }));\n        });\n\n        // Handle process completion\n        geminiProcess.on('close', async (code) => {\n            clearTimeout(timeout);\n\n            // Flush any remaining buffered content\n            if (responseHandler) {\n                responseHandler.forceFlush();\n                responseHandler.destroy();\n            }\n\n            // Clean up process reference\n            const finalSessionId = capturedSessionId || sessionId || processKey;\n            activeGeminiProcesses.delete(finalSessionId);\n\n            // Save assistant response to session if we have one\n            if (finalSessionId && assistantBlocks.length > 0) {\n                sessionManager.addMessage(finalSessionId, 'assistant', assistantBlocks);\n            }\n\n            ws.send(createNormalizedMessage({ kind: 'complete', exitCode: code, isNewSession: !sessionId && !!command, sessionId: finalSessionId, provider: 'gemini' }));\n\n            // Clean up temporary image files if any\n            if (geminiProcess.tempImagePaths && geminiProcess.tempImagePaths.length > 0) {\n                for (const imagePath of geminiProcess.tempImagePaths) {\n                    await fs.unlink(imagePath).catch(err => { });\n                }\n                if (geminiProcess.tempDir) {\n                    await fs.rm(geminiProcess.tempDir, { recursive: true, force: true }).catch(err => { });\n                }\n            }\n\n            if (code === 0) {\n                notifyTerminalState({ code });\n                resolve();\n            } else {\n                notifyTerminalState({\n                    code,\n                    error: code === null ? 'Gemini CLI process was terminated or timed out' : null\n                });\n                reject(new Error(code === null ? 'Gemini CLI process was terminated or timed out' : `Gemini CLI exited with code ${code}`));\n            }\n        });\n\n        // Handle process errors\n        geminiProcess.on('error', (error) => {\n            // Clean up process reference on error\n            const finalSessionId = capturedSessionId || sessionId || processKey;\n            activeGeminiProcesses.delete(finalSessionId);\n\n            const errorSessionId = typeof ws.getSessionId === 'function' ? ws.getSessionId() : finalSessionId;\n            ws.send(createNormalizedMessage({ kind: 'error', content: error.message, sessionId: errorSessionId, provider: 'gemini' }));\n            notifyTerminalState({ error });\n\n            reject(error);\n        });\n\n    });\n}\n\nfunction abortGeminiSession(sessionId) {\n    let geminiProc = activeGeminiProcesses.get(sessionId);\n    let processKey = sessionId;\n\n    if (!geminiProc) {\n        for (const [key, proc] of activeGeminiProcesses.entries()) {\n            if (proc.sessionId === sessionId) {\n                geminiProc = proc;\n                processKey = key;\n                break;\n            }\n        }\n    }\n\n    if (geminiProc) {\n        try {\n            geminiProc.kill('SIGTERM');\n            setTimeout(() => {\n                if (activeGeminiProcesses.has(processKey)) {\n                    try {\n                        geminiProc.kill('SIGKILL');\n                    } catch (e) { }\n                }\n            }, 2000); // Wait 2 seconds before force kill\n\n            return true;\n        } catch (error) {\n            return false;\n        }\n    }\n    return false;\n}\n\nfunction isGeminiSessionActive(sessionId) {\n    return activeGeminiProcesses.has(sessionId);\n}\n\nfunction getActiveGeminiSessions() {\n    return Array.from(activeGeminiProcesses.keys());\n}\n\nexport {\n    spawnGemini,\n    abortGeminiSession,\n    isGeminiSessionActive,\n    getActiveGeminiSessions\n};\n"
  },
  {
    "path": "server/gemini-response-handler.js",
    "content": "// Gemini Response Handler - JSON Stream processing\nimport { geminiAdapter } from './providers/gemini/adapter.js';\n\nclass GeminiResponseHandler {\n  constructor(ws, options = {}) {\n    this.ws = ws;\n    this.buffer = '';\n    this.onContentFragment = options.onContentFragment || null;\n    this.onInit = options.onInit || null;\n    this.onToolUse = options.onToolUse || null;\n    this.onToolResult = options.onToolResult || null;\n  }\n\n  // Process incoming raw data from Gemini stream-json\n  processData(data) {\n    this.buffer += data;\n\n    // Split by newline\n    const lines = this.buffer.split('\\n');\n\n    // Keep the last incomplete line in the buffer\n    this.buffer = lines.pop() || '';\n\n    for (const line of lines) {\n      if (!line.trim()) continue;\n\n      try {\n        const event = JSON.parse(line);\n        this.handleEvent(event);\n      } catch (err) {\n        // Not a JSON line, probably debug output or CLI warnings\n      }\n    }\n  }\n\n  handleEvent(event) {\n    const sid = typeof this.ws.getSessionId === 'function' ? this.ws.getSessionId() : null;\n\n    if (event.type === 'init') {\n      if (this.onInit) {\n        this.onInit(event);\n      }\n      return;\n    }\n\n    // Invoke per-type callbacks for session tracking\n    if (event.type === 'message' && event.role === 'assistant') {\n      const content = event.content || '';\n      if (this.onContentFragment && content) {\n        this.onContentFragment(content);\n      }\n    } else if (event.type === 'tool_use' && this.onToolUse) {\n      this.onToolUse(event);\n    } else if (event.type === 'tool_result' && this.onToolResult) {\n      this.onToolResult(event);\n    }\n\n    // Normalize via adapter and send all resulting messages\n    const normalized = geminiAdapter.normalizeMessage(event, sid);\n    for (const msg of normalized) {\n      this.ws.send(msg);\n    }\n  }\n\n  forceFlush() {\n    if (this.buffer.trim()) {\n      try {\n        const event = JSON.parse(this.buffer);\n        this.handleEvent(event);\n      } catch (err) { }\n    }\n  }\n\n  destroy() {\n    this.buffer = '';\n  }\n}\n\nexport default GeminiResponseHandler;\n"
  },
  {
    "path": "server/index.js",
    "content": "#!/usr/bin/env node\n// Load environment variables before other imports execute\nimport './load-env.js';\nimport fs from 'fs';\nimport path from 'path';\nimport { fileURLToPath } from 'url';\nimport { dirname } from 'path';\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = dirname(__filename);\n\nconst installMode = fs.existsSync(path.join(__dirname, '..', '.git')) ? 'git' : 'npm';\n\n// ANSI color codes for terminal output\nconst colors = {\n    reset: '\\x1b[0m',\n    bright: '\\x1b[1m',\n    cyan: '\\x1b[36m',\n    green: '\\x1b[32m',\n    yellow: '\\x1b[33m',\n    blue: '\\x1b[34m',\n    dim: '\\x1b[2m',\n};\n\nconst c = {\n    info: (text) => `${colors.cyan}${text}${colors.reset}`,\n    ok: (text) => `${colors.green}${text}${colors.reset}`,\n    warn: (text) => `${colors.yellow}${text}${colors.reset}`,\n    tip: (text) => `${colors.blue}${text}${colors.reset}`,\n    bright: (text) => `${colors.bright}${text}${colors.reset}`,\n    dim: (text) => `${colors.dim}${text}${colors.reset}`,\n};\n\nconsole.log('SERVER_PORT from env:', process.env.SERVER_PORT);\n\nimport express from 'express';\nimport { WebSocketServer, WebSocket } from 'ws';\nimport os from 'os';\nimport http from 'http';\nimport cors from 'cors';\nimport { promises as fsPromises } from 'fs';\nimport { spawn } from 'child_process';\nimport pty from 'node-pty';\nimport fetch from 'node-fetch';\nimport mime from 'mime-types';\n\nimport { getProjects, getSessions, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache, searchConversations } from './projects.js';\nimport { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval, getPendingApprovalsForSession, reconnectSessionWriter } from './claude-sdk.js';\nimport { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';\nimport { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js';\nimport { spawnGemini, abortGeminiSession, isGeminiSessionActive, getActiveGeminiSessions } from './gemini-cli.js';\nimport sessionManager from './sessionManager.js';\nimport gitRoutes from './routes/git.js';\nimport authRoutes from './routes/auth.js';\nimport mcpRoutes from './routes/mcp.js';\nimport cursorRoutes from './routes/cursor.js';\nimport taskmasterRoutes from './routes/taskmaster.js';\nimport mcpUtilsRoutes from './routes/mcp-utils.js';\nimport commandsRoutes from './routes/commands.js';\nimport settingsRoutes from './routes/settings.js';\nimport agentRoutes from './routes/agent.js';\nimport projectsRoutes, { WORKSPACES_ROOT, validateWorkspacePath } from './routes/projects.js';\nimport cliAuthRoutes from './routes/cli-auth.js';\nimport userRoutes from './routes/user.js';\nimport codexRoutes from './routes/codex.js';\nimport geminiRoutes from './routes/gemini.js';\nimport pluginsRoutes from './routes/plugins.js';\nimport messagesRoutes from './routes/messages.js';\nimport { createNormalizedMessage } from './providers/types.js';\nimport { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js';\nimport { initializeDatabase, sessionNamesDb, applyCustomSessionNames } from './database/db.js';\nimport { configureWebPush } from './services/vapid-keys.js';\nimport { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';\nimport { IS_PLATFORM } from './constants/config.js';\nimport { getConnectableHost } from '../shared/networkHosts.js';\n\nconst VALID_PROVIDERS = ['claude', 'codex', 'cursor', 'gemini'];\n\n// File system watchers for provider project/session folders\nconst PROVIDER_WATCH_PATHS = [\n    { provider: 'claude', rootPath: path.join(os.homedir(), '.claude', 'projects') },\n    { provider: 'cursor', rootPath: path.join(os.homedir(), '.cursor', 'chats') },\n    { provider: 'codex', rootPath: path.join(os.homedir(), '.codex', 'sessions') },\n    { provider: 'gemini', rootPath: path.join(os.homedir(), '.gemini', 'projects') },\n    { provider: 'gemini_sessions', rootPath: path.join(os.homedir(), '.gemini', 'sessions') }\n];\nconst WATCHER_IGNORED_PATTERNS = [\n    '**/node_modules/**',\n    '**/.git/**',\n    '**/dist/**',\n    '**/build/**',\n    '**/*.tmp',\n    '**/*.swp',\n    '**/.DS_Store'\n];\nconst WATCHER_DEBOUNCE_MS = 300;\nlet projectsWatchers = [];\nlet projectsWatcherDebounceTimer = null;\nconst connectedClients = new Set();\nlet isGetProjectsRunning = false; // Flag to prevent reentrant calls\n\n// Broadcast progress to all connected WebSocket clients\nfunction broadcastProgress(progress) {\n    const message = JSON.stringify({\n        type: 'loading_progress',\n        ...progress\n    });\n    connectedClients.forEach(client => {\n        if (client.readyState === WebSocket.OPEN) {\n            client.send(message);\n        }\n    });\n}\n\n// Setup file system watchers for Claude, Cursor, and Codex project/session folders\nasync function setupProjectsWatcher() {\n    const chokidar = (await import('chokidar')).default;\n\n    if (projectsWatcherDebounceTimer) {\n        clearTimeout(projectsWatcherDebounceTimer);\n        projectsWatcherDebounceTimer = null;\n    }\n\n    await Promise.all(\n        projectsWatchers.map(async (watcher) => {\n            try {\n                await watcher.close();\n            } catch (error) {\n                console.error('[WARN] Failed to close watcher:', error);\n            }\n        })\n    );\n    projectsWatchers = [];\n\n    const debouncedUpdate = (eventType, filePath, provider, rootPath) => {\n        if (projectsWatcherDebounceTimer) {\n            clearTimeout(projectsWatcherDebounceTimer);\n        }\n\n        projectsWatcherDebounceTimer = setTimeout(async () => {\n            // Prevent reentrant calls\n            if (isGetProjectsRunning) {\n                return;\n            }\n\n            try {\n                isGetProjectsRunning = true;\n\n                // Clear project directory cache when files change\n                clearProjectDirectoryCache();\n\n                // Get updated projects list\n                const updatedProjects = await getProjects(broadcastProgress);\n\n                // Notify all connected clients about the project changes\n                const updateMessage = JSON.stringify({\n                    type: 'projects_updated',\n                    projects: updatedProjects,\n                    timestamp: new Date().toISOString(),\n                    changeType: eventType,\n                    changedFile: path.relative(rootPath, filePath),\n                    watchProvider: provider\n                });\n\n                connectedClients.forEach(client => {\n                    if (client.readyState === WebSocket.OPEN) {\n                        client.send(updateMessage);\n                    }\n                });\n\n            } catch (error) {\n                console.error('[ERROR] Error handling project changes:', error);\n            } finally {\n                isGetProjectsRunning = false;\n            }\n        }, WATCHER_DEBOUNCE_MS);\n    };\n\n    for (const { provider, rootPath } of PROVIDER_WATCH_PATHS) {\n        try {\n            // chokidar v4 emits ENOENT via the \"error\" event for missing roots and will not auto-recover.\n            // Ensure provider folders exist before creating the watcher so watching stays active.\n            await fsPromises.mkdir(rootPath, { recursive: true });\n\n            // Initialize chokidar watcher with optimized settings\n            const watcher = chokidar.watch(rootPath, {\n                ignored: WATCHER_IGNORED_PATTERNS,\n                persistent: true,\n                ignoreInitial: true, // Don't fire events for existing files on startup\n                followSymlinks: false,\n                depth: 10, // Reasonable depth limit\n                awaitWriteFinish: {\n                    stabilityThreshold: 100, // Wait 100ms for file to stabilize\n                    pollInterval: 50\n                }\n            });\n\n            // Set up event listeners\n            watcher\n                .on('add', (filePath) => debouncedUpdate('add', filePath, provider, rootPath))\n                .on('change', (filePath) => debouncedUpdate('change', filePath, provider, rootPath))\n                .on('unlink', (filePath) => debouncedUpdate('unlink', filePath, provider, rootPath))\n                .on('addDir', (dirPath) => debouncedUpdate('addDir', dirPath, provider, rootPath))\n                .on('unlinkDir', (dirPath) => debouncedUpdate('unlinkDir', dirPath, provider, rootPath))\n                .on('error', (error) => {\n                    console.error(`[ERROR] ${provider} watcher error:`, error);\n                })\n                .on('ready', () => {\n                });\n\n            projectsWatchers.push(watcher);\n        } catch (error) {\n            console.error(`[ERROR] Failed to setup ${provider} watcher for ${rootPath}:`, error);\n        }\n    }\n\n    if (projectsWatchers.length === 0) {\n        console.error('[ERROR] Failed to setup any provider watchers');\n    }\n}\n\n\nconst app = express();\nconst server = http.createServer(app);\n\nconst ptySessionsMap = new Map();\nconst PTY_SESSION_TIMEOUT = 30 * 60 * 1000;\nconst SHELL_URL_PARSE_BUFFER_LIMIT = 32768;\nconst ANSI_ESCAPE_SEQUENCE_REGEX = /\\x1B(?:[@-Z\\\\-_]|\\[[0-?]*[ -/]*[@-~]|\\][^\\x07]*(?:\\x07|\\x1B\\\\))/g;\nconst TRAILING_URL_PUNCTUATION_REGEX = /[)\\]}>.,;:!?]+$/;\n\nfunction stripAnsiSequences(value = '') {\n    return value.replace(ANSI_ESCAPE_SEQUENCE_REGEX, '');\n}\n\nfunction normalizeDetectedUrl(url) {\n    if (!url || typeof url !== 'string') return null;\n\n    const cleaned = url.trim().replace(TRAILING_URL_PUNCTUATION_REGEX, '');\n    if (!cleaned) return null;\n\n    try {\n        const parsed = new URL(cleaned);\n        if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {\n            return null;\n        }\n        return parsed.toString();\n    } catch {\n        return null;\n    }\n}\n\nfunction extractUrlsFromText(value = '') {\n    const directMatches = value.match(/https?:\\/\\/[^\\s<>\"'`\\\\\\x1b\\x07]+/gi) || [];\n\n    // Handle wrapped terminal URLs split across lines by terminal width.\n    const wrappedMatches = [];\n    const continuationRegex = /^[A-Za-z0-9\\-._~:/?#\\[\\]@!$&'()*+,;=%]+$/;\n    const lines = value.split(/\\r?\\n/);\n    for (let i = 0; i < lines.length; i++) {\n        const line = lines[i].trim();\n        const startMatch = line.match(/https?:\\/\\/[^\\s<>\"'`\\\\\\x1b\\x07]+/i);\n        if (!startMatch) continue;\n\n        let combined = startMatch[0];\n        let j = i + 1;\n        while (j < lines.length) {\n            const continuation = lines[j].trim();\n            if (!continuation) break;\n            if (!continuationRegex.test(continuation)) break;\n            combined += continuation;\n            j++;\n        }\n\n        wrappedMatches.push(combined.replace(/\\r?\\n\\s*/g, ''));\n    }\n\n    return Array.from(new Set([...directMatches, ...wrappedMatches]));\n}\n\nfunction shouldAutoOpenUrlFromOutput(value = '') {\n    const normalized = value.toLowerCase();\n    return (\n        normalized.includes('browser didn\\'t open') ||\n        normalized.includes('open this url') ||\n        normalized.includes('continue in your browser') ||\n        normalized.includes('press enter to open') ||\n        normalized.includes('open_url:')\n    );\n}\n\n// Single WebSocket server that handles both paths\nconst wss = new WebSocketServer({\n    server,\n    verifyClient: (info) => {\n        console.log('WebSocket connection attempt to:', info.req.url);\n\n        // Platform mode: always allow connection\n        if (IS_PLATFORM) {\n            const user = authenticateWebSocket(null); // Will return first user\n            if (!user) {\n                console.log('[WARN] Platform mode: No user found in database');\n                return false;\n            }\n            info.req.user = user;\n            console.log('[OK] Platform mode WebSocket authenticated for user:', user.username);\n            return true;\n        }\n\n        // Normal mode: verify token\n        // Extract token from query parameters or headers\n        const url = new URL(info.req.url, 'http://localhost');\n        const token = url.searchParams.get('token') ||\n            info.req.headers.authorization?.split(' ')[1];\n\n        // Verify token\n        const user = authenticateWebSocket(token);\n        if (!user) {\n            console.log('[WARN] WebSocket authentication failed');\n            return false;\n        }\n\n        // Store user info in the request for later use\n        info.req.user = user;\n        console.log('[OK] WebSocket authenticated for user:', user.username);\n        return true;\n    }\n});\n\n// Make WebSocket server available to routes\napp.locals.wss = wss;\n\napp.use(cors({ exposedHeaders: ['X-Refreshed-Token'] }));\napp.use(express.json({\n    limit: '50mb',\n    type: (req) => {\n        // Skip multipart/form-data requests (for file uploads like images)\n        const contentType = req.headers['content-type'] || '';\n        if (contentType.includes('multipart/form-data')) {\n            return false;\n        }\n        return contentType.includes('json');\n    }\n}));\napp.use(express.urlencoded({ limit: '50mb', extended: true }));\n\n// Public health check endpoint (no authentication required)\napp.get('/health', (req, res) => {\n    res.json({\n        status: 'ok',\n        timestamp: new Date().toISOString(),\n        installMode\n    });\n});\n\n// Optional API key validation (if configured)\napp.use('/api', validateApiKey);\n\n// Authentication routes (public)\napp.use('/api/auth', authRoutes);\n\n// Projects API Routes (protected)\napp.use('/api/projects', authenticateToken, projectsRoutes);\n\n// Git API Routes (protected)\napp.use('/api/git', authenticateToken, gitRoutes);\n\n// MCP API Routes (protected)\napp.use('/api/mcp', authenticateToken, mcpRoutes);\n\n// Cursor API Routes (protected)\napp.use('/api/cursor', authenticateToken, cursorRoutes);\n\n// TaskMaster API Routes (protected)\napp.use('/api/taskmaster', authenticateToken, taskmasterRoutes);\n\n// MCP utilities\napp.use('/api/mcp-utils', authenticateToken, mcpUtilsRoutes);\n\n// Commands API Routes (protected)\napp.use('/api/commands', authenticateToken, commandsRoutes);\n\n// Settings API Routes (protected)\napp.use('/api/settings', authenticateToken, settingsRoutes);\n\n// CLI Authentication API Routes (protected)\napp.use('/api/cli', authenticateToken, cliAuthRoutes);\n\n// User API Routes (protected)\napp.use('/api/user', authenticateToken, userRoutes);\n\n// Codex API Routes (protected)\napp.use('/api/codex', authenticateToken, codexRoutes);\n\n// Gemini API Routes (protected)\napp.use('/api/gemini', authenticateToken, geminiRoutes);\n\n// Plugins API Routes (protected)\napp.use('/api/plugins', authenticateToken, pluginsRoutes);\n\n// Unified session messages route (protected)\napp.use('/api/sessions', authenticateToken, messagesRoutes);\n\n// Agent API Routes (uses API key authentication)\napp.use('/api/agent', agentRoutes);\n\n// Serve public files (like api-docs.html)\napp.use(express.static(path.join(__dirname, '../public')));\n\n// Static files served after API routes\n// Add cache control: HTML files should not be cached, but assets can be cached\napp.use(express.static(path.join(__dirname, '../dist'), {\n    setHeaders: (res, filePath) => {\n        if (filePath.endsWith('.html')) {\n            // Prevent HTML caching to avoid service worker issues after builds\n            res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');\n            res.setHeader('Pragma', 'no-cache');\n            res.setHeader('Expires', '0');\n        } else if (filePath.match(/\\.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico)$/)) {\n            // Cache static assets for 1 year (they have hashed names)\n            res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');\n        }\n    }\n}));\n\n// API Routes (protected)\n// /api/config endpoint removed - no longer needed\n// Frontend now uses window.location for WebSocket URLs\n\n// System update endpoint\napp.post('/api/system/update', authenticateToken, async (req, res) => {\n    try {\n        // Get the project root directory (parent of server directory)\n        const projectRoot = path.join(__dirname, '..');\n\n        console.log('Starting system update from directory:', projectRoot);\n\n        // Run the update command based on install mode\n        const updateCommand = installMode === 'git'\n            ? 'git checkout main && git pull && npm install'\n            : 'npm install -g @siteboon/claude-code-ui@latest';\n\n        const child = spawn('sh', ['-c', updateCommand], {\n            cwd: installMode === 'git' ? projectRoot : os.homedir(),\n            env: process.env\n        });\n\n        let output = '';\n        let errorOutput = '';\n\n        child.stdout.on('data', (data) => {\n            const text = data.toString();\n            output += text;\n            console.log('Update output:', text);\n        });\n\n        child.stderr.on('data', (data) => {\n            const text = data.toString();\n            errorOutput += text;\n            console.error('Update error:', text);\n        });\n\n        child.on('close', (code) => {\n            if (code === 0) {\n                res.json({\n                    success: true,\n                    output: output || 'Update completed successfully',\n                    message: 'Update completed. Please restart the server to apply changes.'\n                });\n            } else {\n                res.status(500).json({\n                    success: false,\n                    error: 'Update command failed',\n                    output: output,\n                    errorOutput: errorOutput\n                });\n            }\n        });\n\n        child.on('error', (error) => {\n            console.error('Update process error:', error);\n            res.status(500).json({\n                success: false,\n                error: error.message\n            });\n        });\n\n    } catch (error) {\n        console.error('System update error:', error);\n        res.status(500).json({\n            success: false,\n            error: error.message\n        });\n    }\n});\n\napp.get('/api/projects', authenticateToken, async (req, res) => {\n    try {\n        const projects = await getProjects(broadcastProgress);\n        res.json(projects);\n    } catch (error) {\n        res.status(500).json({ error: error.message });\n    }\n});\n\napp.get('/api/projects/:projectName/sessions', authenticateToken, async (req, res) => {\n    try {\n        const { limit = 5, offset = 0 } = req.query;\n        const result = await getSessions(req.params.projectName, parseInt(limit), parseInt(offset));\n        applyCustomSessionNames(result.sessions, 'claude');\n        res.json(result);\n    } catch (error) {\n        res.status(500).json({ error: error.message });\n    }\n});\n\n// Rename project endpoint\napp.put('/api/projects/:projectName/rename', authenticateToken, async (req, res) => {\n    try {\n        const { displayName } = req.body;\n        await renameProject(req.params.projectName, displayName);\n        res.json({ success: true });\n    } catch (error) {\n        res.status(500).json({ error: error.message });\n    }\n});\n\n// Delete session endpoint\napp.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken, async (req, res) => {\n    try {\n        const { projectName, sessionId } = req.params;\n        console.log(`[API] Deleting session: ${sessionId} from project: ${projectName}`);\n        await deleteSession(projectName, sessionId);\n        sessionNamesDb.deleteName(sessionId, 'claude');\n        console.log(`[API] Session ${sessionId} deleted successfully`);\n        res.json({ success: true });\n    } catch (error) {\n        console.error(`[API] Error deleting session ${req.params.sessionId}:`, error);\n        res.status(500).json({ error: error.message });\n    }\n});\n\n// Rename session endpoint\napp.put('/api/sessions/:sessionId/rename', authenticateToken, async (req, res) => {\n    try {\n        const { sessionId } = req.params;\n        const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');\n        if (!safeSessionId || safeSessionId !== String(sessionId)) {\n            return res.status(400).json({ error: 'Invalid sessionId' });\n        }\n        const { summary, provider } = req.body;\n        if (!summary || typeof summary !== 'string' || summary.trim() === '') {\n            return res.status(400).json({ error: 'Summary is required' });\n        }\n        if (summary.trim().length > 500) {\n            return res.status(400).json({ error: 'Summary must not exceed 500 characters' });\n        }\n        if (!provider || !VALID_PROVIDERS.includes(provider)) {\n            return res.status(400).json({ error: `Provider must be one of: ${VALID_PROVIDERS.join(', ')}` });\n        }\n        sessionNamesDb.setName(safeSessionId, provider, summary.trim());\n        res.json({ success: true });\n    } catch (error) {\n        console.error(`[API] Error renaming session ${req.params.sessionId}:`, error);\n        res.status(500).json({ error: error.message });\n    }\n});\n\n// Delete project endpoint (force=true to delete with sessions)\napp.delete('/api/projects/:projectName', authenticateToken, async (req, res) => {\n    try {\n        const { projectName } = req.params;\n        const force = req.query.force === 'true';\n        await deleteProject(projectName, force);\n        res.json({ success: true });\n    } catch (error) {\n        res.status(500).json({ error: error.message });\n    }\n});\n\n// Create project endpoint\napp.post('/api/projects/create', authenticateToken, async (req, res) => {\n    try {\n        const { path: projectPath } = req.body;\n\n        if (!projectPath || !projectPath.trim()) {\n            return res.status(400).json({ error: 'Project path is required' });\n        }\n\n        const project = await addProjectManually(projectPath.trim());\n        res.json({ success: true, project });\n    } catch (error) {\n        console.error('Error creating project:', error);\n        res.status(500).json({ error: error.message });\n    }\n});\n\n// Search conversations content (SSE streaming)\napp.get('/api/search/conversations', authenticateToken, async (req, res) => {\n    const query = typeof req.query.q === 'string' ? req.query.q.trim() : '';\n    const parsedLimit = Number.parseInt(String(req.query.limit), 10);\n    const limit = Number.isNaN(parsedLimit) ? 50 : Math.max(1, Math.min(parsedLimit, 100));\n\n    if (query.length < 2) {\n        return res.status(400).json({ error: 'Query must be at least 2 characters' });\n    }\n\n    res.writeHead(200, {\n        'Content-Type': 'text/event-stream',\n        'Cache-Control': 'no-cache',\n        'Connection': 'keep-alive',\n        'X-Accel-Buffering': 'no',\n    });\n\n    let closed = false;\n    const abortController = new AbortController();\n    req.on('close', () => { closed = true; abortController.abort(); });\n\n    try {\n        await searchConversations(query, limit, ({ projectResult, totalMatches, scannedProjects, totalProjects }) => {\n            if (closed) return;\n            if (projectResult) {\n                res.write(`event: result\\ndata: ${JSON.stringify({ projectResult, totalMatches, scannedProjects, totalProjects })}\\n\\n`);\n            } else {\n                res.write(`event: progress\\ndata: ${JSON.stringify({ totalMatches, scannedProjects, totalProjects })}\\n\\n`);\n            }\n        }, abortController.signal);\n        if (!closed) {\n            res.write(`event: done\\ndata: {}\\n\\n`);\n        }\n    } catch (error) {\n        console.error('Error searching conversations:', error);\n        if (!closed) {\n            res.write(`event: error\\ndata: ${JSON.stringify({ error: 'Search failed' })}\\n\\n`);\n        }\n    } finally {\n        if (!closed) {\n            res.end();\n        }\n    }\n});\n\nconst expandWorkspacePath = (inputPath) => {\n    if (!inputPath) return inputPath;\n    if (inputPath === '~') {\n        return WORKSPACES_ROOT;\n    }\n    if (inputPath.startsWith('~/') || inputPath.startsWith('~\\\\')) {\n        return path.join(WORKSPACES_ROOT, inputPath.slice(2));\n    }\n    return inputPath;\n};\n\n// Browse filesystem endpoint for project suggestions - uses existing getFileTree\napp.get('/api/browse-filesystem', authenticateToken, async (req, res) => {\n    try {\n        const { path: dirPath } = req.query;\n\n        console.log('[API] Browse filesystem request for path:', dirPath);\n        console.log('[API] WORKSPACES_ROOT is:', WORKSPACES_ROOT);\n        // Default to home directory if no path provided\n        const defaultRoot = WORKSPACES_ROOT;\n        let targetPath = dirPath ? expandWorkspacePath(dirPath) : defaultRoot;\n\n        // Resolve and normalize the path\n        targetPath = path.resolve(targetPath);\n\n        // Security check - ensure path is within allowed workspace root\n        const validation = await validateWorkspacePath(targetPath);\n        if (!validation.valid) {\n            return res.status(403).json({ error: validation.error });\n        }\n        const resolvedPath = validation.resolvedPath || targetPath;\n\n        // Security check - ensure path is accessible\n        try {\n            await fs.promises.access(resolvedPath);\n            const stats = await fs.promises.stat(resolvedPath);\n\n            if (!stats.isDirectory()) {\n                return res.status(400).json({ error: 'Path is not a directory' });\n            }\n        } catch (err) {\n            return res.status(404).json({ error: 'Directory not accessible' });\n        }\n\n        // Use existing getFileTree function with shallow depth (only direct children)\n        const fileTree = await getFileTree(resolvedPath, 1, 0, false); // maxDepth=1, showHidden=false\n\n        // Filter only directories and format for suggestions\n        const directories = fileTree\n            .filter(item => item.type === 'directory')\n            .map(item => ({\n                path: item.path,\n                name: item.name,\n                type: 'directory'\n            }))\n            .sort((a, b) => {\n                const aHidden = a.name.startsWith('.');\n                const bHidden = b.name.startsWith('.');\n                if (aHidden && !bHidden) return 1;\n                if (!aHidden && bHidden) return -1;\n                return a.name.localeCompare(b.name);\n            });\n\n        // Add common directories if browsing home directory\n        const suggestions = [];\n        let resolvedWorkspaceRoot = defaultRoot;\n        try {\n            resolvedWorkspaceRoot = await fsPromises.realpath(defaultRoot);\n        } catch (error) {\n            // Use default root as-is if realpath fails\n        }\n        if (resolvedPath === resolvedWorkspaceRoot) {\n            const commonDirs = ['Desktop', 'Documents', 'Projects', 'Development', 'Dev', 'Code', 'workspace'];\n            const existingCommon = directories.filter(dir => commonDirs.includes(dir.name));\n            const otherDirs = directories.filter(dir => !commonDirs.includes(dir.name));\n\n            suggestions.push(...existingCommon, ...otherDirs);\n        } else {\n            suggestions.push(...directories);\n        }\n\n        res.json({\n            path: resolvedPath,\n            suggestions: suggestions\n        });\n\n    } catch (error) {\n        console.error('Error browsing filesystem:', error);\n        res.status(500).json({ error: 'Failed to browse filesystem' });\n    }\n});\n\napp.post('/api/create-folder', authenticateToken, async (req, res) => {\n    try {\n        const { path: folderPath } = req.body;\n        if (!folderPath) {\n            return res.status(400).json({ error: 'Path is required' });\n        }\n        const expandedPath = expandWorkspacePath(folderPath);\n        const resolvedInput = path.resolve(expandedPath);\n        const validation = await validateWorkspacePath(resolvedInput);\n        if (!validation.valid) {\n            return res.status(403).json({ error: validation.error });\n        }\n        const targetPath = validation.resolvedPath || resolvedInput;\n        const parentDir = path.dirname(targetPath);\n        try {\n            await fs.promises.access(parentDir);\n        } catch (err) {\n            return res.status(404).json({ error: 'Parent directory does not exist' });\n        }\n        try {\n            await fs.promises.access(targetPath);\n            return res.status(409).json({ error: 'Folder already exists' });\n        } catch (err) {\n            // Folder doesn't exist, which is what we want\n        }\n        try {\n            await fs.promises.mkdir(targetPath, { recursive: false });\n            res.json({ success: true, path: targetPath });\n        } catch (mkdirError) {\n            if (mkdirError.code === 'EEXIST') {\n                return res.status(409).json({ error: 'Folder already exists' });\n            }\n            throw mkdirError;\n        }\n    } catch (error) {\n        console.error('Error creating folder:', error);\n        res.status(500).json({ error: 'Failed to create folder' });\n    }\n});\n\n// Read file content endpoint\napp.get('/api/projects/:projectName/file', authenticateToken, async (req, res) => {\n    try {\n        const { projectName } = req.params;\n        const { filePath } = req.query;\n\n\n        // Security: ensure the requested path is inside the project root\n        if (!filePath) {\n            return res.status(400).json({ error: 'Invalid file path' });\n        }\n\n        const projectRoot = await extractProjectDirectory(projectName).catch(() => null);\n        if (!projectRoot) {\n            return res.status(404).json({ error: 'Project not found' });\n        }\n\n        // Handle both absolute and relative paths\n        const resolved = path.isAbsolute(filePath)\n            ? path.resolve(filePath)\n            : path.resolve(projectRoot, filePath);\n        const normalizedRoot = path.resolve(projectRoot) + path.sep;\n        if (!resolved.startsWith(normalizedRoot)) {\n            return res.status(403).json({ error: 'Path must be under project root' });\n        }\n\n        const content = await fsPromises.readFile(resolved, 'utf8');\n        res.json({ content, path: resolved });\n    } catch (error) {\n        console.error('Error reading file:', error);\n        if (error.code === 'ENOENT') {\n            res.status(404).json({ error: 'File not found' });\n        } else if (error.code === 'EACCES') {\n            res.status(403).json({ error: 'Permission denied' });\n        } else {\n            res.status(500).json({ error: error.message });\n        }\n    }\n});\n\n// Serve binary file content endpoint (for images, etc.)\napp.get('/api/projects/:projectName/files/content', authenticateToken, async (req, res) => {\n    try {\n        const { projectName } = req.params;\n        const { path: filePath } = req.query;\n\n\n        // Security: ensure the requested path is inside the project root\n        if (!filePath) {\n            return res.status(400).json({ error: 'Invalid file path' });\n        }\n\n        const projectRoot = await extractProjectDirectory(projectName).catch(() => null);\n        if (!projectRoot) {\n            return res.status(404).json({ error: 'Project not found' });\n        }\n\n        const resolved = path.resolve(filePath);\n        const normalizedRoot = path.resolve(projectRoot) + path.sep;\n        if (!resolved.startsWith(normalizedRoot)) {\n            return res.status(403).json({ error: 'Path must be under project root' });\n        }\n\n        // Check if file exists\n        try {\n            await fsPromises.access(resolved);\n        } catch (error) {\n            return res.status(404).json({ error: 'File not found' });\n        }\n\n        // Get file extension and set appropriate content type\n        const mimeType = mime.lookup(resolved) || 'application/octet-stream';\n        res.setHeader('Content-Type', mimeType);\n\n        // Stream the file\n        const fileStream = fs.createReadStream(resolved);\n        fileStream.pipe(res);\n\n        fileStream.on('error', (error) => {\n            console.error('Error streaming file:', error);\n            if (!res.headersSent) {\n                res.status(500).json({ error: 'Error reading file' });\n            }\n        });\n\n    } catch (error) {\n        console.error('Error serving binary file:', error);\n        if (!res.headersSent) {\n            res.status(500).json({ error: error.message });\n        }\n    }\n});\n\n// Save file content endpoint\napp.put('/api/projects/:projectName/file', authenticateToken, async (req, res) => {\n    try {\n        const { projectName } = req.params;\n        const { filePath, content } = req.body;\n\n\n        // Security: ensure the requested path is inside the project root\n        if (!filePath) {\n            return res.status(400).json({ error: 'Invalid file path' });\n        }\n\n        if (content === undefined) {\n            return res.status(400).json({ error: 'Content is required' });\n        }\n\n        const projectRoot = await extractProjectDirectory(projectName).catch(() => null);\n        if (!projectRoot) {\n            return res.status(404).json({ error: 'Project not found' });\n        }\n\n        // Handle both absolute and relative paths\n        const resolved = path.isAbsolute(filePath)\n            ? path.resolve(filePath)\n            : path.resolve(projectRoot, filePath);\n        const normalizedRoot = path.resolve(projectRoot) + path.sep;\n        if (!resolved.startsWith(normalizedRoot)) {\n            return res.status(403).json({ error: 'Path must be under project root' });\n        }\n\n        // Write the new content\n        await fsPromises.writeFile(resolved, content, 'utf8');\n\n        res.json({\n            success: true,\n            path: resolved,\n            message: 'File saved successfully'\n        });\n    } catch (error) {\n        console.error('Error saving file:', error);\n        if (error.code === 'ENOENT') {\n            res.status(404).json({ error: 'File or directory not found' });\n        } else if (error.code === 'EACCES') {\n            res.status(403).json({ error: 'Permission denied' });\n        } else {\n            res.status(500).json({ error: error.message });\n        }\n    }\n});\n\napp.get('/api/projects/:projectName/files', authenticateToken, async (req, res) => {\n    try {\n\n        // Using fsPromises from import\n\n        // Use extractProjectDirectory to get the actual project path\n        let actualPath;\n        try {\n            actualPath = await extractProjectDirectory(req.params.projectName);\n        } catch (error) {\n            console.error('Error extracting project directory:', error);\n            // Fallback to simple dash replacement\n            actualPath = req.params.projectName.replace(/-/g, '/');\n        }\n\n        // Check if path exists\n        try {\n            await fsPromises.access(actualPath);\n        } catch (e) {\n            return res.status(404).json({ error: `Project path not found: ${actualPath}` });\n        }\n\n        const files = await getFileTree(actualPath, 10, 0, true);\n        res.json(files);\n    } catch (error) {\n        console.error('[ERROR] File tree error:', error.message);\n        res.status(500).json({ error: error.message });\n    }\n});\n\n// ============================================================================\n// FILE OPERATIONS API ENDPOINTS\n// ============================================================================\n\n/**\n * Validate that a path is within the project root\n * @param {string} projectRoot - The project root path\n * @param {string} targetPath - The path to validate\n * @returns {{ valid: boolean, resolved?: string, error?: string }}\n */\nfunction validatePathInProject(projectRoot, targetPath) {\n    const resolved = path.isAbsolute(targetPath)\n        ? path.resolve(targetPath)\n        : path.resolve(projectRoot, targetPath);\n    const normalizedRoot = path.resolve(projectRoot) + path.sep;\n    if (!resolved.startsWith(normalizedRoot)) {\n        return { valid: false, error: 'Path must be under project root' };\n    }\n    return { valid: true, resolved };\n}\n\n/**\n * Validate filename - check for invalid characters\n * @param {string} name - The filename to validate\n * @returns {{ valid: boolean, error?: string }}\n */\nfunction validateFilename(name) {\n    if (!name || !name.trim()) {\n        return { valid: false, error: 'Filename cannot be empty' };\n    }\n    // Check for invalid characters (Windows + Unix)\n    const invalidChars = /[<>:\"/\\\\|?*\\x00-\\x1f]/;\n    if (invalidChars.test(name)) {\n        return { valid: false, error: 'Filename contains invalid characters' };\n    }\n    // Check for reserved names (Windows)\n    const reserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i;\n    if (reserved.test(name)) {\n        return { valid: false, error: 'Filename is a reserved name' };\n    }\n    // Check for dots only\n    if (/^\\.+$/.test(name)) {\n        return { valid: false, error: 'Filename cannot be only dots' };\n    }\n    return { valid: true };\n}\n\n// POST /api/projects/:projectName/files/create - Create new file or directory\napp.post('/api/projects/:projectName/files/create', authenticateToken, async (req, res) => {\n    try {\n        const { projectName } = req.params;\n        const { path: parentPath, type, name } = req.body;\n\n        // Validate input\n        if (!name || !type) {\n            return res.status(400).json({ error: 'Name and type are required' });\n        }\n\n        if (!['file', 'directory'].includes(type)) {\n            return res.status(400).json({ error: 'Type must be \"file\" or \"directory\"' });\n        }\n\n        const nameValidation = validateFilename(name);\n        if (!nameValidation.valid) {\n            return res.status(400).json({ error: nameValidation.error });\n        }\n\n        // Get project root\n        const projectRoot = await extractProjectDirectory(projectName).catch(() => null);\n        if (!projectRoot) {\n            return res.status(404).json({ error: 'Project not found' });\n        }\n\n        // Build and validate target path\n        const targetDir = parentPath || '';\n        const targetPath = targetDir ? path.join(targetDir, name) : name;\n        const validation = validatePathInProject(projectRoot, targetPath);\n        if (!validation.valid) {\n            return res.status(403).json({ error: validation.error });\n        }\n\n        const resolvedPath = validation.resolved;\n\n        // Check if already exists\n        try {\n            await fsPromises.access(resolvedPath);\n            return res.status(409).json({ error: `${type === 'file' ? 'File' : 'Directory'} already exists` });\n        } catch {\n            // Doesn't exist, which is what we want\n        }\n\n        // Create file or directory\n        if (type === 'directory') {\n            await fsPromises.mkdir(resolvedPath, { recursive: false });\n        } else {\n            // Ensure parent directory exists\n            const parentDir = path.dirname(resolvedPath);\n            try {\n                await fsPromises.access(parentDir);\n            } catch {\n                await fsPromises.mkdir(parentDir, { recursive: true });\n            }\n            await fsPromises.writeFile(resolvedPath, '', 'utf8');\n        }\n\n        res.json({\n            success: true,\n            path: resolvedPath,\n            name,\n            type,\n            message: `${type === 'file' ? 'File' : 'Directory'} created successfully`\n        });\n    } catch (error) {\n        console.error('Error creating file/directory:', error);\n        if (error.code === 'EACCES') {\n            res.status(403).json({ error: 'Permission denied' });\n        } else if (error.code === 'ENOENT') {\n            res.status(404).json({ error: 'Parent directory not found' });\n        } else {\n            res.status(500).json({ error: error.message });\n        }\n    }\n});\n\n// PUT /api/projects/:projectName/files/rename - Rename file or directory\napp.put('/api/projects/:projectName/files/rename', authenticateToken, async (req, res) => {\n    try {\n        const { projectName } = req.params;\n        const { oldPath, newName } = req.body;\n\n        // Validate input\n        if (!oldPath || !newName) {\n            return res.status(400).json({ error: 'oldPath and newName are required' });\n        }\n\n        const nameValidation = validateFilename(newName);\n        if (!nameValidation.valid) {\n            return res.status(400).json({ error: nameValidation.error });\n        }\n\n        // Get project root\n        const projectRoot = await extractProjectDirectory(projectName).catch(() => null);\n        if (!projectRoot) {\n            return res.status(404).json({ error: 'Project not found' });\n        }\n\n        // Validate old path\n        const oldValidation = validatePathInProject(projectRoot, oldPath);\n        if (!oldValidation.valid) {\n            return res.status(403).json({ error: oldValidation.error });\n        }\n\n        const resolvedOldPath = oldValidation.resolved;\n\n        // Check if old path exists\n        try {\n            await fsPromises.access(resolvedOldPath);\n        } catch {\n            return res.status(404).json({ error: 'File or directory not found' });\n        }\n\n        // Build and validate new path\n        const parentDir = path.dirname(resolvedOldPath);\n        const resolvedNewPath = path.join(parentDir, newName);\n        const newValidation = validatePathInProject(projectRoot, resolvedNewPath);\n        if (!newValidation.valid) {\n            return res.status(403).json({ error: newValidation.error });\n        }\n\n        // Check if new path already exists\n        try {\n            await fsPromises.access(resolvedNewPath);\n            return res.status(409).json({ error: 'A file or directory with this name already exists' });\n        } catch {\n            // Doesn't exist, which is what we want\n        }\n\n        // Rename\n        await fsPromises.rename(resolvedOldPath, resolvedNewPath);\n\n        res.json({\n            success: true,\n            oldPath: resolvedOldPath,\n            newPath: resolvedNewPath,\n            newName,\n            message: 'Renamed successfully'\n        });\n    } catch (error) {\n        console.error('Error renaming file/directory:', error);\n        if (error.code === 'EACCES') {\n            res.status(403).json({ error: 'Permission denied' });\n        } else if (error.code === 'ENOENT') {\n            res.status(404).json({ error: 'File or directory not found' });\n        } else if (error.code === 'EXDEV') {\n            res.status(400).json({ error: 'Cannot move across different filesystems' });\n        } else {\n            res.status(500).json({ error: error.message });\n        }\n    }\n});\n\n// DELETE /api/projects/:projectName/files - Delete file or directory\napp.delete('/api/projects/:projectName/files', authenticateToken, async (req, res) => {\n    try {\n        const { projectName } = req.params;\n        const { path: targetPath, type } = req.body;\n\n        // Validate input\n        if (!targetPath) {\n            return res.status(400).json({ error: 'Path is required' });\n        }\n\n        // Get project root\n        const projectRoot = await extractProjectDirectory(projectName).catch(() => null);\n        if (!projectRoot) {\n            return res.status(404).json({ error: 'Project not found' });\n        }\n\n        // Validate path\n        const validation = validatePathInProject(projectRoot, targetPath);\n        if (!validation.valid) {\n            return res.status(403).json({ error: validation.error });\n        }\n\n        const resolvedPath = validation.resolved;\n\n        // Check if path exists and get stats\n        let stats;\n        try {\n            stats = await fsPromises.stat(resolvedPath);\n        } catch {\n            return res.status(404).json({ error: 'File or directory not found' });\n        }\n\n        // Prevent deleting the project root itself\n        if (resolvedPath === path.resolve(projectRoot)) {\n            return res.status(403).json({ error: 'Cannot delete project root directory' });\n        }\n\n        // Delete based on type\n        if (stats.isDirectory()) {\n            await fsPromises.rm(resolvedPath, { recursive: true, force: true });\n        } else {\n            await fsPromises.unlink(resolvedPath);\n        }\n\n        res.json({\n            success: true,\n            path: resolvedPath,\n            type: stats.isDirectory() ? 'directory' : 'file',\n            message: 'Deleted successfully'\n        });\n    } catch (error) {\n        console.error('Error deleting file/directory:', error);\n        if (error.code === 'EACCES') {\n            res.status(403).json({ error: 'Permission denied' });\n        } else if (error.code === 'ENOENT') {\n            res.status(404).json({ error: 'File or directory not found' });\n        } else if (error.code === 'ENOTEMPTY') {\n            res.status(400).json({ error: 'Directory is not empty' });\n        } else {\n            res.status(500).json({ error: error.message });\n        }\n    }\n});\n\n// POST /api/projects/:projectName/files/upload - Upload files\n// Dynamic import of multer for file uploads\nconst uploadFilesHandler = async (req, res) => {\n    // Dynamic import of multer\n    const multer = (await import('multer')).default;\n\n    const uploadMiddleware = multer({\n        storage: multer.diskStorage({\n            destination: (req, file, cb) => {\n                cb(null, os.tmpdir());\n            },\n            filename: (req, file, cb) => {\n                // Use a unique temp name, but preserve original name in file.originalname\n                // Note: file.originalname may contain path separators for folder uploads\n                const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);\n                // For temp file, just use a safe unique name without the path\n                cb(null, `upload-${uniqueSuffix}`);\n            }\n        }),\n        limits: {\n            fileSize: 50 * 1024 * 1024, // 50MB limit\n            files: 20 // Max 20 files at once\n        }\n    });\n\n    // Use multer middleware\n    uploadMiddleware.array('files', 20)(req, res, async (err) => {\n        if (err) {\n            console.error('Multer error:', err);\n            if (err.code === 'LIMIT_FILE_SIZE') {\n                return res.status(400).json({ error: 'File too large. Maximum size is 50MB.' });\n            }\n            if (err.code === 'LIMIT_FILE_COUNT') {\n                return res.status(400).json({ error: 'Too many files. Maximum is 20 files.' });\n            }\n            return res.status(500).json({ error: err.message });\n        }\n\n        try {\n            const { projectName } = req.params;\n            const { targetPath, relativePaths } = req.body;\n\n            // Parse relative paths if provided (for folder uploads)\n            let filePaths = [];\n            if (relativePaths) {\n                try {\n                    filePaths = JSON.parse(relativePaths);\n                } catch (e) {\n                    console.log('[DEBUG] Failed to parse relativePaths:', relativePaths);\n                }\n            }\n\n            console.log('[DEBUG] File upload request:', {\n                projectName,\n                targetPath: JSON.stringify(targetPath),\n                targetPathType: typeof targetPath,\n                filesCount: req.files?.length,\n                relativePaths: filePaths\n            });\n\n            if (!req.files || req.files.length === 0) {\n                return res.status(400).json({ error: 'No files provided' });\n            }\n\n            // Get project root\n            const projectRoot = await extractProjectDirectory(projectName).catch(() => null);\n            if (!projectRoot) {\n                return res.status(404).json({ error: 'Project not found' });\n            }\n\n            console.log('[DEBUG] Project root:', projectRoot);\n\n            // Validate and resolve target path\n            // If targetPath is empty or '.', use project root directly\n            const targetDir = targetPath || '';\n            let resolvedTargetDir;\n\n            console.log('[DEBUG] Target dir:', JSON.stringify(targetDir));\n\n            if (!targetDir || targetDir === '.' || targetDir === './') {\n                // Empty path means upload to project root\n                resolvedTargetDir = path.resolve(projectRoot);\n                console.log('[DEBUG] Using project root as target:', resolvedTargetDir);\n            } else {\n                const validation = validatePathInProject(projectRoot, targetDir);\n                if (!validation.valid) {\n                    console.log('[DEBUG] Path validation failed:', validation.error);\n                    return res.status(403).json({ error: validation.error });\n                }\n                resolvedTargetDir = validation.resolved;\n                console.log('[DEBUG] Resolved target dir:', resolvedTargetDir);\n            }\n\n            // Ensure target directory exists\n            try {\n                await fsPromises.access(resolvedTargetDir);\n            } catch {\n                await fsPromises.mkdir(resolvedTargetDir, { recursive: true });\n            }\n\n            // Move uploaded files from temp to target directory\n            const uploadedFiles = [];\n            console.log('[DEBUG] Processing files:', req.files.map(f => ({ originalname: f.originalname, path: f.path })));\n            for (let i = 0; i < req.files.length; i++) {\n                const file = req.files[i];\n                // Use relative path if provided (for folder uploads), otherwise use originalname\n                const fileName = (filePaths && filePaths[i]) ? filePaths[i] : file.originalname;\n                console.log('[DEBUG] Processing file:', fileName, '(originalname:', file.originalname + ')');\n                const destPath = path.join(resolvedTargetDir, fileName);\n\n                // Validate destination path\n                const destValidation = validatePathInProject(projectRoot, destPath);\n                if (!destValidation.valid) {\n                    console.log('[DEBUG] Destination validation failed for:', destPath);\n                    // Clean up temp file\n                    await fsPromises.unlink(file.path).catch(() => {});\n                    continue;\n                }\n\n                // Ensure parent directory exists (for nested files from folder upload)\n                const parentDir = path.dirname(destPath);\n                try {\n                    await fsPromises.access(parentDir);\n                } catch {\n                    await fsPromises.mkdir(parentDir, { recursive: true });\n                }\n\n                // Move file (copy + unlink to handle cross-device scenarios)\n                await fsPromises.copyFile(file.path, destPath);\n                await fsPromises.unlink(file.path);\n\n                uploadedFiles.push({\n                    name: fileName,\n                    path: destPath,\n                    size: file.size,\n                    mimeType: file.mimetype\n                });\n            }\n\n            res.json({\n                success: true,\n                files: uploadedFiles,\n                targetPath: resolvedTargetDir,\n                message: `Uploaded ${uploadedFiles.length} file(s) successfully`\n            });\n        } catch (error) {\n            console.error('Error uploading files:', error);\n            // Clean up any remaining temp files\n            if (req.files) {\n                for (const file of req.files) {\n                    await fsPromises.unlink(file.path).catch(() => {});\n                }\n            }\n            if (error.code === 'EACCES') {\n                res.status(403).json({ error: 'Permission denied' });\n            } else {\n                res.status(500).json({ error: error.message });\n            }\n        }\n    });\n};\n\napp.post('/api/projects/:projectName/files/upload', authenticateToken, uploadFilesHandler);\n\n/**\n * Proxy an authenticated client WebSocket to a plugin's internal WS server.\n * Auth is enforced by verifyClient before this function is reached.\n */\nfunction handlePluginWsProxy(clientWs, pathname) {\n    const pluginName = pathname.replace('/plugin-ws/', '');\n    if (!pluginName || /[^a-zA-Z0-9_-]/.test(pluginName)) {\n        clientWs.close(4400, 'Invalid plugin name');\n        return;\n    }\n\n    const port = getPluginPort(pluginName);\n    if (!port) {\n        clientWs.close(4404, 'Plugin not running');\n        return;\n    }\n\n    const upstream = new WebSocket(`ws://127.0.0.1:${port}/ws`);\n\n    upstream.on('open', () => {\n        console.log(`[Plugins] WS proxy connected to \"${pluginName}\" on port ${port}`);\n    });\n\n    // Relay messages bidirectionally\n    upstream.on('message', (data) => {\n        if (clientWs.readyState === WebSocket.OPEN) clientWs.send(data);\n    });\n    clientWs.on('message', (data) => {\n        if (upstream.readyState === WebSocket.OPEN) upstream.send(data);\n    });\n\n    // Propagate close in both directions\n    upstream.on('close', () => { if (clientWs.readyState === WebSocket.OPEN) clientWs.close(); });\n    clientWs.on('close', () => { if (upstream.readyState === WebSocket.OPEN) upstream.close(); });\n\n    upstream.on('error', (err) => {\n        console.error(`[Plugins] WS proxy error for \"${pluginName}\":`, err.message);\n        if (clientWs.readyState === WebSocket.OPEN) clientWs.close(4502, 'Upstream error');\n    });\n    clientWs.on('error', () => {\n        if (upstream.readyState === WebSocket.OPEN) upstream.close();\n    });\n}\n\n// WebSocket connection handler that routes based on URL path\nwss.on('connection', (ws, request) => {\n    const url = request.url;\n    console.log('[INFO] Client connected to:', url);\n\n    // Parse URL to get pathname without query parameters\n    const urlObj = new URL(url, 'http://localhost');\n    const pathname = urlObj.pathname;\n\n    if (pathname === '/shell') {\n        handleShellConnection(ws);\n    } else if (pathname === '/ws') {\n        handleChatConnection(ws, request);\n    } else if (pathname.startsWith('/plugin-ws/')) {\n        handlePluginWsProxy(ws, pathname);\n    } else {\n        console.log('[WARN] Unknown WebSocket path:', pathname);\n        ws.close();\n    }\n});\n\n/**\n * WebSocket Writer - Wrapper for WebSocket to match SSEStreamWriter interface\n *\n * Provider files use `createNormalizedMessage()` from `providers/types.js` and\n * adapter `normalizeMessage()` to produce unified NormalizedMessage events.\n * The writer simply serialises and sends.\n */\nclass WebSocketWriter {\n    constructor(ws, userId = null) {\n        this.ws = ws;\n        this.sessionId = null;\n        this.userId = userId;\n        this.isWebSocketWriter = true;  // Marker for transport detection\n    }\n\n    send(data) {\n        if (this.ws.readyState === 1) { // WebSocket.OPEN\n            this.ws.send(JSON.stringify(data));\n        }\n    }\n\n    updateWebSocket(newRawWs) {\n        this.ws = newRawWs;\n    }\n\n    setSessionId(sessionId) {\n        this.sessionId = sessionId;\n    }\n\n    getSessionId() {\n        return this.sessionId;\n    }\n}\n\n// Handle chat WebSocket connections\nfunction handleChatConnection(ws, request) {\n    console.log('[INFO] Chat WebSocket connected');\n\n    // Add to connected clients for project updates\n    connectedClients.add(ws);\n\n    // Wrap WebSocket with writer for consistent interface with SSEStreamWriter\n    const writer = new WebSocketWriter(ws, request?.user?.id ?? request?.user?.userId ?? null);\n\n    ws.on('message', async (message) => {\n        try {\n            const data = JSON.parse(message);\n\n            if (data.type === 'claude-command') {\n                console.log('[DEBUG] User message:', data.command || '[Continue/Resume]');\n                console.log('📁 Project:', data.options?.projectPath || 'Unknown');\n                console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');\n\n                // Use Claude Agents SDK\n                await queryClaudeSDK(data.command, data.options, writer);\n            } else if (data.type === 'cursor-command') {\n                console.log('[DEBUG] Cursor message:', data.command || '[Continue/Resume]');\n                console.log('📁 Project:', data.options?.cwd || 'Unknown');\n                console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');\n                console.log('🤖 Model:', data.options?.model || 'default');\n                await spawnCursor(data.command, data.options, writer);\n            } else if (data.type === 'codex-command') {\n                console.log('[DEBUG] Codex message:', data.command || '[Continue/Resume]');\n                console.log('📁 Project:', data.options?.projectPath || data.options?.cwd || 'Unknown');\n                console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');\n                console.log('🤖 Model:', data.options?.model || 'default');\n                await queryCodex(data.command, data.options, writer);\n            } else if (data.type === 'gemini-command') {\n                console.log('[DEBUG] Gemini message:', data.command || '[Continue/Resume]');\n                console.log('📁 Project:', data.options?.projectPath || data.options?.cwd || 'Unknown');\n                console.log('🔄 Session:', data.options?.sessionId ? 'Resume' : 'New');\n                console.log('🤖 Model:', data.options?.model || 'default');\n                await spawnGemini(data.command, data.options, writer);\n            } else if (data.type === 'cursor-resume') {\n                // Backward compatibility: treat as cursor-command with resume and no prompt\n                console.log('[DEBUG] Cursor resume session (compat):', data.sessionId);\n                await spawnCursor('', {\n                    sessionId: data.sessionId,\n                    resume: true,\n                    cwd: data.options?.cwd\n                }, writer);\n            } else if (data.type === 'abort-session') {\n                console.log('[DEBUG] Abort session request:', data.sessionId);\n                const provider = data.provider || 'claude';\n                let success;\n\n                if (provider === 'cursor') {\n                    success = abortCursorSession(data.sessionId);\n                } else if (provider === 'codex') {\n                    success = abortCodexSession(data.sessionId);\n                } else if (provider === 'gemini') {\n                    success = abortGeminiSession(data.sessionId);\n                } else {\n                    // Use Claude Agents SDK\n                    success = await abortClaudeSDKSession(data.sessionId);\n                }\n\n                writer.send(createNormalizedMessage({ kind: 'complete', exitCode: success ? 0 : 1, aborted: true, success, sessionId: data.sessionId, provider }));\n            } else if (data.type === 'claude-permission-response') {\n                // Relay UI approval decisions back into the SDK control flow.\n                // This does not persist permissions; it only resolves the in-flight request,\n                // introduced so the SDK can resume once the user clicks Allow/Deny.\n                if (data.requestId) {\n                    resolveToolApproval(data.requestId, {\n                        allow: Boolean(data.allow),\n                        updatedInput: data.updatedInput,\n                        message: data.message,\n                        rememberEntry: data.rememberEntry\n                    });\n                }\n            } else if (data.type === 'cursor-abort') {\n                console.log('[DEBUG] Abort Cursor session:', data.sessionId);\n                const success = abortCursorSession(data.sessionId);\n                writer.send(createNormalizedMessage({ kind: 'complete', exitCode: success ? 0 : 1, aborted: true, success, sessionId: data.sessionId, provider: 'cursor' }));\n            } else if (data.type === 'check-session-status') {\n                // Check if a specific session is currently processing\n                const provider = data.provider || 'claude';\n                const sessionId = data.sessionId;\n                let isActive;\n\n                if (provider === 'cursor') {\n                    isActive = isCursorSessionActive(sessionId);\n                } else if (provider === 'codex') {\n                    isActive = isCodexSessionActive(sessionId);\n                } else if (provider === 'gemini') {\n                    isActive = isGeminiSessionActive(sessionId);\n                } else {\n                    // Use Claude Agents SDK\n                    isActive = isClaudeSDKSessionActive(sessionId);\n                    if (isActive) {\n                        // Reconnect the session's writer to the new WebSocket so\n                        // subsequent SDK output flows to the refreshed client.\n                        reconnectSessionWriter(sessionId, ws);\n                    }\n                }\n\n                writer.send({\n                    type: 'session-status',\n                    sessionId,\n                    provider,\n                    isProcessing: isActive\n                });\n            } else if (data.type === 'get-pending-permissions') {\n                // Return pending permission requests for a session\n                const sessionId = data.sessionId;\n                if (sessionId && isClaudeSDKSessionActive(sessionId)) {\n                    const pending = getPendingApprovalsForSession(sessionId);\n                    writer.send({\n                        type: 'pending-permissions-response',\n                        sessionId,\n                        data: pending\n                    });\n                }\n            } else if (data.type === 'get-active-sessions') {\n                // Get all currently active sessions\n                const activeSessions = {\n                    claude: getActiveClaudeSDKSessions(),\n                    cursor: getActiveCursorSessions(),\n                    codex: getActiveCodexSessions(),\n                    gemini: getActiveGeminiSessions()\n                };\n                writer.send({\n                    type: 'active-sessions',\n                    sessions: activeSessions\n                });\n            }\n        } catch (error) {\n            console.error('[ERROR] Chat WebSocket error:', error.message);\n            writer.send({\n                type: 'error',\n                error: error.message\n            });\n        }\n    });\n\n    ws.on('close', () => {\n        console.log('🔌 Chat client disconnected');\n        // Remove from connected clients\n        connectedClients.delete(ws);\n    });\n}\n\n// Handle shell WebSocket connections\nfunction handleShellConnection(ws) {\n    console.log('🐚 Shell client connected');\n    let shellProcess = null;\n    let ptySessionKey = null;\n    let urlDetectionBuffer = '';\n    const announcedAuthUrls = new Set();\n\n    ws.on('message', async (message) => {\n        try {\n            const data = JSON.parse(message);\n            console.log('📨 Shell message received:', data.type);\n\n            if (data.type === 'init') {\n                const projectPath = data.projectPath || process.cwd();\n                const sessionId = data.sessionId;\n                const hasSession = data.hasSession;\n                const provider = data.provider || 'claude';\n                const initialCommand = data.initialCommand;\n                const isPlainShell = data.isPlainShell || (!!initialCommand && !hasSession) || provider === 'plain-shell';\n                urlDetectionBuffer = '';\n                announcedAuthUrls.clear();\n\n                // Login commands (Claude/Cursor auth) should never reuse cached sessions\n                const isLoginCommand = initialCommand && (\n                    initialCommand.includes('setup-token') ||\n                    initialCommand.includes('cursor-agent login') ||\n                    initialCommand.includes('auth login')\n                );\n\n                // Include command hash in session key so different commands get separate sessions\n                const commandSuffix = isPlainShell && initialCommand\n                    ? `_cmd_${Buffer.from(initialCommand).toString('base64').slice(0, 16)}`\n                    : '';\n                ptySessionKey = `${projectPath}_${sessionId || 'default'}${commandSuffix}`;\n\n                // Kill any existing login session before starting fresh\n                if (isLoginCommand) {\n                    const oldSession = ptySessionsMap.get(ptySessionKey);\n                    if (oldSession) {\n                        console.log('🧹 Cleaning up existing login session:', ptySessionKey);\n                        if (oldSession.timeoutId) clearTimeout(oldSession.timeoutId);\n                        if (oldSession.pty && oldSession.pty.kill) oldSession.pty.kill();\n                        ptySessionsMap.delete(ptySessionKey);\n                    }\n                }\n\n                const existingSession = isLoginCommand ? null : ptySessionsMap.get(ptySessionKey);\n                if (existingSession) {\n                    console.log('♻️  Reconnecting to existing PTY session:', ptySessionKey);\n                    shellProcess = existingSession.pty;\n\n                    clearTimeout(existingSession.timeoutId);\n\n                    ws.send(JSON.stringify({\n                        type: 'output',\n                        data: `\\x1b[36m[Reconnected to existing session]\\x1b[0m\\r\\n`\n                    }));\n\n                    if (existingSession.buffer && existingSession.buffer.length > 0) {\n                        console.log(`📜 Sending ${existingSession.buffer.length} buffered messages`);\n                        existingSession.buffer.forEach(bufferedData => {\n                            ws.send(JSON.stringify({\n                                type: 'output',\n                                data: bufferedData\n                            }));\n                        });\n                    }\n\n                    existingSession.ws = ws;\n\n                    return;\n                }\n\n                console.log('[INFO] Starting shell in:', projectPath);\n                console.log('📋 Session info:', hasSession ? `Resume session ${sessionId}` : (isPlainShell ? 'Plain shell mode' : 'New session'));\n                console.log('🤖 Provider:', isPlainShell ? 'plain-shell' : provider);\n                if (initialCommand) {\n                    console.log('⚡ Initial command:', initialCommand);\n                }\n\n                // First send a welcome message\n                let welcomeMsg;\n                if (isPlainShell) {\n                    welcomeMsg = `\\x1b[36mStarting terminal in: ${projectPath}\\x1b[0m\\r\\n`;\n                } else {\n                    const providerName = provider === 'cursor' ? 'Cursor' : (provider === 'codex' ? 'Codex' : (provider === 'gemini' ? 'Gemini' : 'Claude'));\n                    welcomeMsg = hasSession ?\n                        `\\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\\x1b[0m\\r\\n` :\n                        `\\x1b[36mStarting new ${providerName} session in: ${projectPath}\\x1b[0m\\r\\n`;\n                }\n\n                ws.send(JSON.stringify({\n                    type: 'output',\n                    data: welcomeMsg\n                }));\n\n                try {\n                    // Validate projectPath — resolve to absolute and verify it exists\n                    const resolvedProjectPath = path.resolve(projectPath);\n                    try {\n                        const stats = fs.statSync(resolvedProjectPath);\n                        if (!stats.isDirectory()) {\n                            throw new Error('Not a directory');\n                        }\n                    } catch (pathErr) {\n                        ws.send(JSON.stringify({ type: 'error', message: 'Invalid project path' }));\n                        return;\n                    }\n\n                    // Validate sessionId — only allow safe characters\n                    const safeSessionIdPattern = /^[a-zA-Z0-9_.\\-:]+$/;\n                    if (sessionId && !safeSessionIdPattern.test(sessionId)) {\n                        ws.send(JSON.stringify({ type: 'error', message: 'Invalid session ID' }));\n                        return;\n                    }\n\n                    // Build shell command — use cwd for project path (never interpolate into shell string)\n                    let shellCommand;\n                    if (isPlainShell) {\n                        // Plain shell mode - run the initial command in the project directory\n                        shellCommand = initialCommand;\n                    } else if (provider === 'cursor') {\n                        if (hasSession && sessionId) {\n                            shellCommand = `cursor-agent --resume=\"${sessionId}\"`;\n                        } else {\n                            shellCommand = 'cursor-agent';\n                        }\n                    } else if (provider === 'codex') {\n                        // Use codex command; attempt to resume and fall back to a new session when the resume fails.\n                        if (hasSession && sessionId) {\n                            if (os.platform() === 'win32') {\n                                // PowerShell syntax for fallback\n                                shellCommand = `codex resume \"${sessionId}\"; if ($LASTEXITCODE -ne 0) { codex }`;\n                            } else {\n                                shellCommand = `codex resume \"${sessionId}\" || codex`;\n                            }\n                        } else {\n                            shellCommand = 'codex';\n                        }\n                    } else if (provider === 'gemini') {\n                        const command = initialCommand || 'gemini';\n                        let resumeId = sessionId;\n                        if (hasSession && sessionId) {\n                            try {\n                                // Gemini CLI enforces its own native session IDs, unlike other agents that accept arbitrary string names.\n                                // The UI only knows about its internal generated `sessionId` (e.g. gemini_1234).\n                                // We must fetch the mapping from the backend session manager to pass the native `cliSessionId` to the shell.\n                                const sess = sessionManager.getSession(sessionId);\n                                if (sess && sess.cliSessionId) {\n                                    resumeId = sess.cliSessionId;\n                                    // Validate the looked-up CLI session ID too\n                                    if (!safeSessionIdPattern.test(resumeId)) {\n                                        resumeId = null;\n                                    }\n                                }\n                            } catch (err) {\n                                console.error('Failed to get Gemini CLI session ID:', err);\n                            }\n                        }\n\n                        if (hasSession && resumeId) {\n                            shellCommand = `${command} --resume \"${resumeId}\"`;\n                        } else {\n                            shellCommand = command;\n                        }\n                    } else {\n                        // Claude (default provider)\n                        const command = initialCommand || 'claude';\n                        if (hasSession && sessionId) {\n                            if (os.platform() === 'win32') {\n                                shellCommand = `claude --resume \"${sessionId}\"; if ($LASTEXITCODE -ne 0) { claude }`;\n                            } else {\n                                shellCommand = `claude --resume \"${sessionId}\" || claude`;\n                            }\n                        } else {\n                            shellCommand = command;\n                        }\n                    }\n\n                    console.log('🔧 Executing shell command:', shellCommand);\n\n                    // Use appropriate shell based on platform\n                    const shell = os.platform() === 'win32' ? 'powershell.exe' : 'bash';\n                    const shellArgs = os.platform() === 'win32' ? ['-Command', shellCommand] : ['-c', shellCommand];\n\n                    // Use terminal dimensions from client if provided, otherwise use defaults\n                    const termCols = data.cols || 80;\n                    const termRows = data.rows || 24;\n                    console.log('📐 Using terminal dimensions:', termCols, 'x', termRows);\n\n                    shellProcess = pty.spawn(shell, shellArgs, {\n                        name: 'xterm-256color',\n                        cols: termCols,\n                        rows: termRows,\n                        cwd: resolvedProjectPath,\n                        env: {\n                            ...process.env,\n                            TERM: 'xterm-256color',\n                            COLORTERM: 'truecolor',\n                            FORCE_COLOR: '3'\n                        }\n                    });\n\n                    console.log('🟢 Shell process started with PTY, PID:', shellProcess.pid);\n\n                    ptySessionsMap.set(ptySessionKey, {\n                        pty: shellProcess,\n                        ws: ws,\n                        buffer: [],\n                        timeoutId: null,\n                        projectPath,\n                        sessionId\n                    });\n\n                    // Handle data output\n                    shellProcess.onData((data) => {\n                        const session = ptySessionsMap.get(ptySessionKey);\n                        if (!session) return;\n\n                        if (session.buffer.length < 5000) {\n                            session.buffer.push(data);\n                        } else {\n                            session.buffer.shift();\n                            session.buffer.push(data);\n                        }\n\n                        if (session.ws && session.ws.readyState === WebSocket.OPEN) {\n                            let outputData = data;\n\n                            const cleanChunk = stripAnsiSequences(data);\n                            urlDetectionBuffer = `${urlDetectionBuffer}${cleanChunk}`.slice(-SHELL_URL_PARSE_BUFFER_LIMIT);\n\n                            outputData = outputData.replace(\n                                /OPEN_URL:\\s*(https?:\\/\\/[^\\s\\x1b\\x07]+)/g,\n                                '[INFO] Opening in browser: $1'\n                            );\n\n                            const emitAuthUrl = (detectedUrl, autoOpen = false) => {\n                                const normalizedUrl = normalizeDetectedUrl(detectedUrl);\n                                if (!normalizedUrl) return;\n\n                                const isNewUrl = !announcedAuthUrls.has(normalizedUrl);\n                                if (isNewUrl) {\n                                    announcedAuthUrls.add(normalizedUrl);\n                                    session.ws.send(JSON.stringify({\n                                        type: 'auth_url',\n                                        url: normalizedUrl,\n                                        autoOpen\n                                    }));\n                                }\n\n                            };\n\n                            const normalizedDetectedUrls = extractUrlsFromText(urlDetectionBuffer)\n                                .map((url) => normalizeDetectedUrl(url))\n                                .filter(Boolean);\n\n                            // Prefer the most complete URL if shorter prefix variants are also present.\n                            const dedupedDetectedUrls = Array.from(new Set(normalizedDetectedUrls)).filter((url, _, urls) =>\n                                !urls.some((otherUrl) => otherUrl !== url && otherUrl.startsWith(url))\n                            );\n\n                            dedupedDetectedUrls.forEach((url) => emitAuthUrl(url, false));\n\n                            if (shouldAutoOpenUrlFromOutput(cleanChunk) && dedupedDetectedUrls.length > 0) {\n                                const bestUrl = dedupedDetectedUrls.reduce((longest, current) =>\n                                    current.length > longest.length ? current : longest\n                                );\n                                emitAuthUrl(bestUrl, true);\n                            }\n\n                            // Send regular output\n                            session.ws.send(JSON.stringify({\n                                type: 'output',\n                                data: outputData\n                            }));\n                        }\n                    });\n\n                    // Handle process exit\n                    shellProcess.onExit((exitCode) => {\n                        console.log('🔚 Shell process exited with code:', exitCode.exitCode, 'signal:', exitCode.signal);\n                        const session = ptySessionsMap.get(ptySessionKey);\n                        if (session && session.ws && session.ws.readyState === WebSocket.OPEN) {\n                            session.ws.send(JSON.stringify({\n                                type: 'output',\n                                data: `\\r\\n\\x1b[33mProcess exited with code ${exitCode.exitCode}${exitCode.signal ? ` (${exitCode.signal})` : ''}\\x1b[0m\\r\\n`\n                            }));\n                        }\n                        if (session && session.timeoutId) {\n                            clearTimeout(session.timeoutId);\n                        }\n                        ptySessionsMap.delete(ptySessionKey);\n                        shellProcess = null;\n                    });\n\n                } catch (spawnError) {\n                    console.error('[ERROR] Error spawning process:', spawnError);\n                    ws.send(JSON.stringify({\n                        type: 'output',\n                        data: `\\r\\n\\x1b[31mError: ${spawnError.message}\\x1b[0m\\r\\n`\n                    }));\n                }\n\n            } else if (data.type === 'input') {\n                // Send input to shell process\n                if (shellProcess && shellProcess.write) {\n                    try {\n                        shellProcess.write(data.data);\n                    } catch (error) {\n                        console.error('Error writing to shell:', error);\n                    }\n                } else {\n                    console.warn('No active shell process to send input to');\n                }\n            } else if (data.type === 'resize') {\n                // Handle terminal resize\n                if (shellProcess && shellProcess.resize) {\n                    console.log('Terminal resize requested:', data.cols, 'x', data.rows);\n                    shellProcess.resize(data.cols, data.rows);\n                }\n            }\n        } catch (error) {\n            console.error('[ERROR] Shell WebSocket error:', error.message);\n            if (ws.readyState === WebSocket.OPEN) {\n                ws.send(JSON.stringify({\n                    type: 'output',\n                    data: `\\r\\n\\x1b[31mError: ${error.message}\\x1b[0m\\r\\n`\n                }));\n            }\n        }\n    });\n\n    ws.on('close', () => {\n        console.log('🔌 Shell client disconnected');\n\n        if (ptySessionKey) {\n            const session = ptySessionsMap.get(ptySessionKey);\n            if (session) {\n                console.log('⏳ PTY session kept alive, will timeout in 30 minutes:', ptySessionKey);\n                session.ws = null;\n\n                session.timeoutId = setTimeout(() => {\n                    console.log('⏰ PTY session timeout, killing process:', ptySessionKey);\n                    if (session.pty && session.pty.kill) {\n                        session.pty.kill();\n                    }\n                    ptySessionsMap.delete(ptySessionKey);\n                }, PTY_SESSION_TIMEOUT);\n            }\n        }\n    });\n\n    ws.on('error', (error) => {\n        console.error('[ERROR] Shell WebSocket error:', error);\n    });\n}\n// Audio transcription endpoint\napp.post('/api/transcribe', authenticateToken, async (req, res) => {\n    try {\n        const multer = (await import('multer')).default;\n        const upload = multer({ storage: multer.memoryStorage() });\n\n        // Handle multipart form data\n        upload.single('audio')(req, res, async (err) => {\n            if (err) {\n                return res.status(400).json({ error: 'Failed to process audio file' });\n            }\n\n            if (!req.file) {\n                return res.status(400).json({ error: 'No audio file provided' });\n            }\n\n            const apiKey = process.env.OPENAI_API_KEY;\n            if (!apiKey) {\n                return res.status(500).json({ error: 'OpenAI API key not configured. Please set OPENAI_API_KEY in server environment.' });\n            }\n\n            try {\n                // Create form data for OpenAI\n                const FormData = (await import('form-data')).default;\n                const formData = new FormData();\n                formData.append('file', req.file.buffer, {\n                    filename: req.file.originalname,\n                    contentType: req.file.mimetype\n                });\n                formData.append('model', 'whisper-1');\n                formData.append('response_format', 'json');\n                formData.append('language', 'en');\n\n                // Make request to OpenAI\n                const response = await fetch('https://api.openai.com/v1/audio/transcriptions', {\n                    method: 'POST',\n                    headers: {\n                        'Authorization': `Bearer ${apiKey}`,\n                        ...formData.getHeaders()\n                    },\n                    body: formData\n                });\n\n                if (!response.ok) {\n                    const errorData = await response.json().catch(() => ({}));\n                    throw new Error(errorData.error?.message || `Whisper API error: ${response.status}`);\n                }\n\n                const data = await response.json();\n                let transcribedText = data.text || '';\n\n                // Check if enhancement mode is enabled\n                const mode = req.body.mode || 'default';\n\n                // If no transcribed text, return empty\n                if (!transcribedText) {\n                    return res.json({ text: '' });\n                }\n\n                // If default mode, return transcribed text without enhancement\n                if (mode === 'default') {\n                    return res.json({ text: transcribedText });\n                }\n\n                // Handle different enhancement modes\n                try {\n                    const OpenAI = (await import('openai')).default;\n                    const openai = new OpenAI({ apiKey });\n\n                    let prompt, systemMessage, temperature = 0.7, maxTokens = 800;\n\n                    switch (mode) {\n                        case 'prompt':\n                            systemMessage = 'You are an expert prompt engineer who creates clear, detailed, and effective prompts.';\n                            prompt = `You are an expert prompt engineer. Transform the following rough instruction into a clear, detailed, and context-aware AI prompt.\n\nYour enhanced prompt should:\n1. Be specific and unambiguous\n2. Include relevant context and constraints\n3. Specify the desired output format\n4. Use clear, actionable language\n5. Include examples where helpful\n6. Consider edge cases and potential ambiguities\n\nTransform this rough instruction into a well-crafted prompt:\n\"${transcribedText}\"\n\nEnhanced prompt:`;\n                            break;\n\n                        case 'vibe':\n                        case 'instructions':\n                        case 'architect':\n                            systemMessage = 'You are a helpful assistant that formats ideas into clear, actionable instructions for AI agents.';\n                            temperature = 0.5; // Lower temperature for more controlled output\n                            prompt = `Transform the following idea into clear, well-structured instructions that an AI agent can easily understand and execute.\n\nIMPORTANT RULES:\n- Format as clear, step-by-step instructions\n- Add reasonable implementation details based on common patterns\n- Only include details directly related to what was asked\n- Do NOT add features or functionality not mentioned\n- Keep the original intent and scope intact\n- Use clear, actionable language an agent can follow\n\nTransform this idea into agent-friendly instructions:\n\"${transcribedText}\"\n\nAgent instructions:`;\n                            break;\n\n                        default:\n                            // No enhancement needed\n                            break;\n                    }\n\n                    // Only make GPT call if we have a prompt\n                    if (prompt) {\n                        const completion = await openai.chat.completions.create({\n                            model: 'gpt-4o-mini',\n                            messages: [\n                                { role: 'system', content: systemMessage },\n                                { role: 'user', content: prompt }\n                            ],\n                            temperature: temperature,\n                            max_tokens: maxTokens\n                        });\n\n                        transcribedText = completion.choices[0].message.content || transcribedText;\n                    }\n\n                } catch (gptError) {\n                    console.error('GPT processing error:', gptError);\n                    // Fall back to original transcription if GPT fails\n                }\n\n                res.json({ text: transcribedText });\n\n            } catch (error) {\n                console.error('Transcription error:', error);\n                res.status(500).json({ error: error.message });\n            }\n        });\n    } catch (error) {\n        console.error('Endpoint error:', error);\n        res.status(500).json({ error: 'Internal server error' });\n    }\n});\n\n// Image upload endpoint\napp.post('/api/projects/:projectName/upload-images', authenticateToken, async (req, res) => {\n    try {\n        const multer = (await import('multer')).default;\n        const path = (await import('path')).default;\n        const fs = (await import('fs')).promises;\n        const os = (await import('os')).default;\n\n        // Configure multer for image uploads\n        const storage = multer.diskStorage({\n            destination: async (req, file, cb) => {\n                const uploadDir = path.join(os.tmpdir(), 'claude-ui-uploads', String(req.user.id));\n                await fs.mkdir(uploadDir, { recursive: true });\n                cb(null, uploadDir);\n            },\n            filename: (req, file, cb) => {\n                const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);\n                const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_');\n                cb(null, uniqueSuffix + '-' + sanitizedName);\n            }\n        });\n\n        const fileFilter = (req, file, cb) => {\n            const allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];\n            if (allowedMimes.includes(file.mimetype)) {\n                cb(null, true);\n            } else {\n                cb(new Error('Invalid file type. Only JPEG, PNG, GIF, WebP, and SVG are allowed.'));\n            }\n        };\n\n        const upload = multer({\n            storage,\n            fileFilter,\n            limits: {\n                fileSize: 5 * 1024 * 1024, // 5MB\n                files: 5\n            }\n        });\n\n        // Handle multipart form data\n        upload.array('images', 5)(req, res, async (err) => {\n            if (err) {\n                return res.status(400).json({ error: err.message });\n            }\n\n            if (!req.files || req.files.length === 0) {\n                return res.status(400).json({ error: 'No image files provided' });\n            }\n\n            try {\n                // Process uploaded images\n                const processedImages = await Promise.all(\n                    req.files.map(async (file) => {\n                        // Read file and convert to base64\n                        const buffer = await fs.readFile(file.path);\n                        const base64 = buffer.toString('base64');\n                        const mimeType = file.mimetype;\n\n                        // Clean up temp file immediately\n                        await fs.unlink(file.path);\n\n                        return {\n                            name: file.originalname,\n                            data: `data:${mimeType};base64,${base64}`,\n                            size: file.size,\n                            mimeType: mimeType\n                        };\n                    })\n                );\n\n                res.json({ images: processedImages });\n            } catch (error) {\n                console.error('Error processing images:', error);\n                // Clean up any remaining files\n                await Promise.all(req.files.map(f => fs.unlink(f.path).catch(() => { })));\n                res.status(500).json({ error: 'Failed to process images' });\n            }\n        });\n    } catch (error) {\n        console.error('Error in image upload endpoint:', error);\n        res.status(500).json({ error: 'Internal server error' });\n    }\n});\n\n// Get token usage for a specific session\napp.get('/api/projects/:projectName/sessions/:sessionId/token-usage', authenticateToken, async (req, res) => {\n    try {\n        const { projectName, sessionId } = req.params;\n        const { provider = 'claude' } = req.query;\n        const homeDir = os.homedir();\n\n        // Allow only safe characters in sessionId\n        const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9._-]/g, '');\n        if (!safeSessionId || safeSessionId !== String(sessionId)) {\n            return res.status(400).json({ error: 'Invalid sessionId' });\n        }\n\n        // Handle Cursor sessions - they use SQLite and don't have token usage info\n        if (provider === 'cursor') {\n            return res.json({\n                used: 0,\n                total: 0,\n                breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },\n                unsupported: true,\n                message: 'Token usage tracking not available for Cursor sessions'\n            });\n        }\n\n        // Handle Gemini sessions - they are raw logs in our current setup\n        if (provider === 'gemini') {\n            return res.json({\n                used: 0,\n                total: 0,\n                breakdown: { input: 0, cacheCreation: 0, cacheRead: 0 },\n                unsupported: true,\n                message: 'Token usage tracking not available for Gemini sessions'\n            });\n        }\n\n        // Handle Codex sessions\n        if (provider === 'codex') {\n            const codexSessionsDir = path.join(homeDir, '.codex', 'sessions');\n\n            // Find the session file by searching for the session ID\n            const findSessionFile = async (dir) => {\n                try {\n                    const entries = await fsPromises.readdir(dir, { withFileTypes: true });\n                    for (const entry of entries) {\n                        const fullPath = path.join(dir, entry.name);\n                        if (entry.isDirectory()) {\n                            const found = await findSessionFile(fullPath);\n                            if (found) return found;\n                        } else if (entry.name.includes(safeSessionId) && entry.name.endsWith('.jsonl')) {\n                            return fullPath;\n                        }\n                    }\n                } catch (error) {\n                    // Skip directories we can't read\n                }\n                return null;\n            };\n\n            const sessionFilePath = await findSessionFile(codexSessionsDir);\n\n            if (!sessionFilePath) {\n                return res.status(404).json({ error: 'Codex session file not found', sessionId: safeSessionId });\n            }\n\n            // Read and parse the Codex JSONL file\n            let fileContent;\n            try {\n                fileContent = await fsPromises.readFile(sessionFilePath, 'utf8');\n            } catch (error) {\n                if (error.code === 'ENOENT') {\n                    return res.status(404).json({ error: 'Session file not found', path: sessionFilePath });\n                }\n                throw error;\n            }\n            const lines = fileContent.trim().split('\\n');\n            let totalTokens = 0;\n            let contextWindow = 200000; // Default for Codex/OpenAI\n\n            // Find the latest token_count event with info (scan from end)\n            for (let i = lines.length - 1; i >= 0; i--) {\n                try {\n                    const entry = JSON.parse(lines[i]);\n\n                    // Codex stores token info in event_msg with type: \"token_count\"\n                    if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {\n                        const tokenInfo = entry.payload.info;\n                        if (tokenInfo.total_token_usage) {\n                            totalTokens = tokenInfo.total_token_usage.total_tokens || 0;\n                        }\n                        if (tokenInfo.model_context_window) {\n                            contextWindow = tokenInfo.model_context_window;\n                        }\n                        break; // Stop after finding the latest token count\n                    }\n                } catch (parseError) {\n                    // Skip lines that can't be parsed\n                    continue;\n                }\n            }\n\n            return res.json({\n                used: totalTokens,\n                total: contextWindow\n            });\n        }\n\n        // Handle Claude sessions (default)\n        // Extract actual project path\n        let projectPath;\n        try {\n            projectPath = await extractProjectDirectory(projectName);\n        } catch (error) {\n            console.error('Error extracting project directory:', error);\n            return res.status(500).json({ error: 'Failed to determine project path' });\n        }\n\n        // Construct the JSONL file path\n        // Claude stores session files in ~/.claude/projects/[encoded-project-path]/[session-id].jsonl\n        // The encoding replaces any non-alphanumeric character (except -) with -\n        const encodedPath = projectPath.replace(/[^a-zA-Z0-9-]/g, '-');\n        const projectDir = path.join(homeDir, '.claude', 'projects', encodedPath);\n\n        const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);\n\n        // Constrain to projectDir\n        const rel = path.relative(path.resolve(projectDir), path.resolve(jsonlPath));\n        if (rel.startsWith('..') || path.isAbsolute(rel)) {\n            return res.status(400).json({ error: 'Invalid path' });\n        }\n\n        // Read and parse the JSONL file\n        let fileContent;\n        try {\n            fileContent = await fsPromises.readFile(jsonlPath, 'utf8');\n        } catch (error) {\n            if (error.code === 'ENOENT') {\n                return res.status(404).json({ error: 'Session file not found', path: jsonlPath });\n            }\n            throw error; // Re-throw other errors to be caught by outer try-catch\n        }\n        const lines = fileContent.trim().split('\\n');\n\n        const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10);\n        const contextWindow = Number.isFinite(parsedContextWindow) ? parsedContextWindow : 160000;\n        let inputTokens = 0;\n        let cacheCreationTokens = 0;\n        let cacheReadTokens = 0;\n\n        // Find the latest assistant message with usage data (scan from end)\n        for (let i = lines.length - 1; i >= 0; i--) {\n            try {\n                const entry = JSON.parse(lines[i]);\n\n                // Only count assistant messages which have usage data\n                if (entry.type === 'assistant' && entry.message?.usage) {\n                    const usage = entry.message.usage;\n\n                    // Use token counts from latest assistant message only\n                    inputTokens = usage.input_tokens || 0;\n                    cacheCreationTokens = usage.cache_creation_input_tokens || 0;\n                    cacheReadTokens = usage.cache_read_input_tokens || 0;\n\n                    break; // Stop after finding the latest assistant message\n                }\n            } catch (parseError) {\n                // Skip lines that can't be parsed\n                continue;\n            }\n        }\n\n        // Calculate total context usage (excluding output_tokens, as per ccusage)\n        const totalUsed = inputTokens + cacheCreationTokens + cacheReadTokens;\n\n        res.json({\n            used: totalUsed,\n            total: contextWindow,\n            breakdown: {\n                input: inputTokens,\n                cacheCreation: cacheCreationTokens,\n                cacheRead: cacheReadTokens\n            }\n        });\n    } catch (error) {\n        console.error('Error reading session token usage:', error);\n        res.status(500).json({ error: 'Failed to read session token usage' });\n    }\n});\n\n// Serve React app for all other routes (excluding static files)\napp.get('*', (req, res) => {\n    // Skip requests for static assets (files with extensions)\n    if (path.extname(req.path)) {\n        return res.status(404).send('Not found');\n    }\n\n    // Only serve index.html for HTML routes, not for static assets\n    // Static assets should already be handled by express.static middleware above\n    const indexPath = path.join(__dirname, '../dist/index.html');\n\n    // Check if dist/index.html exists (production build available)\n    if (fs.existsSync(indexPath)) {\n        // Set no-cache headers for HTML to prevent service worker issues\n        res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');\n        res.setHeader('Pragma', 'no-cache');\n        res.setHeader('Expires', '0');\n        res.sendFile(indexPath);\n    } else {\n        // In development, redirect to Vite dev server only if dist doesn't exist\n        const redirectHost = getConnectableHost(req.hostname);\n        res.redirect(`${req.protocol}://${redirectHost}:${VITE_PORT}`);\n    }\n});\n\n// Helper function to convert permissions to rwx format\nfunction permToRwx(perm) {\n    const r = perm & 4 ? 'r' : '-';\n    const w = perm & 2 ? 'w' : '-';\n    const x = perm & 1 ? 'x' : '-';\n    return r + w + x;\n}\n\nasync function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden = true) {\n    // Using fsPromises from import\n    const items = [];\n\n    try {\n        const entries = await fsPromises.readdir(dirPath, { withFileTypes: true });\n\n        for (const entry of entries) {\n            // Debug: log all entries including hidden files\n\n\n            // Skip heavy build directories and VCS directories\n            if (entry.name === 'node_modules' ||\n                entry.name === 'dist' ||\n                entry.name === 'build' ||\n                entry.name === '.git' ||\n                entry.name === '.svn' ||\n                entry.name === '.hg') continue;\n\n            const itemPath = path.join(dirPath, entry.name);\n            const item = {\n                name: entry.name,\n                path: itemPath,\n                type: entry.isDirectory() ? 'directory' : 'file'\n            };\n\n            // Get file stats for additional metadata\n            try {\n                const stats = await fsPromises.stat(itemPath);\n                item.size = stats.size;\n                item.modified = stats.mtime.toISOString();\n\n                // Convert permissions to rwx format\n                const mode = stats.mode;\n                const ownerPerm = (mode >> 6) & 7;\n                const groupPerm = (mode >> 3) & 7;\n                const otherPerm = mode & 7;\n                item.permissions = ((mode >> 6) & 7).toString() + ((mode >> 3) & 7).toString() + (mode & 7).toString();\n                item.permissionsRwx = permToRwx(ownerPerm) + permToRwx(groupPerm) + permToRwx(otherPerm);\n            } catch (statError) {\n                // If stat fails, provide default values\n                item.size = 0;\n                item.modified = null;\n                item.permissions = '000';\n                item.permissionsRwx = '---------';\n            }\n\n            if (entry.isDirectory() && currentDepth < maxDepth) {\n                // Recursively get subdirectories but limit depth\n                try {\n                    // Check if we can access the directory before trying to read it\n                    await fsPromises.access(item.path, fs.constants.R_OK);\n                    item.children = await getFileTree(item.path, maxDepth, currentDepth + 1, showHidden);\n                } catch (e) {\n                    // Silently skip directories we can't access (permission denied, etc.)\n                    item.children = [];\n                }\n            }\n\n            items.push(item);\n        }\n    } catch (error) {\n        // Only log non-permission errors to avoid spam\n        if (error.code !== 'EACCES' && error.code !== 'EPERM') {\n            console.error('Error reading directory:', error);\n        }\n    }\n\n    return items.sort((a, b) => {\n        if (a.type !== b.type) {\n            return a.type === 'directory' ? -1 : 1;\n        }\n        return a.name.localeCompare(b.name);\n    });\n}\n\nconst SERVER_PORT = process.env.SERVER_PORT || 3001;\nconst HOST = process.env.HOST || '0.0.0.0';\nconst DISPLAY_HOST = getConnectableHost(HOST);\nconst VITE_PORT = process.env.VITE_PORT || 5173;\n\n// Initialize database and start server\nasync function startServer() {\n    try {\n        // Initialize authentication database\n        await initializeDatabase();\n\n        // Configure Web Push (VAPID keys)\n        configureWebPush();\n\n        // Check if running in production mode (dist folder exists)\n        const distIndexPath = path.join(__dirname, '../dist/index.html');\n        const isProduction = fs.existsSync(distIndexPath);\n\n        // Log Claude implementation mode\n        console.log(`${c.info('[INFO]')} Using Claude Agents SDK for Claude integration`);\n        console.log('');\n\n        if (isProduction) {\n            console.log(`${c.info('[INFO]')} To run in production mode, go to http://${DISPLAY_HOST}:${SERVER_PORT}`);            \n        }\n\n        console.log(`${c.info('[INFO]')} To run in development mode with hot-module replacement, go to http://${DISPLAY_HOST}:${VITE_PORT}`);\n   \n        server.listen(SERVER_PORT, HOST, async () => {\n            const appInstallPath = path.join(__dirname, '..');\n\n            console.log('');\n            console.log(c.dim('═'.repeat(63)));\n            console.log(`  ${c.bright('Claude Code UI Server - Ready')}`);\n            console.log(c.dim('═'.repeat(63)));\n            console.log('');\n            console.log(`${c.info('[INFO]')} Server URL:  ${c.bright('http://' + DISPLAY_HOST + ':' + SERVER_PORT)}`);\n            console.log(`${c.info('[INFO]')} Installed at: ${c.dim(appInstallPath)}`);\n            console.log(`${c.tip('[TIP]')}  Run \"cloudcli status\" for full configuration details`);\n            console.log('');\n\n            // Start watching the projects folder for changes\n            await setupProjectsWatcher();\n\n            // Start server-side plugin processes for enabled plugins\n            startEnabledPluginServers().catch(err => {\n                console.error('[Plugins] Error during startup:', err.message);\n            });\n        });\n\n        // Clean up plugin processes on shutdown\n        const shutdownPlugins = async () => {\n            await stopAllPlugins();\n            process.exit(0);\n        };\n        process.on('SIGTERM', () => void shutdownPlugins());\n        process.on('SIGINT', () => void shutdownPlugins());\n    } catch (error) {\n        console.error('[ERROR] Failed to start server:', error);\n        process.exit(1);\n    }\n}\n\nstartServer();\n"
  },
  {
    "path": "server/load-env.js",
    "content": "// Load environment variables from .env before other imports execute.\nimport fs from 'fs';\nimport os from 'os';\nimport path from 'path';\nimport { fileURLToPath } from 'url';\nimport { dirname } from 'path';\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = dirname(__filename);\n\ntry {\n  const envPath = path.join(__dirname, '../.env');\n  const envFile = fs.readFileSync(envPath, 'utf8');\n  envFile.split('\\n').forEach(line => {\n    const trimmedLine = line.trim();\n    if (trimmedLine && !trimmedLine.startsWith('#')) {\n      const [key, ...valueParts] = trimmedLine.split('=');\n      if (key && valueParts.length > 0 && !process.env[key]) {\n        process.env[key] = valueParts.join('=').trim();\n      }\n    }\n  });\n} catch (e) {\n  console.log('No .env file found or error reading it:', e.message);\n}\n\nif (!process.env.DATABASE_PATH) {\n  process.env.DATABASE_PATH = path.join(os.homedir(), '.cloudcli', 'auth.db');\n}\n"
  },
  {
    "path": "server/middleware/auth.js",
    "content": "import jwt from 'jsonwebtoken';\nimport { userDb, appConfigDb } from '../database/db.js';\nimport { IS_PLATFORM } from '../constants/config.js';\n\n// Use env var if set, otherwise auto-generate a unique secret per installation\nconst JWT_SECRET = process.env.JWT_SECRET || appConfigDb.getOrCreateJwtSecret();\n\n// Optional API key middleware\nconst validateApiKey = (req, res, next) => {\n  // Skip API key validation if not configured\n  if (!process.env.API_KEY) {\n    return next();\n  }\n  \n  const apiKey = req.headers['x-api-key'];\n  if (apiKey !== process.env.API_KEY) {\n    return res.status(401).json({ error: 'Invalid API key' });\n  }\n  next();\n};\n\n// JWT authentication middleware\nconst authenticateToken = async (req, res, next) => {\n  // Platform mode:  use single database user\n  if (IS_PLATFORM) {\n    try {\n      const user = userDb.getFirstUser();\n      if (!user) {\n        return res.status(500).json({ error: 'Platform mode: No user found in database' });\n      }\n      req.user = user;\n      return next();\n    } catch (error) {\n      console.error('Platform mode error:', error);\n      return res.status(500).json({ error: 'Platform mode: Failed to fetch user' });\n    }\n  }\n\n  // Normal OSS JWT validation\n  const authHeader = req.headers['authorization'];\n  let token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN\n\n  // Also check query param for SSE endpoints (EventSource can't set headers)\n  if (!token && req.query.token) {\n    token = req.query.token;\n  }\n\n  if (!token) {\n    return res.status(401).json({ error: 'Access denied. No token provided.' });\n  }\n\n  try {\n    const decoded = jwt.verify(token, JWT_SECRET);\n\n    // Verify user still exists and is active\n    const user = userDb.getUserById(decoded.userId);\n    if (!user) {\n      return res.status(401).json({ error: 'Invalid token. User not found.' });\n    }\n\n    // Auto-refresh: if token is past halfway through its lifetime, issue a new one\n    if (decoded.exp && decoded.iat) {\n      const now = Math.floor(Date.now() / 1000);\n      const halfLife = (decoded.exp - decoded.iat) / 2;\n      if (now > decoded.iat + halfLife) {\n        const newToken = generateToken(user);\n        res.setHeader('X-Refreshed-Token', newToken);\n      }\n    }\n\n    req.user = user;\n    next();\n  } catch (error) {\n    console.error('Token verification error:', error);\n    return res.status(403).json({ error: 'Invalid token' });\n  }\n};\n\n// Generate JWT token\nconst generateToken = (user) => {\n  return jwt.sign(\n    {\n      userId: user.id,\n      username: user.username\n    },\n    JWT_SECRET,\n    { expiresIn: '7d' }\n  );\n};\n\n// WebSocket authentication function\nconst authenticateWebSocket = (token) => {\n  // Platform mode: bypass token validation, return first user\n  if (IS_PLATFORM) {\n    try {\n      const user = userDb.getFirstUser();\n      if (user) {\n        return { id: user.id, userId: user.id, username: user.username };\n      }\n      return null;\n    } catch (error) {\n      console.error('Platform mode WebSocket error:', error);\n      return null;\n    }\n  }\n\n  // Normal OSS JWT validation\n  if (!token) {\n    return null;\n  }\n\n  try {\n    const decoded = jwt.verify(token, JWT_SECRET);\n    // Verify user actually exists in database (matches REST authenticateToken behavior)\n    const user = userDb.getUserById(decoded.userId);\n    if (!user) {\n      return null;\n    }\n    return { userId: user.id, username: user.username };\n  } catch (error) {\n    console.error('WebSocket token verification error:', error);\n    return null;\n  }\n};\n\nexport {\n  validateApiKey,\n  authenticateToken,\n  generateToken,\n  authenticateWebSocket,\n  JWT_SECRET\n};\n"
  },
  {
    "path": "server/openai-codex.js",
    "content": "/**\n * OpenAI Codex SDK Integration\n * =============================\n *\n * This module provides integration with the OpenAI Codex SDK for non-interactive\n * chat sessions. It mirrors the pattern used in claude-sdk.js for consistency.\n *\n * ## Usage\n *\n * - queryCodex(command, options, ws) - Execute a prompt with streaming via WebSocket\n * - abortCodexSession(sessionId) - Cancel an active session\n * - isCodexSessionActive(sessionId) - Check if a session is running\n * - getActiveCodexSessions() - List all active sessions\n */\n\nimport { Codex } from '@openai/codex-sdk';\nimport { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';\nimport { codexAdapter } from './providers/codex/adapter.js';\nimport { createNormalizedMessage } from './providers/types.js';\n\n// Track active sessions\nconst activeCodexSessions = new Map();\n\n/**\n * Transform Codex SDK event to WebSocket message format\n * @param {object} event - SDK event\n * @returns {object} - Transformed event for WebSocket\n */\nfunction transformCodexEvent(event) {\n  // Map SDK event types to a consistent format\n  switch (event.type) {\n    case 'item.started':\n    case 'item.updated':\n    case 'item.completed':\n      const item = event.item;\n      if (!item) {\n        return { type: event.type, item: null };\n      }\n\n      // Transform based on item type\n      switch (item.type) {\n        case 'agent_message':\n          return {\n            type: 'item',\n            itemType: 'agent_message',\n            message: {\n              role: 'assistant',\n              content: item.text\n            }\n          };\n\n        case 'reasoning':\n          return {\n            type: 'item',\n            itemType: 'reasoning',\n            message: {\n              role: 'assistant',\n              content: item.text,\n              isReasoning: true\n            }\n          };\n\n        case 'command_execution':\n          return {\n            type: 'item',\n            itemType: 'command_execution',\n            command: item.command,\n            output: item.aggregated_output,\n            exitCode: item.exit_code,\n            status: item.status\n          };\n\n        case 'file_change':\n          return {\n            type: 'item',\n            itemType: 'file_change',\n            changes: item.changes,\n            status: item.status\n          };\n\n        case 'mcp_tool_call':\n          return {\n            type: 'item',\n            itemType: 'mcp_tool_call',\n            server: item.server,\n            tool: item.tool,\n            arguments: item.arguments,\n            result: item.result,\n            error: item.error,\n            status: item.status\n          };\n\n        case 'web_search':\n          return {\n            type: 'item',\n            itemType: 'web_search',\n            query: item.query\n          };\n\n        case 'todo_list':\n          return {\n            type: 'item',\n            itemType: 'todo_list',\n            items: item.items\n          };\n\n        case 'error':\n          return {\n            type: 'item',\n            itemType: 'error',\n            message: {\n              role: 'error',\n              content: item.message\n            }\n          };\n\n        default:\n          return {\n            type: 'item',\n            itemType: item.type,\n            item: item\n          };\n      }\n\n    case 'turn.started':\n      return {\n        type: 'turn_started'\n      };\n\n    case 'turn.completed':\n      return {\n        type: 'turn_complete',\n        usage: event.usage\n      };\n\n    case 'turn.failed':\n      return {\n        type: 'turn_failed',\n        error: event.error\n      };\n\n    case 'thread.started':\n      return {\n        type: 'thread_started',\n        threadId: event.id\n      };\n\n    case 'error':\n      return {\n        type: 'error',\n        message: event.message\n      };\n\n    default:\n      return {\n        type: event.type,\n        data: event\n      };\n  }\n}\n\n/**\n * Map permission mode to Codex SDK options\n * @param {string} permissionMode - 'default', 'acceptEdits', or 'bypassPermissions'\n * @returns {object} - { sandboxMode, approvalPolicy }\n */\nfunction mapPermissionModeToCodexOptions(permissionMode) {\n  switch (permissionMode) {\n    case 'acceptEdits':\n      return {\n        sandboxMode: 'workspace-write',\n        approvalPolicy: 'never'\n      };\n    case 'bypassPermissions':\n      return {\n        sandboxMode: 'danger-full-access',\n        approvalPolicy: 'never'\n      };\n    case 'default':\n    default:\n      return {\n        sandboxMode: 'workspace-write',\n        approvalPolicy: 'untrusted'\n      };\n  }\n}\n\n/**\n * Execute a Codex query with streaming\n * @param {string} command - The prompt to send\n * @param {object} options - Options including cwd, sessionId, model, permissionMode\n * @param {WebSocket|object} ws - WebSocket connection or response writer\n */\nexport async function queryCodex(command, options = {}, ws) {\n  const {\n    sessionId,\n    sessionSummary,\n    cwd,\n    projectPath,\n    model,\n    permissionMode = 'default'\n  } = options;\n\n  const workingDirectory = cwd || projectPath || process.cwd();\n  const { sandboxMode, approvalPolicy } = mapPermissionModeToCodexOptions(permissionMode);\n\n  let codex;\n  let thread;\n  let currentSessionId = sessionId;\n  let terminalFailure = null;\n  const abortController = new AbortController();\n\n  try {\n    // Initialize Codex SDK\n    codex = new Codex();\n\n    // Thread options with sandbox and approval settings\n    const threadOptions = {\n      workingDirectory,\n      skipGitRepoCheck: true,\n      sandboxMode,\n      approvalPolicy,\n      model\n    };\n\n    // Start or resume thread\n    if (sessionId) {\n      thread = codex.resumeThread(sessionId, threadOptions);\n    } else {\n      thread = codex.startThread(threadOptions);\n    }\n\n    // Get the thread ID\n    currentSessionId = thread.id || sessionId || `codex-${Date.now()}`;\n\n    // Track the session\n    activeCodexSessions.set(currentSessionId, {\n      thread,\n      codex,\n      status: 'running',\n      abortController,\n      startedAt: new Date().toISOString()\n    });\n\n    // Send session created event\n    sendMessage(ws, createNormalizedMessage({ kind: 'session_created', newSessionId: currentSessionId, sessionId: currentSessionId, provider: 'codex' }));\n\n    // Execute with streaming\n    const streamedTurn = await thread.runStreamed(command, {\n      signal: abortController.signal\n    });\n\n    for await (const event of streamedTurn.events) {\n      // Check if session was aborted\n      const session = activeCodexSessions.get(currentSessionId);\n      if (!session || session.status === 'aborted') {\n        break;\n      }\n\n      if (event.type === 'item.started' || event.type === 'item.updated') {\n        continue;\n      }\n\n      const transformed = transformCodexEvent(event);\n\n      // Normalize the transformed event into NormalizedMessage(s) via adapter\n      const normalizedMsgs = codexAdapter.normalizeMessage(transformed, currentSessionId);\n      for (const msg of normalizedMsgs) {\n        sendMessage(ws, msg);\n      }\n\n      if (event.type === 'turn.failed' && !terminalFailure) {\n        terminalFailure = event.error || new Error('Turn failed');\n        notifyRunFailed({\n          userId: ws?.userId || null,\n          provider: 'codex',\n          sessionId: currentSessionId,\n          sessionName: sessionSummary,\n          error: terminalFailure\n        });\n      }\n\n      // Extract and send token usage if available (normalized to match Claude format)\n      if (event.type === 'turn.completed' && event.usage) {\n        const totalTokens = (event.usage.input_tokens || 0) + (event.usage.output_tokens || 0);\n        sendMessage(ws, createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: { used: totalTokens, total: 200000 }, sessionId: currentSessionId, provider: 'codex' }));\n      }\n    }\n\n    // Send completion event\n    if (!terminalFailure) {\n      sendMessage(ws, createNormalizedMessage({ kind: 'complete', actualSessionId: thread.id, sessionId: currentSessionId, provider: 'codex' }));\n      notifyRunStopped({\n        userId: ws?.userId || null,\n        provider: 'codex',\n        sessionId: currentSessionId,\n        sessionName: sessionSummary,\n        stopReason: 'completed'\n      });\n    }\n\n  } catch (error) {\n    const session = currentSessionId ? activeCodexSessions.get(currentSessionId) : null;\n    const wasAborted =\n      session?.status === 'aborted' ||\n      error?.name === 'AbortError' ||\n      String(error?.message || '').toLowerCase().includes('aborted');\n\n    if (!wasAborted) {\n      console.error('[Codex] Error:', error);\n      sendMessage(ws, createNormalizedMessage({ kind: 'error', content: error.message, sessionId: currentSessionId, provider: 'codex' }));\n      if (!terminalFailure) {\n        notifyRunFailed({\n          userId: ws?.userId || null,\n          provider: 'codex',\n          sessionId: currentSessionId,\n          sessionName: sessionSummary,\n          error\n        });\n      }\n    }\n\n  } finally {\n    // Update session status\n    if (currentSessionId) {\n      const session = activeCodexSessions.get(currentSessionId);\n      if (session) {\n        session.status = session.status === 'aborted' ? 'aborted' : 'completed';\n      }\n    }\n  }\n}\n\n/**\n * Abort an active Codex session\n * @param {string} sessionId - Session ID to abort\n * @returns {boolean} - Whether abort was successful\n */\nexport function abortCodexSession(sessionId) {\n  const session = activeCodexSessions.get(sessionId);\n\n  if (!session) {\n    return false;\n  }\n\n  session.status = 'aborted';\n  try {\n    session.abortController?.abort();\n  } catch (error) {\n    console.warn(`[Codex] Failed to abort session ${sessionId}:`, error);\n  }\n\n  return true;\n}\n\n/**\n * Check if a session is active\n * @param {string} sessionId - Session ID to check\n * @returns {boolean} - Whether session is active\n */\nexport function isCodexSessionActive(sessionId) {\n  const session = activeCodexSessions.get(sessionId);\n  return session?.status === 'running';\n}\n\n/**\n * Get all active sessions\n * @returns {Array} - Array of active session info\n */\nexport function getActiveCodexSessions() {\n  const sessions = [];\n\n  for (const [id, session] of activeCodexSessions.entries()) {\n    if (session.status === 'running') {\n      sessions.push({\n        id,\n        status: session.status,\n        startedAt: session.startedAt\n      });\n    }\n  }\n\n  return sessions;\n}\n\n/**\n * Helper to send message via WebSocket or writer\n * @param {WebSocket|object} ws - WebSocket or response writer\n * @param {object} data - Data to send\n */\nfunction sendMessage(ws, data) {\n  try {\n    if (ws.isSSEStreamWriter || ws.isWebSocketWriter) {\n      // Writer handles stringification (SSEStreamWriter or WebSocketWriter)\n      ws.send(data);\n    } else if (typeof ws.send === 'function') {\n      // Raw WebSocket - stringify here\n      ws.send(JSON.stringify(data));\n    }\n  } catch (error) {\n    console.error('[Codex] Error sending message:', error);\n  }\n}\n\n// Clean up old completed sessions periodically\nsetInterval(() => {\n  const now = Date.now();\n  const maxAge = 30 * 60 * 1000; // 30 minutes\n\n  for (const [id, session] of activeCodexSessions.entries()) {\n    if (session.status !== 'running') {\n      const startedAt = new Date(session.startedAt).getTime();\n      if (now - startedAt > maxAge) {\n        activeCodexSessions.delete(id);\n      }\n    }\n  }\n}, 5 * 60 * 1000); // Every 5 minutes\n"
  },
  {
    "path": "server/projects.js",
    "content": "/**\n * PROJECT DISCOVERY AND MANAGEMENT SYSTEM\n * ========================================\n * \n * This module manages project discovery for both Claude CLI and Cursor CLI sessions.\n * \n * ## Architecture Overview\n * \n * 1. **Claude Projects** (stored in ~/.claude/projects/)\n *    - Each project is a directory named with the project path encoded (/ replaced with -)\n *    - Contains .jsonl files with conversation history including 'cwd' field\n *    - Project metadata stored in ~/.claude/project-config.json\n * \n * 2. **Cursor Projects** (stored in ~/.cursor/chats/)\n *    - Each project directory is named with MD5 hash of the absolute project path\n *    - Example: /Users/john/myproject -> MD5 -> a1b2c3d4e5f6...\n *    - Contains session directories with SQLite databases (store.db)\n *    - Project path is NOT stored in the database - only in the MD5 hash\n * \n * ## Project Discovery Strategy\n * \n * 1. **Claude Projects Discovery**:\n *    - Scan ~/.claude/projects/ directory for Claude project folders\n *    - Extract actual project path from .jsonl files (cwd field)\n *    - Fall back to decoded directory name if no sessions exist\n * \n * 2. **Cursor Sessions Discovery**:\n *    - For each KNOWN project (from Claude or manually added)\n *    - Compute MD5 hash of the project's absolute path\n *    - Check if ~/.cursor/chats/{md5_hash}/ directory exists\n *    - Read session metadata from SQLite store.db files\n * \n * 3. **Manual Project Addition**:\n *    - Users can manually add project paths via UI\n *    - Stored in ~/.claude/project-config.json with 'manuallyAdded' flag\n *    - Allows discovering Cursor sessions for projects without Claude sessions\n * \n * ## Critical Limitations\n * \n * - **CANNOT discover Cursor-only projects**: From a quick check, there was no mention of\n *   the cwd of each project. if someone has the time, you can try to reverse engineer it.\n * \n * - **Project relocation breaks history**: If a project directory is moved or renamed,\n *   the MD5 hash changes, making old Cursor sessions inaccessible unless the old\n *   path is known and manually added.\n * \n * ## Error Handling\n * \n * - Missing ~/.claude directory is handled gracefully with automatic creation\n * - ENOENT errors are caught and handled without crashing\n * - Empty arrays returned when no projects/sessions exist\n * \n * ## Caching Strategy\n * \n * - Project directory extraction is cached to minimize file I/O\n * - Cache is cleared when project configuration changes\n * - Session data is fetched on-demand, not cached\n */\n\nimport { promises as fs } from 'fs';\nimport fsSync from 'fs';\nimport path from 'path';\nimport readline from 'readline';\nimport crypto from 'crypto';\nimport sqlite3 from 'sqlite3';\nimport { open } from 'sqlite';\nimport os from 'os';\nimport sessionManager from './sessionManager.js';\nimport { applyCustomSessionNames } from './database/db.js';\n\n// Import TaskMaster detection functions\nasync function detectTaskMasterFolder(projectPath) {\n  try {\n    const taskMasterPath = path.join(projectPath, '.taskmaster');\n\n    // Check if .taskmaster directory exists\n    try {\n      const stats = await fs.stat(taskMasterPath);\n      if (!stats.isDirectory()) {\n        return {\n          hasTaskmaster: false,\n          reason: '.taskmaster exists but is not a directory'\n        };\n      }\n    } catch (error) {\n      if (error.code === 'ENOENT') {\n        return {\n          hasTaskmaster: false,\n          reason: '.taskmaster directory not found'\n        };\n      }\n      throw error;\n    }\n\n    // Check for key TaskMaster files\n    const keyFiles = [\n      'tasks/tasks.json',\n      'config.json'\n    ];\n\n    const fileStatus = {};\n    let hasEssentialFiles = true;\n\n    for (const file of keyFiles) {\n      const filePath = path.join(taskMasterPath, file);\n      try {\n        await fs.access(filePath);\n        fileStatus[file] = true;\n      } catch (error) {\n        fileStatus[file] = false;\n        if (file === 'tasks/tasks.json') {\n          hasEssentialFiles = false;\n        }\n      }\n    }\n\n    // Parse tasks.json if it exists for metadata\n    let taskMetadata = null;\n    if (fileStatus['tasks/tasks.json']) {\n      try {\n        const tasksPath = path.join(taskMasterPath, 'tasks/tasks.json');\n        const tasksContent = await fs.readFile(tasksPath, 'utf8');\n        const tasksData = JSON.parse(tasksContent);\n\n        // Handle both tagged and legacy formats\n        let tasks = [];\n        if (tasksData.tasks) {\n          // Legacy format\n          tasks = tasksData.tasks;\n        } else {\n          // Tagged format - get tasks from all tags\n          Object.values(tasksData).forEach(tagData => {\n            if (tagData.tasks) {\n              tasks = tasks.concat(tagData.tasks);\n            }\n          });\n        }\n\n        // Calculate task statistics\n        const stats = tasks.reduce((acc, task) => {\n          acc.total++;\n          acc[task.status] = (acc[task.status] || 0) + 1;\n\n          // Count subtasks\n          if (task.subtasks) {\n            task.subtasks.forEach(subtask => {\n              acc.subtotalTasks++;\n              acc.subtasks = acc.subtasks || {};\n              acc.subtasks[subtask.status] = (acc.subtasks[subtask.status] || 0) + 1;\n            });\n          }\n\n          return acc;\n        }, {\n          total: 0,\n          subtotalTasks: 0,\n          pending: 0,\n          'in-progress': 0,\n          done: 0,\n          review: 0,\n          deferred: 0,\n          cancelled: 0,\n          subtasks: {}\n        });\n\n        taskMetadata = {\n          taskCount: stats.total,\n          subtaskCount: stats.subtotalTasks,\n          completed: stats.done || 0,\n          pending: stats.pending || 0,\n          inProgress: stats['in-progress'] || 0,\n          review: stats.review || 0,\n          completionPercentage: stats.total > 0 ? Math.round((stats.done / stats.total) * 100) : 0,\n          lastModified: (await fs.stat(tasksPath)).mtime.toISOString()\n        };\n      } catch (parseError) {\n        console.warn('Failed to parse tasks.json:', parseError.message);\n        taskMetadata = { error: 'Failed to parse tasks.json' };\n      }\n    }\n\n    return {\n      hasTaskmaster: true,\n      hasEssentialFiles,\n      files: fileStatus,\n      metadata: taskMetadata,\n      path: taskMasterPath\n    };\n\n  } catch (error) {\n    console.error('Error detecting TaskMaster folder:', error);\n    return {\n      hasTaskmaster: false,\n      reason: `Error checking directory: ${error.message}`\n    };\n  }\n}\n\n// Cache for extracted project directories\nconst projectDirectoryCache = new Map();\n\n// Clear cache when needed (called when project files change)\nfunction clearProjectDirectoryCache() {\n  projectDirectoryCache.clear();\n}\n\n// Load project configuration file\nasync function loadProjectConfig() {\n  const configPath = path.join(os.homedir(), '.claude', 'project-config.json');\n  try {\n    const configData = await fs.readFile(configPath, 'utf8');\n    return JSON.parse(configData);\n  } catch (error) {\n    // Return empty config if file doesn't exist\n    return {};\n  }\n}\n\n// Save project configuration file\nasync function saveProjectConfig(config) {\n  const claudeDir = path.join(os.homedir(), '.claude');\n  const configPath = path.join(claudeDir, 'project-config.json');\n\n  // Ensure the .claude directory exists\n  try {\n    await fs.mkdir(claudeDir, { recursive: true });\n  } catch (error) {\n    if (error.code !== 'EEXIST') {\n      throw error;\n    }\n  }\n\n  await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');\n}\n\n// Generate better display name from path\nasync function generateDisplayName(projectName, actualProjectDir = null) {\n  // Use actual project directory if provided, otherwise decode from project name\n  let projectPath = actualProjectDir || projectName.replace(/-/g, '/');\n\n  // Try to read package.json from the project path\n  try {\n    const packageJsonPath = path.join(projectPath, 'package.json');\n    const packageData = await fs.readFile(packageJsonPath, 'utf8');\n    const packageJson = JSON.parse(packageData);\n\n    // Return the name from package.json if it exists\n    if (packageJson.name) {\n      return packageJson.name;\n    }\n  } catch (error) {\n    // Fall back to path-based naming if package.json doesn't exist or can't be read\n  }\n\n  // If it starts with /, it's an absolute path\n  if (projectPath.startsWith('/')) {\n    const parts = projectPath.split('/').filter(Boolean);\n    // Return only the last folder name\n    return parts[parts.length - 1] || projectPath;\n  }\n\n  return projectPath;\n}\n\n// Extract the actual project directory from JSONL sessions (with caching)\nasync function extractProjectDirectory(projectName) {\n  // Check cache first\n  if (projectDirectoryCache.has(projectName)) {\n    return projectDirectoryCache.get(projectName);\n  }\n\n  // Check project config for originalPath (manually added projects via UI or platform)\n  // This handles projects with dashes in their directory names correctly\n  const config = await loadProjectConfig();\n  if (config[projectName]?.originalPath) {\n    const originalPath = config[projectName].originalPath;\n    projectDirectoryCache.set(projectName, originalPath);\n    return originalPath;\n  }\n\n  const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);\n  const cwdCounts = new Map();\n  let latestTimestamp = 0;\n  let latestCwd = null;\n  let extractedPath;\n\n  try {\n    // Check if the project directory exists\n    await fs.access(projectDir);\n\n    const files = await fs.readdir(projectDir);\n    const jsonlFiles = files.filter(file => file.endsWith('.jsonl'));\n\n    if (jsonlFiles.length === 0) {\n      // Fall back to decoded project name if no sessions\n      extractedPath = projectName.replace(/-/g, '/');\n    } else {\n      // Process all JSONL files to collect cwd values\n      for (const file of jsonlFiles) {\n        const jsonlFile = path.join(projectDir, file);\n        const fileStream = fsSync.createReadStream(jsonlFile);\n        const rl = readline.createInterface({\n          input: fileStream,\n          crlfDelay: Infinity\n        });\n\n        for await (const line of rl) {\n          if (line.trim()) {\n            try {\n              const entry = JSON.parse(line);\n\n              if (entry.cwd) {\n                // Count occurrences of each cwd\n                cwdCounts.set(entry.cwd, (cwdCounts.get(entry.cwd) || 0) + 1);\n\n                // Track the most recent cwd\n                const timestamp = new Date(entry.timestamp || 0).getTime();\n                if (timestamp > latestTimestamp) {\n                  latestTimestamp = timestamp;\n                  latestCwd = entry.cwd;\n                }\n              }\n            } catch (parseError) {\n              // Skip malformed lines\n            }\n          }\n        }\n      }\n\n      // Determine the best cwd to use\n      if (cwdCounts.size === 0) {\n        // No cwd found, fall back to decoded project name\n        extractedPath = projectName.replace(/-/g, '/');\n      } else if (cwdCounts.size === 1) {\n        // Only one cwd, use it\n        extractedPath = Array.from(cwdCounts.keys())[0];\n      } else {\n        // Multiple cwd values - prefer the most recent one if it has reasonable usage\n        const mostRecentCount = cwdCounts.get(latestCwd) || 0;\n        const maxCount = Math.max(...cwdCounts.values());\n\n        // Use most recent if it has at least 25% of the max count\n        if (mostRecentCount >= maxCount * 0.25) {\n          extractedPath = latestCwd;\n        } else {\n          // Otherwise use the most frequently used cwd\n          for (const [cwd, count] of cwdCounts.entries()) {\n            if (count === maxCount) {\n              extractedPath = cwd;\n              break;\n            }\n          }\n        }\n\n        // Fallback (shouldn't reach here)\n        if (!extractedPath) {\n          extractedPath = latestCwd || projectName.replace(/-/g, '/');\n        }\n      }\n    }\n\n    // Cache the result\n    projectDirectoryCache.set(projectName, extractedPath);\n\n    return extractedPath;\n\n  } catch (error) {\n    // If the directory doesn't exist, just use the decoded project name\n    if (error.code === 'ENOENT') {\n      extractedPath = projectName.replace(/-/g, '/');\n    } else {\n      console.error(`Error extracting project directory for ${projectName}:`, error);\n      // Fall back to decoded project name for other errors\n      extractedPath = projectName.replace(/-/g, '/');\n    }\n\n    // Cache the fallback result too\n    projectDirectoryCache.set(projectName, extractedPath);\n\n    return extractedPath;\n  }\n}\n\nasync function getProjects(progressCallback = null) {\n  const claudeDir = path.join(os.homedir(), '.claude', 'projects');\n  const config = await loadProjectConfig();\n  const projects = [];\n  const existingProjects = new Set();\n  const codexSessionsIndexRef = { sessionsByProject: null };\n  let totalProjects = 0;\n  let processedProjects = 0;\n  let directories = [];\n\n  try {\n    // Check if the .claude/projects directory exists\n    await fs.access(claudeDir);\n\n    // First, get existing Claude projects from the file system\n    const entries = await fs.readdir(claudeDir, { withFileTypes: true });\n    directories = entries.filter(e => e.isDirectory());\n\n    // Build set of existing project names for later\n    directories.forEach(e => existingProjects.add(e.name));\n\n    // Count manual projects not already in directories\n    const manualProjectsCount = Object.entries(config)\n      .filter(([name, cfg]) => cfg.manuallyAdded && !existingProjects.has(name))\n      .length;\n\n    totalProjects = directories.length + manualProjectsCount;\n\n    for (const entry of directories) {\n      processedProjects++;\n\n      // Emit progress\n      if (progressCallback) {\n        progressCallback({\n          phase: 'loading',\n          current: processedProjects,\n          total: totalProjects,\n          currentProject: entry.name\n        });\n      }\n\n      // Extract actual project directory from JSONL sessions\n      const actualProjectDir = await extractProjectDirectory(entry.name);\n\n      // Get display name from config or generate one\n      const customName = config[entry.name]?.displayName;\n      const autoDisplayName = await generateDisplayName(entry.name, actualProjectDir);\n      const fullPath = actualProjectDir;\n\n      const project = {\n        name: entry.name,\n        path: actualProjectDir,\n        displayName: customName || autoDisplayName,\n        fullPath: fullPath,\n        isCustomName: !!customName,\n        sessions: [],\n        geminiSessions: [],\n        sessionMeta: {\n          hasMore: false,\n          total: 0\n        }\n      };\n\n      // Try to get sessions for this project (just first 5 for performance)\n      try {\n        const sessionResult = await getSessions(entry.name, 5, 0);\n        project.sessions = sessionResult.sessions || [];\n        project.sessionMeta = {\n          hasMore: sessionResult.hasMore,\n          total: sessionResult.total\n        };\n      } catch (e) {\n        console.warn(`Could not load sessions for project ${entry.name}:`, e.message);\n        project.sessionMeta = {\n          hasMore: false,\n          total: 0\n        };\n      }\n      applyCustomSessionNames(project.sessions, 'claude');\n\n      // Also fetch Cursor sessions for this project\n      try {\n        project.cursorSessions = await getCursorSessions(actualProjectDir);\n      } catch (e) {\n        console.warn(`Could not load Cursor sessions for project ${entry.name}:`, e.message);\n        project.cursorSessions = [];\n      }\n      applyCustomSessionNames(project.cursorSessions, 'cursor');\n\n      // Also fetch Codex sessions for this project\n      try {\n        project.codexSessions = await getCodexSessions(actualProjectDir, {\n          indexRef: codexSessionsIndexRef,\n        });\n      } catch (e) {\n        console.warn(`Could not load Codex sessions for project ${entry.name}:`, e.message);\n        project.codexSessions = [];\n      }\n      applyCustomSessionNames(project.codexSessions, 'codex');\n\n      // Also fetch Gemini sessions for this project (UI + CLI)\n      try {\n        const uiSessions = sessionManager.getProjectSessions(actualProjectDir) || [];\n        const cliSessions = await getGeminiCliSessions(actualProjectDir);\n        const uiIds = new Set(uiSessions.map(s => s.id));\n        const mergedGemini = [...uiSessions, ...cliSessions.filter(s => !uiIds.has(s.id))];\n        project.geminiSessions = mergedGemini;\n      } catch (e) {\n        console.warn(`Could not load Gemini sessions for project ${entry.name}:`, e.message);\n        project.geminiSessions = [];\n      }\n      applyCustomSessionNames(project.geminiSessions, 'gemini');\n\n      // Add TaskMaster detection\n      try {\n        const taskMasterResult = await detectTaskMasterFolder(actualProjectDir);\n        project.taskmaster = {\n          hasTaskmaster: taskMasterResult.hasTaskmaster,\n          hasEssentialFiles: taskMasterResult.hasEssentialFiles,\n          metadata: taskMasterResult.metadata,\n          status: taskMasterResult.hasTaskmaster && taskMasterResult.hasEssentialFiles ? 'configured' : 'not-configured'\n        };\n      } catch (e) {\n        console.warn(`Could not detect TaskMaster for project ${entry.name}:`, e.message);\n        project.taskmaster = {\n          hasTaskmaster: false,\n          hasEssentialFiles: false,\n          metadata: null,\n          status: 'error'\n        };\n      }\n\n      projects.push(project);\n    }\n  } catch (error) {\n    // If the directory doesn't exist (ENOENT), that's okay - just continue with empty projects\n    if (error.code !== 'ENOENT') {\n      console.error('Error reading projects directory:', error);\n    }\n    // Calculate total for manual projects only (no directories exist)\n    totalProjects = Object.entries(config)\n      .filter(([name, cfg]) => cfg.manuallyAdded)\n      .length;\n  }\n\n  // Add manually configured projects that don't exist as folders yet\n  for (const [projectName, projectConfig] of Object.entries(config)) {\n    if (!existingProjects.has(projectName) && projectConfig.manuallyAdded) {\n      processedProjects++;\n\n      // Emit progress for manual projects\n      if (progressCallback) {\n        progressCallback({\n          phase: 'loading',\n          current: processedProjects,\n          total: totalProjects,\n          currentProject: projectName\n        });\n      }\n\n      // Use the original path if available, otherwise extract from potential sessions\n      let actualProjectDir = projectConfig.originalPath;\n\n      if (!actualProjectDir) {\n        try {\n          actualProjectDir = await extractProjectDirectory(projectName);\n        } catch (error) {\n          // Fall back to decoded project name\n          actualProjectDir = projectName.replace(/-/g, '/');\n        }\n      }\n\n      const project = {\n        name: projectName,\n        path: actualProjectDir,\n        displayName: projectConfig.displayName || await generateDisplayName(projectName, actualProjectDir),\n        fullPath: actualProjectDir,\n        isCustomName: !!projectConfig.displayName,\n        isManuallyAdded: true,\n        sessions: [],\n        geminiSessions: [],\n        sessionMeta: {\n          hasMore: false,\n          total: 0\n        },\n        cursorSessions: [],\n        codexSessions: []\n      };\n\n      // Try to fetch Cursor sessions for manual projects too\n      try {\n        project.cursorSessions = await getCursorSessions(actualProjectDir);\n      } catch (e) {\n        console.warn(`Could not load Cursor sessions for manual project ${projectName}:`, e.message);\n      }\n      applyCustomSessionNames(project.cursorSessions, 'cursor');\n\n      // Try to fetch Codex sessions for manual projects too\n      try {\n        project.codexSessions = await getCodexSessions(actualProjectDir, {\n          indexRef: codexSessionsIndexRef,\n        });\n      } catch (e) {\n        console.warn(`Could not load Codex sessions for manual project ${projectName}:`, e.message);\n      }\n      applyCustomSessionNames(project.codexSessions, 'codex');\n\n      // Try to fetch Gemini sessions for manual projects too (UI + CLI)\n      try {\n        const uiSessions = sessionManager.getProjectSessions(actualProjectDir) || [];\n        const cliSessions = await getGeminiCliSessions(actualProjectDir);\n        const uiIds = new Set(uiSessions.map(s => s.id));\n        project.geminiSessions = [...uiSessions, ...cliSessions.filter(s => !uiIds.has(s.id))];\n      } catch (e) {\n        console.warn(`Could not load Gemini sessions for manual project ${projectName}:`, e.message);\n      }\n      applyCustomSessionNames(project.geminiSessions, 'gemini');\n\n      // Add TaskMaster detection for manual projects\n      try {\n        const taskMasterResult = await detectTaskMasterFolder(actualProjectDir);\n\n        // Determine TaskMaster status\n        let taskMasterStatus = 'not-configured';\n        if (taskMasterResult.hasTaskmaster && taskMasterResult.hasEssentialFiles) {\n          taskMasterStatus = 'taskmaster-only'; // We don't check MCP for manual projects in bulk\n        }\n\n        project.taskmaster = {\n          status: taskMasterStatus,\n          hasTaskmaster: taskMasterResult.hasTaskmaster,\n          hasEssentialFiles: taskMasterResult.hasEssentialFiles,\n          metadata: taskMasterResult.metadata\n        };\n      } catch (error) {\n        console.warn(`TaskMaster detection failed for manual project ${projectName}:`, error.message);\n        project.taskmaster = {\n          status: 'error',\n          hasTaskmaster: false,\n          hasEssentialFiles: false,\n          error: error.message\n        };\n      }\n\n      projects.push(project);\n    }\n  }\n\n  // Emit completion after all projects (including manual) are processed\n  if (progressCallback) {\n    progressCallback({\n      phase: 'complete',\n      current: totalProjects,\n      total: totalProjects\n    });\n  }\n\n  return projects;\n}\n\nasync function getSessions(projectName, limit = 5, offset = 0) {\n  const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);\n\n  try {\n    const files = await fs.readdir(projectDir);\n    // agent-*.jsonl files contain session start data at this point. This needs to be revisited\n    // periodically to make sure only accurate data is there and no new functionality is added there\n    const jsonlFiles = files.filter(file => file.endsWith('.jsonl') && !file.startsWith('agent-'));\n\n    if (jsonlFiles.length === 0) {\n      return { sessions: [], hasMore: false, total: 0 };\n    }\n\n    // Sort files by modification time (newest first)\n    const filesWithStats = await Promise.all(\n      jsonlFiles.map(async (file) => {\n        const filePath = path.join(projectDir, file);\n        const stats = await fs.stat(filePath);\n        return { file, mtime: stats.mtime };\n      })\n    );\n    filesWithStats.sort((a, b) => b.mtime - a.mtime);\n\n    const allSessions = new Map();\n    const allEntries = [];\n    const uuidToSessionMap = new Map();\n\n    // Collect all sessions and entries from all files\n    for (const { file } of filesWithStats) {\n      const jsonlFile = path.join(projectDir, file);\n      const result = await parseJsonlSessions(jsonlFile);\n\n      result.sessions.forEach(session => {\n        if (!allSessions.has(session.id)) {\n          allSessions.set(session.id, session);\n        }\n      });\n\n      allEntries.push(...result.entries);\n\n      // Early exit optimization for large projects\n      if (allSessions.size >= (limit + offset) * 2 && allEntries.length >= Math.min(3, filesWithStats.length)) {\n        break;\n      }\n    }\n\n    // Build UUID-to-session mapping for timeline detection\n    allEntries.forEach(entry => {\n      if (entry.uuid && entry.sessionId) {\n        uuidToSessionMap.set(entry.uuid, entry.sessionId);\n      }\n    });\n\n    // Group sessions by first user message ID\n    const sessionGroups = new Map(); // firstUserMsgId -> { latestSession, allSessions[] }\n    const sessionToFirstUserMsgId = new Map(); // sessionId -> firstUserMsgId\n\n    // Find the first user message for each session\n    allEntries.forEach(entry => {\n      if (entry.sessionId && entry.type === 'user' && entry.parentUuid === null && entry.uuid) {\n        // This is a first user message in a session (parentUuid is null)\n        const firstUserMsgId = entry.uuid;\n\n        if (!sessionToFirstUserMsgId.has(entry.sessionId)) {\n          sessionToFirstUserMsgId.set(entry.sessionId, firstUserMsgId);\n\n          const session = allSessions.get(entry.sessionId);\n          if (session) {\n            if (!sessionGroups.has(firstUserMsgId)) {\n              sessionGroups.set(firstUserMsgId, {\n                latestSession: session,\n                allSessions: [session]\n              });\n            } else {\n              const group = sessionGroups.get(firstUserMsgId);\n              group.allSessions.push(session);\n\n              // Update latest session if this one is more recent\n              if (new Date(session.lastActivity) > new Date(group.latestSession.lastActivity)) {\n                group.latestSession = session;\n              }\n            }\n          }\n        }\n      }\n    });\n\n    // Collect all sessions that don't belong to any group (standalone sessions)\n    const groupedSessionIds = new Set();\n    sessionGroups.forEach(group => {\n      group.allSessions.forEach(session => groupedSessionIds.add(session.id));\n    });\n\n    const standaloneSessionsArray = Array.from(allSessions.values())\n      .filter(session => !groupedSessionIds.has(session.id));\n\n    // Combine grouped sessions (only show latest from each group) + standalone sessions\n    const latestFromGroups = Array.from(sessionGroups.values()).map(group => {\n      const session = { ...group.latestSession };\n      // Add metadata about grouping\n      if (group.allSessions.length > 1) {\n        session.isGrouped = true;\n        session.groupSize = group.allSessions.length;\n        session.groupSessions = group.allSessions.map(s => s.id);\n      }\n      return session;\n    });\n    const visibleSessions = [...latestFromGroups, ...standaloneSessionsArray]\n      .filter(session => !session.summary.startsWith('{ \"'))\n      .sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity));\n\n    const total = visibleSessions.length;\n    const paginatedSessions = visibleSessions.slice(offset, offset + limit);\n    const hasMore = offset + limit < total;\n\n    return {\n      sessions: paginatedSessions,\n      hasMore,\n      total,\n      offset,\n      limit\n    };\n  } catch (error) {\n    console.error(`Error reading sessions for project ${projectName}:`, error);\n    return { sessions: [], hasMore: false, total: 0 };\n  }\n}\n\nasync function parseJsonlSessions(filePath) {\n  const sessions = new Map();\n  const entries = [];\n  const pendingSummaries = new Map(); // leafUuid -> summary for entries without sessionId\n\n  try {\n    const fileStream = fsSync.createReadStream(filePath);\n    const rl = readline.createInterface({\n      input: fileStream,\n      crlfDelay: Infinity\n    });\n\n    for await (const line of rl) {\n      if (line.trim()) {\n        try {\n          const entry = JSON.parse(line);\n          entries.push(entry);\n\n          // Handle summary entries that don't have sessionId yet\n          if (entry.type === 'summary' && entry.summary && !entry.sessionId && entry.leafUuid) {\n            pendingSummaries.set(entry.leafUuid, entry.summary);\n          }\n\n          if (entry.sessionId) {\n            if (!sessions.has(entry.sessionId)) {\n              sessions.set(entry.sessionId, {\n                id: entry.sessionId,\n                summary: 'New Session',\n                messageCount: 0,\n                lastActivity: new Date(),\n                cwd: entry.cwd || '',\n                lastUserMessage: null,\n                lastAssistantMessage: null\n              });\n            }\n\n            const session = sessions.get(entry.sessionId);\n\n            // Apply pending summary if this entry has a parentUuid that matches a pending summary\n            if (session.summary === 'New Session' && entry.parentUuid && pendingSummaries.has(entry.parentUuid)) {\n              session.summary = pendingSummaries.get(entry.parentUuid);\n            }\n\n            // Update summary from summary entries with sessionId\n            if (entry.type === 'summary' && entry.summary) {\n              session.summary = entry.summary;\n            }\n\n            // Track last user and assistant messages (skip system messages)\n            if (entry.message?.role === 'user' && entry.message?.content) {\n              const content = entry.message.content;\n\n              // Extract text from array format if needed\n              let textContent = content;\n              if (Array.isArray(content) && content.length > 0 && content[0].type === 'text') {\n                textContent = content[0].text;\n              }\n\n              const isSystemMessage = typeof textContent === 'string' && (\n                textContent.startsWith('<command-name>') ||\n                textContent.startsWith('<command-message>') ||\n                textContent.startsWith('<command-args>') ||\n                textContent.startsWith('<local-command-stdout>') ||\n                textContent.startsWith('<system-reminder>') ||\n                textContent.startsWith('Caveat:') ||\n                textContent.startsWith('This session is being continued from a previous') ||\n                textContent.startsWith('Invalid API key') ||\n                textContent.includes('{\"subtasks\":') || // Filter Task Master prompts\n                textContent.includes('CRITICAL: You MUST respond with ONLY a JSON') || // Filter Task Master system prompts\n                textContent === 'Warmup' // Explicitly filter out \"Warmup\"\n              );\n\n              if (typeof textContent === 'string' && textContent.length > 0 && !isSystemMessage) {\n                session.lastUserMessage = textContent;\n              }\n            } else if (entry.message?.role === 'assistant' && entry.message?.content) {\n              // Skip API error messages using the isApiErrorMessage flag\n              if (entry.isApiErrorMessage === true) {\n                // Skip this message entirely\n              } else {\n                // Track last assistant text message\n                let assistantText = null;\n\n                if (Array.isArray(entry.message.content)) {\n                  for (const part of entry.message.content) {\n                    if (part.type === 'text' && part.text) {\n                      assistantText = part.text;\n                    }\n                  }\n                } else if (typeof entry.message.content === 'string') {\n                  assistantText = entry.message.content;\n                }\n\n                // Additional filter for assistant messages with system content\n                const isSystemAssistantMessage = typeof assistantText === 'string' && (\n                  assistantText.startsWith('Invalid API key') ||\n                  assistantText.includes('{\"subtasks\":') ||\n                  assistantText.includes('CRITICAL: You MUST respond with ONLY a JSON')\n                );\n\n                if (assistantText && !isSystemAssistantMessage) {\n                  session.lastAssistantMessage = assistantText;\n                }\n              }\n            }\n\n            session.messageCount++;\n\n            if (entry.timestamp) {\n              session.lastActivity = new Date(entry.timestamp);\n            }\n          }\n        } catch (parseError) {\n          // Skip malformed lines silently\n        }\n      }\n    }\n\n    // After processing all entries, set final summary based on last message if no summary exists\n    for (const session of sessions.values()) {\n      if (session.summary === 'New Session') {\n        // Prefer last user message, fall back to last assistant message\n        const lastMessage = session.lastUserMessage || session.lastAssistantMessage;\n        if (lastMessage) {\n          session.summary = lastMessage.length > 50 ? lastMessage.substring(0, 50) + '...' : lastMessage;\n        }\n      }\n    }\n\n    // Filter out sessions that contain JSON responses (Task Master errors)\n    const allSessions = Array.from(sessions.values());\n    const filteredSessions = allSessions.filter(session => {\n      const shouldFilter = session.summary.startsWith('{ \"');\n      if (shouldFilter) {\n      }\n      // Log a sample of summaries to debug\n      if (Math.random() < 0.01) { // Log 1% of sessions\n      }\n      return !shouldFilter;\n    });\n\n\n    return {\n      sessions: filteredSessions,\n      entries: entries\n    };\n\n  } catch (error) {\n    console.error('Error reading JSONL file:', error);\n    return { sessions: [], entries: [] };\n  }\n}\n\n// Parse an agent JSONL file and extract tool uses\nasync function parseAgentTools(filePath) {\n  const tools = [];\n\n  try {\n    const fileStream = fsSync.createReadStream(filePath);\n    const rl = readline.createInterface({\n      input: fileStream,\n      crlfDelay: Infinity\n    });\n\n    for await (const line of rl) {\n      if (line.trim()) {\n        try {\n          const entry = JSON.parse(line);\n          // Look for assistant messages with tool_use\n          if (entry.message?.role === 'assistant' && Array.isArray(entry.message?.content)) {\n            for (const part of entry.message.content) {\n              if (part.type === 'tool_use') {\n                tools.push({\n                  toolId: part.id,\n                  toolName: part.name,\n                  toolInput: part.input,\n                  timestamp: entry.timestamp\n                });\n              }\n            }\n          }\n          // Look for tool results\n          if (entry.message?.role === 'user' && Array.isArray(entry.message?.content)) {\n            for (const part of entry.message.content) {\n              if (part.type === 'tool_result') {\n                // Find the matching tool and add result\n                const tool = tools.find(t => t.toolId === part.tool_use_id);\n                if (tool) {\n                  tool.toolResult = {\n                    content: typeof part.content === 'string' ? part.content :\n                      Array.isArray(part.content) ? part.content.map(c => c.text || '').join('\\n') :\n                        JSON.stringify(part.content),\n                    isError: Boolean(part.is_error)\n                  };\n                }\n              }\n            }\n          }\n        } catch (parseError) {\n          // Skip malformed lines\n        }\n      }\n    }\n  } catch (error) {\n    console.warn(`Error parsing agent file ${filePath}:`, error.message);\n  }\n\n  return tools;\n}\n\n// Get messages for a specific session with pagination support\nasync function getSessionMessages(projectName, sessionId, limit = null, offset = 0) {\n  const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);\n\n  try {\n    const files = await fs.readdir(projectDir);\n    // agent-*.jsonl files contain subagent tool history - we'll process them separately\n    const jsonlFiles = files.filter(file => file.endsWith('.jsonl') && !file.startsWith('agent-'));\n    const agentFiles = files.filter(file => file.endsWith('.jsonl') && file.startsWith('agent-'));\n\n    if (jsonlFiles.length === 0) {\n      return { messages: [], total: 0, hasMore: false };\n    }\n\n    const messages = [];\n    // Map of agentId -> tools for subagent tool grouping\n    const agentToolsCache = new Map();\n\n    // Process all JSONL files to find messages for this session\n    for (const file of jsonlFiles) {\n      const jsonlFile = path.join(projectDir, file);\n      const fileStream = fsSync.createReadStream(jsonlFile);\n      const rl = readline.createInterface({\n        input: fileStream,\n        crlfDelay: Infinity\n      });\n\n      for await (const line of rl) {\n        if (line.trim()) {\n          try {\n            const entry = JSON.parse(line);\n            if (entry.sessionId === sessionId) {\n              messages.push(entry);\n            }\n          } catch (parseError) {\n            // Silently skip malformed JSONL lines (common with concurrent writes)\n          }\n        }\n      }\n    }\n\n    // Collect agentIds from Task tool results\n    const agentIds = new Set();\n    for (const message of messages) {\n      if (message.toolUseResult?.agentId) {\n        agentIds.add(message.toolUseResult.agentId);\n      }\n    }\n\n    // Load agent tools for each agentId found\n    for (const agentId of agentIds) {\n      const agentFileName = `agent-${agentId}.jsonl`;\n      if (agentFiles.includes(agentFileName)) {\n        const agentFilePath = path.join(projectDir, agentFileName);\n        const tools = await parseAgentTools(agentFilePath);\n        agentToolsCache.set(agentId, tools);\n      }\n    }\n\n    // Attach agent tools to their parent Task messages\n    for (const message of messages) {\n      if (message.toolUseResult?.agentId) {\n        const agentId = message.toolUseResult.agentId;\n        const agentTools = agentToolsCache.get(agentId);\n        if (agentTools && agentTools.length > 0) {\n          message.subagentTools = agentTools;\n        }\n      }\n    }\n    // Sort messages by timestamp\n    const sortedMessages = messages.sort((a, b) =>\n      new Date(a.timestamp || 0) - new Date(b.timestamp || 0)\n    );\n\n    const total = sortedMessages.length;\n\n    // If no limit is specified, return all messages (backward compatibility)\n    if (limit === null) {\n      return sortedMessages;\n    }\n\n    // Apply pagination - for recent messages, we need to slice from the end\n    // offset 0 should give us the most recent messages\n    const startIndex = Math.max(0, total - offset - limit);\n    const endIndex = total - offset;\n    const paginatedMessages = sortedMessages.slice(startIndex, endIndex);\n    const hasMore = startIndex > 0;\n\n    return {\n      messages: paginatedMessages,\n      total,\n      hasMore,\n      offset,\n      limit\n    };\n  } catch (error) {\n    console.error(`Error reading messages for session ${sessionId}:`, error);\n    return limit === null ? [] : { messages: [], total: 0, hasMore: false };\n  }\n}\n\n// Rename a project's display name\nasync function renameProject(projectName, newDisplayName) {\n  const config = await loadProjectConfig();\n\n  if (!newDisplayName || newDisplayName.trim() === '') {\n    // Remove custom name if empty, will fall back to auto-generated\n    if (config[projectName]) {\n      delete config[projectName].displayName;\n    }\n  } else {\n    // Set custom display name, preserving other properties (manuallyAdded, originalPath)\n    config[projectName] = {\n      ...config[projectName],\n      displayName: newDisplayName.trim()\n    };\n  }\n\n  await saveProjectConfig(config);\n  return true;\n}\n\n// Delete a session from a project\nasync function deleteSession(projectName, sessionId) {\n  const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);\n\n  try {\n    const files = await fs.readdir(projectDir);\n    const jsonlFiles = files.filter(file => file.endsWith('.jsonl'));\n\n    if (jsonlFiles.length === 0) {\n      throw new Error('No session files found for this project');\n    }\n\n    // Check all JSONL files to find which one contains the session\n    for (const file of jsonlFiles) {\n      const jsonlFile = path.join(projectDir, file);\n      const content = await fs.readFile(jsonlFile, 'utf8');\n      const lines = content.split('\\n').filter(line => line.trim());\n\n      // Check if this file contains the session\n      const hasSession = lines.some(line => {\n        try {\n          const data = JSON.parse(line);\n          return data.sessionId === sessionId;\n        } catch {\n          return false;\n        }\n      });\n\n      if (hasSession) {\n        // Filter out all entries for this session\n        const filteredLines = lines.filter(line => {\n          try {\n            const data = JSON.parse(line);\n            return data.sessionId !== sessionId;\n          } catch {\n            return true; // Keep malformed lines\n          }\n        });\n\n        // Write back the filtered content\n        await fs.writeFile(jsonlFile, filteredLines.join('\\n') + (filteredLines.length > 0 ? '\\n' : ''));\n        return true;\n      }\n    }\n\n    throw new Error(`Session ${sessionId} not found in any files`);\n  } catch (error) {\n    console.error(`Error deleting session ${sessionId} from project ${projectName}:`, error);\n    throw error;\n  }\n}\n\n// Check if a project is empty (has no sessions)\nasync function isProjectEmpty(projectName) {\n  try {\n    const sessionsResult = await getSessions(projectName, 1, 0);\n    return sessionsResult.total === 0;\n  } catch (error) {\n    console.error(`Error checking if project ${projectName} is empty:`, error);\n    return false;\n  }\n}\n\n// Delete a project (force=true to delete even with sessions)\nasync function deleteProject(projectName, force = false) {\n  const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);\n\n  try {\n    const isEmpty = await isProjectEmpty(projectName);\n    if (!isEmpty && !force) {\n      throw new Error('Cannot delete project with existing sessions');\n    }\n\n    const config = await loadProjectConfig();\n    let projectPath = config[projectName]?.path || config[projectName]?.originalPath;\n\n    // Fallback to extractProjectDirectory if projectPath is not in config\n    if (!projectPath) {\n      projectPath = await extractProjectDirectory(projectName);\n    }\n\n    // Remove the project directory (includes all Claude sessions)\n    await fs.rm(projectDir, { recursive: true, force: true });\n\n    // Delete all Codex sessions associated with this project\n    if (projectPath) {\n      try {\n        const codexSessions = await getCodexSessions(projectPath, { limit: 0 });\n        for (const session of codexSessions) {\n          try {\n            await deleteCodexSession(session.id);\n          } catch (err) {\n            console.warn(`Failed to delete Codex session ${session.id}:`, err.message);\n          }\n        }\n      } catch (err) {\n        console.warn('Failed to delete Codex sessions:', err.message);\n      }\n\n      // Delete Cursor sessions directory if it exists\n      try {\n        const hash = crypto.createHash('md5').update(projectPath).digest('hex');\n        const cursorProjectDir = path.join(os.homedir(), '.cursor', 'chats', hash);\n        await fs.rm(cursorProjectDir, { recursive: true, force: true });\n      } catch (err) {\n        // Cursor dir may not exist, ignore\n      }\n    }\n\n    // Remove from project config\n    delete config[projectName];\n    await saveProjectConfig(config);\n\n    return true;\n  } catch (error) {\n    console.error(`Error deleting project ${projectName}:`, error);\n    throw error;\n  }\n}\n\n// Add a project manually to the config (without creating folders)\nasync function addProjectManually(projectPath, displayName = null) {\n  const absolutePath = path.resolve(projectPath);\n\n  try {\n    // Check if the path exists\n    await fs.access(absolutePath);\n  } catch (error) {\n    throw new Error(`Path does not exist: ${absolutePath}`);\n  }\n\n  // Generate project name (encode path for use as directory name)\n  const projectName = absolutePath.replace(/[\\\\/:\\s~_]/g, '-');\n\n  // Check if project already exists in config\n  const config = await loadProjectConfig();\n  const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);\n\n  if (config[projectName]) {\n    throw new Error(`Project already configured for path: ${absolutePath}`);\n  }\n\n  // Allow adding projects even if the directory exists - this enables tracking\n  // existing Claude Code or Cursor projects in the UI\n\n  // Add to config as manually added project\n  config[projectName] = {\n    manuallyAdded: true,\n    originalPath: absolutePath\n  };\n\n  if (displayName) {\n    config[projectName].displayName = displayName;\n  }\n\n  await saveProjectConfig(config);\n\n\n  return {\n    name: projectName,\n    path: absolutePath,\n    fullPath: absolutePath,\n    displayName: displayName || await generateDisplayName(projectName, absolutePath),\n    isManuallyAdded: true,\n    sessions: [],\n    cursorSessions: []\n  };\n}\n\n// Fetch Cursor sessions for a given project path\nasync function getCursorSessions(projectPath) {\n  try {\n    // Calculate cwdID hash for the project path (Cursor uses MD5 hash)\n    const cwdId = crypto.createHash('md5').update(projectPath).digest('hex');\n    const cursorChatsPath = path.join(os.homedir(), '.cursor', 'chats', cwdId);\n\n    // Check if the directory exists\n    try {\n      await fs.access(cursorChatsPath);\n    } catch (error) {\n      // No sessions for this project\n      return [];\n    }\n\n    // List all session directories\n    const sessionDirs = await fs.readdir(cursorChatsPath);\n    const sessions = [];\n\n    for (const sessionId of sessionDirs) {\n      const sessionPath = path.join(cursorChatsPath, sessionId);\n      const storeDbPath = path.join(sessionPath, 'store.db');\n\n      try {\n        // Check if store.db exists\n        await fs.access(storeDbPath);\n\n        // Capture store.db mtime as a reliable fallback timestamp\n        let dbStatMtimeMs = null;\n        try {\n          const stat = await fs.stat(storeDbPath);\n          dbStatMtimeMs = stat.mtimeMs;\n        } catch (_) { }\n\n        // Open SQLite database\n        const db = await open({\n          filename: storeDbPath,\n          driver: sqlite3.Database,\n          mode: sqlite3.OPEN_READONLY\n        });\n\n        // Get metadata from meta table\n        const metaRows = await db.all(`\n          SELECT key, value FROM meta\n        `);\n\n        // Parse metadata\n        let metadata = {};\n        for (const row of metaRows) {\n          if (row.value) {\n            try {\n              // Try to decode as hex-encoded JSON\n              const hexMatch = row.value.toString().match(/^[0-9a-fA-F]+$/);\n              if (hexMatch) {\n                const jsonStr = Buffer.from(row.value, 'hex').toString('utf8');\n                metadata[row.key] = JSON.parse(jsonStr);\n              } else {\n                metadata[row.key] = row.value.toString();\n              }\n            } catch (e) {\n              metadata[row.key] = row.value.toString();\n            }\n          }\n        }\n\n        // Get message count\n        const messageCountResult = await db.get(`\n          SELECT COUNT(*) as count FROM blobs\n        `);\n\n        await db.close();\n\n        // Extract session info\n        const sessionName = metadata.title || metadata.sessionTitle || 'Untitled Session';\n\n        // Determine timestamp - prefer createdAt from metadata, fall back to db file mtime\n        let createdAt = null;\n        if (metadata.createdAt) {\n          createdAt = new Date(metadata.createdAt).toISOString();\n        } else if (dbStatMtimeMs) {\n          createdAt = new Date(dbStatMtimeMs).toISOString();\n        } else {\n          createdAt = new Date().toISOString();\n        }\n\n        sessions.push({\n          id: sessionId,\n          name: sessionName,\n          createdAt: createdAt,\n          lastActivity: createdAt, // For compatibility with Claude sessions\n          messageCount: messageCountResult.count || 0,\n          projectPath: projectPath\n        });\n\n      } catch (error) {\n        console.warn(`Could not read Cursor session ${sessionId}:`, error.message);\n      }\n    }\n\n    // Sort sessions by creation time (newest first)\n    sessions.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));\n\n    // Return only the first 5 sessions for performance\n    return sessions.slice(0, 5);\n\n  } catch (error) {\n    console.error('Error fetching Cursor sessions:', error);\n    return [];\n  }\n}\n\n\nfunction normalizeComparablePath(inputPath) {\n  if (!inputPath || typeof inputPath !== 'string') {\n    return '';\n  }\n\n  const withoutLongPathPrefix = inputPath.startsWith('\\\\\\\\?\\\\')\n    ? inputPath.slice(4)\n    : inputPath;\n  const normalized = path.normalize(withoutLongPathPrefix.trim());\n\n  if (!normalized) {\n    return '';\n  }\n\n  const resolved = path.resolve(normalized);\n  return process.platform === 'win32' ? resolved.toLowerCase() : resolved;\n}\n\nasync function findCodexJsonlFiles(dir) {\n  const files = [];\n\n  try {\n    const entries = await fs.readdir(dir, { withFileTypes: true });\n    for (const entry of entries) {\n      const fullPath = path.join(dir, entry.name);\n      if (entry.isDirectory()) {\n        files.push(...await findCodexJsonlFiles(fullPath));\n      } else if (entry.name.endsWith('.jsonl')) {\n        files.push(fullPath);\n      }\n    }\n  } catch (error) {\n    // Skip directories we can't read\n  }\n\n  return files;\n}\n\nasync function buildCodexSessionsIndex() {\n  const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');\n  const sessionsByProject = new Map();\n\n  try {\n    await fs.access(codexSessionsDir);\n  } catch (error) {\n    return sessionsByProject;\n  }\n\n  const jsonlFiles = await findCodexJsonlFiles(codexSessionsDir);\n\n  for (const filePath of jsonlFiles) {\n    try {\n      const sessionData = await parseCodexSessionFile(filePath);\n      if (!sessionData || !sessionData.id) {\n        continue;\n      }\n\n      const normalizedProjectPath = normalizeComparablePath(sessionData.cwd);\n      if (!normalizedProjectPath) {\n        continue;\n      }\n\n      const session = {\n        id: sessionData.id,\n        summary: sessionData.summary || 'Codex Session',\n        messageCount: sessionData.messageCount || 0,\n        lastActivity: sessionData.timestamp ? new Date(sessionData.timestamp) : new Date(),\n        cwd: sessionData.cwd,\n        model: sessionData.model,\n        filePath,\n        provider: 'codex',\n      };\n\n      if (!sessionsByProject.has(normalizedProjectPath)) {\n        sessionsByProject.set(normalizedProjectPath, []);\n      }\n\n      sessionsByProject.get(normalizedProjectPath).push(session);\n    } catch (error) {\n      console.warn(`Could not parse Codex session file ${filePath}:`, error.message);\n    }\n  }\n\n  for (const sessions of sessionsByProject.values()) {\n    sessions.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity));\n  }\n\n  return sessionsByProject;\n}\n\n// Fetch Codex sessions for a given project path\nasync function getCodexSessions(projectPath, options = {}) {\n  const { limit = 5, indexRef = null } = options;\n  try {\n    const normalizedProjectPath = normalizeComparablePath(projectPath);\n    if (!normalizedProjectPath) {\n      return [];\n    }\n\n    if (indexRef && !indexRef.sessionsByProject) {\n      indexRef.sessionsByProject = await buildCodexSessionsIndex();\n    }\n\n    const sessionsByProject = indexRef?.sessionsByProject || await buildCodexSessionsIndex();\n    const sessions = sessionsByProject.get(normalizedProjectPath) || [];\n\n    // Return limited sessions for performance (0 = unlimited for deletion)\n    return limit > 0 ? sessions.slice(0, limit) : [...sessions];\n\n  } catch (error) {\n    console.error('Error fetching Codex sessions:', error);\n    return [];\n  }\n}\n\nfunction isVisibleCodexUserMessage(payload) {\n  if (!payload || payload.type !== 'user_message') {\n    return false;\n  }\n\n  // Codex logs internal context (environment, instructions) as non-plain user_message kinds.\n  if (payload.kind && payload.kind !== 'plain') {\n    return false;\n  }\n\n  if (typeof payload.message !== 'string' || payload.message.trim().length === 0) {\n    return false;\n  }\n  \n  return true;\n}\n\n// Parse a Codex session JSONL file to extract metadata\nasync function parseCodexSessionFile(filePath) {\n  try {\n    const fileStream = fsSync.createReadStream(filePath);\n    const rl = readline.createInterface({\n      input: fileStream,\n      crlfDelay: Infinity\n    });\n\n    let sessionMeta = null;\n    let lastTimestamp = null;\n    let lastUserMessage = null;\n    let messageCount = 0;\n\n    for await (const line of rl) {\n      if (line.trim()) {\n        try {\n          const entry = JSON.parse(line);\n\n          // Track timestamp\n          if (entry.timestamp) {\n            lastTimestamp = entry.timestamp;\n          }\n\n          // Extract session metadata\n          if (entry.type === 'session_meta' && entry.payload) {\n            sessionMeta = {\n              id: entry.payload.id,\n              cwd: entry.payload.cwd,\n              model: entry.payload.model || entry.payload.model_provider,\n              timestamp: entry.timestamp,\n              git: entry.payload.git\n            };\n          }\n\n          // Count visible user messages and extract summary from the latest plain user input.\n          if (entry.type === 'event_msg' && isVisibleCodexUserMessage(entry.payload)) {\n            messageCount++;\n            if (entry.payload.message) {\n              lastUserMessage = entry.payload.message;\n            }\n          }\n\n          if (entry.type === 'response_item' && entry.payload?.type === 'message' && entry.payload.role === 'assistant') {\n            messageCount++;\n          }\n\n        } catch (parseError) {\n          // Skip malformed lines\n        }\n      }\n    }\n\n    if (sessionMeta) {\n      return {\n        ...sessionMeta,\n        timestamp: lastTimestamp || sessionMeta.timestamp,\n        summary: lastUserMessage ?\n          (lastUserMessage.length > 50 ? lastUserMessage.substring(0, 50) + '...' : lastUserMessage) :\n          'Codex Session',\n        messageCount\n      };\n    }\n\n    return null;\n\n  } catch (error) {\n    console.error('Error parsing Codex session file:', error);\n    return null;\n  }\n}\n\n// Get messages for a specific Codex session\nasync function getCodexSessionMessages(sessionId, limit = null, offset = 0) {\n  try {\n    const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');\n\n    // Find the session file by searching for the session ID\n    const findSessionFile = async (dir) => {\n      try {\n        const entries = await fs.readdir(dir, { withFileTypes: true });\n        for (const entry of entries) {\n          const fullPath = path.join(dir, entry.name);\n          if (entry.isDirectory()) {\n            const found = await findSessionFile(fullPath);\n            if (found) return found;\n          } else if (entry.name.includes(sessionId) && entry.name.endsWith('.jsonl')) {\n            return fullPath;\n          }\n        }\n      } catch (error) {\n        // Skip directories we can't read\n      }\n      return null;\n    };\n\n    const sessionFilePath = await findSessionFile(codexSessionsDir);\n\n    if (!sessionFilePath) {\n      console.warn(`Codex session file not found for session ${sessionId}`);\n      return { messages: [], total: 0, hasMore: false };\n    }\n\n    const messages = [];\n    let tokenUsage = null;\n    const fileStream = fsSync.createReadStream(sessionFilePath);\n    const rl = readline.createInterface({\n      input: fileStream,\n      crlfDelay: Infinity\n    });\n\n    // Helper to extract text from Codex content array\n    const extractText = (content) => {\n      if (!Array.isArray(content)) return content;\n      return content\n        .map(item => {\n          if (item.type === 'input_text' || item.type === 'output_text') {\n            return item.text;\n          }\n          if (item.type === 'text') {\n            return item.text;\n          }\n          return '';\n        })\n        .filter(Boolean)\n        .join('\\n');\n    };\n\n    for await (const line of rl) {\n      if (line.trim()) {\n        try {\n          const entry = JSON.parse(line);\n\n          // Extract token usage from token_count events (keep latest)\n          if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {\n            const info = entry.payload.info;\n            if (info.total_token_usage) {\n              tokenUsage = {\n                used: info.total_token_usage.total_tokens || 0,\n                total: info.model_context_window || 200000\n              };\n            }\n          }\n          \n          // Use event_msg.user_message for user-visible inputs.\n          if (entry.type === 'event_msg' && isVisibleCodexUserMessage(entry.payload)) {\n            messages.push({\n              type: 'user',\n              timestamp: entry.timestamp,\n              message: {\n                role: 'user',\n                content: entry.payload.message\n              }\n            });\n          }\n\n          // response_item.message may include internal prompts for non-assistant roles.\n          // Keep only assistant output from response_item.\n          if (\n            entry.type === 'response_item' &&\n            entry.payload?.type === 'message' &&\n            entry.payload.role === 'assistant'\n          ) {\n            const content = entry.payload.content;\n            const textContent = extractText(content);\n\n            // Only add if there's actual content\n            if (textContent?.trim()) {\n              messages.push({\n                type: 'assistant',\n                timestamp: entry.timestamp,\n                message: {\n                  role: 'assistant',\n                  content: textContent\n                }\n              });\n            }\n          }\n\n          if (entry.type === 'response_item' && entry.payload?.type === 'reasoning') {\n            const summaryText = entry.payload.summary\n              ?.map(s => s.text)\n              .filter(Boolean)\n              .join('\\n');\n            if (summaryText?.trim()) {\n              messages.push({\n                type: 'thinking',\n                timestamp: entry.timestamp,\n                message: {\n                  role: 'assistant',\n                  content: summaryText\n                }\n              });\n            }\n          }\n\n          if (entry.type === 'response_item' && entry.payload?.type === 'function_call') {\n            let toolName = entry.payload.name;\n            let toolInput = entry.payload.arguments;\n\n            // Map Codex tool names to Claude equivalents\n            if (toolName === 'shell_command') {\n              toolName = 'Bash';\n              try {\n                const args = JSON.parse(entry.payload.arguments);\n                toolInput = JSON.stringify({ command: args.command });\n              } catch (e) {\n                // Keep original if parsing fails\n              }\n            }\n\n            messages.push({\n              type: 'tool_use',\n              timestamp: entry.timestamp,\n              toolName: toolName,\n              toolInput: toolInput,\n              toolCallId: entry.payload.call_id\n            });\n          }\n\n          if (entry.type === 'response_item' && entry.payload?.type === 'function_call_output') {\n            messages.push({\n              type: 'tool_result',\n              timestamp: entry.timestamp,\n              toolCallId: entry.payload.call_id,\n              output: entry.payload.output\n            });\n          }\n\n          if (entry.type === 'response_item' && entry.payload?.type === 'custom_tool_call') {\n            const toolName = entry.payload.name || 'custom_tool';\n            const input = entry.payload.input || '';\n\n            if (toolName === 'apply_patch') {\n              // Parse Codex patch format and convert to Claude Edit format\n              const fileMatch = input.match(/\\*\\*\\* Update File: (.+)/);\n              const filePath = fileMatch ? fileMatch[1].trim() : 'unknown';\n\n              // Extract old and new content from patch\n              const lines = input.split('\\n');\n              const oldLines = [];\n              const newLines = [];\n\n              for (const line of lines) {\n                if (line.startsWith('-') && !line.startsWith('---')) {\n                  oldLines.push(line.substring(1));\n                } else if (line.startsWith('+') && !line.startsWith('+++')) {\n                  newLines.push(line.substring(1));\n                }\n              }\n\n              messages.push({\n                type: 'tool_use',\n                timestamp: entry.timestamp,\n                toolName: 'Edit',\n                toolInput: JSON.stringify({\n                  file_path: filePath,\n                  old_string: oldLines.join('\\n'),\n                  new_string: newLines.join('\\n')\n                }),\n                toolCallId: entry.payload.call_id\n              });\n            } else {\n              messages.push({\n                type: 'tool_use',\n                timestamp: entry.timestamp,\n                toolName: toolName,\n                toolInput: input,\n                toolCallId: entry.payload.call_id\n              });\n            }\n          }\n\n          if (entry.type === 'response_item' && entry.payload?.type === 'custom_tool_call_output') {\n            messages.push({\n              type: 'tool_result',\n              timestamp: entry.timestamp,\n              toolCallId: entry.payload.call_id,\n              output: entry.payload.output || ''\n            });\n          }\n\n        } catch (parseError) {\n          // Skip malformed lines\n        }\n      }\n    }\n\n    // Sort by timestamp\n    messages.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0));\n\n    const total = messages.length;\n\n    // Apply pagination if limit is specified\n    if (limit !== null) {\n      const startIndex = Math.max(0, total - offset - limit);\n      const endIndex = total - offset;\n      const paginatedMessages = messages.slice(startIndex, endIndex);\n      const hasMore = startIndex > 0;\n\n      return {\n        messages: paginatedMessages,\n        total,\n        hasMore,\n        offset,\n        limit,\n        tokenUsage\n      };\n    }\n\n    return { messages, tokenUsage };\n\n  } catch (error) {\n    console.error(`Error reading Codex session messages for ${sessionId}:`, error);\n    return { messages: [], total: 0, hasMore: false };\n  }\n}\n\nasync function deleteCodexSession(sessionId) {\n  try {\n    const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');\n\n    const findJsonlFiles = async (dir) => {\n      const files = [];\n      try {\n        const entries = await fs.readdir(dir, { withFileTypes: true });\n        for (const entry of entries) {\n          const fullPath = path.join(dir, entry.name);\n          if (entry.isDirectory()) {\n            files.push(...await findJsonlFiles(fullPath));\n          } else if (entry.name.endsWith('.jsonl')) {\n            files.push(fullPath);\n          }\n        }\n      } catch (error) { }\n      return files;\n    };\n\n    const jsonlFiles = await findJsonlFiles(codexSessionsDir);\n\n    for (const filePath of jsonlFiles) {\n      const sessionData = await parseCodexSessionFile(filePath);\n      if (sessionData && sessionData.id === sessionId) {\n        await fs.unlink(filePath);\n        return true;\n      }\n    }\n\n    throw new Error(`Codex session file not found for session ${sessionId}`);\n  } catch (error) {\n    console.error(`Error deleting Codex session ${sessionId}:`, error);\n    throw error;\n  }\n}\n\nasync function searchConversations(query, limit = 50, onProjectResult = null, signal = null) {\n  const safeQuery = typeof query === 'string' ? query.trim() : '';\n  const safeLimit = Math.max(1, Math.min(Number.isFinite(limit) ? limit : 50, 200));\n  const claudeDir = path.join(os.homedir(), '.claude', 'projects');\n  const config = await loadProjectConfig();\n  const results = [];\n  let totalMatches = 0;\n  const words = safeQuery.toLowerCase().split(/\\s+/).filter(w => w.length > 0);\n  if (words.length === 0) return { results: [], totalMatches: 0, query: safeQuery };\n\n  const isAborted = () => signal?.aborted === true;\n\n  const isSystemMessage = (textContent) => {\n    return typeof textContent === 'string' && (\n      textContent.startsWith('<command-name>') ||\n      textContent.startsWith('<command-message>') ||\n      textContent.startsWith('<command-args>') ||\n      textContent.startsWith('<local-command-stdout>') ||\n      textContent.startsWith('<system-reminder>') ||\n      textContent.startsWith('Caveat:') ||\n      textContent.startsWith('This session is being continued from a previous') ||\n      textContent.startsWith('Invalid API key') ||\n      textContent.includes('{\"subtasks\":') ||\n      textContent.includes('CRITICAL: You MUST respond with ONLY a JSON') ||\n      textContent === 'Warmup'\n    );\n  };\n\n  const extractText = (content) => {\n    if (typeof content === 'string') return content;\n    if (Array.isArray(content)) {\n      return content\n        .filter(part => part.type === 'text' && part.text)\n        .map(part => part.text)\n        .join(' ');\n    }\n    return '';\n  };\n\n  const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n  const wordPatterns = words.map(w => new RegExp(`(?<!\\\\p{L})${escapeRegex(w)}(?!\\\\p{L})`, 'u'));\n  const allWordsMatch = (textLower) => {\n    return wordPatterns.every(p => p.test(textLower));\n  };\n\n  const buildSnippet = (text, textLower, snippetLen = 150) => {\n    let firstIndex = -1;\n    let firstWordLen = 0;\n    for (const w of words) {\n      const re = new RegExp(`(?<!\\\\p{L})${escapeRegex(w)}(?!\\\\p{L})`, 'u');\n      const m = re.exec(textLower);\n      if (m && (firstIndex === -1 || m.index < firstIndex)) {\n        firstIndex = m.index;\n        firstWordLen = w.length;\n      }\n    }\n    if (firstIndex === -1) firstIndex = 0;\n    const halfLen = Math.floor(snippetLen / 2);\n    let start = Math.max(0, firstIndex - halfLen);\n    let end = Math.min(text.length, firstIndex + halfLen + firstWordLen);\n    let snippet = text.slice(start, end).replace(/\\n/g, ' ');\n    const prefix = start > 0 ? '...' : '';\n    const suffix = end < text.length ? '...' : '';\n    snippet = prefix + snippet + suffix;\n    const snippetLower = snippet.toLowerCase();\n    const highlights = [];\n    for (const word of words) {\n      const re = new RegExp(`(?<!\\\\p{L})${escapeRegex(word)}(?!\\\\p{L})`, 'gu');\n      let match;\n      while ((match = re.exec(snippetLower)) !== null) {\n        highlights.push({ start: match.index, end: match.index + word.length });\n      }\n    }\n    highlights.sort((a, b) => a.start - b.start);\n    const merged = [];\n    for (const h of highlights) {\n      const last = merged[merged.length - 1];\n      if (last && h.start <= last.end) {\n        last.end = Math.max(last.end, h.end);\n      } else {\n        merged.push({ ...h });\n      }\n    }\n    return { snippet, highlights: merged };\n  };\n\n  try {\n    await fs.access(claudeDir);\n    const entries = await fs.readdir(claudeDir, { withFileTypes: true });\n    const projectDirs = entries.filter(e => e.isDirectory());\n    let scannedProjects = 0;\n    const totalProjects = projectDirs.length;\n\n    for (const projectEntry of projectDirs) {\n      if (totalMatches >= safeLimit || isAborted()) break;\n\n      const projectName = projectEntry.name;\n      const projectDir = path.join(claudeDir, projectName);\n      const displayName = config[projectName]?.displayName\n        || await generateDisplayName(projectName);\n\n      let files;\n      try {\n        files = await fs.readdir(projectDir);\n      } catch {\n        continue;\n      }\n\n      const jsonlFiles = files.filter(\n        file => file.endsWith('.jsonl') && !file.startsWith('agent-')\n      );\n\n      const projectResult = {\n        projectName,\n        projectDisplayName: displayName,\n        sessions: []\n      };\n\n      for (const file of jsonlFiles) {\n        if (totalMatches >= safeLimit || isAborted()) break;\n\n        const filePath = path.join(projectDir, file);\n        const sessionMatches = new Map();\n        const sessionSummaries = new Map();\n        const pendingSummaries = new Map();\n        const sessionLastMessages = new Map();\n        let currentSessionId = null;\n\n        try {\n          const fileStream = fsSync.createReadStream(filePath);\n          const rl = readline.createInterface({\n            input: fileStream,\n            crlfDelay: Infinity\n          });\n\n          for await (const line of rl) {\n            if (totalMatches >= safeLimit || isAborted()) break;\n            if (!line.trim()) continue;\n\n            let entry;\n            try {\n              entry = JSON.parse(line);\n            } catch {\n              continue;\n            }\n\n            if (entry.sessionId) {\n              currentSessionId = entry.sessionId;\n            }\n            if (entry.type === 'summary' && entry.summary) {\n              const sid = entry.sessionId || currentSessionId;\n              if (sid) {\n                sessionSummaries.set(sid, entry.summary);\n              } else if (entry.leafUuid) {\n                pendingSummaries.set(entry.leafUuid, entry.summary);\n              }\n            }\n\n            // Apply pending summary via parentUuid\n            if (entry.parentUuid && currentSessionId && !sessionSummaries.has(currentSessionId)) {\n              const pending = pendingSummaries.get(entry.parentUuid);\n              if (pending) sessionSummaries.set(currentSessionId, pending);\n            }\n\n            // Track last user/assistant message for fallback title\n            if (entry.message?.content && currentSessionId && !entry.isApiErrorMessage) {\n              const role = entry.message.role;\n              if (role === 'user' || role === 'assistant') {\n                const text = extractText(entry.message.content);\n                if (text && !isSystemMessage(text)) {\n                  if (!sessionLastMessages.has(currentSessionId)) {\n                    sessionLastMessages.set(currentSessionId, {});\n                  }\n                  const msgs = sessionLastMessages.get(currentSessionId);\n                  if (role === 'user') msgs.user = text;\n                  else msgs.assistant = text;\n                }\n              }\n            }\n\n            if (!entry.message?.content) continue;\n            if (entry.message.role !== 'user' && entry.message.role !== 'assistant') continue;\n            if (entry.isApiErrorMessage) continue;\n\n            const text = extractText(entry.message.content);\n            if (!text || isSystemMessage(text)) continue;\n\n            const textLower = text.toLowerCase();\n            if (!allWordsMatch(textLower)) continue;\n\n            const sessionId = entry.sessionId || currentSessionId || file.replace('.jsonl', '');\n            if (!sessionMatches.has(sessionId)) {\n              sessionMatches.set(sessionId, []);\n            }\n\n            const matches = sessionMatches.get(sessionId);\n            if (matches.length < 2) {\n              const { snippet, highlights } = buildSnippet(text, textLower);\n              matches.push({\n                role: entry.message.role,\n                snippet,\n                highlights,\n                timestamp: entry.timestamp || null,\n                provider: 'claude',\n                messageUuid: entry.uuid || null\n              });\n              totalMatches++;\n            }\n          }\n        } catch {\n          continue;\n        }\n\n        for (const [sessionId, matches] of sessionMatches) {\n          projectResult.sessions.push({\n            sessionId,\n            provider: 'claude',\n            sessionSummary: sessionSummaries.get(sessionId) || (() => {\n              const msgs = sessionLastMessages.get(sessionId);\n              const lastMsg = msgs?.user || msgs?.assistant;\n              return lastMsg ? (lastMsg.length > 50 ? lastMsg.substring(0, 50) + '...' : lastMsg) : 'New Session';\n            })(),\n            matches\n          });\n        }\n      }\n\n      // Search Codex sessions for this project\n      try {\n        const actualProjectDir = await extractProjectDirectory(projectName);\n        if (actualProjectDir && !isAborted() && totalMatches < safeLimit) {\n          await searchCodexSessionsForProject(\n            actualProjectDir, projectResult, words, allWordsMatch, extractText, isSystemMessage,\n            buildSnippet, safeLimit, () => totalMatches, (n) => { totalMatches += n; }, isAborted\n          );\n        }\n      } catch {\n        // Skip codex search errors\n      }\n\n      // Search Gemini sessions for this project\n      try {\n        const actualProjectDir = await extractProjectDirectory(projectName);\n        if (actualProjectDir && !isAborted() && totalMatches < safeLimit) {\n          await searchGeminiSessionsForProject(\n            actualProjectDir, projectResult, words, allWordsMatch,\n            buildSnippet, safeLimit, () => totalMatches, (n) => { totalMatches += n; }\n          );\n        }\n      } catch {\n        // Skip gemini search errors\n      }\n\n      scannedProjects++;\n      if (projectResult.sessions.length > 0) {\n        results.push(projectResult);\n        if (onProjectResult) {\n          onProjectResult({ projectResult, totalMatches, scannedProjects, totalProjects });\n        }\n      } else if (onProjectResult && scannedProjects % 10 === 0) {\n        onProjectResult({ projectResult: null, totalMatches, scannedProjects, totalProjects });\n      }\n    }\n  } catch {\n    // claudeDir doesn't exist\n  }\n\n  return { results, totalMatches, query: safeQuery };\n}\n\nasync function searchCodexSessionsForProject(\n  projectPath, projectResult, words, allWordsMatch, extractText, isSystemMessage,\n  buildSnippet, limit, getTotalMatches, addMatches, isAborted\n) {\n  const normalizedProjectPath = normalizeComparablePath(projectPath);\n  if (!normalizedProjectPath) return;\n  const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');\n  try {\n    await fs.access(codexSessionsDir);\n  } catch {\n    return;\n  }\n\n  const jsonlFiles = await findCodexJsonlFiles(codexSessionsDir);\n\n  for (const filePath of jsonlFiles) {\n    if (getTotalMatches() >= limit || isAborted()) break;\n\n    try {\n      const fileStream = fsSync.createReadStream(filePath);\n      const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });\n\n      // First pass: read session_meta to check project path match\n      let sessionMeta = null;\n      for await (const line of rl) {\n        if (!line.trim()) continue;\n        try {\n          const entry = JSON.parse(line);\n          if (entry.type === 'session_meta' && entry.payload) {\n            sessionMeta = entry.payload;\n            break;\n          }\n        } catch { continue; }\n      }\n\n      // Skip sessions that don't belong to this project\n      if (!sessionMeta) continue;\n      const sessionProjectPath = normalizeComparablePath(sessionMeta.cwd);\n      if (sessionProjectPath !== normalizedProjectPath) continue;\n\n      // Second pass: re-read file to find matching messages\n      const fileStream2 = fsSync.createReadStream(filePath);\n      const rl2 = readline.createInterface({ input: fileStream2, crlfDelay: Infinity });\n      let lastUserMessage = null;\n      const matches = [];\n\n      for await (const line of rl2) {\n        if (getTotalMatches() >= limit || isAborted()) break;\n        if (!line.trim()) continue;\n\n        let entry;\n        try { entry = JSON.parse(line); } catch { continue; }\n\n        let text = null;\n        let role = null;\n\n        if (entry.type === 'event_msg' && entry.payload?.type === 'user_message' && entry.payload.message) {\n          text = entry.payload.message;\n          role = 'user';\n          lastUserMessage = text;\n        } else if (entry.type === 'response_item' && entry.payload?.type === 'message') {\n          const contentParts = entry.payload.content || [];\n          if (entry.payload.role === 'user') {\n            text = contentParts\n              .filter(p => p.type === 'input_text' && p.text)\n              .map(p => p.text)\n              .join(' ');\n            role = 'user';\n            if (text) lastUserMessage = text;\n          } else if (entry.payload.role === 'assistant') {\n            text = contentParts\n              .filter(p => p.type === 'output_text' && p.text)\n              .map(p => p.text)\n              .join(' ');\n            role = 'assistant';\n          }\n        }\n\n        if (!text || !role) continue;\n        const textLower = text.toLowerCase();\n        if (!allWordsMatch(textLower)) continue;\n\n        if (matches.length < 2) {\n          const { snippet, highlights } = buildSnippet(text, textLower);\n          matches.push({ role, snippet, highlights, timestamp: entry.timestamp || null, provider: 'codex' });\n          addMatches(1);\n        }\n      }\n\n      if (matches.length > 0) {\n        projectResult.sessions.push({\n          sessionId: sessionMeta.id,\n          provider: 'codex',\n          sessionSummary: lastUserMessage\n            ? (lastUserMessage.length > 50 ? lastUserMessage.substring(0, 50) + '...' : lastUserMessage)\n            : 'Codex Session',\n          matches\n        });\n      }\n    } catch {\n      continue;\n    }\n  }\n}\n\nasync function searchGeminiSessionsForProject(\n  projectPath, projectResult, words, allWordsMatch,\n  buildSnippet, limit, getTotalMatches, addMatches\n) {\n  // 1) Search in-memory sessions (created via UI)\n  for (const [sessionId, session] of sessionManager.sessions) {\n    if (getTotalMatches() >= limit) break;\n    if (session.projectPath !== projectPath) continue;\n\n    const matches = [];\n    for (const msg of session.messages) {\n      if (getTotalMatches() >= limit) break;\n      if (msg.role !== 'user' && msg.role !== 'assistant') continue;\n\n      const text = typeof msg.content === 'string' ? msg.content\n        : Array.isArray(msg.content) ? msg.content.filter(p => p.type === 'text').map(p => p.text).join(' ')\n        : '';\n      if (!text) continue;\n\n      const textLower = text.toLowerCase();\n      if (!allWordsMatch(textLower)) continue;\n\n      if (matches.length < 2) {\n        const { snippet, highlights } = buildSnippet(text, textLower);\n        matches.push({\n          role: msg.role, snippet, highlights,\n          timestamp: msg.timestamp ? msg.timestamp.toISOString() : null,\n          provider: 'gemini'\n        });\n        addMatches(1);\n      }\n    }\n\n    if (matches.length > 0) {\n      const firstUserMsg = session.messages.find(m => m.role === 'user');\n      const summary = firstUserMsg?.content\n        ? (typeof firstUserMsg.content === 'string'\n          ? (firstUserMsg.content.length > 50 ? firstUserMsg.content.substring(0, 50) + '...' : firstUserMsg.content)\n          : 'Gemini Session')\n        : 'Gemini Session';\n\n      projectResult.sessions.push({\n        sessionId,\n        provider: 'gemini',\n        sessionSummary: summary,\n        matches\n      });\n    }\n  }\n\n  // 2) Search Gemini CLI sessions on disk (~/.gemini/tmp/<project>/chats/*.json)\n  const normalizedProjectPath = normalizeComparablePath(projectPath);\n  if (!normalizedProjectPath) return;\n\n  const geminiTmpDir = path.join(os.homedir(), '.gemini', 'tmp');\n  try {\n    await fs.access(geminiTmpDir);\n  } catch {\n    return;\n  }\n\n  const trackedSessionIds = new Set();\n  for (const [sid] of sessionManager.sessions) {\n    trackedSessionIds.add(sid);\n  }\n\n  let projectDirs;\n  try {\n    projectDirs = await fs.readdir(geminiTmpDir);\n  } catch {\n    return;\n  }\n\n  for (const projectDir of projectDirs) {\n    if (getTotalMatches() >= limit) break;\n\n    const projectRootFile = path.join(geminiTmpDir, projectDir, '.project_root');\n    let projectRoot;\n    try {\n      projectRoot = (await fs.readFile(projectRootFile, 'utf8')).trim();\n    } catch {\n      continue;\n    }\n\n    if (normalizeComparablePath(projectRoot) !== normalizedProjectPath) continue;\n\n    const chatsDir = path.join(geminiTmpDir, projectDir, 'chats');\n    let chatFiles;\n    try {\n      chatFiles = await fs.readdir(chatsDir);\n    } catch {\n      continue;\n    }\n\n    for (const chatFile of chatFiles) {\n      if (getTotalMatches() >= limit) break;\n      if (!chatFile.endsWith('.json')) continue;\n\n      try {\n        const filePath = path.join(chatsDir, chatFile);\n        const data = await fs.readFile(filePath, 'utf8');\n        const session = JSON.parse(data);\n        if (!session.messages || !Array.isArray(session.messages)) continue;\n\n        const cliSessionId = session.sessionId || chatFile.replace('.json', '');\n        if (trackedSessionIds.has(cliSessionId)) continue;\n\n        const matches = [];\n        let firstUserText = null;\n\n        for (const msg of session.messages) {\n          if (getTotalMatches() >= limit) break;\n\n          const role = msg.type === 'user' ? 'user'\n            : (msg.type === 'gemini' || msg.type === 'assistant') ? 'assistant'\n            : null;\n          if (!role) continue;\n\n          let text = '';\n          if (typeof msg.content === 'string') {\n            text = msg.content;\n          } else if (Array.isArray(msg.content)) {\n            text = msg.content\n              .filter(p => p.text)\n              .map(p => p.text)\n              .join(' ');\n          }\n          if (!text) continue;\n\n          if (role === 'user' && !firstUserText) firstUserText = text;\n\n          const textLower = text.toLowerCase();\n          if (!allWordsMatch(textLower)) continue;\n\n          if (matches.length < 2) {\n            const { snippet, highlights } = buildSnippet(text, textLower);\n            matches.push({\n              role, snippet, highlights,\n              timestamp: msg.timestamp || null,\n              provider: 'gemini'\n            });\n            addMatches(1);\n          }\n        }\n\n        if (matches.length > 0) {\n          const summary = firstUserText\n            ? (firstUserText.length > 50 ? firstUserText.substring(0, 50) + '...' : firstUserText)\n            : 'Gemini CLI Session';\n\n          projectResult.sessions.push({\n            sessionId: cliSessionId,\n            provider: 'gemini',\n            sessionSummary: summary,\n            matches\n          });\n        }\n      } catch {\n        continue;\n      }\n    }\n  }\n}\n\nasync function getGeminiCliSessions(projectPath) {\n  const normalizedProjectPath = normalizeComparablePath(projectPath);\n  if (!normalizedProjectPath) return [];\n\n  const geminiTmpDir = path.join(os.homedir(), '.gemini', 'tmp');\n  try {\n    await fs.access(geminiTmpDir);\n  } catch {\n    return [];\n  }\n\n  const sessions = [];\n  let projectDirs;\n  try {\n    projectDirs = await fs.readdir(geminiTmpDir);\n  } catch {\n    return [];\n  }\n\n  for (const projectDir of projectDirs) {\n    const projectRootFile = path.join(geminiTmpDir, projectDir, '.project_root');\n    let projectRoot;\n    try {\n      projectRoot = (await fs.readFile(projectRootFile, 'utf8')).trim();\n    } catch {\n      continue;\n    }\n\n    if (normalizeComparablePath(projectRoot) !== normalizedProjectPath) continue;\n\n    const chatsDir = path.join(geminiTmpDir, projectDir, 'chats');\n    let chatFiles;\n    try {\n      chatFiles = await fs.readdir(chatsDir);\n    } catch {\n      continue;\n    }\n\n    for (const chatFile of chatFiles) {\n      if (!chatFile.endsWith('.json')) continue;\n      try {\n        const filePath = path.join(chatsDir, chatFile);\n        const data = await fs.readFile(filePath, 'utf8');\n        const session = JSON.parse(data);\n        if (!session.messages || !Array.isArray(session.messages)) continue;\n\n        const sessionId = session.sessionId || chatFile.replace('.json', '');\n        const firstUserMsg = session.messages.find(m => m.type === 'user');\n        let summary = 'Gemini CLI Session';\n        if (firstUserMsg) {\n          const text = Array.isArray(firstUserMsg.content)\n            ? firstUserMsg.content.filter(p => p.text).map(p => p.text).join(' ')\n            : (typeof firstUserMsg.content === 'string' ? firstUserMsg.content : '');\n          if (text) {\n            summary = text.length > 50 ? text.substring(0, 50) + '...' : text;\n          }\n        }\n\n        sessions.push({\n          id: sessionId,\n          summary,\n          messageCount: session.messages.length,\n          lastActivity: session.lastUpdated || session.startTime || null,\n          provider: 'gemini'\n        });\n      } catch {\n        continue;\n      }\n    }\n  }\n\n  return sessions.sort((a, b) =>\n    new Date(b.lastActivity || 0) - new Date(a.lastActivity || 0)\n  );\n}\n\nasync function getGeminiCliSessionMessages(sessionId) {\n  const geminiTmpDir = path.join(os.homedir(), '.gemini', 'tmp');\n  let projectDirs;\n  try {\n    projectDirs = await fs.readdir(geminiTmpDir);\n  } catch {\n    return [];\n  }\n\n  for (const projectDir of projectDirs) {\n    const chatsDir = path.join(geminiTmpDir, projectDir, 'chats');\n    let chatFiles;\n    try {\n      chatFiles = await fs.readdir(chatsDir);\n    } catch {\n      continue;\n    }\n\n    for (const chatFile of chatFiles) {\n      if (!chatFile.endsWith('.json')) continue;\n      try {\n        const filePath = path.join(chatsDir, chatFile);\n        const data = await fs.readFile(filePath, 'utf8');\n        const session = JSON.parse(data);\n        const fileSessionId = session.sessionId || chatFile.replace('.json', '');\n        if (fileSessionId !== sessionId) continue;\n\n        return (session.messages || []).map(msg => {\n          const role = msg.type === 'user' ? 'user'\n            : (msg.type === 'gemini' || msg.type === 'assistant') ? 'assistant'\n            : msg.type;\n\n          let content = '';\n          if (typeof msg.content === 'string') {\n            content = msg.content;\n          } else if (Array.isArray(msg.content)) {\n            content = msg.content.filter(p => p.text).map(p => p.text).join('\\n');\n          }\n\n          return {\n            type: 'message',\n            message: { role, content },\n            timestamp: msg.timestamp || null\n          };\n        });\n      } catch {\n        continue;\n      }\n    }\n  }\n\n  return [];\n}\n\nexport {\n  getProjects,\n  getSessions,\n  getSessionMessages,\n  parseJsonlSessions,\n  renameProject,\n  deleteSession,\n  isProjectEmpty,\n  deleteProject,\n  addProjectManually,\n  loadProjectConfig,\n  saveProjectConfig,\n  extractProjectDirectory,\n  clearProjectDirectoryCache,\n  getCodexSessions,\n  getCodexSessionMessages,\n  deleteCodexSession,\n  getGeminiCliSessions,\n  getGeminiCliSessionMessages,\n  searchConversations\n};\n"
  },
  {
    "path": "server/providers/claude/adapter.js",
    "content": "/**\n * Claude provider adapter.\n *\n * Normalizes Claude SDK session history into NormalizedMessage format.\n * @module adapters/claude\n */\n\nimport { getSessionMessages } from '../../projects.js';\nimport { createNormalizedMessage, generateMessageId } from '../types.js';\nimport { isInternalContent } from '../utils.js';\n\nconst PROVIDER = 'claude';\n\n/**\n * Normalize a raw JSONL message or realtime SDK event into NormalizedMessage(s).\n * Handles both history entries (JSONL `{ message: { role, content } }`) and\n * realtime streaming events (`content_block_delta`, `content_block_stop`, etc.).\n * @param {object} raw - A single entry from JSONL or a live SDK event\n * @param {string} sessionId\n * @returns {import('../types.js').NormalizedMessage[]}\n */\nexport function normalizeMessage(raw, sessionId) {\n  // ── Streaming events (realtime) ──────────────────────────────────────────\n  if (raw.type === 'content_block_delta' && raw.delta?.text) {\n    return [createNormalizedMessage({ kind: 'stream_delta', content: raw.delta.text, sessionId, provider: PROVIDER })];\n  }\n  if (raw.type === 'content_block_stop') {\n    return [createNormalizedMessage({ kind: 'stream_end', sessionId, provider: PROVIDER })];\n  }\n\n  // ── History / full-message events ────────────────────────────────────────\n  const messages = [];\n  const ts = raw.timestamp || new Date().toISOString();\n  const baseId = raw.uuid || generateMessageId('claude');\n\n  // User message\n  if (raw.message?.role === 'user' && raw.message?.content) {\n    if (Array.isArray(raw.message.content)) {\n      // Handle tool_result parts\n      for (const part of raw.message.content) {\n        if (part.type === 'tool_result') {\n          messages.push(createNormalizedMessage({\n            id: `${baseId}_tr_${part.tool_use_id}`,\n            sessionId,\n            timestamp: ts,\n            provider: PROVIDER,\n            kind: 'tool_result',\n            toolId: part.tool_use_id,\n            content: typeof part.content === 'string' ? part.content : JSON.stringify(part.content),\n            isError: Boolean(part.is_error),\n            subagentTools: raw.subagentTools,\n            toolUseResult: raw.toolUseResult,\n          }));\n        } else if (part.type === 'text') {\n          // Regular text parts from user\n          const text = part.text || '';\n          if (text && !isInternalContent(text)) {\n            messages.push(createNormalizedMessage({\n              id: `${baseId}_text`,\n              sessionId,\n              timestamp: ts,\n              provider: PROVIDER,\n              kind: 'text',\n              role: 'user',\n              content: text,\n            }));\n          }\n        }\n      }\n\n      // If no text parts were found, check if it's a pure user message\n      if (messages.length === 0) {\n        const textParts = raw.message.content\n          .filter(p => p.type === 'text')\n          .map(p => p.text)\n          .filter(Boolean)\n          .join('\\n');\n        if (textParts && !isInternalContent(textParts)) {\n          messages.push(createNormalizedMessage({\n            id: `${baseId}_text`,\n            sessionId,\n            timestamp: ts,\n            provider: PROVIDER,\n            kind: 'text',\n            role: 'user',\n            content: textParts,\n          }));\n        }\n      }\n    } else if (typeof raw.message.content === 'string') {\n      const text = raw.message.content;\n      if (text && !isInternalContent(text)) {\n        messages.push(createNormalizedMessage({\n          id: baseId,\n          sessionId,\n          timestamp: ts,\n          provider: PROVIDER,\n          kind: 'text',\n          role: 'user',\n          content: text,\n        }));\n      }\n    }\n    return messages;\n  }\n\n  // Thinking message\n  if (raw.type === 'thinking' && raw.message?.content) {\n    messages.push(createNormalizedMessage({\n      id: baseId,\n      sessionId,\n      timestamp: ts,\n      provider: PROVIDER,\n      kind: 'thinking',\n      content: raw.message.content,\n    }));\n    return messages;\n  }\n\n  // Tool use result (codex-style in Claude)\n  if (raw.type === 'tool_use' && raw.toolName) {\n    messages.push(createNormalizedMessage({\n      id: baseId,\n      sessionId,\n      timestamp: ts,\n      provider: PROVIDER,\n      kind: 'tool_use',\n      toolName: raw.toolName,\n      toolInput: raw.toolInput,\n      toolId: raw.toolCallId || baseId,\n    }));\n    return messages;\n  }\n\n  if (raw.type === 'tool_result') {\n    messages.push(createNormalizedMessage({\n      id: baseId,\n      sessionId,\n      timestamp: ts,\n      provider: PROVIDER,\n      kind: 'tool_result',\n      toolId: raw.toolCallId || '',\n      content: raw.output || '',\n      isError: false,\n    }));\n    return messages;\n  }\n\n  // Assistant message\n  if (raw.message?.role === 'assistant' && raw.message?.content) {\n    if (Array.isArray(raw.message.content)) {\n      let partIndex = 0;\n      for (const part of raw.message.content) {\n        if (part.type === 'text' && part.text) {\n          messages.push(createNormalizedMessage({\n            id: `${baseId}_${partIndex}`,\n            sessionId,\n            timestamp: ts,\n            provider: PROVIDER,\n            kind: 'text',\n            role: 'assistant',\n            content: part.text,\n          }));\n        } else if (part.type === 'tool_use') {\n          messages.push(createNormalizedMessage({\n            id: `${baseId}_${partIndex}`,\n            sessionId,\n            timestamp: ts,\n            provider: PROVIDER,\n            kind: 'tool_use',\n            toolName: part.name,\n            toolInput: part.input,\n            toolId: part.id,\n          }));\n        } else if (part.type === 'thinking' && part.thinking) {\n          messages.push(createNormalizedMessage({\n            id: `${baseId}_${partIndex}`,\n            sessionId,\n            timestamp: ts,\n            provider: PROVIDER,\n            kind: 'thinking',\n            content: part.thinking,\n          }));\n        }\n        partIndex++;\n      }\n    } else if (typeof raw.message.content === 'string') {\n      messages.push(createNormalizedMessage({\n        id: baseId,\n        sessionId,\n        timestamp: ts,\n        provider: PROVIDER,\n        kind: 'text',\n        role: 'assistant',\n        content: raw.message.content,\n      }));\n    }\n    return messages;\n  }\n\n  return messages;\n}\n\n/**\n * @type {import('../types.js').ProviderAdapter}\n */\nexport const claudeAdapter = {\n  normalizeMessage,\n\n  /**\n   * Fetch session history from JSONL files, returning normalized messages.\n   */\n  async fetchHistory(sessionId, opts = {}) {\n    const { projectName, limit = null, offset = 0 } = opts;\n    if (!projectName) {\n      return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };\n    }\n\n    let result;\n    try {\n      result = await getSessionMessages(projectName, sessionId, limit, offset);\n    } catch (error) {\n      console.warn(`[ClaudeAdapter] Failed to load session ${sessionId}:`, error.message);\n      return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };\n    }\n\n    // getSessionMessages returns either an array (no limit) or { messages, total, hasMore }\n    const rawMessages = Array.isArray(result) ? result : (result.messages || []);\n    const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);\n    const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);\n\n    // First pass: collect tool results for attachment to tool_use messages\n    const toolResultMap = new Map();\n    for (const raw of rawMessages) {\n      if (raw.message?.role === 'user' && Array.isArray(raw.message?.content)) {\n        for (const part of raw.message.content) {\n          if (part.type === 'tool_result') {\n            toolResultMap.set(part.tool_use_id, {\n              content: part.content,\n              isError: Boolean(part.is_error),\n              timestamp: raw.timestamp,\n              subagentTools: raw.subagentTools,\n              toolUseResult: raw.toolUseResult,\n            });\n          }\n        }\n      }\n    }\n\n    // Second pass: normalize all messages\n    const normalized = [];\n    for (const raw of rawMessages) {\n      const entries = normalizeMessage(raw, sessionId);\n      normalized.push(...entries);\n    }\n\n    // Attach tool results to their corresponding tool_use messages\n    for (const msg of normalized) {\n      if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {\n        const tr = toolResultMap.get(msg.toolId);\n        msg.toolResult = {\n          content: typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content),\n          isError: tr.isError,\n          toolUseResult: tr.toolUseResult,\n        };\n        msg.subagentTools = tr.subagentTools;\n      }\n    }\n\n    return {\n      messages: normalized,\n      total,\n      hasMore,\n      offset,\n      limit,\n    };\n  },\n};\n"
  },
  {
    "path": "server/providers/codex/adapter.js",
    "content": "/**\n * Codex (OpenAI) provider adapter.\n *\n * Normalizes Codex SDK session history into NormalizedMessage format.\n * @module adapters/codex\n */\n\nimport { getCodexSessionMessages } from '../../projects.js';\nimport { createNormalizedMessage, generateMessageId } from '../types.js';\n\nconst PROVIDER = 'codex';\n\n/**\n * Normalize a raw Codex JSONL message into NormalizedMessage(s).\n * @param {object} raw - A single parsed message from Codex JSONL\n * @param {string} sessionId\n * @returns {import('../types.js').NormalizedMessage[]}\n */\nfunction normalizeCodexHistoryEntry(raw, sessionId) {\n  const ts = raw.timestamp || new Date().toISOString();\n  const baseId = raw.uuid || generateMessageId('codex');\n\n  // User message\n  if (raw.message?.role === 'user') {\n    const content = typeof raw.message.content === 'string'\n      ? raw.message.content\n      : Array.isArray(raw.message.content)\n        ? raw.message.content.map(p => typeof p === 'string' ? p : p?.text || '').filter(Boolean).join('\\n')\n        : String(raw.message.content || '');\n    if (!content.trim()) return [];\n    return [createNormalizedMessage({\n      id: baseId,\n      sessionId,\n      timestamp: ts,\n      provider: PROVIDER,\n      kind: 'text',\n      role: 'user',\n      content,\n    })];\n  }\n\n  // Assistant message\n  if (raw.message?.role === 'assistant') {\n    const content = typeof raw.message.content === 'string'\n      ? raw.message.content\n      : Array.isArray(raw.message.content)\n        ? raw.message.content.map(p => typeof p === 'string' ? p : p?.text || '').filter(Boolean).join('\\n')\n        : '';\n    if (!content.trim()) return [];\n    return [createNormalizedMessage({\n      id: baseId,\n      sessionId,\n      timestamp: ts,\n      provider: PROVIDER,\n      kind: 'text',\n      role: 'assistant',\n      content,\n    })];\n  }\n\n  // Thinking/reasoning\n  if (raw.type === 'thinking' || raw.isReasoning) {\n    return [createNormalizedMessage({\n      id: baseId,\n      sessionId,\n      timestamp: ts,\n      provider: PROVIDER,\n      kind: 'thinking',\n      content: raw.message?.content || '',\n    })];\n  }\n\n  // Tool use\n  if (raw.type === 'tool_use' || raw.toolName) {\n    return [createNormalizedMessage({\n      id: baseId,\n      sessionId,\n      timestamp: ts,\n      provider: PROVIDER,\n      kind: 'tool_use',\n      toolName: raw.toolName || 'Unknown',\n      toolInput: raw.toolInput,\n      toolId: raw.toolCallId || baseId,\n    })];\n  }\n\n  // Tool result\n  if (raw.type === 'tool_result') {\n    return [createNormalizedMessage({\n      id: baseId,\n      sessionId,\n      timestamp: ts,\n      provider: PROVIDER,\n      kind: 'tool_result',\n      toolId: raw.toolCallId || '',\n      content: raw.output || '',\n      isError: Boolean(raw.isError),\n    })];\n  }\n\n  return [];\n}\n\n/**\n * Normalize a raw Codex event (history JSONL or transformed SDK event) into NormalizedMessage(s).\n * @param {object} raw - A history entry (has raw.message.role) or transformed SDK event (has raw.type)\n * @param {string} sessionId\n * @returns {import('../types.js').NormalizedMessage[]}\n */\nexport function normalizeMessage(raw, sessionId) {\n  // History format: has message.role\n  if (raw.message?.role) {\n    return normalizeCodexHistoryEntry(raw, sessionId);\n  }\n\n  const ts = raw.timestamp || new Date().toISOString();\n  const baseId = raw.uuid || generateMessageId('codex');\n\n  // SDK event format (output of transformCodexEvent)\n  if (raw.type === 'item') {\n    switch (raw.itemType) {\n      case 'agent_message':\n        return [createNormalizedMessage({\n          id: baseId, sessionId, timestamp: ts, provider: PROVIDER,\n          kind: 'text', role: 'assistant', content: raw.message?.content || '',\n        })];\n      case 'reasoning':\n        return [createNormalizedMessage({\n          id: baseId, sessionId, timestamp: ts, provider: PROVIDER,\n          kind: 'thinking', content: raw.message?.content || '',\n        })];\n      case 'command_execution':\n        return [createNormalizedMessage({\n          id: baseId, sessionId, timestamp: ts, provider: PROVIDER,\n          kind: 'tool_use', toolName: 'Bash', toolInput: { command: raw.command },\n          toolId: baseId,\n          output: raw.output, exitCode: raw.exitCode, status: raw.status,\n        })];\n      case 'file_change':\n        return [createNormalizedMessage({\n          id: baseId, sessionId, timestamp: ts, provider: PROVIDER,\n          kind: 'tool_use', toolName: 'FileChanges', toolInput: raw.changes,\n          toolId: baseId, status: raw.status,\n        })];\n      case 'mcp_tool_call':\n        return [createNormalizedMessage({\n          id: baseId, sessionId, timestamp: ts, provider: PROVIDER,\n          kind: 'tool_use', toolName: raw.tool || 'MCP', toolInput: raw.arguments,\n          toolId: baseId, server: raw.server, result: raw.result,\n          error: raw.error, status: raw.status,\n        })];\n      case 'web_search':\n        return [createNormalizedMessage({\n          id: baseId, sessionId, timestamp: ts, provider: PROVIDER,\n          kind: 'tool_use', toolName: 'WebSearch', toolInput: { query: raw.query },\n          toolId: baseId,\n        })];\n      case 'todo_list':\n        return [createNormalizedMessage({\n          id: baseId, sessionId, timestamp: ts, provider: PROVIDER,\n          kind: 'tool_use', toolName: 'TodoList', toolInput: { items: raw.items },\n          toolId: baseId,\n        })];\n      case 'error':\n        return [createNormalizedMessage({\n          id: baseId, sessionId, timestamp: ts, provider: PROVIDER,\n          kind: 'error', content: raw.message?.content || 'Unknown error',\n        })];\n      default:\n        // Unknown item type — pass through as generic tool_use\n        return [createNormalizedMessage({\n          id: baseId, sessionId, timestamp: ts, provider: PROVIDER,\n          kind: 'tool_use', toolName: raw.itemType || 'Unknown',\n          toolInput: raw.item || raw, toolId: baseId,\n        })];\n    }\n  }\n\n  if (raw.type === 'turn_complete') {\n    return [createNormalizedMessage({\n      id: baseId, sessionId, timestamp: ts, provider: PROVIDER,\n      kind: 'complete',\n    })];\n  }\n  if (raw.type === 'turn_failed') {\n    return [createNormalizedMessage({\n      id: baseId, sessionId, timestamp: ts, provider: PROVIDER,\n      kind: 'error', content: raw.error?.message || 'Turn failed',\n    })];\n  }\n\n  return [];\n}\n\n/**\n * @type {import('../types.js').ProviderAdapter}\n */\nexport const codexAdapter = {\n  normalizeMessage,\n  /**\n   * Fetch session history from Codex JSONL files.\n   */\n  async fetchHistory(sessionId, opts = {}) {\n    const { limit = null, offset = 0 } = opts;\n\n    let result;\n    try {\n      result = await getCodexSessionMessages(sessionId, limit, offset);\n    } catch (error) {\n      console.warn(`[CodexAdapter] Failed to load session ${sessionId}:`, error.message);\n      return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };\n    }\n\n    const rawMessages = Array.isArray(result) ? result : (result.messages || []);\n    const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);\n    const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);\n    const tokenUsage = result.tokenUsage || null;\n\n    const normalized = [];\n    for (const raw of rawMessages) {\n      const entries = normalizeCodexHistoryEntry(raw, sessionId);\n      normalized.push(...entries);\n    }\n\n    // Attach tool results to tool_use messages\n    const toolResultMap = new Map();\n    for (const msg of normalized) {\n      if (msg.kind === 'tool_result' && msg.toolId) {\n        toolResultMap.set(msg.toolId, msg);\n      }\n    }\n    for (const msg of normalized) {\n      if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {\n        const tr = toolResultMap.get(msg.toolId);\n        msg.toolResult = { content: tr.content, isError: tr.isError };\n      }\n    }\n\n    return {\n      messages: normalized,\n      total,\n      hasMore,\n      offset,\n      limit,\n      tokenUsage,\n    };\n  },\n};\n"
  },
  {
    "path": "server/providers/cursor/adapter.js",
    "content": "/**\n * Cursor provider adapter.\n *\n * Normalizes Cursor CLI session history into NormalizedMessage format.\n * @module adapters/cursor\n */\n\nimport path from 'path';\nimport os from 'os';\nimport crypto from 'crypto';\nimport { createNormalizedMessage, generateMessageId } from '../types.js';\n\nconst PROVIDER = 'cursor';\n\n/**\n * Load raw blobs from Cursor's SQLite store.db, parse the DAG structure,\n * and return sorted message blobs in chronological order.\n * @param {string} sessionId\n * @param {string} projectPath - Absolute project path (used to compute cwdId hash)\n * @returns {Promise<Array<{id: string, sequence: number, rowid: number, content: object}>>}\n */\nasync function loadCursorBlobs(sessionId, projectPath) {\n  // Lazy-import sqlite so the module doesn't fail if sqlite3 is unavailable\n  const { default: sqlite3 } = await import('sqlite3');\n  const { open } = await import('sqlite');\n\n  const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');\n  const storeDbPath = path.join(os.homedir(), '.cursor', 'chats', cwdId, sessionId, 'store.db');\n\n  const db = await open({\n    filename: storeDbPath,\n    driver: sqlite3.Database,\n    mode: sqlite3.OPEN_READONLY,\n  });\n\n  try {\n    const allBlobs = await db.all('SELECT rowid, id, data FROM blobs');\n\n    const blobMap = new Map();\n    const parentRefs = new Map();\n    const childRefs = new Map();\n    const jsonBlobs = [];\n\n    for (const blob of allBlobs) {\n      blobMap.set(blob.id, blob);\n\n      if (blob.data && blob.data[0] === 0x7B) {\n        try {\n          const parsed = JSON.parse(blob.data.toString('utf8'));\n          jsonBlobs.push({ ...blob, parsed });\n        } catch {\n          // skip unparseable blobs\n        }\n      } else if (blob.data) {\n        const parents = [];\n        let i = 0;\n        while (i < blob.data.length - 33) {\n          if (blob.data[i] === 0x0A && blob.data[i + 1] === 0x20) {\n            const parentHash = blob.data.slice(i + 2, i + 34).toString('hex');\n            if (blobMap.has(parentHash)) {\n              parents.push(parentHash);\n            }\n            i += 34;\n          } else {\n            i++;\n          }\n        }\n        if (parents.length > 0) {\n          parentRefs.set(blob.id, parents);\n          for (const parentId of parents) {\n            if (!childRefs.has(parentId)) childRefs.set(parentId, []);\n            childRefs.get(parentId).push(blob.id);\n          }\n        }\n      }\n    }\n\n    // Topological sort (DFS)\n    const visited = new Set();\n    const sorted = [];\n    function visit(nodeId) {\n      if (visited.has(nodeId)) return;\n      visited.add(nodeId);\n      for (const pid of (parentRefs.get(nodeId) || [])) visit(pid);\n      const b = blobMap.get(nodeId);\n      if (b) sorted.push(b);\n    }\n    for (const blob of allBlobs) {\n      if (!parentRefs.has(blob.id)) visit(blob.id);\n    }\n    for (const blob of allBlobs) visit(blob.id);\n\n    // Order JSON blobs by DAG appearance\n    const messageOrder = new Map();\n    let orderIndex = 0;\n    for (const blob of sorted) {\n      if (blob.data && blob.data[0] !== 0x7B) {\n        for (const jb of jsonBlobs) {\n          try {\n            const idBytes = Buffer.from(jb.id, 'hex');\n            if (blob.data.includes(idBytes) && !messageOrder.has(jb.id)) {\n              messageOrder.set(jb.id, orderIndex++);\n            }\n          } catch { /* skip */ }\n        }\n      }\n    }\n\n    const sortedJsonBlobs = jsonBlobs.sort((a, b) => {\n      const oa = messageOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER;\n      const ob = messageOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER;\n      return oa !== ob ? oa - ob : a.rowid - b.rowid;\n    });\n\n    const messages = [];\n    for (let idx = 0; idx < sortedJsonBlobs.length; idx++) {\n      const blob = sortedJsonBlobs[idx];\n      const parsed = blob.parsed;\n      if (!parsed) continue;\n      const role = parsed?.role || parsed?.message?.role;\n      if (role === 'system') continue;\n      messages.push({\n        id: blob.id,\n        sequence: idx + 1,\n        rowid: blob.rowid,\n        content: parsed,\n      });\n    }\n\n    return messages;\n  } finally {\n    await db.close();\n  }\n}\n\n/**\n * Normalize a realtime NDJSON event from Cursor CLI into NormalizedMessage(s).\n * History uses normalizeCursorBlobs (SQLite DAG), this handles streaming NDJSON.\n * @param {object|string} raw - A parsed NDJSON event or a raw text line\n * @param {string} sessionId\n * @returns {import('../types.js').NormalizedMessage[]}\n */\nexport function normalizeMessage(raw, sessionId) {\n  // Structured assistant message with content array\n  if (raw && typeof raw === 'object' && raw.type === 'assistant' && raw.message?.content?.[0]?.text) {\n    return [createNormalizedMessage({ kind: 'stream_delta', content: raw.message.content[0].text, sessionId, provider: PROVIDER })];\n  }\n  // Plain string line (non-JSON output)\n  if (typeof raw === 'string' && raw.trim()) {\n    return [createNormalizedMessage({ kind: 'stream_delta', content: raw, sessionId, provider: PROVIDER })];\n  }\n  return [];\n}\n\n/**\n * @type {import('../types.js').ProviderAdapter}\n */\nexport const cursorAdapter = {\n  normalizeMessage,\n  /**\n   * Fetch session history for Cursor from SQLite store.db.\n   */\n  async fetchHistory(sessionId, opts = {}) {\n    const { projectPath = '', limit = null, offset = 0 } = opts;\n\n    try {\n      const blobs = await loadCursorBlobs(sessionId, projectPath);\n      const allNormalized = cursorAdapter.normalizeCursorBlobs(blobs, sessionId);\n\n      // Apply pagination\n      if (limit !== null && limit > 0) {\n        const start = offset;\n        const page = allNormalized.slice(start, start + limit);\n        return {\n          messages: page,\n          total: allNormalized.length,\n          hasMore: start + limit < allNormalized.length,\n          offset,\n          limit,\n        };\n      }\n\n      return {\n        messages: allNormalized,\n        total: allNormalized.length,\n        hasMore: false,\n        offset: 0,\n        limit: null,\n      };\n    } catch (error) {\n      // DB doesn't exist or is unreadable — return empty\n      console.warn(`[CursorAdapter] Failed to load session ${sessionId}:`, error.message);\n      return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };\n    }\n  },\n\n  /**\n   * Normalize raw Cursor blob messages into NormalizedMessage[].\n   * @param {any[]} blobs - Raw cursor blobs from store.db ({id, sequence, rowid, content})\n   * @param {string} sessionId\n   * @returns {import('../types.js').NormalizedMessage[]}\n   */\n  normalizeCursorBlobs(blobs, sessionId) {\n    const messages = [];\n    const toolUseMap = new Map();\n\n    // Use a fixed base timestamp so messages have stable, monotonically-increasing\n    // timestamps based on their sequence number rather than wall-clock time.\n    const baseTime = Date.now();\n\n    for (let i = 0; i < blobs.length; i++) {\n      const blob = blobs[i];\n      const content = blob.content;\n      const ts = new Date(baseTime + (blob.sequence ?? i) * 100).toISOString();\n      const baseId = blob.id || generateMessageId('cursor');\n\n      try {\n        if (!content?.role || !content?.content) {\n          // Try nested message format\n          if (content?.message?.role && content?.message?.content) {\n            if (content.message.role === 'system') continue;\n            const role = content.message.role === 'user' ? 'user' : 'assistant';\n            let text = '';\n            if (Array.isArray(content.message.content)) {\n              text = content.message.content\n                .map(p => typeof p === 'string' ? p : p?.text || '')\n                .filter(Boolean)\n                .join('\\n');\n            } else if (typeof content.message.content === 'string') {\n              text = content.message.content;\n            }\n            if (text?.trim()) {\n              messages.push(createNormalizedMessage({\n                id: baseId,\n                sessionId,\n                timestamp: ts,\n                provider: PROVIDER,\n                kind: 'text',\n                role,\n                content: text,\n                sequence: blob.sequence,\n                rowid: blob.rowid,\n              }));\n            }\n          }\n          continue;\n        }\n\n        if (content.role === 'system') continue;\n\n        // Tool results\n        if (content.role === 'tool') {\n          const toolItems = Array.isArray(content.content) ? content.content : [];\n          for (const item of toolItems) {\n            if (item?.type !== 'tool-result') continue;\n            const toolCallId = item.toolCallId || content.id;\n            messages.push(createNormalizedMessage({\n              id: `${baseId}_tr`,\n              sessionId,\n              timestamp: ts,\n              provider: PROVIDER,\n              kind: 'tool_result',\n              toolId: toolCallId,\n              content: item.result || '',\n              isError: false,\n            }));\n          }\n          continue;\n        }\n\n        const role = content.role === 'user' ? 'user' : 'assistant';\n\n        if (Array.isArray(content.content)) {\n          for (let partIdx = 0; partIdx < content.content.length; partIdx++) {\n            const part = content.content[partIdx];\n\n            if (part?.type === 'text' && part?.text) {\n              messages.push(createNormalizedMessage({\n                id: `${baseId}_${partIdx}`,\n                sessionId,\n                timestamp: ts,\n                provider: PROVIDER,\n                kind: 'text',\n                role,\n                content: part.text,\n                sequence: blob.sequence,\n                rowid: blob.rowid,\n              }));\n            } else if (part?.type === 'reasoning' && part?.text) {\n              messages.push(createNormalizedMessage({\n                id: `${baseId}_${partIdx}`,\n                sessionId,\n                timestamp: ts,\n                provider: PROVIDER,\n                kind: 'thinking',\n                content: part.text,\n              }));\n            } else if (part?.type === 'tool-call' || part?.type === 'tool_use') {\n              const toolName = (part.toolName || part.name || 'Unknown Tool') === 'ApplyPatch'\n                ? 'Edit' : (part.toolName || part.name || 'Unknown Tool');\n              const toolId = part.toolCallId || part.id || `tool_${i}_${partIdx}`;\n              messages.push(createNormalizedMessage({\n                id: `${baseId}_${partIdx}`,\n                sessionId,\n                timestamp: ts,\n                provider: PROVIDER,\n                kind: 'tool_use',\n                toolName,\n                toolInput: part.args || part.input,\n                toolId,\n              }));\n              toolUseMap.set(toolId, messages[messages.length - 1]);\n            }\n          }\n        } else if (typeof content.content === 'string' && content.content.trim()) {\n          messages.push(createNormalizedMessage({\n            id: baseId,\n            sessionId,\n            timestamp: ts,\n            provider: PROVIDER,\n            kind: 'text',\n            role,\n            content: content.content,\n            sequence: blob.sequence,\n            rowid: blob.rowid,\n          }));\n        }\n      } catch (error) {\n        console.warn('Error normalizing cursor blob:', error);\n      }\n    }\n\n    // Attach tool results to tool_use messages\n    for (const msg of messages) {\n      if (msg.kind === 'tool_result' && msg.toolId && toolUseMap.has(msg.toolId)) {\n        const toolUse = toolUseMap.get(msg.toolId);\n        toolUse.toolResult = {\n          content: msg.content,\n          isError: msg.isError,\n        };\n      }\n    }\n\n    // Sort by sequence/rowid\n    messages.sort((a, b) => {\n      if (a.sequence !== undefined && b.sequence !== undefined) return a.sequence - b.sequence;\n      if (a.rowid !== undefined && b.rowid !== undefined) return a.rowid - b.rowid;\n      return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();\n    });\n\n    return messages;\n  },\n};\n"
  },
  {
    "path": "server/providers/gemini/adapter.js",
    "content": "/**\n * Gemini provider adapter.\n *\n * Normalizes Gemini CLI session history into NormalizedMessage format.\n * @module adapters/gemini\n */\n\nimport sessionManager from '../../sessionManager.js';\nimport { getGeminiCliSessionMessages } from '../../projects.js';\nimport { createNormalizedMessage, generateMessageId } from '../types.js';\n\nconst PROVIDER = 'gemini';\n\n/**\n * Normalize a realtime NDJSON event from Gemini CLI into NormalizedMessage(s).\n * Handles: message (delta/final), tool_use, tool_result, result, error.\n * @param {object} raw - A parsed NDJSON event\n * @param {string} sessionId\n * @returns {import('../types.js').NormalizedMessage[]}\n */\nexport function normalizeMessage(raw, sessionId) {\n  const ts = raw.timestamp || new Date().toISOString();\n  const baseId = raw.uuid || generateMessageId('gemini');\n\n  if (raw.type === 'message' && raw.role === 'assistant') {\n    const content = raw.content || '';\n    const msgs = [];\n    if (content) {\n      msgs.push(createNormalizedMessage({ id: baseId, sessionId, timestamp: ts, provider: PROVIDER, kind: 'stream_delta', content }));\n    }\n    // If not a delta, also send stream_end\n    if (raw.delta !== true) {\n      msgs.push(createNormalizedMessage({ sessionId, timestamp: ts, provider: PROVIDER, kind: 'stream_end' }));\n    }\n    return msgs;\n  }\n\n  if (raw.type === 'tool_use') {\n    return [createNormalizedMessage({\n      id: baseId, sessionId, timestamp: ts, provider: PROVIDER,\n      kind: 'tool_use', toolName: raw.tool_name, toolInput: raw.parameters || {},\n      toolId: raw.tool_id || baseId,\n    })];\n  }\n\n  if (raw.type === 'tool_result') {\n    return [createNormalizedMessage({\n      id: baseId, sessionId, timestamp: ts, provider: PROVIDER,\n      kind: 'tool_result', toolId: raw.tool_id || '',\n      content: raw.output === undefined ? '' : String(raw.output),\n      isError: raw.status === 'error',\n    })];\n  }\n\n  if (raw.type === 'result') {\n    const msgs = [createNormalizedMessage({ sessionId, timestamp: ts, provider: PROVIDER, kind: 'stream_end' })];\n    if (raw.stats?.total_tokens) {\n      msgs.push(createNormalizedMessage({\n        sessionId, timestamp: ts, provider: PROVIDER,\n        kind: 'status', text: 'Complete', tokens: raw.stats.total_tokens, canInterrupt: false,\n      }));\n    }\n    return msgs;\n  }\n\n  if (raw.type === 'error') {\n    return [createNormalizedMessage({\n      id: baseId, sessionId, timestamp: ts, provider: PROVIDER,\n      kind: 'error', content: raw.error || raw.message || 'Unknown Gemini streaming error',\n    })];\n  }\n\n  return [];\n}\n\n/**\n * @type {import('../types.js').ProviderAdapter}\n */\nexport const geminiAdapter = {\n  normalizeMessage,\n  /**\n   * Fetch session history for Gemini.\n   * First tries in-memory session manager, then falls back to CLI sessions on disk.\n   */\n  async fetchHistory(sessionId, opts = {}) {\n    let rawMessages;\n    try {\n      rawMessages = sessionManager.getSessionMessages(sessionId);\n\n      // Fallback to Gemini CLI sessions on disk\n      if (rawMessages.length === 0) {\n        rawMessages = await getGeminiCliSessionMessages(sessionId);\n      }\n    } catch (error) {\n      console.warn(`[GeminiAdapter] Failed to load session ${sessionId}:`, error.message);\n      return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };\n    }\n\n    const normalized = [];\n    for (let i = 0; i < rawMessages.length; i++) {\n      const raw = rawMessages[i];\n      const ts = raw.timestamp || new Date().toISOString();\n      const baseId = raw.uuid || generateMessageId('gemini');\n\n      // sessionManager format: { type: 'message', message: { role, content }, timestamp }\n      // CLI format: { role: 'user'|'gemini'|'assistant', content: string|array }\n      const role = raw.message?.role || raw.role;\n      const content = raw.message?.content || raw.content;\n\n      if (!role || !content) continue;\n\n      const normalizedRole = (role === 'user') ? 'user' : 'assistant';\n\n      if (Array.isArray(content)) {\n        for (let partIdx = 0; partIdx < content.length; partIdx++) {\n          const part = content[partIdx];\n          if (part.type === 'text' && part.text) {\n            normalized.push(createNormalizedMessage({\n              id: `${baseId}_${partIdx}`,\n              sessionId,\n              timestamp: ts,\n              provider: PROVIDER,\n              kind: 'text',\n              role: normalizedRole,\n              content: part.text,\n            }));\n          } else if (part.type === 'tool_use') {\n            normalized.push(createNormalizedMessage({\n              id: `${baseId}_${partIdx}`,\n              sessionId,\n              timestamp: ts,\n              provider: PROVIDER,\n              kind: 'tool_use',\n              toolName: part.name,\n              toolInput: part.input,\n              toolId: part.id || generateMessageId('gemini_tool'),\n            }));\n          } else if (part.type === 'tool_result') {\n            normalized.push(createNormalizedMessage({\n              id: `${baseId}_${partIdx}`,\n              sessionId,\n              timestamp: ts,\n              provider: PROVIDER,\n              kind: 'tool_result',\n              toolId: part.tool_use_id || '',\n              content: part.content === undefined ? '' : String(part.content),\n              isError: Boolean(part.is_error),\n            }));\n          }\n        }\n      } else if (typeof content === 'string' && content.trim()) {\n        normalized.push(createNormalizedMessage({\n          id: baseId,\n          sessionId,\n          timestamp: ts,\n          provider: PROVIDER,\n          kind: 'text',\n          role: normalizedRole,\n          content,\n        }));\n      }\n    }\n\n    // Attach tool results to tool_use messages\n    const toolResultMap = new Map();\n    for (const msg of normalized) {\n      if (msg.kind === 'tool_result' && msg.toolId) {\n        toolResultMap.set(msg.toolId, msg);\n      }\n    }\n    for (const msg of normalized) {\n      if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {\n        const tr = toolResultMap.get(msg.toolId);\n        msg.toolResult = { content: tr.content, isError: tr.isError };\n      }\n    }\n\n    return {\n      messages: normalized,\n      total: normalized.length,\n      hasMore: false,\n      offset: 0,\n      limit: null,\n    };\n  },\n};\n"
  },
  {
    "path": "server/providers/registry.js",
    "content": "/**\n * Provider Registry\n *\n * Centralizes provider adapter lookup. All code that needs a provider adapter\n * should go through this registry instead of importing individual adapters directly.\n *\n * @module providers/registry\n */\n\nimport { claudeAdapter } from './claude/adapter.js';\nimport { cursorAdapter } from './cursor/adapter.js';\nimport { codexAdapter } from './codex/adapter.js';\nimport { geminiAdapter } from './gemini/adapter.js';\n\n/**\n * @typedef {import('./types.js').ProviderAdapter} ProviderAdapter\n * @typedef {import('./types.js').SessionProvider} SessionProvider\n */\n\n/** @type {Map<string, ProviderAdapter>} */\nconst providers = new Map();\n\n// Register built-in providers\nproviders.set('claude', claudeAdapter);\nproviders.set('cursor', cursorAdapter);\nproviders.set('codex', codexAdapter);\nproviders.set('gemini', geminiAdapter);\n\n/**\n * Get a provider adapter by name.\n * @param {string} name - Provider name (e.g., 'claude', 'cursor', 'codex', 'gemini')\n * @returns {ProviderAdapter | undefined}\n */\nexport function getProvider(name) {\n  return providers.get(name);\n}\n\n/**\n * Get all registered provider names.\n * @returns {string[]}\n */\nexport function getAllProviders() {\n  return Array.from(providers.keys());\n}\n"
  },
  {
    "path": "server/providers/types.js",
    "content": "/**\n * Provider Types & Interface\n *\n * Defines the normalized message format and the provider adapter interface.\n * All providers normalize their native formats into NormalizedMessage\n * before sending over REST or WebSocket.\n *\n * @module providers/types\n */\n\n// ─── Session Provider ────────────────────────────────────────────────────────\n\n/**\n * @typedef {'claude' | 'cursor' | 'codex' | 'gemini'} SessionProvider\n */\n\n// ─── Message Kind ────────────────────────────────────────────────────────────\n\n/**\n * @typedef {'text' | 'tool_use' | 'tool_result' | 'thinking' | 'stream_delta' | 'stream_end'\n *   | 'error' | 'complete' | 'status' | 'permission_request' | 'permission_cancelled'\n *   | 'session_created' | 'interactive_prompt' | 'task_notification'} MessageKind\n */\n\n// ─── NormalizedMessage ───────────────────────────────────────────────────────\n\n/**\n * @typedef {Object} NormalizedMessage\n * @property {string} id - Unique message id (for dedup between server + realtime)\n * @property {string} sessionId\n * @property {string} timestamp - ISO 8601\n * @property {SessionProvider} provider\n * @property {MessageKind} kind\n *\n * Additional fields depending on kind:\n * - text:                 role ('user'|'assistant'), content, images?\n * - tool_use:             toolName, toolInput, toolId\n * - tool_result:          toolId, content, isError\n * - thinking:             content\n * - stream_delta:         content\n * - stream_end:           (no extra fields)\n * - error:                content\n * - complete:             (no extra fields)\n * - status:               text, tokens?, canInterrupt?\n * - permission_request:   requestId, toolName, input, context?\n * - permission_cancelled: requestId\n * - session_created:      newSessionId\n * - interactive_prompt:   content\n * - task_notification:    status, summary\n */\n\n// ─── Fetch History ───────────────────────────────────────────────────────────\n\n/**\n * @typedef {Object} FetchHistoryOptions\n * @property {string} [projectName] - Project name (required for Claude)\n * @property {string} [projectPath] - Absolute project path (required for Cursor cwdId hash)\n * @property {number|null} [limit] - Page size (null = all messages)\n * @property {number} [offset] - Pagination offset (default: 0)\n */\n\n/**\n * @typedef {Object} FetchHistoryResult\n * @property {NormalizedMessage[]} messages - Normalized messages\n * @property {number} total - Total number of messages in the session\n * @property {boolean} hasMore - Whether more messages exist before the current page\n * @property {number} offset - Current offset\n * @property {number|null} limit - Page size used\n * @property {object} [tokenUsage] - Token usage data (provider-specific)\n */\n\n// ─── Provider Adapter Interface ──────────────────────────────────────────────\n\n/**\n * Every provider adapter MUST implement this interface.\n *\n * @typedef {Object} ProviderAdapter\n *\n * @property {(sessionId: string, opts?: FetchHistoryOptions) => Promise<FetchHistoryResult>} fetchHistory\n *   Read persisted session messages from disk/database and return them as NormalizedMessage[].\n *   The backend calls this from the unified GET /api/sessions/:id/messages endpoint.\n *\n *   Provider implementations:\n *   - Claude: reads ~/.claude/projects/{projectName}/*.jsonl\n *   - Cursor: reads from SQLite store.db (via normalizeCursorBlobs helper)\n *   - Codex:  reads ~/.codex/sessions/*.jsonl\n *   - Gemini: reads from in-memory sessionManager or ~/.gemini/tmp/ JSON files\n *\n * @property {(raw: any, sessionId: string) => NormalizedMessage[]} normalizeMessage\n *   Normalize a provider-specific event (JSONL entry or live SDK event) into NormalizedMessage[].\n *   Used by provider files to convert both history and realtime events.\n */\n\n// ─── Runtime Helpers ─────────────────────────────────────────────────────────\n\n/**\n * Generate a unique message ID.\n * Uses crypto.randomUUID() to avoid collisions across server restarts and workers.\n * @param {string} [prefix='msg'] - Optional prefix\n * @returns {string}\n */\nexport function generateMessageId(prefix = 'msg') {\n  return `${prefix}_${crypto.randomUUID()}`;\n}\n\n/**\n * Create a NormalizedMessage with common fields pre-filled.\n * @param {Partial<NormalizedMessage> & {kind: MessageKind, provider: SessionProvider}} fields\n * @returns {NormalizedMessage}\n */\nexport function createNormalizedMessage(fields) {\n  return {\n    ...fields,\n    id: fields.id || generateMessageId(fields.kind),\n    sessionId: fields.sessionId || '',\n    timestamp: fields.timestamp || new Date().toISOString(),\n    provider: fields.provider,\n  };\n}\n"
  },
  {
    "path": "server/providers/utils.js",
    "content": "/**\n * Shared provider utilities.\n *\n * @module providers/utils\n */\n\n/**\n * Prefixes that indicate internal/system content which should be hidden from the UI.\n * @type {readonly string[]}\n */\nexport const INTERNAL_CONTENT_PREFIXES = Object.freeze([\n  '<command-name>',\n  '<command-message>',\n  '<command-args>',\n  '<local-command-stdout>',\n  '<system-reminder>',\n  'Caveat:',\n  'This session is being continued from a previous',\n  '[Request interrupted',\n]);\n\n/**\n * Check if user text content is internal/system that should be skipped.\n * @param {string} content\n * @returns {boolean}\n */\nexport function isInternalContent(content) {\n  return INTERNAL_CONTENT_PREFIXES.some(prefix => content.startsWith(prefix));\n}\n"
  },
  {
    "path": "server/routes/agent.js",
    "content": "import express from 'express';\nimport { spawn } from 'child_process';\nimport path from 'path';\nimport os from 'os';\nimport { promises as fs } from 'fs';\nimport crypto from 'crypto';\nimport { userDb, apiKeysDb, githubTokensDb } from '../database/db.js';\nimport { addProjectManually } from '../projects.js';\nimport { queryClaudeSDK } from '../claude-sdk.js';\nimport { spawnCursor } from '../cursor-cli.js';\nimport { queryCodex } from '../openai-codex.js';\nimport { spawnGemini } from '../gemini-cli.js';\nimport { Octokit } from '@octokit/rest';\nimport { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';\nimport { IS_PLATFORM } from '../constants/config.js';\n\nconst router = express.Router();\n\n/**\n * Middleware to authenticate agent API requests.\n *\n * Supports two authentication modes:\n * 1. Platform mode (IS_PLATFORM=true): For managed/hosted deployments where\n *    authentication is handled by an external proxy. Requests are trusted and\n *    the default user context is used.\n *\n * 2. API key mode (default): For self-hosted deployments where users authenticate\n *    via API keys created in the UI. Keys are validated against the local database.\n */\nconst validateExternalApiKey = (req, res, next) => {\n  // Platform mode: Authentication is handled externally (e.g., by a proxy layer).\n  // Trust the request and use the default user context.\n  if (IS_PLATFORM) {\n    try {\n      const user = userDb.getFirstUser();\n      if (!user) {\n        return res.status(500).json({ error: 'Platform mode: No user found in database' });\n      }\n      req.user = user;\n      return next();\n    } catch (error) {\n      console.error('Platform mode error:', error);\n      return res.status(500).json({ error: 'Platform mode: Failed to fetch user' });\n    }\n  }\n\n  // Self-hosted mode: Validate API key from header or query parameter\n  const apiKey = req.headers['x-api-key'] || req.query.apiKey;\n\n  if (!apiKey) {\n    return res.status(401).json({ error: 'API key required' });\n  }\n\n  const user = apiKeysDb.validateApiKey(apiKey);\n\n  if (!user) {\n    return res.status(401).json({ error: 'Invalid or inactive API key' });\n  }\n\n  req.user = user;\n  next();\n};\n\n/**\n * Get the remote URL of a git repository\n * @param {string} repoPath - Path to the git repository\n * @returns {Promise<string>} - Remote URL of the repository\n */\nasync function getGitRemoteUrl(repoPath) {\n  return new Promise((resolve, reject) => {\n    const gitProcess = spawn('git', ['config', '--get', 'remote.origin.url'], {\n      cwd: repoPath,\n      stdio: ['pipe', 'pipe', 'pipe']\n    });\n\n    let stdout = '';\n    let stderr = '';\n\n    gitProcess.stdout.on('data', (data) => {\n      stdout += data.toString();\n    });\n\n    gitProcess.stderr.on('data', (data) => {\n      stderr += data.toString();\n    });\n\n    gitProcess.on('close', (code) => {\n      if (code === 0) {\n        resolve(stdout.trim());\n      } else {\n        reject(new Error(`Failed to get git remote: ${stderr}`));\n      }\n    });\n\n    gitProcess.on('error', (error) => {\n      reject(new Error(`Failed to execute git: ${error.message}`));\n    });\n  });\n}\n\n/**\n * Normalize GitHub URLs for comparison\n * @param {string} url - GitHub URL\n * @returns {string} - Normalized URL\n */\nfunction normalizeGitHubUrl(url) {\n  // Remove .git suffix\n  let normalized = url.replace(/\\.git$/, '');\n  // Convert SSH to HTTPS format for comparison\n  normalized = normalized.replace(/^git@github\\.com:/, 'https://github.com/');\n  // Remove trailing slash\n  normalized = normalized.replace(/\\/$/, '');\n  return normalized.toLowerCase();\n}\n\n/**\n * Parse GitHub URL to extract owner and repo\n * @param {string} url - GitHub URL (HTTPS or SSH)\n * @returns {{owner: string, repo: string}} - Parsed owner and repo\n */\nfunction parseGitHubUrl(url) {\n  // Handle HTTPS URLs: https://github.com/owner/repo or https://github.com/owner/repo.git\n  // Handle SSH URLs: git@github.com:owner/repo or git@github.com:owner/repo.git\n  const match = url.match(/github\\.com[:/]([^/]+)\\/([^/]+?)(?:\\.git)?$/);\n  if (!match) {\n    throw new Error('Invalid GitHub URL format');\n  }\n  return {\n    owner: match[1],\n    repo: match[2].replace(/\\.git$/, '')\n  };\n}\n\n/**\n * Auto-generate a branch name from a message\n * @param {string} message - The agent message\n * @returns {string} - Generated branch name\n */\nfunction autogenerateBranchName(message) {\n  // Convert to lowercase, replace spaces/special chars with hyphens\n  let branchName = message\n    .toLowerCase()\n    .replace(/[^a-z0-9\\s-]/g, '') // Remove special characters\n    .replace(/\\s+/g, '-') // Replace spaces with hyphens\n    .replace(/-+/g, '-') // Replace multiple hyphens with single\n    .replace(/^-|-$/g, ''); // Remove leading/trailing hyphens\n\n  // Ensure non-empty fallback\n  if (!branchName) {\n    branchName = 'task';\n  }\n\n  // Generate timestamp suffix (last 6 chars of base36 timestamp)\n  const timestamp = Date.now().toString(36).slice(-6);\n  const suffix = `-${timestamp}`;\n\n  // Limit length to ensure total length including suffix fits within 50 characters\n  const maxBaseLength = 50 - suffix.length;\n  if (branchName.length > maxBaseLength) {\n    branchName = branchName.substring(0, maxBaseLength);\n  }\n\n  // Remove any trailing hyphen after truncation and ensure no leading hyphen\n  branchName = branchName.replace(/-$/, '').replace(/^-+/, '');\n\n  // If still empty or starts with hyphen after cleanup, use fallback\n  if (!branchName || branchName.startsWith('-')) {\n    branchName = 'task';\n  }\n\n  // Combine base name with timestamp suffix\n  branchName = `${branchName}${suffix}`;\n\n  // Final validation: ensure it matches safe pattern\n  if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(branchName)) {\n    // Fallback to deterministic safe name\n    return `branch-${timestamp}`;\n  }\n\n  return branchName;\n}\n\n/**\n * Validate a Git branch name\n * @param {string} branchName - Branch name to validate\n * @returns {{valid: boolean, error?: string}} - Validation result\n */\nfunction validateBranchName(branchName) {\n  if (!branchName || branchName.trim() === '') {\n    return { valid: false, error: 'Branch name cannot be empty' };\n  }\n\n  // Git branch name rules\n  const invalidPatterns = [\n    { pattern: /^\\./, message: 'Branch name cannot start with a dot' },\n    { pattern: /\\.$/, message: 'Branch name cannot end with a dot' },\n    { pattern: /\\.\\./, message: 'Branch name cannot contain consecutive dots (..)' },\n    { pattern: /\\s/, message: 'Branch name cannot contain spaces' },\n    { pattern: /[~^:?*\\[\\\\]/, message: 'Branch name cannot contain special characters: ~ ^ : ? * [ \\\\' },\n    { pattern: /@{/, message: 'Branch name cannot contain @{' },\n    { pattern: /\\/$/, message: 'Branch name cannot end with a slash' },\n    { pattern: /^\\//, message: 'Branch name cannot start with a slash' },\n    { pattern: /\\/\\//, message: 'Branch name cannot contain consecutive slashes' },\n    { pattern: /\\.lock$/, message: 'Branch name cannot end with .lock' }\n  ];\n\n  for (const { pattern, message } of invalidPatterns) {\n    if (pattern.test(branchName)) {\n      return { valid: false, error: message };\n    }\n  }\n\n  // Check for ASCII control characters\n  if (/[\\x00-\\x1F\\x7F]/.test(branchName)) {\n    return { valid: false, error: 'Branch name cannot contain control characters' };\n  }\n\n  return { valid: true };\n}\n\n/**\n * Get recent commit messages from a repository\n * @param {string} projectPath - Path to the git repository\n * @param {number} limit - Number of commits to retrieve (default: 5)\n * @returns {Promise<string[]>} - Array of commit messages\n */\nasync function getCommitMessages(projectPath, limit = 5) {\n  return new Promise((resolve, reject) => {\n    const gitProcess = spawn('git', ['log', `-${limit}`, '--pretty=format:%s'], {\n      cwd: projectPath,\n      stdio: ['pipe', 'pipe', 'pipe']\n    });\n\n    let stdout = '';\n    let stderr = '';\n\n    gitProcess.stdout.on('data', (data) => {\n      stdout += data.toString();\n    });\n\n    gitProcess.stderr.on('data', (data) => {\n      stderr += data.toString();\n    });\n\n    gitProcess.on('close', (code) => {\n      if (code === 0) {\n        const messages = stdout.trim().split('\\n').filter(msg => msg.length > 0);\n        resolve(messages);\n      } else {\n        reject(new Error(`Failed to get commit messages: ${stderr}`));\n      }\n    });\n\n    gitProcess.on('error', (error) => {\n      reject(new Error(`Failed to execute git: ${error.message}`));\n    });\n  });\n}\n\n/**\n * Create a new branch on GitHub using the API\n * @param {Octokit} octokit - Octokit instance\n * @param {string} owner - Repository owner\n * @param {string} repo - Repository name\n * @param {string} branchName - Name of the new branch\n * @param {string} baseBranch - Base branch to branch from (default: 'main')\n * @returns {Promise<void>}\n */\nasync function createGitHubBranch(octokit, owner, repo, branchName, baseBranch = 'main') {\n  try {\n    // Get the SHA of the base branch\n    const { data: ref } = await octokit.git.getRef({\n      owner,\n      repo,\n      ref: `heads/${baseBranch}`\n    });\n\n    const baseSha = ref.object.sha;\n\n    // Create the new branch\n    await octokit.git.createRef({\n      owner,\n      repo,\n      ref: `refs/heads/${branchName}`,\n      sha: baseSha\n    });\n\n    console.log(`✅ Created branch '${branchName}' on GitHub`);\n  } catch (error) {\n    if (error.status === 422 && error.message.includes('Reference already exists')) {\n      console.log(`ℹ️ Branch '${branchName}' already exists on GitHub`);\n    } else {\n      throw error;\n    }\n  }\n}\n\n/**\n * Create a pull request on GitHub\n * @param {Octokit} octokit - Octokit instance\n * @param {string} owner - Repository owner\n * @param {string} repo - Repository name\n * @param {string} branchName - Head branch name\n * @param {string} title - PR title\n * @param {string} body - PR body/description\n * @param {string} baseBranch - Base branch (default: 'main')\n * @returns {Promise<{number: number, url: string}>} - PR number and URL\n */\nasync function createGitHubPR(octokit, owner, repo, branchName, title, body, baseBranch = 'main') {\n  const { data: pr } = await octokit.pulls.create({\n    owner,\n    repo,\n    title,\n    head: branchName,\n    base: baseBranch,\n    body\n  });\n\n  console.log(`✅ Created pull request #${pr.number}: ${pr.html_url}`);\n\n  return {\n    number: pr.number,\n    url: pr.html_url\n  };\n}\n\n/**\n * Clone a GitHub repository to a directory\n * @param {string} githubUrl - GitHub repository URL\n * @param {string} githubToken - Optional GitHub token for private repos\n * @param {string} projectPath - Path for cloning the repository\n * @returns {Promise<string>} - Path to the cloned repository\n */\nasync function cloneGitHubRepo(githubUrl, githubToken = null, projectPath) {\n  return new Promise(async (resolve, reject) => {\n    try {\n      // Validate GitHub URL\n      if (!githubUrl || !githubUrl.includes('github.com')) {\n        throw new Error('Invalid GitHub URL');\n      }\n\n      const cloneDir = path.resolve(projectPath);\n\n      // Check if directory already exists\n      try {\n        await fs.access(cloneDir);\n        // Directory exists - check if it's a git repo with the same URL\n        try {\n          const existingUrl = await getGitRemoteUrl(cloneDir);\n          const normalizedExisting = normalizeGitHubUrl(existingUrl);\n          const normalizedRequested = normalizeGitHubUrl(githubUrl);\n\n          if (normalizedExisting === normalizedRequested) {\n            console.log('✅ Repository already exists at path with correct URL');\n            return resolve(cloneDir);\n          } else {\n            throw new Error(`Directory ${cloneDir} already exists with a different repository (${existingUrl}). Expected: ${githubUrl}`);\n          }\n        } catch (gitError) {\n          throw new Error(`Directory ${cloneDir} already exists but is not a valid git repository or git command failed`);\n        }\n      } catch (accessError) {\n        // Directory doesn't exist - proceed with clone\n      }\n\n      // Ensure parent directory exists\n      await fs.mkdir(path.dirname(cloneDir), { recursive: true });\n\n      // Prepare the git clone URL with authentication if token is provided\n      let cloneUrl = githubUrl;\n      if (githubToken) {\n        // Convert HTTPS URL to authenticated URL\n        // Example: https://github.com/user/repo -> https://token@github.com/user/repo\n        cloneUrl = githubUrl.replace('https://github.com', `https://${githubToken}@github.com`);\n      }\n\n      console.log('🔄 Cloning repository:', githubUrl);\n      console.log('📁 Destination:', cloneDir);\n\n      // Execute git clone\n      const gitProcess = spawn('git', ['clone', '--depth', '1', cloneUrl, cloneDir], {\n        stdio: ['pipe', 'pipe', 'pipe']\n      });\n\n      let stdout = '';\n      let stderr = '';\n\n      gitProcess.stdout.on('data', (data) => {\n        stdout += data.toString();\n      });\n\n      gitProcess.stderr.on('data', (data) => {\n        stderr += data.toString();\n        console.log('Git stderr:', data.toString());\n      });\n\n      gitProcess.on('close', (code) => {\n        if (code === 0) {\n          console.log('✅ Repository cloned successfully');\n          resolve(cloneDir);\n        } else {\n          console.error('❌ Git clone failed:', stderr);\n          reject(new Error(`Git clone failed: ${stderr}`));\n        }\n      });\n\n      gitProcess.on('error', (error) => {\n        reject(new Error(`Failed to execute git: ${error.message}`));\n      });\n    } catch (error) {\n      reject(error);\n    }\n  });\n}\n\n/**\n * Clean up a temporary project directory and its Claude session\n * @param {string} projectPath - Path to the project directory\n * @param {string} sessionId - Session ID to clean up\n */\nasync function cleanupProject(projectPath, sessionId = null) {\n  try {\n    // Only clean up projects in the external-projects directory\n    if (!projectPath.includes('.claude/external-projects')) {\n      console.warn('⚠️ Refusing to clean up non-external project:', projectPath);\n      return;\n    }\n\n    console.log('🧹 Cleaning up project:', projectPath);\n    await fs.rm(projectPath, { recursive: true, force: true });\n    console.log('✅ Project cleaned up');\n\n    // Also clean up the Claude session directory if sessionId provided\n    if (sessionId) {\n      try {\n        const sessionPath = path.join(os.homedir(), '.claude', 'sessions', sessionId);\n        console.log('🧹 Cleaning up session directory:', sessionPath);\n        await fs.rm(sessionPath, { recursive: true, force: true });\n        console.log('✅ Session directory cleaned up');\n      } catch (error) {\n        console.error('⚠️ Failed to clean up session directory:', error.message);\n      }\n    }\n  } catch (error) {\n    console.error('❌ Failed to clean up project:', error);\n  }\n}\n\n/**\n * SSE Stream Writer - Adapts SDK/CLI output to Server-Sent Events\n */\nclass SSEStreamWriter {\n  constructor(res, userId = null) {\n    this.res = res;\n    this.sessionId = null;\n    this.userId = userId;\n    this.isSSEStreamWriter = true;  // Marker for transport detection\n  }\n\n  send(data) {\n    if (this.res.writableEnded) {\n      return;\n    }\n\n    // Format as SSE - providers send raw objects, we stringify\n    this.res.write(`data: ${JSON.stringify(data)}\\n\\n`);\n  }\n\n  end() {\n    if (!this.res.writableEnded) {\n      this.res.write('data: {\"type\":\"done\"}\\n\\n');\n      this.res.end();\n    }\n  }\n\n  setSessionId(sessionId) {\n    this.sessionId = sessionId;\n  }\n\n  getSessionId() {\n    return this.sessionId;\n  }\n}\n\n/**\n * Non-streaming response collector\n */\nclass ResponseCollector {\n  constructor(userId = null) {\n    this.messages = [];\n    this.sessionId = null;\n    this.userId = userId;\n  }\n\n  send(data) {\n    // Store ALL messages for now - we'll filter when returning\n    this.messages.push(data);\n\n    // Extract sessionId if present\n    if (typeof data === 'string') {\n      try {\n        const parsed = JSON.parse(data);\n        if (parsed.sessionId) {\n          this.sessionId = parsed.sessionId;\n        }\n      } catch (e) {\n        // Not JSON, ignore\n      }\n    } else if (data && data.sessionId) {\n      this.sessionId = data.sessionId;\n    }\n  }\n\n  end() {\n    // Do nothing - we'll collect all messages\n  }\n\n  setSessionId(sessionId) {\n    this.sessionId = sessionId;\n  }\n\n  getSessionId() {\n    return this.sessionId;\n  }\n\n  getMessages() {\n    return this.messages;\n  }\n\n  /**\n   * Get filtered assistant messages only\n   */\n  getAssistantMessages() {\n    const assistantMessages = [];\n\n    for (const msg of this.messages) {\n      // Skip initial status message\n      if (msg && msg.type === 'status') {\n        continue;\n      }\n\n      // Handle JSON strings\n      if (typeof msg === 'string') {\n        try {\n          const parsed = JSON.parse(msg);\n          // Only include claude-response messages with assistant type\n          if (parsed.type === 'claude-response' && parsed.data && parsed.data.type === 'assistant') {\n            assistantMessages.push(parsed.data);\n          }\n        } catch (e) {\n          // Not JSON, skip\n        }\n      }\n    }\n\n    return assistantMessages;\n  }\n\n  /**\n   * Calculate total tokens from all messages\n   */\n  getTotalTokens() {\n    let totalInput = 0;\n    let totalOutput = 0;\n    let totalCacheRead = 0;\n    let totalCacheCreation = 0;\n\n    for (const msg of this.messages) {\n      let data = msg;\n\n      // Parse if string\n      if (typeof msg === 'string') {\n        try {\n          data = JSON.parse(msg);\n        } catch (e) {\n          continue;\n        }\n      }\n\n      // Extract usage from claude-response messages\n      if (data && data.type === 'claude-response' && data.data) {\n        const msgData = data.data;\n        if (msgData.message && msgData.message.usage) {\n          const usage = msgData.message.usage;\n          totalInput += usage.input_tokens || 0;\n          totalOutput += usage.output_tokens || 0;\n          totalCacheRead += usage.cache_read_input_tokens || 0;\n          totalCacheCreation += usage.cache_creation_input_tokens || 0;\n        }\n      }\n    }\n\n    return {\n      inputTokens: totalInput,\n      outputTokens: totalOutput,\n      cacheReadTokens: totalCacheRead,\n      cacheCreationTokens: totalCacheCreation,\n      totalTokens: totalInput + totalOutput + totalCacheRead + totalCacheCreation\n    };\n  }\n}\n\n// ===============================\n// External API Endpoint\n// ===============================\n\n/**\n * POST /api/agent\n *\n * Trigger an AI agent (Claude or Cursor) to work on a project.\n * Supports automatic GitHub branch and pull request creation after successful completion.\n *\n * ================================================================================================\n * REQUEST BODY PARAMETERS\n * ================================================================================================\n *\n * @param {string} githubUrl - (Conditionally Required) GitHub repository URL to clone.\n *                             Supported formats:\n *                             - HTTPS: https://github.com/owner/repo\n *                             - HTTPS with .git: https://github.com/owner/repo.git\n *                             - SSH: git@github.com:owner/repo\n *                             - SSH with .git: git@github.com:owner/repo.git\n *\n * @param {string} projectPath - (Conditionally Required) Path to existing project OR destination for cloning.\n *                               Behavior depends on usage:\n *                               - If used alone: Must point to existing project directory\n *                               - If used with githubUrl: Target location for cloning\n *                               - If omitted with githubUrl: Auto-generates temporary path in ~/.claude/external-projects/\n *\n * @param {string} message - (Required) Task description for the AI agent. Used as:\n *                          - Instructions for the agent\n *                          - Source for auto-generated branch names (if createBranch=true and no branchName)\n *                          - Fallback for PR title if no commits are made\n *\n * @param {string} provider - (Optional) AI provider to use. Options: 'claude' | 'cursor' | 'codex' | 'gemini'\n *                           Default: 'claude'\n *\n * @param {boolean} stream - (Optional) Enable Server-Sent Events (SSE) streaming for real-time updates.\n *                          Default: true\n *                          - true: Returns text/event-stream with incremental updates\n *                          - false: Returns complete JSON response after completion\n *\n * @param {string} model - (Optional) Model identifier for providers.\n *\n *                        Claude models: 'sonnet' (default), 'opus', 'haiku', 'opusplan', 'sonnet[1m]'\n *                        Cursor models: 'gpt-5' (default), 'gpt-5.2', 'gpt-5.2-high', 'sonnet-4.5', 'opus-4.5',\n *                                       'gemini-3-pro', 'composer-1', 'auto', 'gpt-5.1', 'gpt-5.1-high',\n *                                       'gpt-5.1-codex', 'gpt-5.1-codex-high', 'gpt-5.1-codex-max',\n *                                       'gpt-5.1-codex-max-high', 'opus-4.1', 'grok', and thinking variants\n *                        Codex models: 'gpt-5.2' (default), 'gpt-5.1-codex-max', 'o3', 'o4-mini'\n *\n * @param {boolean} cleanup - (Optional) Auto-cleanup project directory after completion.\n *                           Default: true\n *                           Behavior:\n *                           - Only applies when cloning via githubUrl (not for existing projectPath)\n *                           - Deletes cloned repository after 5 seconds\n *                           - Also deletes associated Claude session directory\n *                           - Remote branch and PR remain on GitHub if created\n *\n * @param {string} githubToken - (Optional) GitHub Personal Access Token for authentication.\n *                              Overrides stored token from user settings.\n *                              Required for:\n *                              - Private repositories\n *                              - Branch/PR creation features\n *                              Token must have 'repo' scope for full functionality.\n *\n * @param {string} branchName - (Optional) Custom name for the Git branch.\n *                             If provided, createBranch is automatically set to true.\n *                             Validation rules (errors returned if violated):\n *                             - Cannot be empty or whitespace only\n *                             - Cannot start or end with dot (.)\n *                             - Cannot contain consecutive dots (..)\n *                             - Cannot contain spaces\n *                             - Cannot contain special characters: ~ ^ : ? * [ \\\n *                             - Cannot contain @{\n *                             - Cannot start or end with forward slash (/)\n *                             - Cannot contain consecutive slashes (//)\n *                             - Cannot end with .lock\n *                             - Cannot contain ASCII control characters\n *                             Examples: 'feature/user-auth', 'bugfix/login-error', 'refactor/db-optimization'\n *\n * @param {boolean} createBranch - (Optional) Create a new Git branch after successful agent completion.\n *                                Default: false (or true if branchName is provided)\n *                                Behavior:\n *                                - Creates branch locally and pushes to remote\n *                                - If branch exists locally: Checks out existing branch (no error)\n *                                - If branch exists on remote: Uses existing branch (no error)\n *                                - Branch name: Custom (if branchName provided) or auto-generated from message\n *                                - Requires either githubUrl OR projectPath with GitHub remote\n *\n * @param {boolean} createPR - (Optional) Create a GitHub Pull Request after successful completion.\n *                            Default: false\n *                            Behavior:\n *                            - PR title: First commit message (or fallback to message parameter)\n *                            - PR description: Auto-generated from all commit messages\n *                            - Base branch: Always 'main' (currently hardcoded)\n *                            - If PR already exists: GitHub returns error with details\n *                            - Requires either githubUrl OR projectPath with GitHub remote\n *\n * ================================================================================================\n * PATH HANDLING BEHAVIOR\n * ================================================================================================\n *\n * Scenario 1: Only githubUrl provided\n *   Input:  { githubUrl: \"https://github.com/owner/repo\" }\n *   Action: Clones to auto-generated temporary path: ~/.claude/external-projects/<hash>/\n *   Cleanup: Yes (if cleanup=true)\n *\n * Scenario 2: Only projectPath provided\n *   Input:  { projectPath: \"/home/user/my-project\" }\n *   Action: Uses existing project at specified path\n *   Validation: Path must exist and be accessible\n *   Cleanup: No (never cleanup existing projects)\n *\n * Scenario 3: Both githubUrl and projectPath provided\n *   Input:  { githubUrl: \"https://github.com/owner/repo\", projectPath: \"/custom/path\" }\n *   Action: Clones githubUrl to projectPath location\n *   Validation:\n *     - If projectPath exists with git repo:\n *       - Compares remote URL with githubUrl\n *       - If URLs match: Reuses existing repo\n *       - If URLs differ: Returns error\n *   Cleanup: Yes (if cleanup=true)\n *\n * ================================================================================================\n * GITHUB BRANCH/PR CREATION REQUIREMENTS\n * ================================================================================================\n *\n * For createBranch or createPR to work, one of the following must be true:\n *\n * Option A: githubUrl provided\n *   - Repository URL directly specified\n *   - Works with both cloning and existing paths\n *\n * Option B: projectPath with GitHub remote\n *   - Project must be a Git repository\n *   - Must have 'origin' remote configured\n *   - Remote URL must point to github.com\n *   - System auto-detects GitHub URL via: git remote get-url origin\n *\n * Additional Requirements:\n *   - Valid GitHub token (from settings or githubToken parameter)\n *   - Token must have 'repo' scope for private repos\n *   - Project must have commits (for PR creation)\n *\n * ================================================================================================\n * VALIDATION & ERROR HANDLING\n * ================================================================================================\n *\n * Input Validations (400 Bad Request):\n *   - Either githubUrl OR projectPath must be provided (not neither)\n *   - message must be non-empty string\n *   - provider must be 'claude', 'cursor', 'codex', or 'gemini'\n *   - createBranch/createPR requires githubUrl OR projectPath (not neither)\n *   - branchName must pass Git naming rules (if provided)\n *\n * Runtime Validations (500 Internal Server Error or specific error in response):\n *   - projectPath must exist (if used alone)\n *   - GitHub URL format must be valid\n *   - Git remote URL must include github.com (for projectPath + branch/PR)\n *   - GitHub token must be available (for private repos and branch/PR)\n *   - Directory conflicts handled (existing path with different repo)\n *\n * Branch Name Validation Errors (returned in response, not HTTP error):\n *   Invalid names return: { branch: { error: \"Invalid branch name: <reason>\" } }\n *   Examples:\n *   - \"my branch\" → \"Branch name cannot contain spaces\"\n *   - \".feature\" → \"Branch name cannot start with a dot\"\n *   - \"feature.lock\" → \"Branch name cannot end with .lock\"\n *\n * ================================================================================================\n * RESPONSE FORMATS\n * ================================================================================================\n *\n * Streaming Response (stream=true):\n *   Content-Type: text/event-stream\n *   Events:\n *     - { type: \"status\", message: \"...\", projectPath: \"...\" }\n *     - { type: \"claude-response\", data: {...} }\n *     - { type: \"github-branch\", branch: { name: \"...\", url: \"...\" } }\n *     - { type: \"github-pr\", pullRequest: { number: 42, url: \"...\" } }\n *     - { type: \"github-error\", error: \"...\" }\n *     - { type: \"done\" }\n *\n * Non-Streaming Response (stream=false):\n *   Content-Type: application/json\n *   {\n *     success: true,\n *     sessionId: \"session-123\",\n *     messages: [...],        // Assistant messages only (filtered)\n *     tokens: {\n *       inputTokens: 150,\n *       outputTokens: 50,\n *       cacheReadTokens: 0,\n *       cacheCreationTokens: 0,\n *       totalTokens: 200\n *     },\n *     projectPath: \"/path/to/project\",\n *     branch: {               // Only if createBranch=true\n *       name: \"feature/xyz\",\n *       url: \"https://github.com/owner/repo/tree/feature/xyz\"\n *     } | { error: \"...\" },\n *     pullRequest: {          // Only if createPR=true\n *       number: 42,\n *       url: \"https://github.com/owner/repo/pull/42\"\n *     } | { error: \"...\" }\n *   }\n *\n * Error Response:\n *   HTTP Status: 400, 401, 500\n *   Content-Type: application/json\n *   { success: false, error: \"Error description\" }\n *\n * ================================================================================================\n * EXAMPLES\n * ================================================================================================\n *\n * Example 1: Clone and process with auto-cleanup\n *   POST /api/agent\n *   { \"githubUrl\": \"https://github.com/user/repo\", \"message\": \"Fix bug\" }\n *\n * Example 2: Use existing project with custom branch and PR\n *   POST /api/agent\n *   {\n *     \"projectPath\": \"/home/user/project\",\n *     \"message\": \"Add feature\",\n *     \"branchName\": \"feature/new-feature\",\n *     \"createPR\": true\n *   }\n *\n * Example 3: Clone to specific path with auto-generated branch\n *   POST /api/agent\n *   {\n *     \"githubUrl\": \"https://github.com/user/repo\",\n *     \"projectPath\": \"/tmp/work\",\n *     \"message\": \"Refactor code\",\n *     \"createBranch\": true,\n *     \"cleanup\": false\n *   }\n */\nrouter.post('/', validateExternalApiKey, async (req, res) => {\n  const { githubUrl, projectPath, message, provider = 'claude', model, githubToken, branchName } = req.body;\n\n  // Parse stream and cleanup as booleans (handle string \"true\"/\"false\" from curl)\n  const stream = req.body.stream === undefined ? true : (req.body.stream === true || req.body.stream === 'true');\n  const cleanup = req.body.cleanup === undefined ? true : (req.body.cleanup === true || req.body.cleanup === 'true');\n\n  // If branchName is provided, automatically enable createBranch\n  const createBranch = branchName ? true : (req.body.createBranch === true || req.body.createBranch === 'true');\n  const createPR = req.body.createPR === true || req.body.createPR === 'true';\n\n  // Validate inputs\n  if (!githubUrl && !projectPath) {\n    return res.status(400).json({ error: 'Either githubUrl or projectPath is required' });\n  }\n\n  if (!message || !message.trim()) {\n    return res.status(400).json({ error: 'message is required' });\n  }\n\n  if (!['claude', 'cursor', 'codex', 'gemini'].includes(provider)) {\n    return res.status(400).json({ error: 'provider must be \"claude\", \"cursor\", \"codex\", or \"gemini\"' });\n  }\n\n  // Validate GitHub branch/PR creation requirements\n  // Allow branch/PR creation with projectPath as long as it has a GitHub remote\n  if ((createBranch || createPR) && !githubUrl && !projectPath) {\n    return res.status(400).json({ error: 'createBranch and createPR require either githubUrl or projectPath with a GitHub remote' });\n  }\n\n  let finalProjectPath = null;\n  let writer = null;\n\n  try {\n    // Determine the final project path\n    if (githubUrl) {\n      // Clone repository (to projectPath if provided, otherwise generate path)\n      const tokenToUse = githubToken || githubTokensDb.getActiveGithubToken(req.user.id);\n\n      let targetPath;\n      if (projectPath) {\n        targetPath = projectPath;\n      } else {\n        // Generate a unique path for cloning\n        const repoHash = crypto.createHash('md5').update(githubUrl + Date.now()).digest('hex');\n        targetPath = path.join(os.homedir(), '.claude', 'external-projects', repoHash);\n      }\n\n      finalProjectPath = await cloneGitHubRepo(githubUrl.trim(), tokenToUse, targetPath);\n    } else {\n      // Use existing project path\n      finalProjectPath = path.resolve(projectPath);\n\n      // Verify the path exists\n      try {\n        await fs.access(finalProjectPath);\n      } catch (error) {\n        throw new Error(`Project path does not exist: ${finalProjectPath}`);\n      }\n    }\n\n    // Register the project (or use existing registration)\n    let project;\n    try {\n      project = await addProjectManually(finalProjectPath);\n      console.log('📦 Project registered:', project);\n    } catch (error) {\n      // If project already exists, that's fine - continue with the existing registration\n      if (error.message && error.message.includes('Project already configured')) {\n        console.log('📦 Using existing project registration for:', finalProjectPath);\n        project = { path: finalProjectPath };\n      } else {\n        throw error;\n      }\n    }\n\n    // Set up writer based on streaming mode\n    if (stream) {\n      // Set up SSE headers for streaming\n      res.setHeader('Content-Type', 'text/event-stream');\n      res.setHeader('Cache-Control', 'no-cache');\n      res.setHeader('Connection', 'keep-alive');\n      res.setHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering\n\n      writer = new SSEStreamWriter(res, req.user.id);\n\n      // Send initial status\n      writer.send({\n        type: 'status',\n        message: githubUrl ? 'Repository cloned and session started' : 'Session started',\n        projectPath: finalProjectPath\n      });\n    } else {\n      // Non-streaming mode: collect messages\n      writer = new ResponseCollector(req.user.id);\n\n      // Collect initial status message\n      writer.send({\n        type: 'status',\n        message: githubUrl ? 'Repository cloned and session started' : 'Session started',\n        projectPath: finalProjectPath\n      });\n    }\n\n    // Start the appropriate session\n    if (provider === 'claude') {\n      console.log('🤖 Starting Claude SDK session');\n\n      await queryClaudeSDK(message.trim(), {\n        projectPath: finalProjectPath,\n        cwd: finalProjectPath,\n        sessionId: null, // New session\n        model: model,\n        permissionMode: 'bypassPermissions' // Bypass all permissions for API calls\n      }, writer);\n\n    } else if (provider === 'cursor') {\n      console.log('🖱️ Starting Cursor CLI session');\n\n      await spawnCursor(message.trim(), {\n        projectPath: finalProjectPath,\n        cwd: finalProjectPath,\n        sessionId: null, // New session\n        model: model || undefined,\n        skipPermissions: true // Bypass permissions for Cursor\n      }, writer);\n    } else if (provider === 'codex') {\n      console.log('🤖 Starting Codex SDK session');\n\n      await queryCodex(message.trim(), {\n        projectPath: finalProjectPath,\n        cwd: finalProjectPath,\n        sessionId: null,\n        model: model || CODEX_MODELS.DEFAULT,\n        permissionMode: 'bypassPermissions'\n      }, writer);\n    } else if (provider === 'gemini') {\n      console.log('✨ Starting Gemini CLI session');\n\n      await spawnGemini(message.trim(), {\n        projectPath: finalProjectPath,\n        cwd: finalProjectPath,\n        sessionId: null,\n        model: model,\n        skipPermissions: true // CLI mode bypasses permissions\n      }, writer);\n    }\n\n    // Handle GitHub branch and PR creation after successful agent completion\n    let branchInfo = null;\n    let prInfo = null;\n\n    if (createBranch || createPR) {\n      try {\n        console.log('🔄 Starting GitHub branch/PR creation workflow...');\n\n        // Get GitHub token\n        const tokenToUse = githubToken || githubTokensDb.getActiveGithubToken(req.user.id);\n\n        if (!tokenToUse) {\n          throw new Error('GitHub token required for branch/PR creation. Please configure a GitHub token in settings.');\n        }\n\n        // Initialize Octokit\n        const octokit = new Octokit({ auth: tokenToUse });\n\n        // Get GitHub URL - either from parameter or from git remote\n        let repoUrl = githubUrl;\n        if (!repoUrl) {\n          console.log('🔍 Getting GitHub URL from git remote...');\n          try {\n            repoUrl = await getGitRemoteUrl(finalProjectPath);\n            if (!repoUrl.includes('github.com')) {\n              throw new Error('Project does not have a GitHub remote configured');\n            }\n            console.log(`✅ Found GitHub remote: ${repoUrl}`);\n          } catch (error) {\n            throw new Error(`Failed to get GitHub remote URL: ${error.message}`);\n          }\n        }\n\n        // Parse GitHub URL to get owner and repo\n        const { owner, repo } = parseGitHubUrl(repoUrl);\n        console.log(`📦 Repository: ${owner}/${repo}`);\n\n        // Use provided branch name or auto-generate from message\n        const finalBranchName = branchName || autogenerateBranchName(message);\n        if (branchName) {\n          console.log(`🌿 Using provided branch name: ${finalBranchName}`);\n\n          // Validate custom branch name\n          const validation = validateBranchName(finalBranchName);\n          if (!validation.valid) {\n            throw new Error(`Invalid branch name: ${validation.error}`);\n          }\n        } else {\n          console.log(`🌿 Auto-generated branch name: ${finalBranchName}`);\n        }\n\n        if (createBranch) {\n          // Create and checkout the new branch locally\n          console.log('🔄 Creating local branch...');\n          const checkoutProcess = spawn('git', ['checkout', '-b', finalBranchName], {\n            cwd: finalProjectPath,\n            stdio: 'pipe'\n          });\n\n          await new Promise((resolve, reject) => {\n            let stderr = '';\n            checkoutProcess.stderr.on('data', (data) => { stderr += data.toString(); });\n            checkoutProcess.on('close', (code) => {\n              if (code === 0) {\n                console.log(`✅ Created and checked out local branch '${finalBranchName}'`);\n                resolve();\n              } else {\n                // Branch might already exist locally, try to checkout\n                if (stderr.includes('already exists')) {\n                  console.log(`ℹ️ Branch '${finalBranchName}' already exists locally, checking out...`);\n                  const checkoutExisting = spawn('git', ['checkout', finalBranchName], {\n                    cwd: finalProjectPath,\n                    stdio: 'pipe'\n                  });\n                  checkoutExisting.on('close', (checkoutCode) => {\n                    if (checkoutCode === 0) {\n                      console.log(`✅ Checked out existing branch '${finalBranchName}'`);\n                      resolve();\n                    } else {\n                      reject(new Error(`Failed to checkout existing branch: ${stderr}`));\n                    }\n                  });\n                } else {\n                  reject(new Error(`Failed to create branch: ${stderr}`));\n                }\n              }\n            });\n          });\n\n          // Push the branch to remote\n          console.log('🔄 Pushing branch to remote...');\n          const pushProcess = spawn('git', ['push', '-u', 'origin', finalBranchName], {\n            cwd: finalProjectPath,\n            stdio: 'pipe'\n          });\n\n          await new Promise((resolve, reject) => {\n            let stderr = '';\n            let stdout = '';\n            pushProcess.stdout.on('data', (data) => { stdout += data.toString(); });\n            pushProcess.stderr.on('data', (data) => { stderr += data.toString(); });\n            pushProcess.on('close', (code) => {\n              if (code === 0) {\n                console.log(`✅ Pushed branch '${finalBranchName}' to remote`);\n                resolve();\n              } else {\n                // Check if branch exists on remote but has different commits\n                if (stderr.includes('already exists') || stderr.includes('up-to-date')) {\n                  console.log(`ℹ️ Branch '${finalBranchName}' already exists on remote, using existing branch`);\n                  resolve();\n                } else {\n                  reject(new Error(`Failed to push branch: ${stderr}`));\n                }\n              }\n            });\n          });\n\n          branchInfo = {\n            name: finalBranchName,\n            url: `https://github.com/${owner}/${repo}/tree/${finalBranchName}`\n          };\n        }\n\n        if (createPR) {\n          // Get commit messages to generate PR description\n          console.log('🔄 Generating PR title and description...');\n          const commitMessages = await getCommitMessages(finalProjectPath, 5);\n\n          // Use the first commit message as the PR title, or fallback to the agent message\n          const prTitle = commitMessages.length > 0 ? commitMessages[0] : message;\n\n          // Generate PR body from commit messages\n          let prBody = '## Changes\\n\\n';\n          if (commitMessages.length > 0) {\n            prBody += commitMessages.map(msg => `- ${msg}`).join('\\n');\n          } else {\n            prBody += `Agent task: ${message}`;\n          }\n          prBody += '\\n\\n---\\n*This pull request was automatically created by Claude Code UI Agent.*';\n\n          console.log(`📝 PR Title: ${prTitle}`);\n\n          // Create the pull request\n          console.log('🔄 Creating pull request...');\n          prInfo = await createGitHubPR(octokit, owner, repo, finalBranchName, prTitle, prBody, 'main');\n        }\n\n        // Send branch/PR info in response\n        if (stream) {\n          if (branchInfo) {\n            writer.send({\n              type: 'github-branch',\n              branch: branchInfo\n            });\n          }\n          if (prInfo) {\n            writer.send({\n              type: 'github-pr',\n              pullRequest: prInfo\n            });\n          }\n        }\n\n      } catch (error) {\n        console.error('❌ GitHub branch/PR creation error:', error);\n\n        // Send error but don't fail the entire request\n        if (stream) {\n          writer.send({\n            type: 'github-error',\n            error: error.message\n          });\n        }\n        // Store error info for non-streaming response\n        if (!stream) {\n          branchInfo = { error: error.message };\n          prInfo = { error: error.message };\n        }\n      }\n    }\n\n    // Handle response based on streaming mode\n    if (stream) {\n      // Streaming mode: end the SSE stream\n      writer.end();\n    } else {\n      // Non-streaming mode: send filtered messages and token summary as JSON\n      const assistantMessages = writer.getAssistantMessages();\n      const tokenSummary = writer.getTotalTokens();\n\n      const response = {\n        success: true,\n        sessionId: writer.getSessionId(),\n        messages: assistantMessages,\n        tokens: tokenSummary,\n        projectPath: finalProjectPath\n      };\n\n      // Add branch/PR info if created\n      if (branchInfo) {\n        response.branch = branchInfo;\n      }\n      if (prInfo) {\n        response.pullRequest = prInfo;\n      }\n\n      res.json(response);\n    }\n\n    // Clean up if requested\n    if (cleanup && githubUrl) {\n      // Only cleanup if we cloned a repo (not for existing project paths)\n      const sessionIdForCleanup = writer.getSessionId();\n      setTimeout(() => {\n        cleanupProject(finalProjectPath, sessionIdForCleanup);\n      }, 5000);\n    }\n\n  } catch (error) {\n    console.error('❌ External session error:', error);\n\n    // Clean up on error\n    if (finalProjectPath && cleanup && githubUrl) {\n      const sessionIdForCleanup = writer ? writer.getSessionId() : null;\n      cleanupProject(finalProjectPath, sessionIdForCleanup);\n    }\n\n    if (stream) {\n      // For streaming, send error event and stop\n      if (!writer) {\n        // Set up SSE headers if not already done\n        res.setHeader('Content-Type', 'text/event-stream');\n        res.setHeader('Cache-Control', 'no-cache');\n        res.setHeader('Connection', 'keep-alive');\n        res.setHeader('X-Accel-Buffering', 'no');\n        writer = new SSEStreamWriter(res, req.user.id);\n      }\n\n      if (!res.writableEnded) {\n        writer.send({\n          type: 'error',\n          error: error.message,\n          message: `Failed: ${error.message}`\n        });\n        writer.end();\n      }\n    } else if (!res.headersSent) {\n      res.status(500).json({\n        success: false,\n        error: error.message\n      });\n    }\n  }\n});\n\nexport default router;\n"
  },
  {
    "path": "server/routes/auth.js",
    "content": "import express from 'express';\nimport bcrypt from 'bcrypt';\nimport { userDb, db } from '../database/db.js';\nimport { generateToken, authenticateToken } from '../middleware/auth.js';\n\nconst router = express.Router();\n\n// Check auth status and setup requirements\nrouter.get('/status', async (req, res) => {\n  try {\n    const hasUsers = await userDb.hasUsers();\n    res.json({ \n      needsSetup: !hasUsers,\n      isAuthenticated: false // Will be overridden by frontend if token exists\n    });\n  } catch (error) {\n    console.error('Auth status error:', error);\n    res.status(500).json({ error: 'Internal server error' });\n  }\n});\n\n// User registration (setup) - only allowed if no users exist\nrouter.post('/register', async (req, res) => {\n  try {\n    const { username, password } = req.body;\n    \n    // Validate input\n    if (!username || !password) {\n      return res.status(400).json({ error: 'Username and password are required' });\n    }\n    \n    if (username.length < 3 || password.length < 6) {\n      return res.status(400).json({ error: 'Username must be at least 3 characters, password at least 6 characters' });\n    }\n    \n    // Use a transaction to prevent race conditions\n    db.prepare('BEGIN').run();\n    try {\n      // Check if users already exist (only allow one user)\n      const hasUsers = userDb.hasUsers();\n      if (hasUsers) {\n        db.prepare('ROLLBACK').run();\n        return res.status(403).json({ error: 'User already exists. This is a single-user system.' });\n      }\n      \n      // Hash password\n      const saltRounds = 12;\n      const passwordHash = await bcrypt.hash(password, saltRounds);\n      \n      // Create user\n      const user = userDb.createUser(username, passwordHash);\n      \n      // Generate token\n      const token = generateToken(user);\n      \n      db.prepare('COMMIT').run();\n\n      // Update last login (non-fatal, outside transaction)\n      userDb.updateLastLogin(user.id);\n\n      res.json({\n        success: true,\n        user: { id: user.id, username: user.username },\n        token\n      });\n    } catch (error) {\n      db.prepare('ROLLBACK').run();\n      throw error;\n    }\n    \n  } catch (error) {\n    console.error('Registration error:', error);\n    if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') {\n      res.status(409).json({ error: 'Username already exists' });\n    } else {\n      res.status(500).json({ error: 'Internal server error' });\n    }\n  }\n});\n\n// User login\nrouter.post('/login', async (req, res) => {\n  try {\n    const { username, password } = req.body;\n    \n    // Validate input\n    if (!username || !password) {\n      return res.status(400).json({ error: 'Username and password are required' });\n    }\n    \n    // Get user from database\n    const user = userDb.getUserByUsername(username);\n    if (!user) {\n      return res.status(401).json({ error: 'Invalid username or password' });\n    }\n    \n    // Verify password\n    const isValidPassword = await bcrypt.compare(password, user.password_hash);\n    if (!isValidPassword) {\n      return res.status(401).json({ error: 'Invalid username or password' });\n    }\n    \n    // Generate token\n    const token = generateToken(user);\n    \n    // Update last login\n    userDb.updateLastLogin(user.id);\n    \n    res.json({\n      success: true,\n      user: { id: user.id, username: user.username },\n      token\n    });\n    \n  } catch (error) {\n    console.error('Login error:', error);\n    res.status(500).json({ error: 'Internal server error' });\n  }\n});\n\n// Get current user (protected route)\nrouter.get('/user', authenticateToken, (req, res) => {\n  res.json({\n    user: req.user\n  });\n});\n\n// Logout (client-side token removal, but this endpoint can be used for logging)\nrouter.post('/logout', authenticateToken, (req, res) => {\n  // In a simple JWT system, logout is mainly client-side\n  // This endpoint exists for consistency and potential future logging\n  res.json({ success: true, message: 'Logged out successfully' });\n});\n\nexport default router;"
  },
  {
    "path": "server/routes/cli-auth.js",
    "content": "import express from 'express';\nimport { spawn } from 'child_process';\nimport fs from 'fs/promises';\nimport path from 'path';\nimport os from 'os';\n\nconst router = express.Router();\n\nrouter.get('/claude/status', async (req, res) => {\n  try {\n    const credentialsResult = await checkClaudeCredentials();\n\n    if (credentialsResult.authenticated) {\n      return res.json({\n        authenticated: true,\n        email: credentialsResult.email || 'Authenticated',\n        method: credentialsResult.method  // 'api_key' or 'credentials_file'\n      });\n    }\n\n    return res.json({\n      authenticated: false,\n      email: null,\n      method: null,\n      error: credentialsResult.error || 'Not authenticated'\n    });\n\n  } catch (error) {\n    console.error('Error checking Claude auth status:', error);\n    res.status(500).json({\n      authenticated: false,\n      email: null,\n      method: null,\n      error: error.message\n    });\n  }\n});\n\nrouter.get('/cursor/status', async (req, res) => {\n  try {\n    const result = await checkCursorStatus();\n\n    res.json({\n      authenticated: result.authenticated,\n      email: result.email,\n      error: result.error\n    });\n\n  } catch (error) {\n    console.error('Error checking Cursor auth status:', error);\n    res.status(500).json({\n      authenticated: false,\n      email: null,\n      error: error.message\n    });\n  }\n});\n\nrouter.get('/codex/status', async (req, res) => {\n  try {\n    const result = await checkCodexCredentials();\n\n    res.json({\n      authenticated: result.authenticated,\n      email: result.email,\n      error: result.error\n    });\n\n  } catch (error) {\n    console.error('Error checking Codex auth status:', error);\n    res.status(500).json({\n      authenticated: false,\n      email: null,\n      error: error.message\n    });\n  }\n});\n\nrouter.get('/gemini/status', async (req, res) => {\n  try {\n    const result = await checkGeminiCredentials();\n\n    res.json({\n      authenticated: result.authenticated,\n      email: result.email,\n      error: result.error\n    });\n\n  } catch (error) {\n    console.error('Error checking Gemini auth status:', error);\n    res.status(500).json({\n      authenticated: false,\n      email: null,\n      error: error.message\n    });\n  }\n});\n\nasync function loadClaudeSettingsEnv() {\n  try {\n    const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');\n    const content = await fs.readFile(settingsPath, 'utf8');\n    const settings = JSON.parse(content);\n\n    if (settings?.env && typeof settings.env === 'object') {\n      return settings.env;\n    }\n  } catch (error) {\n    // Ignore missing or malformed settings and fall back to other auth sources.\n  }\n\n  return {};\n}\n\n/**\n * Checks Claude authentication credentials using two methods with priority order:\n *\n * Priority 1: ANTHROPIC_API_KEY environment variable\n * Priority 1b: ~/.claude/settings.json env values\n * Priority 2: ~/.claude/.credentials.json OAuth tokens\n *\n * The Claude Agent SDK prioritizes environment variables over authenticated subscriptions.\n * This matching behavior ensures consistency with how the SDK authenticates.\n *\n * References:\n * - https://support.claude.com/en/articles/12304248-managing-api-key-environment-variables-in-claude-code\n *   \"Claude Code prioritizes environment variable API keys over authenticated subscriptions\"\n * - https://platform.claude.com/docs/en/agent-sdk/overview\n *   SDK authentication documentation\n *\n * @returns {Promise<Object>} Authentication status with { authenticated, email, method }\n *   - authenticated: boolean indicating if valid credentials exist\n *   - email: user email or auth method identifier\n *   - method: 'api_key' for env var, 'credentials_file' for OAuth tokens\n */\nasync function checkClaudeCredentials() {\n  // Priority 1: Check for ANTHROPIC_API_KEY environment variable\n  // The SDK checks this first and uses it if present, even if OAuth tokens exist.\n  // When set, API calls are charged via pay-as-you-go rates instead of subscription.\n  if (process.env.ANTHROPIC_API_KEY && process.env.ANTHROPIC_API_KEY.trim()) {\n    return {\n      authenticated: true,\n      email: 'API Key Auth',\n      method: 'api_key'\n    };\n  }\n\n  // Priority 1b: Check ~/.claude/settings.json env values.\n  // Claude Code can read proxy/auth values from settings.json even when the\n  // CloudCLI server process itself was not started with those env vars exported.\n  const settingsEnv = await loadClaudeSettingsEnv();\n\n  if (typeof settingsEnv.ANTHROPIC_API_KEY === 'string' && settingsEnv.ANTHROPIC_API_KEY.trim()) {\n    return {\n      authenticated: true,\n      email: 'API Key Auth',\n      method: 'api_key'\n    };\n  }\n\n  if (typeof settingsEnv.ANTHROPIC_AUTH_TOKEN === 'string' && settingsEnv.ANTHROPIC_AUTH_TOKEN.trim()) {\n    return {\n      authenticated: true,\n      email: 'Configured via settings.json',\n      method: 'api_key'\n    };\n  }\n\n  // Priority 2: Check ~/.claude/.credentials.json for OAuth tokens\n  // This is the standard authentication method used by Claude CLI after running\n  // 'claude /login' or 'claude setup-token' commands.\n  try {\n    const credPath = path.join(os.homedir(), '.claude', '.credentials.json');\n    const content = await fs.readFile(credPath, 'utf8');\n    const creds = JSON.parse(content);\n\n    const oauth = creds.claudeAiOauth;\n    if (oauth && oauth.accessToken) {\n      const isExpired = oauth.expiresAt && Date.now() >= oauth.expiresAt;\n\n      if (!isExpired) {\n        return {\n          authenticated: true,\n          email: creds.email || creds.user || null,\n          method: 'credentials_file'\n        };\n      }\n    }\n\n    return {\n      authenticated: false,\n      email: null,\n      method: null\n    };\n  } catch (error) {\n    return {\n      authenticated: false,\n      email: null,\n      method: null\n    };\n  }\n}\n\nfunction checkCursorStatus() {\n  return new Promise((resolve) => {\n    let processCompleted = false;\n\n    const timeout = setTimeout(() => {\n      if (!processCompleted) {\n        processCompleted = true;\n        if (childProcess) {\n          childProcess.kill();\n        }\n        resolve({\n          authenticated: false,\n          email: null,\n          error: 'Command timeout'\n        });\n      }\n    }, 5000);\n\n    let childProcess;\n    try {\n      childProcess = spawn('cursor-agent', ['status']);\n    } catch (err) {\n      clearTimeout(timeout);\n      processCompleted = true;\n      resolve({\n        authenticated: false,\n        email: null,\n        error: 'Cursor CLI not found or not installed'\n      });\n      return;\n    }\n\n    let stdout = '';\n    let stderr = '';\n\n    childProcess.stdout.on('data', (data) => {\n      stdout += data.toString();\n    });\n\n    childProcess.stderr.on('data', (data) => {\n      stderr += data.toString();\n    });\n\n    childProcess.on('close', (code) => {\n      if (processCompleted) return;\n      processCompleted = true;\n      clearTimeout(timeout);\n\n      if (code === 0) {\n        const emailMatch = stdout.match(/Logged in as ([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,})/i);\n\n        if (emailMatch) {\n          resolve({\n            authenticated: true,\n            email: emailMatch[1],\n            output: stdout\n          });\n        } else if (stdout.includes('Logged in')) {\n          resolve({\n            authenticated: true,\n            email: 'Logged in',\n            output: stdout\n          });\n        } else {\n          resolve({\n            authenticated: false,\n            email: null,\n            error: 'Not logged in'\n          });\n        }\n      } else {\n        resolve({\n          authenticated: false,\n          email: null,\n          error: stderr || 'Not logged in'\n        });\n      }\n    });\n\n    childProcess.on('error', (err) => {\n      if (processCompleted) return;\n      processCompleted = true;\n      clearTimeout(timeout);\n\n      resolve({\n        authenticated: false,\n        email: null,\n        error: 'Cursor CLI not found or not installed'\n      });\n    });\n  });\n}\n\nasync function checkCodexCredentials() {\n  try {\n    const authPath = path.join(os.homedir(), '.codex', 'auth.json');\n    const content = await fs.readFile(authPath, 'utf8');\n    const auth = JSON.parse(content);\n\n    // Tokens are nested under 'tokens' key\n    const tokens = auth.tokens || {};\n\n    // Check for valid tokens (id_token or access_token)\n    if (tokens.id_token || tokens.access_token) {\n      // Try to extract email from id_token JWT payload\n      let email = 'Authenticated';\n      if (tokens.id_token) {\n        try {\n          // JWT is base64url encoded: header.payload.signature\n          const parts = tokens.id_token.split('.');\n          if (parts.length >= 2) {\n            // Decode the payload (second part)\n            const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));\n            email = payload.email || payload.user || 'Authenticated';\n          }\n        } catch {\n          // If JWT decoding fails, use fallback\n          email = 'Authenticated';\n        }\n      }\n\n      return {\n        authenticated: true,\n        email\n      };\n    }\n\n    // Also check for OPENAI_API_KEY as fallback auth method\n    if (auth.OPENAI_API_KEY) {\n      return {\n        authenticated: true,\n        email: 'API Key Auth'\n      };\n    }\n\n    return {\n      authenticated: false,\n      email: null,\n      error: 'No valid tokens found'\n    };\n  } catch (error) {\n    if (error.code === 'ENOENT') {\n      return {\n        authenticated: false,\n        email: null,\n        error: 'Codex not configured'\n      };\n    }\n    return {\n      authenticated: false,\n      email: null,\n      error: error.message\n    };\n  }\n}\n\nasync function checkGeminiCredentials() {\n  if (process.env.GEMINI_API_KEY && process.env.GEMINI_API_KEY.trim()) {\n    return {\n      authenticated: true,\n      email: 'API Key Auth'\n    };\n  }\n\n  try {\n    const credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json');\n    const content = await fs.readFile(credsPath, 'utf8');\n    const creds = JSON.parse(content);\n\n    if (creds.access_token) {\n      let email = 'OAuth Session';\n\n      try {\n        // Validate token against Google API\n        const tokenRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${creds.access_token}`);\n        if (tokenRes.ok) {\n          const tokenInfo = await tokenRes.json();\n          if (tokenInfo.email) {\n            email = tokenInfo.email;\n          }\n        } else if (!creds.refresh_token) {\n          // Token invalid and no refresh token available\n          return {\n            authenticated: false,\n            email: null,\n            error: 'Access token invalid and no refresh token found'\n          };\n        } else {\n          // Token might be expired but we have a refresh token, so CLI will refresh it\n          try {\n            const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json');\n            const accContent = await fs.readFile(accPath, 'utf8');\n            const accounts = JSON.parse(accContent);\n            if (accounts.active) {\n              email = accounts.active;\n            }\n          } catch (e) { }\n        }\n      } catch (e) {\n        // Network error, fallback to checking local accounts file\n        try {\n          const accPath = path.join(os.homedir(), '.gemini', 'google_accounts.json');\n          const accContent = await fs.readFile(accPath, 'utf8');\n          const accounts = JSON.parse(accContent);\n          if (accounts.active) {\n            email = accounts.active;\n          }\n        } catch (err) { }\n      }\n\n      return {\n        authenticated: true,\n        email: email\n      };\n    }\n\n    return {\n      authenticated: false,\n      email: null,\n      error: 'No valid tokens found in oauth_creds'\n    };\n  } catch (error) {\n    return {\n      authenticated: false,\n      email: null,\n      error: 'Gemini CLI not configured'\n    };\n  }\n}\n\nexport default router;\n"
  },
  {
    "path": "server/routes/codex.js",
    "content": "import express from 'express';\nimport { spawn } from 'child_process';\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport os from 'os';\nimport TOML from '@iarna/toml';\nimport { getCodexSessions, deleteCodexSession } from '../projects.js';\nimport { applyCustomSessionNames, sessionNamesDb } from '../database/db.js';\n\nconst router = express.Router();\n\nfunction createCliResponder(res) {\n  let responded = false;\n  return (status, payload) => {\n    if (responded || res.headersSent) {\n      return;\n    }\n    responded = true;\n    res.status(status).json(payload);\n  };\n}\n\nrouter.get('/config', async (req, res) => {\n  try {\n    const configPath = path.join(os.homedir(), '.codex', 'config.toml');\n    const content = await fs.readFile(configPath, 'utf8');\n    const config = TOML.parse(content);\n\n    res.json({\n      success: true,\n      config: {\n        model: config.model || null,\n        mcpServers: config.mcp_servers || {},\n        approvalMode: config.approval_mode || 'suggest'\n      }\n    });\n  } catch (error) {\n    if (error.code === 'ENOENT') {\n      res.json({\n        success: true,\n        config: {\n          model: null,\n          mcpServers: {},\n          approvalMode: 'suggest'\n        }\n      });\n    } else {\n      console.error('Error reading Codex config:', error);\n      res.status(500).json({ success: false, error: error.message });\n    }\n  }\n});\n\nrouter.get('/sessions', async (req, res) => {\n  try {\n    const { projectPath } = req.query;\n\n    if (!projectPath) {\n      return res.status(400).json({ success: false, error: 'projectPath query parameter required' });\n    }\n\n    const sessions = await getCodexSessions(projectPath);\n    applyCustomSessionNames(sessions, 'codex');\n    res.json({ success: true, sessions });\n  } catch (error) {\n    console.error('Error fetching Codex sessions:', error);\n    res.status(500).json({ success: false, error: error.message });\n  }\n});\n\nrouter.delete('/sessions/:sessionId', async (req, res) => {\n  try {\n    const { sessionId } = req.params;\n    await deleteCodexSession(sessionId);\n    sessionNamesDb.deleteName(sessionId, 'codex');\n    res.json({ success: true });\n  } catch (error) {\n    console.error(`Error deleting Codex session ${req.params.sessionId}:`, error);\n    res.status(500).json({ success: false, error: error.message });\n  }\n});\n\n// MCP Server Management Routes\n\nrouter.get('/mcp/cli/list', async (req, res) => {\n  try {\n    const respond = createCliResponder(res);\n    const proc = spawn('codex', ['mcp', 'list'], { stdio: ['pipe', 'pipe', 'pipe'] });\n\n    let stdout = '';\n    let stderr = '';\n\n    proc.stdout?.on('data', (data) => { stdout += data.toString(); });\n    proc.stderr?.on('data', (data) => { stderr += data.toString(); });\n\n    proc.on('close', (code) => {\n      if (code === 0) {\n        respond(200, { success: true, output: stdout, servers: parseCodexListOutput(stdout) });\n      } else {\n        respond(500, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });\n      }\n    });\n\n    proc.on('error', (error) => {\n      const isMissing = error?.code === 'ENOENT';\n      respond(isMissing ? 503 : 500, {\n        error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',\n        details: error.message,\n        code: error.code\n      });\n    });\n  } catch (error) {\n    res.status(500).json({ error: 'Failed to list MCP servers', details: error.message });\n  }\n});\n\nrouter.post('/mcp/cli/add', async (req, res) => {\n  try {\n    const { name, command, args = [], env = {} } = req.body;\n\n    if (!name || !command) {\n      return res.status(400).json({ error: 'name and command are required' });\n    }\n\n    // Build: codex mcp add <name> [-e KEY=VAL]... -- <command> [args...]\n    let cliArgs = ['mcp', 'add', name];\n\n    Object.entries(env).forEach(([key, value]) => {\n      cliArgs.push('-e', `${key}=${value}`);\n    });\n\n    cliArgs.push('--', command);\n\n    if (args && args.length > 0) {\n      cliArgs.push(...args);\n    }\n\n    const respond = createCliResponder(res);\n    const proc = spawn('codex', cliArgs, { stdio: ['pipe', 'pipe', 'pipe'] });\n\n    let stdout = '';\n    let stderr = '';\n\n    proc.stdout?.on('data', (data) => { stdout += data.toString(); });\n    proc.stderr?.on('data', (data) => { stderr += data.toString(); });\n\n    proc.on('close', (code) => {\n      if (code === 0) {\n        respond(200, { success: true, output: stdout, message: `MCP server \"${name}\" added successfully` });\n      } else {\n        respond(400, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });\n      }\n    });\n\n    proc.on('error', (error) => {\n      const isMissing = error?.code === 'ENOENT';\n      respond(isMissing ? 503 : 500, {\n        error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',\n        details: error.message,\n        code: error.code\n      });\n    });\n  } catch (error) {\n    res.status(500).json({ error: 'Failed to add MCP server', details: error.message });\n  }\n});\n\nrouter.delete('/mcp/cli/remove/:name', async (req, res) => {\n  try {\n    const { name } = req.params;\n\n    const respond = createCliResponder(res);\n    const proc = spawn('codex', ['mcp', 'remove', name], { stdio: ['pipe', 'pipe', 'pipe'] });\n\n    let stdout = '';\n    let stderr = '';\n\n    proc.stdout?.on('data', (data) => { stdout += data.toString(); });\n    proc.stderr?.on('data', (data) => { stderr += data.toString(); });\n\n    proc.on('close', (code) => {\n      if (code === 0) {\n        respond(200, { success: true, output: stdout, message: `MCP server \"${name}\" removed successfully` });\n      } else {\n        respond(400, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });\n      }\n    });\n\n    proc.on('error', (error) => {\n      const isMissing = error?.code === 'ENOENT';\n      respond(isMissing ? 503 : 500, {\n        error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',\n        details: error.message,\n        code: error.code\n      });\n    });\n  } catch (error) {\n    res.status(500).json({ error: 'Failed to remove MCP server', details: error.message });\n  }\n});\n\nrouter.get('/mcp/cli/get/:name', async (req, res) => {\n  try {\n    const { name } = req.params;\n\n    const respond = createCliResponder(res);\n    const proc = spawn('codex', ['mcp', 'get', name], { stdio: ['pipe', 'pipe', 'pipe'] });\n\n    let stdout = '';\n    let stderr = '';\n\n    proc.stdout?.on('data', (data) => { stdout += data.toString(); });\n    proc.stderr?.on('data', (data) => { stderr += data.toString(); });\n\n    proc.on('close', (code) => {\n      if (code === 0) {\n        respond(200, { success: true, output: stdout, server: parseCodexGetOutput(stdout) });\n      } else {\n        respond(404, { error: 'Codex CLI command failed', details: stderr || `Exited with code ${code}` });\n      }\n    });\n\n    proc.on('error', (error) => {\n      const isMissing = error?.code === 'ENOENT';\n      respond(isMissing ? 503 : 500, {\n        error: isMissing ? 'Codex CLI not installed' : 'Failed to run Codex CLI',\n        details: error.message,\n        code: error.code\n      });\n    });\n  } catch (error) {\n    res.status(500).json({ error: 'Failed to get MCP server details', details: error.message });\n  }\n});\n\nrouter.get('/mcp/config/read', async (req, res) => {\n  try {\n    const configPath = path.join(os.homedir(), '.codex', 'config.toml');\n\n    let configData = null;\n\n    try {\n      const fileContent = await fs.readFile(configPath, 'utf8');\n      configData = TOML.parse(fileContent);\n    } catch (error) {\n      // Config file doesn't exist\n    }\n\n    if (!configData) {\n      return res.json({ success: true, configPath, servers: [] });    }\n\n    const servers = [];\n\n    if (configData.mcp_servers && typeof configData.mcp_servers === 'object') {\n      for (const [name, config] of Object.entries(configData.mcp_servers)) {\n        servers.push({\n          id: name,\n          name: name,\n          type: 'stdio',\n          scope: 'user',\n          config: {\n            command: config.command || '',\n            args: config.args || [],\n            env: config.env || {}\n          },\n          raw: config\n        });\n      }\n    }\n\n    res.json({ success: true, configPath, servers });\n  } catch (error) {\n    res.status(500).json({ error: 'Failed to read Codex configuration', details: error.message });\n  }\n});\n\nfunction parseCodexListOutput(output) {\n  const servers = [];\n  const lines = output.split('\\n').filter(line => line.trim());\n\n  for (const line of lines) {\n    if (line.includes(':')) {\n      const colonIndex = line.indexOf(':');\n      const name = line.substring(0, colonIndex).trim();\n\n      if (!name) continue;\n\n      const rest = line.substring(colonIndex + 1).trim();\n      let description = rest;\n      let status = 'unknown';\n\n      if (rest.includes('✓') || rest.includes('✗')) {\n        const statusMatch = rest.match(/(.*?)\\s*-\\s*([✓✗].*)$/);\n        if (statusMatch) {\n          description = statusMatch[1].trim();\n          status = statusMatch[2].includes('✓') ? 'connected' : 'failed';\n        }\n      }\n\n      servers.push({ name, type: 'stdio', status, description });\n    }\n  }\n\n  return servers;\n}\n\nfunction parseCodexGetOutput(output) {\n  try {\n    const jsonMatch = output.match(/\\{[\\s\\S]*\\}/);\n    if (jsonMatch) {\n      return JSON.parse(jsonMatch[0]);\n    }\n\n    const server = { raw_output: output };\n    const lines = output.split('\\n');\n\n    for (const line of lines) {\n      if (line.includes('Name:')) server.name = line.split(':')[1]?.trim();\n      else if (line.includes('Type:')) server.type = line.split(':')[1]?.trim();\n      else if (line.includes('Command:')) server.command = line.split(':')[1]?.trim();\n    }\n\n    return server;\n  } catch (error) {\n    return { raw_output: output, parse_error: error.message };\n  }\n}\n\nexport default router;\n"
  },
  {
    "path": "server/routes/commands.js",
    "content": "import express from 'express';\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport { fileURLToPath } from 'url';\nimport os from 'os';\nimport { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';\nimport { parseFrontmatter } from '../utils/frontmatter.js';\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = path.dirname(__filename);\n\nconst router = express.Router();\n\n/**\n * Recursively scan directory for command files (.md)\n * @param {string} dir - Directory to scan\n * @param {string} baseDir - Base directory for relative paths\n * @param {string} namespace - Namespace for commands (e.g., 'project', 'user')\n * @returns {Promise<Array>} Array of command objects\n */\nasync function scanCommandsDirectory(dir, baseDir, namespace) {\n  const commands = [];\n\n  try {\n    // Check if directory exists\n    await fs.access(dir);\n\n    const entries = await fs.readdir(dir, { withFileTypes: true });\n\n    for (const entry of entries) {\n      const fullPath = path.join(dir, entry.name);\n\n      if (entry.isDirectory()) {\n        // Recursively scan subdirectories\n        const subCommands = await scanCommandsDirectory(fullPath, baseDir, namespace);\n        commands.push(...subCommands);\n      } else if (entry.isFile() && entry.name.endsWith('.md')) {\n        // Parse markdown file for metadata\n        try {\n          const content = await fs.readFile(fullPath, 'utf8');\n          const { data: frontmatter, content: commandContent } = parseFrontmatter(content);\n\n          // Calculate relative path from baseDir for command name\n          const relativePath = path.relative(baseDir, fullPath);\n          // Remove .md extension and convert to command name\n          const commandName = '/' + relativePath.replace(/\\.md$/, '').replace(/\\\\/g, '/');\n\n          // Extract description from frontmatter or first line of content\n          let description = frontmatter.description || '';\n          if (!description) {\n            const firstLine = commandContent.trim().split('\\n')[0];\n            description = firstLine.replace(/^#+\\s*/, '').trim();\n          }\n\n          commands.push({\n            name: commandName,\n            path: fullPath,\n            relativePath,\n            description,\n            namespace,\n            metadata: frontmatter\n          });\n        } catch (err) {\n          console.error(`Error parsing command file ${fullPath}:`, err.message);\n        }\n      }\n    }\n  } catch (err) {\n    // Directory doesn't exist or can't be accessed - this is okay\n    if (err.code !== 'ENOENT' && err.code !== 'EACCES') {\n      console.error(`Error scanning directory ${dir}:`, err.message);\n    }\n  }\n\n  return commands;\n}\n\n/**\n * Built-in commands that are always available\n */\nconst builtInCommands = [\n  {\n    name: '/help',\n    description: 'Show help documentation for Claude Code',\n    namespace: 'builtin',\n    metadata: { type: 'builtin' }\n  },\n  {\n    name: '/clear',\n    description: 'Clear the conversation history',\n    namespace: 'builtin',\n    metadata: { type: 'builtin' }\n  },\n  {\n    name: '/model',\n    description: 'Switch or view the current AI model',\n    namespace: 'builtin',\n    metadata: { type: 'builtin' }\n  },\n  {\n    name: '/cost',\n    description: 'Display token usage and cost information',\n    namespace: 'builtin',\n    metadata: { type: 'builtin' }\n  },\n  {\n    name: '/memory',\n    description: 'Open CLAUDE.md memory file for editing',\n    namespace: 'builtin',\n    metadata: { type: 'builtin' }\n  },\n  {\n    name: '/config',\n    description: 'Open settings and configuration',\n    namespace: 'builtin',\n    metadata: { type: 'builtin' }\n  },\n  {\n    name: '/status',\n    description: 'Show system status and version information',\n    namespace: 'builtin',\n    metadata: { type: 'builtin' }\n  },\n  {\n    name: '/rewind',\n    description: 'Rewind the conversation to a previous state',\n    namespace: 'builtin',\n    metadata: { type: 'builtin' }\n  }\n];\n\n/**\n * Built-in command handlers\n * Each handler returns { type: 'builtin', action: string, data: any }\n */\nconst builtInHandlers = {\n  '/help': async (args, context) => {\n    const helpText = `# Claude Code Commands\n\n## Built-in Commands\n\n${builtInCommands.map(cmd => `### ${cmd.name}\n${cmd.description}\n`).join('\\n')}\n\n## Custom Commands\n\nCustom commands can be created in:\n- Project: \\`.claude/commands/\\` (project-specific)\n- User: \\`~/.claude/commands/\\` (available in all projects)\n\n### Command Syntax\n\n- **Arguments**: Use \\`$ARGUMENTS\\` for all args or \\`$1\\`, \\`$2\\`, etc. for positional\n- **File Includes**: Use \\`@filename\\` to include file contents\n- **Bash Commands**: Use \\`!command\\` to execute bash commands\n\n### Examples\n\n\\`\\`\\`markdown\n/mycommand arg1 arg2\n\\`\\`\\`\n`;\n\n    return {\n      type: 'builtin',\n      action: 'help',\n      data: {\n        content: helpText,\n        format: 'markdown'\n      }\n    };\n  },\n\n  '/clear': async (args, context) => {\n    return {\n      type: 'builtin',\n      action: 'clear',\n      data: {\n        message: 'Conversation history cleared'\n      }\n    };\n  },\n\n  '/model': async (args, context) => {\n    // Read available models from centralized constants\n    const availableModels = {\n      claude: CLAUDE_MODELS.OPTIONS.map(o => o.value),\n      cursor: CURSOR_MODELS.OPTIONS.map(o => o.value),\n      codex: CODEX_MODELS.OPTIONS.map(o => o.value)\n    };\n\n    const currentProvider = context?.provider || 'claude';\n    const currentModel = context?.model || CLAUDE_MODELS.DEFAULT;\n\n    return {\n      type: 'builtin',\n      action: 'model',\n      data: {\n        current: {\n          provider: currentProvider,\n          model: currentModel\n        },\n        available: availableModels,\n        message: args.length > 0\n          ? `Switching to model: ${args[0]}`\n          : `Current model: ${currentModel}`\n      }\n    };\n  },\n\n  '/cost': async (args, context) => {\n    const tokenUsage = context?.tokenUsage || {};\n    const provider = context?.provider || 'claude';\n    const model =\n      context?.model ||\n      (provider === 'cursor'\n        ? CURSOR_MODELS.DEFAULT\n        : provider === 'codex'\n          ? CODEX_MODELS.DEFAULT\n          : CLAUDE_MODELS.DEFAULT);\n\n    const used = Number(tokenUsage.used ?? tokenUsage.totalUsed ?? tokenUsage.total_tokens ?? 0) || 0;\n    const total =\n      Number(\n        tokenUsage.total ??\n          tokenUsage.contextWindow ??\n          parseInt(process.env.CONTEXT_WINDOW || '160000', 10),\n      ) || 160000;\n    const percentage = total > 0 ? Number(((used / total) * 100).toFixed(1)) : 0;\n\n    const inputTokensRaw =\n      Number(\n        tokenUsage.inputTokens ??\n          tokenUsage.input ??\n          tokenUsage.cumulativeInputTokens ??\n          tokenUsage.promptTokens ??\n          0,\n      ) || 0;\n    const outputTokens =\n      Number(\n        tokenUsage.outputTokens ??\n          tokenUsage.output ??\n          tokenUsage.cumulativeOutputTokens ??\n          tokenUsage.completionTokens ??\n          0,\n      ) || 0;\n    const cacheTokens =\n      Number(\n        tokenUsage.cacheReadTokens ??\n          tokenUsage.cacheCreationTokens ??\n          tokenUsage.cacheTokens ??\n          tokenUsage.cachedTokens ??\n          0,\n      ) || 0;\n\n    // If we only have total used tokens, treat them as input for display/estimation.\n    const inputTokens =\n      inputTokensRaw > 0 || outputTokens > 0 || cacheTokens > 0 ? inputTokensRaw + cacheTokens : used;\n\n    // Rough default rates by provider (USD / 1M tokens).\n    const pricingByProvider = {\n      claude: { input: 3, output: 15 },\n      cursor: { input: 3, output: 15 },\n      codex: { input: 1.5, output: 6 },\n    };\n    const rates = pricingByProvider[provider] || pricingByProvider.claude;\n\n    const inputCost = (inputTokens / 1_000_000) * rates.input;\n    const outputCost = (outputTokens / 1_000_000) * rates.output;\n    const totalCost = inputCost + outputCost;\n\n    return {\n      type: 'builtin',\n      action: 'cost',\n      data: {\n        tokenUsage: {\n          used,\n          total,\n          percentage,\n        },\n        cost: {\n          input: inputCost.toFixed(4),\n          output: outputCost.toFixed(4),\n          total: totalCost.toFixed(4),\n        },\n        model,\n      },\n    };\n  },\n\n  '/status': async (args, context) => {\n    // Read version from package.json\n    const packageJsonPath = path.join(path.dirname(__dirname), '..', 'package.json');\n    let version = 'unknown';\n    let packageName = 'claude-code-ui';\n\n    try {\n      const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));\n      version = packageJson.version;\n      packageName = packageJson.name;\n    } catch (err) {\n      console.error('Error reading package.json:', err);\n    }\n\n    const uptime = process.uptime();\n    const uptimeMinutes = Math.floor(uptime / 60);\n    const uptimeHours = Math.floor(uptimeMinutes / 60);\n    const uptimeFormatted = uptimeHours > 0\n      ? `${uptimeHours}h ${uptimeMinutes % 60}m`\n      : `${uptimeMinutes}m`;\n\n    return {\n      type: 'builtin',\n      action: 'status',\n      data: {\n        version,\n        packageName,\n        uptime: uptimeFormatted,\n        uptimeSeconds: Math.floor(uptime),\n        model: context?.model || 'claude-sonnet-4.5',\n        provider: context?.provider || 'claude',\n        nodeVersion: process.version,\n        platform: process.platform\n      }\n    };\n  },\n\n  '/memory': async (args, context) => {\n    const projectPath = context?.projectPath;\n\n    if (!projectPath) {\n      return {\n        type: 'builtin',\n        action: 'memory',\n        data: {\n          error: 'No project selected',\n          message: 'Please select a project to access its CLAUDE.md file'\n        }\n      };\n    }\n\n    const claudeMdPath = path.join(projectPath, 'CLAUDE.md');\n\n    // Check if CLAUDE.md exists\n    let exists = false;\n    try {\n      await fs.access(claudeMdPath);\n      exists = true;\n    } catch (err) {\n      // File doesn't exist\n    }\n\n    return {\n      type: 'builtin',\n      action: 'memory',\n      data: {\n        path: claudeMdPath,\n        exists,\n        message: exists\n          ? `Opening CLAUDE.md at ${claudeMdPath}`\n          : `CLAUDE.md not found at ${claudeMdPath}. Create it to store project-specific instructions.`\n      }\n    };\n  },\n\n  '/config': async (args, context) => {\n    return {\n      type: 'builtin',\n      action: 'config',\n      data: {\n        message: 'Opening settings...'\n      }\n    };\n  },\n\n  '/rewind': async (args, context) => {\n    const steps = args[0] ? parseInt(args[0]) : 1;\n\n    if (isNaN(steps) || steps < 1) {\n      return {\n        type: 'builtin',\n        action: 'rewind',\n        data: {\n          error: 'Invalid steps parameter',\n          message: 'Usage: /rewind [number] - Rewind conversation by N steps (default: 1)'\n        }\n      };\n    }\n\n    return {\n      type: 'builtin',\n      action: 'rewind',\n      data: {\n        steps,\n        message: `Rewinding conversation by ${steps} step${steps > 1 ? 's' : ''}...`\n      }\n    };\n  }\n};\n\n/**\n * POST /api/commands/list\n * List all available commands from project and user directories\n */\nrouter.post('/list', async (req, res) => {\n  try {\n    const { projectPath } = req.body;\n    const allCommands = [...builtInCommands];\n\n    // Scan project-level commands (.claude/commands/)\n    if (projectPath) {\n      const projectCommandsDir = path.join(projectPath, '.claude', 'commands');\n      const projectCommands = await scanCommandsDirectory(\n        projectCommandsDir,\n        projectCommandsDir,\n        'project'\n      );\n      allCommands.push(...projectCommands);\n    }\n\n    // Scan user-level commands (~/.claude/commands/)\n    const homeDir = os.homedir();\n    const userCommandsDir = path.join(homeDir, '.claude', 'commands');\n    const userCommands = await scanCommandsDirectory(\n      userCommandsDir,\n      userCommandsDir,\n      'user'\n    );\n    allCommands.push(...userCommands);\n\n    // Separate built-in and custom commands\n    const customCommands = allCommands.filter(cmd => cmd.namespace !== 'builtin');\n\n    // Sort commands alphabetically by name\n    customCommands.sort((a, b) => a.name.localeCompare(b.name));\n\n    res.json({\n      builtIn: builtInCommands,\n      custom: customCommands,\n      count: allCommands.length\n    });\n  } catch (error) {\n    console.error('Error listing commands:', error);\n    res.status(500).json({\n      error: 'Failed to list commands',\n      message: error.message\n    });\n  }\n});\n\n/**\n * POST /api/commands/load\n * Load a specific command file and return its content and metadata\n */\nrouter.post('/load', async (req, res) => {\n  try {\n    const { commandPath } = req.body;\n\n    if (!commandPath) {\n      return res.status(400).json({\n        error: 'Command path is required'\n      });\n    }\n\n    // Security: Prevent path traversal\n    const resolvedPath = path.resolve(commandPath);\n    if (!resolvedPath.startsWith(path.resolve(os.homedir())) &&\n        !resolvedPath.includes('.claude/commands')) {\n      return res.status(403).json({\n        error: 'Access denied',\n        message: 'Command must be in .claude/commands directory'\n      });\n    }\n\n    // Read and parse the command file\n    const content = await fs.readFile(commandPath, 'utf8');\n    const { data: metadata, content: commandContent } = parseFrontmatter(content);\n\n    res.json({\n      path: commandPath,\n      metadata,\n      content: commandContent\n    });\n  } catch (error) {\n    if (error.code === 'ENOENT') {\n      return res.status(404).json({\n        error: 'Command not found',\n        message: `Command file not found: ${req.body.commandPath}`\n      });\n    }\n\n    console.error('Error loading command:', error);\n    res.status(500).json({\n      error: 'Failed to load command',\n      message: error.message\n    });\n  }\n});\n\n/**\n * POST /api/commands/execute\n * Execute a command with argument replacement\n * This endpoint prepares the command content but doesn't execute bash commands yet\n * (that will be handled in the command parser utility)\n */\nrouter.post('/execute', async (req, res) => {\n  try {\n    const { commandName, commandPath, args = [], context = {} } = req.body;\n\n    if (!commandName) {\n      return res.status(400).json({\n        error: 'Command name is required'\n      });\n    }\n\n    // Handle built-in commands\n    const handler = builtInHandlers[commandName];\n    if (handler) {\n      try {\n        const result = await handler(args, context);\n        return res.json({\n          ...result,\n          command: commandName\n        });\n      } catch (error) {\n        console.error(`Error executing built-in command ${commandName}:`, error);\n        return res.status(500).json({\n          error: 'Command execution failed',\n          message: error.message,\n          command: commandName\n        });\n      }\n    }\n\n    // Handle custom commands\n    if (!commandPath) {\n      return res.status(400).json({\n        error: 'Command path is required for custom commands'\n      });\n    }\n\n    // Load command content\n    // Security: validate commandPath is within allowed directories\n    {\n      const resolvedPath = path.resolve(commandPath);\n      const userBase = path.resolve(path.join(os.homedir(), '.claude', 'commands'));\n      const projectBase = context?.projectPath\n        ? path.resolve(path.join(context.projectPath, '.claude', 'commands'))\n        : null;\n      const isUnder = (base) => {\n        const rel = path.relative(base, resolvedPath);\n        return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);\n      };\n      if (!(isUnder(userBase) || (projectBase && isUnder(projectBase)))) {\n        return res.status(403).json({\n          error: 'Access denied',\n          message: 'Command must be in .claude/commands directory'\n        });\n      }\n    }\n    const content = await fs.readFile(commandPath, 'utf8');\n    const { data: metadata, content: commandContent } = parseFrontmatter(content);\n    // Basic argument replacement (will be enhanced in command parser utility)\n    let processedContent = commandContent;\n\n    // Replace $ARGUMENTS with all arguments joined\n    const argsString = args.join(' ');\n    processedContent = processedContent.replace(/\\$ARGUMENTS/g, argsString);\n\n    // Replace $1, $2, etc. with positional arguments\n    args.forEach((arg, index) => {\n      const placeholder = `$${index + 1}`;\n      processedContent = processedContent.replace(new RegExp(`\\\\${placeholder}\\\\b`, 'g'), arg);\n    });\n\n    res.json({\n      type: 'custom',\n      command: commandName,\n      content: processedContent,\n      metadata,\n      hasFileIncludes: processedContent.includes('@'),\n      hasBashCommands: processedContent.includes('!')\n    });\n  } catch (error) {\n    if (error.code === 'ENOENT') {\n      return res.status(404).json({\n        error: 'Command not found',\n        message: `Command file not found: ${req.body.commandPath}`\n      });\n    }\n\n    console.error('Error executing command:', error);\n    res.status(500).json({\n      error: 'Failed to execute command',\n      message: error.message\n    });\n  }\n});\n\nexport default router;\n"
  },
  {
    "path": "server/routes/cursor.js",
    "content": "import express from 'express';\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport os from 'os';\nimport { spawn } from 'child_process';\nimport sqlite3 from 'sqlite3';\nimport { open } from 'sqlite';\nimport crypto from 'crypto';\nimport { CURSOR_MODELS } from '../../shared/modelConstants.js';\nimport { applyCustomSessionNames } from '../database/db.js';\n\nconst router = express.Router();\n\n// GET /api/cursor/config - Read Cursor CLI configuration\nrouter.get('/config', async (req, res) => {\n  try {\n    const configPath = path.join(os.homedir(), '.cursor', 'cli-config.json');\n    \n    try {\n      const configContent = await fs.readFile(configPath, 'utf8');\n      const config = JSON.parse(configContent);\n      \n      res.json({\n        success: true,\n        config: config,\n        path: configPath\n      });\n    } catch (error) {\n      // Config doesn't exist or is invalid\n      console.log('Cursor config not found or invalid:', error.message);\n      \n      // Return default config\n      res.json({\n        success: true,\n        config: {\n          version: 1,\n          model: {\n            modelId: CURSOR_MODELS.DEFAULT,\n            displayName: \"GPT-5\"\n          },\n          permissions: {\n            allow: [],\n            deny: []\n          }\n        },\n        isDefault: true\n      });\n    }\n  } catch (error) {\n    console.error('Error reading Cursor config:', error);\n    res.status(500).json({ \n      error: 'Failed to read Cursor configuration', \n      details: error.message \n    });\n  }\n});\n\n// POST /api/cursor/config - Update Cursor CLI configuration\nrouter.post('/config', async (req, res) => {\n  try {\n    const { permissions, model } = req.body;\n    const configPath = path.join(os.homedir(), '.cursor', 'cli-config.json');\n    \n    // Read existing config or create default\n    let config = {\n      version: 1,\n      editor: {\n        vimMode: false\n      },\n      hasChangedDefaultModel: false,\n      privacyCache: {\n        ghostMode: false,\n        privacyMode: 3,\n        updatedAt: Date.now()\n      }\n    };\n    \n    try {\n      const existing = await fs.readFile(configPath, 'utf8');\n      config = JSON.parse(existing);\n    } catch (error) {\n      // Config doesn't exist, use defaults\n      console.log('Creating new Cursor config');\n    }\n    \n    // Update permissions if provided\n    if (permissions) {\n      config.permissions = {\n        allow: permissions.allow || [],\n        deny: permissions.deny || []\n      };\n    }\n    \n    // Update model if provided\n    if (model) {\n      config.model = model;\n      config.hasChangedDefaultModel = true;\n    }\n    \n    // Ensure directory exists\n    const configDir = path.dirname(configPath);\n    await fs.mkdir(configDir, { recursive: true });\n    \n    // Write updated config\n    await fs.writeFile(configPath, JSON.stringify(config, null, 2));\n    \n    res.json({\n      success: true,\n      config: config,\n      message: 'Cursor configuration updated successfully'\n    });\n  } catch (error) {\n    console.error('Error updating Cursor config:', error);\n    res.status(500).json({ \n      error: 'Failed to update Cursor configuration', \n      details: error.message \n    });\n  }\n});\n\n// GET /api/cursor/mcp - Read Cursor MCP servers configuration\nrouter.get('/mcp', async (req, res) => {\n  try {\n    const mcpPath = path.join(os.homedir(), '.cursor', 'mcp.json');\n    \n    try {\n      const mcpContent = await fs.readFile(mcpPath, 'utf8');\n      const mcpConfig = JSON.parse(mcpContent);\n      \n      // Convert to UI-friendly format\n      const servers = [];\n      if (mcpConfig.mcpServers && typeof mcpConfig.mcpServers === 'object') {\n        for (const [name, config] of Object.entries(mcpConfig.mcpServers)) {\n          const server = {\n            id: name,\n            name: name,\n            type: 'stdio',\n            scope: 'cursor',\n            config: {},\n            raw: config\n          };\n          \n          // Determine transport type and extract config\n          if (config.command) {\n            server.type = 'stdio';\n            server.config.command = config.command;\n            server.config.args = config.args || [];\n            server.config.env = config.env || {};\n          } else if (config.url) {\n            server.type = config.transport || 'http';\n            server.config.url = config.url;\n            server.config.headers = config.headers || {};\n          }\n          \n          servers.push(server);\n        }\n      }\n      \n      res.json({\n        success: true,\n        servers: servers,\n        path: mcpPath\n      });\n    } catch (error) {\n      // MCP config doesn't exist\n      console.log('Cursor MCP config not found:', error.message);\n      res.json({\n        success: true,\n        servers: [],\n        isDefault: true\n      });\n    }\n  } catch (error) {\n    console.error('Error reading Cursor MCP config:', error);\n    res.status(500).json({ \n      error: 'Failed to read Cursor MCP configuration', \n      details: error.message \n    });\n  }\n});\n\n// POST /api/cursor/mcp/add - Add MCP server to Cursor configuration\nrouter.post('/mcp/add', async (req, res) => {\n  try {\n    const { name, type = 'stdio', command, args = [], url, headers = {}, env = {} } = req.body;\n    const mcpPath = path.join(os.homedir(), '.cursor', 'mcp.json');\n    \n    console.log(`➕ Adding MCP server to Cursor config: ${name}`);\n    \n    // Read existing config or create new\n    let mcpConfig = { mcpServers: {} };\n    \n    try {\n      const existing = await fs.readFile(mcpPath, 'utf8');\n      mcpConfig = JSON.parse(existing);\n      if (!mcpConfig.mcpServers) {\n        mcpConfig.mcpServers = {};\n      }\n    } catch (error) {\n      console.log('Creating new Cursor MCP config');\n    }\n    \n    // Build server config based on type\n    let serverConfig = {};\n    \n    if (type === 'stdio') {\n      serverConfig = {\n        command: command,\n        args: args,\n        env: env\n      };\n    } else if (type === 'http' || type === 'sse') {\n      serverConfig = {\n        url: url,\n        transport: type,\n        headers: headers\n      };\n    }\n    \n    // Add server to config\n    mcpConfig.mcpServers[name] = serverConfig;\n    \n    // Ensure directory exists\n    const mcpDir = path.dirname(mcpPath);\n    await fs.mkdir(mcpDir, { recursive: true });\n    \n    // Write updated config\n    await fs.writeFile(mcpPath, JSON.stringify(mcpConfig, null, 2));\n    \n    res.json({\n      success: true,\n      message: `MCP server \"${name}\" added to Cursor configuration`,\n      config: mcpConfig\n    });\n  } catch (error) {\n    console.error('Error adding MCP server to Cursor:', error);\n    res.status(500).json({ \n      error: 'Failed to add MCP server', \n      details: error.message \n    });\n  }\n});\n\n// DELETE /api/cursor/mcp/:name - Remove MCP server from Cursor configuration\nrouter.delete('/mcp/:name', async (req, res) => {\n  try {\n    const { name } = req.params;\n    const mcpPath = path.join(os.homedir(), '.cursor', 'mcp.json');\n    \n    console.log(`🗑️ Removing MCP server from Cursor config: ${name}`);\n    \n    // Read existing config\n    let mcpConfig = { mcpServers: {} };\n    \n    try {\n      const existing = await fs.readFile(mcpPath, 'utf8');\n      mcpConfig = JSON.parse(existing);\n    } catch (error) {\n      return res.status(404).json({ \n        error: 'Cursor MCP configuration not found' \n      });\n    }\n    \n    // Check if server exists\n    if (!mcpConfig.mcpServers || !mcpConfig.mcpServers[name]) {\n      return res.status(404).json({ \n        error: `MCP server \"${name}\" not found in Cursor configuration` \n      });\n    }\n    \n    // Remove server from config\n    delete mcpConfig.mcpServers[name];\n    \n    // Write updated config\n    await fs.writeFile(mcpPath, JSON.stringify(mcpConfig, null, 2));\n    \n    res.json({\n      success: true,\n      message: `MCP server \"${name}\" removed from Cursor configuration`,\n      config: mcpConfig\n    });\n  } catch (error) {\n    console.error('Error removing MCP server from Cursor:', error);\n    res.status(500).json({ \n      error: 'Failed to remove MCP server', \n      details: error.message \n    });\n  }\n});\n\n// POST /api/cursor/mcp/add-json - Add MCP server using JSON format\nrouter.post('/mcp/add-json', async (req, res) => {\n  try {\n    const { name, jsonConfig } = req.body;\n    const mcpPath = path.join(os.homedir(), '.cursor', 'mcp.json');\n    \n    console.log(`➕ Adding MCP server to Cursor config via JSON: ${name}`);\n    \n    // Validate and parse JSON config\n    let parsedConfig;\n    try {\n      parsedConfig = typeof jsonConfig === 'string' ? JSON.parse(jsonConfig) : jsonConfig;\n    } catch (parseError) {\n      return res.status(400).json({ \n        error: 'Invalid JSON configuration', \n        details: parseError.message \n      });\n    }\n    \n    // Read existing config or create new\n    let mcpConfig = { mcpServers: {} };\n    \n    try {\n      const existing = await fs.readFile(mcpPath, 'utf8');\n      mcpConfig = JSON.parse(existing);\n      if (!mcpConfig.mcpServers) {\n        mcpConfig.mcpServers = {};\n      }\n    } catch (error) {\n      console.log('Creating new Cursor MCP config');\n    }\n    \n    // Add server to config\n    mcpConfig.mcpServers[name] = parsedConfig;\n    \n    // Ensure directory exists\n    const mcpDir = path.dirname(mcpPath);\n    await fs.mkdir(mcpDir, { recursive: true });\n    \n    // Write updated config\n    await fs.writeFile(mcpPath, JSON.stringify(mcpConfig, null, 2));\n    \n    res.json({\n      success: true,\n      message: `MCP server \"${name}\" added to Cursor configuration via JSON`,\n      config: mcpConfig\n    });\n  } catch (error) {\n    console.error('Error adding MCP server to Cursor via JSON:', error);\n    res.status(500).json({ \n      error: 'Failed to add MCP server', \n      details: error.message \n    });\n  }\n});\n\n// GET /api/cursor/sessions - Get Cursor sessions from SQLite database\nrouter.get('/sessions', async (req, res) => {\n  try {\n    const { projectPath } = req.query;\n    \n    // Calculate cwdID hash for the project path (Cursor uses MD5 hash)\n    const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');\n    const cursorChatsPath = path.join(os.homedir(), '.cursor', 'chats', cwdId);\n    \n    \n    // Check if the directory exists\n    try {\n      await fs.access(cursorChatsPath);\n    } catch (error) {\n      // No sessions for this project\n      return res.json({ \n        success: true, \n        sessions: [],\n        cwdId: cwdId,\n        path: cursorChatsPath\n      });\n    }\n    \n    // List all session directories\n    const sessionDirs = await fs.readdir(cursorChatsPath);\n    const sessions = [];\n    \n    for (const sessionId of sessionDirs) {\n      const sessionPath = path.join(cursorChatsPath, sessionId);\n      const storeDbPath = path.join(sessionPath, 'store.db');\n      let dbStatMtimeMs = null;\n      \n      try {\n        // Check if store.db exists\n        await fs.access(storeDbPath);\n        \n        // Capture store.db mtime as a reliable fallback timestamp (last activity)\n        try {\n          const stat = await fs.stat(storeDbPath);\n          dbStatMtimeMs = stat.mtimeMs;\n        } catch (_) {}\n\n        // Open SQLite database\n        const db = await open({\n          filename: storeDbPath,\n          driver: sqlite3.Database,\n          mode: sqlite3.OPEN_READONLY\n        });\n        \n        // Get metadata from meta table\n        const metaRows = await db.all(`\n          SELECT key, value FROM meta\n        `);\n        \n        let sessionData = {\n          id: sessionId,\n          name: 'Untitled Session',\n          createdAt: null,\n          mode: null,\n          projectPath: projectPath,\n          lastMessage: null,\n          messageCount: 0\n        };\n        \n        // Parse meta table entries\n        for (const row of metaRows) {\n          if (row.value) {\n            try {\n              // Try to decode as hex-encoded JSON\n              const hexMatch = row.value.toString().match(/^[0-9a-fA-F]+$/);\n              if (hexMatch) {\n                const jsonStr = Buffer.from(row.value, 'hex').toString('utf8');\n                const data = JSON.parse(jsonStr);\n                \n                if (row.key === 'agent') {\n                  sessionData.name = data.name || sessionData.name;\n                  // Normalize createdAt to ISO string in milliseconds\n                  let createdAt = data.createdAt;\n                  if (typeof createdAt === 'number') {\n                    if (createdAt < 1e12) {\n                      createdAt = createdAt * 1000; // seconds -> ms\n                    }\n                    sessionData.createdAt = new Date(createdAt).toISOString();\n                  } else if (typeof createdAt === 'string') {\n                    const n = Number(createdAt);\n                    if (!Number.isNaN(n)) {\n                      const ms = n < 1e12 ? n * 1000 : n;\n                      sessionData.createdAt = new Date(ms).toISOString();\n                    } else {\n                      // Assume it's already an ISO/date string\n                      const d = new Date(createdAt);\n                      sessionData.createdAt = isNaN(d.getTime()) ? null : d.toISOString();\n                    }\n                  } else {\n                    sessionData.createdAt = sessionData.createdAt || null;\n                  }\n                  sessionData.mode = data.mode;\n                  sessionData.agentId = data.agentId;\n                  sessionData.latestRootBlobId = data.latestRootBlobId;\n                }\n              } else {\n                // If not hex, use raw value for simple keys\n                if (row.key === 'name') {\n                  sessionData.name = row.value.toString();\n                }\n              }\n            } catch (e) {\n              console.log(`Could not parse meta value for key ${row.key}:`, e.message);\n            }\n          }\n        }\n        \n        // Get message count from JSON blobs only (actual messages, not DAG structure)\n        try {\n          const blobCount = await db.get(`\n            SELECT COUNT(*) as count \n            FROM blobs \n            WHERE substr(data, 1, 1) = X'7B'\n          `);\n          sessionData.messageCount = blobCount.count;\n          \n          // Get the most recent JSON blob for preview (actual message, not DAG structure)\n          const lastBlob = await db.get(`\n            SELECT data FROM blobs \n            WHERE substr(data, 1, 1) = X'7B'\n            ORDER BY rowid DESC \n            LIMIT 1\n          `);\n          \n          if (lastBlob && lastBlob.data) {\n            try {\n              // Try to extract readable preview from blob (may contain binary with embedded JSON)\n              const raw = lastBlob.data.toString('utf8');\n              let preview = '';\n              // Attempt direct JSON parse\n              try {\n                const parsed = JSON.parse(raw);\n                if (parsed?.content) {\n                  if (Array.isArray(parsed.content)) {\n                    const firstText = parsed.content.find(p => p?.type === 'text' && p.text)?.text || '';\n                    preview = firstText;\n                  } else if (typeof parsed.content === 'string') {\n                    preview = parsed.content;\n                  }\n                }\n              } catch (_) {}\n              if (!preview) {\n                // Strip non-printable and try to find JSON chunk\n                const cleaned = raw.replace(/[^\\x09\\x0A\\x0D\\x20-\\x7E]/g, '');\n                const s = cleaned;\n                const start = s.indexOf('{');\n                const end = s.lastIndexOf('}');\n                if (start !== -1 && end > start) {\n                  const jsonStr = s.slice(start, end + 1);\n                  try {\n                    const parsed = JSON.parse(jsonStr);\n                    if (parsed?.content) {\n                      if (Array.isArray(parsed.content)) {\n                        const firstText = parsed.content.find(p => p?.type === 'text' && p.text)?.text || '';\n                        preview = firstText;\n                      } else if (typeof parsed.content === 'string') {\n                        preview = parsed.content;\n                      }\n                    }\n                  } catch (_) {\n                    preview = s;\n                  }\n                } else {\n                  preview = s;\n                }\n              }\n              if (preview && preview.length > 0) {\n                sessionData.lastMessage = preview.substring(0, 100) + (preview.length > 100 ? '...' : '');\n              }\n            } catch (e) {\n              console.log('Could not parse blob data:', e.message);\n            }\n          }\n        } catch (e) {\n          console.log('Could not read blobs:', e.message);\n        }\n        \n        await db.close();\n\n        // Finalize createdAt: use parsed meta value when valid, else fall back to store.db mtime\n        if (!sessionData.createdAt) {\n          if (dbStatMtimeMs && Number.isFinite(dbStatMtimeMs)) {\n            sessionData.createdAt = new Date(dbStatMtimeMs).toISOString();\n          }\n        }\n        \n        sessions.push(sessionData);\n        \n      } catch (error) {\n        console.log(`Could not read session ${sessionId}:`, error.message);\n      }\n    }\n    \n    // Fallback: ensure createdAt is a valid ISO string (use session directory mtime as last resort)\n    for (const s of sessions) {\n      if (!s.createdAt) {\n        try {\n          const sessionDir = path.join(cursorChatsPath, s.id);\n          const st = await fs.stat(sessionDir);\n          s.createdAt = new Date(st.mtimeMs).toISOString();\n        } catch {\n          s.createdAt = new Date().toISOString();\n        }\n      }\n    }\n    // Sort sessions by creation date (newest first)\n    sessions.sort((a, b) => {\n      if (!a.createdAt) return 1;\n      if (!b.createdAt) return -1;\n      return new Date(b.createdAt) - new Date(a.createdAt);\n    });\n    \n    applyCustomSessionNames(sessions, 'cursor');\n\n    res.json({\n      success: true,\n      sessions: sessions,\n      cwdId: cwdId,\n      path: cursorChatsPath\n    });\n    \n  } catch (error) {\n    console.error('Error reading Cursor sessions:', error);\n    res.status(500).json({ \n      error: 'Failed to read Cursor sessions', \n      details: error.message \n    });\n  }\n});\n\n// GET /api/cursor/sessions/:sessionId - Get specific Cursor session from SQLite\nrouter.get('/sessions/:sessionId', async (req, res) => {\n  try {\n    const { sessionId } = req.params;\n    const { projectPath } = req.query;\n    \n    // Calculate cwdID hash for the project path\n    const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');\n    const storeDbPath = path.join(os.homedir(), '.cursor', 'chats', cwdId, sessionId, 'store.db');\n    \n    \n    // Open SQLite database\n    const db = await open({\n      filename: storeDbPath,\n      driver: sqlite3.Database,\n      mode: sqlite3.OPEN_READONLY\n    });\n    \n    // Get all blobs to build the DAG structure\n    const allBlobs = await db.all(`\n      SELECT rowid, id, data FROM blobs\n    `);\n    \n    // Build the DAG structure from parent-child relationships\n    const blobMap = new Map(); // id -> blob data\n    const parentRefs = new Map(); // blob id -> [parent blob ids]\n    const childRefs = new Map(); // blob id -> [child blob ids]\n    const jsonBlobs = []; // Clean JSON messages\n    \n    for (const blob of allBlobs) {\n      blobMap.set(blob.id, blob);\n      \n      // Check if this is a JSON blob (actual message) or protobuf (DAG structure)\n      if (blob.data && blob.data[0] === 0x7B) { // Starts with '{' - JSON blob\n        try {\n          const parsed = JSON.parse(blob.data.toString('utf8'));\n          jsonBlobs.push({ ...blob, parsed });\n        } catch (e) {\n          console.log('Failed to parse JSON blob:', blob.rowid);\n        }\n      } else if (blob.data) { // Protobuf blob - extract parent references\n        const parents = [];\n        let i = 0;\n        \n        // Scan for parent references (0x0A 0x20 followed by 32-byte hash)\n        while (i < blob.data.length - 33) {\n          if (blob.data[i] === 0x0A && blob.data[i+1] === 0x20) {\n            const parentHash = blob.data.slice(i+2, i+34).toString('hex');\n            if (blobMap.has(parentHash)) {\n              parents.push(parentHash);\n            }\n            i += 34;\n          } else {\n            i++;\n          }\n        }\n        \n        if (parents.length > 0) {\n          parentRefs.set(blob.id, parents);\n          // Update child references\n          for (const parentId of parents) {\n            if (!childRefs.has(parentId)) {\n              childRefs.set(parentId, []);\n            }\n            childRefs.get(parentId).push(blob.id);\n          }\n        }\n      }\n    }\n    \n    // Perform topological sort to get chronological order\n    const visited = new Set();\n    const sorted = [];\n    \n    // DFS-based topological sort\n    function visit(nodeId) {\n      if (visited.has(nodeId)) return;\n      visited.add(nodeId);\n      \n      // Visit all parents first (dependencies)\n      const parents = parentRefs.get(nodeId) || [];\n      for (const parentId of parents) {\n        visit(parentId);\n      }\n      \n      // Add this node after all its parents\n      const blob = blobMap.get(nodeId);\n      if (blob) {\n        sorted.push(blob);\n      }\n    }\n    \n    // Start with nodes that have no parents (roots)\n    for (const blob of allBlobs) {\n      if (!parentRefs.has(blob.id)) {\n        visit(blob.id);\n      }\n    }\n    \n    // Visit any remaining nodes (disconnected components)\n    for (const blob of allBlobs) {\n      visit(blob.id);\n    }\n    \n    // Now extract JSON messages in the order they appear in the sorted DAG\n    const messageOrder = new Map(); // JSON blob id -> order index\n    let orderIndex = 0;\n    \n    for (const blob of sorted) {\n      // Check if this blob references any JSON messages\n      if (blob.data && blob.data[0] !== 0x7B) { // Protobuf blob\n        // Look for JSON blob references\n        for (const jsonBlob of jsonBlobs) {\n          try {\n            const jsonIdBytes = Buffer.from(jsonBlob.id, 'hex');\n            if (blob.data.includes(jsonIdBytes)) {\n              if (!messageOrder.has(jsonBlob.id)) {\n                messageOrder.set(jsonBlob.id, orderIndex++);\n              }\n            }\n          } catch (e) {\n            // Skip if can't convert ID\n          }\n        }\n      }\n    }\n    \n    // Sort JSON blobs by their appearance order in the DAG\n    const sortedJsonBlobs = jsonBlobs.sort((a, b) => {\n      const orderA = messageOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER;\n      const orderB = messageOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER;\n      if (orderA !== orderB) return orderA - orderB;\n      // Fallback to rowid if not in order map\n      return a.rowid - b.rowid;\n    });\n    \n    // Use sorted JSON blobs\n    const blobs = sortedJsonBlobs.map((blob, idx) => ({\n      ...blob,\n      sequence_num: idx + 1,\n      original_rowid: blob.rowid\n    }));\n    \n    // Get metadata from meta table\n    const metaRows = await db.all(`\n      SELECT key, value FROM meta\n    `);\n    \n    // Parse metadata\n    let metadata = {};\n    for (const row of metaRows) {\n      if (row.value) {\n        try {\n          // Try to decode as hex-encoded JSON\n          const hexMatch = row.value.toString().match(/^[0-9a-fA-F]+$/);\n          if (hexMatch) {\n            const jsonStr = Buffer.from(row.value, 'hex').toString('utf8');\n            metadata[row.key] = JSON.parse(jsonStr);\n          } else {\n            metadata[row.key] = row.value.toString();\n          }\n        } catch (e) {\n          metadata[row.key] = row.value.toString();\n        }\n      }\n    }\n    \n    // Extract messages from sorted JSON blobs\n    const messages = [];\n    for (const blob of blobs) {\n      try {\n        // We already parsed JSON blobs earlier\n        const parsed = blob.parsed;\n        \n        if (parsed) {\n          // Filter out ONLY system messages at the server level\n          // Check both direct role and nested message.role\n          const role = parsed?.role || parsed?.message?.role;\n          if (role === 'system') {\n            continue; // Skip only system messages\n          }\n          messages.push({ \n            id: blob.id, \n            sequence: blob.sequence_num,\n            rowid: blob.original_rowid, \n            content: parsed \n          });\n        }\n      } catch (e) {\n        // Skip blobs that cause errors\n        console.log(`Skipping blob ${blob.id}: ${e.message}`);\n      }\n    }\n    \n    await db.close();\n    \n    res.json({ \n      success: true, \n      session: {\n        id: sessionId,\n        projectPath: projectPath,\n        messages: messages,\n        metadata: metadata,\n        cwdId: cwdId\n      }\n    });\n    \n  } catch (error) {\n    console.error('Error reading Cursor session:', error);\n    res.status(500).json({ \n      error: 'Failed to read Cursor session', \n      details: error.message \n    });\n  }\n});\n\nexport default router;"
  },
  {
    "path": "server/routes/gemini.js",
    "content": "import express from 'express';\nimport sessionManager from '../sessionManager.js';\nimport { sessionNamesDb } from '../database/db.js';\n\nconst router = express.Router();\n\nrouter.delete('/sessions/:sessionId', async (req, res) => {\n    try {\n        const { sessionId } = req.params;\n\n        if (!sessionId || typeof sessionId !== 'string' || !/^[a-zA-Z0-9_.-]{1,100}$/.test(sessionId)) {\n            return res.status(400).json({ success: false, error: 'Invalid session ID format' });\n        }\n\n        await sessionManager.deleteSession(sessionId);\n        sessionNamesDb.deleteName(sessionId, 'gemini');\n        res.json({ success: true });\n    } catch (error) {\n        console.error(`Error deleting Gemini session ${req.params.sessionId}:`, error);\n        res.status(500).json({ success: false, error: error.message });\n    }\n});\n\nexport default router;\n"
  },
  {
    "path": "server/routes/git.js",
    "content": "import express from 'express';\nimport { spawn } from 'child_process';\nimport path from 'path';\nimport { promises as fs } from 'fs';\nimport { extractProjectDirectory } from '../projects.js';\nimport { queryClaudeSDK } from '../claude-sdk.js';\nimport { spawnCursor } from '../cursor-cli.js';\n\nconst router = express.Router();\nconst COMMIT_DIFF_CHARACTER_LIMIT = 500_000;\n\nfunction spawnAsync(command, args, options = {}) {\n  return new Promise((resolve, reject) => {\n    const child = spawn(command, args, {\n      ...options,\n      shell: false,\n    });\n\n    let stdout = '';\n    let stderr = '';\n\n    child.stdout.on('data', (data) => {\n      stdout += data.toString();\n    });\n\n    child.stderr.on('data', (data) => {\n      stderr += data.toString();\n    });\n\n    child.on('error', (error) => {\n      reject(error);\n    });\n\n    child.on('close', (code) => {\n      if (code === 0) {\n        resolve({ stdout, stderr });\n        return;\n      }\n\n      const error = new Error(`Command failed: ${command} ${args.join(' ')}`);\n      error.code = code;\n      error.stdout = stdout;\n      error.stderr = stderr;\n      reject(error);\n    });\n  });\n}\n\n// Input validation helpers (defense-in-depth)\nfunction validateCommitRef(commit) {\n  // Allow hex hashes, HEAD, HEAD~N, HEAD^N, tag names, branch names\n  if (!/^[a-zA-Z0-9._~^{}@\\/-]+$/.test(commit)) {\n    throw new Error('Invalid commit reference');\n  }\n  return commit;\n}\n\nfunction validateBranchName(branch) {\n  if (!/^[a-zA-Z0-9._\\/-]+$/.test(branch)) {\n    throw new Error('Invalid branch name');\n  }\n  return branch;\n}\n\nfunction validateFilePath(file, projectPath) {\n  if (!file || file.includes('\\0')) {\n    throw new Error('Invalid file path');\n  }\n  // Prevent path traversal: resolve the file relative to the project root\n  // and ensure the result stays within the project directory\n  if (projectPath) {\n    const resolved = path.resolve(projectPath, file);\n    const normalizedRoot = path.resolve(projectPath) + path.sep;\n    if (!resolved.startsWith(normalizedRoot) && resolved !== path.resolve(projectPath)) {\n      throw new Error('Invalid file path: path traversal detected');\n    }\n  }\n  return file;\n}\n\nfunction validateRemoteName(remote) {\n  if (!/^[a-zA-Z0-9._-]+$/.test(remote)) {\n    throw new Error('Invalid remote name');\n  }\n  return remote;\n}\n\nfunction validateProjectPath(projectPath) {\n  if (!projectPath || projectPath.includes('\\0')) {\n    throw new Error('Invalid project path');\n  }\n  const resolved = path.resolve(projectPath);\n  // Must be an absolute path after resolution\n  if (!path.isAbsolute(resolved)) {\n    throw new Error('Invalid project path: must be absolute');\n  }\n  // Block obviously dangerous paths\n  if (resolved === '/' || resolved === path.sep) {\n    throw new Error('Invalid project path: root directory not allowed');\n  }\n  return resolved;\n}\n\n// Helper function to get the actual project path from the encoded project name\nasync function getActualProjectPath(projectName) {\n  let projectPath;\n  try {\n    projectPath = await extractProjectDirectory(projectName);\n  } catch (error) {\n    console.error(`Error extracting project directory for ${projectName}:`, error);\n    throw new Error(`Unable to resolve project path for \"${projectName}\"`);\n  }\n  return validateProjectPath(projectPath);\n}\n\n// Helper function to strip git diff headers\nfunction stripDiffHeaders(diff) {\n  if (!diff) return '';\n\n  const lines = diff.split('\\n');\n  const filteredLines = [];\n  let startIncluding = false;\n\n  for (const line of lines) {\n    // Skip all header lines including diff --git, index, file mode, and --- / +++ file paths\n    if (line.startsWith('diff --git') ||\n        line.startsWith('index ') ||\n        line.startsWith('new file mode') ||\n        line.startsWith('deleted file mode') ||\n        line.startsWith('---') ||\n        line.startsWith('+++')) {\n      continue;\n    }\n\n    // Start including lines from @@ hunk headers onwards\n    if (line.startsWith('@@') || startIncluding) {\n      startIncluding = true;\n      filteredLines.push(line);\n    }\n  }\n\n  return filteredLines.join('\\n');\n}\n\n// Helper function to validate git repository\nasync function validateGitRepository(projectPath) {\n  try {\n    // Check if directory exists\n    await fs.access(projectPath);\n  } catch {\n    throw new Error(`Project path not found: ${projectPath}`);\n  }\n\n  try {\n    // Allow any directory that is inside a work tree (repo root or nested folder).\n    const { stdout: insideWorkTreeOutput } = await spawnAsync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: projectPath });\n    const isInsideWorkTree = insideWorkTreeOutput.trim() === 'true';\n    if (!isInsideWorkTree) {\n      throw new Error('Not inside a git work tree');\n    }\n\n    // Ensure git can resolve the repository root for this directory.\n    await spawnAsync('git', ['rev-parse', '--show-toplevel'], { cwd: projectPath });\n  } catch {\n    throw new Error('Not a git repository. This directory does not contain a .git folder. Initialize a git repository with \"git init\" to use source control features.');\n  }\n}\n\nfunction getGitErrorDetails(error) {\n  return `${error?.message || ''} ${error?.stderr || ''} ${error?.stdout || ''}`;\n}\n\nfunction isMissingHeadRevisionError(error) {\n  const errorDetails = getGitErrorDetails(error).toLowerCase();\n  return errorDetails.includes('unknown revision')\n    || errorDetails.includes('ambiguous argument')\n    || errorDetails.includes('needed a single revision')\n    || errorDetails.includes('bad revision');\n}\n\nasync function getCurrentBranchName(projectPath) {\n  try {\n    // symbolic-ref works even when the repository has no commits.\n    const { stdout } = await spawnAsync('git', ['symbolic-ref', '--short', 'HEAD'], { cwd: projectPath });\n    const branchName = stdout.trim();\n    if (branchName) {\n      return branchName;\n    }\n  } catch (error) {\n    // Fall back to rev-parse for detached HEAD and older git edge cases.\n  }\n\n  const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });\n  return stdout.trim();\n}\n\nasync function repositoryHasCommits(projectPath) {\n  try {\n    await spawnAsync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: projectPath });\n    return true;\n  } catch (error) {\n    if (isMissingHeadRevisionError(error)) {\n      return false;\n    }\n    throw error;\n  }\n}\n\nasync function getRepositoryRootPath(projectPath) {\n  const { stdout } = await spawnAsync('git', ['rev-parse', '--show-toplevel'], { cwd: projectPath });\n  return stdout.trim();\n}\n\nfunction normalizeRepositoryRelativeFilePath(filePath) {\n  return String(filePath)\n    .replace(/\\\\/g, '/')\n    .replace(/^\\.\\/+/, '')\n    .replace(/^\\/+/, '')\n    .trim();\n}\n\nfunction parseStatusFilePaths(statusOutput) {\n  return statusOutput\n    .split('\\n')\n    .map((line) => line.trimEnd())\n    .filter((line) => line.trim())\n    .map((line) => {\n      const statusPath = line.substring(3);\n      const renamedFilePath = statusPath.split(' -> ')[1];\n      return normalizeRepositoryRelativeFilePath(renamedFilePath || statusPath);\n    })\n    .filter(Boolean);\n}\n\nfunction buildFilePathCandidates(projectPath, repositoryRootPath, filePath) {\n  const normalizedFilePath = normalizeRepositoryRelativeFilePath(filePath);\n  const projectRelativePath = normalizeRepositoryRelativeFilePath(path.relative(repositoryRootPath, projectPath));\n  const candidates = [normalizedFilePath];\n\n  if (\n    projectRelativePath\n    && projectRelativePath !== '.'\n    && !normalizedFilePath.startsWith(`${projectRelativePath}/`)\n  ) {\n    candidates.push(`${projectRelativePath}/${normalizedFilePath}`);\n  }\n\n  return Array.from(new Set(candidates.filter(Boolean)));\n}\n\nasync function resolveRepositoryFilePath(projectPath, filePath) {\n  validateFilePath(filePath);\n\n  const repositoryRootPath = await getRepositoryRootPath(projectPath);\n  const candidateFilePaths = buildFilePathCandidates(projectPath, repositoryRootPath, filePath);\n\n  for (const candidateFilePath of candidateFilePaths) {\n    const { stdout } = await spawnAsync('git', ['status', '--porcelain', '--', candidateFilePath], { cwd: repositoryRootPath });\n    if (stdout.trim()) {\n      return {\n        repositoryRootPath,\n        repositoryRelativeFilePath: candidateFilePath,\n      };\n    }\n  }\n\n  // If the caller sent a bare filename (e.g. \"hello.ts\"), recover it from changed files.\n  const normalizedFilePath = normalizeRepositoryRelativeFilePath(filePath);\n  if (!normalizedFilePath.includes('/')) {\n    const { stdout: repositoryStatusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: repositoryRootPath });\n    const changedFilePaths = parseStatusFilePaths(repositoryStatusOutput);\n    const suffixMatches = changedFilePaths.filter(\n      (changedFilePath) => changedFilePath === normalizedFilePath || changedFilePath.endsWith(`/${normalizedFilePath}`),\n    );\n\n    if (suffixMatches.length === 1) {\n      return {\n        repositoryRootPath,\n        repositoryRelativeFilePath: suffixMatches[0],\n      };\n    }\n  }\n\n  return {\n    repositoryRootPath,\n    repositoryRelativeFilePath: candidateFilePaths[0],\n  };\n}\n\n// Get git status for a project\nrouter.get('/status', async (req, res) => {\n  const { project } = req.query;\n\n  if (!project) {\n    return res.status(400).json({ error: 'Project name is required' });\n  }\n\n  try {\n    const projectPath = await getActualProjectPath(project);\n\n    // Validate git repository\n    await validateGitRepository(projectPath);\n\n    const branch = await getCurrentBranchName(projectPath);\n    const hasCommits = await repositoryHasCommits(projectPath);\n\n    // Get git status\n    const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: projectPath });\n\n    const modified = [];\n    const added = [];\n    const deleted = [];\n    const untracked = [];\n\n    statusOutput.split('\\n').forEach(line => {\n      if (!line.trim()) return;\n\n      const status = line.substring(0, 2);\n      const file = line.substring(3);\n\n      if (status === 'M ' || status === ' M' || status === 'MM') {\n        modified.push(file);\n      } else if (status === 'A ' || status === 'AM') {\n        added.push(file);\n      } else if (status === 'D ' || status === ' D') {\n        deleted.push(file);\n      } else if (status === '??') {\n        untracked.push(file);\n      }\n    });\n\n    res.json({\n      branch,\n      hasCommits,\n      modified,\n      added,\n      deleted,\n      untracked\n    });\n  } catch (error) {\n    console.error('Git status error:', error);\n    res.json({\n      error: error.message.includes('not a git repository') || error.message.includes('Project directory is not a git repository')\n        ? error.message\n        : 'Git operation failed',\n      details: error.message.includes('not a git repository') || error.message.includes('Project directory is not a git repository')\n        ? error.message\n        : `Failed to get git status: ${error.message}`\n    });\n  }\n});\n\n// Get diff for a specific file\nrouter.get('/diff', async (req, res) => {\n  const { project, file } = req.query;\n  \n  if (!project || !file) {\n    return res.status(400).json({ error: 'Project name and file path are required' });\n  }\n\n  try {\n    const projectPath = await getActualProjectPath(project);\n    \n    // Validate git repository\n    await validateGitRepository(projectPath);\n\n    const {\n      repositoryRootPath,\n      repositoryRelativeFilePath,\n    } = await resolveRepositoryFilePath(projectPath, file);\n\n    // Check if file is untracked or deleted\n    const { stdout: statusOutput } = await spawnAsync(\n      'git',\n      ['status', '--porcelain', '--', repositoryRelativeFilePath],\n      { cwd: repositoryRootPath },\n    );\n    const isUntracked = statusOutput.startsWith('??');\n    const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');\n\n    let diff;\n    if (isUntracked) {\n      // For untracked files, show the entire file content as additions\n      const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);\n      const stats = await fs.stat(filePath);\n\n      if (stats.isDirectory()) {\n        // For directories, show a simple message\n        diff = `Directory: ${repositoryRelativeFilePath}\\n(Cannot show diff for directories)`;\n      } else {\n        const fileContent = await fs.readFile(filePath, 'utf-8');\n        const lines = fileContent.split('\\n');\n        diff = `--- /dev/null\\n+++ b/${repositoryRelativeFilePath}\\n@@ -0,0 +1,${lines.length} @@\\n` +\n               lines.map(line => `+${line}`).join('\\n');\n      }\n    } else if (isDeleted) {\n      // For deleted files, show the entire file content from HEAD as deletions\n      const { stdout: fileContent } = await spawnAsync(\n        'git',\n        ['show', `HEAD:${repositoryRelativeFilePath}`],\n        { cwd: repositoryRootPath },\n      );\n      const lines = fileContent.split('\\n');\n      diff = `--- a/${repositoryRelativeFilePath}\\n+++ /dev/null\\n@@ -1,${lines.length} +0,0 @@\\n` +\n             lines.map(line => `-${line}`).join('\\n');\n    } else {\n      // Get diff for tracked files\n      // First check for unstaged changes (working tree vs index)\n      const { stdout: unstagedDiff } = await spawnAsync(\n        'git',\n        ['diff', '--', repositoryRelativeFilePath],\n        { cwd: repositoryRootPath },\n      );\n\n      if (unstagedDiff) {\n        // Show unstaged changes if they exist\n        diff = stripDiffHeaders(unstagedDiff);\n      } else {\n        // If no unstaged changes, check for staged changes (index vs HEAD)\n        const { stdout: stagedDiff } = await spawnAsync(\n          'git',\n          ['diff', '--cached', '--', repositoryRelativeFilePath],\n          { cwd: repositoryRootPath },\n        );\n        diff = stripDiffHeaders(stagedDiff) || '';\n      }\n    }\n\n    res.json({ diff });\n  } catch (error) {\n    console.error('Git diff error:', error);\n    res.json({ error: error.message });\n  }\n});\n\n// Get file content with diff information for CodeEditor\nrouter.get('/file-with-diff', async (req, res) => {\n  const { project, file } = req.query;\n\n  if (!project || !file) {\n    return res.status(400).json({ error: 'Project name and file path are required' });\n  }\n\n  try {\n    const projectPath = await getActualProjectPath(project);\n\n    // Validate git repository\n    await validateGitRepository(projectPath);\n\n    const {\n      repositoryRootPath,\n      repositoryRelativeFilePath,\n    } = await resolveRepositoryFilePath(projectPath, file);\n\n    // Check file status\n    const { stdout: statusOutput } = await spawnAsync(\n      'git',\n      ['status', '--porcelain', '--', repositoryRelativeFilePath],\n      { cwd: repositoryRootPath },\n    );\n    const isUntracked = statusOutput.startsWith('??');\n    const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');\n\n    let currentContent = '';\n    let oldContent = '';\n\n    if (isDeleted) {\n      // For deleted files, get content from HEAD\n      const { stdout: headContent } = await spawnAsync(\n        'git',\n        ['show', `HEAD:${repositoryRelativeFilePath}`],\n        { cwd: repositoryRootPath },\n      );\n      oldContent = headContent;\n      currentContent = headContent; // Show the deleted content in editor\n    } else {\n      // Get current file content\n      const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);\n      const stats = await fs.stat(filePath);\n\n      if (stats.isDirectory()) {\n        // Cannot show content for directories\n        return res.status(400).json({ error: 'Cannot show diff for directories' });\n      }\n\n      currentContent = await fs.readFile(filePath, 'utf-8');\n\n      if (!isUntracked) {\n        // Get the old content from HEAD for tracked files\n        try {\n          const { stdout: headContent } = await spawnAsync(\n            'git',\n            ['show', `HEAD:${repositoryRelativeFilePath}`],\n            { cwd: repositoryRootPath },\n          );\n          oldContent = headContent;\n        } catch (error) {\n          // File might be newly added to git (staged but not committed)\n          oldContent = '';\n        }\n      }\n    }\n\n    res.json({\n      currentContent,\n      oldContent,\n      isDeleted,\n      isUntracked\n    });\n  } catch (error) {\n    console.error('Git file-with-diff error:', error);\n    res.json({ error: error.message });\n  }\n});\n\n// Create initial commit\nrouter.post('/initial-commit', async (req, res) => {\n  const { project } = req.body;\n\n  if (!project) {\n    return res.status(400).json({ error: 'Project name is required' });\n  }\n\n  try {\n    const projectPath = await getActualProjectPath(project);\n\n    // Validate git repository\n    await validateGitRepository(projectPath);\n\n    // Check if there are already commits\n    try {\n      await spawnAsync('git', ['rev-parse', 'HEAD'], { cwd: projectPath });\n      return res.status(400).json({ error: 'Repository already has commits. Use regular commit instead.' });\n    } catch (error) {\n      // No HEAD - this is good, we can create initial commit\n    }\n\n    // Add all files\n    await spawnAsync('git', ['add', '.'], { cwd: projectPath });\n\n    // Create initial commit\n    const { stdout } = await spawnAsync('git', ['commit', '-m', 'Initial commit'], { cwd: projectPath });\n\n    res.json({ success: true, output: stdout, message: 'Initial commit created successfully' });\n  } catch (error) {\n    console.error('Git initial commit error:', error);\n\n    // Handle the case where there's nothing to commit\n    if (error.message.includes('nothing to commit')) {\n      return res.status(400).json({\n        error: 'Nothing to commit',\n        details: 'No files found in the repository. Add some files first.'\n      });\n    }\n\n    res.status(500).json({ error: error.message });\n  }\n});\n\n// Commit changes\nrouter.post('/commit', async (req, res) => {\n  const { project, message, files } = req.body;\n  \n  if (!project || !message || !files || files.length === 0) {\n    return res.status(400).json({ error: 'Project name, commit message, and files are required' });\n  }\n\n  try {\n    const projectPath = await getActualProjectPath(project);\n    \n    // Validate git repository\n    await validateGitRepository(projectPath);\n    const repositoryRootPath = await getRepositoryRootPath(projectPath);\n    \n    // Stage selected files\n    for (const file of files) {\n      const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);\n      await spawnAsync('git', ['add', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });\n    }\n\n    // Commit with message\n    const { stdout } = await spawnAsync('git', ['commit', '-m', message], { cwd: repositoryRootPath });\n    \n    res.json({ success: true, output: stdout });\n  } catch (error) {\n    console.error('Git commit error:', error);\n    res.status(500).json({ error: error.message });\n  }\n});\n\n// Revert latest local commit (keeps changes staged)\nrouter.post('/revert-local-commit', async (req, res) => {\n  const { project } = req.body;\n\n  if (!project) {\n    return res.status(400).json({ error: 'Project name is required' });\n  }\n\n  try {\n    const projectPath = await getActualProjectPath(project);\n    await validateGitRepository(projectPath);\n\n    try {\n      await spawnAsync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: projectPath });\n    } catch (error) {\n      return res.status(400).json({\n        error: 'No local commit to revert',\n        details: 'This repository has no commit yet.',\n      });\n    }\n\n    try {\n      // Soft reset rewinds one commit while preserving all file changes in the index.\n      await spawnAsync('git', ['reset', '--soft', 'HEAD~1'], { cwd: projectPath });\n    } catch (error) {\n      const errorDetails = `${error.stderr || ''} ${error.message || ''}`;\n      const isInitialCommit = errorDetails.includes('HEAD~1') &&\n        (errorDetails.includes('unknown revision') || errorDetails.includes('ambiguous argument'));\n\n      if (!isInitialCommit) {\n        throw error;\n      }\n\n      // Initial commit has no parent; deleting HEAD uncommits it and keeps files staged.\n      await spawnAsync('git', ['update-ref', '-d', 'HEAD'], { cwd: projectPath });\n    }\n\n    res.json({\n      success: true,\n      output: 'Latest local commit reverted successfully. Changes were kept staged.',\n    });\n  } catch (error) {\n    console.error('Git revert local commit error:', error);\n    res.status(500).json({ error: error.message });\n  }\n});\n\n// Get list of branches\nrouter.get('/branches', async (req, res) => {\n  const { project } = req.query;\n  \n  if (!project) {\n    return res.status(400).json({ error: 'Project name is required' });\n  }\n\n  try {\n    const projectPath = await getActualProjectPath(project);\n    \n    // Validate git repository\n    await validateGitRepository(projectPath);\n    \n    // Get all branches\n    const { stdout } = await spawnAsync('git', ['branch', '-a'], { cwd: projectPath });\n\n    const rawLines = stdout\n      .split('\\n')\n      .map(b => b.trim())\n      .filter(b => b && !b.includes('->'));\n\n    // Local branches (may start with '* ' for current)\n    const localBranches = rawLines\n      .filter(b => !b.startsWith('remotes/'))\n      .map(b => (b.startsWith('* ') ? b.substring(2) : b));\n\n    // Remote branches — strip 'remotes/<remote>/' prefix\n    const remoteBranches = rawLines\n      .filter(b => b.startsWith('remotes/'))\n      .map(b => b.replace(/^remotes\\/[^/]+\\//, ''))\n      .filter(name => !localBranches.includes(name)); // skip if already a local branch\n\n    // Backward-compat flat list (local + unique remotes, deduplicated)\n    const branches = [...localBranches, ...remoteBranches]\n      .filter((b, i, arr) => arr.indexOf(b) === i);\n\n    res.json({ branches, localBranches, remoteBranches });\n  } catch (error) {\n    console.error('Git branches error:', error);\n    res.json({ error: error.message });\n  }\n});\n\n// Checkout branch\nrouter.post('/checkout', async (req, res) => {\n  const { project, branch } = req.body;\n  \n  if (!project || !branch) {\n    return res.status(400).json({ error: 'Project name and branch are required' });\n  }\n\n  try {\n    const projectPath = await getActualProjectPath(project);\n    \n    // Checkout the branch\n    validateBranchName(branch);\n    const { stdout } = await spawnAsync('git', ['checkout', branch], { cwd: projectPath });\n    \n    res.json({ success: true, output: stdout });\n  } catch (error) {\n    console.error('Git checkout error:', error);\n    res.status(500).json({ error: error.message });\n  }\n});\n\n// Create new branch\nrouter.post('/create-branch', async (req, res) => {\n  const { project, branch } = req.body;\n  \n  if (!project || !branch) {\n    return res.status(400).json({ error: 'Project name and branch name are required' });\n  }\n\n  try {\n    const projectPath = await getActualProjectPath(project);\n    \n    // Create and checkout new branch\n    validateBranchName(branch);\n    const { stdout } = await spawnAsync('git', ['checkout', '-b', branch], { cwd: projectPath });\n    \n    res.json({ success: true, output: stdout });\n  } catch (error) {\n    console.error('Git create branch error:', error);\n    res.status(500).json({ error: error.message });\n  }\n});\n\n// Delete a local branch\nrouter.post('/delete-branch', async (req, res) => {\n  const { project, branch } = req.body;\n\n  if (!project || !branch) {\n    return res.status(400).json({ error: 'Project name and branch name are required' });\n  }\n\n  try {\n    const projectPath = await getActualProjectPath(project);\n    await validateGitRepository(projectPath);\n\n    // Safety: cannot delete the currently checked-out branch\n    const { stdout: currentBranch } = await spawnAsync('git', ['branch', '--show-current'], { cwd: projectPath });\n    if (currentBranch.trim() === branch) {\n      return res.status(400).json({ error: 'Cannot delete the currently checked-out branch' });\n    }\n\n    const { stdout } = await spawnAsync('git', ['branch', '-d', branch], { cwd: projectPath });\n    res.json({ success: true, output: stdout });\n  } catch (error) {\n    console.error('Git delete branch error:', error);\n    res.status(500).json({ error: error.message });\n  }\n});\n\n// Get recent commits\nrouter.get('/commits', async (req, res) => {\n  const { project, limit = 10 } = req.query;\n  \n  if (!project) {\n    return res.status(400).json({ error: 'Project name is required' });\n  }\n\n  try {\n    const projectPath = await getActualProjectPath(project);\n    await validateGitRepository(projectPath);\n    const parsedLimit = Number.parseInt(String(limit), 10);\n    const safeLimit = Number.isFinite(parsedLimit) && parsedLimit > 0\n      ? Math.min(parsedLimit, 100)\n      : 10;\n    \n    // Get commit log with stats\n    const { stdout } = await spawnAsync(\n      'git',\n      ['log', '--pretty=format:%H|%an|%ae|%ad|%s', '--date=iso-strict', '-n', String(safeLimit)],\n      { cwd: projectPath },\n    );\n    \n    const commits = stdout\n      .split('\\n')\n      .filter(line => line.trim())\n      .map(line => {\n        const [hash, author, email, date, ...messageParts] = line.split('|');\n        return {\n          hash,\n          author,\n          email,\n          date,\n          message: messageParts.join('|')\n        };\n      });\n    \n    // Get stats for each commit\n    for (const commit of commits) {\n      try {\n        const { stdout: stats } = await spawnAsync(\n          'git', ['show', '--stat', '--format=', commit.hash],\n          { cwd: projectPath }\n        );\n        commit.stats = stats.trim().split('\\n').pop(); // Get the summary line\n      } catch (error) {\n        commit.stats = '';\n      }\n    }\n    \n    res.json({ commits });\n  } catch (error) {\n    console.error('Git commits error:', error);\n    res.json({ error: error.message });\n  }\n});\n\n// Get diff for a specific commit\nrouter.get('/commit-diff', async (req, res) => {\n  const { project, commit } = req.query;\n  \n  if (!project || !commit) {\n    return res.status(400).json({ error: 'Project name and commit hash are required' });\n  }\n\n  try {\n    const projectPath = await getActualProjectPath(project);\n\n    // Validate commit reference (defense-in-depth)\n    validateCommitRef(commit);\n\n    // Get diff for the commit\n    const { stdout } = await spawnAsync(\n      'git', ['show', commit],\n      { cwd: projectPath }\n    );\n\n    const isTruncated = stdout.length > COMMIT_DIFF_CHARACTER_LIMIT;\n    const diff = isTruncated\n      ? `${stdout.slice(0, COMMIT_DIFF_CHARACTER_LIMIT)}\\n\\n... Diff truncated to keep the UI responsive ...`\n      : stdout;\n\n    res.json({ diff, isTruncated });\n  } catch (error) {\n    console.error('Git commit diff error:', error);\n    res.json({ error: error.message });\n  }\n});\n\n// Generate commit message based on staged changes using AI\nrouter.post('/generate-commit-message', async (req, res) => {\n  const { project, files, provider = 'claude' } = req.body;\n\n  if (!project || !files || files.length === 0) {\n    return res.status(400).json({ error: 'Project name and files are required' });\n  }\n\n  // Validate provider\n  if (!['claude', 'cursor'].includes(provider)) {\n    return res.status(400).json({ error: 'provider must be \"claude\" or \"cursor\"' });\n  }\n\n  try {\n    const projectPath = await getActualProjectPath(project);\n    await validateGitRepository(projectPath);\n    const repositoryRootPath = await getRepositoryRootPath(projectPath);\n\n    // Get diff for selected files\n    let diffContext = '';\n    for (const file of files) {\n      try {\n        const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);\n        const { stdout } = await spawnAsync(\n          'git', ['diff', 'HEAD', '--', repositoryRelativeFilePath],\n          { cwd: repositoryRootPath }\n        );\n        if (stdout) {\n          diffContext += `\\n--- ${repositoryRelativeFilePath} ---\\n${stdout}`;\n        }\n      } catch (error) {\n        console.error(`Error getting diff for ${file}:`, error);\n      }\n    }\n\n    // If no diff found, might be untracked files\n    if (!diffContext.trim()) {\n      // Try to get content of untracked files\n      for (const file of files) {\n        try {\n          const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);\n          const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);\n          const stats = await fs.stat(filePath);\n\n          if (!stats.isDirectory()) {\n            const content = await fs.readFile(filePath, 'utf-8');\n            diffContext += `\\n--- ${repositoryRelativeFilePath} (new file) ---\\n${content.substring(0, 1000)}\\n`;\n          } else {\n            diffContext += `\\n--- ${repositoryRelativeFilePath} (new directory) ---\\n`;\n          }\n        } catch (error) {\n          console.error(`Error reading file ${file}:`, error);\n        }\n      }\n    }\n\n    // Generate commit message using AI\n    const message = await generateCommitMessageWithAI(files, diffContext, provider, projectPath);\n\n    res.json({ message });\n  } catch (error) {\n    console.error('Generate commit message error:', error);\n    res.status(500).json({ error: error.message });\n  }\n});\n\n/**\n * Generates a commit message using AI (Claude SDK or Cursor CLI)\n * @param {Array<string>} files - List of changed files\n * @param {string} diffContext - Git diff content\n * @param {string} provider - 'claude' or 'cursor'\n * @param {string} projectPath - Project directory path\n * @returns {Promise<string>} Generated commit message\n */\nasync function generateCommitMessageWithAI(files, diffContext, provider, projectPath) {\n  // Create the prompt\n  const prompt = `Generate a conventional commit message for these changes.\n\nREQUIREMENTS:\n- Format: type(scope): subject\n- Include body explaining what changed and why\n- Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore\n- Subject under 50 chars, body wrapped at 72 chars\n- Focus on user-facing changes, not implementation details\n- Consider what's being added AND removed\n- Return ONLY the commit message (no markdown, explanations, or code blocks)\n\nFILES CHANGED:\n${files.map(f => `- ${f}`).join('\\n')}\n\nDIFFS:\n${diffContext.substring(0, 4000)}\n\nGenerate the commit message:`;\n\n  try {\n    // Create a simple writer that collects the response\n    let responseText = '';\n    const writer = {\n      send: (data) => {\n        try {\n          const parsed = typeof data === 'string' ? JSON.parse(data) : data;\n          console.log('🔍 Writer received message type:', parsed.type);\n\n          // Handle different message formats from Claude SDK and Cursor CLI\n          // Claude SDK sends: {type: 'claude-response', data: {message: {content: [...]}}}\n          if (parsed.type === 'claude-response' && parsed.data) {\n            const message = parsed.data.message || parsed.data;\n            console.log('📦 Claude response message:', JSON.stringify(message, null, 2).substring(0, 500));\n            if (message.content && Array.isArray(message.content)) {\n              // Extract text from content array\n              for (const item of message.content) {\n                if (item.type === 'text' && item.text) {\n                  console.log('✅ Extracted text chunk:', item.text.substring(0, 100));\n                  responseText += item.text;\n                }\n              }\n            }\n          }\n          // Cursor CLI sends: {type: 'cursor-output', output: '...'}\n          else if (parsed.type === 'cursor-output' && parsed.output) {\n            console.log('✅ Cursor output:', parsed.output.substring(0, 100));\n            responseText += parsed.output;\n          }\n          // Also handle direct text messages\n          else if (parsed.type === 'text' && parsed.text) {\n            console.log('✅ Direct text:', parsed.text.substring(0, 100));\n            responseText += parsed.text;\n          }\n        } catch (e) {\n          // Ignore parse errors\n          console.error('Error parsing writer data:', e);\n        }\n      },\n      setSessionId: () => {}, // No-op for this use case\n    };\n\n    console.log('🚀 Calling AI agent with provider:', provider);\n    console.log('📝 Prompt length:', prompt.length);\n\n    // Call the appropriate agent\n    if (provider === 'claude') {\n      await queryClaudeSDK(prompt, {\n        cwd: projectPath,\n        permissionMode: 'bypassPermissions',\n        model: 'sonnet'\n      }, writer);\n    } else if (provider === 'cursor') {\n      await spawnCursor(prompt, {\n        cwd: projectPath,\n        skipPermissions: true\n      }, writer);\n    }\n\n    console.log('📊 Total response text collected:', responseText.length, 'characters');\n    console.log('📄 Response preview:', responseText.substring(0, 200));\n\n    // Clean up the response\n    const cleanedMessage = cleanCommitMessage(responseText);\n    console.log('🧹 Cleaned message:', cleanedMessage.substring(0, 200));\n\n    return cleanedMessage || 'chore: update files';\n  } catch (error) {\n    console.error('Error generating commit message with AI:', error);\n    // Fallback to simple message\n    return `chore: update ${files.length} file${files.length !== 1 ? 's' : ''}`;\n  }\n}\n\n/**\n * Cleans the AI-generated commit message by removing markdown, code blocks, and extra formatting\n * @param {string} text - Raw AI response\n * @returns {string} Clean commit message\n */\nfunction cleanCommitMessage(text) {\n  if (!text || !text.trim()) {\n    return '';\n  }\n\n  let cleaned = text.trim();\n\n  // Remove markdown code blocks\n  cleaned = cleaned.replace(/```[a-z]*\\n/g, '');\n  cleaned = cleaned.replace(/```/g, '');\n\n  // Remove markdown headers\n  cleaned = cleaned.replace(/^#+\\s*/gm, '');\n\n  // Remove leading/trailing quotes\n  cleaned = cleaned.replace(/^[\"']|[\"']$/g, '');\n\n  // If there are multiple lines, take everything (subject + body)\n  // Just clean up extra blank lines\n  cleaned = cleaned.replace(/\\n{3,}/g, '\\n\\n');\n\n  // Remove any explanatory text before the actual commit message\n  // Look for conventional commit pattern and start from there\n  const conventionalCommitMatch = cleaned.match(/(feat|fix|docs|style|refactor|perf|test|build|ci|chore)(\\(.+?\\))?:.+/s);\n  if (conventionalCommitMatch) {\n    cleaned = cleaned.substring(cleaned.indexOf(conventionalCommitMatch[0]));\n  }\n\n  return cleaned.trim();\n}\n\n// Get remote status (ahead/behind commits with smart remote detection)\nrouter.get('/remote-status', async (req, res) => {\n  const { project } = req.query;\n  \n  if (!project) {\n    return res.status(400).json({ error: 'Project name is required' });\n  }\n\n  try {\n    const projectPath = await getActualProjectPath(project);\n    await validateGitRepository(projectPath);\n\n    const branch = await getCurrentBranchName(projectPath);\n    const hasCommits = await repositoryHasCommits(projectPath);\n\n    const { stdout: remoteOutput } = await spawnAsync('git', ['remote'], { cwd: projectPath });\n    const remotes = remoteOutput.trim().split('\\n').filter(r => r.trim());\n    const hasRemote = remotes.length > 0;\n    const fallbackRemoteName = hasRemote\n      ? (remotes.includes('origin') ? 'origin' : remotes[0])\n      : null;\n\n    // Repositories initialized with `git init` can have a branch but no commits.\n    // Return a non-error state so the UI can show the initial-commit workflow.\n    if (!hasCommits) {\n      return res.json({\n        hasRemote,\n        hasUpstream: false,\n        branch,\n        remoteName: fallbackRemoteName,\n        ahead: 0,\n        behind: 0,\n        isUpToDate: false,\n        message: 'Repository has no commits yet'\n      });\n    }\n\n    // Check if there's a remote tracking branch (smart detection)\n    let trackingBranch;\n    let remoteName;\n    try {\n      const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });\n      trackingBranch = stdout.trim();\n      remoteName = trackingBranch.split('/')[0]; // Extract remote name (e.g., \"origin/main\" -> \"origin\")\n    } catch (error) {\n      return res.json({\n        hasRemote,\n        hasUpstream: false,\n        branch,\n        remoteName: fallbackRemoteName,\n        message: 'No remote tracking branch configured'\n      });\n    }\n\n    // Get ahead/behind counts\n    const { stdout: countOutput } = await spawnAsync(\n      'git', ['rev-list', '--count', '--left-right', `${trackingBranch}...HEAD`],\n      { cwd: projectPath }\n    );\n    \n    const [behind, ahead] = countOutput.trim().split('\\t').map(Number);\n\n    res.json({\n      hasRemote: true,\n      hasUpstream: true,\n      branch,\n      remoteBranch: trackingBranch,\n      remoteName,\n      ahead: ahead || 0,\n      behind: behind || 0,\n      isUpToDate: ahead === 0 && behind === 0\n    });\n  } catch (error) {\n    console.error('Git remote status error:', error);\n    res.json({ error: error.message });\n  }\n});\n\n// Fetch from remote (using smart remote detection)\nrouter.post('/fetch', async (req, res) => {\n  const { project } = req.body;\n  \n  if (!project) {\n    return res.status(400).json({ error: 'Project name is required' });\n  }\n\n  try {\n    const projectPath = await getActualProjectPath(project);\n    await validateGitRepository(projectPath);\n\n    // Get current branch and its upstream remote\n    const branch = await getCurrentBranchName(projectPath);\n\n    let remoteName = 'origin'; // fallback\n    try {\n      const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });\n      remoteName = stdout.trim().split('/')[0]; // Extract remote name\n    } catch (error) {\n      // No upstream, try to fetch from origin anyway\n      console.log('No upstream configured, using origin as fallback');\n    }\n\n    validateRemoteName(remoteName);\n    const { stdout } = await spawnAsync('git', ['fetch', remoteName], { cwd: projectPath });\n\n    res.json({ success: true, output: stdout || 'Fetch completed successfully', remoteName });\n  } catch (error) {\n    console.error('Git fetch error:', error);\n    res.status(500).json({ \n      error: 'Fetch failed', \n      details: error.message.includes('Could not resolve hostname') \n        ? 'Unable to connect to remote repository. Check your internet connection.'\n        : error.message.includes('fatal: \\'origin\\' does not appear to be a git repository')\n        ? 'No remote repository configured. Add a remote with: git remote add origin <url>'\n        : error.message\n    });\n  }\n});\n\n// Pull from remote (fetch + merge using smart remote detection)\nrouter.post('/pull', async (req, res) => {\n  const { project } = req.body;\n  \n  if (!project) {\n    return res.status(400).json({ error: 'Project name is required' });\n  }\n\n  try {\n    const projectPath = await getActualProjectPath(project);\n    await validateGitRepository(projectPath);\n\n    // Get current branch and its upstream remote\n    const branch = await getCurrentBranchName(projectPath);\n\n    let remoteName = 'origin'; // fallback\n    let remoteBranch = branch; // fallback\n    try {\n      const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });\n      const tracking = stdout.trim();\n      remoteName = tracking.split('/')[0]; // Extract remote name\n      remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name\n    } catch (error) {\n      // No upstream, use fallback\n      console.log('No upstream configured, using origin/branch as fallback');\n    }\n\n    validateRemoteName(remoteName);\n    validateBranchName(remoteBranch);\n    const { stdout } = await spawnAsync('git', ['pull', remoteName, remoteBranch], { cwd: projectPath });\n\n    res.json({\n      success: true,\n      output: stdout || 'Pull completed successfully',\n      remoteName,\n      remoteBranch\n    });\n  } catch (error) {\n    console.error('Git pull error:', error);\n\n    // Enhanced error handling for common pull scenarios\n    let errorMessage = 'Pull failed';\n    let details = error.message;\n    \n    if (error.message.includes('CONFLICT')) {\n      errorMessage = 'Merge conflicts detected';\n      details = 'Pull created merge conflicts. Please resolve conflicts manually in the editor, then commit the changes.';\n    } else if (error.message.includes('Please commit your changes or stash them')) {\n      errorMessage = 'Uncommitted changes detected';  \n      details = 'Please commit or stash your local changes before pulling.';\n    } else if (error.message.includes('Could not resolve hostname')) {\n      errorMessage = 'Network error';\n      details = 'Unable to connect to remote repository. Check your internet connection.';\n    } else if (error.message.includes('fatal: \\'origin\\' does not appear to be a git repository')) {\n      errorMessage = 'Remote not configured';\n      details = 'No remote repository configured. Add a remote with: git remote add origin <url>';\n    } else if (error.message.includes('diverged')) {\n      errorMessage = 'Branches have diverged';\n      details = 'Your local branch and remote branch have diverged. Consider fetching first to review changes.';\n    }\n    \n    res.status(500).json({ \n      error: errorMessage, \n      details: details\n    });\n  }\n});\n\n// Push commits to remote repository\nrouter.post('/push', async (req, res) => {\n  const { project } = req.body;\n  \n  if (!project) {\n    return res.status(400).json({ error: 'Project name is required' });\n  }\n\n  try {\n    const projectPath = await getActualProjectPath(project);\n    await validateGitRepository(projectPath);\n\n    // Get current branch and its upstream remote\n    const branch = await getCurrentBranchName(projectPath);\n\n    let remoteName = 'origin'; // fallback\n    let remoteBranch = branch; // fallback\n    try {\n      const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });\n      const tracking = stdout.trim();\n      remoteName = tracking.split('/')[0]; // Extract remote name\n      remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name\n    } catch (error) {\n      // No upstream, use fallback\n      console.log('No upstream configured, using origin/branch as fallback');\n    }\n\n    validateRemoteName(remoteName);\n    validateBranchName(remoteBranch);\n    const { stdout } = await spawnAsync('git', ['push', remoteName, remoteBranch], { cwd: projectPath });\n\n    res.json({\n      success: true,\n      output: stdout || 'Push completed successfully',\n      remoteName,\n      remoteBranch\n    });\n  } catch (error) {\n    console.error('Git push error:', error);\n    \n    // Enhanced error handling for common push scenarios\n    let errorMessage = 'Push failed';\n    let details = error.message;\n    \n    if (error.message.includes('rejected')) {\n      errorMessage = 'Push rejected';\n      details = 'The remote has newer commits. Pull first to merge changes before pushing.';\n    } else if (error.message.includes('non-fast-forward')) {\n      errorMessage = 'Non-fast-forward push';\n      details = 'Your branch is behind the remote. Pull the latest changes first.';\n    } else if (error.message.includes('Could not resolve hostname')) {\n      errorMessage = 'Network error';\n      details = 'Unable to connect to remote repository. Check your internet connection.';\n    } else if (error.message.includes('fatal: \\'origin\\' does not appear to be a git repository')) {\n      errorMessage = 'Remote not configured';\n      details = 'No remote repository configured. Add a remote with: git remote add origin <url>';\n    } else if (error.message.includes('Permission denied')) {\n      errorMessage = 'Authentication failed';\n      details = 'Permission denied. Check your credentials or SSH keys.';\n    } else if (error.message.includes('no upstream branch')) {\n      errorMessage = 'No upstream branch';\n      details = 'No upstream branch configured. Use: git push --set-upstream origin <branch>';\n    }\n    \n    res.status(500).json({ \n      error: errorMessage, \n      details: details\n    });\n  }\n});\n\n// Publish branch to remote (set upstream and push)\nrouter.post('/publish', async (req, res) => {\n  const { project, branch } = req.body;\n  \n  if (!project || !branch) {\n    return res.status(400).json({ error: 'Project name and branch are required' });\n  }\n\n  try {\n    const projectPath = await getActualProjectPath(project);\n    await validateGitRepository(projectPath);\n\n    // Validate branch name\n    validateBranchName(branch);\n\n    // Get current branch to verify it matches the requested branch\n    const currentBranchName = await getCurrentBranchName(projectPath);\n\n    if (currentBranchName !== branch) {\n      return res.status(400).json({\n        error: `Branch mismatch. Current branch is ${currentBranchName}, but trying to publish ${branch}`\n      });\n    }\n\n    // Check if remote exists\n    let remoteName = 'origin';\n    try {\n      const { stdout } = await spawnAsync('git', ['remote'], { cwd: projectPath });\n      const remotes = stdout.trim().split('\\n').filter(r => r.trim());\n      if (remotes.length === 0) {\n        return res.status(400).json({\n          error: 'No remote repository configured. Add a remote with: git remote add origin <url>'\n        });\n      }\n      remoteName = remotes.includes('origin') ? 'origin' : remotes[0];\n    } catch (error) {\n      return res.status(400).json({\n        error: 'No remote repository configured. Add a remote with: git remote add origin <url>'\n      });\n    }\n\n    // Publish the branch (set upstream and push)\n    validateRemoteName(remoteName);\n    const { stdout } = await spawnAsync('git', ['push', '--set-upstream', remoteName, branch], { cwd: projectPath });\n    \n    res.json({ \n      success: true, \n      output: stdout || 'Branch published successfully', \n      remoteName,\n      branch\n    });\n  } catch (error) {\n    console.error('Git publish error:', error);\n    \n    // Enhanced error handling for common publish scenarios\n    let errorMessage = 'Publish failed';\n    let details = error.message;\n    \n    if (error.message.includes('rejected')) {\n      errorMessage = 'Publish rejected';\n      details = 'The remote branch already exists and has different commits. Use push instead.';\n    } else if (error.message.includes('Could not resolve hostname')) {\n      errorMessage = 'Network error';\n      details = 'Unable to connect to remote repository. Check your internet connection.';\n    } else if (error.message.includes('Permission denied')) {\n      errorMessage = 'Authentication failed';\n      details = 'Permission denied. Check your credentials or SSH keys.';\n    } else if (error.message.includes('fatal:') && error.message.includes('does not appear to be a git repository')) {\n      errorMessage = 'Remote not configured';\n      details = 'Remote repository not properly configured. Check your remote URL.';\n    }\n    \n    res.status(500).json({ \n      error: errorMessage, \n      details: details\n    });\n  }\n});\n\n// Discard changes for a specific file\nrouter.post('/discard', async (req, res) => {\n  const { project, file } = req.body;\n  \n  if (!project || !file) {\n    return res.status(400).json({ error: 'Project name and file path are required' });\n  }\n\n  try {\n    const projectPath = await getActualProjectPath(project);\n    await validateGitRepository(projectPath);\n    const {\n      repositoryRootPath,\n      repositoryRelativeFilePath,\n    } = await resolveRepositoryFilePath(projectPath, file);\n\n    // Check file status to determine correct discard command\n    const { stdout: statusOutput } = await spawnAsync(\n      'git',\n      ['status', '--porcelain', '--', repositoryRelativeFilePath],\n      { cwd: repositoryRootPath },\n    );\n\n    if (!statusOutput.trim()) {\n      return res.status(400).json({ error: 'No changes to discard for this file' });\n    }\n\n    const status = statusOutput.substring(0, 2);\n\n    if (status === '??') {\n      // Untracked file or directory - delete it\n      const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);\n      const stats = await fs.stat(filePath);\n\n      if (stats.isDirectory()) {\n        await fs.rm(filePath, { recursive: true, force: true });\n      } else {\n        await fs.unlink(filePath);\n      }\n    } else if (status.includes('M') || status.includes('D')) {\n      // Modified or deleted file - restore from HEAD\n      await spawnAsync('git', ['restore', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });\n    } else if (status.includes('A')) {\n      // Added file - unstage it\n      await spawnAsync('git', ['reset', 'HEAD', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });\n    }\n    \n    res.json({ success: true, message: `Changes discarded for ${repositoryRelativeFilePath}` });\n  } catch (error) {\n    console.error('Git discard error:', error);\n    res.status(500).json({ error: error.message });\n  }\n});\n\n// Delete untracked file\nrouter.post('/delete-untracked', async (req, res) => {\n  const { project, file } = req.body;\n  \n  if (!project || !file) {\n    return res.status(400).json({ error: 'Project name and file path are required' });\n  }\n\n  try {\n    const projectPath = await getActualProjectPath(project);\n    await validateGitRepository(projectPath);\n    const {\n      repositoryRootPath,\n      repositoryRelativeFilePath,\n    } = await resolveRepositoryFilePath(projectPath, file);\n\n    // Check if file is actually untracked\n    const { stdout: statusOutput } = await spawnAsync(\n      'git',\n      ['status', '--porcelain', '--', repositoryRelativeFilePath],\n      { cwd: repositoryRootPath },\n    );\n    \n    if (!statusOutput.trim()) {\n      return res.status(400).json({ error: 'File is not untracked or does not exist' });\n    }\n\n    const status = statusOutput.substring(0, 2);\n    \n    if (status !== '??') {\n      return res.status(400).json({ error: 'File is not untracked. Use discard for tracked files.' });\n    }\n\n    // Delete the untracked file or directory\n    const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);\n    const stats = await fs.stat(filePath);\n\n    if (stats.isDirectory()) {\n      // Use rm with recursive option for directories\n      await fs.rm(filePath, { recursive: true, force: true });\n      res.json({ success: true, message: `Untracked directory ${repositoryRelativeFilePath} deleted successfully` });\n    } else {\n      await fs.unlink(filePath);\n      res.json({ success: true, message: `Untracked file ${repositoryRelativeFilePath} deleted successfully` });\n    }\n  } catch (error) {\n    console.error('Git delete untracked error:', error);\n    res.status(500).json({ error: error.message });\n  }\n});\n\nexport default router;\n"
  },
  {
    "path": "server/routes/mcp-utils.js",
    "content": "/**\n * MCP UTILITIES API ROUTES\n * ========================\n * \n * API endpoints for MCP server detection and configuration utilities.\n * These endpoints expose centralized MCP detection functionality.\n */\n\nimport express from 'express';\nimport { detectTaskMasterMCPServer, getAllMCPServers } from '../utils/mcp-detector.js';\n\nconst router = express.Router();\n\n/**\n * GET /api/mcp-utils/taskmaster-server\n * Check if TaskMaster MCP server is configured\n */\nrouter.get('/taskmaster-server', async (req, res) => {\n    try {\n        const result = await detectTaskMasterMCPServer();\n        res.json(result);\n    } catch (error) {\n        console.error('TaskMaster MCP detection error:', error);\n        res.status(500).json({\n            error: 'Failed to detect TaskMaster MCP server',\n            message: error.message\n        });\n    }\n});\n\n/**\n * GET /api/mcp-utils/all-servers\n * Get all configured MCP servers\n */\nrouter.get('/all-servers', async (req, res) => {\n    try {\n        const result = await getAllMCPServers();\n        res.json(result);\n    } catch (error) {\n        console.error('MCP servers detection error:', error);\n        res.status(500).json({\n            error: 'Failed to get MCP servers',\n            message: error.message\n        });\n    }\n});\n\nexport default router;"
  },
  {
    "path": "server/routes/mcp.js",
    "content": "import express from 'express';\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport os from 'os';\nimport { fileURLToPath } from 'url';\nimport { dirname } from 'path';\nimport { spawn } from 'child_process';\n\nconst router = express.Router();\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = dirname(__filename);\n\n// Claude CLI command routes\n\n// GET /api/mcp/cli/list - List MCP servers using Claude CLI\nrouter.get('/cli/list', async (req, res) => {\n  try {\n    console.log('📋 Listing MCP servers using Claude CLI');\n    \n    const { spawn } = await import('child_process');\n    const { promisify } = await import('util');\n    const exec = promisify(spawn);\n    \n    const process = spawn('claude', ['mcp', 'list'], {\n      stdio: ['pipe', 'pipe', 'pipe']\n    });\n    \n    let stdout = '';\n    let stderr = '';\n    \n    process.stdout.on('data', (data) => {\n      stdout += data.toString();\n    });\n    \n    process.stderr.on('data', (data) => {\n      stderr += data.toString();\n    });\n    \n    process.on('close', (code) => {\n      if (code === 0) {\n        res.json({ success: true, output: stdout, servers: parseClaudeListOutput(stdout) });\n      } else {\n        console.error('Claude CLI error:', stderr);\n        res.status(500).json({ error: 'Claude CLI command failed', details: stderr });\n      }\n    });\n    \n    process.on('error', (error) => {\n      console.error('Error running Claude CLI:', error);\n      res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });\n    });\n  } catch (error) {\n    console.error('Error listing MCP servers via CLI:', error);\n    res.status(500).json({ error: 'Failed to list MCP servers', details: error.message });\n  }\n});\n\n// POST /api/mcp/cli/add - Add MCP server using Claude CLI\nrouter.post('/cli/add', async (req, res) => {\n  try {\n    const { name, type = 'stdio', command, args = [], url, headers = {}, env = {}, scope = 'user', projectPath } = req.body;\n    \n    console.log(`➕ Adding MCP server using Claude CLI (${scope} scope):`, name);\n    \n    const { spawn } = await import('child_process');\n    \n    let cliArgs = ['mcp', 'add'];\n    \n    // Add scope flag\n    cliArgs.push('--scope', scope);\n    \n    if (type === 'http') {\n      cliArgs.push('--transport', 'http', name, url);\n      // Add headers if provided\n      Object.entries(headers).forEach(([key, value]) => {\n        cliArgs.push('--header', `${key}: ${value}`);\n      });\n    } else if (type === 'sse') {\n      cliArgs.push('--transport', 'sse', name, url);\n      // Add headers if provided\n      Object.entries(headers).forEach(([key, value]) => {\n        cliArgs.push('--header', `${key}: ${value}`);\n      });\n    } else {\n      // stdio (default): claude mcp add --scope user <name> <command> [args...]\n      cliArgs.push(name);\n      // Add environment variables\n      Object.entries(env).forEach(([key, value]) => {\n        cliArgs.push('-e', `${key}=${value}`);\n      });\n      cliArgs.push(command);\n      if (args && args.length > 0) {\n        cliArgs.push(...args);\n      }\n    }\n    \n    console.log('🔧 Running Claude CLI command:', 'claude', cliArgs.join(' '));\n    \n    // For local scope, we need to run the command in the project directory\n    const spawnOptions = {\n      stdio: ['pipe', 'pipe', 'pipe']\n    };\n    \n    if (scope === 'local' && projectPath) {\n      spawnOptions.cwd = projectPath;\n      console.log('📁 Running in project directory:', projectPath);\n    }\n    \n    const process = spawn('claude', cliArgs, spawnOptions);\n    \n    let stdout = '';\n    let stderr = '';\n    \n    process.stdout.on('data', (data) => {\n      stdout += data.toString();\n    });\n    \n    process.stderr.on('data', (data) => {\n      stderr += data.toString();\n    });\n    \n    process.on('close', (code) => {\n      if (code === 0) {\n        res.json({ success: true, output: stdout, message: `MCP server \"${name}\" added successfully` });\n      } else {\n        console.error('Claude CLI error:', stderr);\n        res.status(400).json({ error: 'Claude CLI command failed', details: stderr });\n      }\n    });\n    \n    process.on('error', (error) => {\n      console.error('Error running Claude CLI:', error);\n      res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });\n    });\n  } catch (error) {\n    console.error('Error adding MCP server via CLI:', error);\n    res.status(500).json({ error: 'Failed to add MCP server', details: error.message });\n  }\n});\n\n// POST /api/mcp/cli/add-json - Add MCP server using JSON format\nrouter.post('/cli/add-json', async (req, res) => {\n  try {\n    const { name, jsonConfig, scope = 'user', projectPath } = req.body;\n    \n    console.log('➕ Adding MCP server using JSON format:', name);\n    \n    // Validate and parse JSON config\n    let parsedConfig;\n    try {\n      parsedConfig = typeof jsonConfig === 'string' ? JSON.parse(jsonConfig) : jsonConfig;\n    } catch (parseError) {\n      return res.status(400).json({ \n        error: 'Invalid JSON configuration', \n        details: parseError.message \n      });\n    }\n    \n    // Validate required fields\n    if (!parsedConfig.type) {\n      return res.status(400).json({ \n        error: 'Invalid configuration', \n        details: 'Missing required field: type' \n      });\n    }\n    \n    if (parsedConfig.type === 'stdio' && !parsedConfig.command) {\n      return res.status(400).json({ \n        error: 'Invalid configuration', \n        details: 'stdio type requires a command field' \n      });\n    }\n    \n    if ((parsedConfig.type === 'http' || parsedConfig.type === 'sse') && !parsedConfig.url) {\n      return res.status(400).json({ \n        error: 'Invalid configuration', \n        details: `${parsedConfig.type} type requires a url field` \n      });\n    }\n    \n    const { spawn } = await import('child_process');\n    \n    // Build the command: claude mcp add-json --scope <scope> <name> '<json>'\n    const cliArgs = ['mcp', 'add-json', '--scope', scope, name];\n    \n    // Add the JSON config as a properly formatted string\n    const jsonString = JSON.stringify(parsedConfig);\n    cliArgs.push(jsonString);\n    \n    console.log('🔧 Running Claude CLI command:', 'claude', cliArgs[0], cliArgs[1], cliArgs[2], cliArgs[3], cliArgs[4], jsonString);\n    \n    // For local scope, we need to run the command in the project directory\n    const spawnOptions = {\n      stdio: ['pipe', 'pipe', 'pipe']\n    };\n    \n    if (scope === 'local' && projectPath) {\n      spawnOptions.cwd = projectPath;\n      console.log('📁 Running in project directory:', projectPath);\n    }\n    \n    const process = spawn('claude', cliArgs, spawnOptions);\n    \n    let stdout = '';\n    let stderr = '';\n    \n    process.stdout.on('data', (data) => {\n      stdout += data.toString();\n    });\n    \n    process.stderr.on('data', (data) => {\n      stderr += data.toString();\n    });\n    \n    process.on('close', (code) => {\n      if (code === 0) {\n        res.json({ success: true, output: stdout, message: `MCP server \"${name}\" added successfully via JSON` });\n      } else {\n        console.error('Claude CLI error:', stderr);\n        res.status(400).json({ error: 'Claude CLI command failed', details: stderr });\n      }\n    });\n    \n    process.on('error', (error) => {\n      console.error('Error running Claude CLI:', error);\n      res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });\n    });\n  } catch (error) {\n    console.error('Error adding MCP server via JSON:', error);\n    res.status(500).json({ error: 'Failed to add MCP server', details: error.message });\n  }\n});\n\n// DELETE /api/mcp/cli/remove/:name - Remove MCP server using Claude CLI\nrouter.delete('/cli/remove/:name', async (req, res) => {\n  try {\n    const { name } = req.params;\n    const { scope } = req.query; // Get scope from query params\n    \n    // Handle the ID format (remove scope prefix if present)\n    let actualName = name;\n    let actualScope = scope;\n    \n    // If the name includes a scope prefix like \"local:test\", extract it\n    if (name.includes(':')) {\n      const [prefix, serverName] = name.split(':');\n      actualName = serverName;\n      actualScope = actualScope || prefix; // Use prefix as scope if not provided in query\n    }\n    \n    console.log('🗑️ Removing MCP server using Claude CLI:', actualName, 'scope:', actualScope);\n    \n    const { spawn } = await import('child_process');\n    \n    // Build command args based on scope\n    let cliArgs = ['mcp', 'remove'];\n    \n    // Add scope flag if it's local scope\n    if (actualScope === 'local') {\n      cliArgs.push('--scope', 'local');\n    } else if (actualScope === 'user' || !actualScope) {\n      // User scope is default, but we can be explicit\n      cliArgs.push('--scope', 'user');\n    }\n    \n    cliArgs.push(actualName);\n    \n    console.log('🔧 Running Claude CLI command:', 'claude', cliArgs.join(' '));\n    \n    const process = spawn('claude', cliArgs, {\n      stdio: ['pipe', 'pipe', 'pipe']\n    });\n    \n    let stdout = '';\n    let stderr = '';\n    \n    process.stdout.on('data', (data) => {\n      stdout += data.toString();\n    });\n    \n    process.stderr.on('data', (data) => {\n      stderr += data.toString();\n    });\n    \n    process.on('close', (code) => {\n      if (code === 0) {\n        res.json({ success: true, output: stdout, message: `MCP server \"${name}\" removed successfully` });\n      } else {\n        console.error('Claude CLI error:', stderr);\n        res.status(400).json({ error: 'Claude CLI command failed', details: stderr });\n      }\n    });\n    \n    process.on('error', (error) => {\n      console.error('Error running Claude CLI:', error);\n      res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });\n    });\n  } catch (error) {\n    console.error('Error removing MCP server via CLI:', error);\n    res.status(500).json({ error: 'Failed to remove MCP server', details: error.message });\n  }\n});\n\n// GET /api/mcp/cli/get/:name - Get MCP server details using Claude CLI\nrouter.get('/cli/get/:name', async (req, res) => {\n  try {\n    const { name } = req.params;\n    \n    console.log('📄 Getting MCP server details using Claude CLI:', name);\n    \n    const { spawn } = await import('child_process');\n    \n    const process = spawn('claude', ['mcp', 'get', name], {\n      stdio: ['pipe', 'pipe', 'pipe']\n    });\n    \n    let stdout = '';\n    let stderr = '';\n    \n    process.stdout.on('data', (data) => {\n      stdout += data.toString();\n    });\n    \n    process.stderr.on('data', (data) => {\n      stderr += data.toString();\n    });\n    \n    process.on('close', (code) => {\n      if (code === 0) {\n        res.json({ success: true, output: stdout, server: parseClaudeGetOutput(stdout) });\n      } else {\n        console.error('Claude CLI error:', stderr);\n        res.status(404).json({ error: 'Claude CLI command failed', details: stderr });\n      }\n    });\n    \n    process.on('error', (error) => {\n      console.error('Error running Claude CLI:', error);\n      res.status(500).json({ error: 'Failed to run Claude CLI', details: error.message });\n    });\n  } catch (error) {\n    console.error('Error getting MCP server details via CLI:', error);\n    res.status(500).json({ error: 'Failed to get MCP server details', details: error.message });\n  }\n});\n\n// GET /api/mcp/config/read - Read MCP servers directly from Claude config files\nrouter.get('/config/read', async (req, res) => {\n  try {\n    console.log('📖 Reading MCP servers from Claude config files');\n    \n    const homeDir = os.homedir();\n    const configPaths = [\n      path.join(homeDir, '.claude.json'),\n      path.join(homeDir, '.claude', 'settings.json')\n    ];\n    \n    let configData = null;\n    let configPath = null;\n    \n    // Try to read from either config file\n    for (const filepath of configPaths) {\n      try {\n        const fileContent = await fs.readFile(filepath, 'utf8');\n        configData = JSON.parse(fileContent);\n        configPath = filepath;\n        console.log(`✅ Found Claude config at: ${filepath}`);\n        break;\n      } catch (error) {\n        // File doesn't exist or is not valid JSON, try next\n        console.log(`ℹ️ Config not found or invalid at: ${filepath}`);\n      }\n    }\n    \n    if (!configData) {\n      return res.json({ \n        success: false, \n        message: 'No Claude configuration file found',\n        servers: [] \n      });\n    }\n    \n    // Extract MCP servers from the config\n    const servers = [];\n    \n    // Check for user-scoped MCP servers (at root level)\n    if (configData.mcpServers && typeof configData.mcpServers === 'object' && Object.keys(configData.mcpServers).length > 0) {\n      console.log('🔍 Found user-scoped MCP servers:', Object.keys(configData.mcpServers));\n      for (const [name, config] of Object.entries(configData.mcpServers)) {\n        const server = {\n          id: name,\n          name: name,\n          type: 'stdio', // Default type\n          scope: 'user',  // User scope - available across all projects\n          config: {},\n          raw: config // Include raw config for full details\n        };\n        \n        // Determine transport type and extract config\n        if (config.command) {\n          server.type = 'stdio';\n          server.config.command = config.command;\n          server.config.args = config.args || [];\n          server.config.env = config.env || {};\n        } else if (config.url) {\n          server.type = config.transport || 'http';\n          server.config.url = config.url;\n          server.config.headers = config.headers || {};\n        }\n        \n        servers.push(server);\n      }\n    }\n    \n    // Check for local-scoped MCP servers (project-specific)\n    const currentProjectPath = process.cwd();\n    \n    // Check under 'projects' key\n    if (configData.projects && configData.projects[currentProjectPath]) {\n      const projectConfig = configData.projects[currentProjectPath];\n      if (projectConfig.mcpServers && typeof projectConfig.mcpServers === 'object' && Object.keys(projectConfig.mcpServers).length > 0) {\n        console.log(`🔍 Found local-scoped MCP servers for ${currentProjectPath}:`, Object.keys(projectConfig.mcpServers));\n        for (const [name, config] of Object.entries(projectConfig.mcpServers)) {\n          const server = {\n            id: `local:${name}`,  // Prefix with scope for uniqueness\n            name: name,           // Keep original name\n            type: 'stdio', // Default type\n            scope: 'local',  // Local scope - only for this project\n            projectPath: currentProjectPath,\n            config: {},\n            raw: config // Include raw config for full details\n          };\n          \n          // Determine transport type and extract config\n          if (config.command) {\n            server.type = 'stdio';\n            server.config.command = config.command;\n            server.config.args = config.args || [];\n            server.config.env = config.env || {};\n          } else if (config.url) {\n            server.type = config.transport || 'http';\n            server.config.url = config.url;\n            server.config.headers = config.headers || {};\n          }\n          \n          servers.push(server);\n        }\n      }\n    }\n    \n    console.log(`📋 Found ${servers.length} MCP servers in config`);\n    \n    res.json({ \n      success: true, \n      configPath: configPath,\n      servers: servers \n    });\n  } catch (error) {\n    console.error('Error reading Claude config:', error);\n    res.status(500).json({ \n      error: 'Failed to read Claude configuration', \n      details: error.message \n    });\n  }\n});\n\n// Helper functions to parse Claude CLI output\nfunction parseClaudeListOutput(output) {\n  const servers = [];\n  const lines = output.split('\\n').filter(line => line.trim());\n  \n  for (const line of lines) {\n    // Skip the header line\n    if (line.includes('Checking MCP server health')) continue;\n    \n    // Parse lines like \"test: test test - ✗ Failed to connect\"\n    // or \"server-name: command or description - ✓ Connected\"\n    if (line.includes(':')) {\n      const colonIndex = line.indexOf(':');\n      const name = line.substring(0, colonIndex).trim();\n      \n      // Skip empty names\n      if (!name) continue;\n      \n      // Extract the rest after the name\n      const rest = line.substring(colonIndex + 1).trim();\n      \n      // Try to extract description and status\n      let description = rest;\n      let status = 'unknown';\n      let type = 'stdio'; // default type\n      \n      // Check for status indicators\n      if (rest.includes('✓') || rest.includes('✗')) {\n        const statusMatch = rest.match(/(.*?)\\s*-\\s*([✓✗].*)$/);\n        if (statusMatch) {\n          description = statusMatch[1].trim();\n          status = statusMatch[2].includes('✓') ? 'connected' : 'failed';\n        }\n      }\n      \n      // Try to determine type from description\n      if (description.startsWith('http://') || description.startsWith('https://')) {\n        type = 'http';\n      }\n      \n      servers.push({\n        name,\n        type,\n        status: status || 'active',\n        description\n      });\n    }\n  }\n  \n  console.log('🔍 Parsed Claude CLI servers:', servers);\n  return servers;\n}\n\nfunction parseClaudeGetOutput(output) {\n  // Parse the output from 'claude mcp get <name>' command\n  // This is a simple parser - might need adjustment based on actual output format\n  try {\n    // Try to extract JSON if present\n    const jsonMatch = output.match(/\\{[\\s\\S]*\\}/);\n    if (jsonMatch) {\n      return JSON.parse(jsonMatch[0]);\n    }\n    \n    // Otherwise, parse as text\n    const server = { raw_output: output };\n    const lines = output.split('\\n');\n    \n    for (const line of lines) {\n      if (line.includes('Name:')) {\n        server.name = line.split(':')[1]?.trim();\n      } else if (line.includes('Type:')) {\n        server.type = line.split(':')[1]?.trim();\n      } else if (line.includes('Command:')) {\n        server.command = line.split(':')[1]?.trim();\n      } else if (line.includes('URL:')) {\n        server.url = line.split(':')[1]?.trim();\n      }\n    }\n    \n    return server;\n  } catch (error) {\n    return { raw_output: output, parse_error: error.message };\n  }\n}\n\nexport default router;"
  },
  {
    "path": "server/routes/messages.js",
    "content": "/**\n * Unified messages endpoint.\n *\n * GET /api/sessions/:sessionId/messages?provider=claude&projectName=foo&limit=50&offset=0\n *\n * Replaces the four provider-specific session message endpoints with a single route\n * that delegates to the appropriate adapter via the provider registry.\n *\n * @module routes/messages\n */\n\nimport express from 'express';\nimport { getProvider, getAllProviders } from '../providers/registry.js';\n\nconst router = express.Router();\n\n/**\n * GET /api/sessions/:sessionId/messages\n *\n * Auth: authenticateToken applied at mount level in index.js\n *\n * Query params:\n *   provider    - 'claude' | 'cursor' | 'codex' | 'gemini' (default: 'claude')\n *   projectName - required for claude provider\n *   projectPath - required for cursor provider (absolute path used for cwdId hash)\n *   limit       - page size (omit or null for all)\n *   offset      - pagination offset (default: 0)\n */\nrouter.get('/:sessionId/messages', async (req, res) => {\n  try {\n    const { sessionId } = req.params;\n    const provider = req.query.provider || 'claude';\n    const projectName = req.query.projectName || '';\n    const projectPath = req.query.projectPath || '';\n    const limitParam = req.query.limit;\n    const limit = limitParam !== undefined && limitParam !== null && limitParam !== ''\n      ? parseInt(limitParam, 10)\n      : null;\n    const offset = parseInt(req.query.offset || '0', 10);\n\n    const adapter = getProvider(provider);\n    if (!adapter) {\n      const available = getAllProviders().join(', ');\n      return res.status(400).json({ error: `Unknown provider: ${provider}. Available: ${available}` });\n    }\n\n    const result = await adapter.fetchHistory(sessionId, {\n      projectName,\n      projectPath,\n      limit,\n      offset,\n    });\n\n    return res.json(result);\n  } catch (error) {\n    console.error('Error fetching unified messages:', error);\n    return res.status(500).json({ error: 'Failed to fetch messages' });\n  }\n});\n\nexport default router;\n"
  },
  {
    "path": "server/routes/plugins.js",
    "content": "import express from 'express';\nimport path from 'path';\nimport http from 'http';\nimport mime from 'mime-types';\nimport fs from 'fs';\nimport {\n  scanPlugins,\n  getPluginsConfig,\n  getPluginsDir,\n  savePluginsConfig,\n  getPluginDir,\n  resolvePluginAssetPath,\n  installPluginFromGit,\n  updatePluginFromGit,\n  uninstallPlugin,\n} from '../utils/plugin-loader.js';\nimport {\n  startPluginServer,\n  stopPluginServer,\n  getPluginPort,\n  isPluginRunning,\n} from '../utils/plugin-process-manager.js';\n\nconst router = express.Router();\n\n// GET / — List all installed plugins (includes server running status)\nrouter.get('/', (req, res) => {\n  try {\n    const plugins = scanPlugins().map(p => ({\n      ...p,\n      serverRunning: p.server ? isPluginRunning(p.name) : false,\n    }));\n    res.json({ plugins });\n  } catch (err) {\n    res.status(500).json({ error: 'Failed to scan plugins', details: err.message });\n  }\n});\n\n// GET /:name/manifest — Get single plugin manifest\nrouter.get('/:name/manifest', (req, res) => {\n  try {\n    if (!/^[a-zA-Z0-9_-]+$/.test(req.params.name)) {\n      return res.status(400).json({ error: 'Invalid plugin name' });\n    }\n    const plugins = scanPlugins();\n    const plugin = plugins.find(p => p.name === req.params.name);\n    if (!plugin) {\n      return res.status(404).json({ error: 'Plugin not found' });\n    }\n    res.json(plugin);\n  } catch (err) {\n    res.status(500).json({ error: 'Failed to read plugin manifest', details: err.message });\n  }\n});\n\n// GET /:name/assets/* — Serve plugin static files\nrouter.get('/:name/assets/*', (req, res) => {\n  const pluginName = req.params.name;\n  if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {\n    return res.status(400).json({ error: 'Invalid plugin name' });\n  }\n  const assetPath = req.params[0];\n\n  if (!assetPath) {\n    return res.status(400).json({ error: 'No asset path specified' });\n  }\n\n  const resolvedPath = resolvePluginAssetPath(pluginName, assetPath);\n  if (!resolvedPath) {\n    return res.status(404).json({ error: 'Asset not found' });\n  }\n\n  try {\n    const stat = fs.statSync(resolvedPath);\n    if (!stat.isFile()) {\n      return res.status(404).json({ error: 'Asset not found' });\n    }\n  } catch {\n    return res.status(404).json({ error: 'Asset not found' });\n  }\n\n  const contentType = mime.lookup(resolvedPath) || 'application/octet-stream';\n  res.setHeader('Content-Type', contentType);\n  // Prevent CDN/proxy caching of plugin assets so updates take effect immediately\n  res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');\n  res.setHeader('Pragma', 'no-cache');\n  res.setHeader('Expires', '0');\n  const stream = fs.createReadStream(resolvedPath);\n  stream.on('error', () => {\n    if (!res.headersSent) {\n      res.status(500).json({ error: 'Failed to read asset' });\n    } else {\n      res.end();\n    }\n  });\n  stream.pipe(res);\n});\n\n// PUT /:name/enable — Toggle plugin enabled/disabled (starts/stops server if applicable)\nrouter.put('/:name/enable', async (req, res) => {\n  try {\n    const { enabled } = req.body;\n    if (typeof enabled !== 'boolean') {\n      return res.status(400).json({ error: '\"enabled\" must be a boolean' });\n    }\n\n    const plugins = scanPlugins();\n    const plugin = plugins.find(p => p.name === req.params.name);\n    if (!plugin) {\n      return res.status(404).json({ error: 'Plugin not found' });\n    }\n\n    const config = getPluginsConfig();\n    config[req.params.name] = { ...config[req.params.name], enabled };\n    savePluginsConfig(config);\n\n    // Start or stop the plugin server as needed\n    if (plugin.server) {\n      if (enabled && !isPluginRunning(plugin.name)) {\n        const pluginDir = getPluginDir(plugin.name);\n        if (pluginDir) {\n          try {\n            await startPluginServer(plugin.name, pluginDir, plugin.server);\n          } catch (err) {\n            console.error(`[Plugins] Failed to start server for \"${plugin.name}\":`, err.message);\n          }\n        }\n      } else if (!enabled && isPluginRunning(plugin.name)) {\n        await stopPluginServer(plugin.name);\n      }\n    }\n\n    res.json({ success: true, name: req.params.name, enabled });\n  } catch (err) {\n    res.status(500).json({ error: 'Failed to update plugin', details: err.message });\n  }\n});\n\n// POST /install — Install plugin from git URL\nrouter.post('/install', async (req, res) => {\n  try {\n    const { url } = req.body;\n    if (!url || typeof url !== 'string') {\n      return res.status(400).json({ error: '\"url\" is required and must be a string' });\n    }\n\n    // Basic URL validation\n    if (!url.startsWith('https://') && !url.startsWith('git@')) {\n      return res.status(400).json({ error: 'URL must start with https:// or git@' });\n    }\n\n    const manifest = await installPluginFromGit(url);\n\n    // Auto-start the server if the plugin has one (enabled by default)\n    if (manifest.server) {\n      const pluginDir = getPluginDir(manifest.name);\n      if (pluginDir) {\n        try {\n          await startPluginServer(manifest.name, pluginDir, manifest.server);\n        } catch (err) {\n          console.error(`[Plugins] Failed to start server for \"${manifest.name}\":`, err.message);\n        }\n      }\n    }\n\n    res.json({ success: true, plugin: manifest });\n  } catch (err) {\n    res.status(400).json({ error: 'Failed to install plugin', details: err.message });\n  }\n});\n\n// POST /:name/update — Pull latest from git (restarts server if running)\nrouter.post('/:name/update', async (req, res) => {\n  try {\n    const pluginName = req.params.name;\n\n    if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {\n      return res.status(400).json({ error: 'Invalid plugin name' });\n    }\n\n    const wasRunning = isPluginRunning(pluginName);\n    if (wasRunning) {\n      await stopPluginServer(pluginName);\n    }\n\n    const manifest = await updatePluginFromGit(pluginName);\n\n    // Restart server if it was running before the update\n    if (wasRunning && manifest.server) {\n      const pluginDir = getPluginDir(pluginName);\n      if (pluginDir) {\n        try {\n          await startPluginServer(pluginName, pluginDir, manifest.server);\n        } catch (err) {\n          console.error(`[Plugins] Failed to restart server for \"${pluginName}\":`, err.message);\n        }\n      }\n    }\n\n    res.json({ success: true, plugin: manifest });\n  } catch (err) {\n    res.status(400).json({ error: 'Failed to update plugin', details: err.message });\n  }\n});\n\n// ALL /:name/rpc/* — Proxy requests to plugin's server subprocess\nrouter.all('/:name/rpc/*', async (req, res) => {\n  const pluginName = req.params.name;\n  const rpcPath = req.params[0] || '';\n\n  if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {\n    return res.status(400).json({ error: 'Invalid plugin name' });\n  }\n\n  let port = getPluginPort(pluginName);\n  if (!port) {\n    // Lazily start the plugin server if it exists and is enabled\n    const plugins = scanPlugins();\n    const plugin = plugins.find(p => p.name === pluginName);\n    if (!plugin || !plugin.server) {\n      return res.status(503).json({ error: 'Plugin server is not running' });\n    }\n    if (!plugin.enabled) {\n      return res.status(503).json({ error: 'Plugin is disabled' });\n    }\n    const pluginDir = path.join(getPluginsDir(), plugin.dirName);\n    try {\n      port = await startPluginServer(pluginName, pluginDir, plugin.server);\n    } catch (err) {\n      return res.status(503).json({ error: 'Plugin server failed to start', details: err.message });\n    }\n  }\n\n  // Inject configured secrets as headers\n  const config = getPluginsConfig();\n  const pluginConfig = config[pluginName] || {};\n  const secrets = pluginConfig.secrets || {};\n\n  const headers = {\n    'content-type': req.headers['content-type'] || 'application/json',\n  };\n\n  // Add per-plugin user-configured secrets as X-Plugin-Secret-* headers\n  for (const [key, value] of Object.entries(secrets)) {\n    headers[`x-plugin-secret-${key.toLowerCase()}`] = String(value);\n  }\n\n  // Reconstruct query string\n  const qs = req.url.includes('?') ? '?' + req.url.split('?').slice(1).join('?') : '';\n\n  const options = {\n    hostname: '127.0.0.1',\n    port,\n    path: `/${rpcPath}${qs}`,\n    method: req.method,\n    headers,\n  };\n\n  const proxyReq = http.request(options, (proxyRes) => {\n    res.writeHead(proxyRes.statusCode, proxyRes.headers);\n    proxyRes.pipe(res);\n  });\n\n  proxyReq.on('error', (err) => {\n    if (!res.headersSent) {\n      res.status(502).json({ error: 'Plugin server error', details: err.message });\n    } else {\n      res.end();\n    }\n  });\n\n  // Forward body (already parsed by express JSON middleware, so re-stringify).\n  // Check content-length to detect whether a body was actually sent, since\n  // req.body can be falsy for valid payloads like 0, false, null, or {}.\n  const hasBody = req.headers['content-length'] && parseInt(req.headers['content-length'], 10) > 0;\n  if (hasBody && req.body !== undefined) {\n    const bodyStr = JSON.stringify(req.body);\n    proxyReq.setHeader('content-length', Buffer.byteLength(bodyStr));\n    proxyReq.write(bodyStr);\n  }\n\n  proxyReq.end();\n});\n\n// DELETE /:name — Uninstall plugin (stops server first)\nrouter.delete('/:name', async (req, res) => {\n  try {\n    const pluginName = req.params.name;\n\n    // Validate name format to prevent path traversal\n    if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {\n      return res.status(400).json({ error: 'Invalid plugin name' });\n    }\n\n    // Stop server and wait for the process to fully exit before deleting files\n    if (isPluginRunning(pluginName)) {\n      await stopPluginServer(pluginName);\n    }\n\n    await uninstallPlugin(pluginName);\n    res.json({ success: true, name: pluginName });\n  } catch (err) {\n    res.status(400).json({ error: 'Failed to uninstall plugin', details: err.message });\n  }\n});\n\nexport default router;\n"
  },
  {
    "path": "server/routes/projects.js",
    "content": "import express from 'express';\nimport { promises as fs } from 'fs';\nimport path from 'path';\nimport { spawn } from 'child_process';\nimport os from 'os';\nimport { addProjectManually } from '../projects.js';\n\nconst router = express.Router();\n\nfunction sanitizeGitError(message, token) {\n  if (!message || !token) return message;\n  return message.replace(new RegExp(token.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&'), 'g'), '***');\n}\n\n// Configure allowed workspace root (defaults to user's home directory)\nexport const WORKSPACES_ROOT = process.env.WORKSPACES_ROOT || os.homedir();\n\n// System-critical paths that should never be used as workspace directories\nexport const FORBIDDEN_PATHS = [\n  // Unix\n  '/',\n  '/etc',\n  '/bin',\n  '/sbin',\n  '/usr',\n  '/dev',\n  '/proc',\n  '/sys',\n  '/var',\n  '/boot',\n  '/root',\n  '/lib',\n  '/lib64',\n  '/opt',\n  '/tmp',\n  '/run',\n  // Windows\n  'C:\\\\Windows',\n  'C:\\\\Program Files',\n  'C:\\\\Program Files (x86)',\n  'C:\\\\ProgramData',\n  'C:\\\\System Volume Information',\n  'C:\\\\$Recycle.Bin'\n];\n\n/**\n * Validates that a path is safe for workspace operations\n * @param {string} requestedPath - The path to validate\n * @returns {Promise<{valid: boolean, resolvedPath?: string, error?: string}>}\n */\nexport async function validateWorkspacePath(requestedPath) {\n  try {\n    // Resolve to absolute path\n    let absolutePath = path.resolve(requestedPath);\n\n    // Check if path is a forbidden system directory\n    const normalizedPath = path.normalize(absolutePath);\n    if (FORBIDDEN_PATHS.includes(normalizedPath) || normalizedPath === '/') {\n      return {\n        valid: false,\n        error: 'Cannot use system-critical directories as workspace locations'\n      };\n    }\n\n    // Additional check for paths starting with forbidden directories\n    for (const forbidden of FORBIDDEN_PATHS) {\n      if (normalizedPath === forbidden ||\n          normalizedPath.startsWith(forbidden + path.sep)) {\n        // Exception: /var/tmp and similar user-accessible paths might be allowed\n        // but /var itself and most /var subdirectories should be blocked\n        if (forbidden === '/var' &&\n            (normalizedPath.startsWith('/var/tmp') ||\n             normalizedPath.startsWith('/var/folders'))) {\n          continue; // Allow these specific cases\n        }\n\n        return {\n          valid: false,\n          error: `Cannot create workspace in system directory: ${forbidden}`\n        };\n      }\n    }\n\n    // Try to resolve the real path (following symlinks)\n    let realPath;\n    try {\n      // Check if path exists to resolve real path\n      await fs.access(absolutePath);\n      realPath = await fs.realpath(absolutePath);\n    } catch (error) {\n      if (error.code === 'ENOENT') {\n        // Path doesn't exist yet - check parent directory\n        let parentPath = path.dirname(absolutePath);\n        try {\n          const parentRealPath = await fs.realpath(parentPath);\n\n          // Reconstruct the full path with real parent\n          realPath = path.join(parentRealPath, path.basename(absolutePath));\n        } catch (parentError) {\n          if (parentError.code === 'ENOENT') {\n            // Parent doesn't exist either - use the absolute path as-is\n            // We'll validate it's within allowed root\n            realPath = absolutePath;\n          } else {\n            throw parentError;\n          }\n        }\n      } else {\n        throw error;\n      }\n    }\n\n    // Resolve the workspace root to its real path\n    const resolvedWorkspaceRoot = await fs.realpath(WORKSPACES_ROOT);\n\n    // Ensure the resolved path is contained within the allowed workspace root\n    if (!realPath.startsWith(resolvedWorkspaceRoot + path.sep) &&\n        realPath !== resolvedWorkspaceRoot) {\n      return {\n        valid: false,\n        error: `Workspace path must be within the allowed workspace root: ${WORKSPACES_ROOT}`\n      };\n    }\n\n    // Additional symlink check for existing paths\n    try {\n      await fs.access(absolutePath);\n      const stats = await fs.lstat(absolutePath);\n\n      if (stats.isSymbolicLink()) {\n        // Verify symlink target is also within allowed root\n        const linkTarget = await fs.readlink(absolutePath);\n        const resolvedTarget = path.resolve(path.dirname(absolutePath), linkTarget);\n        const realTarget = await fs.realpath(resolvedTarget);\n\n        if (!realTarget.startsWith(resolvedWorkspaceRoot + path.sep) &&\n            realTarget !== resolvedWorkspaceRoot) {\n          return {\n            valid: false,\n            error: 'Symlink target is outside the allowed workspace root'\n          };\n        }\n      }\n    } catch (error) {\n      if (error.code !== 'ENOENT') {\n        throw error;\n      }\n      // Path doesn't exist - that's fine for new workspace creation\n    }\n\n    return {\n      valid: true,\n      resolvedPath: realPath\n    };\n\n  } catch (error) {\n    return {\n      valid: false,\n      error: `Path validation failed: ${error.message}`\n    };\n  }\n}\n\n/**\n * Create a new workspace\n * POST /api/projects/create-workspace\n *\n * Body:\n * - workspaceType: 'existing' | 'new'\n * - path: string (workspace path)\n * - githubUrl?: string (optional, for new workspaces)\n * - githubTokenId?: number (optional, ID of stored token)\n * - newGithubToken?: string (optional, one-time token)\n */\nrouter.post('/create-workspace', async (req, res) => {\n  try {\n    const { workspaceType, path: workspacePath, githubUrl, githubTokenId, newGithubToken } = req.body;\n\n    // Validate required fields\n    if (!workspaceType || !workspacePath) {\n      return res.status(400).json({ error: 'workspaceType and path are required' });\n    }\n\n    if (!['existing', 'new'].includes(workspaceType)) {\n      return res.status(400).json({ error: 'workspaceType must be \"existing\" or \"new\"' });\n    }\n\n    // Validate path safety before any operations\n    const validation = await validateWorkspacePath(workspacePath);\n    if (!validation.valid) {\n      return res.status(400).json({\n        error: 'Invalid workspace path',\n        details: validation.error\n      });\n    }\n\n    const absolutePath = validation.resolvedPath;\n\n    // Handle existing workspace\n    if (workspaceType === 'existing') {\n      // Check if the path exists\n      try {\n        await fs.access(absolutePath);\n        const stats = await fs.stat(absolutePath);\n\n        if (!stats.isDirectory()) {\n          return res.status(400).json({ error: 'Path exists but is not a directory' });\n        }\n      } catch (error) {\n        if (error.code === 'ENOENT') {\n          return res.status(404).json({ error: 'Workspace path does not exist' });\n        }\n        throw error;\n      }\n\n      // Add the existing workspace to the project list\n      const project = await addProjectManually(absolutePath);\n\n      return res.json({\n        success: true,\n        project,\n        message: 'Existing workspace added successfully'\n      });\n    }\n\n    // Handle new workspace creation\n    if (workspaceType === 'new') {\n      // Create the directory if it doesn't exist\n      await fs.mkdir(absolutePath, { recursive: true });\n\n      // If GitHub URL is provided, clone the repository\n      if (githubUrl) {\n        let githubToken = null;\n\n        // Get GitHub token if needed\n        if (githubTokenId) {\n          // Fetch token from database\n          const token = await getGithubTokenById(githubTokenId, req.user.id);\n          if (!token) {\n            // Clean up created directory\n            await fs.rm(absolutePath, { recursive: true, force: true });\n            return res.status(404).json({ error: 'GitHub token not found' });\n          }\n          githubToken = token.github_token;\n        } else if (newGithubToken) {\n          githubToken = newGithubToken;\n        }\n\n        // Extract repo name from URL for the clone destination\n        const normalizedUrl = githubUrl.replace(/\\/+$/, '').replace(/\\.git$/, '');\n        const repoName = normalizedUrl.split('/').pop() || 'repository';\n        const clonePath = path.join(absolutePath, repoName);\n\n        // Check if clone destination already exists to prevent data loss\n        try {\n          await fs.access(clonePath);\n          return res.status(409).json({\n            error: 'Directory already exists',\n            details: `The destination path \"${clonePath}\" already exists. Please choose a different location or remove the existing directory.`\n          });\n        } catch (err) {\n          // Directory doesn't exist, which is what we want\n        }\n\n        // Clone the repository into a subfolder\n        try {\n          await cloneGitHubRepository(githubUrl, clonePath, githubToken);\n        } catch (error) {\n          // Only clean up if clone created partial data (check if dir exists and is empty or partial)\n          try {\n            const stats = await fs.stat(clonePath);\n            if (stats.isDirectory()) {\n              await fs.rm(clonePath, { recursive: true, force: true });\n            }\n          } catch (cleanupError) {\n            // Directory doesn't exist or cleanup failed - ignore\n          }\n          throw new Error(`Failed to clone repository: ${error.message}`);\n        }\n\n        // Add the cloned repo path to the project list\n        const project = await addProjectManually(clonePath);\n\n        return res.json({\n          success: true,\n          project,\n          message: 'New workspace created and repository cloned successfully'\n        });\n      }\n\n      // Add the new workspace to the project list (no clone)\n      const project = await addProjectManually(absolutePath);\n\n      return res.json({\n        success: true,\n        project,\n        message: 'New workspace created successfully'\n      });\n    }\n\n  } catch (error) {\n    console.error('Error creating workspace:', error);\n    res.status(500).json({\n      error: error.message || 'Failed to create workspace',\n      details: process.env.NODE_ENV === 'development' ? error.stack : undefined\n    });\n  }\n});\n\n/**\n * Helper function to get GitHub token from database\n */\nasync function getGithubTokenById(tokenId, userId) {\n  const { db } = await import('../database/db.js');\n\n  const credential = db.prepare(\n    'SELECT * FROM user_credentials WHERE id = ? AND user_id = ? AND credential_type = ? AND is_active = 1'\n  ).get(tokenId, userId, 'github_token');\n\n  // Return in the expected format (github_token field for compatibility)\n  if (credential) {\n    return {\n      ...credential,\n      github_token: credential.credential_value\n    };\n  }\n\n  return null;\n}\n\n/**\n * Clone repository with progress streaming (SSE)\n * GET /api/projects/clone-progress\n */\nrouter.get('/clone-progress', async (req, res) => {\n  const { path: workspacePath, githubUrl, githubTokenId, newGithubToken } = req.query;\n\n  res.setHeader('Content-Type', 'text/event-stream');\n  res.setHeader('Cache-Control', 'no-cache');\n  res.setHeader('Connection', 'keep-alive');\n  res.flushHeaders();\n\n  const sendEvent = (type, data) => {\n    res.write(`data: ${JSON.stringify({ type, ...data })}\\n\\n`);\n  };\n\n  try {\n    if (!workspacePath || !githubUrl) {\n      sendEvent('error', { message: 'workspacePath and githubUrl are required' });\n      res.end();\n      return;\n    }\n\n    const validation = await validateWorkspacePath(workspacePath);\n    if (!validation.valid) {\n      sendEvent('error', { message: validation.error });\n      res.end();\n      return;\n    }\n\n    const absolutePath = validation.resolvedPath;\n\n    await fs.mkdir(absolutePath, { recursive: true });\n\n    let githubToken = null;\n    if (githubTokenId) {\n      const token = await getGithubTokenById(parseInt(githubTokenId), req.user.id);\n      if (!token) {\n        await fs.rm(absolutePath, { recursive: true, force: true });\n        sendEvent('error', { message: 'GitHub token not found' });\n        res.end();\n        return;\n      }\n      githubToken = token.github_token;\n    } else if (newGithubToken) {\n      githubToken = newGithubToken;\n    }\n\n    const normalizedUrl = githubUrl.replace(/\\/+$/, '').replace(/\\.git$/, '');\n    const repoName = normalizedUrl.split('/').pop() || 'repository';\n    const clonePath = path.join(absolutePath, repoName);\n\n    // Check if clone destination already exists to prevent data loss\n    try {\n      await fs.access(clonePath);\n      sendEvent('error', { message: `Directory \"${repoName}\" already exists. Please choose a different location or remove the existing directory.` });\n      res.end();\n      return;\n    } catch (err) {\n      // Directory doesn't exist, which is what we want\n    }\n\n    let cloneUrl = githubUrl;\n    if (githubToken) {\n      try {\n        const url = new URL(githubUrl);\n        url.username = githubToken;\n        url.password = '';\n        cloneUrl = url.toString();\n      } catch (error) {\n        // SSH URL or invalid - use as-is\n      }\n    }\n\n    sendEvent('progress', { message: `Cloning into '${repoName}'...` });\n\n    const gitProcess = spawn('git', ['clone', '--progress', cloneUrl, clonePath], {\n      stdio: ['ignore', 'pipe', 'pipe'],\n      env: {\n        ...process.env,\n        GIT_TERMINAL_PROMPT: '0'\n      }\n    });\n\n    let lastError = '';\n\n    gitProcess.stdout.on('data', (data) => {\n      const message = data.toString().trim();\n      if (message) {\n        sendEvent('progress', { message });\n      }\n    });\n\n    gitProcess.stderr.on('data', (data) => {\n      const message = data.toString().trim();\n      lastError = message;\n      if (message) {\n        sendEvent('progress', { message });\n      }\n    });\n\n    gitProcess.on('close', async (code) => {\n      if (code === 0) {\n        try {\n          const project = await addProjectManually(clonePath);\n          sendEvent('complete', { project, message: 'Repository cloned successfully' });\n        } catch (error) {\n          sendEvent('error', { message: `Clone succeeded but failed to add project: ${error.message}` });\n        }\n      } else {\n        const sanitizedError = sanitizeGitError(lastError, githubToken);\n        let errorMessage = 'Git clone failed';\n        if (lastError.includes('Authentication failed') || lastError.includes('could not read Username')) {\n          errorMessage = 'Authentication failed. Please check your credentials.';\n        } else if (lastError.includes('Repository not found')) {\n          errorMessage = 'Repository not found. Please check the URL and ensure you have access.';\n        } else if (lastError.includes('already exists')) {\n          errorMessage = 'Directory already exists';\n        } else if (sanitizedError) {\n          errorMessage = sanitizedError;\n        }\n        try {\n          await fs.rm(clonePath, { recursive: true, force: true });\n        } catch (cleanupError) {\n          console.error('Failed to clean up after clone failure:', sanitizeGitError(cleanupError.message, githubToken));\n        }\n        sendEvent('error', { message: errorMessage });\n      }\n      res.end();\n    });\n\n    gitProcess.on('error', (error) => {\n      if (error.code === 'ENOENT') {\n        sendEvent('error', { message: 'Git is not installed or not in PATH' });\n      } else {\n        sendEvent('error', { message: error.message });\n      }\n      res.end();\n    });\n\n    req.on('close', () => {\n      gitProcess.kill();\n    });\n\n  } catch (error) {\n    sendEvent('error', { message: error.message });\n    res.end();\n  }\n});\n\n/**\n * Helper function to clone a GitHub repository\n */\nfunction cloneGitHubRepository(githubUrl, destinationPath, githubToken = null) {\n  return new Promise((resolve, reject) => {\n    let cloneUrl = githubUrl;\n\n    if (githubToken) {\n      try {\n        const url = new URL(githubUrl);\n        url.username = githubToken;\n        url.password = '';\n        cloneUrl = url.toString();\n      } catch (error) {\n        // SSH URL - use as-is\n      }\n    }\n\n    const gitProcess = spawn('git', ['clone', '--progress', cloneUrl, destinationPath], {\n      stdio: ['ignore', 'pipe', 'pipe'],\n      env: {\n        ...process.env,\n        GIT_TERMINAL_PROMPT: '0'\n      }\n    });\n\n    let stdout = '';\n    let stderr = '';\n\n    gitProcess.stdout.on('data', (data) => {\n      stdout += data.toString();\n    });\n\n    gitProcess.stderr.on('data', (data) => {\n      stderr += data.toString();\n    });\n\n    gitProcess.on('close', (code) => {\n      if (code === 0) {\n        resolve({ stdout, stderr });\n      } else {\n        let errorMessage = 'Git clone failed';\n\n        if (stderr.includes('Authentication failed') || stderr.includes('could not read Username')) {\n          errorMessage = 'Authentication failed. Please check your GitHub token.';\n        } else if (stderr.includes('Repository not found')) {\n          errorMessage = 'Repository not found. Please check the URL and ensure you have access.';\n        } else if (stderr.includes('already exists')) {\n          errorMessage = 'Directory already exists';\n        } else if (stderr) {\n          errorMessage = stderr;\n        }\n\n        reject(new Error(errorMessage));\n      }\n    });\n\n    gitProcess.on('error', (error) => {\n      if (error.code === 'ENOENT') {\n        reject(new Error('Git is not installed or not in PATH'));\n      } else {\n        reject(error);\n      }\n    });\n  });\n}\n\nexport default router;\n"
  },
  {
    "path": "server/routes/settings.js",
    "content": "import express from 'express';\nimport { apiKeysDb, credentialsDb, notificationPreferencesDb, pushSubscriptionsDb } from '../database/db.js';\nimport { getPublicKey } from '../services/vapid-keys.js';\nimport { createNotificationEvent, notifyUserIfEnabled } from '../services/notification-orchestrator.js';\n\nconst router = express.Router();\n\n// ===============================\n// API Keys Management\n// ===============================\n\n// Get all API keys for the authenticated user\nrouter.get('/api-keys', async (req, res) => {\n  try {\n    const apiKeys = apiKeysDb.getApiKeys(req.user.id);\n    // Don't send the full API key in the list for security\n    const sanitizedKeys = apiKeys.map(key => ({\n      ...key,\n      api_key: key.api_key.substring(0, 10) + '...'\n    }));\n    res.json({ apiKeys: sanitizedKeys });\n  } catch (error) {\n    console.error('Error fetching API keys:', error);\n    res.status(500).json({ error: 'Failed to fetch API keys' });\n  }\n});\n\n// Create a new API key\nrouter.post('/api-keys', async (req, res) => {\n  try {\n    const { keyName } = req.body;\n\n    if (!keyName || !keyName.trim()) {\n      return res.status(400).json({ error: 'Key name is required' });\n    }\n\n    const result = apiKeysDb.createApiKey(req.user.id, keyName.trim());\n    res.json({\n      success: true,\n      apiKey: result\n    });\n  } catch (error) {\n    console.error('Error creating API key:', error);\n    res.status(500).json({ error: 'Failed to create API key' });\n  }\n});\n\n// Delete an API key\nrouter.delete('/api-keys/:keyId', async (req, res) => {\n  try {\n    const { keyId } = req.params;\n    const success = apiKeysDb.deleteApiKey(req.user.id, parseInt(keyId));\n\n    if (success) {\n      res.json({ success: true });\n    } else {\n      res.status(404).json({ error: 'API key not found' });\n    }\n  } catch (error) {\n    console.error('Error deleting API key:', error);\n    res.status(500).json({ error: 'Failed to delete API key' });\n  }\n});\n\n// Toggle API key active status\nrouter.patch('/api-keys/:keyId/toggle', async (req, res) => {\n  try {\n    const { keyId } = req.params;\n    const { isActive } = req.body;\n\n    if (typeof isActive !== 'boolean') {\n      return res.status(400).json({ error: 'isActive must be a boolean' });\n    }\n\n    const success = apiKeysDb.toggleApiKey(req.user.id, parseInt(keyId), isActive);\n\n    if (success) {\n      res.json({ success: true });\n    } else {\n      res.status(404).json({ error: 'API key not found' });\n    }\n  } catch (error) {\n    console.error('Error toggling API key:', error);\n    res.status(500).json({ error: 'Failed to toggle API key' });\n  }\n});\n\n// ===============================\n// Generic Credentials Management\n// ===============================\n\n// Get all credentials for the authenticated user (optionally filtered by type)\nrouter.get('/credentials', async (req, res) => {\n  try {\n    const { type } = req.query;\n    const credentials = credentialsDb.getCredentials(req.user.id, type || null);\n    // Don't send the actual credential values for security\n    res.json({ credentials });\n  } catch (error) {\n    console.error('Error fetching credentials:', error);\n    res.status(500).json({ error: 'Failed to fetch credentials' });\n  }\n});\n\n// Create a new credential\nrouter.post('/credentials', async (req, res) => {\n  try {\n    const { credentialName, credentialType, credentialValue, description } = req.body;\n\n    if (!credentialName || !credentialName.trim()) {\n      return res.status(400).json({ error: 'Credential name is required' });\n    }\n\n    if (!credentialType || !credentialType.trim()) {\n      return res.status(400).json({ error: 'Credential type is required' });\n    }\n\n    if (!credentialValue || !credentialValue.trim()) {\n      return res.status(400).json({ error: 'Credential value is required' });\n    }\n\n    const result = credentialsDb.createCredential(\n      req.user.id,\n      credentialName.trim(),\n      credentialType.trim(),\n      credentialValue.trim(),\n      description?.trim() || null\n    );\n\n    res.json({\n      success: true,\n      credential: result\n    });\n  } catch (error) {\n    console.error('Error creating credential:', error);\n    res.status(500).json({ error: 'Failed to create credential' });\n  }\n});\n\n// Delete a credential\nrouter.delete('/credentials/:credentialId', async (req, res) => {\n  try {\n    const { credentialId } = req.params;\n    const success = credentialsDb.deleteCredential(req.user.id, parseInt(credentialId));\n\n    if (success) {\n      res.json({ success: true });\n    } else {\n      res.status(404).json({ error: 'Credential not found' });\n    }\n  } catch (error) {\n    console.error('Error deleting credential:', error);\n    res.status(500).json({ error: 'Failed to delete credential' });\n  }\n});\n\n// Toggle credential active status\nrouter.patch('/credentials/:credentialId/toggle', async (req, res) => {\n  try {\n    const { credentialId } = req.params;\n    const { isActive } = req.body;\n\n    if (typeof isActive !== 'boolean') {\n      return res.status(400).json({ error: 'isActive must be a boolean' });\n    }\n\n    const success = credentialsDb.toggleCredential(req.user.id, parseInt(credentialId), isActive);\n\n    if (success) {\n      res.json({ success: true });\n    } else {\n      res.status(404).json({ error: 'Credential not found' });\n    }\n  } catch (error) {\n    console.error('Error toggling credential:', error);\n    res.status(500).json({ error: 'Failed to toggle credential' });\n  }\n});\n\n// ===============================\n// Notification Preferences\n// ===============================\n\nrouter.get('/notification-preferences', async (req, res) => {\n  try {\n    const preferences = notificationPreferencesDb.getPreferences(req.user.id);\n    res.json({ success: true, preferences });\n  } catch (error) {\n    console.error('Error fetching notification preferences:', error);\n    res.status(500).json({ error: 'Failed to fetch notification preferences' });\n  }\n});\n\nrouter.put('/notification-preferences', async (req, res) => {\n  try {\n    const preferences = notificationPreferencesDb.updatePreferences(req.user.id, req.body || {});\n    res.json({ success: true, preferences });\n  } catch (error) {\n    console.error('Error saving notification preferences:', error);\n    res.status(500).json({ error: 'Failed to save notification preferences' });\n  }\n});\n\n// ===============================\n// Push Subscription Management\n// ===============================\n\nrouter.get('/push/vapid-public-key', async (req, res) => {\n  try {\n    const publicKey = getPublicKey();\n    res.json({ publicKey });\n  } catch (error) {\n    console.error('Error fetching VAPID public key:', error);\n    res.status(500).json({ error: 'Failed to fetch VAPID public key' });\n  }\n});\n\nrouter.post('/push/subscribe', async (req, res) => {\n  try {\n    const { endpoint, keys } = req.body;\n    if (!endpoint || !keys?.p256dh || !keys?.auth) {\n      return res.status(400).json({ error: 'Missing subscription fields' });\n    }\n    pushSubscriptionsDb.saveSubscription(req.user.id, endpoint, keys.p256dh, keys.auth);\n\n    // Enable webPush in preferences so the confirmation goes through the full pipeline\n    const currentPrefs = notificationPreferencesDb.getPreferences(req.user.id);\n    if (!currentPrefs?.channels?.webPush) {\n      notificationPreferencesDb.updatePreferences(req.user.id, {\n        ...currentPrefs,\n        channels: { ...currentPrefs?.channels, webPush: true },\n      });\n    }\n\n    res.json({ success: true });\n\n    // Send a confirmation push through the full notification pipeline\n    const event = createNotificationEvent({\n      provider: 'system',\n      kind: 'info',\n      code: 'push.enabled',\n      meta: { message: 'Push notifications are now enabled!' },\n      severity: 'info'\n    });\n    notifyUserIfEnabled({ userId: req.user.id, event });\n  } catch (error) {\n    console.error('Error saving push subscription:', error);\n    res.status(500).json({ error: 'Failed to save push subscription' });\n  }\n});\n\nrouter.post('/push/unsubscribe', async (req, res) => {\n  try {\n    const { endpoint } = req.body;\n    if (!endpoint) {\n      return res.status(400).json({ error: 'Missing endpoint' });\n    }\n    pushSubscriptionsDb.removeSubscription(endpoint);\n\n    // Disable webPush in preferences to match subscription state\n    const currentPrefs = notificationPreferencesDb.getPreferences(req.user.id);\n    if (currentPrefs?.channels?.webPush) {\n      notificationPreferencesDb.updatePreferences(req.user.id, {\n        ...currentPrefs,\n        channels: { ...currentPrefs.channels, webPush: false },\n      });\n    }\n\n    res.json({ success: true });\n  } catch (error) {\n    console.error('Error removing push subscription:', error);\n    res.status(500).json({ error: 'Failed to remove push subscription' });\n  }\n});\n\nexport default router;\n"
  },
  {
    "path": "server/routes/taskmaster.js",
    "content": "/**\n * TASKMASTER API ROUTES\n * ====================\n * \n * This module provides API endpoints for TaskMaster integration including:\n * - .taskmaster folder detection in project directories\n * - MCP server configuration detection\n * - TaskMaster state and metadata management\n */\n\nimport express from 'express';\nimport fs from 'fs';\nimport path from 'path';\nimport { promises as fsPromises } from 'fs';\nimport { spawn } from 'child_process';\nimport { fileURLToPath } from 'url';\nimport { dirname } from 'path';\nimport os from 'os';\nimport { extractProjectDirectory } from '../projects.js';\nimport { detectTaskMasterMCPServer } from '../utils/mcp-detector.js';\nimport { broadcastTaskMasterProjectUpdate, broadcastTaskMasterTasksUpdate } from '../utils/taskmaster-websocket.js';\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = dirname(__filename);\n\nconst router = express.Router();\n\n/**\n * Check if TaskMaster CLI is installed globally\n * @returns {Promise<Object>} Installation status result\n */\nasync function checkTaskMasterInstallation() {\n    return new Promise((resolve) => {\n        // Check if task-master command is available\n        const child = spawn('which', ['task-master'], { \n            stdio: ['ignore', 'pipe', 'pipe'],\n            shell: true \n        });\n        \n        let output = '';\n        let errorOutput = '';\n        \n        child.stdout.on('data', (data) => {\n            output += data.toString();\n        });\n        \n        child.stderr.on('data', (data) => {\n            errorOutput += data.toString();\n        });\n        \n        child.on('close', (code) => {\n            if (code === 0 && output.trim()) {\n                // TaskMaster is installed, get version\n                const versionChild = spawn('task-master', ['--version'], { \n                    stdio: ['ignore', 'pipe', 'pipe'],\n                    shell: true \n                });\n                \n                let versionOutput = '';\n                \n                versionChild.stdout.on('data', (data) => {\n                    versionOutput += data.toString();\n                });\n                \n                versionChild.on('close', (versionCode) => {\n                    resolve({\n                        isInstalled: true,\n                        installPath: output.trim(),\n                        version: versionCode === 0 ? versionOutput.trim() : 'unknown',\n                        reason: null\n                    });\n                });\n                \n                versionChild.on('error', () => {\n                    resolve({\n                        isInstalled: true,\n                        installPath: output.trim(),\n                        version: 'unknown',\n                        reason: null\n                    });\n                });\n            } else {\n                resolve({\n                    isInstalled: false,\n                    installPath: null,\n                    version: null,\n                    reason: 'TaskMaster CLI not found in PATH'\n                });\n            }\n        });\n        \n        child.on('error', (error) => {\n            resolve({\n                isInstalled: false,\n                installPath: null,\n                version: null,\n                reason: `Error checking installation: ${error.message}`\n            });\n        });\n    });\n}\n\n/**\n * Detect .taskmaster folder presence in a given project directory\n * @param {string} projectPath - Absolute path to project directory\n * @returns {Promise<Object>} Detection result with status and metadata\n */\nasync function detectTaskMasterFolder(projectPath) {\n    try {\n        const taskMasterPath = path.join(projectPath, '.taskmaster');\n        \n        // Check if .taskmaster directory exists\n        try {\n            const stats = await fsPromises.stat(taskMasterPath);\n            if (!stats.isDirectory()) {\n                return {\n                    hasTaskmaster: false,\n                    reason: '.taskmaster exists but is not a directory'\n                };\n            }\n        } catch (error) {\n            if (error.code === 'ENOENT') {\n                return {\n                    hasTaskmaster: false,\n                    reason: '.taskmaster directory not found'\n                };\n            }\n            throw error;\n        }\n\n        // Check for key TaskMaster files\n        const keyFiles = [\n            'tasks/tasks.json',\n            'config.json'\n        ];\n        \n        const fileStatus = {};\n        let hasEssentialFiles = true;\n\n        for (const file of keyFiles) {\n            const filePath = path.join(taskMasterPath, file);\n            try {\n                await fsPromises.access(filePath, fs.constants.R_OK);\n                fileStatus[file] = true;\n            } catch (error) {\n                fileStatus[file] = false;\n                if (file === 'tasks/tasks.json') {\n                    hasEssentialFiles = false;\n                }\n            }\n        }\n\n        // Parse tasks.json if it exists for metadata\n        let taskMetadata = null;\n        if (fileStatus['tasks/tasks.json']) {\n            try {\n                const tasksPath = path.join(taskMasterPath, 'tasks/tasks.json');\n                const tasksContent = await fsPromises.readFile(tasksPath, 'utf8');\n                const tasksData = JSON.parse(tasksContent);\n                \n                // Handle both tagged and legacy formats\n                let tasks = [];\n                if (tasksData.tasks) {\n                    // Legacy format\n                    tasks = tasksData.tasks;\n                } else {\n                    // Tagged format - get tasks from all tags\n                    Object.values(tasksData).forEach(tagData => {\n                        if (tagData.tasks) {\n                            tasks = tasks.concat(tagData.tasks);\n                        }\n                    });\n                }\n\n                // Calculate task statistics\n                const stats = tasks.reduce((acc, task) => {\n                    acc.total++;\n                    acc[task.status] = (acc[task.status] || 0) + 1;\n                    \n                    // Count subtasks\n                    if (task.subtasks) {\n                        task.subtasks.forEach(subtask => {\n                            acc.subtotalTasks++;\n                            acc.subtasks = acc.subtasks || {};\n                            acc.subtasks[subtask.status] = (acc.subtasks[subtask.status] || 0) + 1;\n                        });\n                    }\n                    \n                    return acc;\n                }, { \n                    total: 0, \n                    subtotalTasks: 0,\n                    pending: 0, \n                    'in-progress': 0, \n                    done: 0, \n                    review: 0,\n                    deferred: 0,\n                    cancelled: 0,\n                    subtasks: {}\n                });\n\n                taskMetadata = {\n                    taskCount: stats.total,\n                    subtaskCount: stats.subtotalTasks,\n                    completed: stats.done || 0,\n                    pending: stats.pending || 0,\n                    inProgress: stats['in-progress'] || 0,\n                    review: stats.review || 0,\n                    completionPercentage: stats.total > 0 ? Math.round((stats.done / stats.total) * 100) : 0,\n                    lastModified: (await fsPromises.stat(tasksPath)).mtime.toISOString()\n                };\n            } catch (parseError) {\n                console.warn('Failed to parse tasks.json:', parseError.message);\n                taskMetadata = { error: 'Failed to parse tasks.json' };\n            }\n        }\n\n        return {\n            hasTaskmaster: true,\n            hasEssentialFiles,\n            files: fileStatus,\n            metadata: taskMetadata,\n            path: taskMasterPath\n        };\n\n    } catch (error) {\n        console.error('Error detecting TaskMaster folder:', error);\n        return {\n            hasTaskmaster: false,\n            reason: `Error checking directory: ${error.message}`\n        };\n    }\n}\n\n// MCP detection is now handled by the centralized utility\n\n// API Routes\n\n/**\n * GET /api/taskmaster/installation-status\n * Check if TaskMaster CLI is installed on the system\n */\nrouter.get('/installation-status', async (req, res) => {\n    try {\n        const installationStatus = await checkTaskMasterInstallation();\n        \n        // Also check for MCP server configuration\n        const mcpStatus = await detectTaskMasterMCPServer();\n        \n        res.json({\n            success: true,\n            installation: installationStatus,\n            mcpServer: mcpStatus,\n            isReady: installationStatus.isInstalled && mcpStatus.hasMCPServer\n        });\n    } catch (error) {\n        console.error('Error checking TaskMaster installation:', error);\n        res.status(500).json({\n            success: false,\n            error: 'Failed to check TaskMaster installation status',\n            installation: {\n                isInstalled: false,\n                reason: `Server error: ${error.message}`\n            },\n            mcpServer: {\n                hasMCPServer: false,\n                reason: `Server error: ${error.message}`\n            },\n            isReady: false\n        });\n    }\n});\n\n/**\n * GET /api/taskmaster/detect/:projectName\n * Detect TaskMaster configuration for a specific project\n */\nrouter.get('/detect/:projectName', async (req, res) => {\n    try {\n        const { projectName } = req.params;\n        \n        // Use the existing extractProjectDirectory function to get actual project path\n        let projectPath;\n        try {\n            projectPath = await extractProjectDirectory(projectName);\n        } catch (error) {\n            console.error('Error extracting project directory:', error);\n            return res.status(404).json({\n                error: 'Project path not found',\n                projectName,\n                message: error.message\n            });\n        }\n        \n        // Verify the project path exists\n        try {\n            await fsPromises.access(projectPath, fs.constants.R_OK);\n        } catch (error) {\n            return res.status(404).json({\n                error: 'Project path not accessible',\n                projectPath,\n                projectName,\n                message: error.message\n            });\n        }\n\n        // Run detection in parallel\n        const [taskMasterResult, mcpResult] = await Promise.all([\n            detectTaskMasterFolder(projectPath),\n            detectTaskMasterMCPServer()\n        ]);\n\n        // Determine overall status\n        let status = 'not-configured';\n        if (taskMasterResult.hasTaskmaster && taskMasterResult.hasEssentialFiles) {\n            if (mcpResult.hasMCPServer && mcpResult.isConfigured) {\n                status = 'fully-configured';\n            } else {\n                status = 'taskmaster-only';\n            }\n        } else if (mcpResult.hasMCPServer && mcpResult.isConfigured) {\n            status = 'mcp-only';\n        }\n\n        const responseData = {\n            projectName,\n            projectPath,\n            status,\n            taskmaster: taskMasterResult,\n            mcp: mcpResult,\n            timestamp: new Date().toISOString()\n        };\n\n        res.json(responseData);\n\n    } catch (error) {\n        console.error('TaskMaster detection error:', error);\n        res.status(500).json({\n            error: 'Failed to detect TaskMaster configuration',\n            message: error.message\n        });\n    }\n});\n\n/**\n * GET /api/taskmaster/detect-all\n * Detect TaskMaster configuration for all known projects\n * This endpoint works with the existing projects system\n */\nrouter.get('/detect-all', async (req, res) => {\n    try {\n        // Import getProjects from the projects module\n        const { getProjects } = await import('../projects.js');\n        const projects = await getProjects();\n\n        // Run detection for all projects in parallel\n        const detectionPromises = projects.map(async (project) => {\n            try {\n                // Use the project's fullPath if available, otherwise extract the directory\n                let projectPath;\n                if (project.fullPath) {\n                    projectPath = project.fullPath;\n                } else {\n                    try {\n                        projectPath = await extractProjectDirectory(project.name);\n                    } catch (error) {\n                        throw new Error(`Failed to extract project directory: ${error.message}`);\n                    }\n                }\n                \n                const [taskMasterResult, mcpResult] = await Promise.all([\n                    detectTaskMasterFolder(projectPath),\n                    detectTaskMasterMCPServer()\n                ]);\n\n                // Determine status\n                let status = 'not-configured';\n                if (taskMasterResult.hasTaskmaster && taskMasterResult.hasEssentialFiles) {\n                    if (mcpResult.hasMCPServer && mcpResult.isConfigured) {\n                        status = 'fully-configured';\n                    } else {\n                        status = 'taskmaster-only';\n                    }\n                } else if (mcpResult.hasMCPServer && mcpResult.isConfigured) {\n                    status = 'mcp-only';\n                }\n\n                return {\n                    projectName: project.name,\n                    displayName: project.displayName,\n                    projectPath,\n                    status,\n                    taskmaster: taskMasterResult,\n                    mcp: mcpResult\n                };\n            } catch (error) {\n                return {\n                    projectName: project.name,\n                    displayName: project.displayName,\n                    status: 'error',\n                    error: error.message\n                };\n            }\n        });\n\n        const results = await Promise.all(detectionPromises);\n\n        res.json({\n            projects: results,\n            summary: {\n                total: results.length,\n                fullyConfigured: results.filter(p => p.status === 'fully-configured').length,\n                taskmasterOnly: results.filter(p => p.status === 'taskmaster-only').length,\n                mcpOnly: results.filter(p => p.status === 'mcp-only').length,\n                notConfigured: results.filter(p => p.status === 'not-configured').length,\n                errors: results.filter(p => p.status === 'error').length\n            },\n            timestamp: new Date().toISOString()\n        });\n\n    } catch (error) {\n        console.error('Bulk TaskMaster detection error:', error);\n        res.status(500).json({\n            error: 'Failed to detect TaskMaster configuration for projects',\n            message: error.message\n        });\n    }\n});\n\n/**\n * POST /api/taskmaster/initialize/:projectName\n * Initialize TaskMaster in a project (placeholder for future CLI integration)\n */\nrouter.post('/initialize/:projectName', async (req, res) => {\n    try {\n        const { projectName } = req.params;\n        const { rules } = req.body; // Optional rule profiles\n        \n        // This will be implemented in a later subtask with CLI integration\n        res.status(501).json({\n            error: 'TaskMaster initialization not yet implemented',\n            message: 'This endpoint will execute task-master init via CLI in a future update',\n            projectName,\n            rules\n        });\n        \n    } catch (error) {\n        console.error('TaskMaster initialization error:', error);\n        res.status(500).json({\n            error: 'Failed to initialize TaskMaster',\n            message: error.message\n        });\n    }\n});\n\n/**\n * GET /api/taskmaster/next/:projectName\n * Get the next recommended task using task-master CLI\n */\nrouter.get('/next/:projectName', async (req, res) => {\n    try {\n        const { projectName } = req.params;\n        \n        // Get project path\n        let projectPath;\n        try {\n            projectPath = await extractProjectDirectory(projectName);\n        } catch (error) {\n            return res.status(404).json({\n                error: 'Project not found',\n                message: `Project \"${projectName}\" does not exist`\n            });\n        }\n\n        // Try to execute task-master next command\n        try {\n            const { spawn } = await import('child_process');\n            \n            const nextTaskCommand = spawn('task-master', ['next'], {\n                cwd: projectPath,\n                stdio: ['pipe', 'pipe', 'pipe']\n            });\n\n            let stdout = '';\n            let stderr = '';\n\n            nextTaskCommand.stdout.on('data', (data) => {\n                stdout += data.toString();\n            });\n\n            nextTaskCommand.stderr.on('data', (data) => {\n                stderr += data.toString();\n            });\n\n            await new Promise((resolve, reject) => {\n                nextTaskCommand.on('close', (code) => {\n                    if (code === 0) {\n                        resolve();\n                    } else {\n                        reject(new Error(`task-master next failed with code ${code}: ${stderr}`));\n                    }\n                });\n\n                nextTaskCommand.on('error', (error) => {\n                    reject(error);\n                });\n            });\n\n            // Parse the output - task-master next usually returns JSON\n            let nextTaskData = null;\n            if (stdout.trim()) {\n                try {\n                    nextTaskData = JSON.parse(stdout);\n                } catch (parseError) {\n                    // If not JSON, treat as plain text\n                    nextTaskData = { message: stdout.trim() };\n                }\n            }\n\n            res.json({\n                projectName,\n                projectPath,\n                nextTask: nextTaskData,\n                timestamp: new Date().toISOString()\n            });\n\n        } catch (cliError) {\n            console.warn('Failed to execute task-master CLI:', cliError.message);\n            \n            // Fallback to loading tasks and finding next one locally\n            // Use localhost to bypass proxy for internal server-to-server calls\n            const tasksResponse = await fetch(`http://localhost:${process.env.SERVER_PORT || process.env.PORT || '3001'}/api/taskmaster/tasks/${encodeURIComponent(projectName)}`, {\n                headers: {\n                    'Authorization': req.headers.authorization\n                }\n            });\n\n            if (tasksResponse.ok) {\n                const tasksData = await tasksResponse.json();\n                const nextTask = tasksData.tasks?.find(task => \n                    task.status === 'pending' || task.status === 'in-progress'\n                ) || null;\n\n                res.json({\n                    projectName,\n                    projectPath,\n                    nextTask,\n                    fallback: true,\n                    message: 'Used fallback method (CLI not available)',\n                    timestamp: new Date().toISOString()\n                });\n            } else {\n                throw new Error('Failed to load tasks via fallback method');\n            }\n        }\n\n    } catch (error) {\n        console.error('TaskMaster next task error:', error);\n        res.status(500).json({\n            error: 'Failed to get next task',\n            message: error.message\n        });\n    }\n});\n\n/**\n * GET /api/taskmaster/tasks/:projectName\n * Load actual tasks from .taskmaster/tasks/tasks.json\n */\nrouter.get('/tasks/:projectName', async (req, res) => {\n    try {\n        const { projectName } = req.params;\n        \n        // Get project path\n        let projectPath;\n        try {\n            projectPath = await extractProjectDirectory(projectName);\n        } catch (error) {\n            return res.status(404).json({\n                error: 'Project not found',\n                message: `Project \"${projectName}\" does not exist`\n            });\n        }\n\n        const taskMasterPath = path.join(projectPath, '.taskmaster');\n        const tasksFilePath = path.join(taskMasterPath, 'tasks', 'tasks.json');\n\n        // Check if tasks file exists\n        try {\n            await fsPromises.access(tasksFilePath);\n        } catch (error) {\n            return res.json({\n                projectName,\n                tasks: [],\n                message: 'No tasks.json file found'\n            });\n        }\n\n        // Read and parse tasks file\n        try {\n            const tasksContent = await fsPromises.readFile(tasksFilePath, 'utf8');\n            const tasksData = JSON.parse(tasksContent);\n            \n            let tasks = [];\n            let currentTag = 'master';\n            \n            // Handle both tagged and legacy formats\n            if (Array.isArray(tasksData)) {\n                // Legacy format\n                tasks = tasksData;\n            } else if (tasksData.tasks) {\n                // Simple format with tasks array\n                tasks = tasksData.tasks;\n            } else {\n                // Tagged format - get tasks from current tag or master\n                if (tasksData[currentTag] && tasksData[currentTag].tasks) {\n                    tasks = tasksData[currentTag].tasks;\n                } else if (tasksData.master && tasksData.master.tasks) {\n                    tasks = tasksData.master.tasks;\n                } else {\n                    // Get tasks from first available tag\n                    const firstTag = Object.keys(tasksData).find(key => \n                        tasksData[key].tasks && Array.isArray(tasksData[key].tasks)\n                    );\n                    if (firstTag) {\n                        tasks = tasksData[firstTag].tasks;\n                        currentTag = firstTag;\n                    }\n                }\n            }\n\n            // Transform tasks to ensure all have required fields\n            const transformedTasks = tasks.map(task => ({\n                id: task.id,\n                title: task.title || 'Untitled Task',\n                description: task.description || '',\n                status: task.status || 'pending',\n                priority: task.priority || 'medium',\n                dependencies: task.dependencies || [],\n                createdAt: task.createdAt || task.created || new Date().toISOString(),\n                updatedAt: task.updatedAt || task.updated || new Date().toISOString(),\n                details: task.details || '',\n                testStrategy: task.testStrategy || task.test_strategy || '',\n                subtasks: task.subtasks || []\n            }));\n\n            res.json({\n                projectName,\n                projectPath,\n                tasks: transformedTasks,\n                currentTag,\n                totalTasks: transformedTasks.length,\n                tasksByStatus: {\n                    pending: transformedTasks.filter(t => t.status === 'pending').length,\n                    'in-progress': transformedTasks.filter(t => t.status === 'in-progress').length,\n                    done: transformedTasks.filter(t => t.status === 'done').length,\n                    review: transformedTasks.filter(t => t.status === 'review').length,\n                    deferred: transformedTasks.filter(t => t.status === 'deferred').length,\n                    cancelled: transformedTasks.filter(t => t.status === 'cancelled').length\n                },\n                timestamp: new Date().toISOString()\n            });\n\n        } catch (parseError) {\n            console.error('Failed to parse tasks.json:', parseError);\n            return res.status(500).json({\n                error: 'Failed to parse tasks file',\n                message: parseError.message\n            });\n        }\n\n    } catch (error) {\n        console.error('TaskMaster tasks loading error:', error);\n        res.status(500).json({\n            error: 'Failed to load TaskMaster tasks',\n            message: error.message\n        });\n    }\n});\n\n/**\n * GET /api/taskmaster/prd/:projectName\n * List all PRD files in the project's .taskmaster/docs directory\n */\nrouter.get('/prd/:projectName', async (req, res) => {\n    try {\n        const { projectName } = req.params;\n        \n        // Get project path\n        let projectPath;\n        try {\n            projectPath = await extractProjectDirectory(projectName);\n        } catch (error) {\n            return res.status(404).json({\n                error: 'Project not found',\n                message: `Project \"${projectName}\" does not exist`\n            });\n        }\n\n        const docsPath = path.join(projectPath, '.taskmaster', 'docs');\n        \n        // Check if docs directory exists\n        try {\n            await fsPromises.access(docsPath, fs.constants.R_OK);\n        } catch (error) {\n            return res.json({\n                projectName,\n                prdFiles: [],\n                message: 'No .taskmaster/docs directory found'\n            });\n        }\n\n        // Read directory and filter for PRD files\n        try {\n            const files = await fsPromises.readdir(docsPath);\n            const prdFiles = [];\n\n            for (const file of files) {\n                const filePath = path.join(docsPath, file);\n                const stats = await fsPromises.stat(filePath);\n                \n                if (stats.isFile() && (file.endsWith('.txt') || file.endsWith('.md'))) {\n                    prdFiles.push({\n                        name: file,\n                        path: path.relative(projectPath, filePath),\n                        size: stats.size,\n                        modified: stats.mtime.toISOString(),\n                        created: stats.birthtime.toISOString()\n                    });\n                }\n            }\n\n            res.json({\n                projectName,\n                projectPath,\n                prdFiles: prdFiles.sort((a, b) => new Date(b.modified) - new Date(a.modified)),\n                timestamp: new Date().toISOString()\n            });\n\n        } catch (readError) {\n            console.error('Error reading docs directory:', readError);\n            return res.status(500).json({\n                error: 'Failed to read PRD files',\n                message: readError.message\n            });\n        }\n\n    } catch (error) {\n        console.error('PRD list error:', error);\n        res.status(500).json({\n            error: 'Failed to list PRD files',\n            message: error.message\n        });\n    }\n});\n\n/**\n * POST /api/taskmaster/prd/:projectName\n * Create or update a PRD file in the project's .taskmaster/docs directory\n */\nrouter.post('/prd/:projectName', async (req, res) => {\n    try {\n        const { projectName } = req.params;\n        const { fileName, content } = req.body;\n\n        if (!fileName || !content) {\n            return res.status(400).json({\n                error: 'Missing required fields',\n                message: 'fileName and content are required'\n            });\n        }\n\n        // Validate filename\n        if (!fileName.match(/^[\\w\\-. ]+\\.(txt|md)$/)) {\n            return res.status(400).json({\n                error: 'Invalid filename',\n                message: 'Filename must end with .txt or .md and contain only alphanumeric characters, spaces, dots, and dashes'\n            });\n        }\n\n        // Get project path\n        let projectPath;\n        try {\n            projectPath = await extractProjectDirectory(projectName);\n        } catch (error) {\n            return res.status(404).json({\n                error: 'Project not found',\n                message: `Project \"${projectName}\" does not exist`\n            });\n        }\n\n        const docsPath = path.join(projectPath, '.taskmaster', 'docs');\n        const filePath = path.join(docsPath, fileName);\n\n        // Ensure docs directory exists\n        try {\n            await fsPromises.mkdir(docsPath, { recursive: true });\n        } catch (error) {\n            console.error('Failed to create docs directory:', error);\n            return res.status(500).json({\n                error: 'Failed to create directory',\n                message: error.message\n            });\n        }\n\n        // Write the PRD file\n        try {\n            await fsPromises.writeFile(filePath, content, 'utf8');\n            \n            // Get file stats\n            const stats = await fsPromises.stat(filePath);\n\n            res.json({\n                projectName,\n                projectPath,\n                fileName,\n                filePath: path.relative(projectPath, filePath),\n                size: stats.size,\n                created: stats.birthtime.toISOString(),\n                modified: stats.mtime.toISOString(),\n                message: 'PRD file saved successfully',\n                timestamp: new Date().toISOString()\n            });\n\n        } catch (writeError) {\n            console.error('Failed to write PRD file:', writeError);\n            return res.status(500).json({\n                error: 'Failed to write PRD file',\n                message: writeError.message\n            });\n        }\n\n    } catch (error) {\n        console.error('PRD create/update error:', error);\n        res.status(500).json({\n            error: 'Failed to create/update PRD file',\n            message: error.message\n        });\n    }\n});\n\n/**\n * GET /api/taskmaster/prd/:projectName/:fileName\n * Get content of a specific PRD file\n */\nrouter.get('/prd/:projectName/:fileName', async (req, res) => {\n    try {\n        const { projectName, fileName } = req.params;\n        \n        // Get project path\n        let projectPath;\n        try {\n            projectPath = await extractProjectDirectory(projectName);\n        } catch (error) {\n            return res.status(404).json({\n                error: 'Project not found',\n                message: `Project \"${projectName}\" does not exist`\n            });\n        }\n\n        const filePath = path.join(projectPath, '.taskmaster', 'docs', fileName);\n        \n        // Check if file exists\n        try {\n            await fsPromises.access(filePath, fs.constants.R_OK);\n        } catch (error) {\n            return res.status(404).json({\n                error: 'PRD file not found',\n                message: `File \"${fileName}\" does not exist`\n            });\n        }\n\n        // Read file content\n        try {\n            const content = await fsPromises.readFile(filePath, 'utf8');\n            const stats = await fsPromises.stat(filePath);\n\n            res.json({\n                projectName,\n                projectPath,\n                fileName,\n                filePath: path.relative(projectPath, filePath),\n                content,\n                size: stats.size,\n                created: stats.birthtime.toISOString(),\n                modified: stats.mtime.toISOString(),\n                timestamp: new Date().toISOString()\n            });\n\n        } catch (readError) {\n            console.error('Failed to read PRD file:', readError);\n            return res.status(500).json({\n                error: 'Failed to read PRD file',\n                message: readError.message\n            });\n        }\n\n    } catch (error) {\n        console.error('PRD read error:', error);\n        res.status(500).json({\n            error: 'Failed to read PRD file',\n            message: error.message\n        });\n    }\n});\n\n/**\n * DELETE /api/taskmaster/prd/:projectName/:fileName\n * Delete a specific PRD file\n */\nrouter.delete('/prd/:projectName/:fileName', async (req, res) => {\n    try {\n        const { projectName, fileName } = req.params;\n        \n        // Get project path\n        let projectPath;\n        try {\n            projectPath = await extractProjectDirectory(projectName);\n        } catch (error) {\n            return res.status(404).json({\n                error: 'Project not found',\n                message: `Project \"${projectName}\" does not exist`\n            });\n        }\n\n        const filePath = path.join(projectPath, '.taskmaster', 'docs', fileName);\n        \n        // Check if file exists\n        try {\n            await fsPromises.access(filePath, fs.constants.F_OK);\n        } catch (error) {\n            return res.status(404).json({\n                error: 'PRD file not found',\n                message: `File \"${fileName}\" does not exist`\n            });\n        }\n\n        // Delete the file\n        try {\n            await fsPromises.unlink(filePath);\n\n            res.json({\n                projectName,\n                projectPath,\n                fileName,\n                message: 'PRD file deleted successfully',\n                timestamp: new Date().toISOString()\n            });\n\n        } catch (deleteError) {\n            console.error('Failed to delete PRD file:', deleteError);\n            return res.status(500).json({\n                error: 'Failed to delete PRD file',\n                message: deleteError.message\n            });\n        }\n\n    } catch (error) {\n        console.error('PRD delete error:', error);\n        res.status(500).json({\n            error: 'Failed to delete PRD file',\n            message: error.message\n        });\n    }\n});\n\n/**\n * POST /api/taskmaster/init/:projectName\n * Initialize TaskMaster in a project\n */\nrouter.post('/init/:projectName', async (req, res) => {\n    try {\n        const { projectName } = req.params;\n        \n        // Get project path\n        let projectPath;\n        try {\n            projectPath = await extractProjectDirectory(projectName);\n        } catch (error) {\n            return res.status(404).json({\n                error: 'Project not found',\n                message: `Project \"${projectName}\" does not exist`\n            });\n        }\n\n        // Check if TaskMaster is already initialized\n        const taskMasterPath = path.join(projectPath, '.taskmaster');\n        try {\n            await fsPromises.access(taskMasterPath, fs.constants.F_OK);\n            return res.status(400).json({\n                error: 'TaskMaster already initialized',\n                message: 'TaskMaster is already configured for this project'\n            });\n        } catch (error) {\n            // Directory doesn't exist, we can proceed\n        }\n\n        // Run taskmaster init command\n        const initProcess = spawn('npx', ['task-master', 'init'], {\n            cwd: projectPath,\n            stdio: ['pipe', 'pipe', 'pipe']\n        });\n\n        let stdout = '';\n        let stderr = '';\n\n        initProcess.stdout.on('data', (data) => {\n            stdout += data.toString();\n        });\n\n        initProcess.stderr.on('data', (data) => {\n            stderr += data.toString();\n        });\n\n        initProcess.on('close', (code) => {\n            if (code === 0) {\n                // Broadcast TaskMaster project update via WebSocket\n                if (req.app.locals.wss) {\n                    broadcastTaskMasterProjectUpdate(\n                        req.app.locals.wss, \n                        projectName, \n                        { hasTaskmaster: true, status: 'initialized' }\n                    );\n                }\n\n                res.json({\n                    projectName,\n                    projectPath,\n                    message: 'TaskMaster initialized successfully',\n                    output: stdout,\n                    timestamp: new Date().toISOString()\n                });\n            } else {\n                console.error('TaskMaster init failed:', stderr);\n                res.status(500).json({\n                    error: 'Failed to initialize TaskMaster',\n                    message: stderr || stdout,\n                    code\n                });\n            }\n        });\n\n        // Send 'yes' responses to automated prompts\n        initProcess.stdin.write('yes\\n');\n        initProcess.stdin.end();\n\n    } catch (error) {\n        console.error('TaskMaster init error:', error);\n        res.status(500).json({\n            error: 'Failed to initialize TaskMaster',\n            message: error.message\n        });\n    }\n});\n\n/**\n * POST /api/taskmaster/add-task/:projectName\n * Add a new task to the project\n */\nrouter.post('/add-task/:projectName', async (req, res) => {\n    try {\n        const { projectName } = req.params;\n        const { prompt, title, description, priority = 'medium', dependencies } = req.body;\n\n        if (!prompt && (!title || !description)) {\n            return res.status(400).json({\n                error: 'Missing required parameters',\n                message: 'Either \"prompt\" or both \"title\" and \"description\" are required'\n            });\n        }\n        \n        // Get project path\n        let projectPath;\n        try {\n            projectPath = await extractProjectDirectory(projectName);\n        } catch (error) {\n            return res.status(404).json({\n                error: 'Project not found',\n                message: `Project \"${projectName}\" does not exist`\n            });\n        }\n\n        // Build the task-master add-task command\n        const args = ['task-master-ai', 'add-task'];\n        \n        if (prompt) {\n            args.push('--prompt', prompt);\n            args.push('--research'); // Use research for AI-generated tasks\n        } else {\n            args.push('--prompt', `Create a task titled \"${title}\" with description: ${description}`);\n        }\n        \n        if (priority) {\n            args.push('--priority', priority);\n        }\n        \n        if (dependencies) {\n            args.push('--dependencies', dependencies);\n        }\n\n        // Run task-master add-task command\n        const addTaskProcess = spawn('npx', args, {\n            cwd: projectPath,\n            stdio: ['pipe', 'pipe', 'pipe']\n        });\n\n        let stdout = '';\n        let stderr = '';\n\n        addTaskProcess.stdout.on('data', (data) => {\n            stdout += data.toString();\n        });\n\n        addTaskProcess.stderr.on('data', (data) => {\n            stderr += data.toString();\n        });\n\n        addTaskProcess.on('close', (code) => {\n            console.log('Add task process completed with code:', code);\n            console.log('Stdout:', stdout);\n            console.log('Stderr:', stderr);\n            \n            if (code === 0) {\n                // Broadcast task update via WebSocket\n                if (req.app.locals.wss) {\n                    broadcastTaskMasterTasksUpdate(\n                        req.app.locals.wss, \n                        projectName\n                    );\n                }\n\n                res.json({\n                    projectName,\n                    projectPath,\n                    message: 'Task added successfully',\n                    output: stdout,\n                    timestamp: new Date().toISOString()\n                });\n            } else {\n                console.error('Add task failed:', stderr);\n                res.status(500).json({\n                    error: 'Failed to add task',\n                    message: stderr || stdout,\n                    code\n                });\n            }\n        });\n\n        addTaskProcess.stdin.end();\n\n    } catch (error) {\n        console.error('Add task error:', error);\n        res.status(500).json({\n            error: 'Failed to add task',\n            message: error.message\n        });\n    }\n});\n\n/**\n * PUT /api/taskmaster/update-task/:projectName/:taskId\n * Update a specific task using TaskMaster CLI\n */\nrouter.put('/update-task/:projectName/:taskId', async (req, res) => {\n    try {\n        const { projectName, taskId } = req.params;\n        const { title, description, status, priority, details } = req.body;\n        \n        // Get project path\n        let projectPath;\n        try {\n            projectPath = await extractProjectDirectory(projectName);\n        } catch (error) {\n            return res.status(404).json({\n                error: 'Project not found',\n                message: `Project \"${projectName}\" does not exist`\n            });\n        }\n\n        // If only updating status, use set-status command\n        if (status && Object.keys(req.body).length === 1) {\n            const setStatusProcess = spawn('npx', ['task-master-ai', 'set-status', `--id=${taskId}`, `--status=${status}`], {\n                cwd: projectPath,\n                stdio: ['pipe', 'pipe', 'pipe']\n            });\n\n            let stdout = '';\n            let stderr = '';\n\n            setStatusProcess.stdout.on('data', (data) => {\n                stdout += data.toString();\n            });\n\n            setStatusProcess.stderr.on('data', (data) => {\n                stderr += data.toString();\n            });\n\n            setStatusProcess.on('close', (code) => {\n                if (code === 0) {\n                    // Broadcast task update via WebSocket\n                    if (req.app.locals.wss) {\n                        broadcastTaskMasterTasksUpdate(req.app.locals.wss, projectName);\n                    }\n\n                    res.json({\n                        projectName,\n                        projectPath,\n                        taskId,\n                        message: 'Task status updated successfully',\n                        output: stdout,\n                        timestamp: new Date().toISOString()\n                    });\n                } else {\n                    console.error('Set task status failed:', stderr);\n                    res.status(500).json({\n                        error: 'Failed to update task status',\n                        message: stderr || stdout,\n                        code\n                    });\n                }\n            });\n\n            setStatusProcess.stdin.end();\n        } else {\n            // For other updates, use update-task command with a prompt describing the changes\n            const updates = [];\n            if (title) updates.push(`title: \"${title}\"`);\n            if (description) updates.push(`description: \"${description}\"`);\n            if (priority) updates.push(`priority: \"${priority}\"`);\n            if (details) updates.push(`details: \"${details}\"`);\n            \n            const prompt = `Update task with the following changes: ${updates.join(', ')}`;\n\n            const updateProcess = spawn('npx', ['task-master-ai', 'update-task', `--id=${taskId}`, `--prompt=${prompt}`], {\n                cwd: projectPath,\n                stdio: ['pipe', 'pipe', 'pipe']\n            });\n\n            let stdout = '';\n            let stderr = '';\n\n            updateProcess.stdout.on('data', (data) => {\n                stdout += data.toString();\n            });\n\n            updateProcess.stderr.on('data', (data) => {\n                stderr += data.toString();\n            });\n\n            updateProcess.on('close', (code) => {\n                if (code === 0) {\n                    // Broadcast task update via WebSocket\n                    if (req.app.locals.wss) {\n                        broadcastTaskMasterTasksUpdate(req.app.locals.wss, projectName);\n                    }\n\n                    res.json({\n                        projectName,\n                        projectPath,\n                        taskId,\n                        message: 'Task updated successfully',\n                        output: stdout,\n                        timestamp: new Date().toISOString()\n                    });\n                } else {\n                    console.error('Update task failed:', stderr);\n                    res.status(500).json({\n                        error: 'Failed to update task',\n                        message: stderr || stdout,\n                        code\n                    });\n                }\n            });\n\n            updateProcess.stdin.end();\n        }\n\n    } catch (error) {\n        console.error('Update task error:', error);\n        res.status(500).json({\n            error: 'Failed to update task',\n            message: error.message\n        });\n    }\n});\n\n/**\n * POST /api/taskmaster/parse-prd/:projectName\n * Parse a PRD file to generate tasks\n */\nrouter.post('/parse-prd/:projectName', async (req, res) => {\n    try {\n        const { projectName } = req.params;\n        const { fileName = 'prd.txt', numTasks, append = false } = req.body;\n        \n        // Get project path\n        let projectPath;\n        try {\n            projectPath = await extractProjectDirectory(projectName);\n        } catch (error) {\n            return res.status(404).json({\n                error: 'Project not found',\n                message: `Project \"${projectName}\" does not exist`\n            });\n        }\n\n        const prdPath = path.join(projectPath, '.taskmaster', 'docs', fileName);\n        \n        // Check if PRD file exists\n        try {\n            await fsPromises.access(prdPath, fs.constants.F_OK);\n        } catch (error) {\n            return res.status(404).json({\n                error: 'PRD file not found',\n                message: `File \"${fileName}\" does not exist in .taskmaster/docs/`\n            });\n        }\n\n        // Build the command args\n        const args = ['task-master-ai', 'parse-prd', prdPath];\n        \n        if (numTasks) {\n            args.push('--num-tasks', numTasks.toString());\n        }\n        \n        if (append) {\n            args.push('--append');\n        }\n        \n        args.push('--research'); // Use research for better PRD parsing\n\n        // Run task-master parse-prd command\n        const parsePRDProcess = spawn('npx', args, {\n            cwd: projectPath,\n            stdio: ['pipe', 'pipe', 'pipe']\n        });\n\n        let stdout = '';\n        let stderr = '';\n\n        parsePRDProcess.stdout.on('data', (data) => {\n            stdout += data.toString();\n        });\n\n        parsePRDProcess.stderr.on('data', (data) => {\n            stderr += data.toString();\n        });\n\n        parsePRDProcess.on('close', (code) => {\n            if (code === 0) {\n                // Broadcast task update via WebSocket\n                if (req.app.locals.wss) {\n                    broadcastTaskMasterTasksUpdate(\n                        req.app.locals.wss, \n                        projectName\n                    );\n                }\n\n                res.json({\n                    projectName,\n                    projectPath,\n                    prdFile: fileName,\n                    message: 'PRD parsed and tasks generated successfully',\n                    output: stdout,\n                    timestamp: new Date().toISOString()\n                });\n            } else {\n                console.error('Parse PRD failed:', stderr);\n                res.status(500).json({\n                    error: 'Failed to parse PRD',\n                    message: stderr || stdout,\n                    code\n                });\n            }\n        });\n\n        parsePRDProcess.stdin.end();\n\n    } catch (error) {\n        console.error('Parse PRD error:', error);\n        res.status(500).json({\n            error: 'Failed to parse PRD',\n            message: error.message\n        });\n    }\n});\n\n/**\n * GET /api/taskmaster/prd-templates\n * Get available PRD templates\n */\nrouter.get('/prd-templates', async (req, res) => {\n    try {\n        // Return built-in templates\n        const templates = [\n            {\n                id: 'web-app',\n                name: 'Web Application',\n                description: 'Template for web application projects with frontend and backend components',\n                category: 'web',\n                content: `# Product Requirements Document - Web Application\n\n## Overview\n**Product Name:** [Your App Name]\n**Version:** 1.0\n**Date:** ${new Date().toISOString().split('T')[0]}\n**Author:** [Your Name]\n\n## Executive Summary\nBrief description of what this web application will do and why it's needed.\n\n## Product Goals\n- Goal 1: [Specific measurable goal]\n- Goal 2: [Specific measurable goal]\n- Goal 3: [Specific measurable goal]\n\n## User Stories\n### Core Features\n1. **User Registration & Authentication**\n   - As a user, I want to create an account so I can access personalized features\n   - As a user, I want to log in securely so my data is protected\n   - As a user, I want to reset my password if I forget it\n\n2. **Main Application Features**\n   - As a user, I want to [core feature 1] so I can [benefit]\n   - As a user, I want to [core feature 2] so I can [benefit]\n   - As a user, I want to [core feature 3] so I can [benefit]\n\n3. **User Interface**\n   - As a user, I want a responsive design so I can use the app on any device\n   - As a user, I want intuitive navigation so I can easily find features\n\n## Technical Requirements\n### Frontend\n- Framework: React/Vue/Angular or vanilla JavaScript\n- Styling: CSS framework (Tailwind, Bootstrap, etc.)\n- State Management: Redux/Vuex/Context API\n- Build Tools: Webpack/Vite\n- Testing: Jest/Vitest for unit tests\n\n### Backend\n- Runtime: Node.js/Python/Java\n- Database: PostgreSQL/MySQL/MongoDB\n- API: RESTful API or GraphQL\n- Authentication: JWT tokens\n- Testing: Integration and unit tests\n\n### Infrastructure\n- Hosting: Cloud provider (AWS, Azure, GCP)\n- CI/CD: GitHub Actions/GitLab CI\n- Monitoring: Application monitoring tools\n- Security: HTTPS, input validation, rate limiting\n\n## Success Metrics\n- User engagement metrics\n- Performance benchmarks (load time < 2s)\n- Error rates < 1%\n- User satisfaction scores\n\n## Timeline\n- Phase 1: Core functionality (4-6 weeks)\n- Phase 2: Advanced features (2-4 weeks)  \n- Phase 3: Polish and launch (2 weeks)\n\n## Constraints & Assumptions\n- Budget constraints\n- Technical limitations\n- Team size and expertise\n- Timeline constraints`\n            },\n            {\n                id: 'api',\n                name: 'REST API',\n                description: 'Template for REST API development projects',\n                category: 'backend',\n                content: `# Product Requirements Document - REST API\n\n## Overview\n**API Name:** [Your API Name]\n**Version:** v1.0\n**Date:** ${new Date().toISOString().split('T')[0]}\n**Author:** [Your Name]\n\n## Executive Summary\nDescription of the API's purpose, target users, and primary use cases.\n\n## API Goals\n- Goal 1: Provide secure data access\n- Goal 2: Ensure scalable architecture\n- Goal 3: Maintain high availability (99.9% uptime)\n\n## Functional Requirements\n### Core Endpoints\n1. **Authentication Endpoints**\n   - POST /api/auth/login - User authentication\n   - POST /api/auth/logout - User logout\n   - POST /api/auth/refresh - Token refresh\n   - POST /api/auth/register - User registration\n\n2. **Data Management Endpoints**\n   - GET /api/resources - List resources with pagination\n   - GET /api/resources/{id} - Get specific resource\n   - POST /api/resources - Create new resource\n   - PUT /api/resources/{id} - Update existing resource\n   - DELETE /api/resources/{id} - Delete resource\n\n3. **Administrative Endpoints**\n   - GET /api/admin/users - Manage users (admin only)\n   - GET /api/admin/analytics - System analytics\n   - POST /api/admin/backup - Trigger system backup\n\n## Technical Requirements\n### API Design\n- RESTful architecture following OpenAPI 3.0 specification\n- JSON request/response format\n- Consistent error response format\n- API versioning strategy\n\n### Authentication & Security\n- JWT token-based authentication\n- Role-based access control (RBAC)\n- Rate limiting (100 requests/minute per user)\n- Input validation and sanitization\n- HTTPS enforcement\n\n### Database\n- Database type: [PostgreSQL/MongoDB/MySQL]\n- Connection pooling\n- Database migrations\n- Backup and recovery procedures\n\n### Performance Requirements\n- Response time: < 200ms for 95% of requests\n- Throughput: 1000+ requests/second\n- Concurrent users: 10,000+\n- Database query optimization\n\n### Documentation\n- Auto-generated API documentation (Swagger/OpenAPI)\n- Code examples for common use cases\n- SDK development for major languages\n- Postman collection for testing\n\n## Error Handling\n- Standardized error codes and messages\n- Proper HTTP status codes\n- Detailed error logging\n- Graceful degradation strategies\n\n## Testing Strategy\n- Unit tests (80%+ coverage)\n- Integration tests for all endpoints\n- Load testing and performance testing\n- Security testing (OWASP compliance)\n\n## Monitoring & Logging\n- Application performance monitoring\n- Error tracking and alerting\n- Access logs and audit trails\n- Health check endpoints\n\n## Deployment\n- Containerized deployment (Docker)\n- CI/CD pipeline setup\n- Environment management (dev, staging, prod)\n- Blue-green deployment strategy\n\n## Success Metrics\n- API uptime > 99.9%\n- Average response time < 200ms\n- Zero critical security vulnerabilities\n- Developer adoption metrics`\n            },\n            {\n                id: 'mobile-app',\n                name: 'Mobile Application',\n                description: 'Template for mobile app development projects (iOS/Android)',\n                category: 'mobile',\n                content: `# Product Requirements Document - Mobile Application\n\n## Overview\n**App Name:** [Your App Name]\n**Platform:** iOS / Android / Cross-platform\n**Version:** 1.0\n**Date:** ${new Date().toISOString().split('T')[0]}\n**Author:** [Your Name]\n\n## Executive Summary\nBrief description of the mobile app's purpose, target audience, and key value proposition.\n\n## Product Goals\n- Goal 1: [Specific user engagement goal]\n- Goal 2: [Specific functionality goal]\n- Goal 3: [Specific performance goal]\n\n## User Stories\n### Core Features\n1. **Onboarding & Authentication**\n   - As a new user, I want a simple onboarding process\n   - As a user, I want to sign up with email or social media\n   - As a user, I want biometric authentication for security\n\n2. **Main App Features**\n   - As a user, I want [core feature 1] accessible from home screen\n   - As a user, I want [core feature 2] to work offline\n   - As a user, I want to sync data across devices\n\n3. **User Experience**\n   - As a user, I want intuitive navigation patterns\n   - As a user, I want fast loading times\n   - As a user, I want accessibility features\n\n## Technical Requirements\n### Mobile Development\n- **Cross-platform:** React Native / Flutter / Xamarin\n- **Native:** Swift (iOS) / Kotlin (Android)\n- **State Management:** Redux / MobX / Provider\n- **Navigation:** React Navigation / Flutter Navigation\n\n### Backend Integration\n- REST API or GraphQL integration\n- Real-time features (WebSockets/Push notifications)\n- Offline data synchronization\n- Background processing\n\n### Device Features\n- Camera and photo library access\n- GPS location services\n- Push notifications\n- Biometric authentication\n- Device storage\n\n### Performance Requirements\n- App launch time < 3 seconds\n- Screen transition animations < 300ms\n- Memory usage optimization\n- Battery usage optimization\n\n## Platform-Specific Considerations\n### iOS Requirements\n- iOS 13.0+ minimum version\n- App Store guidelines compliance\n- iOS design guidelines (Human Interface Guidelines)\n- TestFlight beta testing\n\n### Android Requirements\n- Android 8.0+ (API level 26) minimum\n- Google Play Store guidelines\n- Material Design guidelines\n- Google Play Console testing\n\n## User Interface Design\n- Responsive design for different screen sizes\n- Dark mode support\n- Accessibility compliance (WCAG 2.1)\n- Consistent design system\n\n## Security & Privacy\n- Secure data storage (Keychain/Keystore)\n- API communication encryption\n- Privacy policy compliance (GDPR/CCPA)\n- App security best practices\n\n## Testing Strategy\n- Unit testing (80%+ coverage)\n- UI/E2E testing (Detox/Appium)\n- Device testing on multiple screen sizes\n- Performance testing\n- Security testing\n\n## App Store Deployment\n- App store optimization (ASO)\n- App icons and screenshots\n- Store listing content\n- Release management strategy\n\n## Analytics & Monitoring\n- User analytics (Firebase/Analytics)\n- Crash reporting (Crashlytics/Sentry)\n- Performance monitoring\n- User feedback collection\n\n## Success Metrics\n- App store ratings > 4.0\n- User retention rates\n- Daily/Monthly active users\n- App performance metrics\n- Conversion rates`\n            },\n            {\n                id: 'data-analysis',\n                name: 'Data Analysis Project',\n                description: 'Template for data analysis and visualization projects',\n                category: 'data',\n                content: `# Product Requirements Document - Data Analysis Project\n\n## Overview\n**Project Name:** [Your Analysis Project]\n**Analysis Type:** [Descriptive/Predictive/Prescriptive]\n**Date:** ${new Date().toISOString().split('T')[0]}\n**Author:** [Your Name]\n\n## Executive Summary\nDescription of the business problem, data sources, and expected insights.\n\n## Project Goals\n- Goal 1: [Specific business question to answer]\n- Goal 2: [Specific prediction to make]\n- Goal 3: [Specific recommendation to provide]\n\n## Business Requirements\n### Key Questions\n1. What patterns exist in the current data?\n2. What factors influence [target variable]?\n3. What predictions can be made for [future outcome]?\n4. What recommendations can improve [business metric]?\n\n### Success Criteria\n- Actionable insights for stakeholders\n- Statistical significance in findings\n- Reproducible analysis pipeline\n- Clear visualization and reporting\n\n## Data Requirements\n### Data Sources\n1. **Primary Data**\n   - Source: [Database/API/Files]\n   - Format: [CSV/JSON/SQL]\n   - Size: [Volume estimate]\n   - Update frequency: [Real-time/Daily/Monthly]\n\n2. **External Data**\n   - Third-party APIs\n   - Public datasets\n   - Market research data\n\n### Data Quality Requirements\n- Data completeness (< 5% missing values)\n- Data accuracy validation\n- Data consistency checks\n- Historical data availability\n\n## Technical Requirements\n### Data Pipeline\n- Data extraction and ingestion\n- Data cleaning and preprocessing\n- Data transformation and feature engineering\n- Data validation and quality checks\n\n### Analysis Tools\n- **Programming:** Python/R/SQL\n- **Libraries:** pandas, numpy, scikit-learn, matplotlib\n- **Visualization:** Tableau, PowerBI, or custom dashboards\n- **Version Control:** Git for code and DVC for data\n\n### Computing Resources\n- Local development environment\n- Cloud computing (AWS/GCP/Azure) if needed\n- Database access and permissions\n- Storage requirements\n\n## Analysis Methodology\n### Data Exploration\n1. Descriptive statistics and data profiling\n2. Data visualization and pattern identification\n3. Correlation analysis\n4. Outlier detection and handling\n\n### Statistical Analysis\n1. Hypothesis formulation\n2. Statistical testing\n3. Confidence intervals\n4. Effect size calculations\n\n### Machine Learning (if applicable)\n1. Feature selection and engineering\n2. Model selection and training\n3. Cross-validation and evaluation\n4. Model interpretation and explainability\n\n## Deliverables\n### Reports\n- Executive summary for stakeholders\n- Technical analysis report\n- Data quality report\n- Methodology documentation\n\n### Visualizations\n- Interactive dashboards\n- Static charts and graphs\n- Data story presentations\n- Key findings infographics\n\n### Code & Documentation\n- Reproducible analysis scripts\n- Data pipeline code\n- Documentation and comments\n- Testing and validation code\n\n## Timeline\n- Phase 1: Data collection and exploration (2 weeks)\n- Phase 2: Analysis and modeling (3 weeks)\n- Phase 3: Reporting and visualization (1 week)\n- Phase 4: Stakeholder presentation (1 week)\n\n## Risks & Assumptions\n- Data availability and quality risks\n- Technical complexity assumptions\n- Resource and timeline constraints\n- Stakeholder engagement assumptions\n\n## Success Metrics\n- Stakeholder satisfaction with insights\n- Accuracy of predictions (if applicable)\n- Business impact of recommendations\n- Reproducibility of results`\n            }\n        ];\n\n        res.json({\n            templates,\n            timestamp: new Date().toISOString()\n        });\n\n    } catch (error) {\n        console.error('PRD templates error:', error);\n        res.status(500).json({\n            error: 'Failed to get PRD templates',\n            message: error.message\n        });\n    }\n});\n\n/**\n * POST /api/taskmaster/apply-template/:projectName\n * Apply a PRD template to create a new PRD file\n */\nrouter.post('/apply-template/:projectName', async (req, res) => {\n    try {\n        const { projectName } = req.params;\n        const { templateId, fileName = 'prd.txt', customizations = {} } = req.body;\n\n        if (!templateId) {\n            return res.status(400).json({\n                error: 'Missing required parameter',\n                message: 'templateId is required'\n            });\n        }\n\n        // Get project path\n        let projectPath;\n        try {\n            projectPath = await extractProjectDirectory(projectName);\n        } catch (error) {\n            return res.status(404).json({\n                error: 'Project not found',\n                message: `Project \"${projectName}\" does not exist`\n            });\n        }\n\n        // Get the template content (this would normally fetch from the templates list)\n        const templates = await getAvailableTemplates();\n        const template = templates.find(t => t.id === templateId);\n\n        if (!template) {\n            return res.status(404).json({\n                error: 'Template not found',\n                message: `Template \"${templateId}\" does not exist`\n            });\n        }\n\n        // Apply customizations to template content\n        let content = template.content;\n        \n        // Replace placeholders with customizations\n        for (const [key, value] of Object.entries(customizations)) {\n            const placeholder = `[${key}]`;\n            content = content.replace(new RegExp(placeholder.replace(/[.*+?^${}()|[\\\\]\\\\\\\\]/g, '\\\\\\\\$&'), 'g'), value);\n        }\n\n        // Ensure .taskmaster/docs directory exists\n        const docsDir = path.join(projectPath, '.taskmaster', 'docs');\n        try {\n            await fsPromises.mkdir(docsDir, { recursive: true });\n        } catch (error) {\n            console.error('Failed to create docs directory:', error);\n        }\n\n        const filePath = path.join(docsDir, fileName);\n\n        // Write the template content to the file\n        try {\n            await fsPromises.writeFile(filePath, content, 'utf8');\n\n            res.json({\n                projectName,\n                projectPath,\n                templateId,\n                templateName: template.name,\n                fileName,\n                filePath: filePath,\n                message: 'PRD template applied successfully',\n                timestamp: new Date().toISOString()\n            });\n\n        } catch (writeError) {\n            console.error('Failed to write PRD template:', writeError);\n            return res.status(500).json({\n                error: 'Failed to write PRD template',\n                message: writeError.message\n            });\n        }\n\n    } catch (error) {\n        console.error('Apply template error:', error);\n        res.status(500).json({\n            error: 'Failed to apply PRD template',\n            message: error.message\n        });\n    }\n});\n\n// Helper function to get available templates\nasync function getAvailableTemplates() {\n    // This could be extended to read from files or database\n    return [\n        {\n            id: 'web-app',\n            name: 'Web Application',\n            description: 'Template for web application projects',\n            category: 'web',\n            content: `# Product Requirements Document - Web Application\n\n## Overview\n**Product Name:** [Your App Name]\n**Version:** 1.0\n**Date:** ${new Date().toISOString().split('T')[0]}\n**Author:** [Your Name]\n\n## Executive Summary\nBrief description of what this web application will do and why it's needed.\n\n## User Stories\n1. As a user, I want [feature] so I can [benefit]\n2. As a user, I want [feature] so I can [benefit]\n3. As a user, I want [feature] so I can [benefit]\n\n## Technical Requirements\n- Frontend framework\n- Backend services\n- Database requirements\n- Security considerations\n\n## Success Metrics\n- User engagement metrics\n- Performance benchmarks\n- Business objectives`\n        },\n        // Add other templates here if needed\n    ];\n}\n\nexport default router;\n"
  },
  {
    "path": "server/routes/user.js",
    "content": "import express from 'express';\nimport { userDb } from '../database/db.js';\nimport { authenticateToken } from '../middleware/auth.js';\nimport { getSystemGitConfig } from '../utils/gitConfig.js';\nimport { spawn } from 'child_process';\n\nconst router = express.Router();\n\nfunction spawnAsync(command, args, options = {}) {\n  return new Promise((resolve, reject) => {\n    const child = spawn(command, args, { ...options, shell: false });\n    let stdout = '';\n    let stderr = '';\n    child.stdout.on('data', (data) => { stdout += data.toString(); });\n    child.stderr.on('data', (data) => { stderr += data.toString(); });\n    child.on('error', (error) => { reject(error); });\n    child.on('close', (code) => {\n      if (code === 0) { resolve({ stdout, stderr }); return; }\n      const error = new Error(`Command failed: ${command} ${args.join(' ')}`);\n      error.code = code;\n      error.stdout = stdout;\n      error.stderr = stderr;\n      reject(error);\n    });\n  });\n}\n\nrouter.get('/git-config', authenticateToken, async (req, res) => {\n  try {\n    const userId = req.user.id;\n    let gitConfig = userDb.getGitConfig(userId);\n\n    // If database is empty, try to get from system git config\n    if (!gitConfig || (!gitConfig.git_name && !gitConfig.git_email)) {\n      const systemConfig = await getSystemGitConfig();\n\n      // If system has values, save them to database for this user\n      if (systemConfig.git_name || systemConfig.git_email) {\n        userDb.updateGitConfig(userId, systemConfig.git_name, systemConfig.git_email);\n        gitConfig = systemConfig;\n        console.log(`Auto-populated git config from system for user ${userId}: ${systemConfig.git_name} <${systemConfig.git_email}>`);\n      }\n    }\n\n    res.json({\n      success: true,\n      gitName: gitConfig?.git_name || null,\n      gitEmail: gitConfig?.git_email || null\n    });\n  } catch (error) {\n    console.error('Error getting git config:', error);\n    res.status(500).json({ error: 'Failed to get git configuration' });\n  }\n});\n\n// Apply git config globally via git config --global\nrouter.post('/git-config', authenticateToken, async (req, res) => {\n  try {\n    const userId = req.user.id;\n    const { gitName, gitEmail } = req.body;\n\n    if (!gitName || !gitEmail) {\n      return res.status(400).json({ error: 'Git name and email are required' });\n    }\n\n    // Validate email format\n    const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n    if (!emailRegex.test(gitEmail)) {\n      return res.status(400).json({ error: 'Invalid email format' });\n    }\n\n    userDb.updateGitConfig(userId, gitName, gitEmail);\n\n    try {\n      await spawnAsync('git', ['config', '--global', 'user.name', gitName]);\n      await spawnAsync('git', ['config', '--global', 'user.email', gitEmail]);\n      console.log(`Applied git config globally: ${gitName} <${gitEmail}>`);\n    } catch (gitError) {\n      console.error('Error applying git config:', gitError);\n    }\n\n    res.json({\n      success: true,\n      gitName,\n      gitEmail\n    });\n  } catch (error) {\n    console.error('Error updating git config:', error);\n    res.status(500).json({ error: 'Failed to update git configuration' });\n  }\n});\n\nrouter.post('/complete-onboarding', authenticateToken, async (req, res) => {\n  try {\n    const userId = req.user.id;\n    userDb.completeOnboarding(userId);\n\n    res.json({\n      success: true,\n      message: 'Onboarding completed successfully'\n    });\n  } catch (error) {\n    console.error('Error completing onboarding:', error);\n    res.status(500).json({ error: 'Failed to complete onboarding' });\n  }\n});\n\nrouter.get('/onboarding-status', authenticateToken, async (req, res) => {\n  try {\n    const userId = req.user.id;\n    const hasCompleted = userDb.hasCompletedOnboarding(userId);\n\n    res.json({\n      success: true,\n      hasCompletedOnboarding: hasCompleted\n    });\n  } catch (error) {\n    console.error('Error checking onboarding status:', error);\n    res.status(500).json({ error: 'Failed to check onboarding status' });\n  }\n});\n\nexport default router;\n"
  },
  {
    "path": "server/services/notification-orchestrator.js",
    "content": "import webPush from 'web-push';\nimport { notificationPreferencesDb, pushSubscriptionsDb, sessionNamesDb } from '../database/db.js';\n\nconst KIND_TO_PREF_KEY = {\n  action_required: 'actionRequired',\n  stop: 'stop',\n  error: 'error'\n};\n\nconst PROVIDER_LABELS = {\n  claude: 'Claude',\n  cursor: 'Cursor',\n  codex: 'Codex',\n  gemini: 'Gemini',\n  system: 'System'\n};\n\nconst recentEventKeys = new Map();\nconst DEDUPE_WINDOW_MS = 20000;\n\nconst cleanupOldEventKeys = () => {\n  const now = Date.now();\n  for (const [key, timestamp] of recentEventKeys.entries()) {\n    if (now - timestamp > DEDUPE_WINDOW_MS) {\n      recentEventKeys.delete(key);\n    }\n  }\n};\n\nfunction shouldSendPush(preferences, event) {\n  const webPushEnabled = Boolean(preferences?.channels?.webPush);\n  const prefEventKey = KIND_TO_PREF_KEY[event.kind];\n  const eventEnabled = prefEventKey ? Boolean(preferences?.events?.[prefEventKey]) : true;\n\n  return webPushEnabled && eventEnabled;\n}\n\nfunction isDuplicate(event) {\n  cleanupOldEventKeys();\n  const key = event.dedupeKey || `${event.provider}:${event.kind || 'info'}:${event.code || 'generic'}:${event.sessionId || 'none'}`;\n  if (recentEventKeys.has(key)) {\n    return true;\n  }\n  recentEventKeys.set(key, Date.now());\n  return false;\n}\n\nfunction createNotificationEvent({\n  provider,\n  sessionId = null,\n  kind = 'info',\n  code = 'generic.info',\n  meta = {},\n  severity = 'info',\n  dedupeKey = null,\n  requiresUserAction = false\n}) {\n  return {\n    provider,\n    sessionId,\n    kind,\n    code,\n    meta,\n    severity,\n    requiresUserAction,\n    dedupeKey,\n    createdAt: new Date().toISOString()\n  };\n}\n\nfunction normalizeErrorMessage(error) {\n  if (typeof error === 'string') {\n    return error;\n  }\n\n  if (error && typeof error.message === 'string') {\n    return error.message;\n  }\n\n  if (error == null) {\n    return 'Unknown error';\n  }\n\n  return String(error);\n}\n\nfunction normalizeSessionName(sessionName) {\n  if (typeof sessionName !== 'string') {\n    return null;\n  }\n\n  const normalized = sessionName.replace(/\\s+/g, ' ').trim();\n  if (!normalized) {\n    return null;\n  }\n\n  return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized;\n}\n\nfunction resolveSessionName(event) {\n  const explicitSessionName = normalizeSessionName(event.meta?.sessionName);\n  if (explicitSessionName) {\n    return explicitSessionName;\n  }\n\n  if (!event.sessionId || !event.provider) {\n    return null;\n  }\n\n  return normalizeSessionName(sessionNamesDb.getName(event.sessionId, event.provider));\n}\n\nfunction buildPushBody(event) {\n  const CODE_MAP = {\n    'permission.required': event.meta?.toolName\n      ? `Action Required: Tool \"${event.meta.toolName}\" needs approval`\n      : 'Action Required: A tool needs your approval',\n    'run.stopped': event.meta?.stopReason || 'Run Stopped: The run has stopped',\n    'run.failed': event.meta?.error ? `Run Failed: ${event.meta.error}` : 'Run Failed: The run encountered an error',\n    'agent.notification': event.meta?.message ? String(event.meta.message) : 'You have a new notification',\n    'push.enabled': 'Push notifications are now enabled!'\n  };\n  const providerLabel = PROVIDER_LABELS[event.provider] || 'Assistant';\n  const sessionName = resolveSessionName(event);\n  const message = CODE_MAP[event.code] || 'You have a new notification';\n\n  return {\n    title: sessionName || 'Claude Code UI',\n    body: `${providerLabel}: ${message}`,\n    data: {\n      sessionId: event.sessionId || null,\n      code: event.code,\n      provider: event.provider || null,\n      sessionName,\n      tag: `${event.provider || 'assistant'}:${event.sessionId || 'none'}:${event.code}`\n    }\n  };\n}\n\nasync function sendWebPush(userId, event) {\n  const subscriptions = pushSubscriptionsDb.getSubscriptions(userId);\n  if (!subscriptions.length) return;\n\n  const payload = JSON.stringify(buildPushBody(event));\n\n  const results = await Promise.allSettled(\n    subscriptions.map((sub) =>\n      webPush.sendNotification(\n        {\n          endpoint: sub.endpoint,\n          keys: {\n            p256dh: sub.keys_p256dh,\n            auth: sub.keys_auth\n          }\n        },\n        payload\n      )\n    )\n  );\n\n  // Clean up gone subscriptions (410 Gone or 404)\n  results.forEach((result, index) => {\n    if (result.status === 'rejected') {\n      const statusCode = result.reason?.statusCode;\n      if (statusCode === 410 || statusCode === 404) {\n        pushSubscriptionsDb.removeSubscription(subscriptions[index].endpoint);\n      }\n    }\n  });\n}\n\nfunction notifyUserIfEnabled({ userId, event }) {\n  if (!userId || !event) {\n    return;\n  }\n\n  const preferences = notificationPreferencesDb.getPreferences(userId);\n  if (!shouldSendPush(preferences, event)) {\n    return;\n  }\n  if (isDuplicate(event)) {\n    return;\n  }\n\n  sendWebPush(userId, event).catch((err) => {\n    console.error('Web push send error:', err);\n  });\n}\n\nfunction notifyRunStopped({ userId, provider, sessionId = null, stopReason = 'completed', sessionName = null }) {\n  notifyUserIfEnabled({\n    userId,\n    event: createNotificationEvent({\n      provider,\n      sessionId,\n      kind: 'stop',\n      code: 'run.stopped',\n      meta: { stopReason, sessionName },\n      severity: 'info',\n      dedupeKey: `${provider}:run:stop:${sessionId || 'none'}:${stopReason}`\n    })\n  });\n}\n\nfunction notifyRunFailed({ userId, provider, sessionId = null, error, sessionName = null }) {\n  const errorMessage = normalizeErrorMessage(error);\n\n  notifyUserIfEnabled({\n    userId,\n    event: createNotificationEvent({\n      provider,\n      sessionId,\n      kind: 'error',\n      code: 'run.failed',\n      meta: { error: errorMessage, sessionName },\n      severity: 'error',\n      dedupeKey: `${provider}:run:error:${sessionId || 'none'}:${errorMessage}`\n    })\n  });\n}\n\nexport {\n  createNotificationEvent,\n  notifyUserIfEnabled,\n  notifyRunStopped,\n  notifyRunFailed\n};\n"
  },
  {
    "path": "server/services/vapid-keys.js",
    "content": "import webPush from 'web-push';\nimport { db } from '../database/db.js';\n\nlet cachedKeys = null;\n\nfunction ensureVapidKeys() {\n  if (cachedKeys) return cachedKeys;\n\n  const row = db.prepare('SELECT public_key, private_key FROM vapid_keys ORDER BY id DESC LIMIT 1').get();\n  if (row) {\n    cachedKeys = { publicKey: row.public_key, privateKey: row.private_key };\n    return cachedKeys;\n  }\n\n  const keys = webPush.generateVAPIDKeys();\n  db.prepare('INSERT INTO vapid_keys (public_key, private_key) VALUES (?, ?)').run(keys.publicKey, keys.privateKey);\n  cachedKeys = keys;\n  return cachedKeys;\n}\n\nfunction getPublicKey() {\n  return ensureVapidKeys().publicKey;\n}\n\nfunction configureWebPush() {\n  const keys = ensureVapidKeys();\n  webPush.setVapidDetails(\n    'mailto:noreply@claudecodeui.local',\n    keys.publicKey,\n    keys.privateKey\n  );\n  console.log('Web Push notifications configured');\n}\n\nexport { ensureVapidKeys, getPublicKey, configureWebPush };\n"
  },
  {
    "path": "server/sessionManager.js",
    "content": "import { promises as fs } from 'fs';\nimport path from 'path';\nimport os from 'os';\n\nclass SessionManager {\n  constructor() {\n    // Store sessions in memory with conversation history\n    this.sessions = new Map();\n    this.maxSessions = 100;\n    this.sessionsDir = path.join(os.homedir(), '.gemini', 'sessions');\n    this.ready = this.init();\n  }\n\n  async init() {\n    await this.initSessionsDir();\n    await this.loadSessions();\n  }\n\n  async initSessionsDir() {\n    try {\n      await fs.mkdir(this.sessionsDir, { recursive: true });\n    } catch (error) {\n      // console.error('Error creating sessions directory:', error);\n    }\n  }\n\n  // Create a new session\n  createSession(sessionId, projectPath) {\n    const session = {\n      id: sessionId,\n      projectPath: projectPath,\n      messages: [],\n      createdAt: new Date(),\n      lastActivity: new Date()\n    };\n\n    // Evict oldest session from memory if we exceed limit\n    if (this.sessions.size >= this.maxSessions) {\n      const oldestKey = this.sessions.keys().next().value;\n      if (oldestKey) this.sessions.delete(oldestKey);\n    }\n\n    this.sessions.set(sessionId, session);\n    this.saveSession(sessionId);\n\n    return session;\n  }\n\n  // Add a message to session\n  addMessage(sessionId, role, content) {\n    let session = this.sessions.get(sessionId);\n\n    if (!session) {\n      // Create session if it doesn't exist\n      session = this.createSession(sessionId, '');\n    }\n\n    const message = {\n      role: role, // 'user' or 'assistant'\n      content: content,\n      timestamp: new Date()\n    };\n\n    session.messages.push(message);\n    session.lastActivity = new Date();\n\n    this.saveSession(sessionId);\n\n    return session;\n  }\n\n  // Get session by ID\n  getSession(sessionId) {\n    return this.sessions.get(sessionId);\n  }\n\n  // Get all sessions for a project\n  getProjectSessions(projectPath) {\n    const sessions = [];\n\n    for (const [id, session] of this.sessions) {\n      if (session.projectPath === projectPath) {\n        sessions.push({\n          id: session.id,\n          summary: this.getSessionSummary(session),\n          messageCount: session.messages.length,\n          lastActivity: session.lastActivity\n        });\n      }\n    }\n\n    return sessions.sort((a, b) =>\n      new Date(b.lastActivity) - new Date(a.lastActivity)\n    );\n  }\n\n  // Get session summary\n  getSessionSummary(session) {\n    if (session.messages.length === 0) {\n      return 'New Session';\n    }\n\n    // Find first user message\n    const firstUserMessage = session.messages.find(m => m.role === 'user');\n    if (firstUserMessage) {\n      const content = firstUserMessage.content;\n      return content.length > 50 ? content.substring(0, 50) + '...' : content;\n    }\n\n    return 'New Session';\n  }\n\n  // Build conversation context for Gemini\n  buildConversationContext(sessionId, maxMessages = 10) {\n    const session = this.sessions.get(sessionId);\n\n    if (!session || session.messages.length === 0) {\n      return '';\n    }\n\n    // Get last N messages for context\n    const recentMessages = session.messages.slice(-maxMessages);\n\n    let context = 'Here is the conversation history:\\n\\n';\n\n    for (const msg of recentMessages) {\n      if (msg.role === 'user') {\n        context += `User: ${msg.content}\\n`;\n      } else {\n        context += `Assistant: ${msg.content}\\n`;\n      }\n    }\n\n    context += '\\nBased on the conversation history above, please answer the following:\\n';\n\n    return context;\n  }\n\n  // Prevent path traversal\n  _safeFilePath(sessionId) {\n    const safeId = String(sessionId).replace(/[/\\\\]|\\.\\./g, '');\n    return path.join(this.sessionsDir, `${safeId}.json`);\n  }\n\n  // Save session to disk\n  async saveSession(sessionId) {\n    const session = this.sessions.get(sessionId);\n    if (!session) return;\n\n    try {\n      const filePath = this._safeFilePath(sessionId);\n      await fs.writeFile(filePath, JSON.stringify(session, null, 2));\n    } catch (error) {\n      // console.error('Error saving session:', error);\n    }\n  }\n\n  // Load sessions from disk\n  async loadSessions() {\n    try {\n      const files = await fs.readdir(this.sessionsDir);\n\n      for (const file of files) {\n        if (file.endsWith('.json')) {\n          try {\n            const filePath = path.join(this.sessionsDir, file);\n            const data = await fs.readFile(filePath, 'utf8');\n            const session = JSON.parse(data);\n\n            // Convert dates\n            session.createdAt = new Date(session.createdAt);\n            session.lastActivity = new Date(session.lastActivity);\n            session.messages.forEach(msg => {\n              msg.timestamp = new Date(msg.timestamp);\n            });\n\n            this.sessions.set(session.id, session);\n          } catch (error) {\n            // console.error(`Error loading session ${file}:`, error);\n          }\n        }\n      }\n\n      // Enforce eviction after loading to prevent massive memory usage\n      while (this.sessions.size > this.maxSessions) {\n        const oldestKey = this.sessions.keys().next().value;\n        if (oldestKey) this.sessions.delete(oldestKey);\n      }\n    } catch (error) {\n      // console.error('Error loading sessions:', error);\n    }\n  }\n\n  // Delete a session\n  async deleteSession(sessionId) {\n    this.sessions.delete(sessionId);\n\n    try {\n      const filePath = this._safeFilePath(sessionId);\n      await fs.unlink(filePath);\n    } catch (error) {\n      // console.error('Error deleting session file:', error);\n    }\n  }\n\n  // Get session messages for display\n  getSessionMessages(sessionId) {\n    const session = this.sessions.get(sessionId);\n    if (!session) return [];\n\n    return session.messages.map(msg => ({\n      type: 'message',\n      message: {\n        role: msg.role,\n        content: msg.content\n      },\n      timestamp: msg.timestamp.toISOString()\n    }));\n  }\n}\n\n// Singleton instance\nconst sessionManager = new SessionManager();\n\nexport const ready = sessionManager.ready;\nexport default sessionManager;"
  },
  {
    "path": "server/utils/commandParser.js",
    "content": "import { promises as fs } from 'fs';\nimport path from 'path';\nimport { execFile } from 'child_process';\nimport { promisify } from 'util';\nimport { parse as parseShellCommand } from 'shell-quote';\nimport { parseFrontmatter } from './frontmatter.js';\n\nconst execFileAsync = promisify(execFile);\n\n// Configuration\nconst MAX_INCLUDE_DEPTH = 3;\nconst BASH_TIMEOUT = 30000; // 30 seconds\nconst BASH_COMMAND_ALLOWLIST = [\n  'echo',\n  'ls',\n  'pwd',\n  'date',\n  'whoami',\n  'git',\n  'npm',\n  'node',\n  'cat',\n  'grep',\n  'find',\n  'task-master'\n];\n\n/**\n * Parse a markdown command file and extract frontmatter and content\n * @param {string} content - Raw markdown content\n * @returns {object} Parsed command with data (frontmatter) and content\n */\nexport function parseCommand(content) {\n  try {\n    const parsed = parseFrontmatter(content);\n    return {\n      data: parsed.data || {},\n      content: parsed.content || '',\n      raw: content\n    };\n  } catch (error) {\n    throw new Error(`Failed to parse command: ${error.message}`);\n  }\n}\n\n/**\n * Replace argument placeholders in content\n * @param {string} content - Content with placeholders\n * @param {string|array} args - Arguments to replace (string or array)\n * @returns {string} Content with replaced arguments\n */\nexport function replaceArguments(content, args) {\n  if (!content) return content;\n\n  let result = content;\n\n  // Convert args to array if it's a string\n  const argsArray = Array.isArray(args) ? args : (args ? [args] : []);\n\n  // Replace $ARGUMENTS with all arguments joined by space\n  const allArgs = argsArray.join(' ');\n  result = result.replace(/\\$ARGUMENTS/g, allArgs);\n\n  // Replace positional arguments $1-$9\n  for (let i = 1; i <= 9; i++) {\n    const regex = new RegExp(`\\\\$${i}`, 'g');\n    const value = argsArray[i - 1] || '';\n    result = result.replace(regex, value);\n  }\n\n  return result;\n}\n\n/**\n * Validate file path to prevent directory traversal\n * @param {string} filePath - Path to validate\n * @param {string} basePath - Base directory path\n * @returns {boolean} True if path is safe\n */\nexport function isPathSafe(filePath, basePath) {\n  const resolvedPath = path.resolve(basePath, filePath);\n  const resolvedBase = path.resolve(basePath);\n  const relative = path.relative(resolvedBase, resolvedPath);\n  return (\n    relative !== '' &&\n    !relative.startsWith('..') &&\n    !path.isAbsolute(relative)\n  );\n}\n\n/**\n * Process file includes in content (@filename syntax)\n * @param {string} content - Content with @filename includes\n * @param {string} basePath - Base directory for resolving file paths\n * @param {number} depth - Current recursion depth\n * @returns {Promise<string>} Content with includes resolved\n */\nexport async function processFileIncludes(content, basePath, depth = 0) {\n  if (!content) return content;\n\n  // Prevent infinite recursion\n  if (depth >= MAX_INCLUDE_DEPTH) {\n    throw new Error(`Maximum include depth (${MAX_INCLUDE_DEPTH}) exceeded`);\n  }\n\n  // Match @filename patterns (at start of line or after whitespace)\n  const includePattern = /(?:^|\\s)@([^\\s]+)/gm;\n  const matches = [...content.matchAll(includePattern)];\n\n  if (matches.length === 0) {\n    return content;\n  }\n\n  let result = content;\n\n  for (const match of matches) {\n    const fullMatch = match[0];\n    const filename = match[1];\n\n    // Security: prevent directory traversal\n    if (!isPathSafe(filename, basePath)) {\n      throw new Error(`Invalid file path (directory traversal detected): ${filename}`);\n    }\n\n    try {\n      const filePath = path.resolve(basePath, filename);\n      const fileContent = await fs.readFile(filePath, 'utf-8');\n\n      // Recursively process includes in the included file\n      const processedContent = await processFileIncludes(fileContent, basePath, depth + 1);\n\n      // Replace the @filename with the file content\n      result = result.replace(fullMatch, fullMatch.startsWith(' ') ? ' ' + processedContent : processedContent);\n    } catch (error) {\n      if (error.code === 'ENOENT') {\n        throw new Error(`File not found: ${filename}`);\n      }\n      throw error;\n    }\n  }\n\n  return result;\n}\n\n/**\n * Validate that a command and its arguments are safe\n * @param {string} commandString - Command string to validate\n * @returns {{ allowed: boolean, command: string, args: string[], error?: string }} Validation result\n */\nexport function validateCommand(commandString) {\n  const trimmedCommand = commandString.trim();\n  if (!trimmedCommand) {\n    return { allowed: false, command: '', args: [], error: 'Empty command' };\n  }\n\n  // Parse the command using shell-quote to handle quotes properly\n  const parsed = parseShellCommand(trimmedCommand);\n\n  // Check for shell operators or control structures\n  const hasOperators = parsed.some(token =>\n    typeof token === 'object' && token.op\n  );\n\n  if (hasOperators) {\n    return {\n      allowed: false,\n      command: '',\n      args: [],\n      error: 'Shell operators (&&, ||, |, ;, etc.) are not allowed'\n    };\n  }\n\n  // Extract command and args (all should be strings after validation)\n  const tokens = parsed.filter(token => typeof token === 'string');\n\n  if (tokens.length === 0) {\n    return { allowed: false, command: '', args: [], error: 'No valid command found' };\n  }\n\n  const [command, ...args] = tokens;\n\n  // Extract just the command name (remove path if present)\n  const commandName = path.basename(command);\n\n  // Check if command exactly matches allowlist (no prefix matching)\n  const isAllowed = BASH_COMMAND_ALLOWLIST.includes(commandName);\n\n  if (!isAllowed) {\n    return {\n      allowed: false,\n      command: commandName,\n      args,\n      error: `Command '${commandName}' is not in the allowlist`\n    };\n  }\n\n  // Validate arguments don't contain dangerous metacharacters\n  const dangerousPattern = /[;&|`$()<>{}[\\]\\\\]/;\n  for (const arg of args) {\n    if (dangerousPattern.test(arg)) {\n      return {\n        allowed: false,\n        command: commandName,\n        args,\n        error: `Argument contains dangerous characters: ${arg}`\n      };\n    }\n  }\n\n  return { allowed: true, command: commandName, args };\n}\n\n/**\n * Backward compatibility: Check if command is allowed (deprecated)\n * @deprecated Use validateCommand() instead for better security\n * @param {string} command - Command to validate\n * @returns {boolean} True if command is allowed\n */\nexport function isBashCommandAllowed(command) {\n  const result = validateCommand(command);\n  return result.allowed;\n}\n\n/**\n * Sanitize bash command output\n * @param {string} output - Raw command output\n * @returns {string} Sanitized output\n */\nexport function sanitizeOutput(output) {\n  if (!output) return '';\n\n  // Remove control characters except \\t, \\n, \\r\n  return [...output]\n    .filter(ch => {\n      const code = ch.charCodeAt(0);\n      return code === 9  // \\t\n          || code === 10 // \\n\n          || code === 13 // \\r\n          || (code >= 32 && code !== 127);\n    })\n    .join('');\n}\n\n/**\n * Process bash commands in content (!command syntax)\n * @param {string} content - Content with !command syntax\n * @param {object} options - Options for bash execution\n * @returns {Promise<string>} Content with bash commands executed and replaced\n */\nexport async function processBashCommands(content, options = {}) {\n  if (!content) return content;\n\n  const { cwd = process.cwd(), timeout = BASH_TIMEOUT } = options;\n\n  // Match !command patterns (at start of line or after whitespace)\n  const commandPattern = /(?:^|\\n)!(.+?)(?=\\n|$)/g;\n  const matches = [...content.matchAll(commandPattern)];\n\n  if (matches.length === 0) {\n    return content;\n  }\n\n  let result = content;\n\n  for (const match of matches) {\n    const fullMatch = match[0];\n    const commandString = match[1].trim();\n\n    // Security: validate command and parse args\n    const validation = validateCommand(commandString);\n\n    if (!validation.allowed) {\n      throw new Error(`Command not allowed: ${commandString} - ${validation.error}`);\n    }\n\n    try {\n      // Execute without shell using execFile with parsed args\n      const { stdout, stderr } = await execFileAsync(\n        validation.command,\n        validation.args,\n        {\n          cwd,\n          timeout,\n          maxBuffer: 1024 * 1024, // 1MB max output\n          shell: false, // IMPORTANT: No shell interpretation\n          env: { ...process.env, PATH: process.env.PATH } // Inherit PATH for finding commands\n        }\n      );\n\n      const output = sanitizeOutput(stdout || stderr || '');\n\n      // Replace the !command with the output\n      result = result.replace(fullMatch, fullMatch.startsWith('\\n') ? '\\n' + output : output);\n    } catch (error) {\n      if (error.killed) {\n        throw new Error(`Command timeout: ${commandString}`);\n      }\n      throw new Error(`Command failed: ${commandString} - ${error.message}`);\n    }\n  }\n\n  return result;\n}\n"
  },
  {
    "path": "server/utils/frontmatter.js",
    "content": "import matter from 'gray-matter';\n\nconst disabledFrontmatterEngine = () => ({});\n\nconst frontmatterOptions = {\n  language: 'yaml',\n  // Disable JS/JSON frontmatter parsing to avoid executable project content.\n  // Mirrors Gatsby's mitigation for gray-matter.\n  engines: {\n    js: disabledFrontmatterEngine,\n    javascript: disabledFrontmatterEngine,\n    json: disabledFrontmatterEngine\n  }\n};\n\nexport function parseFrontmatter(content) {\n  return matter(content, frontmatterOptions);\n}\n"
  },
  {
    "path": "server/utils/gitConfig.js",
    "content": "import { spawn } from 'child_process';\n\nfunction spawnAsync(command, args) {\n  return new Promise((resolve, reject) => {\n    const child = spawn(command, args, { shell: false });\n    let stdout = '';\n    child.stdout.on('data', (data) => { stdout += data.toString(); });\n    child.on('error', (error) => { reject(error); });\n    child.on('close', (code) => {\n      if (code === 0) { resolve({ stdout }); return; }\n      reject(new Error(`Command failed with code ${code}`));\n    });\n  });\n}\n\n/**\n * Read git configuration from system's global git config\n * @returns {Promise<{git_name: string|null, git_email: string|null}>}\n */\nexport async function getSystemGitConfig() {\n  try {\n    const [nameResult, emailResult] = await Promise.all([\n      spawnAsync('git', ['config', '--global', 'user.name']).catch(() => ({ stdout: '' })),\n      spawnAsync('git', ['config', '--global', 'user.email']).catch(() => ({ stdout: '' }))\n    ]);\n\n    return {\n      git_name: nameResult.stdout.trim() || null,\n      git_email: emailResult.stdout.trim() || null\n    };\n  } catch (error) {\n    return { git_name: null, git_email: null };\n  }\n}\n"
  },
  {
    "path": "server/utils/mcp-detector.js",
    "content": "/**\n * MCP SERVER DETECTION UTILITY\n * ============================\n * \n * Centralized utility for detecting MCP server configurations.\n * Used across TaskMaster integration and other MCP-dependent features.\n */\n\nimport { promises as fsPromises } from 'fs';\nimport path from 'path';\nimport os from 'os';\n\n/**\n * Check if task-master-ai MCP server is configured\n * Reads directly from Claude configuration files like claude-cli.js does\n * @returns {Promise<Object>} MCP detection result\n */\nexport async function detectTaskMasterMCPServer() {\n    try {\n        // Read Claude configuration files directly (same logic as mcp.js)\n        const homeDir = os.homedir();\n        const configPaths = [\n            path.join(homeDir, '.claude.json'),\n            path.join(homeDir, '.claude', 'settings.json')\n        ];\n        \n        let configData = null;\n        let configPath = null;\n        \n        // Try to read from either config file\n        for (const filepath of configPaths) {\n            try {\n                const fileContent = await fsPromises.readFile(filepath, 'utf8');\n                configData = JSON.parse(fileContent);\n                configPath = filepath;\n                break;\n            } catch (error) {\n                // File doesn't exist or is not valid JSON, try next\n                continue;\n            }\n        }\n        \n        if (!configData) {\n            return {\n                hasMCPServer: false,\n                reason: 'No Claude configuration file found',\n                hasConfig: false\n            };\n        }\n\n        // Look for task-master-ai in user-scoped MCP servers\n        let taskMasterServer = null;\n        if (configData.mcpServers && typeof configData.mcpServers === 'object') {\n            const serverEntry = Object.entries(configData.mcpServers).find(([name, config]) => \n                name === 'task-master-ai' || \n                name.includes('task-master') ||\n                (config && config.command && config.command.includes('task-master'))\n            );\n            \n            if (serverEntry) {\n                const [name, config] = serverEntry;\n                taskMasterServer = {\n                    name,\n                    scope: 'user',\n                    config,\n                    type: config.command ? 'stdio' : (config.url ? 'http' : 'unknown')\n                };\n            }\n        }\n\n        // Also check project-specific MCP servers if not found globally\n        if (!taskMasterServer && configData.projects) {\n            for (const [projectPath, projectConfig] of Object.entries(configData.projects)) {\n                if (projectConfig.mcpServers && typeof projectConfig.mcpServers === 'object') {\n                    const serverEntry = Object.entries(projectConfig.mcpServers).find(([name, config]) => \n                        name === 'task-master-ai' || \n                        name.includes('task-master') ||\n                        (config && config.command && config.command.includes('task-master'))\n                    );\n                    \n                    if (serverEntry) {\n                        const [name, config] = serverEntry;\n                        taskMasterServer = {\n                            name,\n                            scope: 'local',\n                            projectPath,\n                            config,\n                            type: config.command ? 'stdio' : (config.url ? 'http' : 'unknown')\n                        };\n                        break;\n                    }\n                }\n            }\n        }\n\n        if (taskMasterServer) {\n            const isValid = !!(taskMasterServer.config && \n                             (taskMasterServer.config.command || taskMasterServer.config.url));\n            const hasEnvVars = !!(taskMasterServer.config && \n                                taskMasterServer.config.env && \n                                Object.keys(taskMasterServer.config.env).length > 0);\n\n            return {\n                hasMCPServer: true,\n                isConfigured: isValid,\n                hasApiKeys: hasEnvVars,\n                scope: taskMasterServer.scope,\n                config: {\n                    command: taskMasterServer.config?.command,\n                    args: taskMasterServer.config?.args || [],\n                    url: taskMasterServer.config?.url,\n                    envVars: hasEnvVars ? Object.keys(taskMasterServer.config.env) : [],\n                    type: taskMasterServer.type\n                }\n            };\n        } else {\n            // Get list of available servers for debugging\n            const availableServers = [];\n            if (configData.mcpServers) {\n                availableServers.push(...Object.keys(configData.mcpServers));\n            }\n            if (configData.projects) {\n                for (const projectConfig of Object.values(configData.projects)) {\n                    if (projectConfig.mcpServers) {\n                        availableServers.push(...Object.keys(projectConfig.mcpServers).map(name => `local:${name}`));\n                    }\n                }\n            }\n\n            return {\n                hasMCPServer: false,\n                reason: 'task-master-ai not found in configured MCP servers',\n                hasConfig: true,\n                configPath,\n                availableServers\n            };\n        }\n    } catch (error) {\n        console.error('Error detecting MCP server config:', error);\n        return {\n            hasMCPServer: false,\n            reason: `Error checking MCP config: ${error.message}`,\n            hasConfig: false\n        };\n    }\n}\n\n/**\n * Get all configured MCP servers (not just TaskMaster)\n * @returns {Promise<Object>} All MCP servers configuration\n */\nexport async function getAllMCPServers() {\n    try {\n        const homeDir = os.homedir();\n        const configPaths = [\n            path.join(homeDir, '.claude.json'),\n            path.join(homeDir, '.claude', 'settings.json')\n        ];\n        \n        let configData = null;\n        let configPath = null;\n        \n        // Try to read from either config file\n        for (const filepath of configPaths) {\n            try {\n                const fileContent = await fsPromises.readFile(filepath, 'utf8');\n                configData = JSON.parse(fileContent);\n                configPath = filepath;\n                break;\n            } catch (error) {\n                continue;\n            }\n        }\n        \n        if (!configData) {\n            return {\n                hasConfig: false,\n                servers: {},\n                projectServers: {}\n            };\n        }\n\n        return {\n            hasConfig: true,\n            configPath,\n            servers: configData.mcpServers || {},\n            projectServers: configData.projects || {}\n        };\n    } catch (error) {\n        console.error('Error getting all MCP servers:', error);\n        return {\n            hasConfig: false,\n            error: error.message,\n            servers: {},\n            projectServers: {}\n        };\n    }\n}"
  },
  {
    "path": "server/utils/plugin-loader.js",
    "content": "import fs from 'fs';\nimport path from 'path';\nimport os from 'os';\nimport { spawn } from 'child_process';\n\nconst PLUGINS_DIR = path.join(os.homedir(), '.claude-code-ui', 'plugins');\nconst PLUGINS_CONFIG_PATH = path.join(os.homedir(), '.claude-code-ui', 'plugins.json');\n\nconst REQUIRED_MANIFEST_FIELDS = ['name', 'displayName', 'entry'];\n\n/** Strip embedded credentials from a repo URL before exposing it to the client. */\nfunction sanitizeRepoUrl(raw) {\n  try {\n    const u = new URL(raw);\n    u.username = '';\n    u.password = '';\n    return u.toString().replace(/\\/$/, '');\n  } catch {\n    // Not a parseable URL (e.g. SSH shorthand) — strip user:pass@ segment\n    return raw.replace(/\\/\\/[^@/]+@/, '//');\n  }\n}\nconst ALLOWED_TYPES = ['react', 'module'];\nconst ALLOWED_SLOTS = ['tab'];\n\nexport function getPluginsDir() {\n  if (!fs.existsSync(PLUGINS_DIR)) {\n    fs.mkdirSync(PLUGINS_DIR, { recursive: true });\n  }\n  return PLUGINS_DIR;\n}\n\nexport function getPluginsConfig() {\n  try {\n    if (fs.existsSync(PLUGINS_CONFIG_PATH)) {\n      return JSON.parse(fs.readFileSync(PLUGINS_CONFIG_PATH, 'utf-8'));\n    }\n  } catch {\n    // Corrupted config, start fresh\n  }\n  return {};\n}\n\nexport function savePluginsConfig(config) {\n  const dir = path.dirname(PLUGINS_CONFIG_PATH);\n  if (!fs.existsSync(dir)) {\n    fs.mkdirSync(dir, { recursive: true, mode: 0o700 });\n  }\n  fs.writeFileSync(PLUGINS_CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });\n}\n\nexport function validateManifest(manifest) {\n  if (!manifest || typeof manifest !== 'object') {\n    return { valid: false, error: 'Manifest must be a JSON object' };\n  }\n\n  for (const field of REQUIRED_MANIFEST_FIELDS) {\n    if (!manifest[field] || typeof manifest[field] !== 'string') {\n      return { valid: false, error: `Missing or invalid required field: ${field}` };\n    }\n  }\n\n  // Sanitize name — only allow alphanumeric, hyphens, underscores\n  if (!/^[a-zA-Z0-9_-]+$/.test(manifest.name)) {\n    return { valid: false, error: 'Plugin name must only contain letters, numbers, hyphens, and underscores' };\n  }\n\n  if (manifest.type && !ALLOWED_TYPES.includes(manifest.type)) {\n    return { valid: false, error: `Invalid plugin type: ${manifest.type}. Must be one of: ${ALLOWED_TYPES.join(', ')}` };\n  }\n\n  if (manifest.slot && !ALLOWED_SLOTS.includes(manifest.slot)) {\n    return { valid: false, error: `Invalid plugin slot: ${manifest.slot}. Must be one of: ${ALLOWED_SLOTS.join(', ')}` };\n  }\n\n  // Validate entry is a relative path without traversal\n  if (manifest.entry.includes('..') || path.isAbsolute(manifest.entry)) {\n    return { valid: false, error: 'Entry must be a relative path without \"..\"' };\n  }\n\n  if (manifest.server !== undefined && manifest.server !== null) {\n    if (typeof manifest.server !== 'string' || manifest.server.includes('..') || path.isAbsolute(manifest.server)) {\n      return { valid: false, error: 'Server entry must be a relative path string without \"..\"' };\n    }\n  }\n\n  if (manifest.permissions !== undefined) {\n    if (!Array.isArray(manifest.permissions) || !manifest.permissions.every(p => typeof p === 'string')) {\n      return { valid: false, error: 'Permissions must be an array of strings' };\n    }\n  }\n\n  return { valid: true };\n}\n\nconst BUILD_TIMEOUT_MS = 60_000;\n\n/** Run `npm run build` if the plugin's package.json declares a build script. */\nfunction runBuildIfNeeded(dir, packageJsonPath, onSuccess, onError) {\n  try {\n    const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));\n    if (!pkg.scripts?.build) {\n      return onSuccess();\n    }\n  } catch {\n    return onSuccess(); // Unreadable package.json — skip build\n  }\n\n  const buildProcess = spawn('npm', ['run', 'build'], {\n    cwd: dir,\n    stdio: ['ignore', 'pipe', 'pipe'],\n  });\n\n  let stderr = '';\n  let settled = false;\n\n  const timer = setTimeout(() => {\n    if (settled) return;\n    settled = true;\n    buildProcess.removeAllListeners();\n    buildProcess.kill();\n    onError(new Error('npm run build timed out'));\n  }, BUILD_TIMEOUT_MS);\n\n  buildProcess.stderr.on('data', (data) => { stderr += data.toString(); });\n\n  buildProcess.on('close', (code) => {\n    if (settled) return;\n    settled = true;\n    clearTimeout(timer);\n    if (code !== 0) {\n      return onError(new Error(`npm run build failed (exit code ${code}): ${stderr.trim()}`));\n    }\n    onSuccess();\n  });\n\n  buildProcess.on('error', (err) => {\n    if (settled) return;\n    settled = true;\n    clearTimeout(timer);\n    onError(new Error(`Failed to spawn build: ${err.message}`));\n  });\n}\n\nexport function scanPlugins() {\n  const pluginsDir = getPluginsDir();\n  const config = getPluginsConfig();\n  const plugins = [];\n\n  let entries;\n  try {\n    entries = fs.readdirSync(pluginsDir, { withFileTypes: true });\n  } catch {\n    return plugins;\n  }\n\n  const seenNames = new Set();\n\n  for (const entry of entries) {\n    if (!entry.isDirectory()) continue;\n    // Skip transient temp directories from in-progress installs\n    if (entry.name.startsWith('.tmp-')) continue;\n\n    const manifestPath = path.join(pluginsDir, entry.name, 'manifest.json');\n    if (!fs.existsSync(manifestPath)) continue;\n\n    try {\n      const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));\n      const validation = validateManifest(manifest);\n      if (!validation.valid) {\n        console.warn(`[Plugins] Skipping ${entry.name}: ${validation.error}`);\n        continue;\n      }\n\n      // Skip duplicate manifest names\n      if (seenNames.has(manifest.name)) {\n        console.warn(`[Plugins] Skipping ${entry.name}: duplicate plugin name \"${manifest.name}\"`);\n        continue;\n      }\n      seenNames.add(manifest.name);\n\n      // Try to read git remote URL\n      let repoUrl = null;\n      try {\n        const gitConfigPath = path.join(pluginsDir, entry.name, '.git', 'config');\n        if (fs.existsSync(gitConfigPath)) {\n          const gitConfig = fs.readFileSync(gitConfigPath, 'utf-8');\n          const match = gitConfig.match(/url\\s*=\\s*(.+)/);\n          if (match) {\n            repoUrl = match[1].trim().replace(/\\.git$/, '');\n            // Convert SSH URLs to HTTPS\n            if (repoUrl.startsWith('git@')) {\n              repoUrl = repoUrl.replace(/^git@([^:]+):/, 'https://$1/');\n            }\n            // Strip embedded credentials (e.g. https://user:pass@host/...)\n            repoUrl = sanitizeRepoUrl(repoUrl);\n          }\n        }\n      } catch { /* ignore */ }\n\n      plugins.push({\n        name: manifest.name,\n        displayName: manifest.displayName,\n        version: manifest.version || '0.0.0',\n        description: manifest.description || '',\n        author: manifest.author || '',\n        icon: manifest.icon || 'Puzzle',\n        type: manifest.type || 'module',\n        slot: manifest.slot || 'tab',\n        entry: manifest.entry,\n        server: manifest.server || null,\n        permissions: manifest.permissions || [],\n        enabled: config[manifest.name]?.enabled !== false, // enabled by default\n        dirName: entry.name,\n        repoUrl,\n      });\n    } catch (err) {\n      console.warn(`[Plugins] Failed to read manifest for ${entry.name}:`, err.message);\n    }\n  }\n\n  return plugins;\n}\n\nexport function getPluginDir(name) {\n  const plugins = scanPlugins();\n  const plugin = plugins.find(p => p.name === name);\n  if (!plugin) return null;\n  return path.join(getPluginsDir(), plugin.dirName);\n}\n\nexport function resolvePluginAssetPath(name, assetPath) {\n  const pluginDir = getPluginDir(name);\n  if (!pluginDir) return null;\n\n  const resolved = path.resolve(pluginDir, assetPath);\n\n  // Prevent path traversal — canonicalize via realpath to defeat symlink bypasses\n  if (!fs.existsSync(resolved)) return null;\n\n  const realResolved = fs.realpathSync(resolved);\n  const realPluginDir = fs.realpathSync(pluginDir);\n  if (!realResolved.startsWith(realPluginDir + path.sep) && realResolved !== realPluginDir) {\n    return null;\n  }\n\n  return realResolved;\n}\n\nexport function installPluginFromGit(url) {\n  return new Promise((resolve, reject) => {\n    if (typeof url !== 'string' || !url.trim()) {\n      return reject(new Error('Invalid URL: must be a non-empty string'));\n    }\n    if (url.startsWith('-')) {\n      return reject(new Error('Invalid URL: must not start with \"-\"'));\n    }\n\n    // Extract repo name from URL for directory name\n    const urlClean = url.replace(/\\.git$/, '').replace(/\\/$/, '');\n    const repoName = urlClean.split('/').pop();\n\n    if (!repoName || !/^[a-zA-Z0-9_.-]+$/.test(repoName)) {\n      return reject(new Error('Could not determine a valid directory name from the URL'));\n    }\n\n    const pluginsDir = getPluginsDir();\n    const targetDir = path.resolve(pluginsDir, repoName);\n\n    // Ensure the resolved target directory stays within the plugins directory\n    if (!targetDir.startsWith(pluginsDir + path.sep)) {\n      return reject(new Error('Invalid plugin directory path'));\n    }\n\n    if (fs.existsSync(targetDir)) {\n      return reject(new Error(`Plugin directory \"${repoName}\" already exists`));\n    }\n\n    // Clone into a temp directory so scanPlugins() never sees a partially-installed plugin\n    const tempDir = fs.mkdtempSync(path.join(pluginsDir, `.tmp-${repoName}-`));\n\n    const cleanupTemp = () => {\n      try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch {}\n    };\n\n    const finalize = (manifest) => {\n      try {\n        fs.renameSync(tempDir, targetDir);\n      } catch (err) {\n        cleanupTemp();\n        return reject(new Error(`Failed to move plugin into place: ${err.message}`));\n      }\n      resolve(manifest);\n    };\n\n    const gitProcess = spawn('git', ['clone', '--depth', '1', '--', url, tempDir], {\n      stdio: ['ignore', 'pipe', 'pipe'],\n    });\n\n    let stderr = '';\n    gitProcess.stderr.on('data', (data) => { stderr += data.toString(); });\n\n    gitProcess.on('close', (code) => {\n      if (code !== 0) {\n        cleanupTemp();\n        return reject(new Error(`git clone failed (exit code ${code}): ${stderr.trim()}`));\n      }\n\n      // Validate manifest exists\n      const manifestPath = path.join(tempDir, 'manifest.json');\n      if (!fs.existsSync(manifestPath)) {\n        cleanupTemp();\n        return reject(new Error('Cloned repository does not contain a manifest.json'));\n      }\n\n      let manifest;\n      try {\n        manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));\n      } catch {\n        cleanupTemp();\n        return reject(new Error('manifest.json is not valid JSON'));\n      }\n\n      const validation = validateManifest(manifest);\n      if (!validation.valid) {\n        cleanupTemp();\n        return reject(new Error(`Invalid manifest: ${validation.error}`));\n      }\n\n      // Reject if another installed plugin already uses this name\n      const existing = scanPlugins().find(p => p.name === manifest.name);\n      if (existing) {\n        cleanupTemp();\n        return reject(new Error(`A plugin named \"${manifest.name}\" is already installed (in \"${existing.dirName}\")`));\n      }\n\n      // Run npm install if package.json exists.\n      // --ignore-scripts prevents postinstall hooks from executing arbitrary code.\n      const packageJsonPath = path.join(tempDir, 'package.json');\n      if (fs.existsSync(packageJsonPath)) {\n        const npmProcess = spawn('npm', ['install', '--ignore-scripts'], {\n          cwd: tempDir,\n          stdio: ['ignore', 'pipe', 'pipe'],\n        });\n\n        npmProcess.on('close', (npmCode) => {\n          if (npmCode !== 0) {\n            cleanupTemp();\n            return reject(new Error(`npm install for ${repoName} failed (exit code ${npmCode})`));\n          }\n          runBuildIfNeeded(tempDir, packageJsonPath, () => finalize(manifest), (err) => { cleanupTemp(); reject(err); });\n        });\n\n        npmProcess.on('error', (err) => {\n          cleanupTemp();\n          reject(err);\n        });\n      } else {\n        finalize(manifest);\n      }\n    });\n\n    gitProcess.on('error', (err) => {\n      cleanupTemp();\n      reject(new Error(`Failed to spawn git: ${err.message}`));\n    });\n  });\n}\n\nexport function updatePluginFromGit(name) {\n  return new Promise((resolve, reject) => {\n    const pluginDir = getPluginDir(name);\n    if (!pluginDir) {\n      return reject(new Error(`Plugin \"${name}\" not found`));\n    }\n\n    // Only fast-forward to avoid silent divergence\n    const gitProcess = spawn('git', ['pull', '--ff-only', '--'], {\n      cwd: pluginDir,\n      stdio: ['ignore', 'pipe', 'pipe'],\n    });\n\n    let stderr = '';\n    gitProcess.stderr.on('data', (data) => { stderr += data.toString(); });\n\n    gitProcess.on('close', (code) => {\n      if (code !== 0) {\n        return reject(new Error(`git pull failed (exit code ${code}): ${stderr.trim()}`));\n      }\n\n      // Re-validate manifest after update\n      const manifestPath = path.join(pluginDir, 'manifest.json');\n      let manifest;\n      try {\n        manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));\n      } catch {\n        return reject(new Error('manifest.json is not valid JSON after update'));\n      }\n\n      const validation = validateManifest(manifest);\n      if (!validation.valid) {\n        return reject(new Error(`Invalid manifest after update: ${validation.error}`));\n      }\n\n      // Re-run npm install if package.json exists\n      const packageJsonPath = path.join(pluginDir, 'package.json');\n      if (fs.existsSync(packageJsonPath)) {\n        const npmProcess = spawn('npm', ['install', '--ignore-scripts'], {\n          cwd: pluginDir,\n          stdio: ['ignore', 'pipe', 'pipe'],\n        });\n        npmProcess.on('close', (npmCode) => {\n          if (npmCode !== 0) {\n            return reject(new Error(`npm install for ${name} failed (exit code ${npmCode})`));\n          }\n          runBuildIfNeeded(pluginDir, packageJsonPath, () => resolve(manifest), (err) => reject(err));\n        });\n        npmProcess.on('error', (err) => reject(err));\n      } else {\n        resolve(manifest);\n      }\n    });\n\n    gitProcess.on('error', (err) => {\n      reject(new Error(`Failed to spawn git: ${err.message}`));\n    });\n  });\n}\n\nexport async function uninstallPlugin(name) {\n  const pluginDir = getPluginDir(name);\n  if (!pluginDir) {\n    throw new Error(`Plugin \"${name}\" not found`);\n  }\n\n  // On Windows, file handles may be released slightly after process exit.\n  // Retry a few times with a short delay before giving up.\n  const MAX_RETRIES = 5;\n  const RETRY_DELAY_MS = 500;\n  for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {\n    try {\n      fs.rmSync(pluginDir, { recursive: true, force: true });\n      break;\n    } catch (err) {\n      if (err.code === 'EBUSY' && attempt < MAX_RETRIES) {\n        await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));\n      } else {\n        throw err;\n      }\n    }\n  }\n\n  // Remove from config\n  const config = getPluginsConfig();\n  delete config[name];\n  savePluginsConfig(config);\n}\n"
  },
  {
    "path": "server/utils/plugin-process-manager.js",
    "content": "import { spawn } from 'child_process';\nimport path from 'path';\nimport { scanPlugins, getPluginsConfig, getPluginDir } from './plugin-loader.js';\n\n// Map<pluginName, { process, port }>\nconst runningPlugins = new Map();\n// Map<pluginName, Promise<port>> — in-flight start operations\nconst startingPlugins = new Map();\n\n/**\n * Start a plugin's server subprocess.\n * The plugin's server entry must print a JSON line with { ready: true, port: <number> }\n * to stdout within 10 seconds.\n */\nexport function startPluginServer(name, pluginDir, serverEntry) {\n  if (runningPlugins.has(name)) {\n    return Promise.resolve(runningPlugins.get(name).port);\n  }\n\n  // Coalesce concurrent starts for the same plugin\n  if (startingPlugins.has(name)) {\n    return startingPlugins.get(name);\n  }\n\n  const startPromise = new Promise((resolve, reject) => {\n\n    const serverPath = path.join(pluginDir, serverEntry);\n\n    // Restricted env — only essentials, no host secrets\n    const pluginProcess = spawn('node', [serverPath], {\n      cwd: pluginDir,\n      env: {\n        PATH: process.env.PATH,\n        HOME: process.env.HOME,\n        NODE_ENV: process.env.NODE_ENV || 'production',\n        PLUGIN_NAME: name,\n      },\n      stdio: ['ignore', 'pipe', 'pipe'],\n    });\n\n    let resolved = false;\n    let stdout = '';\n\n    const timeout = setTimeout(() => {\n      if (!resolved) {\n        resolved = true;\n        pluginProcess.kill();\n        reject(new Error('Plugin server did not report ready within 10 seconds'));\n      }\n    }, 10000);\n\n    pluginProcess.stdout.on('data', (data) => {\n      if (resolved) return;\n      stdout += data.toString();\n\n      // Look for the JSON ready line\n      const lines = stdout.split('\\n');\n      for (const line of lines) {\n        try {\n          const msg = JSON.parse(line.trim());\n          if (msg.ready && typeof msg.port === 'number') {\n            clearTimeout(timeout);\n            resolved = true;\n            runningPlugins.set(name, { process: pluginProcess, port: msg.port });\n\n            pluginProcess.on('exit', () => {\n              runningPlugins.delete(name);\n            });\n\n            console.log(`[Plugins] Server started for \"${name}\" on port ${msg.port}`);\n            resolve(msg.port);\n          }\n        } catch {\n          // Not JSON yet, keep buffering\n        }\n      }\n    });\n\n    pluginProcess.stderr.on('data', (data) => {\n      console.warn(`[Plugin:${name}] ${data.toString().trim()}`);\n    });\n\n    pluginProcess.on('error', (err) => {\n      clearTimeout(timeout);\n      if (!resolved) {\n        resolved = true;\n        reject(new Error(`Failed to start plugin server: ${err.message}`));\n      }\n    });\n\n    pluginProcess.on('exit', (code) => {\n      clearTimeout(timeout);\n      runningPlugins.delete(name);\n      if (!resolved) {\n        resolved = true;\n        reject(new Error(`Plugin server exited with code ${code} before reporting ready`));\n      }\n    });\n  }).finally(() => {\n    startingPlugins.delete(name);\n  });\n\n  startingPlugins.set(name, startPromise);\n  return startPromise;\n}\n\n/**\n * Stop a plugin's server subprocess.\n * Returns a Promise that resolves when the process has fully exited.\n */\nexport function stopPluginServer(name) {\n  const entry = runningPlugins.get(name);\n  if (!entry) return Promise.resolve();\n\n  return new Promise((resolve) => {\n    const cleanup = () => {\n      clearTimeout(forceKillTimer);\n      runningPlugins.delete(name);\n      resolve();\n    };\n\n    entry.process.once('exit', cleanup);\n\n    entry.process.kill('SIGTERM');\n\n    // Force kill after 5 seconds if still running\n    const forceKillTimer = setTimeout(() => {\n      if (runningPlugins.has(name)) {\n        entry.process.kill('SIGKILL');\n        cleanup();\n      }\n    }, 5000);\n\n    console.log(`[Plugins] Server stopped for \"${name}\"`);\n  });\n}\n\n/**\n * Get the port a running plugin server is listening on.\n */\nexport function getPluginPort(name) {\n  return runningPlugins.get(name)?.port ?? null;\n}\n\n/**\n * Check if a plugin's server is running.\n */\nexport function isPluginRunning(name) {\n  return runningPlugins.has(name);\n}\n\n/**\n * Stop all running plugin servers (called on host shutdown).\n */\nexport function stopAllPlugins() {\n  const stops = [];\n  for (const [name] of runningPlugins) {\n    stops.push(stopPluginServer(name));\n  }\n  return Promise.all(stops);\n}\n\n/**\n * Start servers for all enabled plugins that have a server entry.\n * Called once on host server boot.\n */\nexport async function startEnabledPluginServers() {\n  const plugins = scanPlugins();\n  const config = getPluginsConfig();\n\n  for (const plugin of plugins) {\n    if (!plugin.server) continue;\n    if (config[plugin.name]?.enabled === false) continue;\n\n    const pluginDir = getPluginDir(plugin.name);\n    if (!pluginDir) continue;\n\n    try {\n      await startPluginServer(plugin.name, pluginDir, plugin.server);\n    } catch (err) {\n      console.error(`[Plugins] Failed to start server for \"${plugin.name}\":`, err.message);\n    }\n  }\n}\n"
  },
  {
    "path": "server/utils/taskmaster-websocket.js",
    "content": "/**\n * TASKMASTER WEBSOCKET UTILITIES\n * ==============================\n * \n * Utilities for broadcasting TaskMaster state changes via WebSocket.\n * Integrates with the existing WebSocket system to provide real-time updates.\n */\n\n/**\n * Broadcast TaskMaster project update to all connected clients\n * @param {WebSocket.Server} wss - WebSocket server instance\n * @param {string} projectName - Name of the updated project\n * @param {Object} taskMasterData - Updated TaskMaster data\n */\nexport function broadcastTaskMasterProjectUpdate(wss, projectName, taskMasterData) {\n    if (!wss || !projectName) {\n        console.warn('TaskMaster WebSocket broadcast: Missing wss or projectName');\n        return;\n    }\n\n    const message = {\n        type: 'taskmaster-project-updated',\n        projectName,\n        taskMasterData,\n        timestamp: new Date().toISOString()\n    };\n\n    \n    wss.clients.forEach((client) => {\n        if (client.readyState === 1) { // WebSocket.OPEN\n            try {\n                client.send(JSON.stringify(message));\n            } catch (error) {\n                console.error('Error sending TaskMaster project update:', error);\n            }\n        }\n    });\n}\n\n/**\n * Broadcast TaskMaster tasks update for a specific project\n * @param {WebSocket.Server} wss - WebSocket server instance  \n * @param {string} projectName - Name of the project with updated tasks\n * @param {Object} tasksData - Updated tasks data\n */\nexport function broadcastTaskMasterTasksUpdate(wss, projectName, tasksData) {\n    if (!wss || !projectName) {\n        console.warn('TaskMaster WebSocket broadcast: Missing wss or projectName');\n        return;\n    }\n\n    const message = {\n        type: 'taskmaster-tasks-updated',\n        projectName,\n        tasksData,\n        timestamp: new Date().toISOString()\n    };\n\n    \n    wss.clients.forEach((client) => {\n        if (client.readyState === 1) { // WebSocket.OPEN\n            try {\n                client.send(JSON.stringify(message));\n            } catch (error) {\n                console.error('Error sending TaskMaster tasks update:', error);\n            }\n        }\n    });\n}\n\n/**\n * Broadcast MCP server status change\n * @param {WebSocket.Server} wss - WebSocket server instance\n * @param {Object} mcpStatus - Updated MCP server status\n */\nexport function broadcastMCPStatusChange(wss, mcpStatus) {\n    if (!wss) {\n        console.warn('TaskMaster WebSocket broadcast: Missing wss');\n        return;\n    }\n\n    const message = {\n        type: 'taskmaster-mcp-status-changed',\n        mcpStatus,\n        timestamp: new Date().toISOString()\n    };\n\n    \n    wss.clients.forEach((client) => {\n        if (client.readyState === 1) { // WebSocket.OPEN\n            try {\n                client.send(JSON.stringify(message));\n            } catch (error) {\n                console.error('Error sending TaskMaster MCP status update:', error);\n            }\n        }\n    });\n}\n\n/**\n * Broadcast general TaskMaster update notification\n * @param {WebSocket.Server} wss - WebSocket server instance\n * @param {string} updateType - Type of update (e.g., 'initialization', 'configuration')\n * @param {Object} data - Additional data about the update\n */\nexport function broadcastTaskMasterUpdate(wss, updateType, data = {}) {\n    if (!wss || !updateType) {\n        console.warn('TaskMaster WebSocket broadcast: Missing wss or updateType');\n        return;\n    }\n\n    const message = {\n        type: 'taskmaster-update',\n        updateType,\n        data,\n        timestamp: new Date().toISOString()\n    };\n\n    \n    wss.clients.forEach((client) => {\n        if (client.readyState === 1) { // WebSocket.OPEN\n            try {\n                client.send(JSON.stringify(message));\n            } catch (error) {\n                console.error('Error sending TaskMaster update:', error);\n            }\n        }\n    });\n}"
  },
  {
    "path": "shared/modelConstants.js",
    "content": "/**\n * Centralized Model Definitions\n * Single source of truth for all supported AI models\n */\n\n/**\n * Claude (Anthropic) Models\n *\n * Note: Claude uses two different formats:\n * - SDK format ('sonnet', 'opus') - used by the UI and claude-sdk.js\n * - API format ('claude-sonnet-4.5') - used by slash commands for display\n */\nexport const CLAUDE_MODELS = {\n  // Models in SDK format (what the actual SDK accepts)\n  OPTIONS: [\n    { value: \"sonnet\", label: \"Sonnet\" },\n    { value: \"opus\", label: \"Opus\" },\n    { value: \"haiku\", label: \"Haiku\" },\n    { value: \"opusplan\", label: \"Opus Plan\" },\n    { value: \"sonnet[1m]\", label: \"Sonnet [1M]\" },\n  ],\n\n  DEFAULT: \"sonnet\",\n};\n\n/**\n * Cursor Models\n */\nexport const CURSOR_MODELS = {\n  OPTIONS: [\n    { value: \"opus-4.6-thinking\", label: \"Claude 4.6 Opus (Thinking)\" },\n    { value: \"gpt-5.3-codex\", label: \"GPT-5.3\" },\n    { value: \"gpt-5.2-high\", label: \"GPT-5.2 High\" },\n    { value: \"gemini-3-pro\", label: \"Gemini 3 Pro\" },\n    { value: \"opus-4.5-thinking\", label: \"Claude 4.5 Opus (Thinking)\" },\n    { value: \"gpt-5.2\", label: \"GPT-5.2\" },\n    { value: \"gpt-5.1\", label: \"GPT-5.1\" },\n    { value: \"gpt-5.1-high\", label: \"GPT-5.1 High\" },\n    { value: \"composer-1\", label: \"Composer 1\" },\n    { value: \"auto\", label: \"Auto\" },\n    { value: \"sonnet-4.5\", label: \"Claude 4.5 Sonnet\" },\n    { value: \"sonnet-4.5-thinking\", label: \"Claude 4.5 Sonnet (Thinking)\" },\n    { value: \"opus-4.5\", label: \"Claude 4.5 Opus\" },\n    { value: \"gpt-5.1-codex\", label: \"GPT-5.1 Codex\" },\n    { value: \"gpt-5.1-codex-high\", label: \"GPT-5.1 Codex High\" },\n    { value: \"gpt-5.1-codex-max\", label: \"GPT-5.1 Codex Max\" },\n    { value: \"gpt-5.1-codex-max-high\", label: \"GPT-5.1 Codex Max High\" },\n    { value: \"opus-4.1\", label: \"Claude 4.1 Opus\" },\n    { value: \"grok\", label: \"Grok\" },\n  ],\n\n  DEFAULT: \"gpt-5-3-codex\",\n};\n\n/**\n * Codex (OpenAI) Models\n */\nexport const CODEX_MODELS = {\n  OPTIONS: [\n    { value: \"gpt-5.4\", label: \"GPT-5.4\" },\n    { value: \"gpt-5.3-codex\", label: \"GPT-5.3 Codex\" },\n    { value: \"gpt-5.2-codex\", label: \"GPT-5.2 Codex\" },\n    { value: \"gpt-5.2\", label: \"GPT-5.2\" },\n    { value: \"gpt-5.1-codex-max\", label: \"GPT-5.1 Codex Max\" },\n    { value: \"o3\", label: \"O3\" },\n    { value: \"o4-mini\", label: \"O4-mini\" },\n  ],\n\n  DEFAULT: \"gpt-5.4\",\n};\n\n/**\n * Gemini Models\n */\nexport const GEMINI_MODELS = {\n  OPTIONS: [\n    { value: \"gemini-3.1-pro-preview\", label: \"Gemini 3.1 Pro Preview\" },\n    { value: \"gemini-3-pro-preview\", label: \"Gemini 3 Pro Preview\" },\n    { value: \"gemini-3-flash-preview\", label: \"Gemini 3 Flash Preview\" },\n    { value: \"gemini-2.5-flash\", label: \"Gemini 2.5 Flash\" },\n    { value: \"gemini-2.5-pro\", label: \"Gemini 2.5 Pro\" },\n    { value: \"gemini-2.0-flash-lite\", label: \"Gemini 2.0 Flash Lite\" },\n    { value: \"gemini-2.0-flash\", label: \"Gemini 2.0 Flash\" },\n    { value: \"gemini-2.0-pro-exp\", label: \"Gemini 2.0 Pro Experimental\" },\n    {\n      value: \"gemini-2.0-flash-thinking-exp\",\n      label: \"Gemini 2.0 Flash Thinking\",\n    },\n  ],\n\n  DEFAULT: \"gemini-2.5-flash\",\n};\n"
  },
  {
    "path": "shared/networkHosts.js",
    "content": "export function isWildcardHost(host) {\n  return host === '0.0.0.0' || host === '::';\n}\n\nexport function isLoopbackHost(host) {\n  return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';\n}\n\nexport function normalizeLoopbackHost(host) {\n  if (!host) {\n    return host;\n  }\n  return isLoopbackHost(host) ? 'localhost' : host;\n}\n\n// Use localhost for connectable loopback and wildcard addresses in browser-facing URLs.\nexport function getConnectableHost(host) {\n  if (!host) {\n    return 'localhost';\n  }\n  return isWildcardHost(host) || isLoopbackHost(host) ? 'localhost' : host;\n}\n"
  },
  {
    "path": "src/App.tsx",
    "content": "import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';\nimport { I18nextProvider } from 'react-i18next';\nimport { ThemeProvider } from './contexts/ThemeContext';\nimport { AuthProvider, ProtectedRoute } from './components/auth';\nimport { TaskMasterProvider } from './contexts/TaskMasterContext';\nimport { TasksSettingsProvider } from './contexts/TasksSettingsContext';\nimport { WebSocketProvider } from './contexts/WebSocketContext';\nimport { PluginsProvider } from './contexts/PluginsContext';\nimport AppContent from './components/app/AppContent';\nimport i18n from './i18n/config.js';\n\nexport default function App() {\n  return (\n    <I18nextProvider i18n={i18n}>\n      <ThemeProvider>\n        <AuthProvider>\n          <WebSocketProvider>\n            <PluginsProvider>\n              <TasksSettingsProvider>\n                <TaskMasterProvider>\n                <ProtectedRoute>\n                  <Router basename={window.__ROUTER_BASENAME__ || ''}>\n                    <Routes>\n                      <Route path=\"/\" element={<AppContent />} />\n                      <Route path=\"/session/:sessionId\" element={<AppContent />} />\n                    </Routes>\n                  </Router>\n                </ProtectedRoute>\n                </TaskMasterProvider>\n              </TasksSettingsProvider>\n            </PluginsProvider>\n          </WebSocketProvider>\n        </AuthProvider>\n      </ThemeProvider>\n    </I18nextProvider>\n  );\n}\n"
  },
  {
    "path": "src/components/app/AppContent.tsx",
    "content": "import { useEffect, useRef } from 'react';\nimport { useNavigate, useParams } from 'react-router-dom';\nimport { useTranslation } from 'react-i18next';\nimport Sidebar from '../sidebar/view/Sidebar';\nimport MainContent from '../main-content/view/MainContent';\nimport { useWebSocket } from '../../contexts/WebSocketContext';\nimport { useDeviceSettings } from '../../hooks/useDeviceSettings';\nimport { useSessionProtection } from '../../hooks/useSessionProtection';\nimport { useProjectsState } from '../../hooks/useProjectsState';\nimport MobileNav from './MobileNav';\n\nexport default function AppContent() {\n  const navigate = useNavigate();\n  const { sessionId } = useParams<{ sessionId?: string }>();\n  const { t } = useTranslation('common');\n  const { isMobile } = useDeviceSettings({ trackPWA: false });\n  const { ws, sendMessage, latestMessage, isConnected } = useWebSocket();\n  const wasConnectedRef = useRef(false);\n\n  const {\n    activeSessions,\n    processingSessions,\n    markSessionAsActive,\n    markSessionAsInactive,\n    markSessionAsProcessing,\n    markSessionAsNotProcessing,\n    replaceTemporarySession,\n  } = useSessionProtection();\n\n  const {\n    selectedProject,\n    selectedSession,\n    activeTab,\n    sidebarOpen,\n    isLoadingProjects,\n    isInputFocused,\n    externalMessageUpdate,\n    setActiveTab,\n    setSidebarOpen,\n    setIsInputFocused,\n    setShowSettings,\n    openSettings,\n    refreshProjectsSilently,\n    sidebarSharedProps,\n  } = useProjectsState({\n    sessionId,\n    navigate,\n    latestMessage,\n    isMobile,\n    activeSessions,\n  });\n\n  useEffect(() => {\n    // Expose a non-blocking refresh for chat/session flows.\n    // Full loading refreshes are still available through direct fetchProjects calls.\n    window.refreshProjects = refreshProjectsSilently;\n\n    return () => {\n      if (window.refreshProjects === refreshProjectsSilently) {\n        delete window.refreshProjects;\n      }\n    };\n  }, [refreshProjectsSilently]);\n\n  useEffect(() => {\n    window.openSettings = openSettings;\n\n    return () => {\n      if (window.openSettings === openSettings) {\n        delete window.openSettings;\n      }\n    };\n  }, [openSettings]);\n\n  useEffect(() => {\n    if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {\n      return undefined;\n    }\n\n    const handleServiceWorkerMessage = (event: MessageEvent) => {\n      const message = event.data;\n      if (!message || message.type !== 'notification:navigate') {\n        return;\n      }\n\n      if (typeof message.provider === 'string' && message.provider.trim()) {\n        localStorage.setItem('selected-provider', message.provider);\n      }\n\n      setActiveTab('chat');\n      setSidebarOpen(false);\n      void refreshProjectsSilently();\n\n      if (typeof message.sessionId === 'string' && message.sessionId) {\n        navigate(`/session/${message.sessionId}`);\n        return;\n      }\n\n      navigate('/');\n    };\n\n    navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage);\n\n    return () => {\n      navigator.serviceWorker.removeEventListener('message', handleServiceWorkerMessage);\n    };\n  }, [navigate, refreshProjectsSilently, setActiveTab, setSidebarOpen]);\n\n  // Permission recovery: query pending permissions on WebSocket reconnect or session change\n  useEffect(() => {\n    const isReconnect = isConnected && !wasConnectedRef.current;\n\n    if (isReconnect) {\n      wasConnectedRef.current = true;\n    } else if (!isConnected) {\n      wasConnectedRef.current = false;\n    }\n\n    if (isConnected && selectedSession?.id) {\n      sendMessage({\n        type: 'get-pending-permissions',\n        sessionId: selectedSession.id\n      });\n    }\n  }, [isConnected, selectedSession?.id, sendMessage]);\n\n  return (\n    <div className=\"fixed inset-0 flex bg-background\">\n      {!isMobile ? (\n        <div className=\"h-full flex-shrink-0 border-r border-border/50\">\n          <Sidebar {...sidebarSharedProps} />\n        </div>\n      ) : (\n        <div\n          className={`fixed inset-0 z-50 flex transition-all duration-150 ease-out ${sidebarOpen ? 'visible opacity-100' : 'invisible opacity-0'\n            }`}\n        >\n          <button\n            className=\"fixed inset-0 bg-background/60 backdrop-blur-sm transition-opacity duration-150 ease-out\"\n            onClick={(event) => {\n              event.stopPropagation();\n              setSidebarOpen(false);\n            }}\n            onTouchStart={(event) => {\n              event.preventDefault();\n              event.stopPropagation();\n              setSidebarOpen(false);\n            }}\n            aria-label={t('versionUpdate.ariaLabels.closeSidebar')}\n          />\n          <div\n            className={`relative h-full w-[85vw] max-w-sm transform border-r border-border/40 bg-card transition-transform duration-150 ease-out sm:w-80 ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'\n              }`}\n            onClick={(event) => event.stopPropagation()}\n            onTouchStart={(event) => event.stopPropagation()}\n          >\n            <Sidebar {...sidebarSharedProps} />\n          </div>\n        </div>\n      )}\n\n      <div className={`flex min-w-0 flex-1 flex-col ${isMobile ? 'pb-mobile-nav' : ''}`}>\n        <MainContent\n          selectedProject={selectedProject}\n          selectedSession={selectedSession}\n          activeTab={activeTab}\n          setActiveTab={setActiveTab}\n          ws={ws}\n          sendMessage={sendMessage}\n          latestMessage={latestMessage}\n          isMobile={isMobile}\n          onMenuClick={() => setSidebarOpen(true)}\n          isLoading={isLoadingProjects}\n          onInputFocusChange={setIsInputFocused}\n          onSessionActive={markSessionAsActive}\n          onSessionInactive={markSessionAsInactive}\n          onSessionProcessing={markSessionAsProcessing}\n          onSessionNotProcessing={markSessionAsNotProcessing}\n          processingSessions={processingSessions}\n          onReplaceTemporarySession={replaceTemporarySession}\n          onNavigateToSession={(targetSessionId: string) => navigate(`/session/${targetSessionId}`)}\n          onShowSettings={() => setShowSettings(true)}\n          externalMessageUpdate={externalMessageUpdate}\n        />\n      </div>\n\n      {isMobile && (\n        <MobileNav\n          activeTab={activeTab}\n          setActiveTab={setActiveTab}\n          isInputFocused={isInputFocused}\n        />\n      )}\n\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/app/MobileNav.tsx",
    "content": "import { useState, useRef, useEffect, type Dispatch, type SetStateAction } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport {\n  MessageSquare,\n  Folder,\n  Terminal,\n  GitBranch,\n  ClipboardCheck,\n  Ellipsis,\n  Puzzle,\n  Box,\n  Database,\n  Globe,\n  Wrench,\n  Zap,\n  BarChart3,\n  type LucideIcon,\n} from 'lucide-react';\nimport { useTasksSettings } from '../../contexts/TasksSettingsContext';\nimport { usePlugins } from '../../contexts/PluginsContext';\nimport { AppTab } from '../../types/app';\n\nconst PLUGIN_ICON_MAP: Record<string, LucideIcon> = {\n  Puzzle, Box, Database, Globe, Terminal, Wrench, Zap, BarChart3, Folder, MessageSquare, GitBranch,\n};\n\ntype CoreTabId = Exclude<AppTab, `plugin:${string}` | 'preview'>;\ntype CoreNavItem = {\n  id: CoreTabId;\n  icon: LucideIcon;\n  label: string;\n};\n\ntype MobileNavProps = {\n  activeTab: AppTab;\n  setActiveTab: Dispatch<SetStateAction<AppTab>>;\n  isInputFocused: boolean;\n};\n\nexport default function MobileNav({ activeTab, setActiveTab, isInputFocused }: MobileNavProps) {\n  const { t } = useTranslation(['common', 'settings']);\n  const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();\n  const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled);\n  const { plugins } = usePlugins();\n  const [moreOpen, setMoreOpen] = useState(false);\n  const moreRef = useRef<HTMLDivElement | null>(null);\n\n  const enabledPlugins = plugins.filter((p) => p.enabled);\n  const hasPlugins = enabledPlugins.length > 0;\n  const isPluginActive = activeTab.startsWith('plugin:');\n\n  // Close the menu on outside tap\n  useEffect(() => {\n    if (!moreOpen) return;\n    const handleTap = (e: PointerEvent) => {\n      const target = e.target;\n      if (moreRef.current && target instanceof Node && !moreRef.current.contains(target)) {\n        setMoreOpen(false);\n      }\n    };\n    document.addEventListener('pointerdown', handleTap);\n    return () => document.removeEventListener('pointerdown', handleTap);\n  }, [moreOpen]);\n\n  // Close menu when a plugin tab is selected\n  const selectPlugin = (name: string) => {\n    const pluginTab = `plugin:${name}` as AppTab;\n    setActiveTab(pluginTab);\n    setMoreOpen(false);\n  };\n\n  const baseCoreItems: CoreNavItem[] = [\n    { id: 'chat', icon: MessageSquare, label: 'Chat' },\n    { id: 'shell', icon: Terminal, label: 'Shell' },\n    { id: 'files', icon: Folder, label: 'Files' },\n    { id: 'git', icon: GitBranch, label: 'Git' },\n  ];\n  const coreItems: CoreNavItem[] = shouldShowTasksTab\n    ? [...baseCoreItems, { id: 'tasks', icon: ClipboardCheck, label: 'Tasks' }]\n    : baseCoreItems;\n\n  return (\n    <div\n      className={`fixed bottom-0 left-0 right-0 z-50 transform px-3 pb-[max(8px,env(safe-area-inset-bottom))] transition-transform duration-300 ease-in-out ${isInputFocused ? 'translate-y-full' : 'translate-y-0'\n        }`}\n    >\n      <div className=\"nav-glass mobile-nav-float rounded-2xl border border-border/30\">\n        <div className=\"flex items-center justify-around gap-0.5 px-1 py-1.5\">\n          {coreItems.map((item) => {\n            const Icon = item.icon;\n            const isActive = activeTab === item.id;\n\n            return (\n              <button\n                key={item.id}\n                onClick={() => setActiveTab(item.id)}\n                onTouchStart={(e) => {\n                  e.preventDefault();\n                  setActiveTab(item.id);\n                }}\n                className={`relative flex flex-1 touch-manipulation flex-col items-center justify-center gap-0.5 rounded-xl px-3 py-2 transition-all duration-200 active:scale-95 ${isActive\n                  ? 'text-primary'\n                  : 'text-muted-foreground hover:text-foreground'\n                  }`}\n                aria-label={item.label}\n                aria-current={isActive ? 'page' : undefined}\n              >\n                {isActive && (\n                  <div className=\"bg-primary/8 dark:bg-primary/12 absolute inset-0 rounded-xl\" />\n                )}\n                <Icon\n                  className={`relative z-10 transition-all duration-200 ${isActive ? 'h-5 w-5' : 'h-[18px] w-[18px]'}`}\n                  strokeWidth={isActive ? 2.4 : 1.8}\n                />\n                <span className={`relative z-10 text-[10px] font-medium transition-all duration-200 ${isActive ? 'opacity-100' : 'opacity-60'}`}>\n                  {item.label}\n                </span>\n              </button>\n            );\n          })}\n\n          {/* \"More\" button — only shown when there are enabled plugins */}\n          {hasPlugins && (\n            <div ref={moreRef} className=\"relative flex-1\">\n              <button\n                onClick={() => setMoreOpen((v) => !v)}\n                onTouchStart={(e) => {\n                  e.preventDefault();\n                  setMoreOpen((v) => !v);\n                }}\n                className={`relative flex w-full touch-manipulation flex-col items-center justify-center gap-0.5 rounded-xl px-3 py-2 transition-all duration-200 active:scale-95 ${isPluginActive || moreOpen\n                    ? 'text-primary'\n                    : 'text-muted-foreground hover:text-foreground'\n                  }`}\n                aria-label=\"More plugins\"\n                aria-expanded={moreOpen}\n              >\n                {(isPluginActive && !moreOpen) && (\n                  <div className=\"bg-primary/8 dark:bg-primary/12 absolute inset-0 rounded-xl\" />\n                )}\n                <Ellipsis\n                  className={`relative z-10 transition-all duration-200 ${isPluginActive ? 'h-5 w-5' : 'h-[18px] w-[18px]'}`}\n                  strokeWidth={isPluginActive ? 2.4 : 1.8}\n                />\n                <span className={`relative z-10 text-[10px] font-medium transition-all duration-200 ${isPluginActive || moreOpen ? 'opacity-100' : 'opacity-60'}`}>\n                  {t('settings:pluginSettings.morePlugins')}\n                </span>\n              </button>\n\n              {/* Popover menu */}\n              {moreOpen && (\n                <div className=\"animate-in fade-in slide-in-from-bottom-2 absolute bottom-full right-0 z-[60] mb-2 min-w-[180px] rounded-xl border border-border/40 bg-popover py-1.5 shadow-lg duration-150\">\n                  {enabledPlugins.map((p) => {\n                    const Icon = PLUGIN_ICON_MAP[p.icon] || Puzzle;\n                    const isActive = activeTab === `plugin:${p.name}`;\n\n                    return (\n                      <button\n                        key={p.name}\n                        onClick={() => selectPlugin(p.name)}\n                        className={`flex w-full items-center gap-2.5 px-3.5 py-2.5 text-sm transition-colors ${isActive\n                            ? 'bg-primary/8 text-primary'\n                            : 'text-foreground hover:bg-muted/60'\n                          }`}\n                      >\n                        <Icon className=\"h-4 w-4 flex-shrink-0\" strokeWidth={isActive ? 2.2 : 1.8} />\n                        <span className=\"truncate\">{p.displayName}</span>\n                      </button>\n                    );\n                  })}\n                </div>\n              )}\n            </div>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/auth/constants.ts",
    "content": "export const AUTH_TOKEN_STORAGE_KEY = 'auth-token';\n\nexport const AUTH_ERROR_MESSAGES = {\n  authStatusCheckFailed: 'Failed to check authentication status',\n  loginFailed: 'Login failed',\n  registrationFailed: 'Registration failed',\n  networkError: 'Network error. Please try again.',\n} as const;\n"
  },
  {
    "path": "src/components/auth/context/AuthContext.tsx",
    "content": "import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';\nimport { IS_PLATFORM } from '../../../constants/config';\nimport { api } from '../../../utils/api';\nimport { AUTH_ERROR_MESSAGES, AUTH_TOKEN_STORAGE_KEY } from '../constants';\nimport type {\n  AuthContextValue,\n  AuthProviderProps,\n  AuthSessionPayload,\n  AuthStatusPayload,\n  AuthUser,\n  AuthUserPayload,\n  OnboardingStatusPayload,\n} from '../types';\nimport { parseJsonSafely, resolveApiErrorMessage } from '../utils';\n\nconst AuthContext = createContext<AuthContextValue | null>(null);\n\nconst readStoredToken = (): string | null => localStorage.getItem(AUTH_TOKEN_STORAGE_KEY);\n\nconst persistToken = (token: string) => {\n  localStorage.setItem(AUTH_TOKEN_STORAGE_KEY, token);\n};\n\nconst clearStoredToken = () => {\n  localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY);\n};\n\nexport function useAuth(): AuthContextValue {\n  const context = useContext(AuthContext);\n  if (!context) {\n    throw new Error('useAuth must be used within an AuthProvider');\n  }\n\n  return context;\n}\n\nexport function AuthProvider({ children }: AuthProviderProps) {\n  const [user, setUser] = useState<AuthUser | null>(null);\n  const [token, setToken] = useState<string | null>(() => readStoredToken());\n  const [isLoading, setIsLoading] = useState(true);\n  const [needsSetup, setNeedsSetup] = useState(false);\n  const [hasCompletedOnboarding, setHasCompletedOnboarding] = useState(true);\n  const [error, setError] = useState<string | null>(null);\n\n  const setSession = useCallback((nextUser: AuthUser, nextToken: string) => {\n    setUser(nextUser);\n    setToken(nextToken);\n    persistToken(nextToken);\n  }, []);\n\n  const clearSession = useCallback(() => {\n    setUser(null);\n    setToken(null);\n    clearStoredToken();\n  }, []);\n\n  const checkOnboardingStatus = useCallback(async () => {\n    try {\n      const response = await api.user.onboardingStatus();\n      if (!response.ok) {\n        return;\n      }\n\n      const payload = await parseJsonSafely<OnboardingStatusPayload>(response);\n      setHasCompletedOnboarding(Boolean(payload?.hasCompletedOnboarding));\n    } catch (caughtError) {\n      console.error('Error checking onboarding status:', caughtError);\n      // Fail open to avoid blocking access on transient onboarding status errors.\n      setHasCompletedOnboarding(true);\n    }\n  }, []);\n\n  const refreshOnboardingStatus = useCallback(async () => {\n    await checkOnboardingStatus();\n  }, [checkOnboardingStatus]);\n\n  const checkAuthStatus = useCallback(async () => {\n    try {\n      setIsLoading(true);\n      setError(null);\n\n      const statusResponse = await api.auth.status();\n      const statusPayload = await parseJsonSafely<AuthStatusPayload>(statusResponse);\n\n      if (statusPayload?.needsSetup) {\n        setNeedsSetup(true);\n        return;\n      }\n\n      setNeedsSetup(false);\n\n      if (!token) {\n        return;\n      }\n\n      const userResponse = await api.auth.user();\n      if (!userResponse.ok) {\n        clearSession();\n        return;\n      }\n\n      const userPayload = await parseJsonSafely<AuthUserPayload>(userResponse);\n      if (!userPayload?.user) {\n        clearSession();\n        return;\n      }\n\n      setUser(userPayload.user);\n      await checkOnboardingStatus();\n    } catch (caughtError) {\n      console.error('[Auth] Auth status check failed:', caughtError);\n      setError(AUTH_ERROR_MESSAGES.authStatusCheckFailed);\n    } finally {\n      setIsLoading(false);\n    }\n  }, [checkOnboardingStatus, clearSession, token]);\n\n  useEffect(() => {\n    if (IS_PLATFORM) {\n      setUser({ username: 'platform-user' });\n      setNeedsSetup(false);\n      void checkOnboardingStatus().finally(() => {\n        setIsLoading(false);\n      });\n      return;\n    }\n\n    void checkAuthStatus();\n  }, [checkAuthStatus, checkOnboardingStatus]);\n\n  const login = useCallback<AuthContextValue['login']>(\n    async (username, password) => {\n      try {\n        setError(null);\n        const response = await api.auth.login(username, password);\n        const payload = await parseJsonSafely<AuthSessionPayload>(response);\n\n        if (!response.ok || !payload?.token || !payload.user) {\n          const message = resolveApiErrorMessage(payload, AUTH_ERROR_MESSAGES.loginFailed);\n          setError(message);\n          return { success: false, error: message };\n        }\n\n        setSession(payload.user, payload.token);\n        setNeedsSetup(false);\n        await checkOnboardingStatus();\n        return { success: true };\n      } catch (caughtError) {\n        console.error('Login error:', caughtError);\n        setError(AUTH_ERROR_MESSAGES.networkError);\n        return { success: false, error: AUTH_ERROR_MESSAGES.networkError };\n      }\n    },\n    [checkOnboardingStatus, setSession],\n  );\n\n  const register = useCallback<AuthContextValue['register']>(\n    async (username, password) => {\n      try {\n        setError(null);\n        const response = await api.auth.register(username, password);\n        const payload = await parseJsonSafely<AuthSessionPayload>(response);\n\n        if (!response.ok || !payload?.token || !payload.user) {\n          const message = resolveApiErrorMessage(payload, AUTH_ERROR_MESSAGES.registrationFailed);\n          setError(message);\n          return { success: false, error: message };\n        }\n\n        setSession(payload.user, payload.token);\n        setNeedsSetup(false);\n        await checkOnboardingStatus();\n        return { success: true };\n      } catch (caughtError) {\n        console.error('Registration error:', caughtError);\n        setError(AUTH_ERROR_MESSAGES.networkError);\n        return { success: false, error: AUTH_ERROR_MESSAGES.networkError };\n      }\n    },\n    [checkOnboardingStatus, setSession],\n  );\n\n  const logout = useCallback(() => {\n    const tokenToInvalidate = token;\n    clearSession();\n\n    if (tokenToInvalidate) {\n      void api.auth.logout().catch((caughtError: unknown) => {\n        console.error('Logout endpoint error:', caughtError);\n      });\n    }\n  }, [clearSession, token]);\n\n  const contextValue = useMemo<AuthContextValue>(\n    () => ({\n      user,\n      token,\n      isLoading,\n      needsSetup,\n      hasCompletedOnboarding,\n      error,\n      login,\n      register,\n      logout,\n      refreshOnboardingStatus,\n    }),\n    [\n      error,\n      hasCompletedOnboarding,\n      isLoading,\n      login,\n      logout,\n      needsSetup,\n      refreshOnboardingStatus,\n      register,\n      token,\n      user,\n    ],\n  );\n\n  return <AuthContext.Provider value={contextValue}>{children}</AuthContext.Provider>;\n}\n"
  },
  {
    "path": "src/components/auth/index.ts",
    "content": "export { AuthProvider, useAuth } from './context/AuthContext';\nexport { default as ProtectedRoute } from './view/ProtectedRoute';\n"
  },
  {
    "path": "src/components/auth/types.ts",
    "content": "import type { ReactNode } from 'react';\n\nexport type AuthUser = {\n  id?: number | string;\n  username: string;\n  [key: string]: unknown;\n};\n\nexport type AuthActionResult = { success: true } | { success: false; error: string };\n\nexport type AuthSessionPayload = {\n  token?: string;\n  user?: AuthUser;\n  error?: string;\n  message?: string;\n};\n\nexport type AuthStatusPayload = {\n  needsSetup?: boolean;\n};\n\nexport type AuthUserPayload = {\n  user?: AuthUser;\n};\n\nexport type OnboardingStatusPayload = {\n  hasCompletedOnboarding?: boolean;\n};\n\nexport type ApiErrorPayload = {\n  error?: string;\n  message?: string;\n};\n\nexport type AuthContextValue = {\n  user: AuthUser | null;\n  token: string | null;\n  isLoading: boolean;\n  needsSetup: boolean;\n  hasCompletedOnboarding: boolean;\n  error: string | null;\n  login: (username: string, password: string) => Promise<AuthActionResult>;\n  register: (username: string, password: string) => Promise<AuthActionResult>;\n  logout: () => void;\n  refreshOnboardingStatus: () => Promise<void>;\n};\n\nexport type AuthProviderProps = {\n  children: ReactNode;\n};\n"
  },
  {
    "path": "src/components/auth/utils.ts",
    "content": "import type { ApiErrorPayload } from './types';\n\nexport async function parseJsonSafely<T>(response: Response): Promise<T | null> {\n  try {\n    return (await response.json()) as T;\n  } catch {\n    return null;\n  }\n}\n\nexport function resolveApiErrorMessage(payload: ApiErrorPayload | null, fallback: string): string {\n  if (!payload) {\n    return fallback;\n  }\n\n  return payload.error ?? payload.message ?? fallback;\n}\n"
  },
  {
    "path": "src/components/auth/view/AuthErrorAlert.tsx",
    "content": "type AuthErrorAlertProps = {\n  errorMessage: string;\n};\n\nexport default function AuthErrorAlert({ errorMessage }: AuthErrorAlertProps) {\n  if (!errorMessage) {\n    return null;\n  }\n\n  return (\n    <div className=\"rounded-md border border-red-300 bg-red-100 p-3 dark:border-red-800 dark:bg-red-900/20\">\n      <p className=\"text-sm text-red-700 dark:text-red-400\">{errorMessage}</p>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/auth/view/AuthInputField.tsx",
    "content": "type AuthInputFieldProps = {\n  id: string;\n  label: string;\n  value: string;\n  onChange: (nextValue: string) => void;\n  placeholder: string;\n  isDisabled: boolean;\n  type?: 'text' | 'password' | 'email';\n  name?: string;\n  autoComplete?: string;\n};\n\n/**\n * A labelled input field for authentication forms.\n * Renders a `<label>` / `<input>` pair and forwards browser autofill hints\n * (`name`, `autoComplete`) so that password managers can identify and fill\n * the field correctly.\n */\nexport default function AuthInputField({\n  id,\n  label,\n  value,\n  onChange,\n  placeholder,\n  isDisabled,\n  type = 'text',\n  name,\n  autoComplete,\n}: AuthInputFieldProps) {\n  return (\n    <div>\n      <label htmlFor={id} className=\"mb-1 block text-sm font-medium text-foreground\">\n        {label}\n      </label>\n      <input\n        id={id}\n        type={type}\n        name={name ?? id}\n        autoComplete={autoComplete}\n        value={value}\n        onChange={(event) => onChange(event.target.value)}\n        className=\"w-full rounded-md border border-border bg-background px-3 py-2 text-foreground focus:border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500\"\n        placeholder={placeholder}\n        required\n        disabled={isDisabled}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/auth/view/AuthLoadingScreen.tsx",
    "content": "import { MessageSquare } from 'lucide-react';\n\nconst loadingDotAnimationDelays = ['0s', '0.1s', '0.2s'];\n\nexport default function AuthLoadingScreen() {\n  return (\n    <div className=\"flex min-h-screen items-center justify-center bg-background p-4\">\n      <div className=\"text-center\">\n        <div className=\"mb-4 flex justify-center\">\n          <div className=\"flex h-16 w-16 items-center justify-center rounded-lg bg-primary shadow-sm\">\n            <MessageSquare className=\"h-8 w-8 text-primary-foreground\" />\n          </div>\n        </div>\n\n        <h1 className=\"mb-2 text-2xl font-bold text-foreground\">Claude Code UI</h1>\n\n        <div className=\"flex items-center justify-center space-x-2\">\n          {loadingDotAnimationDelays.map((delay) => (\n            <div\n              key={delay}\n              className=\"h-2 w-2 animate-bounce rounded-full bg-blue-500\"\n              style={{ animationDelay: delay }}\n            />\n          ))}\n        </div>\n\n        <p className=\"mt-2 text-muted-foreground\">Loading...</p>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/auth/view/AuthScreenLayout.tsx",
    "content": "import type { ReactNode } from 'react';\nimport { MessageSquare } from 'lucide-react';\n\ntype AuthScreenLayoutProps = {\n  title: string;\n  description: string;\n  children: ReactNode;\n  footerText: string;\n  logo?: ReactNode;\n};\n\nexport default function AuthScreenLayout({\n  title,\n  description,\n  children,\n  footerText,\n  logo,\n}: AuthScreenLayoutProps) {\n  return (\n    <div className=\"flex min-h-screen items-center justify-center bg-background p-4\">\n      <div className=\"w-full max-w-md\">\n        <div className=\"space-y-6 rounded-lg border border-border bg-card p-8 shadow-lg\">\n          <div className=\"text-center\">\n            <div className=\"mb-4 flex justify-center\">\n              {logo ?? (\n                <div className=\"flex h-16 w-16 items-center justify-center rounded-lg bg-primary shadow-sm\">\n                  <MessageSquare className=\"h-8 w-8 text-primary-foreground\" />\n                </div>\n              )}\n            </div>\n            <h1 className=\"text-2xl font-bold text-foreground\">{title}</h1>\n            <p className=\"mt-2 text-muted-foreground\">{description}</p>\n          </div>\n\n          {children}\n\n          <div className=\"text-center\">\n            <p className=\"text-sm text-muted-foreground\">{footerText}</p>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/auth/view/LoginForm.tsx",
    "content": "import { useCallback, useState } from 'react';\nimport type { FormEvent } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useAuth } from '../context/AuthContext';\nimport AuthErrorAlert from './AuthErrorAlert';\nimport AuthInputField from './AuthInputField';\nimport AuthScreenLayout from './AuthScreenLayout';\n\ntype LoginFormState = {\n  username: string;\n  password: string;\n};\n\nconst initialState: LoginFormState = {\n  username: '',\n  password: '',\n};\n\n/**\n * Login form component.\n * Handles credential input with browser autofill support (`autocomplete`\n * attributes) so that password managers can offer to fill saved credentials.\n */\nexport default function LoginForm() {\n  const { t } = useTranslation('auth');\n  const { login } = useAuth();\n\n  const [formState, setFormState] = useState<LoginFormState>(initialState);\n  const [errorMessage, setErrorMessage] = useState('');\n  const [isSubmitting, setIsSubmitting] = useState(false);\n\n  const updateField = useCallback((field: keyof LoginFormState, value: string) => {\n    setFormState((previous) => ({ ...previous, [field]: value }));\n  }, []);\n\n  const handleSubmit = useCallback(\n    async (event: FormEvent<HTMLFormElement>) => {\n      event.preventDefault();\n      setErrorMessage('');\n\n      // Keep form validation local so each auth screen owns its own UI feedback.\n      if (!formState.username.trim() || !formState.password) {\n        setErrorMessage(t('login.errors.requiredFields'));\n        return;\n      }\n\n      setIsSubmitting(true);\n      const result = await login(formState.username.trim(), formState.password);\n      if (!result.success) {\n        setErrorMessage(result.error);\n      }\n      setIsSubmitting(false);\n    },\n    [formState.password, formState.username, login, t],\n  );\n\n  return (\n    <AuthScreenLayout\n      title={t('login.title')}\n      description={t('login.description')}\n      footerText=\"Enter your credentials to access Claude Code UI\"\n    >\n      <form onSubmit={handleSubmit} className=\"space-y-4\">\n        <AuthInputField\n          id=\"username\"\n          label={t('login.username')}\n          value={formState.username}\n          onChange={(value) => updateField('username', value)}\n          placeholder={t('login.placeholders.username')}\n          isDisabled={isSubmitting}\n          autoComplete=\"username\"\n        />\n\n        <AuthInputField\n          id=\"password\"\n          label={t('login.password')}\n          value={formState.password}\n          onChange={(value) => updateField('password', value)}\n          placeholder={t('login.placeholders.password')}\n          isDisabled={isSubmitting}\n          type=\"password\"\n          autoComplete=\"current-password\"\n        />\n\n        <AuthErrorAlert errorMessage={errorMessage} />\n\n        <button\n          type=\"submit\"\n          disabled={isSubmitting}\n          className=\"w-full rounded-md bg-blue-600 px-4 py-2 font-medium text-white transition-colors duration-200 hover:bg-blue-700 disabled:bg-blue-400\"\n        >\n          {isSubmitting ? t('login.loading') : t('login.submit')}\n        </button>\n      </form>\n    </AuthScreenLayout>\n  );\n}\n"
  },
  {
    "path": "src/components/auth/view/ProtectedRoute.tsx",
    "content": "import type { ReactNode } from 'react';\nimport { IS_PLATFORM } from '../../../constants/config';\nimport { useAuth } from '../context/AuthContext';\nimport Onboarding from '../../onboarding/view/Onboarding';\nimport AuthLoadingScreen from './AuthLoadingScreen';\nimport LoginForm from './LoginForm';\nimport SetupForm from './SetupForm';\n\ntype ProtectedRouteProps = {\n  children: ReactNode;\n};\n\nexport default function ProtectedRoute({ children }: ProtectedRouteProps) {\n  const { user, isLoading, needsSetup, hasCompletedOnboarding, refreshOnboardingStatus } = useAuth();\n\n  if (isLoading) {\n    return <AuthLoadingScreen />;\n  }\n\n  if (IS_PLATFORM) {\n    if (!hasCompletedOnboarding) {\n      return <Onboarding onComplete={refreshOnboardingStatus} />;\n    }\n\n    return <>{children}</>;\n  }\n\n  if (needsSetup) {\n    return <SetupForm />;\n  }\n\n  if (!user) {\n    return <LoginForm />;\n  }\n\n  if (!hasCompletedOnboarding) {\n    return <Onboarding onComplete={refreshOnboardingStatus} />;\n  }\n\n  return <>{children}</>;\n}\n"
  },
  {
    "path": "src/components/auth/view/SetupForm.tsx",
    "content": "import { useCallback, useState } from 'react';\nimport type { FormEvent } from 'react';\nimport { useAuth } from '../context/AuthContext';\nimport AuthErrorAlert from './AuthErrorAlert';\nimport AuthInputField from './AuthInputField';\nimport AuthScreenLayout from './AuthScreenLayout';\n\ntype SetupFormState = {\n  username: string;\n  password: string;\n  confirmPassword: string;\n};\n\nconst initialState: SetupFormState = {\n  username: '',\n  password: '',\n  confirmPassword: '',\n};\n\n/**\n * Validates the account-setup form state.\n * @returns An error message string if validation fails, or `null` when the\n *   form is valid.\n */\nfunction validateSetupForm(formState: SetupFormState): string | null {\n  if (!formState.username.trim() || !formState.password || !formState.confirmPassword) {\n    return 'Please fill in all fields.';\n  }\n\n  if (formState.username.trim().length < 3) {\n    return 'Username must be at least 3 characters long.';\n  }\n\n  if (formState.password.length < 6) {\n    return 'Password must be at least 6 characters long.';\n  }\n\n  if (formState.password !== formState.confirmPassword) {\n    return 'Passwords do not match.';\n  }\n\n  return null;\n}\n\n/**\n * Account setup / registration form.\n * Uses `autoComplete=\"new-password\"` on password fields so that password\n * managers recognise this as a registration flow and offer to save the new\n * credentials after submission.\n */\nexport default function SetupForm() {\n  const { register } = useAuth();\n\n  const [formState, setFormState] = useState<SetupFormState>(initialState);\n  const [errorMessage, setErrorMessage] = useState('');\n  const [isSubmitting, setIsSubmitting] = useState(false);\n\n  const updateField = useCallback((field: keyof SetupFormState, value: string) => {\n    setFormState((previous) => ({ ...previous, [field]: value }));\n  }, []);\n\n  const handleSubmit = useCallback(\n    async (event: FormEvent<HTMLFormElement>) => {\n      event.preventDefault();\n      setErrorMessage('');\n\n      const validationError = validateSetupForm(formState);\n      if (validationError) {\n        setErrorMessage(validationError);\n        return;\n      }\n\n      setIsSubmitting(true);\n      const result = await register(formState.username.trim(), formState.password);\n      if (!result.success) {\n        setErrorMessage(result.error);\n      }\n      setIsSubmitting(false);\n    },\n    [formState, register],\n  );\n\n  return (\n    <AuthScreenLayout\n      title=\"Welcome to Claude Code UI\"\n      description=\"Set up your account to get started\"\n      footerText=\"This is a single-user system. Only one account can be created.\"\n      logo={<img src=\"/logo.svg\" alt=\"CloudCLI\" className=\"h-16 w-16\" />}\n    >\n      <form onSubmit={handleSubmit} className=\"space-y-4\">\n        <AuthInputField\n          id=\"username\"\n          name=\"username\"\n          label=\"Username\"\n          value={formState.username}\n          onChange={(value) => updateField('username', value)}\n          placeholder=\"Enter your username\"\n          isDisabled={isSubmitting}\n          autoComplete=\"username\"\n        />\n\n        <AuthInputField\n          id=\"password\"\n          name=\"password\"\n          label=\"Password\"\n          value={formState.password}\n          onChange={(value) => updateField('password', value)}\n          placeholder=\"Enter your password\"\n          isDisabled={isSubmitting}\n          type=\"password\"\n          autoComplete=\"new-password\"\n        />\n\n        <AuthInputField\n          id=\"confirmPassword\"\n          name=\"confirmPassword\"\n          label=\"Confirm Password\"\n          value={formState.confirmPassword}\n          onChange={(value) => updateField('confirmPassword', value)}\n          placeholder=\"Confirm your password\"\n          isDisabled={isSubmitting}\n          type=\"password\"\n          autoComplete=\"new-password\"\n        />\n\n        <AuthErrorAlert errorMessage={errorMessage} />\n\n        <button\n          type=\"submit\"\n          disabled={isSubmitting}\n          className=\"w-full rounded-md bg-blue-600 px-4 py-2 font-medium text-white transition-colors duration-200 hover:bg-blue-700 disabled:bg-blue-400\"\n        >\n          {isSubmitting ? 'Setting up...' : 'Create Account'}\n        </button>\n      </form>\n    </AuthScreenLayout>\n  );\n}\n"
  },
  {
    "path": "src/components/chat/constants/thinkingModes.ts",
    "content": "import { Brain, Zap, Sparkles, Atom } from 'lucide-react';\n\nexport const thinkingModes = [\n  {\n    id: 'none',\n    name: 'Standard',\n    description: 'Regular Claude response',\n    icon: null,\n    prefix: '',\n    color: 'text-gray-600'\n  },\n  {\n    id: 'think',\n    name: 'Think',\n    description: 'Basic extended thinking',\n    icon: Brain,\n    prefix: 'think',\n    color: 'text-blue-600'\n  },\n  {\n    id: 'think-hard',\n    name: 'Think Hard',\n    description: 'More thorough evaluation',\n    icon: Zap,\n    prefix: 'think hard',\n    color: 'text-purple-600'\n  },\n  {\n    id: 'think-harder',\n    name: 'Think Harder',\n    description: 'Deep analysis with alternatives',\n    icon: Sparkles,\n    prefix: 'think harder',\n    color: 'text-indigo-600'\n  },\n  {\n    id: 'ultrathink',\n    name: 'Ultrathink',\n    description: 'Maximum thinking budget',\n    icon: Atom,\n    prefix: 'ultrathink',\n    color: 'text-red-600'\n  }\n];"
  },
  {
    "path": "src/components/chat/hooks/useChatComposerState.ts",
    "content": "import { useCallback, useEffect, useRef, useState } from 'react';\nimport type {\n  ChangeEvent,\n  ClipboardEvent,\n  Dispatch,\n  FormEvent,\n  KeyboardEvent,\n  MouseEvent,\n  SetStateAction,\n  TouchEvent,\n} from 'react';\nimport { useDropzone } from 'react-dropzone';\nimport { authenticatedFetch } from '../../../utils/api';\nimport { thinkingModes } from '../constants/thinkingModes';\nimport { grantClaudeToolPermission } from '../utils/chatPermissions';\nimport { safeLocalStorage } from '../utils/chatStorage';\nimport type {\n  ChatMessage,\n  PendingPermissionRequest,\n  PermissionMode,\n} from '../types/types';\nimport type { Project, ProjectSession, SessionProvider } from '../../../types/app';\nimport { escapeRegExp } from '../utils/chatFormatting';\nimport { useFileMentions } from './useFileMentions';\nimport { type SlashCommand, useSlashCommands } from './useSlashCommands';\n\ntype PendingViewSession = {\n  sessionId: string | null;\n  startedAt: number;\n};\n\ninterface UseChatComposerStateArgs {\n  selectedProject: Project | null;\n  selectedSession: ProjectSession | null;\n  currentSessionId: string | null;\n  provider: SessionProvider;\n  permissionMode: PermissionMode | string;\n  cyclePermissionMode: () => void;\n  cursorModel: string;\n  claudeModel: string;\n  codexModel: string;\n  geminiModel: string;\n  isLoading: boolean;\n  canAbortSession: boolean;\n  tokenBudget: Record<string, unknown> | null;\n  sendMessage: (message: unknown) => void;\n  sendByCtrlEnter?: boolean;\n  onSessionActive?: (sessionId?: string | null) => void;\n  onSessionProcessing?: (sessionId?: string | null) => void;\n  onInputFocusChange?: (focused: boolean) => void;\n  onFileOpen?: (filePath: string, diffInfo?: unknown) => void;\n  onShowSettings?: () => void;\n  pendingViewSessionRef: { current: PendingViewSession | null };\n  scrollToBottom: () => void;\n  addMessage: (msg: ChatMessage) => void;\n  clearMessages: () => void;\n  rewindMessages: (count: number) => void;\n  setIsLoading: (loading: boolean) => void;\n  setCanAbortSession: (canAbort: boolean) => void;\n  setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void;\n  setIsUserScrolledUp: (isScrolledUp: boolean) => void;\n  setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;\n}\n\ninterface MentionableFile {\n  name: string;\n  path: string;\n}\n\ninterface CommandExecutionResult {\n  type: 'builtin' | 'custom';\n  action?: string;\n  data?: any;\n  content?: string;\n  hasBashCommands?: boolean;\n  hasFileIncludes?: boolean;\n}\n\nconst createFakeSubmitEvent = () => {\n  return { preventDefault: () => undefined } as unknown as FormEvent<HTMLFormElement>;\n};\n\nconst isTemporarySessionId = (sessionId: string | null | undefined) =>\n  Boolean(sessionId && sessionId.startsWith('new-session-'));\n\nconst getNotificationSessionSummary = (\n  selectedSession: ProjectSession | null,\n  fallbackInput: string,\n): string | null => {\n  const sessionSummary = selectedSession?.summary || selectedSession?.name || selectedSession?.title;\n  if (typeof sessionSummary === 'string' && sessionSummary.trim()) {\n    const normalized = sessionSummary.replace(/\\s+/g, ' ').trim();\n    return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized;\n  }\n\n  const normalizedFallback = fallbackInput.replace(/\\s+/g, ' ').trim();\n  if (!normalizedFallback) {\n    return null;\n  }\n\n  return normalizedFallback.length > 80 ? `${normalizedFallback.slice(0, 77)}...` : normalizedFallback;\n};\n\nexport function useChatComposerState({\n  selectedProject,\n  selectedSession,\n  currentSessionId,\n  provider,\n  permissionMode,\n  cyclePermissionMode,\n  cursorModel,\n  claudeModel,\n  codexModel,\n  geminiModel,\n  isLoading,\n  canAbortSession,\n  tokenBudget,\n  sendMessage,\n  sendByCtrlEnter,\n  onSessionActive,\n  onSessionProcessing,\n  onInputFocusChange,\n  onFileOpen,\n  onShowSettings,\n  pendingViewSessionRef,\n  scrollToBottom,\n  addMessage,\n  clearMessages,\n  rewindMessages,\n  setIsLoading,\n  setCanAbortSession,\n  setClaudeStatus,\n  setIsUserScrolledUp,\n  setPendingPermissionRequests,\n}: UseChatComposerStateArgs) {\n  const [input, setInput] = useState(() => {\n    if (typeof window !== 'undefined' && selectedProject) {\n      return safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || '';\n    }\n    return '';\n  });\n  const [attachedImages, setAttachedImages] = useState<File[]>([]);\n  const [uploadingImages, setUploadingImages] = useState<Map<string, number>>(new Map());\n  const [imageErrors, setImageErrors] = useState<Map<string, string>>(new Map());\n  const [isTextareaExpanded, setIsTextareaExpanded] = useState(false);\n  const [thinkingMode, setThinkingMode] = useState('none');\n\n  const textareaRef = useRef<HTMLTextAreaElement>(null);\n  const inputHighlightRef = useRef<HTMLDivElement>(null);\n  const handleSubmitRef = useRef<\n    ((event: FormEvent<HTMLFormElement> | MouseEvent | TouchEvent | KeyboardEvent<HTMLTextAreaElement>) => Promise<void>) | null\n  >(null);\n  const inputValueRef = useRef(input);\n\n  const handleBuiltInCommand = useCallback(\n    (result: CommandExecutionResult) => {\n      const { action, data } = result;\n      switch (action) {\n        case 'clear':\n          clearMessages();\n          break;\n\n        case 'help':\n          addMessage({\n            type: 'assistant',\n            content: data.content,\n            timestamp: Date.now(),\n          });\n          break;\n\n        case 'model':\n          addMessage({\n            type: 'assistant',\n            content: `**Current Model**: ${data.current.model}\\n\\n**Available Models**:\\n\\nClaude: ${data.available.claude.join(', ')}\\n\\nCursor: ${data.available.cursor.join(', ')}`,\n            timestamp: Date.now(),\n          });\n          break;\n\n        case 'cost': {\n          const costMessage = `**Token Usage**: ${data.tokenUsage.used.toLocaleString()} / ${data.tokenUsage.total.toLocaleString()} (${data.tokenUsage.percentage}%)\\n\\n**Estimated Cost**:\\n- Input: $${data.cost.input}\\n- Output: $${data.cost.output}\\n- **Total**: $${data.cost.total}\\n\\n**Model**: ${data.model}`;\n          addMessage({ type: 'assistant', content: costMessage, timestamp: Date.now() });\n          break;\n        }\n\n        case 'status': {\n          const statusMessage = `**System Status**\\n\\n- Version: ${data.version}\\n- Uptime: ${data.uptime}\\n- Model: ${data.model}\\n- Provider: ${data.provider}\\n- Node.js: ${data.nodeVersion}\\n- Platform: ${data.platform}`;\n          addMessage({ type: 'assistant', content: statusMessage, timestamp: Date.now() });\n          break;\n        }\n\n        case 'memory':\n          if (data.error) {\n            addMessage({\n              type: 'assistant',\n              content: `Warning: ${data.message}`,\n              timestamp: Date.now(),\n            });\n          } else {\n            addMessage({\n              type: 'assistant',\n              content: `${data.message}\\n\\nPath: \\`${data.path}\\``,\n              timestamp: Date.now(),\n            });\n            if (data.exists && onFileOpen) {\n              onFileOpen(data.path);\n            }\n          }\n          break;\n\n        case 'config':\n          onShowSettings?.();\n          break;\n\n        case 'rewind':\n          if (data.error) {\n            addMessage({\n              type: 'assistant',\n              content: `Warning: ${data.message}`,\n              timestamp: Date.now(),\n            });\n          } else {\n            rewindMessages(data.steps * 2);\n            addMessage({\n              type: 'assistant',\n              content: `Rewound ${data.steps} step(s). ${data.message}`,\n              timestamp: Date.now(),\n            });\n          }\n          break;\n\n        default:\n          console.warn('Unknown built-in command action:', action);\n      }\n    },\n    [onFileOpen, onShowSettings, addMessage, clearMessages, rewindMessages],\n  );\n\n  const handleCustomCommand = useCallback(async (result: CommandExecutionResult) => {\n    const { content, hasBashCommands } = result;\n\n    if (hasBashCommands) {\n      const confirmed = window.confirm(\n        'This command contains bash commands that will be executed. Do you want to proceed?',\n      );\n      if (!confirmed) {\n        addMessage({\n          type: 'assistant',\n          content: 'Command execution cancelled',\n          timestamp: Date.now(),\n        });\n        return;\n      }\n    }\n\n    const commandContent = content || '';\n    setInput(commandContent);\n    inputValueRef.current = commandContent;\n\n    // Defer submit to next tick so the command text is reflected in UI before dispatching.\n    setTimeout(() => {\n      if (handleSubmitRef.current) {\n        handleSubmitRef.current(createFakeSubmitEvent());\n      }\n    }, 0);\n  }, [addMessage]);\n\n  const executeCommand = useCallback(\n    async (command: SlashCommand, rawInput?: string) => {\n      if (!command || !selectedProject) {\n        return;\n      }\n\n      try {\n        const effectiveInput = rawInput ?? input;\n        const commandMatch = effectiveInput.match(new RegExp(`${escapeRegExp(command.name)}\\\\s*(.*)`));\n        const args =\n          commandMatch && commandMatch[1] ? commandMatch[1].trim().split(/\\s+/) : [];\n\n        const context = {\n          projectPath: selectedProject.fullPath || selectedProject.path,\n          projectName: selectedProject.name,\n          sessionId: currentSessionId,\n          provider,\n          model: provider === 'cursor' ? cursorModel : provider === 'codex' ? codexModel : provider === 'gemini' ? geminiModel : claudeModel,\n          tokenUsage: tokenBudget,\n        };\n\n        const response = await authenticatedFetch('/api/commands/execute', {\n          method: 'POST',\n          headers: {\n            'Content-Type': 'application/json',\n          },\n          body: JSON.stringify({\n            commandName: command.name,\n            commandPath: command.path,\n            args,\n            context,\n          }),\n        });\n\n        if (!response.ok) {\n          let errorMessage = `Failed to execute command (${response.status})`;\n          try {\n            const errorData = await response.json();\n            errorMessage = errorData?.message || errorData?.error || errorMessage;\n          } catch {\n            // Ignore JSON parse failures and use fallback message.\n          }\n          throw new Error(errorMessage);\n        }\n\n        const result = (await response.json()) as CommandExecutionResult;\n        if (result.type === 'builtin') {\n          handleBuiltInCommand(result);\n          setInput('');\n          inputValueRef.current = '';\n        } else if (result.type === 'custom') {\n          await handleCustomCommand(result);\n        }\n      } catch (error) {\n        const message = error instanceof Error ? error.message : 'Unknown error';\n        console.error('Error executing command:', error);\n        addMessage({\n          type: 'assistant',\n          content: `Error executing command: ${message}`,\n          timestamp: Date.now(),\n        });\n      }\n    },\n    [\n      claudeModel,\n      codexModel,\n      currentSessionId,\n      cursorModel,\n      geminiModel,\n      handleBuiltInCommand,\n      handleCustomCommand,\n      input,\n      provider,\n      selectedProject,\n      addMessage,\n      tokenBudget,\n    ],\n  );\n\n  const {\n    slashCommands,\n    slashCommandsCount,\n    filteredCommands,\n    frequentCommands,\n    commandQuery,\n    showCommandMenu,\n    selectedCommandIndex,\n    resetCommandMenuState,\n    handleCommandSelect,\n    handleToggleCommandMenu,\n    handleCommandInputChange,\n    handleCommandMenuKeyDown,\n  } = useSlashCommands({\n    selectedProject,\n    input,\n    setInput,\n    textareaRef,\n    onExecuteCommand: executeCommand,\n  });\n\n  const {\n    showFileDropdown,\n    filteredFiles,\n    selectedFileIndex,\n    renderInputWithMentions,\n    selectFile,\n    setCursorPosition,\n    handleFileMentionsKeyDown,\n  } = useFileMentions({\n    selectedProject,\n    input,\n    setInput,\n    textareaRef,\n  });\n\n  const syncInputOverlayScroll = useCallback((target: HTMLTextAreaElement) => {\n    if (!inputHighlightRef.current || !target) {\n      return;\n    }\n    inputHighlightRef.current.scrollTop = target.scrollTop;\n    inputHighlightRef.current.scrollLeft = target.scrollLeft;\n  }, []);\n\n  const handleImageFiles = useCallback((files: File[]) => {\n    const validFiles = files.filter((file) => {\n      try {\n        if (!file || typeof file !== 'object') {\n          console.warn('Invalid file object:', file);\n          return false;\n        }\n\n        if (!file.type || !file.type.startsWith('image/')) {\n          return false;\n        }\n\n        if (!file.size || file.size > 5 * 1024 * 1024) {\n          const fileName = file.name || 'Unknown file';\n          setImageErrors((previous) => {\n            const next = new Map(previous);\n            next.set(fileName, 'File too large (max 5MB)');\n            return next;\n          });\n          return false;\n        }\n\n        return true;\n      } catch (error) {\n        console.error('Error validating file:', error, file);\n        return false;\n      }\n    });\n\n    if (validFiles.length > 0) {\n      setAttachedImages((previous) => [...previous, ...validFiles].slice(0, 5));\n    }\n  }, []);\n\n  const handlePaste = useCallback(\n    (event: ClipboardEvent<HTMLTextAreaElement>) => {\n      const items = Array.from(event.clipboardData.items);\n\n      items.forEach((item) => {\n        if (!item.type.startsWith('image/')) {\n          return;\n        }\n        const file = item.getAsFile();\n        if (file) {\n          handleImageFiles([file]);\n        }\n      });\n\n      if (items.length === 0 && event.clipboardData.files.length > 0) {\n        const files = Array.from(event.clipboardData.files);\n        const imageFiles = files.filter((file) => file.type.startsWith('image/'));\n        if (imageFiles.length > 0) {\n          handleImageFiles(imageFiles);\n        }\n      }\n    },\n    [handleImageFiles],\n  );\n\n  const { getRootProps, getInputProps, isDragActive, open } = useDropzone({\n    accept: {\n      'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'],\n    },\n    maxSize: 5 * 1024 * 1024,\n    maxFiles: 5,\n    onDrop: handleImageFiles,\n    noClick: true,\n    noKeyboard: true,\n  });\n\n  const handleSubmit = useCallback(\n    async (\n      event: FormEvent<HTMLFormElement> | MouseEvent | TouchEvent | KeyboardEvent<HTMLTextAreaElement>,\n    ) => {\n      event.preventDefault();\n      const currentInput = inputValueRef.current;\n      if (!currentInput.trim() || isLoading || !selectedProject) {\n        return;\n      }\n\n      // Intercept slash commands: if input starts with /commandName, execute as command with args\n      const trimmedInput = currentInput.trim();\n      if (trimmedInput.startsWith('/')) {\n        const firstSpace = trimmedInput.indexOf(' ');\n        const commandName = firstSpace > 0 ? trimmedInput.slice(0, firstSpace) : trimmedInput;\n        const matchedCommand = slashCommands.find((cmd: SlashCommand) => cmd.name === commandName);\n        if (matchedCommand) {\n          executeCommand(matchedCommand, trimmedInput);\n          setInput('');\n          inputValueRef.current = '';\n          setAttachedImages([]);\n          setUploadingImages(new Map());\n          setImageErrors(new Map());\n          resetCommandMenuState();\n          setIsTextareaExpanded(false);\n          if (textareaRef.current) {\n            textareaRef.current.style.height = 'auto';\n          }\n          return;\n        }\n      }\n\n      let messageContent = currentInput;\n      const selectedThinkingMode = thinkingModes.find((mode: { id: string; prefix?: string }) => mode.id === thinkingMode);\n      if (selectedThinkingMode && selectedThinkingMode.prefix) {\n        messageContent = `${selectedThinkingMode.prefix}: ${currentInput}`;\n      }\n\n      let uploadedImages: unknown[] = [];\n      if (attachedImages.length > 0) {\n        const formData = new FormData();\n        attachedImages.forEach((file) => {\n          formData.append('images', file);\n        });\n\n        try {\n          const response = await authenticatedFetch(`/api/projects/${selectedProject.name}/upload-images`, {\n            method: 'POST',\n            headers: {},\n            body: formData,\n          });\n\n          if (!response.ok) {\n            throw new Error('Failed to upload images');\n          }\n\n          const result = await response.json();\n          uploadedImages = result.images;\n        } catch (error) {\n          const message = error instanceof Error ? error.message : 'Unknown error';\n          console.error('Image upload failed:', error);\n          addMessage({\n            type: 'error',\n            content: `Failed to upload images: ${message}`,\n            timestamp: new Date(),\n          });\n          return;\n        }\n      }\n\n      const effectiveSessionId =\n        currentSessionId || selectedSession?.id || sessionStorage.getItem('cursorSessionId');\n      const sessionToActivate = effectiveSessionId || `new-session-${Date.now()}`;\n\n      const userMessage: ChatMessage = {\n        type: 'user',\n        content: currentInput,\n        images: uploadedImages as any,\n        timestamp: new Date(),\n      };\n\n      addMessage(userMessage);\n      setIsLoading(true); // Processing banner starts\n      setCanAbortSession(true);\n      setClaudeStatus({\n        text: 'Processing',\n        tokens: 0,\n        can_interrupt: true,\n      });\n\n      setIsUserScrolledUp(false);\n      setTimeout(() => scrollToBottom(), 100);\n\n      if (!effectiveSessionId && !selectedSession?.id) {\n        if (typeof window !== 'undefined') {\n          // Reset stale pending IDs from previous interrupted runs before creating a new one.\n          sessionStorage.removeItem('pendingSessionId');\n        }\n        pendingViewSessionRef.current = { sessionId: null, startedAt: Date.now() };\n      }\n      onSessionActive?.(sessionToActivate);\n      if (effectiveSessionId && !isTemporarySessionId(effectiveSessionId)) {\n        onSessionProcessing?.(effectiveSessionId);\n      }\n\n      const getToolsSettings = () => {\n        try {\n          const settingsKey =\n            provider === 'cursor'\n              ? 'cursor-tools-settings'\n              : provider === 'codex'\n                ? 'codex-settings'\n                : provider === 'gemini'\n                  ? 'gemini-settings'\n                  : 'claude-settings';\n          const savedSettings = safeLocalStorage.getItem(settingsKey);\n          if (savedSettings) {\n            return JSON.parse(savedSettings);\n          }\n        } catch (error) {\n          console.error('Error loading tools settings:', error);\n        }\n\n        return {\n          allowedTools: [],\n          disallowedTools: [],\n          skipPermissions: false,\n        };\n      };\n\n      const toolsSettings = getToolsSettings();\n      const resolvedProjectPath = selectedProject.fullPath || selectedProject.path || '';\n      const sessionSummary = getNotificationSessionSummary(selectedSession, currentInput);\n\n      if (provider === 'cursor') {\n        sendMessage({\n          type: 'cursor-command',\n          command: messageContent,\n          sessionId: effectiveSessionId,\n          options: {\n            cwd: resolvedProjectPath,\n            projectPath: resolvedProjectPath,\n            sessionId: effectiveSessionId,\n            resume: Boolean(effectiveSessionId),\n            model: cursorModel,\n            skipPermissions: toolsSettings?.skipPermissions || false,\n            sessionSummary,\n            toolsSettings,\n          },\n        });\n      } else if (provider === 'codex') {\n        sendMessage({\n          type: 'codex-command',\n          command: messageContent,\n          sessionId: effectiveSessionId,\n          options: {\n            cwd: resolvedProjectPath,\n            projectPath: resolvedProjectPath,\n            sessionId: effectiveSessionId,\n            resume: Boolean(effectiveSessionId),\n            model: codexModel,\n            sessionSummary,\n            permissionMode: permissionMode === 'plan' ? 'default' : permissionMode,\n          },\n        });\n      } else if (provider === 'gemini') {\n        sendMessage({\n          type: 'gemini-command',\n          command: messageContent,\n          sessionId: effectiveSessionId,\n          options: {\n            cwd: resolvedProjectPath,\n            projectPath: resolvedProjectPath,\n            sessionId: effectiveSessionId,\n            resume: Boolean(effectiveSessionId),\n            model: geminiModel,\n            sessionSummary,\n            permissionMode,\n            toolsSettings,\n          },\n        });\n      } else {\n        sendMessage({\n          type: 'claude-command',\n          command: messageContent,\n          options: {\n            projectPath: resolvedProjectPath,\n            cwd: resolvedProjectPath,\n            sessionId: effectiveSessionId,\n            resume: Boolean(effectiveSessionId),\n            toolsSettings,\n            permissionMode,\n            model: claudeModel,\n            sessionSummary,\n            images: uploadedImages,\n          },\n        });\n      }\n\n      setInput('');\n      inputValueRef.current = '';\n      resetCommandMenuState();\n      setAttachedImages([]);\n      setUploadingImages(new Map());\n      setImageErrors(new Map());\n      setIsTextareaExpanded(false);\n      setThinkingMode('none');\n\n      if (textareaRef.current) {\n        textareaRef.current.style.height = 'auto';\n      }\n\n      safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`);\n    },\n    [\n      selectedSession,\n      attachedImages,\n      claudeModel,\n      codexModel,\n      currentSessionId,\n      cursorModel,\n      executeCommand,\n      geminiModel,\n      isLoading,\n      onSessionActive,\n      onSessionProcessing,\n      pendingViewSessionRef,\n      permissionMode,\n      provider,\n      resetCommandMenuState,\n      scrollToBottom,\n      selectedProject,\n      sendMessage,\n      setCanAbortSession,\n      addMessage,\n      setClaudeStatus,\n      setIsLoading,\n      setIsUserScrolledUp,\n      slashCommands,\n      thinkingMode,\n    ],\n  );\n\n  useEffect(() => {\n    handleSubmitRef.current = handleSubmit;\n  }, [handleSubmit]);\n\n  useEffect(() => {\n    inputValueRef.current = input;\n  }, [input]);\n\n  useEffect(() => {\n    if (!selectedProject) {\n      return;\n    }\n    const savedInput = safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || '';\n    setInput((previous) => {\n      const next = previous === savedInput ? previous : savedInput;\n      inputValueRef.current = next;\n      return next;\n    });\n  }, [selectedProject?.name]);\n\n  useEffect(() => {\n    if (!selectedProject) {\n      return;\n    }\n    if (input !== '') {\n      safeLocalStorage.setItem(`draft_input_${selectedProject.name}`, input);\n    } else {\n      safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`);\n    }\n  }, [input, selectedProject]);\n\n  useEffect(() => {\n    if (!textareaRef.current) {\n      return;\n    }\n    // Re-run when input changes so restored drafts get the same autosize behavior as typed text.\n    textareaRef.current.style.height = 'auto';\n    textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;\n    const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight);\n    const expanded = textareaRef.current.scrollHeight > lineHeight * 2;\n    setIsTextareaExpanded(expanded);\n  }, [input]);\n\n  useEffect(() => {\n    if (!textareaRef.current || input.trim()) {\n      return;\n    }\n    textareaRef.current.style.height = 'auto';\n    setIsTextareaExpanded(false);\n  }, [input]);\n\n  const handleInputChange = useCallback(\n    (event: ChangeEvent<HTMLTextAreaElement>) => {\n      const newValue = event.target.value;\n      const cursorPos = event.target.selectionStart;\n\n      setInput(newValue);\n      inputValueRef.current = newValue;\n      setCursorPosition(cursorPos);\n\n      if (!newValue.trim()) {\n        event.target.style.height = 'auto';\n        setIsTextareaExpanded(false);\n        resetCommandMenuState();\n        return;\n      }\n\n      handleCommandInputChange(newValue, cursorPos);\n    },\n    [handleCommandInputChange, resetCommandMenuState, setCursorPosition],\n  );\n\n  const handleKeyDown = useCallback(\n    (event: KeyboardEvent<HTMLTextAreaElement>) => {\n      if (handleCommandMenuKeyDown(event)) {\n        return;\n      }\n\n      if (handleFileMentionsKeyDown(event)) {\n        return;\n      }\n\n      if (event.key === 'Tab' && !showFileDropdown && !showCommandMenu) {\n        event.preventDefault();\n        cyclePermissionMode();\n        return;\n      }\n\n      if (event.key === 'Enter') {\n        if (event.nativeEvent.isComposing) {\n          return;\n        }\n\n        if ((event.ctrlKey || event.metaKey) && !event.shiftKey) {\n          event.preventDefault();\n          handleSubmit(event);\n        } else if (!event.shiftKey && !event.ctrlKey && !event.metaKey && !sendByCtrlEnter) {\n          event.preventDefault();\n          handleSubmit(event);\n        }\n      }\n    },\n    [\n      cyclePermissionMode,\n      handleCommandMenuKeyDown,\n      handleFileMentionsKeyDown,\n      handleSubmit,\n      sendByCtrlEnter,\n      showCommandMenu,\n      showFileDropdown,\n    ],\n  );\n\n  const handleTextareaClick = useCallback(\n    (event: MouseEvent<HTMLTextAreaElement>) => {\n      setCursorPosition(event.currentTarget.selectionStart);\n    },\n    [setCursorPosition],\n  );\n\n  const handleTextareaInput = useCallback(\n    (event: FormEvent<HTMLTextAreaElement>) => {\n      const target = event.currentTarget;\n      target.style.height = 'auto';\n      target.style.height = `${target.scrollHeight}px`;\n      setCursorPosition(target.selectionStart);\n      syncInputOverlayScroll(target);\n\n      const lineHeight = parseInt(window.getComputedStyle(target).lineHeight);\n      setIsTextareaExpanded(target.scrollHeight > lineHeight * 2);\n    },\n    [setCursorPosition, syncInputOverlayScroll],\n  );\n\n  const handleClearInput = useCallback(() => {\n    setInput('');\n    inputValueRef.current = '';\n    resetCommandMenuState();\n    if (textareaRef.current) {\n      textareaRef.current.style.height = 'auto';\n      textareaRef.current.focus();\n    }\n    setIsTextareaExpanded(false);\n  }, [resetCommandMenuState]);\n\n  const handleAbortSession = useCallback(() => {\n    if (!canAbortSession) {\n      return;\n    }\n\n    const pendingSessionId =\n      typeof window !== 'undefined' ? sessionStorage.getItem('pendingSessionId') : null;\n    const cursorSessionId =\n      typeof window !== 'undefined' ? sessionStorage.getItem('cursorSessionId') : null;\n\n    const candidateSessionIds = [\n      currentSessionId,\n      pendingViewSessionRef.current?.sessionId || null,\n      pendingSessionId,\n      provider === 'cursor' ? cursorSessionId : null,\n      selectedSession?.id || null,\n    ];\n\n    const targetSessionId =\n      candidateSessionIds.find((sessionId) => Boolean(sessionId) && !isTemporarySessionId(sessionId)) || null;\n\n    if (!targetSessionId) {\n      console.warn('Abort requested but no concrete session ID is available yet.');\n      return;\n    }\n\n    sendMessage({\n      type: 'abort-session',\n      sessionId: targetSessionId,\n      provider,\n    });\n  }, [canAbortSession, currentSessionId, pendingViewSessionRef, provider, selectedSession?.id, sendMessage]);\n\n  const handleTranscript = useCallback((text: string) => {\n    if (!text.trim()) {\n      return;\n    }\n\n    setInput((previousInput) => {\n      const newInput = previousInput.trim() ? `${previousInput} ${text}` : text;\n      inputValueRef.current = newInput;\n\n      setTimeout(() => {\n        if (!textareaRef.current) {\n          return;\n        }\n\n        textareaRef.current.style.height = 'auto';\n        textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;\n        const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight);\n        setIsTextareaExpanded(textareaRef.current.scrollHeight > lineHeight * 2);\n      }, 0);\n\n      return newInput;\n    });\n  }, []);\n\n  const handleGrantToolPermission = useCallback(\n    (suggestion: { entry: string; toolName: string }) => {\n      if (!suggestion || provider !== 'claude') {\n        return { success: false };\n      }\n      return grantClaudeToolPermission(suggestion.entry);\n    },\n    [provider],\n  );\n\n  const handlePermissionDecision = useCallback(\n    (\n      requestIds: string | string[],\n      decision: { allow?: boolean; message?: string; rememberEntry?: string | null; updatedInput?: unknown },\n    ) => {\n      const ids = Array.isArray(requestIds) ? requestIds : [requestIds];\n      const validIds = ids.filter(Boolean);\n      if (validIds.length === 0) {\n        return;\n      }\n\n      validIds.forEach((requestId) => {\n        sendMessage({\n          type: 'claude-permission-response',\n          requestId,\n          allow: Boolean(decision?.allow),\n          updatedInput: decision?.updatedInput,\n          message: decision?.message,\n          rememberEntry: decision?.rememberEntry,\n        });\n      });\n\n      setPendingPermissionRequests((previous) => {\n        const next = previous.filter((request) => !validIds.includes(request.requestId));\n        if (next.length === 0) {\n          setClaudeStatus(null);\n        }\n        return next;\n      });\n    },\n    [sendMessage, setClaudeStatus, setPendingPermissionRequests],\n  );\n\n  const [isInputFocused, setIsInputFocused] = useState(false);\n\n  const handleInputFocusChange = useCallback(\n    (focused: boolean) => {\n      setIsInputFocused(focused);\n      onInputFocusChange?.(focused);\n    },\n    [onInputFocusChange],\n  );\n\n  return {\n    input,\n    setInput,\n    textareaRef,\n    inputHighlightRef,\n    isTextareaExpanded,\n    thinkingMode,\n    setThinkingMode,\n    slashCommandsCount,\n    filteredCommands,\n    frequentCommands,\n    commandQuery,\n    showCommandMenu,\n    selectedCommandIndex,\n    resetCommandMenuState,\n    handleCommandSelect,\n    handleToggleCommandMenu,\n    showFileDropdown,\n    filteredFiles: filteredFiles as MentionableFile[],\n    selectedFileIndex,\n    renderInputWithMentions,\n    selectFile,\n    attachedImages,\n    setAttachedImages,\n    uploadingImages,\n    imageErrors,\n    getRootProps,\n    getInputProps,\n    isDragActive,\n    openImagePicker: open,\n    handleSubmit,\n    handleInputChange,\n    handleKeyDown,\n    handlePaste,\n    handleTextareaClick,\n    handleTextareaInput,\n    syncInputOverlayScroll,\n    handleClearInput,\n    handleAbortSession,\n    handleTranscript,\n    handlePermissionDecision,\n    handleGrantToolPermission,\n    handleInputFocusChange,\n    isInputFocused,\n  };\n}\n"
  },
  {
    "path": "src/components/chat/hooks/useChatMessages.ts",
    "content": "/**\n * Message normalization utilities.\n * Converts NormalizedMessage[] from the session store into ChatMessage[] for the UI.\n */\n\nimport type { NormalizedMessage } from '../../../stores/useSessionStore';\nimport type { ChatMessage, SubagentChildTool } from '../types/types';\nimport { decodeHtmlEntities, unescapeWithMathProtection, formatUsageLimitText } from '../utils/chatFormatting';\n\n/**\n * Convert NormalizedMessage[] from the session store into ChatMessage[]\n * that the existing UI components expect.\n *\n * Internal/system content (e.g. <system-reminder>, <command-name>) is already\n * filtered server-side by the Claude adapter (server/providers/utils.js).\n */\nexport function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMessage[] {\n  const converted: ChatMessage[] = [];\n\n  // First pass: collect tool results for attachment\n  const toolResultMap = new Map<string, NormalizedMessage>();\n  for (const msg of messages) {\n    if (msg.kind === 'tool_result' && msg.toolId) {\n      toolResultMap.set(msg.toolId, msg);\n    }\n  }\n\n  for (const msg of messages) {\n    switch (msg.kind) {\n      case 'text': {\n        const content = msg.content || '';\n        if (!content.trim()) continue;\n\n        if (msg.role === 'user') {\n          // Parse task notifications\n          const taskNotifRegex = /<task-notification>\\s*<task-id>[^<]*<\\/task-id>\\s*<output-file>[^<]*<\\/output-file>\\s*<status>([^<]*)<\\/status>\\s*<summary>([^<]*)<\\/summary>\\s*<\\/task-notification>/g;\n          const taskNotifMatch = taskNotifRegex.exec(content);\n          if (taskNotifMatch) {\n            converted.push({\n              type: 'assistant',\n              content: taskNotifMatch[2]?.trim() || 'Background task finished',\n              timestamp: msg.timestamp,\n              isTaskNotification: true,\n              taskStatus: taskNotifMatch[1]?.trim() || 'completed',\n            });\n          } else {\n            converted.push({\n              type: 'user',\n              content: unescapeWithMathProtection(decodeHtmlEntities(content)),\n              timestamp: msg.timestamp,\n            });\n          }\n        } else {\n          let text = decodeHtmlEntities(content);\n          text = unescapeWithMathProtection(text);\n          text = formatUsageLimitText(text);\n          converted.push({\n            type: 'assistant',\n            content: text,\n            timestamp: msg.timestamp,\n          });\n        }\n        break;\n      }\n\n      case 'tool_use': {\n        const tr = msg.toolResult || (msg.toolId ? toolResultMap.get(msg.toolId) : null);\n        const isSubagentContainer = msg.toolName === 'Task';\n\n        // Build child tools from subagentTools\n        const childTools: SubagentChildTool[] = [];\n        if (isSubagentContainer && msg.subagentTools && Array.isArray(msg.subagentTools)) {\n          for (const tool of msg.subagentTools as any[]) {\n            childTools.push({\n              toolId: tool.toolId,\n              toolName: tool.toolName,\n              toolInput: tool.toolInput,\n              toolResult: tool.toolResult || null,\n              timestamp: new Date(tool.timestamp || Date.now()),\n            });\n          }\n        }\n\n        const toolResult = tr\n          ? {\n              content: typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content),\n              isError: Boolean(tr.isError),\n              toolUseResult: (tr as any).toolUseResult,\n            }\n          : null;\n\n        converted.push({\n          type: 'assistant',\n          content: '',\n          timestamp: msg.timestamp,\n          isToolUse: true,\n          toolName: msg.toolName,\n          toolInput: typeof msg.toolInput === 'string' ? msg.toolInput : JSON.stringify(msg.toolInput ?? '', null, 2),\n          toolId: msg.toolId,\n          toolResult,\n          isSubagentContainer,\n          subagentState: isSubagentContainer\n            ? {\n                childTools,\n                currentToolIndex: childTools.length > 0 ? childTools.length - 1 : -1,\n                isComplete: Boolean(toolResult),\n              }\n            : undefined,\n        });\n        break;\n      }\n\n      case 'thinking':\n        if (msg.content?.trim()) {\n          converted.push({\n            type: 'assistant',\n            content: unescapeWithMathProtection(msg.content),\n            timestamp: msg.timestamp,\n            isThinking: true,\n          });\n        }\n        break;\n\n      case 'error':\n        converted.push({\n          type: 'error',\n          content: msg.content || 'Unknown error',\n          timestamp: msg.timestamp,\n        });\n        break;\n\n      case 'interactive_prompt':\n        converted.push({\n          type: 'assistant',\n          content: msg.content || '',\n          timestamp: msg.timestamp,\n          isInteractivePrompt: true,\n        });\n        break;\n\n      case 'task_notification':\n        converted.push({\n          type: 'assistant',\n          content: msg.summary || 'Background task update',\n          timestamp: msg.timestamp,\n          isTaskNotification: true,\n          taskStatus: msg.status || 'completed',\n        });\n        break;\n\n      case 'stream_delta':\n        if (msg.content) {\n          converted.push({\n            type: 'assistant',\n            content: msg.content,\n            timestamp: msg.timestamp,\n            isStreaming: true,\n          });\n        }\n        break;\n\n      // stream_end, complete, status, permission_*, session_created\n      // are control events — not rendered as messages\n      case 'stream_end':\n      case 'complete':\n      case 'status':\n      case 'permission_request':\n      case 'permission_cancelled':\n      case 'session_created':\n        // Skip — these are handled by useChatRealtimeHandlers\n        break;\n\n      // tool_result is handled via attachment to tool_use above\n      case 'tool_result':\n        break;\n\n      default:\n        break;\n    }\n  }\n\n  return converted;\n}\n"
  },
  {
    "path": "src/components/chat/hooks/useChatProviderState.ts",
    "content": "import { useCallback, useEffect, useRef, useState } from 'react';\nimport { authenticatedFetch } from '../../../utils/api';\nimport { CLAUDE_MODELS, CODEX_MODELS, CURSOR_MODELS, GEMINI_MODELS } from '../../../../shared/modelConstants';\nimport type { PendingPermissionRequest, PermissionMode } from '../types/types';\nimport type { ProjectSession, SessionProvider } from '../../../types/app';\n\ninterface UseChatProviderStateArgs {\n  selectedSession: ProjectSession | null;\n}\n\nexport function useChatProviderState({ selectedSession }: UseChatProviderStateArgs) {\n  const [permissionMode, setPermissionMode] = useState<PermissionMode>('default');\n  const [pendingPermissionRequests, setPendingPermissionRequests] = useState<PendingPermissionRequest[]>([]);\n  const [provider, setProvider] = useState<SessionProvider>(() => {\n    return (localStorage.getItem('selected-provider') as SessionProvider) || 'claude';\n  });\n  const [cursorModel, setCursorModel] = useState<string>(() => {\n    return localStorage.getItem('cursor-model') || CURSOR_MODELS.DEFAULT;\n  });\n  const [claudeModel, setClaudeModel] = useState<string>(() => {\n    return localStorage.getItem('claude-model') || CLAUDE_MODELS.DEFAULT;\n  });\n  const [codexModel, setCodexModel] = useState<string>(() => {\n    return localStorage.getItem('codex-model') || CODEX_MODELS.DEFAULT;\n  });\n  const [geminiModel, setGeminiModel] = useState<string>(() => {\n    return localStorage.getItem('gemini-model') || GEMINI_MODELS.DEFAULT;\n  });\n\n  const lastProviderRef = useRef(provider);\n\n  useEffect(() => {\n    if (!selectedSession?.id) {\n      return;\n    }\n\n    const savedMode = localStorage.getItem(`permissionMode-${selectedSession.id}`);\n    setPermissionMode((savedMode as PermissionMode) || 'default');\n  }, [selectedSession?.id]);\n\n  useEffect(() => {\n    if (!selectedSession?.__provider || selectedSession.__provider === provider) {\n      return;\n    }\n\n    setProvider(selectedSession.__provider);\n    localStorage.setItem('selected-provider', selectedSession.__provider);\n  }, [provider, selectedSession]);\n\n  useEffect(() => {\n    if (lastProviderRef.current === provider) {\n      return;\n    }\n    setPendingPermissionRequests([]);\n    lastProviderRef.current = provider;\n  }, [provider]);\n\n  useEffect(() => {\n    setPendingPermissionRequests((previous) =>\n      previous.filter((request) => !request.sessionId || request.sessionId === selectedSession?.id),\n    );\n  }, [selectedSession?.id]);\n\n  useEffect(() => {\n    if (provider !== 'cursor') {\n      return;\n    }\n\n    authenticatedFetch('/api/cursor/config')\n      .then((response) => response.json())\n      .then((data) => {\n        if (!data.success || !data.config?.model?.modelId) {\n          return;\n        }\n\n        const modelId = data.config.model.modelId as string;\n        if (!localStorage.getItem('cursor-model')) {\n          setCursorModel(modelId);\n        }\n      })\n      .catch((error) => {\n        console.error('Error loading Cursor config:', error);\n      });\n  }, [provider]);\n\n  const cyclePermissionMode = useCallback(() => {\n    const modes: PermissionMode[] =\n      provider === 'codex'\n        ? ['default', 'acceptEdits', 'bypassPermissions']\n        : ['default', 'acceptEdits', 'bypassPermissions', 'plan'];\n\n    const currentIndex = modes.indexOf(permissionMode);\n    const nextIndex = (currentIndex + 1) % modes.length;\n    const nextMode = modes[nextIndex];\n    setPermissionMode(nextMode);\n\n    if (selectedSession?.id) {\n      localStorage.setItem(`permissionMode-${selectedSession.id}`, nextMode);\n    }\n  }, [permissionMode, provider, selectedSession?.id]);\n\n  return {\n    provider,\n    setProvider,\n    cursorModel,\n    setCursorModel,\n    claudeModel,\n    setClaudeModel,\n    codexModel,\n    setCodexModel,\n    geminiModel,\n    setGeminiModel,\n    permissionMode,\n    setPermissionMode,\n    pendingPermissionRequests,\n    setPendingPermissionRequests,\n    cyclePermissionMode,\n  };\n}\n"
  },
  {
    "path": "src/components/chat/hooks/useChatRealtimeHandlers.ts",
    "content": "import { useEffect, useRef } from 'react';\nimport type { Dispatch, MutableRefObject, SetStateAction } from 'react';\nimport type { PendingPermissionRequest } from '../types/types';\nimport type { Project, ProjectSession, SessionProvider } from '../../../types/app';\nimport type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';\n\ntype PendingViewSession = {\n  sessionId: string | null;\n  startedAt: number;\n};\n\ntype LatestChatMessage = {\n  type?: string;\n  kind?: string;\n  data?: any;\n  message?: any;\n  delta?: string;\n  sessionId?: string;\n  session_id?: string;\n  requestId?: string;\n  toolName?: string;\n  input?: unknown;\n  context?: unknown;\n  error?: string;\n  tool?: any;\n  toolId?: string;\n  result?: any;\n  exitCode?: number;\n  isProcessing?: boolean;\n  actualSessionId?: string;\n  event?: string;\n  status?: any;\n  isNewSession?: boolean;\n  resultText?: string;\n  isError?: boolean;\n  success?: boolean;\n  reason?: string;\n  provider?: string;\n  content?: string;\n  text?: string;\n  tokens?: number;\n  canInterrupt?: boolean;\n  tokenBudget?: unknown;\n  newSessionId?: string;\n  aborted?: boolean;\n  [key: string]: any;\n};\n\ninterface UseChatRealtimeHandlersArgs {\n  latestMessage: LatestChatMessage | null;\n  provider: SessionProvider;\n  selectedProject: Project | null;\n  selectedSession: ProjectSession | null;\n  currentSessionId: string | null;\n  setCurrentSessionId: (sessionId: string | null) => void;\n  setIsLoading: (loading: boolean) => void;\n  setCanAbortSession: (canAbort: boolean) => void;\n  setClaudeStatus: (status: { text: string; tokens: number; can_interrupt: boolean } | null) => void;\n  setTokenBudget: (budget: Record<string, unknown> | null) => void;\n  setPendingPermissionRequests: Dispatch<SetStateAction<PendingPermissionRequest[]>>;\n  pendingViewSessionRef: MutableRefObject<PendingViewSession | null>;\n  streamBufferRef: MutableRefObject<string>;\n  streamTimerRef: MutableRefObject<number | null>;\n  accumulatedStreamRef: MutableRefObject<string>;\n  onSessionInactive?: (sessionId?: string | null) => void;\n  onSessionProcessing?: (sessionId?: string | null) => void;\n  onSessionNotProcessing?: (sessionId?: string | null) => void;\n  onReplaceTemporarySession?: (sessionId?: string | null) => void;\n  onNavigateToSession?: (sessionId: string) => void;\n  onWebSocketReconnect?: () => void;\n  sessionStore: SessionStore;\n}\n\n/* ------------------------------------------------------------------ */\n/*  Hook                                                              */\n/* ------------------------------------------------------------------ */\n\nexport function useChatRealtimeHandlers({\n  latestMessage,\n  provider,\n  selectedProject,\n  selectedSession,\n  currentSessionId,\n  setCurrentSessionId,\n  setIsLoading,\n  setCanAbortSession,\n  setClaudeStatus,\n  setTokenBudget,\n  setPendingPermissionRequests,\n  pendingViewSessionRef,\n  streamBufferRef,\n  streamTimerRef,\n  accumulatedStreamRef,\n  onSessionInactive,\n  onSessionProcessing,\n  onSessionNotProcessing,\n  onReplaceTemporarySession,\n  onNavigateToSession,\n  onWebSocketReconnect,\n  sessionStore,\n}: UseChatRealtimeHandlersArgs) {\n  const lastProcessedMessageRef = useRef<LatestChatMessage | null>(null);\n\n  useEffect(() => {\n    if (!latestMessage) return;\n    if (lastProcessedMessageRef.current === latestMessage) return;\n    lastProcessedMessageRef.current = latestMessage;\n\n    const activeViewSessionId =\n      selectedSession?.id || currentSessionId || pendingViewSessionRef.current?.sessionId || null;\n\n    /* ---------------------------------------------------------------- */\n    /*  Legacy messages (no `kind` field) — handle and return           */\n    /* ---------------------------------------------------------------- */\n\n    const msg = latestMessage as any;\n\n    if (!msg.kind) {\n      const messageType = String(msg.type || '');\n\n      switch (messageType) {\n        case 'websocket-reconnected':\n          onWebSocketReconnect?.();\n          return;\n\n        case 'pending-permissions-response': {\n          const permSessionId = msg.sessionId;\n          const isCurrentPermSession =\n            permSessionId === currentSessionId || (selectedSession && permSessionId === selectedSession.id);\n          if (permSessionId && !isCurrentPermSession) return;\n          setPendingPermissionRequests(msg.data || []);\n          return;\n        }\n\n        case 'session-status': {\n          const statusSessionId = msg.sessionId;\n          if (!statusSessionId) return;\n\n          const status = msg.status;\n          if (status) {\n            const statusInfo = {\n              text: status.text || 'Working...',\n              tokens: status.tokens || 0,\n              can_interrupt: status.can_interrupt !== undefined ? status.can_interrupt : true,\n            };\n            setClaudeStatus(statusInfo);\n            setIsLoading(true);\n            setCanAbortSession(statusInfo.can_interrupt);\n            return;\n          }\n\n          // Legacy isProcessing format from check-session-status\n          const isCurrentSession =\n            statusSessionId === currentSessionId || (selectedSession && statusSessionId === selectedSession.id);\n\n          if (msg.isProcessing) {\n            onSessionProcessing?.(statusSessionId);\n            if (isCurrentSession) { setIsLoading(true); setCanAbortSession(true); }\n            return;\n          }\n          onSessionInactive?.(statusSessionId);\n          onSessionNotProcessing?.(statusSessionId);\n          if (isCurrentSession) {\n            setIsLoading(false);\n            setCanAbortSession(false);\n            setClaudeStatus(null);\n          }\n          return;\n        }\n\n        default:\n          // Unknown legacy message type — ignore\n          return;\n      }\n    }\n\n    /* ---------------------------------------------------------------- */\n    /*  NormalizedMessage handling (has `kind` field)                    */\n    /* ---------------------------------------------------------------- */\n\n    const sid = msg.sessionId || activeViewSessionId;\n\n    // --- Streaming: buffer for performance ---\n    if (msg.kind === 'stream_delta') {\n      const text = msg.content || '';\n      if (!text) return;\n      streamBufferRef.current += text;\n      accumulatedStreamRef.current += text;\n      if (!streamTimerRef.current) {\n        streamTimerRef.current = window.setTimeout(() => {\n          streamTimerRef.current = null;\n          if (sid) {\n            sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider);\n          }\n        }, 100);\n      }\n      // Also route to store for non-active sessions\n      if (sid && sid !== activeViewSessionId) {\n        sessionStore.appendRealtime(sid, msg as NormalizedMessage);\n      }\n      return;\n    }\n\n    if (msg.kind === 'stream_end') {\n      if (streamTimerRef.current) {\n        clearTimeout(streamTimerRef.current);\n        streamTimerRef.current = null;\n      }\n      if (sid) {\n        if (accumulatedStreamRef.current) {\n          sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider);\n        }\n        sessionStore.finalizeStreaming(sid);\n      }\n      accumulatedStreamRef.current = '';\n      streamBufferRef.current = '';\n      return;\n    }\n\n    // --- All other messages: route to store ---\n    if (sid) {\n      sessionStore.appendRealtime(sid, msg as NormalizedMessage);\n    }\n\n    // --- UI side effects for specific kinds ---\n    switch (msg.kind) {\n      case 'session_created': {\n        const newSessionId = msg.newSessionId;\n        if (!newSessionId) break;\n\n        if (!currentSessionId || currentSessionId.startsWith('new-session-')) {\n          sessionStorage.setItem('pendingSessionId', newSessionId);\n          if (pendingViewSessionRef.current && !pendingViewSessionRef.current.sessionId) {\n            pendingViewSessionRef.current.sessionId = newSessionId;\n          }\n          setCurrentSessionId(newSessionId);\n          onReplaceTemporarySession?.(newSessionId);\n          setPendingPermissionRequests((prev) =>\n            prev.map((r) => (r.sessionId ? r : { ...r, sessionId: newSessionId })),\n          );\n        }\n        onNavigateToSession?.(newSessionId);\n        break;\n      }\n\n      case 'complete': {\n        // Flush any remaining streaming state\n        if (streamTimerRef.current) {\n          clearTimeout(streamTimerRef.current);\n          streamTimerRef.current = null;\n        }\n        if (sid && accumulatedStreamRef.current) {\n          sessionStore.updateStreaming(sid, accumulatedStreamRef.current, provider);\n          sessionStore.finalizeStreaming(sid);\n        }\n        accumulatedStreamRef.current = '';\n        streamBufferRef.current = '';\n\n        setIsLoading(false);\n        setCanAbortSession(false);\n        setClaudeStatus(null);\n        setPendingPermissionRequests([]);\n        onSessionInactive?.(sid);\n        onSessionNotProcessing?.(sid);\n\n        // Handle aborted case\n        if (msg.aborted) {\n          // Abort was requested — the complete event confirms it\n          // No special UI action needed beyond clearing loading state above\n          // The backend already sent any abort-related messages\n          break;\n        }\n\n        // Clear pending session\n        const pendingSessionId = sessionStorage.getItem('pendingSessionId');\n        if (pendingSessionId && !currentSessionId && msg.exitCode === 0) {\n          const actualId = msg.actualSessionId || pendingSessionId;\n          setCurrentSessionId(actualId);\n          if (msg.actualSessionId) {\n            onNavigateToSession?.(actualId);\n          }\n          sessionStorage.removeItem('pendingSessionId');\n          if (window.refreshProjects) {\n            setTimeout(() => window.refreshProjects?.(), 500);\n          }\n        }\n        break;\n      }\n\n      case 'error': {\n        setIsLoading(false);\n        setCanAbortSession(false);\n        setClaudeStatus(null);\n        onSessionInactive?.(sid);\n        onSessionNotProcessing?.(sid);\n        break;\n      }\n\n      case 'permission_request': {\n        if (!msg.requestId) break;\n        setPendingPermissionRequests((prev) => {\n          if (prev.some((r: PendingPermissionRequest) => r.requestId === msg.requestId)) return prev;\n          return [...prev, {\n            requestId: msg.requestId,\n            toolName: msg.toolName || 'UnknownTool',\n            input: msg.input,\n            context: msg.context,\n            sessionId: sid || null,\n            receivedAt: new Date(),\n          }];\n        });\n        setIsLoading(true);\n        setCanAbortSession(true);\n        setClaudeStatus({ text: 'Waiting for permission', tokens: 0, can_interrupt: true });\n        break;\n      }\n\n      case 'permission_cancelled': {\n        if (msg.requestId) {\n          setPendingPermissionRequests((prev) => prev.filter((r: PendingPermissionRequest) => r.requestId !== msg.requestId));\n        }\n        break;\n      }\n\n      case 'status': {\n        if (msg.text === 'token_budget' && msg.tokenBudget) {\n          setTokenBudget(msg.tokenBudget as Record<string, unknown>);\n        } else if (msg.text) {\n          setClaudeStatus({\n            text: msg.text,\n            tokens: msg.tokens || 0,\n            can_interrupt: msg.canInterrupt !== undefined ? msg.canInterrupt : true,\n          });\n          setIsLoading(true);\n          setCanAbortSession(msg.canInterrupt !== false);\n        }\n        break;\n      }\n\n      // text, tool_use, tool_result, thinking, interactive_prompt, task_notification\n      // → already routed to store above, no UI side effects needed\n      default:\n        break;\n    }\n  }, [\n    latestMessage,\n    provider,\n    selectedProject,\n    selectedSession,\n    currentSessionId,\n    setCurrentSessionId,\n    setIsLoading,\n    setCanAbortSession,\n    setClaudeStatus,\n    setTokenBudget,\n    setPendingPermissionRequests,\n    pendingViewSessionRef,\n    streamBufferRef,\n    streamTimerRef,\n    accumulatedStreamRef,\n    onSessionInactive,\n    onSessionProcessing,\n    onSessionNotProcessing,\n    onReplaceTemporarySession,\n    onNavigateToSession,\n    onWebSocketReconnect,\n    sessionStore,\n  ]);\n}\n"
  },
  {
    "path": "src/components/chat/hooks/useChatSessionState.ts",
    "content": "import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport type { MutableRefObject } from 'react';\nimport { authenticatedFetch } from '../../../utils/api';\nimport type { ChatMessage, Provider } from '../types/types';\nimport type { Project, ProjectSession, SessionProvider } from '../../../types/app';\nimport { createCachedDiffCalculator, type DiffCalculator } from '../utils/messageTransforms';\nimport { normalizedToChatMessages } from './useChatMessages';\nimport type { SessionStore, NormalizedMessage } from '../../../stores/useSessionStore';\n\nconst MESSAGES_PER_PAGE = 20;\nconst INITIAL_VISIBLE_MESSAGES = 100;\n\ntype PendingViewSession = {\n  sessionId: string | null;\n  startedAt: number;\n};\n\ninterface UseChatSessionStateArgs {\n  selectedProject: Project | null;\n  selectedSession: ProjectSession | null;\n  ws: WebSocket | null;\n  sendMessage: (message: unknown) => void;\n  autoScrollToBottom?: boolean;\n  externalMessageUpdate?: number;\n  processingSessions?: Set<string>;\n  resetStreamingState: () => void;\n  pendingViewSessionRef: MutableRefObject<PendingViewSession | null>;\n  sessionStore: SessionStore;\n}\n\ninterface ScrollRestoreState {\n  height: number;\n  top: number;\n}\n\n/* ------------------------------------------------------------------ */\n/*  Helper: Convert a ChatMessage to a NormalizedMessage for the store */\n/* ------------------------------------------------------------------ */\n\nfunction chatMessageToNormalized(\n  msg: ChatMessage,\n  sessionId: string,\n  provider: SessionProvider,\n): NormalizedMessage | null {\n  const id = `local_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;\n  const ts = msg.timestamp instanceof Date\n    ? msg.timestamp.toISOString()\n    : typeof msg.timestamp === 'number'\n      ? new Date(msg.timestamp).toISOString()\n      : String(msg.timestamp);\n  const base = { id, sessionId, timestamp: ts, provider };\n\n  if (msg.isToolUse) {\n    return {\n      ...base,\n      kind: 'tool_use',\n      toolName: msg.toolName,\n      toolInput: msg.toolInput,\n      toolId: msg.toolId || id,\n    } as NormalizedMessage;\n  }\n  if (msg.isThinking) {\n    return { ...base, kind: 'thinking', content: msg.content || '' } as NormalizedMessage;\n  }\n  if (msg.isInteractivePrompt) {\n    return { ...base, kind: 'interactive_prompt', content: msg.content || '' } as NormalizedMessage;\n  }\n  if ((msg as any).isTaskNotification) {\n    return {\n      ...base,\n      kind: 'task_notification',\n      status: (msg as any).taskStatus || 'completed',\n      summary: msg.content || '',\n    } as NormalizedMessage;\n  }\n  if (msg.type === 'error') {\n    return { ...base, kind: 'error', content: msg.content || '' } as NormalizedMessage;\n  }\n  return {\n    ...base,\n    kind: 'text',\n    role: msg.type === 'user' ? 'user' : 'assistant',\n    content: msg.content || '',\n  } as NormalizedMessage;\n}\n\n/* ------------------------------------------------------------------ */\n/*  Hook                                                              */\n/* ------------------------------------------------------------------ */\n\nexport function useChatSessionState({\n  selectedProject,\n  selectedSession,\n  ws,\n  sendMessage,\n  autoScrollToBottom,\n  externalMessageUpdate,\n  processingSessions,\n  resetStreamingState,\n  pendingViewSessionRef,\n  sessionStore,\n}: UseChatSessionStateArgs) {\n  const [isLoading, setIsLoading] = useState(false);\n  const [currentSessionId, setCurrentSessionId] = useState<string | null>(selectedSession?.id || null);\n  const [isLoadingSessionMessages, setIsLoadingSessionMessages] = useState(false);\n  const [isLoadingMoreMessages, setIsLoadingMoreMessages] = useState(false);\n  const [hasMoreMessages, setHasMoreMessages] = useState(false);\n  const [totalMessages, setTotalMessages] = useState(0);\n  const [canAbortSession, setCanAbortSession] = useState(false);\n  const [isUserScrolledUp, setIsUserScrolledUp] = useState(false);\n  const [tokenBudget, setTokenBudget] = useState<Record<string, unknown> | null>(null);\n  const [visibleMessageCount, setVisibleMessageCount] = useState(INITIAL_VISIBLE_MESSAGES);\n  const [claudeStatus, setClaudeStatus] = useState<{ text: string; tokens: number; can_interrupt: boolean } | null>(null);\n  const [allMessagesLoaded, setAllMessagesLoaded] = useState(false);\n  const [isLoadingAllMessages, setIsLoadingAllMessages] = useState(false);\n  const [loadAllJustFinished, setLoadAllJustFinished] = useState(false);\n  const [showLoadAllOverlay, setShowLoadAllOverlay] = useState(false);\n  const [viewHiddenCount, setViewHiddenCount] = useState(0);\n\n  const scrollContainerRef = useRef<HTMLDivElement>(null);\n  const [searchTarget, setSearchTarget] = useState<{ timestamp?: string; uuid?: string; snippet?: string } | null>(null);\n  const searchScrollActiveRef = useRef(false);\n  const isLoadingSessionRef = useRef(false);\n  const isLoadingMoreRef = useRef(false);\n  const allMessagesLoadedRef = useRef(false);\n  const topLoadLockRef = useRef(false);\n  const pendingScrollRestoreRef = useRef<ScrollRestoreState | null>(null);\n  const pendingInitialScrollRef = useRef(true);\n  const messagesOffsetRef = useRef(0);\n  const scrollPositionRef = useRef({ height: 0, top: 0 });\n  const loadAllFinishedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const loadAllOverlayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const lastLoadedSessionKeyRef = useRef<string | null>(null);\n\n  const createDiff = useMemo<DiffCalculator>(() => createCachedDiffCalculator(), []);\n\n  /* ---------------------------------------------------------------- */\n  /*  Derive chatMessages from the store                              */\n  /* ---------------------------------------------------------------- */\n\n  const activeSessionId = selectedSession?.id || currentSessionId || null;\n  const [pendingUserMessage, setPendingUserMessage] = useState<ChatMessage | null>(null);\n\n  // Tell the store which session we're viewing so it only re-renders for this one\n  const prevActiveForStoreRef = useRef<string | null>(null);\n  if (activeSessionId !== prevActiveForStoreRef.current) {\n    prevActiveForStoreRef.current = activeSessionId;\n    sessionStore.setActiveSession(activeSessionId);\n  }\n\n  // When a real session ID arrives and we have a pending user message, flush it to the store\n  const prevActiveSessionRef = useRef<string | null>(null);\n  if (activeSessionId && activeSessionId !== prevActiveSessionRef.current && pendingUserMessage) {\n    const prov = (localStorage.getItem('selected-provider') as SessionProvider) || 'claude';\n    const normalized = chatMessageToNormalized(pendingUserMessage, activeSessionId, prov);\n    if (normalized) {\n      sessionStore.appendRealtime(activeSessionId, normalized);\n    }\n    setPendingUserMessage(null);\n  }\n  prevActiveSessionRef.current = activeSessionId;\n\n  const storeMessages = activeSessionId ? sessionStore.getMessages(activeSessionId) : [];\n\n  // Reset viewHiddenCount when store messages change\n  const prevStoreLenRef = useRef(0);\n  if (storeMessages.length !== prevStoreLenRef.current) {\n    prevStoreLenRef.current = storeMessages.length;\n    if (viewHiddenCount > 0) setViewHiddenCount(0);\n  }\n\n  const chatMessages = useMemo(() => {\n    const all = normalizedToChatMessages(storeMessages);\n    // Show pending user message when no session data exists yet (new session, pre-backend-response)\n    if (pendingUserMessage && all.length === 0) {\n      return [pendingUserMessage];\n    }\n    if (viewHiddenCount > 0 && viewHiddenCount < all.length) return all.slice(0, -viewHiddenCount);\n    return all;\n  }, [storeMessages, viewHiddenCount, pendingUserMessage]);\n\n  /* ---------------------------------------------------------------- */\n  /*  addMessage / clearMessages / rewindMessages                     */\n  /* ---------------------------------------------------------------- */\n\n  const addMessage = useCallback((msg: ChatMessage) => {\n    if (!activeSessionId) {\n      // No session yet — show as pending until the backend creates one\n      setPendingUserMessage(msg);\n      return;\n    }\n    const prov = (localStorage.getItem('selected-provider') as SessionProvider) || 'claude';\n    const normalized = chatMessageToNormalized(msg, activeSessionId, prov);\n    if (normalized) {\n      sessionStore.appendRealtime(activeSessionId, normalized);\n    }\n  }, [activeSessionId, sessionStore]);\n\n  const clearMessages = useCallback(() => {\n    if (!activeSessionId) return;\n    sessionStore.clearRealtime(activeSessionId);\n  }, [activeSessionId, sessionStore]);\n\n  const rewindMessages = useCallback((count: number) => setViewHiddenCount(count), []);\n\n  const scrollToBottom = useCallback(() => {\n    const container = scrollContainerRef.current;\n    if (!container) return;\n    container.scrollTop = container.scrollHeight;\n  }, []);\n\n  const scrollToBottomAndReset = useCallback(() => {\n    scrollToBottom();\n    if (allMessagesLoaded) {\n      setVisibleMessageCount(INITIAL_VISIBLE_MESSAGES);\n      setAllMessagesLoaded(false);\n      allMessagesLoadedRef.current = false;\n    }\n  }, [allMessagesLoaded, scrollToBottom]);\n\n  const isNearBottom = useCallback(() => {\n    const container = scrollContainerRef.current;\n    if (!container) return false;\n    const { scrollTop, scrollHeight, clientHeight } = container;\n    return scrollHeight - scrollTop - clientHeight < 50;\n  }, []);\n\n  const loadOlderMessages = useCallback(\n    async (container: HTMLDivElement) => {\n      if (!container || isLoadingMoreRef.current || isLoadingMoreMessages) return false;\n      if (allMessagesLoadedRef.current) return false;\n      if (!hasMoreMessages || !selectedSession || !selectedProject) return false;\n\n      const sessionProvider = selectedSession.__provider || 'claude';\n      if (sessionProvider === 'cursor') return false;\n\n      isLoadingMoreRef.current = true;\n      const previousScrollHeight = container.scrollHeight;\n      const previousScrollTop = container.scrollTop;\n\n      try {\n        const slot = await sessionStore.fetchMore(selectedSession.id, {\n          provider: sessionProvider as SessionProvider,\n          projectName: selectedProject.name,\n          projectPath: selectedProject.fullPath || selectedProject.path || '',\n          limit: MESSAGES_PER_PAGE,\n        });\n        if (!slot || slot.serverMessages.length === 0) return false;\n\n        pendingScrollRestoreRef.current = { height: previousScrollHeight, top: previousScrollTop };\n        setHasMoreMessages(slot.hasMore);\n        setTotalMessages(slot.total);\n        setVisibleMessageCount((prev) => prev + MESSAGES_PER_PAGE);\n        return true;\n      } finally {\n        isLoadingMoreRef.current = false;\n      }\n    },\n    [hasMoreMessages, isLoadingMoreMessages, selectedProject, selectedSession, sessionStore],\n  );\n\n  const handleScroll = useCallback(async () => {\n    const container = scrollContainerRef.current;\n    if (!container) return;\n\n    const nearBottom = isNearBottom();\n    setIsUserScrolledUp(!nearBottom);\n\n    if (!allMessagesLoadedRef.current) {\n      const scrolledNearTop = container.scrollTop < 100;\n      if (!scrolledNearTop) { topLoadLockRef.current = false; return; }\n      if (topLoadLockRef.current) {\n        if (container.scrollTop > 20) topLoadLockRef.current = false;\n        return;\n      }\n      const didLoad = await loadOlderMessages(container);\n      if (didLoad) topLoadLockRef.current = true;\n    }\n  }, [isNearBottom, loadOlderMessages]);\n\n  useLayoutEffect(() => {\n    if (!pendingScrollRestoreRef.current || !scrollContainerRef.current) return;\n    const { height, top } = pendingScrollRestoreRef.current;\n    const container = scrollContainerRef.current;\n    const newScrollHeight = container.scrollHeight;\n    container.scrollTop = top + Math.max(newScrollHeight - height, 0);\n    pendingScrollRestoreRef.current = null;\n  }, [chatMessages.length]);\n\n  // Reset scroll/pagination state on session change\n  useEffect(() => {\n    if (!searchScrollActiveRef.current) {\n      pendingInitialScrollRef.current = true;\n      setVisibleMessageCount(INITIAL_VISIBLE_MESSAGES);\n    }\n    topLoadLockRef.current = false;\n    pendingScrollRestoreRef.current = null;\n    setIsUserScrolledUp(false);\n  }, [selectedProject?.name, selectedSession?.id]);\n\n  // Initial scroll to bottom\n  useEffect(() => {\n    if (!pendingInitialScrollRef.current || !scrollContainerRef.current || isLoadingSessionMessages) return;\n    if (chatMessages.length === 0) { pendingInitialScrollRef.current = false; return; }\n    pendingInitialScrollRef.current = false;\n    if (!searchScrollActiveRef.current) setTimeout(() => scrollToBottom(), 200);\n  }, [chatMessages.length, isLoadingSessionMessages, scrollToBottom]);\n\n  // Main session loading effect — store-based\n  useEffect(() => {\n    if (!selectedSession || !selectedProject) {\n      resetStreamingState();\n      pendingViewSessionRef.current = null;\n      setClaudeStatus(null);\n      setCanAbortSession(false);\n      setIsLoading(false);\n      setCurrentSessionId(null);\n      sessionStorage.removeItem('cursorSessionId');\n      messagesOffsetRef.current = 0;\n      setHasMoreMessages(false);\n      setTotalMessages(0);\n      setTokenBudget(null);\n      lastLoadedSessionKeyRef.current = null;\n      return;\n    }\n\n    const provider = (selectedSession.__provider || localStorage.getItem('selected-provider') as Provider) || 'claude';\n    const sessionKey = `${selectedSession.id}:${selectedProject.name}:${provider}`;\n\n    // Skip if already loaded and fresh\n    if (lastLoadedSessionKeyRef.current === sessionKey && sessionStore.has(selectedSession.id) && !sessionStore.isStale(selectedSession.id)) {\n      return;\n    }\n\n    const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id;\n    if (sessionChanged) {\n      resetStreamingState();\n      pendingViewSessionRef.current = null;\n      setClaudeStatus(null);\n      setCanAbortSession(false);\n    }\n\n    // Reset pagination/scroll state\n    messagesOffsetRef.current = 0;\n    setHasMoreMessages(false);\n    setTotalMessages(0);\n    setVisibleMessageCount(INITIAL_VISIBLE_MESSAGES);\n    setAllMessagesLoaded(false);\n    allMessagesLoadedRef.current = false;\n    setIsLoadingAllMessages(false);\n    setLoadAllJustFinished(false);\n    setShowLoadAllOverlay(false);\n    setViewHiddenCount(0);\n    if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current);\n    if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current);\n\n    if (sessionChanged) {\n      setTokenBudget(null);\n      setIsLoading(false);\n    }\n\n    setCurrentSessionId(selectedSession.id);\n    if (provider === 'cursor') {\n      sessionStorage.setItem('cursorSessionId', selectedSession.id);\n    }\n\n    // Check session status\n    if (ws) {\n      sendMessage({ type: 'check-session-status', sessionId: selectedSession.id, provider });\n    }\n\n    lastLoadedSessionKeyRef.current = sessionKey;\n\n    // Fetch from server → store updates → chatMessages re-derives automatically\n    setIsLoadingSessionMessages(true);\n    sessionStore.fetchFromServer(selectedSession.id, {\n      provider: (selectedSession.__provider || provider) as SessionProvider,\n      projectName: selectedProject.name,\n      projectPath: selectedProject.fullPath || selectedProject.path || '',\n      limit: MESSAGES_PER_PAGE,\n      offset: 0,\n    }).then(slot => {\n      if (slot) {\n        setHasMoreMessages(slot.hasMore);\n        setTotalMessages(slot.total);\n        if (slot.tokenUsage) setTokenBudget(slot.tokenUsage as Record<string, unknown>);\n      }\n      setIsLoadingSessionMessages(false);\n    }).catch(() => {\n      setIsLoadingSessionMessages(false);\n    });\n  }, [\n    pendingViewSessionRef,\n    resetStreamingState,\n    selectedProject,\n    selectedSession?.id,\n    sendMessage,\n    ws,\n    sessionStore,\n  ]);\n\n  // External message update (e.g. WebSocket reconnect, background refresh)\n  useEffect(() => {\n    if (!externalMessageUpdate || !selectedSession || !selectedProject) return;\n\n    const reloadExternalMessages = async () => {\n      try {\n        const provider = (localStorage.getItem('selected-provider') as Provider) || 'claude';\n\n        // Skip store refresh during active streaming\n        if (!isLoading) {\n          await sessionStore.refreshFromServer(selectedSession.id, {\n            provider: (selectedSession.__provider || provider) as SessionProvider,\n            projectName: selectedProject.name,\n            projectPath: selectedProject.fullPath || selectedProject.path || '',\n          });\n\n          if (Boolean(autoScrollToBottom) && isNearBottom()) {\n            setTimeout(() => scrollToBottom(), 200);\n          }\n        }\n      } catch (error) {\n        console.error('Error reloading messages from external update:', error);\n      }\n    };\n\n    reloadExternalMessages();\n  }, [\n    autoScrollToBottom,\n    externalMessageUpdate,\n    isNearBottom,\n    scrollToBottom,\n    selectedProject,\n    selectedSession,\n    sessionStore,\n    isLoading,\n  ]);\n\n  // Search navigation target\n  useEffect(() => {\n    const session = selectedSession as Record<string, unknown> | null;\n    const targetSnippet = session?.__searchTargetSnippet;\n    const targetTimestamp = session?.__searchTargetTimestamp;\n    if (typeof targetSnippet === 'string' && targetSnippet) {\n      searchScrollActiveRef.current = true;\n      setSearchTarget({\n        snippet: targetSnippet,\n        timestamp: typeof targetTimestamp === 'string' ? targetTimestamp : undefined,\n      });\n    }\n  }, [selectedSession]);\n\n  useEffect(() => {\n    if (selectedSession?.id) pendingViewSessionRef.current = null;\n  }, [pendingViewSessionRef, selectedSession?.id]);\n\n  // Scroll to search target\n  useEffect(() => {\n    if (!searchTarget || chatMessages.length === 0 || isLoadingSessionMessages) return;\n\n    const target = searchTarget;\n    setSearchTarget(null);\n\n    const scrollToTarget = async () => {\n      if (!allMessagesLoadedRef.current && selectedSession && selectedProject) {\n        const sessionProvider = selectedSession.__provider || 'claude';\n        if (sessionProvider !== 'cursor') {\n          try {\n            // Load all messages into the store for search navigation\n            const slot = await sessionStore.fetchFromServer(selectedSession.id, {\n              provider: sessionProvider as SessionProvider,\n              projectName: selectedProject.name,\n              projectPath: selectedProject.fullPath || selectedProject.path || '',\n              limit: null,\n              offset: 0,\n            });\n            if (slot) {\n              setHasMoreMessages(false);\n              setTotalMessages(slot.total);\n              messagesOffsetRef.current = slot.total;\n              setVisibleMessageCount(Infinity);\n              setAllMessagesLoaded(true);\n              allMessagesLoadedRef.current = true;\n              await new Promise(resolve => setTimeout(resolve, 300));\n            }\n          } catch {\n            // Fall through and scroll in current messages\n          }\n        }\n      }\n      setVisibleMessageCount(Infinity);\n\n      const findAndScroll = (retriesLeft: number) => {\n        const container = scrollContainerRef.current;\n        if (!container) return;\n\n        let targetElement: Element | null = null;\n\n        if (target.snippet) {\n          const cleanSnippet = target.snippet.replace(/^\\.{3}/, '').replace(/\\.{3}$/, '').trim();\n          const searchPhrase = cleanSnippet.slice(0, 80).toLowerCase().trim();\n          if (searchPhrase.length >= 10) {\n            const messageElements = container.querySelectorAll('.chat-message');\n            for (const el of messageElements) {\n              const text = (el.textContent || '').toLowerCase();\n              if (text.includes(searchPhrase)) { targetElement = el; break; }\n            }\n          }\n        }\n\n        if (!targetElement && target.timestamp) {\n          const targetDate = new Date(target.timestamp).getTime();\n          const messageElements = container.querySelectorAll('[data-message-timestamp]');\n          let closestDiff = Infinity;\n          for (const el of messageElements) {\n            const ts = el.getAttribute('data-message-timestamp');\n            if (!ts) continue;\n            const diff = Math.abs(new Date(ts).getTime() - targetDate);\n            if (diff < closestDiff) { closestDiff = diff; targetElement = el; }\n          }\n        }\n\n        if (targetElement) {\n          targetElement.scrollIntoView({ block: 'center', behavior: 'smooth' });\n          targetElement.classList.add('search-highlight-flash');\n          setTimeout(() => targetElement?.classList.remove('search-highlight-flash'), 4000);\n          searchScrollActiveRef.current = false;\n        } else if (retriesLeft > 0) {\n          setTimeout(() => findAndScroll(retriesLeft - 1), 200);\n        } else {\n          searchScrollActiveRef.current = false;\n        }\n      };\n\n      setTimeout(() => findAndScroll(15), 150);\n    };\n\n    scrollToTarget();\n  // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [chatMessages.length, isLoadingSessionMessages, searchTarget]);\n\n  // Token usage fetch for Claude\n  useEffect(() => {\n    if (!selectedProject || !selectedSession?.id || selectedSession.id.startsWith('new-session-')) {\n      setTokenBudget(null);\n      return;\n    }\n    const sessionProvider = selectedSession.__provider || 'claude';\n    if (sessionProvider !== 'claude') return;\n\n    const fetchInitialTokenUsage = async () => {\n      try {\n        const url = `/api/projects/${selectedProject.name}/sessions/${selectedSession.id}/token-usage`;\n        const response = await authenticatedFetch(url);\n        if (response.ok) {\n          setTokenBudget(await response.json());\n        } else {\n          setTokenBudget(null);\n        }\n      } catch (error) {\n        console.error('Failed to fetch initial token usage:', error);\n      }\n    };\n    fetchInitialTokenUsage();\n  }, [selectedProject, selectedSession?.id, selectedSession?.__provider]);\n\n  const visibleMessages = useMemo(() => {\n    if (chatMessages.length <= visibleMessageCount) return chatMessages;\n    return chatMessages.slice(-visibleMessageCount);\n  }, [chatMessages, visibleMessageCount]);\n\n  useEffect(() => {\n    if (!autoScrollToBottom && scrollContainerRef.current) {\n      const container = scrollContainerRef.current;\n      scrollPositionRef.current = { height: container.scrollHeight, top: container.scrollTop };\n    }\n  });\n\n  useEffect(() => {\n    if (!scrollContainerRef.current || chatMessages.length === 0) return;\n    if (isLoadingMoreRef.current || isLoadingMoreMessages || pendingScrollRestoreRef.current) return;\n    if (searchScrollActiveRef.current) return;\n\n    if (autoScrollToBottom) {\n      if (!isUserScrolledUp) setTimeout(() => scrollToBottom(), 50);\n      return;\n    }\n\n    const container = scrollContainerRef.current;\n    const prevHeight = scrollPositionRef.current.height;\n    const prevTop = scrollPositionRef.current.top;\n    const newHeight = container.scrollHeight;\n    const heightDiff = newHeight - prevHeight;\n    if (heightDiff > 0 && prevTop > 0) container.scrollTop = prevTop + heightDiff;\n  }, [autoScrollToBottom, chatMessages.length, isLoadingMoreMessages, isUserScrolledUp, scrollToBottom]);\n\n  useEffect(() => {\n    const container = scrollContainerRef.current;\n    if (!container) return;\n    container.addEventListener('scroll', handleScroll);\n    return () => container.removeEventListener('scroll', handleScroll);\n  }, [handleScroll]);\n\n  useEffect(() => {\n    const activeViewSessionId = selectedSession?.id || currentSessionId;\n    if (!activeViewSessionId || !processingSessions) return;\n    const shouldBeProcessing = processingSessions.has(activeViewSessionId);\n    if (shouldBeProcessing && !isLoading) {\n      setIsLoading(true);\n      setCanAbortSession(true);\n    }\n  }, [currentSessionId, isLoading, processingSessions, selectedSession?.id]);\n\n  // \"Load all\" overlay\n  const prevLoadingRef = useRef(false);\n  useEffect(() => {\n    const wasLoading = prevLoadingRef.current;\n    prevLoadingRef.current = isLoadingMoreMessages;\n\n    if (wasLoading && !isLoadingMoreMessages && hasMoreMessages) {\n      if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current);\n      setShowLoadAllOverlay(true);\n      loadAllOverlayTimerRef.current = setTimeout(() => setShowLoadAllOverlay(false), 2000);\n    }\n    if (!hasMoreMessages && !isLoadingMoreMessages) {\n      if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current);\n      setShowLoadAllOverlay(false);\n    }\n    return () => { if (loadAllOverlayTimerRef.current) clearTimeout(loadAllOverlayTimerRef.current); };\n  }, [isLoadingMoreMessages, hasMoreMessages]);\n\n  const loadAllMessages = useCallback(async () => {\n    if (!selectedSession || !selectedProject) return;\n    if (isLoadingAllMessages) return;\n    const sessionProvider = selectedSession.__provider || 'claude';\n    if (sessionProvider === 'cursor') {\n      setVisibleMessageCount(Infinity);\n      setAllMessagesLoaded(true);\n      allMessagesLoadedRef.current = true;\n      setLoadAllJustFinished(true);\n      if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current);\n      loadAllFinishedTimerRef.current = setTimeout(() => { setLoadAllJustFinished(false); setShowLoadAllOverlay(false); }, 1000);\n      return;\n    }\n\n    const requestSessionId = selectedSession.id;\n    allMessagesLoadedRef.current = true;\n    isLoadingMoreRef.current = true;\n    setIsLoadingAllMessages(true);\n    setShowLoadAllOverlay(true);\n\n    const container = scrollContainerRef.current;\n    const previousScrollHeight = container ? container.scrollHeight : 0;\n    const previousScrollTop = container ? container.scrollTop : 0;\n\n    try {\n      const slot = await sessionStore.fetchFromServer(requestSessionId, {\n        provider: sessionProvider as SessionProvider,\n        projectName: selectedProject.name,\n        projectPath: selectedProject.fullPath || selectedProject.path || '',\n        limit: null,\n        offset: 0,\n      });\n\n      if (currentSessionId !== requestSessionId) return;\n\n      if (slot) {\n        if (container) {\n          pendingScrollRestoreRef.current = { height: previousScrollHeight, top: previousScrollTop };\n        }\n\n        setHasMoreMessages(false);\n        setTotalMessages(slot.total);\n        messagesOffsetRef.current = slot.total;\n        setVisibleMessageCount(Infinity);\n        setAllMessagesLoaded(true);\n\n        setLoadAllJustFinished(true);\n        if (loadAllFinishedTimerRef.current) clearTimeout(loadAllFinishedTimerRef.current);\n        loadAllFinishedTimerRef.current = setTimeout(() => { setLoadAllJustFinished(false); setShowLoadAllOverlay(false); }, 1000);\n      } else {\n        allMessagesLoadedRef.current = false;\n        setShowLoadAllOverlay(false);\n      }\n    } catch (error) {\n      console.error('Error loading all messages:', error);\n      allMessagesLoadedRef.current = false;\n      setShowLoadAllOverlay(false);\n    } finally {\n      isLoadingMoreRef.current = false;\n      setIsLoadingAllMessages(false);\n    }\n  }, [selectedSession, selectedProject, isLoadingAllMessages, currentSessionId, sessionStore]);\n\n  const loadEarlierMessages = useCallback(() => {\n    setVisibleMessageCount((prev) => prev + 100);\n  }, []);\n\n  return {\n    chatMessages,\n    addMessage,\n    clearMessages,\n    rewindMessages,\n    isLoading,\n    setIsLoading,\n    currentSessionId,\n    setCurrentSessionId,\n    isLoadingSessionMessages,\n    isLoadingMoreMessages,\n    hasMoreMessages,\n    totalMessages,\n    canAbortSession,\n    setCanAbortSession,\n    isUserScrolledUp,\n    setIsUserScrolledUp,\n    tokenBudget,\n    setTokenBudget,\n    visibleMessageCount,\n    visibleMessages,\n    loadEarlierMessages,\n    loadAllMessages,\n    allMessagesLoaded,\n    isLoadingAllMessages,\n    loadAllJustFinished,\n    showLoadAllOverlay,\n    claudeStatus,\n    setClaudeStatus,\n    createDiff,\n    scrollContainerRef,\n    scrollToBottom,\n    scrollToBottomAndReset,\n    isNearBottom,\n    handleScroll,\n  };\n}\n"
  },
  {
    "path": "src/components/chat/hooks/useFileMentions.tsx",
    "content": "import { useCallback, useEffect, useMemo, useState } from 'react';\nimport type { Dispatch, KeyboardEvent, RefObject, SetStateAction } from 'react';\nimport { api } from '../../../utils/api';\nimport { escapeRegExp } from '../utils/chatFormatting';\nimport type { Project } from '../../../types/app';\n\ninterface ProjectFileNode {\n  name: string;\n  type: 'file' | 'directory';\n  path?: string;\n  children?: ProjectFileNode[];\n}\n\nexport interface MentionableFile {\n  name: string;\n  path: string;\n  relativePath?: string;\n}\n\ninterface UseFileMentionsOptions {\n  selectedProject: Project | null;\n  input: string;\n  setInput: Dispatch<SetStateAction<string>>;\n  textareaRef: RefObject<HTMLTextAreaElement>;\n}\n\nconst flattenFileTree = (files: ProjectFileNode[], basePath = ''): MentionableFile[] => {\n  let flattened: MentionableFile[] = [];\n\n  files.forEach((file) => {\n    const fullPath = basePath ? `${basePath}/${file.name}` : file.name;\n    if (file.type === 'directory' && file.children) {\n      flattened = flattened.concat(flattenFileTree(file.children, fullPath));\n      return;\n    }\n\n    if (file.type === 'file') {\n      flattened.push({\n        name: file.name,\n        path: fullPath,\n        relativePath: file.path,\n      });\n    }\n  });\n\n  return flattened;\n};\n\nexport function useFileMentions({ selectedProject, input, setInput, textareaRef }: UseFileMentionsOptions) {\n  const [fileList, setFileList] = useState<MentionableFile[]>([]);\n  const [fileMentions, setFileMentions] = useState<string[]>([]);\n  const [filteredFiles, setFilteredFiles] = useState<MentionableFile[]>([]);\n  const [showFileDropdown, setShowFileDropdown] = useState(false);\n  const [selectedFileIndex, setSelectedFileIndex] = useState(-1);\n  const [cursorPosition, setCursorPosition] = useState(0);\n  const [atSymbolPosition, setAtSymbolPosition] = useState(-1);\n\n  useEffect(() => {\n    const abortController = new AbortController();\n\n    const fetchProjectFiles = async () => {\n      const projectName = selectedProject?.name;\n      setFileList([]);\n      setFilteredFiles([]);\n      if (!projectName) {\n        return;\n      }\n\n\n      try {\n        const response = await api.getFiles(projectName, { signal: abortController.signal });\n        if (!response.ok) {\n          return;\n        }\n\n        const files = (await response.json()) as ProjectFileNode[];\n        setFileList(flattenFileTree(files));\n      } catch (error) {\n        // Ignore aborts from rapid project switches; we only care about the latest request.\n        if ((error as { name?: string })?.name === 'AbortError') {\n          return;\n        }\n        console.error('Error fetching files:', error);\n      }\n    };\n\n    fetchProjectFiles();\n    return () => {\n      abortController.abort();\n    };\n  }, [selectedProject?.name]);\n\n  useEffect(() => {\n    const textBeforeCursor = input.slice(0, cursorPosition);\n    const lastAtIndex = textBeforeCursor.lastIndexOf('@');\n\n    if (lastAtIndex === -1) {\n      setShowFileDropdown(false);\n      setAtSymbolPosition(-1);\n      return;\n    }\n\n    const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1);\n    if (textAfterAt.includes(' ')) {\n      setShowFileDropdown(false);\n      setAtSymbolPosition(-1);\n      return;\n    }\n\n    setAtSymbolPosition(lastAtIndex);\n    setShowFileDropdown(true);\n    setSelectedFileIndex(-1);\n\n    const matchingFiles = fileList\n      .filter(\n        (file) =>\n          file.name.toLowerCase().includes(textAfterAt.toLowerCase()) ||\n          file.path.toLowerCase().includes(textAfterAt.toLowerCase()),\n      )\n      .slice(0, 10);\n\n    setFilteredFiles(matchingFiles);\n  }, [input, cursorPosition, fileList]);\n\n  const activeFileMentions = useMemo(() => {\n    if (!input || fileMentions.length === 0) {\n      return [];\n    }\n    return fileMentions.filter((path) => input.includes(path));\n  }, [fileMentions, input]);\n\n  const sortedFileMentions = useMemo(() => {\n    if (activeFileMentions.length === 0) {\n      return [];\n    }\n    const uniqueMentions = Array.from(new Set(activeFileMentions));\n    return uniqueMentions.sort((mentionA, mentionB) => mentionB.length - mentionA.length);\n  }, [activeFileMentions]);\n\n  const fileMentionRegex = useMemo(() => {\n    if (sortedFileMentions.length === 0) {\n      return null;\n    }\n    const pattern = sortedFileMentions.map(escapeRegExp).join('|');\n    return new RegExp(`(${pattern})`, 'g');\n  }, [sortedFileMentions]);\n\n  const fileMentionSet = useMemo(() => new Set(sortedFileMentions), [sortedFileMentions]);\n\n  const renderInputWithMentions = useCallback(\n    (text: string) => {\n      if (!text) {\n        return '';\n      }\n      if (!fileMentionRegex) {\n        return text;\n      }\n\n      const parts = text.split(fileMentionRegex);\n      return parts.map((part, index) =>\n        fileMentionSet.has(part) ? (\n          <span\n            key={`mention-${index}`}\n            className=\"-ml-0.5 rounded-md bg-blue-200/70 box-decoration-clone px-0.5 text-transparent dark:bg-blue-300/40\"\n          >\n            {part}\n          </span>\n        ) : (\n          <span key={`text-${index}`}>{part}</span>\n        ),\n      );\n    },\n    [fileMentionRegex, fileMentionSet],\n  );\n\n  const selectFile = useCallback(\n    (file: MentionableFile) => {\n      const textBeforeAt = input.slice(0, atSymbolPosition);\n      const textAfterAtQuery = input.slice(atSymbolPosition);\n      const spaceIndex = textAfterAtQuery.indexOf(' ');\n      const textAfterQuery = spaceIndex !== -1 ? textAfterAtQuery.slice(spaceIndex) : '';\n\n      const newInput = `${textBeforeAt}${file.path} ${textAfterQuery}`;\n      const newCursorPosition = textBeforeAt.length + file.path.length + 1;\n\n      if (textareaRef.current && !textareaRef.current.matches(':focus')) {\n        textareaRef.current.focus();\n      }\n\n      setInput(newInput);\n      setCursorPosition(newCursorPosition);\n      setFileMentions((previousMentions) =>\n        previousMentions.includes(file.path) ? previousMentions : [...previousMentions, file.path],\n      );\n\n      setShowFileDropdown(false);\n      setAtSymbolPosition(-1);\n\n      if (!textareaRef.current) {\n        return;\n      }\n\n      requestAnimationFrame(() => {\n        if (!textareaRef.current) {\n          return;\n        }\n        textareaRef.current.setSelectionRange(newCursorPosition, newCursorPosition);\n        if (!textareaRef.current.matches(':focus')) {\n          textareaRef.current.focus();\n        }\n      });\n    },\n    [input, atSymbolPosition, textareaRef, setInput],\n  );\n\n  const handleFileMentionsKeyDown = useCallback(\n    (event: KeyboardEvent<HTMLTextAreaElement>): boolean => {\n      if (!showFileDropdown || filteredFiles.length === 0) {\n        return false;\n      }\n\n      if (event.key === 'ArrowDown') {\n        event.preventDefault();\n        setSelectedFileIndex((previousIndex) =>\n          previousIndex < filteredFiles.length - 1 ? previousIndex + 1 : 0,\n        );\n        return true;\n      }\n\n      if (event.key === 'ArrowUp') {\n        event.preventDefault();\n        setSelectedFileIndex((previousIndex) =>\n          previousIndex > 0 ? previousIndex - 1 : filteredFiles.length - 1,\n        );\n        return true;\n      }\n\n      if (event.key === 'Tab' || event.key === 'Enter') {\n        event.preventDefault();\n        if (selectedFileIndex >= 0) {\n          selectFile(filteredFiles[selectedFileIndex]);\n        } else if (filteredFiles.length > 0) {\n          selectFile(filteredFiles[0]);\n        }\n        return true;\n      }\n\n      if (event.key === 'Escape') {\n        event.preventDefault();\n        setShowFileDropdown(false);\n        return true;\n      }\n\n      return false;\n    },\n    [showFileDropdown, filteredFiles, selectedFileIndex, selectFile],\n  );\n\n  return {\n    showFileDropdown,\n    filteredFiles,\n    selectedFileIndex,\n    renderInputWithMentions,\n    selectFile,\n    setCursorPosition,\n    handleFileMentionsKeyDown,\n  };\n}\n"
  },
  {
    "path": "src/components/chat/hooks/useSlashCommands.ts",
    "content": "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport type { Dispatch, KeyboardEvent, RefObject, SetStateAction } from 'react';\nimport Fuse from 'fuse.js';\nimport { authenticatedFetch } from '../../../utils/api';\nimport { safeLocalStorage } from '../utils/chatStorage';\nimport type { Project } from '../../../types/app';\n\nconst COMMAND_QUERY_DEBOUNCE_MS = 150;\n\nexport interface SlashCommand {\n  name: string;\n  description?: string;\n  namespace?: string;\n  path?: string;\n  type?: string;\n  metadata?: Record<string, unknown>;\n  [key: string]: unknown;\n}\n\ninterface UseSlashCommandsOptions {\n  selectedProject: Project | null;\n  input: string;\n  setInput: Dispatch<SetStateAction<string>>;\n  textareaRef: RefObject<HTMLTextAreaElement>;\n  onExecuteCommand: (command: SlashCommand, rawInput?: string) => void | Promise<void>;\n}\n\nconst getCommandHistoryKey = (projectName: string) => `command_history_${projectName}`;\n\nconst readCommandHistory = (projectName: string): Record<string, number> => {\n  const history = safeLocalStorage.getItem(getCommandHistoryKey(projectName));\n  if (!history) {\n    return {};\n  }\n\n  try {\n    return JSON.parse(history);\n  } catch (error) {\n    console.error('Error parsing command history:', error);\n    return {};\n  }\n};\n\nconst saveCommandHistory = (projectName: string, history: Record<string, number>) => {\n  safeLocalStorage.setItem(getCommandHistoryKey(projectName), JSON.stringify(history));\n};\n\nconst isPromiseLike = (value: unknown): value is Promise<unknown> =>\n  Boolean(value) && typeof (value as Promise<unknown>).then === 'function';\n\nexport function useSlashCommands({\n  selectedProject,\n  input,\n  setInput,\n  textareaRef,\n  onExecuteCommand,\n}: UseSlashCommandsOptions) {\n  const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);\n  const [filteredCommands, setFilteredCommands] = useState<SlashCommand[]>([]);\n  const [showCommandMenu, setShowCommandMenu] = useState(false);\n  const [commandQuery, setCommandQuery] = useState('');\n  const [selectedCommandIndex, setSelectedCommandIndex] = useState(-1);\n  const [slashPosition, setSlashPosition] = useState(-1);\n\n  const commandQueryTimerRef = useRef<number | null>(null);\n\n  const clearCommandQueryTimer = useCallback(() => {\n    if (commandQueryTimerRef.current !== null) {\n      window.clearTimeout(commandQueryTimerRef.current);\n      commandQueryTimerRef.current = null;\n    }\n  }, []);\n\n  const resetCommandMenuState = useCallback(() => {\n    setShowCommandMenu(false);\n    setSlashPosition(-1);\n    setCommandQuery('');\n    setSelectedCommandIndex(-1);\n    clearCommandQueryTimer();\n  }, [clearCommandQueryTimer]);\n\n  useEffect(() => {\n    const fetchCommands = async () => {\n      if (!selectedProject) {\n        setSlashCommands([]);\n        setFilteredCommands([]);\n        return;\n      }\n\n      try {\n        const response = await authenticatedFetch('/api/commands/list', {\n          method: 'POST',\n          headers: {\n            'Content-Type': 'application/json',\n          },\n          body: JSON.stringify({\n            projectPath: selectedProject.path,\n          }),\n        });\n\n        if (!response.ok) {\n          throw new Error('Failed to fetch commands');\n        }\n\n        const data = await response.json();\n        const allCommands: SlashCommand[] = [\n          ...((data.builtIn || []) as SlashCommand[]).map((command) => ({\n            ...command,\n            type: 'built-in',\n          })),\n          ...((data.custom || []) as SlashCommand[]).map((command) => ({\n            ...command,\n            type: 'custom',\n          })),\n        ];\n\n        const parsedHistory = readCommandHistory(selectedProject.name);\n        const sortedCommands = [...allCommands].sort((commandA, commandB) => {\n          const commandAUsage = parsedHistory[commandA.name] || 0;\n          const commandBUsage = parsedHistory[commandB.name] || 0;\n          return commandBUsage - commandAUsage;\n        });\n\n        setSlashCommands(sortedCommands);\n      } catch (error) {\n        console.error('Error fetching slash commands:', error);\n        setSlashCommands([]);\n      }\n    };\n\n    fetchCommands();\n  }, [selectedProject]);\n\n  useEffect(() => {\n    if (!showCommandMenu) {\n      setSelectedCommandIndex(-1);\n    }\n  }, [showCommandMenu]);\n\n  const fuse = useMemo(() => {\n    if (!slashCommands.length) {\n      return null;\n    }\n\n    return new Fuse(slashCommands, {\n      keys: [\n        { name: 'name', weight: 2 },\n        { name: 'description', weight: 1 },\n      ],\n      threshold: 0.4,\n      includeScore: true,\n      minMatchCharLength: 1,\n    });\n  }, [slashCommands]);\n\n  useEffect(() => {\n    if (!commandQuery) {\n      setFilteredCommands(slashCommands);\n      return;\n    }\n\n    if (!fuse) {\n      setFilteredCommands([]);\n      return;\n    }\n\n    const results = fuse.search(commandQuery);\n    setFilteredCommands(results.map((result) => result.item));\n  }, [commandQuery, slashCommands, fuse]);\n\n  const frequentCommands = useMemo(() => {\n    if (!selectedProject || slashCommands.length === 0) {\n      return [];\n    }\n\n    const parsedHistory = readCommandHistory(selectedProject.name);\n\n    return slashCommands\n      .map((command) => ({\n        ...command,\n        usageCount: parsedHistory[command.name] || 0,\n      }))\n      .filter((command) => command.usageCount > 0)\n      .sort((commandA, commandB) => commandB.usageCount - commandA.usageCount)\n      .slice(0, 5);\n  }, [selectedProject, slashCommands]);\n\n  const trackCommandUsage = useCallback(\n    (command: SlashCommand) => {\n      if (!selectedProject) {\n        return;\n      }\n\n      const parsedHistory = readCommandHistory(selectedProject.name);\n      parsedHistory[command.name] = (parsedHistory[command.name] || 0) + 1;\n      saveCommandHistory(selectedProject.name, parsedHistory);\n    },\n    [selectedProject],\n  );\n\n  const selectCommandFromKeyboard = useCallback(\n    (command: SlashCommand) => {\n      const textBeforeSlash = input.slice(0, slashPosition);\n      const textAfterSlash = input.slice(slashPosition);\n      const spaceIndex = textAfterSlash.indexOf(' ');\n      const textAfterQuery = spaceIndex !== -1 ? textAfterSlash.slice(spaceIndex) : '';\n      const newInput = `${textBeforeSlash}${command.name} ${textAfterQuery}`;\n\n      setInput(newInput);\n      resetCommandMenuState();\n\n      const executionResult = onExecuteCommand(command);\n      if (isPromiseLike(executionResult)) {\n        executionResult.catch(() => {\n          // Keep behavior silent; execution errors are handled by caller.\n        });\n      }\n    },\n    [input, slashPosition, setInput, resetCommandMenuState, onExecuteCommand],\n  );\n\n  const handleCommandSelect = useCallback(\n    (command: SlashCommand | null, index: number, isHover: boolean) => {\n      if (!command || !selectedProject) {\n        return;\n      }\n\n      if (isHover) {\n        setSelectedCommandIndex(index);\n        return;\n      }\n\n      trackCommandUsage(command);\n      const executionResult = onExecuteCommand(command);\n\n      if (isPromiseLike(executionResult)) {\n        executionResult.then(() => {\n          resetCommandMenuState();\n        });\n        executionResult.catch(() => {\n          // Keep behavior silent; execution errors are handled by caller.\n        });\n      } else {\n        resetCommandMenuState();\n      }\n    },\n    [selectedProject, trackCommandUsage, onExecuteCommand, resetCommandMenuState],\n  );\n\n  const handleToggleCommandMenu = useCallback(() => {\n    const isOpening = !showCommandMenu;\n    setShowCommandMenu(isOpening);\n    setCommandQuery('');\n    setSelectedCommandIndex(-1);\n\n    if (isOpening) {\n      setFilteredCommands(slashCommands);\n    }\n\n    textareaRef.current?.focus();\n  }, [showCommandMenu, slashCommands, textareaRef]);\n\n  const handleCommandInputChange = useCallback(\n    (newValue: string, cursorPos: number) => {\n      if (!newValue.trim()) {\n        resetCommandMenuState();\n        return;\n      }\n\n      const textBeforeCursor = newValue.slice(0, cursorPos);\n      const backticksBefore = (textBeforeCursor.match(/```/g) || []).length;\n      const inCodeBlock = backticksBefore % 2 === 1;\n\n      if (inCodeBlock) {\n        resetCommandMenuState();\n        return;\n      }\n\n      const slashPattern = /(^|\\s)\\/(\\S*)$/;\n      const match = textBeforeCursor.match(slashPattern);\n\n      if (!match) {\n        resetCommandMenuState();\n        return;\n      }\n\n      const slashPos = (match.index || 0) + match[1].length;\n      const query = match[2];\n\n      setSlashPosition(slashPos);\n      setShowCommandMenu(true);\n      setSelectedCommandIndex(-1);\n\n      clearCommandQueryTimer();\n      commandQueryTimerRef.current = window.setTimeout(() => {\n        setCommandQuery(query);\n      }, COMMAND_QUERY_DEBOUNCE_MS);\n    },\n    [resetCommandMenuState, clearCommandQueryTimer],\n  );\n\n  const handleCommandMenuKeyDown = useCallback(\n    (event: KeyboardEvent<HTMLTextAreaElement>): boolean => {\n      if (!showCommandMenu) {\n        return false;\n      }\n\n      if (!filteredCommands.length) {\n        if (event.key === 'Escape') {\n          event.preventDefault();\n          resetCommandMenuState();\n          return true;\n        }\n        return false;\n      }\n\n      if (event.key === 'ArrowDown') {\n        event.preventDefault();\n        setSelectedCommandIndex((previousIndex) =>\n          previousIndex < filteredCommands.length - 1 ? previousIndex + 1 : 0,\n        );\n        return true;\n      }\n\n      if (event.key === 'ArrowUp') {\n        event.preventDefault();\n        setSelectedCommandIndex((previousIndex) =>\n          previousIndex > 0 ? previousIndex - 1 : filteredCommands.length - 1,\n        );\n        return true;\n      }\n\n      if (event.key === 'Tab' || event.key === 'Enter') {\n        event.preventDefault();\n        if (selectedCommandIndex >= 0) {\n          selectCommandFromKeyboard(filteredCommands[selectedCommandIndex]);\n        } else if (filteredCommands.length > 0) {\n          selectCommandFromKeyboard(filteredCommands[0]);\n        }\n        return true;\n      }\n\n      if (event.key === 'Escape') {\n        event.preventDefault();\n        resetCommandMenuState();\n        return true;\n      }\n\n      return false;\n    },\n    [showCommandMenu, filteredCommands, resetCommandMenuState, selectCommandFromKeyboard, selectedCommandIndex],\n  );\n\n  useEffect(\n    () => () => {\n      clearCommandQueryTimer();\n    },\n    [clearCommandQueryTimer],\n  );\n\n  return {\n    slashCommands,\n    slashCommandsCount: slashCommands.length,\n    filteredCommands,\n    frequentCommands,\n    commandQuery,\n    showCommandMenu,\n    selectedCommandIndex,\n    resetCommandMenuState,\n    handleCommandSelect,\n    handleToggleCommandMenu,\n    handleCommandInputChange,\n    handleCommandMenuKeyDown,\n  };\n}\n"
  },
  {
    "path": "src/components/chat/tools/README.md",
    "content": "# Tool Rendering System\n\n## Overview\n\nConfig-driven architecture for rendering tool executions in chat. All tool display behavior is defined in `toolConfigs.ts` — no scattered conditionals. Two base display patterns: **OneLineDisplay** for compact tools, **CollapsibleDisplay** for tools with expandable content.\n\nNon-error tool results route through `ToolRenderer` with `mode=\"result\"` (single source of truth). Error results are handled inline in `MessageComponent` with a red error box.\n\n---\n\n## Architecture\n\n```\ntools/\n├── components/\n│   ├── OneLineDisplay.tsx          # Compact one-line tool display\n│   ├── CollapsibleDisplay.tsx      # Expandable tool display (uses children pattern)\n│   ├── CollapsibleSection.tsx      # <details>/<summary> wrapper\n│   ├── ContentRenderers/\n│   │   ├── ToolDiffViewer.tsx          # File diff viewer (memoized)\n│   │   ├── MarkdownContent.tsx     # Markdown renderer\n│   │   ├── FileListContent.tsx     # Comma-separated clickable file list\n│   │   ├── TodoListContent.tsx     # Todo items with status badges\n│   │   ├── TaskListContent.tsx     # Task tracker with progress bar\n│   │   └── TextContent.tsx         # Plain text / JSON / code\n├── configs/\n│   └── toolConfigs.ts              # All tool configs + ToolDisplayConfig type\n├── ToolRenderer.tsx                # Main router (React.memo wrapped)\n└── README.md\n```\n\n---\n\n## Display Patterns\n\n### OneLineDisplay\n\nUsed by: Bash, Read, Grep, Glob, TodoRead, TaskCreate, TaskUpdate, TaskGet\n\nRenders as a single line with `border-l-2` accent. Supports multiple rendering modes based on `action`:\n\n- **terminal** (`style: 'terminal'`) — Dark pill around command text, green `$` prompt\n- **open-file** — Shows filename only (truncated from full path), clickable to open\n- **jump-to-results** — Shows pattern with anchor link to result section\n- **copy** — Shows value with hover copy button\n- **none** — Plain display\n\n```tsx\n<OneLineDisplay\n  toolName=\"Read\"\n  icon=\"terminal\"           // Optional icon or style keyword\n  label=\"Read\"              // Tool label\n  value=\"/path/to/file.ts\"  // Main display value\n  secondary=\"description\"   // Optional secondary text (italic)\n  action=\"open-file\"        // Action type\n  onAction={() => ...}      // Click handler\n  colorScheme={{             // Per-tool colors\n    primary: 'text-...',\n    border: 'border-...',\n    icon: 'text-...'\n  }}\n  resultId=\"tool-result-x\"  // For jump-to-results anchor\n  toolResult={...}          // For conditional jump arrow\n  toolId=\"x\"                // Tool use ID\n/>\n```\n\n### CollapsibleDisplay\n\nUsed by: Edit, Write, ApplyPatch, Grep/Glob results, TodoWrite, TaskList/TaskGet results, ExitPlanMode, Default\n\nWraps `CollapsibleSection` (`<details>`/`<summary>`) with a `border-l-2` accent colored by tool category. Accepts **children** directly (not contentProps).\n\n```tsx\n<CollapsibleDisplay\n  toolName=\"Edit\"\n  toolId=\"123\"\n  title=\"filename.ts\"           // Section title (can be clickable)\n  defaultOpen={false}\n  onTitleClick={() => ...}      // Makes title a clickable link (for edit tools)\n  showRawParameters={true}      // Show raw JSON toggle\n  rawContent=\"...\"              // Raw JSON string\n  toolCategory=\"edit\"           // Drives border color\n>\n  <ToolDiffViewer {...} />          // Content as children\n</CollapsibleDisplay>\n```\n\n**Tool category colors** (via `border-l-2`):\n| Category | Tools | Color |\n|----------|-------|-------|\n| `edit` | Edit, Write, ApplyPatch | amber |\n| `bash` | Bash | green |\n| `search` | Grep, Glob | gray |\n| `todo` | TodoWrite, TodoRead | violet |\n| `task` | TaskCreate/Update/List/Get | violet |\n| `plan` | ExitPlanMode | indigo |\n| `default` | everything else | neutral gray |\n\n---\n\n## Content Renderers\n\nSpecialized components for different content types, rendered as children of `CollapsibleDisplay`:\n\n| contentType | Component | Used by |\n|---|---|---|\n| `diff` | `DiffViewer` | Edit, Write, ApplyPatch |\n| `markdown` | `MarkdownContent` | ExitPlanMode |\n| `file-list` | `FileListContent` | Grep/Glob results |\n| `todo-list` | `TodoListContent` | TodoWrite, TodoRead |\n| `task` | `TaskListContent` | TaskList, TaskGet results |\n| `text` | `TextContent` | Default fallback |\n| `success-message` | inline SVG | TodoWrite result |\n\n---\n\n## Adding a New Tool\n\n**Step 1:** Add config to `configs/toolConfigs.ts`\n\n```typescript\nMyTool: {\n  input: {\n    type: 'one-line',              // or 'collapsible'\n    label: 'MyTool',\n    getValue: (input) => input.some_field,\n    action: 'open-file',\n    colorScheme: {\n      primary: 'text-purple-600 dark:text-purple-400',\n      border: 'border-purple-400 dark:border-purple-500'\n    }\n  },\n  result: {\n    hideOnSuccess: true            // Only show errors\n  }\n}\n```\n\n**Step 2:** If the tool needs a category color, add it to `getToolCategory()` in `ToolRenderer.tsx`.\n\n**That's it.** The ToolRenderer auto-routes based on config.\n\n---\n\n## Configuration Reference\n\n### ToolDisplayConfig\n\n```typescript\ninterface ToolDisplayConfig {\n  input: {\n    type: 'one-line' | 'collapsible' | 'hidden';\n\n    // One-line\n    icon?: string;\n    label?: string;\n    getValue?: (input) => string;\n    getSecondary?: (input) => string | undefined;\n    action?: 'copy' | 'open-file' | 'jump-to-results' | 'none';\n    style?: string;                              // 'terminal' for Bash\n    wrapText?: boolean;\n    colorScheme?: {\n      primary?: string;\n      secondary?: string;\n      background?: string;\n      border?: string;\n      icon?: string;\n    };\n\n    // Collapsible\n    title?: string | ((input) => string);\n    defaultOpen?: boolean;\n    contentType?: 'diff' | 'markdown' | 'file-list' | 'todo-list' | 'text' | 'task';\n    getContentProps?: (input, helpers?) => any;\n    actionButton?: 'none';\n  };\n\n  result?: {\n    hidden?: boolean;                            // Never show\n    hideOnSuccess?: boolean;                     // Only show errors\n    type?: 'one-line' | 'collapsible' | 'special';\n    title?: string | ((result) => string);\n    defaultOpen?: boolean;\n    contentType?: 'markdown' | 'file-list' | 'todo-list' | 'text' | 'success-message' | 'task';\n    getMessage?: (result) => string;\n    getContentProps?: (result) => any;\n  };\n}\n```\n\n---\n\n## All Configured Tools\n\n| Tool | Input | Result | Notes |\n|------|-------|--------|-------|\n| Bash | terminal one-line | hide success | Dark command pill, green accent |\n| Read | one-line (open-file) | hidden | Shows filename, clicks to open |\n| Edit | collapsible (diff) | hide success | Amber border, clickable filename |\n| Write | collapsible (diff) | hide success | \"New\" badge on diff |\n| ApplyPatch | collapsible (diff) | hide success | \"Patch\" badge on diff |\n| Grep | one-line (jump) | collapsible file-list | Collapsed by default |\n| Glob | one-line (jump) | collapsible file-list | Collapsed by default |\n| TodoWrite | collapsible (todo-list) | success message | |\n| TodoRead | one-line | collapsible todo-list | |\n| TaskCreate | one-line | hide success | Shows task subject |\n| TaskUpdate | one-line | hide success | Shows `#id → status` |\n| TaskList | one-line | collapsible task | Progress bar, status icons |\n| TaskGet | one-line | collapsible task | Task details with status |\n| ExitPlanMode | collapsible (markdown) | collapsible markdown | Also registered as `exit_plan_mode` |\n| Default | collapsible (code) | collapsible text | Fallback for unknown tools |\n\n---\n\n## Performance\n\n- **ToolRenderer** is wrapped with `React.memo` — skips re-render when props haven't changed\n- **parsedData** is memoized with `useMemo` — JSON parsing only runs when input changes\n- **ToolDiffViewer** memoizes `createDiff()` — expensive diff computation cached\n- **MessageComponent** caches `localStorage` reads and timestamp formatting via `useMemo`\n- Tool results route through `ToolRenderer` (no duplicate rendering paths)\n- `CollapsibleDisplay` uses children pattern (no wasteful contentProps indirection)\n- Configs are static module-level objects — zero runtime overhead for lookups\n"
  },
  {
    "path": "src/components/chat/tools/ToolRenderer.tsx",
    "content": "import React, { memo, useMemo, useCallback } from 'react';\nimport type { Project } from '../../../types/app';\nimport type { SubagentChildTool } from '../types/types';\nimport { getToolConfig } from './configs/toolConfigs';\nimport { OneLineDisplay, CollapsibleDisplay, ToolDiffViewer, MarkdownContent, FileListContent, TodoListContent, TaskListContent, TextContent, QuestionAnswerContent, SubagentContainer } from './components';\n\ntype DiffLine = {\n  type: string;\n  content: string;\n  lineNum: number;\n};\n\ninterface ToolRendererProps {\n  toolName: string;\n  toolInput: any;\n  toolResult?: any;\n  toolId?: string;\n  mode: 'input' | 'result';\n  onFileOpen?: (filePath: string, diffInfo?: any) => void;\n  createDiff?: (oldStr: string, newStr: string) => DiffLine[];\n  selectedProject?: Project | null;\n  autoExpandTools?: boolean;\n  showRawParameters?: boolean;\n  rawToolInput?: string;\n  isSubagentContainer?: boolean;\n  subagentState?: {\n    childTools: SubagentChildTool[];\n    currentToolIndex: number;\n    isComplete: boolean;\n  };\n}\n\nfunction getToolCategory(toolName: string): string {\n  if (['Edit', 'Write', 'ApplyPatch'].includes(toolName)) return 'edit';\n  if (['Grep', 'Glob'].includes(toolName)) return 'search';\n  if (toolName === 'Bash') return 'bash';\n  if (['TodoWrite', 'TodoRead'].includes(toolName)) return 'todo';\n  if (['TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet'].includes(toolName)) return 'task';\n  if (toolName === 'Task') return 'agent';  // Subagent task\n  if (toolName === 'exit_plan_mode' || toolName === 'ExitPlanMode') return 'plan';\n  if (toolName === 'AskUserQuestion') return 'question';\n  return 'default';\n}\n\n/**\n * Main tool renderer router\n * Routes to OneLineDisplay or CollapsibleDisplay based on tool config\n */\nexport const ToolRenderer: React.FC<ToolRendererProps> = memo(({\n  toolName,\n  toolInput,\n  toolResult,\n  toolId,\n  mode,\n  onFileOpen,\n  createDiff,\n  selectedProject,\n  autoExpandTools = false,\n  showRawParameters = false,\n  rawToolInput,\n  isSubagentContainer,\n  subagentState\n}) => {\n  const config = getToolConfig(toolName);\n  const displayConfig: any = mode === 'input' ? config.input : config.result;\n\n  const parsedData = useMemo(() => {\n    try {\n      const rawData = mode === 'input' ? toolInput : toolResult;\n      return typeof rawData === 'string' ? JSON.parse(rawData) : rawData;\n    } catch {\n      return mode === 'input' ? toolInput : toolResult;\n    }\n  }, [mode, toolInput, toolResult]);\n\n  const handleAction = useCallback(() => {\n    if (displayConfig?.action === 'open-file' && onFileOpen) {\n      const value = displayConfig.getValue?.(parsedData) || '';\n      onFileOpen(value);\n    }\n  }, [displayConfig, parsedData, onFileOpen]);\n\n  // Route subagent containers to dedicated component (after hooks to satisfy Rules of Hooks)\n  if (isSubagentContainer && subagentState) {\n    if (mode === 'result') {\n      return null;\n    }\n    return (\n      <SubagentContainer\n        toolInput={toolInput}\n        toolResult={toolResult}\n        subagentState={subagentState}\n      />\n    );\n  }\n\n  if (!displayConfig) return null;\n\n  if (displayConfig.type === 'one-line') {\n    const value = displayConfig.getValue?.(parsedData) || '';\n    const secondary = displayConfig.getSecondary?.(parsedData);\n\n    return (\n      <OneLineDisplay\n        toolName={toolName}\n        toolResult={toolResult}\n        toolId={toolId}\n        icon={displayConfig.icon}\n        label={displayConfig.label}\n        value={value}\n        secondary={secondary}\n        action={displayConfig.action}\n        onAction={handleAction}\n        style={displayConfig.style}\n        wrapText={displayConfig.wrapText}\n        colorScheme={displayConfig.colorScheme}\n        resultId={mode === 'input' ? `tool-result-${toolId}` : undefined}\n      />\n    );\n  }\n\n  if (displayConfig.type === 'collapsible') {\n    const title = typeof displayConfig.title === 'function'\n      ? displayConfig.title(parsedData)\n      : displayConfig.title || 'Details';\n\n    const defaultOpen = displayConfig.defaultOpen !== undefined\n      ? displayConfig.defaultOpen\n      : autoExpandTools;\n\n    const contentProps = displayConfig.getContentProps?.(parsedData, {\n      selectedProject,\n      createDiff,\n      onFileOpen\n    }) || {};\n\n    // Build the content component based on contentType\n    let contentComponent: React.ReactNode = null;\n\n    switch (displayConfig.contentType) {\n      case 'diff':\n        if (createDiff) {\n          contentComponent = (\n            <ToolDiffViewer\n              {...contentProps}\n              createDiff={createDiff}\n              onFileClick={() => onFileOpen?.(contentProps.filePath)}\n            />\n          );\n        }\n        break;\n\n      case 'markdown':\n        contentComponent = <MarkdownContent content={contentProps.content || ''} />;\n        break;\n\n      case 'file-list':\n        contentComponent = (\n          <FileListContent\n            files={contentProps.files || []}\n            onFileClick={onFileOpen}\n            title={contentProps.title}\n          />\n        );\n        break;\n\n      case 'todo-list':\n        if (contentProps.todos?.length > 0) {\n          contentComponent = (\n            <TodoListContent\n              todos={contentProps.todos}\n              isResult={contentProps.isResult}\n            />\n          );\n        }\n        break;\n\n      case 'task':\n        contentComponent = <TaskListContent content={contentProps.content || ''} />;\n        break;\n\n      case 'question-answer':\n        contentComponent = (\n          <QuestionAnswerContent\n            questions={contentProps.questions || []}\n            answers={contentProps.answers || {}}\n          />\n        );\n        break;\n\n      case 'text':\n        contentComponent = (\n          <TextContent\n            content={contentProps.content || ''}\n            format={contentProps.format || 'plain'}\n          />\n        );\n        break;\n\n      case 'success-message': {\n        const msg = displayConfig.getMessage?.(parsedData) || 'Success';\n        contentComponent = (\n          <div className=\"flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400\">\n            <svg className=\"h-3 w-3\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n              <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M5 13l4 4L19 7\" />\n            </svg>\n            {msg}\n          </div>\n        );\n        break;\n      }\n    }\n\n    // For edit tools, make the title (filename) clickable to open the file\n    const handleTitleClick = (toolName === 'Edit' || toolName === 'Write' || toolName === 'ApplyPatch') && contentProps.filePath && onFileOpen\n      ? () => onFileOpen(contentProps.filePath, {\n          old_string: contentProps.oldContent,\n          new_string: contentProps.newContent\n        })\n      : undefined;\n\n    return (\n      <CollapsibleDisplay\n        toolName={toolName}\n        toolId={toolId}\n        title={title}\n        defaultOpen={defaultOpen}\n        onTitleClick={handleTitleClick}\n        showRawParameters={mode === 'input' && showRawParameters}\n        rawContent={rawToolInput}\n        toolCategory={getToolCategory(toolName)}\n      >\n        {contentComponent}\n      </CollapsibleDisplay>\n    );\n  }\n\n  return null;\n});\n\nToolRenderer.displayName = 'ToolRenderer';\n"
  },
  {
    "path": "src/components/chat/tools/components/CollapsibleDisplay.tsx",
    "content": "import React from 'react';\nimport { CollapsibleSection } from './CollapsibleSection';\n\ninterface CollapsibleDisplayProps {\n  toolName: string;\n  toolId?: string;\n  title: string;\n  defaultOpen?: boolean;\n  action?: React.ReactNode;\n  onTitleClick?: () => void;\n  children: React.ReactNode;\n  showRawParameters?: boolean;\n  rawContent?: string;\n  className?: string;\n  toolCategory?: string;\n}\n\nconst borderColorMap: Record<string, string> = {\n  edit: 'border-l-amber-500 dark:border-l-amber-400',\n  search: 'border-l-gray-400 dark:border-l-gray-500',\n  bash: 'border-l-green-500 dark:border-l-green-400',\n  todo: 'border-l-violet-500 dark:border-l-violet-400',\n  task: 'border-l-violet-500 dark:border-l-violet-400',\n  agent: 'border-l-purple-500 dark:border-l-purple-400',\n  plan: 'border-l-indigo-500 dark:border-l-indigo-400',\n  question: 'border-l-blue-500 dark:border-l-blue-400',\n  default: 'border-l-gray-300 dark:border-l-gray-600',\n};\n\nexport const CollapsibleDisplay: React.FC<CollapsibleDisplayProps> = ({\n  toolName,\n  title,\n  defaultOpen = false,\n  action,\n  onTitleClick,\n  children,\n  showRawParameters = false,\n  rawContent,\n  className = '',\n  toolCategory\n}) => {\n  // Fall back to default styling for unknown/new categories so className never includes \"undefined\".\n  const borderColor = borderColorMap[toolCategory || 'default'] || borderColorMap.default;\n\n  return (\n    <div className={`border-l-2 ${borderColor} my-1 py-0.5 pl-3 ${className}`}>\n      <CollapsibleSection\n        title={title}\n        toolName={toolName}\n        open={defaultOpen}\n        action={action}\n        onTitleClick={onTitleClick}\n      >\n        {children}\n\n        {showRawParameters && rawContent && (\n          <details className=\"group/raw relative mt-2\">\n            <summary className=\"flex cursor-pointer items-center gap-1.5 py-0.5 text-[11px] text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300\">\n              <svg\n                className=\"h-2.5 w-2.5 transition-transform duration-150 group-open/raw:rotate-90\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                viewBox=\"0 0 24 24\"\n              >\n                <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M9 5l7 7-7 7\" />\n              </svg>\n              raw params\n            </summary>\n            <pre className=\"mt-1 overflow-hidden whitespace-pre-wrap break-words rounded border border-gray-200/40 bg-gray-50 p-2 font-mono text-[11px] text-gray-600 dark:border-gray-700/40 dark:bg-gray-900/50 dark:text-gray-400\">\n              {rawContent}\n            </pre>\n          </details>\n        )}\n      </CollapsibleSection>\n    </div>\n  );\n};\n"
  },
  {
    "path": "src/components/chat/tools/components/CollapsibleSection.tsx",
    "content": "import React from 'react';\n\ninterface CollapsibleSectionProps {\n  title: string;\n  toolName?: string;\n  open?: boolean;\n  action?: React.ReactNode;\n  onTitleClick?: () => void;\n  children: React.ReactNode;\n  className?: string;\n}\n\n/**\n * Reusable collapsible section with consistent styling\n */\nexport const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({\n  title,\n  toolName,\n  open = false,\n  action,\n  onTitleClick,\n  children,\n  className = ''\n}) => {\n  return (\n    <details className={`group/details relative ${className}`} open={open}>\n      <summary className=\"flex cursor-pointer select-none items-center gap-1.5 py-0.5 text-xs group-open/details:sticky group-open/details:top-0 group-open/details:z-10 group-open/details:-mx-1 group-open/details:bg-background group-open/details:px-1\">\n        <svg\n          className=\"h-3 w-3 flex-shrink-0 text-gray-400 transition-transform duration-150 group-open/details:rotate-90 dark:text-gray-500\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          viewBox=\"0 0 24 24\"\n        >\n          <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M9 5l7 7-7 7\" />\n        </svg>\n        {toolName && (\n          <span className=\"flex-shrink-0 font-medium text-gray-500 dark:text-gray-400\">{toolName}</span>\n        )}\n        {toolName && (\n          <span className=\"flex-shrink-0 text-[10px] text-gray-300 dark:text-gray-600\">/</span>\n        )}\n        {onTitleClick ? (\n          <button\n            onClick={(e) => { e.preventDefault(); e.stopPropagation(); onTitleClick(); }}\n            className=\"flex-1 truncate text-left font-mono text-blue-600 transition-colors hover:text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300\"\n          >\n            {title}\n          </button>\n        ) : (\n          <span className=\"flex-1 truncate text-gray-600 dark:text-gray-400\">\n            {title}\n          </span>\n        )}\n        {action && <span className=\"ml-1 flex-shrink-0\">{action}</span>}\n      </summary>\n      <div className=\"mt-1.5 pl-[18px]\">\n        {children}\n      </div>\n    </details>\n  );\n};\n"
  },
  {
    "path": "src/components/chat/tools/components/ContentRenderers/FileListContent.tsx",
    "content": "import React from 'react';\n\ninterface FileListItem {\n  path: string;\n  onClick?: () => void;\n}\n\ninterface FileListContentProps {\n  files: string[] | FileListItem[];\n  onFileClick?: (filePath: string) => void;\n  title?: string;\n}\n\n/**\n * Renders a compact comma-separated list of clickable file names\n * Used by: Grep/Glob results\n */\nexport const FileListContent: React.FC<FileListContentProps> = ({\n  files,\n  onFileClick,\n  title\n}) => {\n  return (\n    <div>\n      {title && (\n        <div className=\"mb-1 text-[11px] text-gray-500 dark:text-gray-400\">\n          {title}\n        </div>\n      )}\n      <div className=\"flex max-h-48 flex-wrap gap-x-1 gap-y-0.5 overflow-y-auto\">\n        {files.map((file, index) => {\n          const filePath = typeof file === 'string' ? file : file.path;\n          const fileName = filePath.split('/').pop() || filePath;\n          const handleClick = typeof file === 'string'\n            ? () => onFileClick?.(file)\n            : file.onClick;\n\n          return (\n            <span key={index} className=\"inline-flex items-center\">\n              <button\n                onClick={handleClick}\n                className=\"font-mono text-[11px] text-blue-600 transition-colors hover:text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300\"\n                title={filePath}\n              >\n                {fileName}\n              </button>\n              {index < files.length - 1 && (\n                <span className=\"ml-1 text-[10px] text-gray-300 dark:text-gray-600\">,</span>\n              )}\n            </span>\n          );\n        })}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "src/components/chat/tools/components/ContentRenderers/MarkdownContent.tsx",
    "content": "import React from 'react';\nimport { Markdown } from '../../../view/subcomponents/Markdown';\n\ninterface MarkdownContentProps {\n  content: string;\n  className?: string;\n}\n\n/**\n * Renders markdown content with proper styling\n * Used by: exit_plan_mode, long text results, etc.\n */\nexport const MarkdownContent: React.FC<MarkdownContentProps> = ({\n  content,\n  className = 'mt-1 prose prose-sm max-w-none dark:prose-invert'\n}) => {\n  return (\n    <Markdown className={className}>\n      {content}\n    </Markdown>\n  );\n};\n"
  },
  {
    "path": "src/components/chat/tools/components/ContentRenderers/QuestionAnswerContent.tsx",
    "content": "import React, { useState } from 'react';\nimport type { Question } from '../../../types/types';\n\ninterface QuestionAnswerContentProps {\n  questions: Question[];\n  answers: Record<string, string>;\n  className?: string;\n}\n\n// Exception to the stateless ContentRenderer pattern: multi-question navigation requires local state.\nexport const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({\n  questions,\n  answers,\n  className = '',\n}) => {\n  const [expandedIdx, setExpandedIdx] = useState<number | null>(null);\n\n  if (!questions || questions.length === 0) {\n    return null;\n  }\n\n  const hasAnyAnswer = Object.keys(answers || {}).length > 0;\n  const total = questions.length;\n\n  return (\n    <div className={`space-y-2 ${className}`}>\n      {questions.map((q, idx) => {\n        const answer = answers?.[q.question];\n        const answerLabels = answer ? answer.split(', ') : [];\n        const skipped = !answer;\n        const isExpanded = expandedIdx === idx;\n\n        return (\n          <div\n            key={idx}\n            className=\"border-gray-150 overflow-hidden rounded-lg border bg-gray-50/50 dark:border-gray-700/50 dark:bg-gray-800/30\"\n          >\n            <button\n              type=\"button\"\n              onClick={() => setExpandedIdx(isExpanded ? null : idx)}\n              className=\"flex w-full items-start gap-2.5 px-3 py-2 text-left transition-colors hover:bg-gray-50 dark:hover:bg-gray-800/50\"\n            >\n              <div className={`mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full ${\n                answerLabels.length > 0\n                  ? 'bg-blue-100 dark:bg-blue-900/40'\n                  : 'bg-gray-100 dark:bg-gray-800'\n              }`}>\n                {answerLabels.length > 0 ? (\n                  <svg className=\"h-2.5 w-2.5 text-blue-600 dark:text-blue-400\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" strokeWidth={3}>\n                    <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M5 13l4 4L19 7\" />\n                  </svg>\n                ) : (\n                  <div className=\"h-1.5 w-1.5 rounded-full bg-gray-300 dark:bg-gray-600\" />\n                )}\n              </div>\n\n              <div className=\"min-w-0 flex-1\">\n                <div className=\"flex flex-wrap items-center gap-2\">\n                  {q.header && (\n                    <span className=\"inline-flex items-center rounded border border-blue-100/80 bg-blue-50 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-blue-600 dark:border-blue-800/40 dark:bg-blue-900/30 dark:text-blue-400\">\n                      {q.header}\n                    </span>\n                  )}\n                  {total > 1 && (\n                    <span className=\"text-[10px] tabular-nums text-gray-400 dark:text-gray-500\">\n                      {idx + 1}/{total}\n                    </span>\n                  )}\n                </div>\n                <div className=\"mt-0.5 text-xs leading-snug text-gray-600 dark:text-gray-400\">\n                  {q.question}\n                </div>\n\n                {!isExpanded && answerLabels.length > 0 && (\n                  <div className=\"mt-1.5 flex flex-wrap gap-1\">\n                    {answerLabels.map((lbl) => {\n                      const isCustom = !q.options.some(o => o.label === lbl);\n                      return (\n                        <span\n                          key={lbl}\n                          className=\"inline-flex items-center gap-1 rounded-md bg-blue-50 px-1.5 py-0.5 text-[11px] font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-300\"\n                        >\n                          {lbl}\n                          {isCustom && (\n                            <span className=\"text-[9px] font-normal text-blue-400 dark:text-blue-500\">(custom)</span>\n                          )}\n                        </span>\n                      );\n                    })}\n                  </div>\n                )}\n\n                {!isExpanded && skipped && hasAnyAnswer && (\n                  <span className=\"mt-1 inline-block text-[10px] italic text-gray-400 dark:text-gray-500\">\n                    Skipped\n                  </span>\n                )}\n              </div>\n\n              <svg\n                className={`mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-gray-400 transition-transform duration-200 dark:text-gray-500 ${\n                  isExpanded ? 'rotate-180' : ''\n                }`}\n                fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" strokeWidth={2}\n              >\n                <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M19 9l-7 7-7-7\" />\n              </svg>\n            </button>\n\n            {isExpanded && (\n              <div className=\"border-t border-gray-100 px-3 pb-2.5 pt-0.5 dark:border-gray-700/40\">\n                <div className=\"ml-6.5 space-y-1\">\n                  {q.options.map((opt) => {\n                    const wasSelected = answerLabels.includes(opt.label);\n                    return (\n                      <div\n                        key={opt.label}\n                        className={`flex items-start gap-2 rounded-lg px-2.5 py-1.5 text-[12px] ${\n                          wasSelected\n                            ? 'border border-blue-200/60 bg-blue-50/80 dark:border-blue-800/40 dark:bg-blue-900/20'\n                            : 'text-gray-400 dark:text-gray-500'\n                        }`}\n                      >\n                        <div className={`mt-0.5 h-3.5 w-3.5 flex-shrink-0 ${q.multiSelect ? 'rounded-[3px]' : 'rounded-full'} flex items-center justify-center border-[1.5px] ${\n                          wasSelected\n                            ? 'border-blue-500 bg-blue-500 dark:border-blue-400 dark:bg-blue-500'\n                            : 'border-gray-300 dark:border-gray-600'\n                        }`}>\n                          {wasSelected && (\n                            <svg className=\"h-2 w-2 text-white\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" strokeWidth={3}>\n                              <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M5 13l4 4L19 7\" />\n                            </svg>\n                          )}\n                        </div>\n                        <div className=\"min-w-0 flex-1\">\n                          <span className={wasSelected ? 'font-medium text-gray-900 dark:text-gray-100' : ''}>\n                            {opt.label}\n                          </span>\n                          {opt.description && (\n                            <span className={`mt-0.5 block text-[11px] ${\n                              wasSelected ? 'text-blue-600/70 dark:text-blue-300/70' : 'text-gray-400 dark:text-gray-600'\n                            }`}>\n                              {opt.description}\n                            </span>\n                          )}\n                        </div>\n                      </div>\n                    );\n                  })}\n\n                  {answerLabels.filter(lbl => !q.options.some(o => o.label === lbl)).map(lbl => (\n                    <div\n                      key={lbl}\n                      className=\"flex items-start gap-2 rounded-lg border border-blue-200/60 bg-blue-50/80 px-2.5 py-1.5 text-[12px] dark:border-blue-800/40 dark:bg-blue-900/20\"\n                    >\n                      <div className={`mt-0.5 h-3.5 w-3.5 flex-shrink-0 ${q.multiSelect ? 'rounded-[3px]' : 'rounded-full'} flex items-center justify-center border-[1.5px] border-blue-500 bg-blue-500 dark:border-blue-400 dark:bg-blue-500`}>\n                        <svg className=\"h-2 w-2 text-white\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" strokeWidth={3}>\n                          <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M5 13l4 4L19 7\" />\n                        </svg>\n                      </div>\n                      <div className=\"min-w-0 flex-1\">\n                        <span className=\"font-medium text-gray-900 dark:text-gray-100\">{lbl}</span>\n                        <span className=\"ml-1 text-[10px] text-blue-500 dark:text-blue-400\">(custom)</span>\n                      </div>\n                    </div>\n                  ))}\n\n                  {skipped && hasAnyAnswer && (\n                    <div className=\"px-2.5 py-1 text-[11px] italic text-gray-400 dark:text-gray-500\">\n                      No answer provided\n                    </div>\n                  )}\n                </div>\n              </div>\n            )}\n          </div>\n        );\n      })}\n\n      {!hasAnyAnswer && total === 1 && (\n        <div className=\"text-[11px] italic text-gray-400 dark:text-gray-500\">\n          Skipped\n        </div>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "src/components/chat/tools/components/ContentRenderers/TaskListContent.tsx",
    "content": "import React from 'react';\n\ninterface TaskItem {\n  id: string;\n  subject: string;\n  status: 'pending' | 'in_progress' | 'completed';\n  owner?: string;\n  blockedBy?: string[];\n}\n\ninterface TaskListContentProps {\n  content: string;\n}\n\nfunction parseTaskContent(content: string): TaskItem[] {\n  const tasks: TaskItem[] = [];\n  const lines = content.split('\\n');\n\n  for (const line of lines) {\n    // Match patterns like: #15. [in_progress] Subject here\n    // or: - #15 [in_progress] Subject (owner: agent)\n    // or: #15. Subject here (status: in_progress)\n    const match = line.match(/#(\\d+)\\.?\\s*(?:\\[(\\w+)\\]\\s*)?(.+?)(?:\\s*\\((?:owner:\\s*\\w+)?\\))?$/);\n    if (match) {\n      const [, id, status, subject] = match;\n      const blockedMatch = line.match(/blockedBy:\\s*\\[([^\\]]*)\\]/);\n      tasks.push({\n        id,\n        subject: subject.trim(),\n        status: (status as TaskItem['status']) || 'pending',\n        blockedBy: blockedMatch ? blockedMatch[1].split(',').map(s => s.trim()).filter(Boolean) : undefined\n      });\n    }\n  }\n\n  return tasks;\n}\n\nconst statusConfig = {\n  completed: {\n    icon: (\n      <svg className=\"h-3.5 w-3.5 text-green-500 dark:text-green-400\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n        <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z\" />\n      </svg>\n    ),\n    textClass: 'line-through text-gray-400 dark:text-gray-500',\n    badgeClass: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 border-green-200 dark:border-green-800'\n  },\n  in_progress: {\n    icon: (\n      <svg className=\"h-3.5 w-3.5 text-blue-500 dark:text-blue-400\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n        <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z\" />\n      </svg>\n    ),\n    textClass: 'text-gray-900 dark:text-gray-100',\n    badgeClass: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-800'\n  },\n  pending: {\n    icon: (\n      <svg className=\"h-3.5 w-3.5 text-gray-400 dark:text-gray-500\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n        <circle cx=\"12\" cy=\"12\" r=\"9\" strokeWidth={2} />\n      </svg>\n    ),\n    textClass: 'text-gray-700 dark:text-gray-300',\n    badgeClass: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-700'\n  }\n};\n\n/**\n * Renders task list results with proper status icons and compact layout\n * Parses text content from TaskList/TaskGet results\n */\nexport const TaskListContent: React.FC<TaskListContentProps> = ({ content }) => {\n  const tasks = parseTaskContent(content);\n\n  // If we couldn't parse any tasks, fall back to text display\n  if (tasks.length === 0) {\n    return (\n      <pre className=\"whitespace-pre-wrap font-mono text-[11px] text-gray-600 dark:text-gray-400\">\n        {content}\n      </pre>\n    );\n  }\n\n  const completed = tasks.filter(t => t.status === 'completed').length;\n  const total = tasks.length;\n\n  return (\n    <div>\n      <div className=\"mb-1.5 flex items-center gap-2\">\n        <span className=\"text-[11px] text-gray-500 dark:text-gray-400\">\n          {completed}/{total} completed\n        </span>\n        <div className=\"h-1 flex-1 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700\">\n          <div\n            className=\"h-full rounded-full bg-green-500 transition-all dark:bg-green-400\"\n            style={{ width: `${total > 0 ? (completed / total) * 100 : 0}%` }}\n          />\n        </div>\n      </div>\n      <div className=\"space-y-px\">\n        {tasks.map((task) => {\n          const config = statusConfig[task.status] || statusConfig.pending;\n          return (\n            <div\n              key={task.id}\n              className=\"group flex items-center gap-1.5 py-0.5\"\n            >\n              <span className=\"flex-shrink-0\">{config.icon}</span>\n              <span className=\"flex-shrink-0 font-mono text-[11px] text-gray-400 dark:text-gray-500\">\n                #{task.id}\n              </span>\n              <span className={`flex-1 truncate text-xs ${config.textClass}`}>\n                {task.subject}\n              </span>\n              <span className={`flex-shrink-0 rounded border px-1 py-px text-[10px] ${config.badgeClass}`}>\n                {task.status.replace('_', ' ')}\n              </span>\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "src/components/chat/tools/components/ContentRenderers/TextContent.tsx",
    "content": "import React from 'react';\n\ninterface TextContentProps {\n  content: string;\n  format?: 'plain' | 'json' | 'code';\n  className?: string;\n}\n\n/**\n * Renders plain text, JSON, or code content\n * Used by: Raw parameters, generic text results, JSON responses\n */\nexport const TextContent: React.FC<TextContentProps> = ({\n  content,\n  format = 'plain',\n  className = ''\n}) => {\n  if (format === 'json') {\n    let formattedJson = content;\n    try {\n      const parsed = JSON.parse(content);\n      formattedJson = JSON.stringify(parsed, null, 2);\n    } catch (e) {\n      // If parsing fails, use original content\n      console.warn('Failed to parse JSON content:', e);\n    }\n\n    return (\n      <pre className={`mt-1 overflow-x-auto rounded bg-gray-900 p-2.5 font-mono text-xs text-gray-100 dark:bg-gray-950 ${className}`}>\n        {formattedJson}\n      </pre>\n    );\n  }\n\n  if (format === 'code') {\n    return (\n      <pre className={`mt-1 overflow-hidden whitespace-pre-wrap break-words rounded border border-gray-200/50 bg-gray-50 p-2 font-mono text-xs text-gray-700 dark:border-gray-700/50 dark:bg-gray-800/50 dark:text-gray-300 ${className}`}>\n        {content}\n      </pre>\n    );\n  }\n\n  // Plain text\n  return (\n    <div className={`mt-1 whitespace-pre-wrap text-sm text-gray-700 dark:text-gray-300 ${className}`}>\n      {content}\n    </div>\n  );\n};\n"
  },
  {
    "path": "src/components/chat/tools/components/ContentRenderers/TodoList.tsx",
    "content": "import { memo, useMemo } from 'react';\nimport { CheckCircle2, Circle, Clock, type LucideIcon } from 'lucide-react';\nimport { Badge } from '../../../../../shared/view/ui';\n\ntype TodoStatus = 'completed' | 'in_progress' | 'pending';\ntype TodoPriority = 'high' | 'medium' | 'low';\n\nexport type TodoItem = {\n  id?: string;\n  content: string;\n  status: string;\n  priority?: string;\n};\n\ntype NormalizedTodoItem = {\n  id?: string;\n  content: string;\n  status: TodoStatus;\n  priority: TodoPriority;\n};\n\ntype StatusConfig = {\n  icon: LucideIcon;\n  iconClassName: string;\n  badgeClassName: string;\n  textClassName: string;\n};\n\n// Centralized visual config keeps rendering logic compact and easier to scan.\nconst STATUS_CONFIG: Record<TodoStatus, StatusConfig> = {\n  completed: {\n    icon: CheckCircle2,\n    iconClassName: 'w-3.5 h-3.5 text-green-500 dark:text-green-400',\n    badgeClassName:\n      'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-200 border-green-200 dark:border-green-800',\n    textClassName: 'line-through text-gray-500 dark:text-gray-400',\n  },\n  in_progress: {\n    icon: Clock,\n    iconClassName: 'w-3.5 h-3.5 text-blue-500 dark:text-blue-400',\n    badgeClassName:\n      'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 border-blue-200 dark:border-blue-800',\n    textClassName: 'text-gray-900 dark:text-gray-100',\n  },\n  pending: {\n    icon: Circle,\n    iconClassName: 'w-3.5 h-3.5 text-gray-400 dark:text-gray-500',\n    badgeClassName:\n      'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-700',\n    textClassName: 'text-gray-900 dark:text-gray-100',\n  },\n};\n\nconst PRIORITY_BADGE_CLASS: Record<TodoPriority, string> = {\n  high: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 border-red-200 dark:border-red-800',\n  medium:\n    'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800',\n  low: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-200 dark:border-gray-700',\n};\n\n// Incoming tool payloads can vary; normalize to supported UI states.\nconst normalizeStatus = (status: string): TodoStatus => {\n  if (status === 'completed' || status === 'in_progress') {\n    return status;\n  }\n  return 'pending';\n};\n\nconst normalizePriority = (priority?: string): TodoPriority => {\n  if (priority === 'high' || priority === 'medium') {\n    return priority;\n  }\n  return 'low';\n};\n\nconst TodoRow = memo(\n  ({ todo }: { todo: NormalizedTodoItem }) => {\n    const statusConfig = STATUS_CONFIG[todo.status];\n    const StatusIcon = statusConfig.icon;\n\n    return (\n      <div className=\"flex items-start gap-2 rounded border border-gray-200 bg-white p-2 transition-colors dark:border-gray-700 dark:bg-gray-800\">\n        <div className=\"mt-0.5 flex-shrink-0\">\n          <StatusIcon className={statusConfig.iconClassName} />\n        </div>\n        <div className=\"min-w-0 flex-1\">\n          <div className=\"mb-0.5 flex items-start justify-between gap-2\">\n            <p className={`text-xs font-medium ${statusConfig.textClassName}`}>\n              {todo.content}\n            </p>\n            <div className=\"flex flex-shrink-0 gap-1\">\n              <Badge\n                variant=\"outline\"\n                className={`px-1.5 py-px text-[10px] ${PRIORITY_BADGE_CLASS[todo.priority]}`}\n              >\n                {todo.priority}\n              </Badge>\n              <Badge\n                variant=\"outline\"\n                className={`px-1.5 py-px text-[10px] ${statusConfig.badgeClassName}`}\n              >\n                {todo.status.replace('_', ' ')}\n              </Badge>\n            </div>\n          </div>\n        </div>\n      </div>\n    );\n  }\n);\n\nconst TodoList = memo(\n  ({\n    todos,\n    isResult = false,\n  }: {\n    todos: TodoItem[];\n    isResult?: boolean;\n  }) => {\n    // Memoize normalization to avoid recomputing list metadata on every render.\n    const normalizedTodos = useMemo<NormalizedTodoItem[]>(\n      () =>\n        todos.map((todo) => ({\n          id: todo.id,\n          content: todo.content,\n          status: normalizeStatus(todo.status),\n          priority: normalizePriority(todo.priority),\n        })),\n      [todos]\n    );\n\n    if (normalizedTodos.length === 0) {\n      return null;\n    }\n\n    return (\n      <div className=\"space-y-1.5\">\n        {isResult && (\n          <div className=\"mb-1.5 text-xs font-medium text-gray-600 dark:text-gray-400\">\n            Todo List ({normalizedTodos.length}{' '}\n            {normalizedTodos.length === 1 ? 'item' : 'items'})\n          </div>\n        )}\n        {normalizedTodos.map((todo, index) => (\n          <TodoRow key={todo.id ?? `${todo.content}-${index}`} todo={todo} />\n        ))}\n      </div>\n    );\n  }\n);\n\nexport default TodoList;\n"
  },
  {
    "path": "src/components/chat/tools/components/ContentRenderers/TodoListContent.tsx",
    "content": "import { memo, useMemo } from 'react';\nimport TodoList, { type TodoItem } from './TodoList';\n\nconst isTodoItem = (value: unknown): value is TodoItem => {\n  if (typeof value !== 'object' || value === null) {\n    return false;\n  }\n\n  const todo = value as Record<string, unknown>;\n  return typeof todo.content === 'string' && typeof todo.status === 'string';\n};\n\n/**\n * Renders a todo list\n * Used by: TodoWrite, TodoRead\n */\nexport const TodoListContent = memo(\n  ({\n    todos,\n    isResult = false,\n  }: {\n    todos: unknown;\n    isResult?: boolean;\n  }) => {\n    const safeTodos = useMemo<TodoItem[]>(() => {\n      if (!Array.isArray(todos)) {\n        return [];\n      }\n\n      // Tool payloads are runtime data; render only validated todo objects.\n      return todos.filter(isTodoItem);\n    }, [todos]);\n\n    if (safeTodos.length === 0) {\n      return null;\n    }\n\n    return <TodoList todos={safeTodos} isResult={isResult} />;\n  }\n);\n"
  },
  {
    "path": "src/components/chat/tools/components/ContentRenderers/index.ts",
    "content": "export { MarkdownContent } from './MarkdownContent';\nexport { FileListContent } from './FileListContent';\nexport { TodoListContent } from './TodoListContent';\nexport { TaskListContent } from './TaskListContent';\nexport { TextContent } from './TextContent';\nexport { QuestionAnswerContent } from './QuestionAnswerContent';\n"
  },
  {
    "path": "src/components/chat/tools/components/InteractiveRenderers/AskUserQuestionPanel.tsx",
    "content": "import React, { useState, useCallback, useRef, useEffect } from 'react';\nimport type { PermissionPanelProps } from '../../configs/permissionPanelRegistry';\nimport type { Question } from '../../../types/types';\n\nexport const AskUserQuestionPanel: React.FC<PermissionPanelProps> = ({\n  request,\n  onDecision,\n}) => {\n  const input = request.input as { questions?: Question[] } | undefined;\n  const questions: Question[] = input?.questions || [];\n\n  const [currentStep, setCurrentStep] = useState(0);\n  const [selections, setSelections] = useState<Map<number, Set<string>>>(() => new Map());\n  const [otherTexts, setOtherTexts] = useState<Map<number, string>>(() => new Map());\n  const [otherActive, setOtherActive] = useState<Map<number, boolean>>(() => new Map());\n  const [mounted, setMounted] = useState(false);\n\n  const containerRef = useRef<HTMLDivElement>(null);\n  const otherInputRef = useRef<HTMLInputElement>(null);\n\n  useEffect(() => {\n    requestAnimationFrame(() => setMounted(true));\n  }, []);\n\n  // Focus the container for keyboard events when step changes\n  useEffect(() => {\n    if (!otherActive.get(currentStep)) {\n      containerRef.current?.focus();\n    }\n  }, [currentStep, otherActive]);\n\n  useEffect(() => {\n    if (otherActive.get(currentStep)) {\n      otherInputRef.current?.focus();\n    }\n  }, [otherActive, currentStep]);\n\n  const toggleOption = useCallback((qIdx: number, label: string, multiSelect: boolean) => {\n    setSelections(prev => {\n      const next = new Map(prev);\n      const current = new Set(next.get(qIdx) || []);\n      if (multiSelect) {\n        if (current.has(label)) current.delete(label);\n        else current.add(label);\n      } else {\n        current.clear();\n        current.add(label);\n        setOtherActive(p => { const n = new Map(p); n.set(qIdx, false); return n; });\n      }\n      next.set(qIdx, current);\n      return next;\n    });\n  }, []);\n\n  const toggleOther = useCallback((qIdx: number, multiSelect: boolean) => {\n    setOtherActive(prev => {\n      const next = new Map(prev);\n      const wasActive = next.get(qIdx) || false;\n      next.set(qIdx, !wasActive);\n      if (!multiSelect && !wasActive) {\n        setSelections(p => { const n = new Map(p); n.set(qIdx, new Set()); return n; });\n      }\n      return next;\n    });\n  }, []);\n\n  const setOtherText = useCallback((qIdx: number, text: string) => {\n    setOtherTexts(prev => { const next = new Map(prev); next.set(qIdx, text); return next; });\n  }, []);\n\n  const buildAnswers = useCallback(() => {\n    const answers: Record<string, string> = {};\n    questions.forEach((q, idx) => {\n      const selected = Array.from(selections.get(idx) || []);\n      const isOther = otherActive.get(idx) || false;\n      const otherText = (otherTexts.get(idx) || '').trim();\n      if (isOther && otherText) selected.push(otherText);\n      if (selected.length > 0) answers[q.question] = selected.join(', ');\n    });\n    return answers;\n  }, [questions, selections, otherActive, otherTexts]);\n\n  const handleSubmit = useCallback(() => {\n    onDecision(request.requestId, { allow: true, updatedInput: { ...input, answers: buildAnswers() } });\n  }, [onDecision, request.requestId, input, buildAnswers]);\n\n  const handleSkip = useCallback(() => {\n    onDecision(request.requestId, { allow: true, updatedInput: { ...input, answers: {} } });\n  }, [onDecision, request.requestId, input]);\n\n  // Keyboard handler for number keys and navigation\n  const handleKeyDown = useCallback((e: React.KeyboardEvent) => {\n    // Don't capture keys when typing in the \"Other\" input\n    if (e.target instanceof HTMLInputElement) return;\n\n    const q = questions[currentStep];\n    if (!q) return;\n    const multi = q.multiSelect || false;\n    const optCount = q.options.length;\n\n    // Number keys 1-9 for options\n    const num = parseInt(e.key);\n    if (!isNaN(num) && num >= 1 && num <= optCount) {\n      e.preventDefault();\n      toggleOption(currentStep, q.options[num - 1].label, multi);\n      return;\n    }\n\n    // 0 for \"Other\"\n    if (e.key === '0') {\n      e.preventDefault();\n      toggleOther(currentStep, multi);\n      return;\n    }\n\n    // Enter to advance / submit\n    if (e.key === 'Enter') {\n      e.preventDefault();\n      const isLast = currentStep === questions.length - 1;\n      if (isLast) handleSubmit();\n      else setCurrentStep(s => s + 1);\n      return;\n    }\n\n    // Escape to skip\n    if (e.key === 'Escape') {\n      e.preventDefault();\n      handleSkip();\n      return;\n    }\n  }, [currentStep, questions, toggleOption, toggleOther, handleSubmit, handleSkip]);\n\n  if (questions.length === 0) return null;\n\n  const total = questions.length;\n  const isSingle = total === 1;\n  const q = questions[currentStep];\n  const multi = q.multiSelect || false;\n  const selected = selections.get(currentStep) || new Set<string>();\n  const isOtherOn = otherActive.get(currentStep) || false;\n  const isLast = currentStep === total - 1;\n  const isFirst = currentStep === 0;\n  const hasCurrentSelection = selected.size > 0 || (isOtherOn && (otherTexts.get(currentStep) || '').trim().length > 0);\n\n  return (\n    <div\n      ref={containerRef}\n      tabIndex={-1}\n      onKeyDown={handleKeyDown}\n      className={`w-full outline-none transition-all duration-500 ease-out ${\n        mounted ? 'translate-y-0 opacity-100' : 'translate-y-3 opacity-0'\n      }`}\n    >\n      <div className=\"relative overflow-hidden rounded-2xl border border-gray-200/80 bg-white shadow-lg dark:border-gray-700/50 dark:bg-gray-800/90 dark:shadow-2xl\">\n        {/* Accent line */}\n        <div className=\"absolute left-0 right-0 top-0 h-[2px] bg-gradient-to-r from-blue-500 via-cyan-400 to-teal-400\" />\n\n        {/* Header + Question — compact */}\n        <div className=\"px-4 pb-2 pt-3.5\">\n          <div className=\"mb-1.5 flex items-center gap-2.5\">\n            {/* Question icon */}\n            <div className=\"relative flex-shrink-0\">\n              <div className=\"flex h-6 w-6 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500/10 to-cyan-500/10 dark:from-blue-400/15 dark:to-cyan-400/15\">\n                <svg className=\"h-3.5 w-3.5 text-blue-600 dark:text-blue-400\" fill=\"none\" viewBox=\"0 0 24 24\" strokeWidth={1.75} stroke=\"currentColor\">\n                  <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827m0 3h.01\" />\n                </svg>\n              </div>\n              <div className=\"absolute -right-0.5 -top-0.5 h-2 w-2 animate-pulse rounded-full bg-cyan-400 dark:bg-cyan-500\" />\n            </div>\n\n            <div className=\"flex min-w-0 flex-1 items-center gap-2\">\n              <span className=\"text-[10px] font-medium uppercase tracking-wide text-gray-400 dark:text-gray-500\">\n                Claude needs your input\n              </span>\n              {q.header && (\n                <span className=\"inline-flex items-center rounded border border-blue-100 bg-blue-50 px-1.5 py-px text-[9px] font-semibold uppercase tracking-wider text-blue-600 dark:border-blue-800/50 dark:bg-blue-900/30 dark:text-blue-400\">\n                  {q.header}\n                </span>\n              )}\n            </div>\n\n            {/* Step counter */}\n            {!isSingle && (\n              <span className=\"flex-shrink-0 text-[10px] tabular-nums text-gray-400 dark:text-gray-500\">\n                {currentStep + 1}/{total}\n              </span>\n            )}\n          </div>\n\n          {/* Progress dots (multi-question) */}\n          {!isSingle && (\n            <div className=\"mb-2 flex items-center gap-1\">\n              {questions.map((_, i) => (\n                <button\n                  key={i}\n                  type=\"button\"\n                  onClick={() => setCurrentStep(i)}\n                  className={`h-[3px] rounded-full transition-all duration-300 ${\n                    i === currentStep\n                      ? 'w-5 bg-blue-500 dark:bg-blue-400'\n                      : i < currentStep\n                        ? 'w-2.5 bg-blue-300 dark:bg-blue-600'\n                        : 'w-2.5 bg-gray-200 dark:bg-gray-700'\n                  }`}\n                />\n              ))}\n            </div>\n          )}\n\n          {/* Question text */}\n          <p className=\"text-[14px] font-medium leading-snug text-gray-900 dark:text-gray-100\">\n            {q.question}\n          </p>\n          {multi && (\n            <span className=\"text-[10px] text-gray-400 dark:text-gray-500\">Select all that apply</span>\n          )}\n        </div>\n\n        {/* Options — tight spacing */}\n        <div className=\"scrollbar-thin max-h-48 overflow-y-auto px-4 pb-2\" role={multi ? 'group' : 'radiogroup'} aria-label={q.question}>\n          <div className=\"space-y-1\">\n            {q.options.map((opt, optIdx) => {\n              const isSelected = selected.has(opt.label);\n              return (\n                <button\n                  key={opt.label}\n                  type=\"button\"\n                  onClick={() => toggleOption(currentStep, opt.label, multi)}\n                  className={`group flex w-full items-center gap-2.5 rounded-lg border px-3 py-2 text-left transition-all duration-150 ${\n                    isSelected\n                      ? 'border-blue-300 bg-blue-50/80 ring-1 ring-blue-200/50 dark:border-blue-600 dark:bg-blue-900/25 dark:ring-blue-700/30'\n                      : 'dark:hover:bg-gray-750/50 border-gray-200 hover:border-gray-300 hover:bg-gray-50/60 dark:border-gray-700/60 dark:hover:border-gray-600'\n                  }`}\n                >\n                  {/* Keyboard hint */}\n                  <kbd className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded font-mono text-[10px] transition-all duration-150 ${\n                    isSelected\n                      ? 'bg-blue-500 font-semibold text-white dark:bg-blue-500'\n                      : 'border border-gray-200 bg-gray-100 text-gray-400 group-hover:border-gray-300 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-500 dark:group-hover:border-gray-600'\n                  }`}>\n                    {optIdx + 1}\n                  </kbd>\n\n                  <div className=\"min-w-0 flex-1\">\n                    <div className={`text-[13px] leading-tight transition-colors duration-150 ${\n                      isSelected\n                        ? 'font-medium text-gray-900 dark:text-gray-100'\n                        : 'text-gray-700 dark:text-gray-300'\n                    }`}>\n                      {opt.label}\n                    </div>\n                    {opt.description && (\n                      <div className={`text-[11px] leading-snug transition-colors duration-150 ${\n                        isSelected\n                          ? 'text-blue-600/70 dark:text-blue-300/70'\n                          : 'text-gray-400 dark:text-gray-500'\n                      }`}>\n                        {opt.description}\n                      </div>\n                    )}\n                  </div>\n\n                  {/* Selection check */}\n                  {isSelected && (\n                    <svg className=\"h-4 w-4 flex-shrink-0 text-blue-500 dark:text-blue-400\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" strokeWidth={2.5}>\n                      <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M4.5 12.75l6 6 9-13.5\" />\n                    </svg>\n                  )}\n                </button>\n              );\n            })}\n\n            {/* \"Other\" option */}\n            <button\n              type=\"button\"\n              onClick={() => toggleOther(currentStep, multi)}\n              className={`group flex w-full items-center gap-2.5 rounded-lg border px-3 py-2 text-left transition-all duration-150 ${\n                isOtherOn\n                  ? 'border-blue-300 bg-blue-50/80 ring-1 ring-blue-200/50 dark:border-blue-600 dark:bg-blue-900/25 dark:ring-blue-700/30'\n                  : 'dark:hover:bg-gray-750/50 border-dashed border-gray-200 hover:border-gray-300 hover:bg-gray-50/60 dark:border-gray-700/60 dark:hover:border-gray-600'\n              }`}\n            >\n              <kbd className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded font-mono text-[10px] transition-all duration-150 ${\n                isOtherOn\n                  ? 'bg-blue-500 font-semibold text-white dark:bg-blue-500'\n                  : 'border border-gray-200 bg-gray-100 text-gray-400 group-hover:border-gray-300 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-500 dark:group-hover:border-gray-600'\n              }`}>\n                0\n              </kbd>\n              <span className={`text-[13px] leading-tight transition-colors ${\n                isOtherOn\n                  ? 'font-medium text-gray-900 dark:text-gray-100'\n                  : 'text-gray-500 dark:text-gray-400'\n              }`}>\n                Other...\n              </span>\n              {isOtherOn && (\n                <svg className=\"ml-auto h-4 w-4 flex-shrink-0 text-blue-500 dark:text-blue-400\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" strokeWidth={2.5}>\n                  <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M4.5 12.75l6 6 9-13.5\" />\n                </svg>\n              )}\n            </button>\n\n            {/* Other text input — inline */}\n            {isOtherOn && (\n              <div className=\"pl-[30px] pr-0.5\">\n                <div className=\"relative\">\n                  <input\n                    ref={otherInputRef}\n                    type=\"text\"\n                    value={otherTexts.get(currentStep) || ''}\n                    onChange={(e) => setOtherText(currentStep, e.target.value)}\n                    onKeyDown={(e) => {\n                      if (e.key === 'Enter') {\n                        e.preventDefault();\n                        if (isLast) handleSubmit();\n                        else setCurrentStep(s => s + 1);\n                      }\n                      // Prevent container keydown from firing\n                      e.stopPropagation();\n                    }}\n                    placeholder=\"Type your answer...\"\n                    className=\"w-full rounded-lg border-0 bg-gray-50 px-3 py-1.5 text-[13px] text-gray-900 outline-none ring-1 ring-gray-200 transition-shadow duration-200 placeholder:text-gray-400 focus:ring-2 focus:ring-blue-400 dark:bg-gray-900/60 dark:text-gray-100 dark:ring-gray-700 dark:placeholder:text-gray-600 dark:focus:ring-blue-500\"\n                  />\n                  <kbd className=\"absolute right-2 top-1/2 -translate-y-1/2 rounded border border-gray-200 bg-gray-100 px-1 py-0.5 font-mono text-[9px] text-gray-300 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-600\">\n                    Enter\n                  </kbd>\n                </div>\n              </div>\n            )}\n          </div>\n        </div>\n\n        {/* Footer — compact */}\n        <div className=\"flex items-center justify-between gap-2 border-t border-gray-100 bg-gray-50/50 px-4 py-2 dark:border-gray-700/50 dark:bg-gray-800/50\">\n          <button\n            type=\"button\"\n            onClick={handleSkip}\n            className=\"text-[11px] text-gray-400 transition-colors hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300\"\n          >\n            {isSingle ? 'Skip' : 'Skip all'}\n            <span className=\"ml-1 text-[9px] text-gray-300 dark:text-gray-600\">Esc</span>\n          </button>\n\n          <div className=\"flex items-center gap-1.5\">\n            {!isSingle && !isFirst && (\n              <button\n                type=\"button\"\n                onClick={() => setCurrentStep(s => s - 1)}\n                className=\"inline-flex items-center gap-0.5 rounded-lg px-2.5 py-1.5 text-[11px] font-medium text-gray-600 transition-all duration-150 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700/60\"\n              >\n                <svg className=\"h-3 w-3\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" strokeWidth={2}>\n                  <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M15 19l-7-7 7-7\" />\n                </svg>\n                Back\n              </button>\n            )}\n\n            {isLast ? (\n              <button\n                type=\"button\"\n                onClick={handleSubmit}\n                disabled={!hasCurrentSelection && !Object.keys(buildAnswers()).length}\n                className=\"inline-flex items-center gap-1 rounded-lg bg-gradient-to-r from-blue-600 to-blue-500 px-3.5 py-1.5 text-[11px] font-semibold text-white shadow-sm transition-all duration-200 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-30 disabled:shadow-none dark:from-blue-500 dark:to-blue-600\"\n              >\n                Submit\n                <span className=\"ml-0.5 font-mono text-[9px] opacity-70\">Enter</span>\n              </button>\n            ) : (\n              <button\n                type=\"button\"\n                onClick={() => setCurrentStep(s => s + 1)}\n                className=\"inline-flex items-center gap-1 rounded-lg bg-gradient-to-r from-blue-600 to-blue-500 px-3.5 py-1.5 text-[11px] font-semibold text-white shadow-sm transition-all duration-200 hover:shadow-md dark:from-blue-500 dark:to-blue-600\"\n              >\n                Next\n                <span className=\"ml-0.5 font-mono text-[9px] opacity-70\">Enter</span>\n              </button>\n            )}\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "src/components/chat/tools/components/InteractiveRenderers/index.ts",
    "content": "export { AskUserQuestionPanel } from './AskUserQuestionPanel';\n"
  },
  {
    "path": "src/components/chat/tools/components/OneLineDisplay.tsx",
    "content": "import React, { useState } from 'react';\nimport { copyTextToClipboard } from '../../../../utils/clipboard';\n\ntype ActionType = 'copy' | 'open-file' | 'jump-to-results' | 'none';\n\ninterface OneLineDisplayProps {\n  toolName: string;\n  icon?: string;\n  label?: string;\n  value: string;\n  secondary?: string;\n  action?: ActionType;\n  onAction?: () => void;\n  style?: string;\n  wrapText?: boolean;\n  colorScheme?: {\n    primary?: string;\n    secondary?: string;\n    background?: string;\n    border?: string;\n    icon?: string;\n  };\n  resultId?: string;\n  toolResult?: any;\n  toolId?: string;\n}\n\n/**\n * Unified one-line display for simple tool inputs and results\n * Used by: Bash, Read, Grep/Glob (minimized), TodoRead, etc.\n */\nexport const OneLineDisplay: React.FC<OneLineDisplayProps> = ({\n  toolName,\n  icon,\n  label,\n  value,\n  secondary,\n  action = 'none',\n  onAction,\n  style,\n  wrapText = false,\n  colorScheme = {\n    primary: 'text-gray-700 dark:text-gray-300',\n    secondary: 'text-gray-500 dark:text-gray-400',\n    background: '',\n    border: 'border-gray-300 dark:border-gray-600',\n    icon: 'text-gray-500 dark:text-gray-400'\n  },\n  toolResult,\n  toolId\n}) => {\n  const [copied, setCopied] = useState(false);\n  const isTerminal = style === 'terminal';\n\n  const handleAction = async () => {\n    if (action === 'copy' && value) {\n      const didCopy = await copyTextToClipboard(value);\n      if (!didCopy) {\n        return;\n      }\n      setCopied(true);\n      setTimeout(() => setCopied(false), 2000);\n    } else if (onAction) {\n      onAction();\n    }\n  };\n\n  const renderCopyButton = () => (\n    <button\n      onClick={handleAction}\n      className=\"ml-1 flex-shrink-0 text-gray-400 opacity-0 transition-all hover:text-gray-600 group-hover:opacity-100 dark:hover:text-gray-200\"\n      title=\"Copy to clipboard\"\n      aria-label=\"Copy to clipboard\"\n    >\n      {copied ? (\n        <svg className=\"h-3 w-3 text-green-500\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n          <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M5 13l4 4L19 7\" />\n        </svg>\n      ) : (\n        <svg className=\"h-3 w-3\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n          <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z\" />\n        </svg>\n      )}\n    </button>\n  );\n\n  // Terminal style: dark pill only around the command\n  if (isTerminal) {\n    return (\n      <div className=\"group my-1\">\n        <div className=\"flex items-start gap-2\">\n          <div className=\"flex flex-shrink-0 items-center gap-1.5 pt-0.5\">\n            <svg className=\"h-3 w-3 text-green-500 dark:text-green-400\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n              <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z\" />\n            </svg>\n          </div>\n          <div className=\"flex min-w-0 flex-1 items-start gap-2\">\n            <div className=\"min-w-0 flex-1 rounded bg-gray-900 px-2.5 py-1 dark:bg-black\">\n              <code className={`font-mono text-xs text-green-400 ${wrapText ? 'whitespace-pre-wrap break-all' : 'block truncate'}`}>\n                <span className=\"select-none text-green-600 dark:text-green-500\">$ </span>{value}\n              </code>\n            </div>\n            {action === 'copy' && renderCopyButton()}\n          </div>\n        </div>\n        {secondary && (\n          <div className=\"ml-7 mt-1\">\n            <span className=\"text-[11px] italic text-gray-400 dark:text-gray-500\">\n              {secondary}\n            </span>\n          </div>\n        )}\n      </div>\n    );\n  }\n\n  // File open style - show filename only, full path on hover\n  if (action === 'open-file') {\n    const displayName = value.split('/').pop() || value;\n    return (\n      <div className={`group flex items-center gap-1.5 border-l-2 ${colorScheme.border} my-0.5 py-0.5 pl-3`}>\n        <span className=\"flex-shrink-0 text-xs text-gray-500 dark:text-gray-400\">{label || toolName}</span>\n        <span className=\"text-[10px] text-gray-300 dark:text-gray-600\">/</span>\n        <button\n          onClick={handleAction}\n          className=\"truncate font-mono text-xs text-blue-600 transition-colors hover:text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300\"\n          title={value}\n        >\n          {displayName}\n        </button>\n      </div>\n    );\n  }\n\n  // Search / jump-to-results style\n  if (action === 'jump-to-results') {\n    return (\n      <div className={`group flex items-center gap-1.5 border-l-2 ${colorScheme.border} my-0.5 py-0.5 pl-3`}>\n        <span className=\"flex-shrink-0 text-xs text-gray-500 dark:text-gray-400\">{label || toolName}</span>\n        <span className=\"text-[10px] text-gray-300 dark:text-gray-600\">/</span>\n        <span className={`min-w-0 flex-1 truncate font-mono text-xs ${colorScheme.primary}`}>\n          {value}\n        </span>\n        {secondary && (\n          <span className=\"flex-shrink-0 text-[11px] italic text-gray-400 dark:text-gray-500\">\n            {secondary}\n          </span>\n        )}\n        {toolResult && (\n          <a\n            href={`#tool-result-${toolId}`}\n            className=\"flex flex-shrink-0 items-center gap-0.5 text-[11px] text-blue-600 transition-colors hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300\"\n          >\n            <svg className=\"h-3 w-3\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n              <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M19 9l-7 7-7-7\" />\n            </svg>\n          </a>\n        )}\n      </div>\n    );\n  }\n\n  // Default one-line style\n  return (\n    <div className={`group flex items-center gap-1.5 ${colorScheme.background || ''} border-l-2 ${colorScheme.border} my-0.5 py-0.5 pl-3`}>\n      {icon && icon !== 'terminal' && (\n        <span className={`${colorScheme.icon} flex-shrink-0 text-xs`}>{icon}</span>\n      )}\n      {!icon && (label || toolName) && (\n        <span className=\"flex-shrink-0 text-xs text-gray-500 dark:text-gray-400\">{label || toolName}</span>\n      )}\n      {(icon || label || toolName) && (\n        <span className=\"text-[10px] text-gray-300 dark:text-gray-600\">/</span>\n      )}\n      <span className={`font-mono text-xs ${wrapText ? 'whitespace-pre-wrap break-all' : 'truncate'} min-w-0 flex-1 ${colorScheme.primary}`}>\n        {value}\n      </span>\n      {secondary && (\n        <span className={`text-[11px] ${colorScheme.secondary} flex-shrink-0 italic`}>\n          {secondary}\n        </span>\n      )}\n      {action === 'copy' && renderCopyButton()}\n    </div>\n  );\n};\n"
  },
  {
    "path": "src/components/chat/tools/components/SubagentContainer.tsx",
    "content": "import React from 'react';\nimport type { SubagentChildTool } from '../../types/types';\nimport { CollapsibleSection } from './CollapsibleSection';\n\ninterface SubagentContainerProps {\n  toolInput: unknown;\n  toolResult?: { content?: unknown; isError?: boolean } | null;\n  subagentState: {\n    childTools: SubagentChildTool[];\n    currentToolIndex: number;\n    isComplete: boolean;\n  };\n}\n\nconst getCompactToolDisplay = (toolName: string, toolInput: unknown): string => {\n  const input = typeof toolInput === 'string' ? (() => {\n    try { return JSON.parse(toolInput); } catch { return {}; }\n  })() : (toolInput || {});\n\n  switch (toolName) {\n    case 'Read':\n    case 'Write':\n    case 'Edit':\n    case 'ApplyPatch':\n      return input.file_path?.split('/').pop() || input.file_path || '';\n    case 'Grep':\n    case 'Glob':\n      return input.pattern || '';\n    case 'Bash':\n      const cmd = input.command || '';\n      return cmd.length > 40 ? `${cmd.slice(0, 40)}...` : cmd;\n    case 'Task':\n      return input.description || input.subagent_type || '';\n    case 'WebFetch':\n    case 'WebSearch':\n      return input.url || input.query || '';\n    default:\n      return '';\n  }\n};\n\nexport const SubagentContainer: React.FC<SubagentContainerProps> = ({\n  toolInput,\n  toolResult,\n  subagentState,\n}) => {\n  const parsedInput = typeof toolInput === 'string' ? (() => {\n    try { return JSON.parse(toolInput); } catch { return {}; }\n  })() : (toolInput || {});\n\n  const subagentType = parsedInput?.subagent_type || 'Agent';\n  const description = parsedInput?.description || 'Running task';\n  const prompt = parsedInput?.prompt || '';\n  const { childTools, currentToolIndex, isComplete } = subagentState;\n  const currentTool = currentToolIndex >= 0 ? childTools[currentToolIndex] : null;\n\n  const title = `Subagent / ${subagentType}: ${description}`;\n\n  return (\n    <div className=\"my-1 border-l-2 border-l-purple-500 py-0.5 pl-3 dark:border-l-purple-400\">\n      <CollapsibleSection\n        title={title}\n        toolName=\"Task\"\n        open={false}\n      >\n        {/* Prompt/request to the subagent */}\n        {prompt && (\n          <div className=\"mb-2 line-clamp-4 whitespace-pre-wrap break-words text-xs text-gray-600 dark:text-gray-400\">\n            {prompt}\n          </div>\n        )}\n\n        {/* Current tool indicator (while running) */}\n        {currentTool && !isComplete && (\n          <div className=\"mt-1 flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400\">\n            <span className=\"h-1.5 w-1.5 flex-shrink-0 animate-pulse rounded-full bg-purple-500 dark:bg-purple-400\" />\n            <span className=\"text-gray-400 dark:text-gray-500\">Currently:</span>\n            <span className=\"font-medium text-gray-600 dark:text-gray-300\">{currentTool.toolName}</span>\n            {getCompactToolDisplay(currentTool.toolName, currentTool.toolInput) && (\n              <>\n                <span className=\"text-gray-300 dark:text-gray-600\">/</span>\n                <span className=\"truncate font-mono text-gray-500 dark:text-gray-400\">\n                  {getCompactToolDisplay(currentTool.toolName, currentTool.toolInput)}\n                </span>\n              </>\n            )}\n          </div>\n        )}\n\n        {/* Completion status */}\n        {isComplete && (\n          <div className=\"mt-1 flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400\">\n            <svg className=\"h-3 w-3 flex-shrink-0\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n              <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M5 13l4 4L19 7\" />\n            </svg>\n            <span>Completed ({childTools.length} {childTools.length === 1 ? 'tool' : 'tools'})</span>\n          </div>\n        )}\n\n        {/* Tool history (collapsed) */}\n        {childTools.length > 0 && (\n          <details className=\"group/history mt-2\">\n            <summary className=\"flex cursor-pointer items-center gap-1 text-[11px] text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300\">\n              <svg\n                className=\"h-2.5 w-2.5 flex-shrink-0 transition-transform duration-150 group-open/history:rotate-90\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                viewBox=\"0 0 24 24\"\n              >\n                <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M9 5l7 7-7 7\" />\n              </svg>\n              <span>View tool history ({childTools.length})</span>\n            </summary>\n            <div className=\"mt-1 space-y-0.5 border-l border-gray-200 pl-3 dark:border-gray-700\">\n              {childTools.map((child, index) => (\n                <div key={child.toolId} className=\"flex items-center gap-1.5 text-[11px] text-gray-500 dark:text-gray-400\">\n                  <span className=\"w-4 flex-shrink-0 text-right text-gray-400 dark:text-gray-500\">{index + 1}.</span>\n                  <span className=\"font-medium\">{child.toolName}</span>\n                  {getCompactToolDisplay(child.toolName, child.toolInput) && (\n                    <span className=\"truncate font-mono text-gray-400 dark:text-gray-500\">\n                      {getCompactToolDisplay(child.toolName, child.toolInput)}\n                    </span>\n                  )}\n                  {child.toolResult?.isError && (\n                    <span className=\"flex-shrink-0 text-red-500\">(error)</span>\n                  )}\n                </div>\n              ))}\n            </div>\n          </details>\n        )}\n\n        {/* Final result */}\n        {isComplete && toolResult && (\n          <div className=\"mt-2 text-xs text-gray-600 dark:text-gray-400\">\n            {(() => {\n              let content = toolResult.content;\n\n              // Handle JSON string that needs parsing\n              if (typeof content === 'string') {\n                try {\n                  const parsed = JSON.parse(content);\n                  if (Array.isArray(parsed)) {\n                    // Extract text from array format like [{\"type\":\"text\",\"text\":\"...\"}]\n                    const textParts = parsed\n                      .filter((p: any) => p.type === 'text' && p.text)\n                      .map((p: any) => p.text);\n                    if (textParts.length > 0) {\n                      content = textParts.join('\\n');\n                    }\n                  }\n                } catch {\n                  // Not JSON, use as-is\n                }\n              } else if (Array.isArray(content)) {\n                // Direct array format\n                const textParts = content\n                  .filter((p: any) => p.type === 'text' && p.text)\n                  .map((p: any) => p.text);\n                if (textParts.length > 0) {\n                  content = textParts.join('\\n');\n                }\n              }\n\n              return typeof content === 'string' ? (\n                <div className=\"line-clamp-6 whitespace-pre-wrap break-words\">\n                  {content}\n                </div>\n              ) : content ? (\n                <pre className=\"line-clamp-6 whitespace-pre-wrap break-words font-mono text-[11px]\">\n                  {JSON.stringify(content, null, 2)}\n                </pre>\n              ) : null;\n            })()}\n          </div>\n        )}\n      </CollapsibleSection>\n    </div>\n  );\n};\n"
  },
  {
    "path": "src/components/chat/tools/components/ToolDiffViewer.tsx",
    "content": "import React, { useMemo } from 'react';\n\ntype DiffLine = {\n  type: string;\n  content: string;\n  lineNum: number;\n};\n\ninterface ToolDiffViewerProps {\n  oldContent: string;\n  newContent: string;\n  filePath: string;\n  createDiff: (oldStr: string, newStr: string) => DiffLine[];\n  onFileClick?: () => void;\n  badge?: string;\n  badgeColor?: 'gray' | 'green';\n}\n\n/**\n * Compact diff viewer — VS Code-style\n */\nexport const ToolDiffViewer: React.FC<ToolDiffViewerProps> = ({\n  oldContent,\n  newContent,\n  filePath,\n  createDiff,\n  onFileClick,\n  badge = 'Diff',\n  badgeColor = 'gray'\n}) => {\n  const badgeClasses = badgeColor === 'green'\n    ? 'bg-green-100 dark:bg-green-900/30 text-green-600 dark:text-green-400'\n    : 'bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400';\n\n  const diffLines = useMemo(\n    () => createDiff(oldContent, newContent),\n    [createDiff, oldContent, newContent]\n  );\n\n  return (\n    <div className=\"overflow-hidden rounded border border-gray-200/60 dark:border-gray-700/50\">\n      {/* Header */}\n      <div className=\"flex items-center justify-between border-b border-gray-200/60 bg-gray-50/80 px-2.5 py-1 dark:border-gray-700/50 dark:bg-gray-800/40\">\n        {onFileClick ? (\n          <button\n            onClick={onFileClick}\n            className=\"cursor-pointer truncate font-mono text-[11px] text-blue-600 transition-colors hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300\"\n          >\n            {filePath}\n          </button>\n        ) : (\n          <span className=\"truncate font-mono text-[11px] text-gray-600 dark:text-gray-400\">\n            {filePath}\n          </span>\n        )}\n        <span className={`rounded px-1.5 py-px text-[10px] font-medium ${badgeClasses} ml-2 flex-shrink-0`}>\n          {badge}\n        </span>\n      </div>\n\n      {/* Diff lines */}\n      <div className=\"font-mono text-[11px] leading-[18px]\">\n        {diffLines.map((diffLine, i) => (\n          <div key={i} className=\"flex\">\n            <span\n              className={`w-6 flex-shrink-0 select-none text-center ${\n                diffLine.type === 'removed'\n                  ? 'bg-red-50 text-red-400 dark:bg-red-950/30 dark:text-red-500'\n                  : 'bg-green-50 text-green-400 dark:bg-green-950/30 dark:text-green-500'\n              }`}\n            >\n              {diffLine.type === 'removed' ? '-' : '+'}\n            </span>\n            <span\n              className={`flex-1 whitespace-pre-wrap px-2 ${\n                diffLine.type === 'removed'\n                  ? 'bg-red-50/50 text-red-800 dark:bg-red-950/20 dark:text-red-200'\n                  : 'bg-green-50/50 text-green-800 dark:bg-green-950/20 dark:text-green-200'\n              }`}\n            >\n              {diffLine.content}\n            </span>\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "src/components/chat/tools/components/index.ts",
    "content": "export { CollapsibleSection } from './CollapsibleSection';\nexport { ToolDiffViewer } from './ToolDiffViewer';\nexport { OneLineDisplay } from './OneLineDisplay';\nexport { CollapsibleDisplay } from './CollapsibleDisplay';\nexport { SubagentContainer } from './SubagentContainer';\nexport * from './ContentRenderers';\nexport * from './InteractiveRenderers';\n"
  },
  {
    "path": "src/components/chat/tools/configs/permissionPanelRegistry.ts",
    "content": "import type { ComponentType } from 'react';\nimport type { PendingPermissionRequest } from '../../types/types';\n\nexport interface PermissionPanelProps {\n  request: PendingPermissionRequest;\n  onDecision: (\n    requestIds: string | string[],\n    decision: { allow?: boolean; message?: string; updatedInput?: unknown },\n  ) => void;\n}\n\nconst registry: Record<string, ComponentType<PermissionPanelProps>> = {};\n\nexport function registerPermissionPanel(\n  toolName: string,\n  component: ComponentType<PermissionPanelProps>,\n): void {\n  registry[toolName] = component;\n}\n\nexport function getPermissionPanel(\n  toolName: string,\n): ComponentType<PermissionPanelProps> | null {\n  return registry[toolName] || null;\n}\n"
  },
  {
    "path": "src/components/chat/tools/configs/toolConfigs.ts",
    "content": "/**\n * Centralized tool configuration registry\n * Defines display behavior for all tool types \n */\n\nexport interface ToolDisplayConfig {\n  input: {\n    type: 'one-line' | 'collapsible' | 'hidden';\n    // One-line config\n    icon?: string;\n    label?: string;\n    getValue?: (input: any) => string;\n    getSecondary?: (input: any) => string | undefined;\n    action?: 'copy' | 'open-file' | 'jump-to-results' | 'none';\n    style?: string;\n    wrapText?: boolean;\n    colorScheme?: {\n      primary?: string;\n      secondary?: string;\n      background?: string;\n      border?: string;\n      icon?: string;\n    };\n    // Collapsible config\n    title?: string | ((input: any) => string);\n    defaultOpen?: boolean;\n    contentType?: 'diff' | 'markdown' | 'file-list' | 'todo-list' | 'text' | 'task' | 'question-answer';\n    getContentProps?: (input: any, helpers?: any) => any;\n    actionButton?: 'file-button' | 'none';\n  };\n  result?: {\n    hidden?: boolean;\n    hideOnSuccess?: boolean;\n    type?: 'one-line' | 'collapsible' | 'special';\n    title?: string | ((result: any) => string);\n    defaultOpen?: boolean;\n    // Special result handlers\n    contentType?: 'markdown' | 'file-list' | 'todo-list' | 'text' | 'success-message' | 'task' | 'question-answer';\n    getMessage?: (result: any) => string;\n    getContentProps?: (result: any) => any;\n  };\n}\n\nexport const TOOL_CONFIGS: Record<string, ToolDisplayConfig> = {\n  // ============================================================================\n  // COMMAND TOOLS\n  // ============================================================================\n\n  Bash: {\n    input: {\n      type: 'one-line',\n      icon: 'terminal',\n      getValue: (input) => input.command,\n      getSecondary: (input) => input.description,\n      action: 'copy',\n      style: 'terminal',\n      wrapText: true,\n      colorScheme: {\n        primary: 'text-green-400 font-mono',\n        secondary: 'text-gray-400',\n        background: '',\n        border: 'border-green-500 dark:border-green-400',\n        icon: 'text-green-500 dark:text-green-400'\n      }\n    },\n    result: {\n      hideOnSuccess: true,\n      type: 'special'\n    }\n  },\n\n  // ============================================================================\n  // FILE OPERATION TOOLS\n  // ============================================================================\n\n  Read: {\n    input: {\n      type: 'one-line',\n      label: 'Read',\n      getValue: (input) => input.file_path || '',\n      action: 'open-file',\n      colorScheme: {\n        primary: 'text-gray-700 dark:text-gray-300',\n        background: '',\n        border: 'border-gray-300 dark:border-gray-600',\n        icon: 'text-gray-500 dark:text-gray-400'\n      }\n    },\n    result: {\n      hidden: true\n    }\n  },\n\n  Edit: {\n    input: {\n      type: 'collapsible',\n      title: (input) => {\n        const filename = input.file_path?.split('/').pop() || input.file_path || 'file';\n        return `${filename}`;\n      },\n      defaultOpen: false,\n      contentType: 'diff',\n      actionButton: 'none',\n      getContentProps: (input) => ({\n        oldContent: input.old_string,\n        newContent: input.new_string,\n        filePath: input.file_path,\n        badge: 'Edit',\n        badgeColor: 'gray'\n      })\n    },\n    result: {\n      hideOnSuccess: true\n    }\n  },\n\n  Write: {\n    input: {\n      type: 'collapsible',\n      title: (input) => {\n        const filename = input.file_path?.split('/').pop() || input.file_path || 'file';\n        return `${filename}`;\n      },\n      defaultOpen: false,\n      contentType: 'diff',\n      actionButton: 'none',\n      getContentProps: (input) => ({\n        oldContent: '',\n        newContent: input.content,\n        filePath: input.file_path,\n        badge: 'New',\n        badgeColor: 'green'\n      })\n    },\n    result: {\n      hideOnSuccess: true\n    }\n  },\n\n  ApplyPatch: {\n    input: {\n      type: 'collapsible',\n      title: (input) => {\n        const filename = input.file_path?.split('/').pop() || input.file_path || 'file';\n        return `${filename}`;\n      },\n      defaultOpen: false,\n      contentType: 'diff',\n      actionButton: 'none',\n      getContentProps: (input) => ({\n        oldContent: input.old_string,\n        newContent: input.new_string,\n        filePath: input.file_path,\n        badge: 'Patch',\n        badgeColor: 'gray'\n      })\n    },\n    result: {\n      hideOnSuccess: true\n    }\n  },\n\n  // ============================================================================\n  // SEARCH TOOLS\n  // ============================================================================\n\n  Grep: {\n    input: {\n      type: 'one-line',\n      label: 'Grep',\n      getValue: (input) => input.pattern,\n      getSecondary: (input) => input.path ? `in ${input.path}` : undefined,\n      action: 'jump-to-results',\n      colorScheme: {\n        primary: 'text-gray-700 dark:text-gray-300',\n        secondary: 'text-gray-500 dark:text-gray-400',\n        background: '',\n        border: 'border-gray-400 dark:border-gray-500',\n        icon: 'text-gray-500 dark:text-gray-400'\n      }\n    },\n    result: {\n      type: 'collapsible',\n      defaultOpen: false,\n      title: (result) => {\n        const toolData = result.toolUseResult || {};\n        const count = toolData.numFiles || toolData.filenames?.length || 0;\n        return `Found ${count} ${count === 1 ? 'file' : 'files'}`;\n      },\n      contentType: 'file-list',\n      getContentProps: (result) => {\n        const toolData = result.toolUseResult || {};\n        return {\n          files: toolData.filenames || []\n        };\n      }\n    }\n  },\n\n  Glob: {\n    input: {\n      type: 'one-line',\n      label: 'Glob',\n      getValue: (input) => input.pattern,\n      getSecondary: (input) => input.path ? `in ${input.path}` : undefined,\n      action: 'jump-to-results',\n      colorScheme: {\n        primary: 'text-gray-700 dark:text-gray-300',\n        secondary: 'text-gray-500 dark:text-gray-400',\n        background: '',\n        border: 'border-gray-400 dark:border-gray-500',\n        icon: 'text-gray-500 dark:text-gray-400'\n      }\n    },\n    result: {\n      type: 'collapsible',\n      defaultOpen: false,\n      title: (result) => {\n        const toolData = result.toolUseResult || {};\n        const count = toolData.numFiles || toolData.filenames?.length || 0;\n        return `Found ${count} ${count === 1 ? 'file' : 'files'}`;\n      },\n      contentType: 'file-list',\n      getContentProps: (result) => {\n        const toolData = result.toolUseResult || {};\n        return {\n          files: toolData.filenames || []\n        };\n      }\n    }\n  },\n\n  // ============================================================================\n  // TODO TOOLS\n  // ============================================================================\n\n  TodoWrite: {\n    input: {\n      type: 'collapsible',\n      title: 'Updating todo list',\n      defaultOpen: false,\n      contentType: 'todo-list',\n      getContentProps: (input) => ({\n        todos: input.todos\n      })\n    },\n    result: {\n      type: 'collapsible',\n      contentType: 'success-message',\n      getMessage: () => 'Todo list updated'\n    }\n  },\n\n  TodoRead: {\n    input: {\n      type: 'one-line',\n      label: 'TodoRead',\n      getValue: () => 'reading list',\n      action: 'none',\n      colorScheme: {\n        primary: 'text-gray-500 dark:text-gray-400',\n        border: 'border-violet-400 dark:border-violet-500'\n      }\n    },\n    result: {\n      type: 'collapsible',\n      contentType: 'todo-list',\n      getContentProps: (result) => {\n        try {\n          const content = String(result.content || '');\n          let todos = null;\n          if (content.startsWith('[')) {\n            todos = JSON.parse(content);\n          }\n          return { todos, isResult: true };\n        } catch (e) {\n          console.warn('Failed to parse todo list content:', e);\n          return { todos: [], isResult: true };\n        }\n      }\n    }\n  },\n\n  // ============================================================================\n  // TASK TOOLS (TaskCreate, TaskUpdate, TaskList, TaskGet)\n  // ============================================================================\n\n  TaskCreate: {\n    input: {\n      type: 'one-line',\n      label: 'Task',\n      getValue: (input) => input.subject || 'Creating task',\n      getSecondary: (input) => input.status || undefined,\n      action: 'none',\n      colorScheme: {\n        primary: 'text-gray-700 dark:text-gray-300',\n        border: 'border-violet-400 dark:border-violet-500',\n        icon: 'text-violet-500 dark:text-violet-400'\n      }\n    },\n    result: {\n      hideOnSuccess: true\n    }\n  },\n\n  TaskUpdate: {\n    input: {\n      type: 'one-line',\n      label: 'Task',\n      getValue: (input) => {\n        const parts = [];\n        if (input.taskId) parts.push(`#${input.taskId}`);\n        if (input.status) parts.push(input.status);\n        if (input.subject) parts.push(`\"${input.subject}\"`);\n        return parts.join(' → ') || 'updating';\n      },\n      action: 'none',\n      colorScheme: {\n        primary: 'text-gray-700 dark:text-gray-300',\n        border: 'border-violet-400 dark:border-violet-500',\n        icon: 'text-violet-500 dark:text-violet-400'\n      }\n    },\n    result: {\n      hideOnSuccess: true\n    }\n  },\n\n  TaskList: {\n    input: {\n      type: 'one-line',\n      label: 'Tasks',\n      getValue: () => 'listing tasks',\n      action: 'none',\n      colorScheme: {\n        primary: 'text-gray-500 dark:text-gray-400',\n        border: 'border-violet-400 dark:border-violet-500',\n        icon: 'text-violet-500 dark:text-violet-400'\n      }\n    },\n    result: {\n      type: 'collapsible',\n      defaultOpen: true,\n      title: 'Task list',\n      contentType: 'task',\n      getContentProps: (result) => ({\n        content: String(result?.content || '')\n      })\n    }\n  },\n\n  TaskGet: {\n    input: {\n      type: 'one-line',\n      label: 'Task',\n      getValue: (input) => input.taskId ? `#${input.taskId}` : 'fetching',\n      action: 'none',\n      colorScheme: {\n        primary: 'text-gray-700 dark:text-gray-300',\n        border: 'border-violet-400 dark:border-violet-500',\n        icon: 'text-violet-500 dark:text-violet-400'\n      }\n    },\n    result: {\n      type: 'collapsible',\n      defaultOpen: true,\n      title: 'Task details',\n      contentType: 'task',\n      getContentProps: (result) => ({\n        content: String(result?.content || '')\n      })\n    }\n  },\n\n  // ============================================================================\n  // SUBAGENT TASK TOOL\n  // ============================================================================\n\n  Task: {\n    input: {\n      type: 'collapsible',\n      title: (input) => {\n        const subagentType = input.subagent_type || 'Agent';\n        const description = input.description || 'Running task';\n        return `Subagent / ${subagentType}: ${description}`;\n      },\n      defaultOpen: false,\n      contentType: 'markdown',\n      getContentProps: (input) => {\n        // If only prompt exists (and required fields), show just the prompt\n        // Otherwise show all available fields\n        const hasOnlyPrompt = input.prompt &&\n          !input.model &&\n          !input.resume;\n\n        if (hasOnlyPrompt) {\n          return {\n            content: input.prompt || ''\n          };\n        }\n\n        // Format multiple fields\n        const parts = [];\n\n        if (input.model) {\n          parts.push(`**Model:** ${input.model}`);\n        }\n\n        if (input.prompt) {\n          parts.push(`**Prompt:**\\n${input.prompt}`);\n        }\n\n        if (input.resume) {\n          parts.push(`**Resuming from:** ${input.resume}`);\n        }\n\n        return {\n          content: parts.join('\\n\\n')\n        };\n      },\n      colorScheme: {\n        border: 'border-purple-500 dark:border-purple-400',\n        icon: 'text-purple-500 dark:text-purple-400'\n      }\n    },\n    result: {\n      type: 'collapsible',\n      title: 'Subagent result',\n      defaultOpen: false,\n      contentType: 'markdown',\n      getContentProps: (result) => {\n        // Handle agent results which may have complex structure\n        if (result && result.content) {\n          let content = result.content;\n          // If content is a JSON string, try to parse it (agent results may arrive serialized)\n          if (typeof content === 'string') {\n            try {\n              const parsed = JSON.parse(content);\n              if (Array.isArray(parsed)) {\n                content = parsed;\n              }\n            } catch {\n              // Not JSON — use as-is\n              return { content };\n            }\n          }\n          // If content is an array (typical for agent responses with multiple text blocks)\n          if (Array.isArray(content)) {\n            const textContent = content\n              .filter((item: any) => item.type === 'text')\n              .map((item: any) => item.text)\n              .join('\\n\\n');\n            return { content: textContent || 'No response text' };\n          }\n          return { content: String(content) };\n        }\n        // Fallback to string representation\n        return { content: String(result || 'No response') };\n      }\n    }\n  },\n\n  // ============================================================================\n  // INTERACTIVE TOOLS\n  // ============================================================================\n\n  AskUserQuestion: {\n    input: {\n      type: 'collapsible',\n      title: (input: any) => {\n        const count = input.questions?.length || 0;\n        const hasAnswers = input.answers && Object.keys(input.answers).length > 0;\n        if (count === 1) {\n          const header = input.questions[0]?.header || 'Question';\n          return hasAnswers ? `${header} — answered` : header;\n        }\n        return hasAnswers ? `${count} questions — answered` : `${count} questions`;\n      },\n      defaultOpen: true,\n      contentType: 'question-answer',\n      getContentProps: (input: any) => ({\n        questions: input.questions || [],\n        answers: input.answers || {}\n      }),\n    },\n    result: {\n      hideOnSuccess: true\n    }\n  },\n\n  // ============================================================================\n  // PLAN TOOLS\n  // ============================================================================\n\n  exit_plan_mode: {\n    input: {\n      type: 'collapsible',\n      title: 'Implementation plan',\n      defaultOpen: true,\n      contentType: 'markdown',\n      getContentProps: (input) => ({\n        content: input.plan?.replace(/\\\\n/g, '\\n') || input.plan\n      })\n    },\n    result: {\n      type: 'collapsible',\n      contentType: 'markdown',\n      getContentProps: (result) => {\n        try {\n          let parsed = result.content;\n          if (typeof parsed === 'string') {\n            parsed = JSON.parse(parsed);\n          }\n          return {\n            content: parsed.plan?.replace(/\\\\n/g, '\\n') || parsed.plan\n          };\n        } catch (e) {\n          console.warn('Failed to parse plan content:', e);\n          return { content: '' };\n        }\n      }\n    }\n  },\n\n  // Also register as ExitPlanMode (the actual tool name used by Claude)\n  ExitPlanMode: {\n    input: {\n      type: 'collapsible',\n      title: 'Implementation plan',\n      defaultOpen: true,\n      contentType: 'markdown',\n      getContentProps: (input) => ({\n        content: input.plan?.replace(/\\\\n/g, '\\n') || input.plan\n      })\n    },\n    result: {\n      type: 'collapsible',\n      contentType: 'markdown',\n      getContentProps: (result) => {\n        try {\n          let parsed = result.content;\n          if (typeof parsed === 'string') {\n            parsed = JSON.parse(parsed);\n          }\n          return {\n            content: parsed.plan?.replace(/\\\\n/g, '\\n') || parsed.plan\n          };\n        } catch (e) {\n          console.warn('Failed to parse plan content:', e);\n          return { content: '' };\n        }\n      }\n    }\n  },\n\n  // ============================================================================\n  // DEFAULT FALLBACK\n  // ============================================================================\n\n  Default: {\n    input: {\n      type: 'collapsible',\n      title: 'Parameters',\n      defaultOpen: false,\n      contentType: 'text',\n      getContentProps: (input) => ({\n        content: typeof input === 'string' ? input : JSON.stringify(input, null, 2),\n        format: 'code'\n      })\n    },\n    result: {\n      type: 'collapsible',\n      contentType: 'text',\n      getContentProps: (result) => ({\n        content: String(result?.content || ''),\n        format: 'plain'\n      })\n    }\n  }\n};\n\n/**\n * Get configuration for a tool, with fallback to default\n */\nexport function getToolConfig(toolName: string): ToolDisplayConfig {\n  return TOOL_CONFIGS[toolName] || TOOL_CONFIGS.Default;\n}\n\n/**\n * Check if a tool result should be hidden\n */\nexport function shouldHideToolResult(toolName: string, toolResult: any): boolean {\n  const config = getToolConfig(toolName);\n\n  if (!config.result) return false;\n\n  // Always hidden\n  if (config.result.hidden) return true;\n\n  // Hide on success only\n  if (config.result.hideOnSuccess && toolResult && !toolResult.isError) {\n    return true;\n  }\n\n  return false;\n}\n"
  },
  {
    "path": "src/components/chat/tools/index.ts",
    "content": "export { ToolRenderer } from './ToolRenderer';\nexport { getToolConfig, shouldHideToolResult } from './configs/toolConfigs';\nexport * from './components';\n"
  },
  {
    "path": "src/components/chat/types/types.ts",
    "content": "import type { Project, ProjectSession, SessionProvider } from '../../../types/app';\n\nexport type Provider = SessionProvider;\n\nexport type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan';\n\nexport interface ChatImage {\n  data: string;\n  name: string;\n}\n\nexport interface ToolResult {\n  content?: unknown;\n  isError?: boolean;\n  timestamp?: string | number | Date;\n  toolUseResult?: unknown;\n  [key: string]: unknown;\n}\n\nexport interface SubagentChildTool {\n  toolId: string;\n  toolName: string;\n  toolInput: unknown;\n  toolResult?: ToolResult | null;\n  timestamp: Date;\n}\n\nexport interface ChatMessage {\n  type: string;\n  content?: string;\n  timestamp: string | number | Date;\n  images?: ChatImage[];\n  reasoning?: string;\n  isThinking?: boolean;\n  isStreaming?: boolean;\n  isInteractivePrompt?: boolean;\n  isToolUse?: boolean;\n  toolName?: string;\n  toolInput?: unknown;\n  toolResult?: ToolResult | null;\n  toolId?: string;\n  toolCallId?: string;\n  isSubagentContainer?: boolean;\n  subagentState?: {\n    childTools: SubagentChildTool[];\n    currentToolIndex: number;\n    isComplete: boolean;\n  };\n  [key: string]: unknown;\n}\n\nexport interface ClaudeSettings {\n  allowedTools: string[];\n  disallowedTools: string[];\n  skipPermissions: boolean;\n  projectSortOrder: string;\n  lastUpdated?: string;\n  [key: string]: unknown;\n}\n\nexport interface ClaudePermissionSuggestion {\n  toolName: string;\n  entry: string;\n  isAllowed: boolean;\n}\n\nexport interface PermissionGrantResult {\n  success: boolean;\n  alreadyAllowed?: boolean;\n  updatedSettings?: ClaudeSettings;\n}\n\nexport interface PendingPermissionRequest {\n  requestId: string;\n  toolName: string;\n  input?: unknown;\n  context?: unknown;\n  sessionId?: string | null;\n  receivedAt?: Date;\n}\n\nexport interface QuestionOption {\n  label: string;\n  description?: string;\n}\n\nexport interface Question {\n  question: string;\n  header?: string;\n  options: QuestionOption[];\n  multiSelect?: boolean;\n}\n\nexport interface ChatInterfaceProps {\n  selectedProject: Project | null;\n  selectedSession: ProjectSession | null;\n  ws: WebSocket | null;\n  sendMessage: (message: unknown) => void;\n  latestMessage: any;\n  onFileOpen?: (filePath: string, diffInfo?: any) => void;\n  onInputFocusChange?: (focused: boolean) => void;\n  onSessionActive?: (sessionId?: string | null) => void;\n  onSessionInactive?: (sessionId?: string | null) => void;\n  onSessionProcessing?: (sessionId?: string | null) => void;\n  onSessionNotProcessing?: (sessionId?: string | null) => void;\n  processingSessions?: Set<string>;\n  onReplaceTemporarySession?: (sessionId?: string | null) => void;\n  onNavigateToSession?: (targetSessionId: string) => void;\n  onShowSettings?: () => void;\n  autoExpandTools?: boolean;\n  showRawParameters?: boolean;\n  showThinking?: boolean;\n  autoScrollToBottom?: boolean;\n  sendByCtrlEnter?: boolean;\n  externalMessageUpdate?: number;\n  onTaskClick?: (...args: unknown[]) => void;\n  onShowAllTasks?: (() => void) | null;\n}\n"
  },
  {
    "path": "src/components/chat/utils/chatFormatting.ts",
    "content": "export function decodeHtmlEntities(text: string) {\n  if (!text) return text;\n  return text\n    .replace(/&lt;/g, '<')\n    .replace(/&gt;/g, '>')\n    .replace(/&quot;/g, '\"')\n    .replace(/&#39;/g, \"'\")\n    .replace(/&amp;/g, '&');\n}\n\nexport function normalizeInlineCodeFences(text: string) {\n  if (!text || typeof text !== 'string') return text;\n  try {\n    return text.replace(/```\\s*([^\\n\\r]+?)\\s*```/g, '`$1`');\n  } catch {\n    return text;\n  }\n}\n\nexport function unescapeWithMathProtection(text: string) {\n  if (!text || typeof text !== 'string') return text;\n\n  const mathBlocks: string[] = [];\n  const placeholderPrefix = '__MATH_BLOCK_';\n  const placeholderSuffix = '__';\n\n  let processedText = text.replace(/\\$\\$([\\s\\S]*?)\\$\\$|\\$([^\\$\\n]+?)\\$/g, (match) => {\n    const index = mathBlocks.length;\n    mathBlocks.push(match);\n    return `${placeholderPrefix}${index}${placeholderSuffix}`;\n  });\n\n  processedText = processedText.replace(/\\\\n/g, '\\n').replace(/\\\\t/g, '\\t').replace(/\\\\r/g, '\\r');\n\n  processedText = processedText.replace(\n    new RegExp(`${placeholderPrefix}(\\\\d+)${placeholderSuffix}`, 'g'),\n    (match, index) => {\n      return mathBlocks[parseInt(index, 10)];\n    },\n  );\n\n  return processedText;\n}\n\nexport function escapeRegExp(value: string) {\n  return value.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n\nexport function formatUsageLimitText(text: string) {\n  try {\n    if (typeof text !== 'string') return text;\n    return text.replace(/Claude AI usage limit reached\\|(\\d{10,13})/g, (match, ts) => {\n      let timestampMs = parseInt(ts, 10);\n      if (!Number.isFinite(timestampMs)) return match;\n      if (timestampMs < 1e12) timestampMs *= 1000;\n      const reset = new Date(timestampMs);\n\n      const timeStr = new Intl.DateTimeFormat(undefined, {\n        hour: '2-digit',\n        minute: '2-digit',\n        hour12: false,\n      }).format(reset);\n\n      const offsetMinutesLocal = -reset.getTimezoneOffset();\n      const sign = offsetMinutesLocal >= 0 ? '+' : '-';\n      const abs = Math.abs(offsetMinutesLocal);\n      const offH = Math.floor(abs / 60);\n      const offM = abs % 60;\n      const gmt = `GMT${sign}${offH}${offM ? ':' + String(offM).padStart(2, '0') : ''}`;\n      const tzId = Intl.DateTimeFormat().resolvedOptions().timeZone || '';\n      const cityRaw = tzId.split('/').pop() || '';\n      const city = cityRaw\n        .replace(/_/g, ' ')\n        .toLowerCase()\n        .replace(/\\b\\w/g, (char) => char.toUpperCase());\n      const tzHuman = city ? `${gmt} (${city})` : gmt;\n\n      const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];\n      const dateReadable = `${reset.getDate()} ${months[reset.getMonth()]} ${reset.getFullYear()}`;\n\n      return `Claude usage limit reached. Your limit will reset at **${timeStr} ${tzHuman}** - ${dateReadable}`;\n    });\n  } catch {\n    return text;\n  }\n}\n"
  },
  {
    "path": "src/components/chat/utils/chatPermissions.ts",
    "content": "import { safeJsonParse } from '../../../lib/utils.js';\nimport type { ChatMessage, ClaudePermissionSuggestion, PermissionGrantResult } from '../types/types.js';\nimport { CLAUDE_SETTINGS_KEY, getClaudeSettings, safeLocalStorage } from './chatStorage';\n\nexport function buildClaudeToolPermissionEntry(toolName?: string, toolInput?: unknown) {\n  if (!toolName) return null;\n  if (toolName !== 'Bash') return toolName;\n\n  const parsed = safeJsonParse(toolInput);\n  const command = typeof parsed?.command === 'string' ? parsed.command.trim() : '';\n  if (!command) return toolName;\n\n  const tokens = command.split(/\\s+/);\n  if (tokens.length === 0) return toolName;\n\n  if (tokens[0] === 'git' && tokens[1]) {\n    return `Bash(${tokens[0]} ${tokens[1]}:*)`;\n  }\n  return `Bash(${tokens[0]}:*)`;\n}\n\nexport function formatToolInputForDisplay(input: unknown) {\n  if (input === undefined || input === null) return '';\n  if (typeof input === 'string') return input;\n  try {\n    return JSON.stringify(input, null, 2);\n  } catch {\n    return String(input);\n  }\n}\n\nexport function getClaudePermissionSuggestion(\n  message: ChatMessage | null | undefined,\n  provider: string,\n): ClaudePermissionSuggestion | null {\n  if (provider !== 'claude') return null;\n  if (!message?.toolResult?.isError) return null;\n\n  const toolName = message?.toolName;\n  const entry = buildClaudeToolPermissionEntry(toolName, message.toolInput);\n  if (!entry) return null;\n\n  const settings = getClaudeSettings();\n  const isAllowed = settings.allowedTools.includes(entry);\n  return { toolName: toolName || 'UnknownTool', entry, isAllowed };\n}\n\nexport function grantClaudeToolPermission(entry: string | null): PermissionGrantResult {\n  if (!entry) return { success: false };\n\n  const settings = getClaudeSettings();\n  const alreadyAllowed = settings.allowedTools.includes(entry);\n  const nextAllowed = alreadyAllowed ? settings.allowedTools : [...settings.allowedTools, entry];\n  const nextDisallowed = settings.disallowedTools.filter((tool) => tool !== entry);\n  const updatedSettings = {\n    ...settings,\n    allowedTools: nextAllowed,\n    disallowedTools: nextDisallowed,\n    lastUpdated: new Date().toISOString(),\n  };\n\n  safeLocalStorage.setItem(CLAUDE_SETTINGS_KEY, JSON.stringify(updatedSettings));\n  return { success: true, alreadyAllowed, updatedSettings };\n}\n"
  },
  {
    "path": "src/components/chat/utils/chatStorage.ts",
    "content": "import type { ClaudeSettings } from '../types/types';\n\nexport const CLAUDE_SETTINGS_KEY = 'claude-settings';\n\nexport const safeLocalStorage = {\n  setItem: (key: string, value: string) => {\n    try {\n      localStorage.setItem(key, value);\n    } catch (error: any) {\n      if (error?.name === 'QuotaExceededError') {\n        console.warn('localStorage quota exceeded, clearing old data');\n\n        const keys = Object.keys(localStorage);\n        const draftKeys = keys.filter((k) => k.startsWith('draft_input_'));\n        draftKeys.forEach((k) => {\n          localStorage.removeItem(k);\n        });\n\n        try {\n          localStorage.setItem(key, value);\n        } catch (retryError) {\n          console.error('Failed to save to localStorage even after cleanup:', retryError);\n        }\n      } else {\n        console.error('localStorage error:', error);\n      }\n    }\n  },\n  getItem: (key: string): string | null => {\n    try {\n      return localStorage.getItem(key);\n    } catch (error) {\n      console.error('localStorage getItem error:', error);\n      return null;\n    }\n  },\n  removeItem: (key: string) => {\n    try {\n      localStorage.removeItem(key);\n    } catch (error) {\n      console.error('localStorage removeItem error:', error);\n    }\n  },\n};\n\nexport function getClaudeSettings(): ClaudeSettings {\n  const raw = safeLocalStorage.getItem(CLAUDE_SETTINGS_KEY);\n  if (!raw) {\n    return {\n      allowedTools: [],\n      disallowedTools: [],\n      skipPermissions: false,\n      projectSortOrder: 'name',\n    };\n  }\n\n  try {\n    const parsed = JSON.parse(raw);\n    return {\n      ...parsed,\n      allowedTools: Array.isArray(parsed.allowedTools) ? parsed.allowedTools : [],\n      disallowedTools: Array.isArray(parsed.disallowedTools) ? parsed.disallowedTools : [],\n      skipPermissions: Boolean(parsed.skipPermissions),\n      projectSortOrder: parsed.projectSortOrder || 'name',\n    };\n  } catch {\n    return {\n      allowedTools: [],\n      disallowedTools: [],\n      skipPermissions: false,\n      projectSortOrder: 'name',\n    };\n  }\n}\n"
  },
  {
    "path": "src/components/chat/utils/messageKeys.ts",
    "content": "import type { ChatMessage } from '../types/types';\n\nconst toMessageKeyPart = (value: unknown): string | null => {\n  if (typeof value !== 'string' && typeof value !== 'number') {\n    return null;\n  }\n\n  const normalized = String(value).trim();\n  return normalized.length > 0 ? normalized : null;\n};\n\nexport const getIntrinsicMessageKey = (message: ChatMessage): string | null => {\n  const candidates = [\n    message.id,\n    message.messageId,\n    message.toolId,\n    message.toolCallId,\n    message.blobId,\n    message.rowid,\n    message.sequence,\n  ];\n\n  for (const candidate of candidates) {\n    const keyPart = toMessageKeyPart(candidate);\n    if (keyPart) {\n      return `message-${message.type}-${keyPart}`;\n    }\n  }\n\n  const timestamp = new Date(message.timestamp).getTime();\n  if (!Number.isFinite(timestamp)) {\n    return null;\n  }\n\n  const contentPreview = typeof message.content === 'string' ? message.content.slice(0, 48) : '';\n  const toolName = typeof message.toolName === 'string' ? message.toolName : '';\n  return `message-${message.type}-${timestamp}-${toolName}-${contentPreview}`;\n};\n"
  },
  {
    "path": "src/components/chat/utils/messageTransforms.ts",
    "content": "export interface DiffLine {\n  type: 'added' | 'removed';\n  content: string;\n  lineNum: number;\n}\n\nexport type DiffCalculator = (oldStr: string, newStr: string) => DiffLine[];\n\nexport const calculateDiff = (oldStr: string, newStr: string): DiffLine[] => {\n  const oldLines = oldStr.split('\\n');\n  const newLines = newStr.split('\\n');\n\n  // Use LCS alignment so insertions/deletions don't cascade into a full-file \"changed\" diff.\n  const lcsTable: number[][] = Array.from({ length: oldLines.length + 1 }, () =>\n    new Array<number>(newLines.length + 1).fill(0),\n  );\n  for (let oldIndex = oldLines.length - 1; oldIndex >= 0; oldIndex -= 1) {\n    for (let newIndex = newLines.length - 1; newIndex >= 0; newIndex -= 1) {\n      if (oldLines[oldIndex] === newLines[newIndex]) {\n        lcsTable[oldIndex][newIndex] = lcsTable[oldIndex + 1][newIndex + 1] + 1;\n      } else {\n        lcsTable[oldIndex][newIndex] = Math.max(\n          lcsTable[oldIndex + 1][newIndex],\n          lcsTable[oldIndex][newIndex + 1],\n        );\n      }\n    }\n  }\n\n  const diffLines: DiffLine[] = [];\n  let oldIndex = 0;\n  let newIndex = 0;\n\n  while (oldIndex < oldLines.length && newIndex < newLines.length) {\n    const oldLine = oldLines[oldIndex];\n    const newLine = newLines[newIndex];\n\n    if (oldLine === newLine) {\n      oldIndex += 1;\n      newIndex += 1;\n      continue;\n    }\n\n    if (lcsTable[oldIndex + 1][newIndex] >= lcsTable[oldIndex][newIndex + 1]) {\n      diffLines.push({ type: 'removed', content: oldLine, lineNum: oldIndex + 1 });\n      oldIndex += 1;\n      continue;\n    }\n\n    diffLines.push({ type: 'added', content: newLine, lineNum: newIndex + 1 });\n    newIndex += 1;\n  }\n\n  while (oldIndex < oldLines.length) {\n    diffLines.push({ type: 'removed', content: oldLines[oldIndex], lineNum: oldIndex + 1 });\n    oldIndex += 1;\n  }\n\n  while (newIndex < newLines.length) {\n    diffLines.push({ type: 'added', content: newLines[newIndex], lineNum: newIndex + 1 });\n    newIndex += 1;\n  }\n\n  return diffLines;\n};\n\nexport const createCachedDiffCalculator = (): DiffCalculator => {\n  const cache = new Map<string, DiffLine[]>();\n\n  return (oldStr: string, newStr: string) => {\n    const key = JSON.stringify([oldStr, newStr]);\n    const cached = cache.get(key);\n    if (cached) {\n      return cached;\n    }\n\n    const calculated = calculateDiff(oldStr, newStr);\n    cache.set(key, calculated);\n    if (cache.size > 100) {\n      const firstKey = cache.keys().next().value;\n      if (firstKey) {\n        cache.delete(firstKey);\n      }\n    }\n    return calculated;\n  };\n};\n"
  },
  {
    "path": "src/components/chat/view/ChatInterface.tsx",
    "content": "import React, { useCallback, useEffect, useRef } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useTasksSettings } from '../../../contexts/TasksSettingsContext';\nimport { QuickSettingsPanel } from '../../quick-settings-panel';\nimport type { ChatInterfaceProps, Provider  } from '../types/types';\nimport type { SessionProvider } from '../../../types/app';\nimport { useChatProviderState } from '../hooks/useChatProviderState';\nimport { useChatSessionState } from '../hooks/useChatSessionState';\nimport { useChatRealtimeHandlers } from '../hooks/useChatRealtimeHandlers';\nimport { useChatComposerState } from '../hooks/useChatComposerState';\nimport { useSessionStore } from '../../../stores/useSessionStore';\nimport ChatMessagesPane from './subcomponents/ChatMessagesPane';\nimport ChatComposer from './subcomponents/ChatComposer';\n\n\ntype PendingViewSession = {\n  sessionId: string | null;\n  startedAt: number;\n};\n\nfunction ChatInterface({\n  selectedProject,\n  selectedSession,\n  ws,\n  sendMessage,\n  latestMessage,\n  onFileOpen,\n  onInputFocusChange,\n  onSessionActive,\n  onSessionInactive,\n  onSessionProcessing,\n  onSessionNotProcessing,\n  processingSessions,\n  onReplaceTemporarySession,\n  onNavigateToSession,\n  onShowSettings,\n  autoExpandTools,\n  showRawParameters,\n  showThinking,\n  autoScrollToBottom,\n  sendByCtrlEnter,\n  externalMessageUpdate,\n  onShowAllTasks,\n}: ChatInterfaceProps) {\n  const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();\n  const { t } = useTranslation('chat');\n\n  const sessionStore = useSessionStore();\n  const streamBufferRef = useRef('');\n  const streamTimerRef = useRef<number | null>(null);\n  const accumulatedStreamRef = useRef('');\n  const pendingViewSessionRef = useRef<PendingViewSession | null>(null);\n\n  const resetStreamingState = useCallback(() => {\n    if (streamTimerRef.current) {\n      clearTimeout(streamTimerRef.current);\n      streamTimerRef.current = null;\n    }\n    streamBufferRef.current = '';\n    accumulatedStreamRef.current = '';\n  }, []);\n\n  const {\n    provider,\n    setProvider,\n    cursorModel,\n    setCursorModel,\n    claudeModel,\n    setClaudeModel,\n    codexModel,\n    setCodexModel,\n    geminiModel,\n    setGeminiModel,\n    permissionMode,\n    pendingPermissionRequests,\n    setPendingPermissionRequests,\n    cyclePermissionMode,\n  } = useChatProviderState({\n    selectedSession,\n  });\n\n  const {\n    chatMessages,\n    addMessage,\n    clearMessages,\n    rewindMessages,\n    isLoading,\n    setIsLoading,\n    currentSessionId,\n    setCurrentSessionId,\n    isLoadingSessionMessages,\n    isLoadingMoreMessages,\n    hasMoreMessages,\n    totalMessages,\n    canAbortSession,\n    setCanAbortSession,\n    isUserScrolledUp,\n    setIsUserScrolledUp,\n    tokenBudget,\n    setTokenBudget,\n    visibleMessageCount,\n    visibleMessages,\n    loadEarlierMessages,\n    loadAllMessages,\n    allMessagesLoaded,\n    isLoadingAllMessages,\n    loadAllJustFinished,\n    showLoadAllOverlay,\n    claudeStatus,\n    setClaudeStatus,\n    createDiff,\n    scrollContainerRef,\n    scrollToBottom,\n    scrollToBottomAndReset,\n    handleScroll,\n  } = useChatSessionState({\n    selectedProject,\n    selectedSession,\n    ws,\n    sendMessage,\n    autoScrollToBottom,\n    externalMessageUpdate,\n    processingSessions,\n    resetStreamingState,\n    pendingViewSessionRef,\n    sessionStore,\n  });\n\n  const {\n    input,\n    setInput,\n    textareaRef,\n    inputHighlightRef,\n    isTextareaExpanded,\n    thinkingMode,\n    setThinkingMode,\n    slashCommandsCount,\n    filteredCommands,\n    frequentCommands,\n    commandQuery,\n    showCommandMenu,\n    selectedCommandIndex,\n    resetCommandMenuState,\n    handleCommandSelect,\n    handleToggleCommandMenu,\n    showFileDropdown,\n    filteredFiles,\n    selectedFileIndex,\n    renderInputWithMentions,\n    selectFile,\n    attachedImages,\n    setAttachedImages,\n    uploadingImages,\n    imageErrors,\n    getRootProps,\n    getInputProps,\n    isDragActive,\n    openImagePicker,\n    handleSubmit,\n    handleInputChange,\n    handleKeyDown,\n    handlePaste,\n    handleTextareaClick,\n    handleTextareaInput,\n    syncInputOverlayScroll,\n    handleClearInput,\n    handleAbortSession,\n    handleTranscript,\n    handlePermissionDecision,\n    handleGrantToolPermission,\n    handleInputFocusChange,\n    isInputFocused,\n  } = useChatComposerState({\n    selectedProject,\n    selectedSession,\n    currentSessionId,\n    provider,\n    permissionMode,\n    cyclePermissionMode,\n    cursorModel,\n    claudeModel,\n    codexModel,\n    geminiModel,\n    isLoading,\n    canAbortSession,\n    tokenBudget,\n    sendMessage,\n    sendByCtrlEnter,\n    onSessionActive,\n    onSessionProcessing,\n    onInputFocusChange,\n    onFileOpen,\n    onShowSettings,\n    pendingViewSessionRef,\n    scrollToBottom,\n    addMessage,\n    clearMessages,\n    rewindMessages,\n    setIsLoading,\n    setCanAbortSession,\n    setClaudeStatus,\n    setIsUserScrolledUp,\n    setPendingPermissionRequests,\n  });\n\n  // On WebSocket reconnect, re-fetch the current session's messages from the server\n  // so missed streaming events are shown. Also reset isLoading.\n  const handleWebSocketReconnect = useCallback(async () => {\n    if (!selectedProject || !selectedSession) return;\n    const providerVal = (localStorage.getItem('selected-provider') as SessionProvider) || 'claude';\n    await sessionStore.refreshFromServer(selectedSession.id, {\n      provider: (selectedSession.__provider || providerVal) as SessionProvider,\n      projectName: selectedProject.name,\n      projectPath: selectedProject.fullPath || selectedProject.path || '',\n    });\n    setIsLoading(false);\n    setCanAbortSession(false);\n  }, [selectedProject, selectedSession, sessionStore, setIsLoading, setCanAbortSession]);\n\n  useChatRealtimeHandlers({\n    latestMessage,\n    provider,\n    selectedProject,\n    selectedSession,\n    currentSessionId,\n    setCurrentSessionId,\n    setIsLoading,\n    setCanAbortSession,\n    setClaudeStatus,\n    setTokenBudget,\n    setPendingPermissionRequests,\n    pendingViewSessionRef,\n    streamBufferRef,\n    streamTimerRef,\n    accumulatedStreamRef,\n    onSessionInactive,\n    onSessionProcessing,\n    onSessionNotProcessing,\n    onReplaceTemporarySession,\n    onNavigateToSession,\n    onWebSocketReconnect: handleWebSocketReconnect,\n    sessionStore,\n  });\n\n  useEffect(() => {\n    if (!isLoading || !canAbortSession) {\n      return;\n    }\n\n    const handleGlobalEscape = (event: KeyboardEvent) => {\n      if (event.key !== 'Escape' || event.repeat || event.defaultPrevented) {\n        return;\n      }\n\n      event.preventDefault();\n      handleAbortSession();\n    };\n\n    document.addEventListener('keydown', handleGlobalEscape, { capture: true });\n    return () => {\n      document.removeEventListener('keydown', handleGlobalEscape, { capture: true });\n    };\n  }, [canAbortSession, handleAbortSession, isLoading]);\n\n  useEffect(() => {\n    return () => {\n      resetStreamingState();\n    };\n  }, [resetStreamingState]);\n\n  if (!selectedProject) {\n    const selectedProviderLabel =\n      provider === 'cursor'\n        ? t('messageTypes.cursor')\n        : provider === 'codex'\n          ? t('messageTypes.codex')\n          : provider === 'gemini'\n            ? t('messageTypes.gemini')\n            : t('messageTypes.claude');\n\n    return (\n      <div className=\"flex h-full items-center justify-center\">\n        <div className=\"text-center text-muted-foreground\">\n          <p className=\"text-sm\">\n            {t('projectSelection.startChatWithProvider', {\n              provider: selectedProviderLabel,\n              defaultValue: 'Select a project to start chatting with {{provider}}',\n            })}\n          </p>\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <>\n      <div className=\"flex h-full flex-col\">\n        <ChatMessagesPane\n          scrollContainerRef={scrollContainerRef}\n          onWheel={handleScroll}\n          onTouchMove={handleScroll}\n          isLoadingSessionMessages={isLoadingSessionMessages}\n          chatMessages={chatMessages}\n          selectedSession={selectedSession}\n          currentSessionId={currentSessionId}\n          provider={provider}\n          setProvider={(nextProvider) => setProvider(nextProvider as Provider)}\n          textareaRef={textareaRef}\n          claudeModel={claudeModel}\n          setClaudeModel={setClaudeModel}\n          cursorModel={cursorModel}\n          setCursorModel={setCursorModel}\n          codexModel={codexModel}\n          setCodexModel={setCodexModel}\n          geminiModel={geminiModel}\n          setGeminiModel={setGeminiModel}\n          tasksEnabled={tasksEnabled}\n          isTaskMasterInstalled={isTaskMasterInstalled}\n          onShowAllTasks={onShowAllTasks}\n          setInput={setInput}\n          isLoadingMoreMessages={isLoadingMoreMessages}\n          hasMoreMessages={hasMoreMessages}\n          totalMessages={totalMessages}\n          sessionMessagesCount={chatMessages.length}\n          visibleMessageCount={visibleMessageCount}\n          visibleMessages={visibleMessages}\n          loadEarlierMessages={loadEarlierMessages}\n          loadAllMessages={loadAllMessages}\n          allMessagesLoaded={allMessagesLoaded}\n          isLoadingAllMessages={isLoadingAllMessages}\n          loadAllJustFinished={loadAllJustFinished}\n          showLoadAllOverlay={showLoadAllOverlay}\n          createDiff={createDiff}\n          onFileOpen={onFileOpen}\n          onShowSettings={onShowSettings}\n          onGrantToolPermission={handleGrantToolPermission}\n          autoExpandTools={autoExpandTools}\n          showRawParameters={showRawParameters}\n          showThinking={showThinking}\n          selectedProject={selectedProject}\n          isLoading={isLoading}\n        />\n\n        <ChatComposer\n          pendingPermissionRequests={pendingPermissionRequests}\n          handlePermissionDecision={handlePermissionDecision}\n          handleGrantToolPermission={handleGrantToolPermission}\n          claudeStatus={claudeStatus}\n          isLoading={isLoading}\n          onAbortSession={handleAbortSession}\n          provider={provider}\n          permissionMode={permissionMode}\n          onModeSwitch={cyclePermissionMode}\n          thinkingMode={thinkingMode}\n          setThinkingMode={setThinkingMode}\n          tokenBudget={tokenBudget}\n          slashCommandsCount={slashCommandsCount}\n          onToggleCommandMenu={handleToggleCommandMenu}\n          hasInput={Boolean(input.trim())}\n          onClearInput={handleClearInput}\n          isUserScrolledUp={isUserScrolledUp}\n          hasMessages={chatMessages.length > 0}\n          onScrollToBottom={scrollToBottomAndReset}\n          onSubmit={handleSubmit}\n          isDragActive={isDragActive}\n          attachedImages={attachedImages}\n          onRemoveImage={(index) =>\n            setAttachedImages((previous) =>\n              previous.filter((_, currentIndex) => currentIndex !== index),\n            )\n          }\n          uploadingImages={uploadingImages}\n          imageErrors={imageErrors}\n          showFileDropdown={showFileDropdown}\n          filteredFiles={filteredFiles}\n          selectedFileIndex={selectedFileIndex}\n          onSelectFile={selectFile}\n          filteredCommands={filteredCommands}\n          selectedCommandIndex={selectedCommandIndex}\n          onCommandSelect={handleCommandSelect}\n          onCloseCommandMenu={resetCommandMenuState}\n          isCommandMenuOpen={showCommandMenu}\n          frequentCommands={commandQuery ? [] : frequentCommands}\n          getRootProps={getRootProps as (...args: unknown[]) => Record<string, unknown>}\n          getInputProps={getInputProps as (...args: unknown[]) => Record<string, unknown>}\n          openImagePicker={openImagePicker}\n          inputHighlightRef={inputHighlightRef}\n          renderInputWithMentions={renderInputWithMentions}\n          textareaRef={textareaRef}\n          input={input}\n          onInputChange={handleInputChange}\n          onTextareaClick={handleTextareaClick}\n          onTextareaKeyDown={handleKeyDown}\n          onTextareaPaste={handlePaste}\n          onTextareaScrollSync={syncInputOverlayScroll}\n          onTextareaInput={handleTextareaInput}\n          onInputFocusChange={handleInputFocusChange}\n          isInputFocused={isInputFocused}\n          placeholder={t('input.placeholder', {\n            provider:\n              provider === 'cursor'\n                ? t('messageTypes.cursor')\n                : provider === 'codex'\n                  ? t('messageTypes.codex')\n                  : provider === 'gemini'\n                    ? t('messageTypes.gemini')\n                    : t('messageTypes.claude'),\n          })}\n          isTextareaExpanded={isTextareaExpanded}\n          sendByCtrlEnter={sendByCtrlEnter}\n          onTranscript={handleTranscript}\n        />\n      </div>\n\n      <QuickSettingsPanel />\n    </>\n  );\n}\n\nexport default React.memo(ChatInterface);\n"
  },
  {
    "path": "src/components/chat/view/subcomponents/AssistantThinkingIndicator.tsx",
    "content": "import { SessionProvider } from '../../../../types/app';\nimport SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';\n\ntype AssistantThinkingIndicatorProps = {\n  selectedProvider: SessionProvider;\n}\n\n\nexport default function AssistantThinkingIndicator({ selectedProvider }: AssistantThinkingIndicatorProps) {\n  return (\n    <div className=\"chat-message assistant\">\n      <div className=\"w-full\">\n        <div className=\"mb-2 flex items-center space-x-3\">\n          <div className=\"flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-transparent p-1 text-sm text-white\">\n            <SessionProviderLogo provider={selectedProvider} className=\"h-full w-full\" />\n          </div>\n          <div className=\"text-sm font-medium text-gray-900 dark:text-white\">\n            {selectedProvider === 'cursor' ? 'Cursor' : selectedProvider === 'codex' ? 'Codex' : selectedProvider === 'gemini' ? 'Gemini' : 'Claude'}\n          </div>\n        </div>\n        <div className=\"w-full pl-3 text-sm text-gray-500 dark:text-gray-400 sm:pl-0\">\n          <div className=\"flex items-center space-x-1\">\n            <div className=\"animate-pulse\">.</div>\n            <div className=\"animate-pulse\" style={{ animationDelay: '0.2s' }}>\n              .\n            </div>\n            <div className=\"animate-pulse\" style={{ animationDelay: '0.4s' }}>\n              .\n            </div>\n            <span className=\"ml-2\">Thinking...</span>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/chat/view/subcomponents/ChatComposer.tsx",
    "content": "import { useTranslation } from 'react-i18next';\nimport type {\n  ChangeEvent,\n  ClipboardEvent,\n  Dispatch,\n  FormEvent,\n  KeyboardEvent,\n  MouseEvent,\n  ReactNode,\n  RefObject,\n  SetStateAction,\n  TouchEvent,\n} from 'react';\nimport MicButton from '../../../mic-button/view/MicButton';\nimport type { PendingPermissionRequest, PermissionMode, Provider } from '../../types/types';\nimport CommandMenu from './CommandMenu';\nimport ClaudeStatus from './ClaudeStatus';\nimport ImageAttachment from './ImageAttachment';\nimport PermissionRequestsBanner from './PermissionRequestsBanner';\nimport ChatInputControls from './ChatInputControls';\n\ninterface MentionableFile {\n  name: string;\n  path: string;\n}\n\ninterface SlashCommand {\n  name: string;\n  description?: string;\n  namespace?: string;\n  path?: string;\n  type?: string;\n  metadata?: Record<string, unknown>;\n  [key: string]: unknown;\n}\n\ninterface ChatComposerProps {\n  pendingPermissionRequests: PendingPermissionRequest[];\n  handlePermissionDecision: (\n    requestIds: string | string[],\n    decision: { allow?: boolean; message?: string; rememberEntry?: string | null; updatedInput?: unknown },\n  ) => void;\n  handleGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean };\n  claudeStatus: { text: string; tokens: number; can_interrupt: boolean } | null;\n  isLoading: boolean;\n  onAbortSession: () => void;\n  provider: Provider | string;\n  permissionMode: PermissionMode | string;\n  onModeSwitch: () => void;\n  thinkingMode: string;\n  setThinkingMode: Dispatch<SetStateAction<string>>;\n  tokenBudget: { used?: number; total?: number } | null;\n  slashCommandsCount: number;\n  onToggleCommandMenu: () => void;\n  hasInput: boolean;\n  onClearInput: () => void;\n  isUserScrolledUp: boolean;\n  hasMessages: boolean;\n  onScrollToBottom: () => void;\n  onSubmit: (event: FormEvent<HTMLFormElement> | MouseEvent<HTMLButtonElement> | TouchEvent<HTMLButtonElement>) => void;\n  isDragActive: boolean;\n  attachedImages: File[];\n  onRemoveImage: (index: number) => void;\n  uploadingImages: Map<string, number>;\n  imageErrors: Map<string, string>;\n  showFileDropdown: boolean;\n  filteredFiles: MentionableFile[];\n  selectedFileIndex: number;\n  onSelectFile: (file: MentionableFile) => void;\n  filteredCommands: SlashCommand[];\n  selectedCommandIndex: number;\n  onCommandSelect: (command: SlashCommand, index: number, isHover: boolean) => void;\n  onCloseCommandMenu: () => void;\n  isCommandMenuOpen: boolean;\n  frequentCommands: SlashCommand[];\n  getRootProps: (...args: unknown[]) => Record<string, unknown>;\n  getInputProps: (...args: unknown[]) => Record<string, unknown>;\n  openImagePicker: () => void;\n  inputHighlightRef: RefObject<HTMLDivElement>;\n  renderInputWithMentions: (text: string) => ReactNode;\n  textareaRef: RefObject<HTMLTextAreaElement>;\n  input: string;\n  onInputChange: (event: ChangeEvent<HTMLTextAreaElement>) => void;\n  onTextareaClick: (event: MouseEvent<HTMLTextAreaElement>) => void;\n  onTextareaKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void;\n  onTextareaPaste: (event: ClipboardEvent<HTMLTextAreaElement>) => void;\n  onTextareaScrollSync: (target: HTMLTextAreaElement) => void;\n  onTextareaInput: (event: FormEvent<HTMLTextAreaElement>) => void;\n  onInputFocusChange?: (focused: boolean) => void;\n  isInputFocused?: boolean;\n  placeholder: string;\n  isTextareaExpanded: boolean;\n  sendByCtrlEnter?: boolean;\n  onTranscript: (text: string) => void;\n}\n\nexport default function ChatComposer({\n  pendingPermissionRequests,\n  handlePermissionDecision,\n  handleGrantToolPermission,\n  claudeStatus,\n  isLoading,\n  onAbortSession,\n  provider,\n  permissionMode,\n  onModeSwitch,\n  thinkingMode,\n  setThinkingMode,\n  tokenBudget,\n  slashCommandsCount,\n  onToggleCommandMenu,\n  hasInput,\n  onClearInput,\n  isUserScrolledUp,\n  hasMessages,\n  onScrollToBottom,\n  onSubmit,\n  isDragActive,\n  attachedImages,\n  onRemoveImage,\n  uploadingImages,\n  imageErrors,\n  showFileDropdown,\n  filteredFiles,\n  selectedFileIndex,\n  onSelectFile,\n  filteredCommands,\n  selectedCommandIndex,\n  onCommandSelect,\n  onCloseCommandMenu,\n  isCommandMenuOpen,\n  frequentCommands,\n  getRootProps,\n  getInputProps,\n  openImagePicker,\n  inputHighlightRef,\n  renderInputWithMentions,\n  textareaRef,\n  input,\n  onInputChange,\n  onTextareaClick,\n  onTextareaKeyDown,\n  onTextareaPaste,\n  onTextareaScrollSync,\n  onTextareaInput,\n  onInputFocusChange,\n  isInputFocused,\n  placeholder,\n  isTextareaExpanded,\n  sendByCtrlEnter,\n  onTranscript,\n}: ChatComposerProps) {\n  const { t } = useTranslation('chat');\n  const textareaRect = textareaRef.current?.getBoundingClientRect();\n  const commandMenuPosition = {\n    top: textareaRect ? Math.max(16, textareaRect.top - 316) : 0,\n    left: textareaRect ? textareaRect.left : 16,\n    bottom: textareaRect ? window.innerHeight - textareaRect.top + 8 : 90,\n  };\n\n  // Detect if the AskUserQuestion interactive panel is active\n  const hasQuestionPanel = pendingPermissionRequests.some(\n    (r) => r.toolName === 'AskUserQuestion'\n  );\n\n  // On mobile, when input is focused, float the input box at the bottom\n  const mobileFloatingClass = isInputFocused\n    ? 'max-sm:fixed max-sm:bottom-0 max-sm:left-0 max-sm:right-0 max-sm:z-50 max-sm:bg-background max-sm:shadow-[0_-4px_20px_rgba(0,0,0,0.15)]'\n    : '';\n\n  return (\n    <div className={`flex-shrink-0 p-2 pb-2 sm:p-4 sm:pb-4 md:p-4 md:pb-6 ${mobileFloatingClass}`}>\n      {!hasQuestionPanel && (\n        <div className=\"flex-1\">\n          <ClaudeStatus\n            status={claudeStatus}\n            isLoading={isLoading}\n            onAbort={onAbortSession}\n            provider={provider}\n          />\n        </div>\n      )}\n\n      <div className=\"mx-auto mb-3 max-w-4xl\">\n        <PermissionRequestsBanner\n          pendingPermissionRequests={pendingPermissionRequests}\n          handlePermissionDecision={handlePermissionDecision}\n          handleGrantToolPermission={handleGrantToolPermission}\n        />\n\n        {!hasQuestionPanel && <ChatInputControls\n          permissionMode={permissionMode}\n          onModeSwitch={onModeSwitch}\n          provider={provider}\n          thinkingMode={thinkingMode}\n          setThinkingMode={setThinkingMode}\n          tokenBudget={tokenBudget}\n          slashCommandsCount={slashCommandsCount}\n          onToggleCommandMenu={onToggleCommandMenu}\n          hasInput={hasInput}\n          onClearInput={onClearInput}\n          isUserScrolledUp={isUserScrolledUp}\n          hasMessages={hasMessages}\n          onScrollToBottom={onScrollToBottom}\n        />}\n      </div>\n\n      {!hasQuestionPanel && <form onSubmit={onSubmit as (event: FormEvent<HTMLFormElement>) => void} className=\"relative mx-auto max-w-4xl\">\n        {isDragActive && (\n          <div className=\"absolute inset-0 z-50 flex items-center justify-center rounded-2xl border-2 border-dashed border-primary/50 bg-primary/15\">\n            <div className=\"rounded-xl border border-border/30 bg-card p-4 shadow-lg\">\n              <svg className=\"mx-auto mb-2 h-8 w-8 text-primary\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                <path\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                  strokeWidth={2}\n                  d=\"M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12\"\n                />\n              </svg>\n              <p className=\"text-sm font-medium\">Drop images here</p>\n            </div>\n          </div>\n        )}\n\n        {attachedImages.length > 0 && (\n          <div className=\"mb-2 rounded-xl bg-muted/40 p-2\">\n            <div className=\"flex flex-wrap gap-2\">\n              {attachedImages.map((file, index) => (\n                <ImageAttachment\n                  key={index}\n                  file={file}\n                  onRemove={() => onRemoveImage(index)}\n                  uploadProgress={uploadingImages.get(file.name)}\n                  error={imageErrors.get(file.name)}\n                />\n              ))}\n            </div>\n          </div>\n        )}\n\n        {showFileDropdown && filteredFiles.length > 0 && (\n          <div className=\"absolute bottom-full left-0 right-0 z-50 mb-2 max-h-48 overflow-y-auto rounded-xl border border-border/50 bg-card/95 shadow-lg backdrop-blur-md\">\n            {filteredFiles.map((file, index) => (\n              <div\n                key={file.path}\n                className={`cursor-pointer touch-manipulation border-b border-border/30 px-4 py-3 last:border-b-0 ${\n                  index === selectedFileIndex\n                    ? 'bg-primary/8 text-primary'\n                    : 'text-foreground hover:bg-accent/50'\n                }`}\n                onMouseDown={(event) => {\n                  event.preventDefault();\n                  event.stopPropagation();\n                }}\n                onClick={(event) => {\n                  event.preventDefault();\n                  event.stopPropagation();\n                  onSelectFile(file);\n                }}\n              >\n                <div className=\"text-sm font-medium\">{file.name}</div>\n                <div className=\"font-mono text-xs text-muted-foreground\">{file.path}</div>\n              </div>\n            ))}\n          </div>\n        )}\n\n        <CommandMenu\n          commands={filteredCommands}\n          selectedIndex={selectedCommandIndex}\n          onSelect={onCommandSelect}\n          onClose={onCloseCommandMenu}\n          position={commandMenuPosition}\n          isOpen={isCommandMenuOpen}\n          frequentCommands={frequentCommands}\n        />\n\n        <div\n          {...getRootProps()}\n          className={`relative overflow-hidden rounded-2xl border border-border/50 bg-card/80 shadow-sm backdrop-blur-sm transition-all duration-200 focus-within:border-primary/30 focus-within:shadow-md focus-within:ring-1 focus-within:ring-primary/15 ${\n            isTextareaExpanded ? 'chat-input-expanded' : ''\n          }`}\n        >\n          <input {...getInputProps()} />\n          <div ref={inputHighlightRef} aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 overflow-hidden rounded-2xl\">\n            <div className=\"chat-input-placeholder block w-full whitespace-pre-wrap break-words py-1.5 pl-12 pr-20 text-base leading-6 text-transparent sm:py-4 sm:pr-40\">\n              {renderInputWithMentions(input)}\n            </div>\n          </div>\n\n          <div className=\"relative z-10\">\n            <textarea\n              ref={textareaRef}\n              value={input}\n              onChange={onInputChange}\n              onClick={onTextareaClick}\n              onKeyDown={onTextareaKeyDown}\n              onPaste={onTextareaPaste}\n              onScroll={(event) => onTextareaScrollSync(event.target as HTMLTextAreaElement)}\n              onFocus={() => onInputFocusChange?.(true)}\n              onBlur={() => onInputFocusChange?.(false)}\n              onInput={onTextareaInput}\n              placeholder={placeholder}\n              className=\"chat-input-placeholder block max-h-[40vh] min-h-[50px] w-full resize-none overflow-y-auto rounded-2xl bg-transparent py-1.5 pl-12 pr-20 text-base leading-6 text-foreground placeholder-muted-foreground/50 transition-all duration-200 focus:outline-none sm:max-h-[300px] sm:min-h-[80px] sm:py-4 sm:pr-40\"\n              style={{ height: '50px' }}\n            />\n\n            <button\n              type=\"button\"\n              onClick={openImagePicker}\n              className=\"absolute left-2 top-1/2 -translate-y-1/2 transform rounded-xl p-2 transition-colors hover:bg-accent/60\"\n              title={t('input.attachImages')}\n            >\n              <svg className=\"h-5 w-5 text-muted-foreground\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                <path\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                  strokeWidth={2}\n                  d=\"M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z\"\n                />\n              </svg>\n            </button>\n\n            <div className=\"absolute right-16 top-1/2 -translate-y-1/2 transform sm:right-16\" style={{ display: 'none' }}>\n              <MicButton onTranscript={onTranscript} className=\"h-10 w-10 sm:h-10 sm:w-10\" />\n            </div>\n\n            <button\n              type=\"submit\"\n              disabled={!input.trim() || isLoading}\n              onMouseDown={(event) => {\n                event.preventDefault();\n                onSubmit(event);\n              }}\n              onTouchStart={(event) => {\n                event.preventDefault();\n                onSubmit(event);\n              }}\n              className=\"absolute right-2 top-1/2 flex h-10 w-10 -translate-y-1/2 transform items-center justify-center rounded-xl bg-primary transition-all duration-200 hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary/30 focus:ring-offset-1 focus:ring-offset-background disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground sm:h-11 sm:w-11\"\n            >\n              <svg className=\"h-4 w-4 rotate-90 transform text-primary-foreground sm:h-[18px] sm:w-[18px]\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2.2} d=\"M12 19l9 2-9-18-9 18 9-2zm0 0v-8\" />\n              </svg>\n            </button>\n\n            <div\n              className={`pointer-events-none absolute bottom-1 left-12 right-14 hidden text-xs text-muted-foreground/50 transition-opacity duration-200 sm:right-40 sm:block ${\n                input.trim() ? 'opacity-0' : 'opacity-100'\n              }`}\n            >\n              {sendByCtrlEnter ? t('input.hintText.ctrlEnter') : t('input.hintText.enter')}\n            </div>\n          </div>\n        </div>\n      </form>}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/chat/view/subcomponents/ChatInputControls.tsx",
    "content": "import React from 'react';\nimport { useTranslation } from 'react-i18next';\nimport type { PermissionMode, Provider } from '../../types/types';\nimport ThinkingModeSelector from './ThinkingModeSelector';\nimport TokenUsagePie from './TokenUsagePie';\n\ninterface ChatInputControlsProps {\n  permissionMode: PermissionMode | string;\n  onModeSwitch: () => void;\n  provider: Provider | string;\n  thinkingMode: string;\n  setThinkingMode: React.Dispatch<React.SetStateAction<string>>;\n  tokenBudget: { used?: number; total?: number } | null;\n  slashCommandsCount: number;\n  onToggleCommandMenu: () => void;\n  hasInput: boolean;\n  onClearInput: () => void;\n  isUserScrolledUp: boolean;\n  hasMessages: boolean;\n  onScrollToBottom: () => void;\n}\n\nexport default function ChatInputControls({\n  permissionMode,\n  onModeSwitch,\n  provider,\n  thinkingMode,\n  setThinkingMode,\n  tokenBudget,\n  slashCommandsCount,\n  onToggleCommandMenu,\n  hasInput,\n  onClearInput,\n  isUserScrolledUp,\n  hasMessages,\n  onScrollToBottom,\n}: ChatInputControlsProps) {\n  const { t } = useTranslation('chat');\n\n  return (\n    <div className=\"flex flex-wrap items-center justify-center gap-2 sm:gap-3\">\n      <button\n        type=\"button\"\n        onClick={onModeSwitch}\n        className={`rounded-lg border px-2.5 py-1 text-sm font-medium transition-all duration-200 sm:px-3 sm:py-1.5 ${\n          permissionMode === 'default'\n            ? 'border-border/60 bg-muted/50 text-muted-foreground hover:bg-muted'\n            : permissionMode === 'acceptEdits'\n              ? 'border-green-300/60 bg-green-50 text-green-700 hover:bg-green-100 dark:border-green-600/40 dark:bg-green-900/15 dark:text-green-300 dark:hover:bg-green-900/25'\n              : permissionMode === 'bypassPermissions'\n                ? 'border-orange-300/60 bg-orange-50 text-orange-700 hover:bg-orange-100 dark:border-orange-600/40 dark:bg-orange-900/15 dark:text-orange-300 dark:hover:bg-orange-900/25'\n                : 'border-primary/20 bg-primary/5 text-primary hover:bg-primary/10'\n        }`}\n        title={t('input.clickToChangeMode')}\n      >\n        <div className=\"flex items-center gap-1.5\">\n          <div\n            className={`h-1.5 w-1.5 rounded-full ${\n              permissionMode === 'default'\n                ? 'bg-muted-foreground'\n                : permissionMode === 'acceptEdits'\n                  ? 'bg-green-500'\n                  : permissionMode === 'bypassPermissions'\n                    ? 'bg-orange-500'\n                    : 'bg-primary'\n            }`}\n          />\n          <span>\n            {permissionMode === 'default' && t('codex.modes.default')}\n            {permissionMode === 'acceptEdits' && t('codex.modes.acceptEdits')}\n            {permissionMode === 'bypassPermissions' && t('codex.modes.bypassPermissions')}\n            {permissionMode === 'plan' && t('codex.modes.plan')}\n          </span>\n        </div>\n      </button>\n\n      {provider === 'claude' && (\n        <ThinkingModeSelector selectedMode={thinkingMode} onModeChange={setThinkingMode} onClose={() => {}} className=\"\" />\n      )}\n\n      <TokenUsagePie used={tokenBudget?.used || 0} total={tokenBudget?.total || parseInt(import.meta.env.VITE_CONTEXT_WINDOW) || 160000} />\n\n      <button\n        type=\"button\"\n        onClick={onToggleCommandMenu}\n        className=\"relative flex h-7 w-7 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground sm:h-8 sm:w-8\"\n        title={t('input.showAllCommands')}\n      >\n        <svg className=\"h-4 w-4 sm:h-5 sm:w-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n          <path\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            strokeWidth={2}\n            d=\"M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z\"\n          />\n        </svg>\n        {slashCommandsCount > 0 && (\n          <span\n            className=\"absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground sm:h-5 sm:w-5\"\n          >\n            {slashCommandsCount}\n          </span>\n        )}\n      </button>\n\n      {hasInput && (\n        <button\n          type=\"button\"\n          onClick={onClearInput}\n          className=\"group flex h-7 w-7 items-center justify-center rounded-lg border border-border/50 bg-card shadow-sm transition-all duration-200 hover:bg-accent/60 sm:h-8 sm:w-8\"\n          title={t('input.clearInput', { defaultValue: 'Clear input' })}\n        >\n          <svg\n            className=\"h-3.5 w-3.5 text-muted-foreground transition-colors group-hover:text-foreground sm:h-4 sm:w-4\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            viewBox=\"0 0 24 24\"\n          >\n            <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M6 18L18 6M6 6l12 12\" />\n          </svg>\n        </button>\n      )}\n\n      {isUserScrolledUp && hasMessages && (\n        <button\n          onClick={onScrollToBottom}\n          className=\"flex h-7 w-7 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm transition-all duration-200 hover:scale-105 hover:bg-primary/90 sm:h-8 sm:w-8\"\n          title={t('input.scrollToBottom', { defaultValue: 'Scroll to bottom' })}\n        >\n          <svg className=\"h-3.5 w-3.5 sm:h-4 sm:w-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n            <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M19 14l-7 7m0 0l-7-7m7 7V3\" />\n          </svg>\n        </button>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/chat/view/subcomponents/ChatMessagesPane.tsx",
    "content": "import { useTranslation } from 'react-i18next';\nimport { useCallback, useRef } from 'react';\nimport type { Dispatch, RefObject, SetStateAction } from 'react';\nimport type { ChatMessage } from '../../types/types';\nimport type { Project, ProjectSession, SessionProvider } from '../../../../types/app';\nimport { getIntrinsicMessageKey } from '../../utils/messageKeys';\nimport MessageComponent from './MessageComponent';\nimport ProviderSelectionEmptyState from './ProviderSelectionEmptyState';\nimport AssistantThinkingIndicator from './AssistantThinkingIndicator';\n\ninterface ChatMessagesPaneProps {\n  scrollContainerRef: RefObject<HTMLDivElement>;\n  onWheel: () => void;\n  onTouchMove: () => void;\n  isLoadingSessionMessages: boolean;\n  chatMessages: ChatMessage[];\n  selectedSession: ProjectSession | null;\n  currentSessionId: string | null;\n  provider: SessionProvider;\n  setProvider: (provider: SessionProvider) => void;\n  textareaRef: RefObject<HTMLTextAreaElement>;\n  claudeModel: string;\n  setClaudeModel: (model: string) => void;\n  cursorModel: string;\n  setCursorModel: (model: string) => void;\n  codexModel: string;\n  setCodexModel: (model: string) => void;\n  geminiModel: string;\n  setGeminiModel: (model: string) => void;\n  tasksEnabled: boolean;\n  isTaskMasterInstalled: boolean | null;\n  onShowAllTasks?: (() => void) | null;\n  setInput: Dispatch<SetStateAction<string>>;\n  isLoadingMoreMessages: boolean;\n  hasMoreMessages: boolean;\n  totalMessages: number;\n  sessionMessagesCount: number;\n  visibleMessageCount: number;\n  visibleMessages: ChatMessage[];\n  loadEarlierMessages: () => void;\n  loadAllMessages: () => void;\n  allMessagesLoaded: boolean;\n  isLoadingAllMessages: boolean;\n  loadAllJustFinished: boolean;\n  showLoadAllOverlay: boolean;\n  createDiff: any;\n  onFileOpen?: (filePath: string, diffInfo?: unknown) => void;\n  onShowSettings?: () => void;\n  onGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean };\n  autoExpandTools?: boolean;\n  showRawParameters?: boolean;\n  showThinking?: boolean;\n  selectedProject: Project;\n  isLoading: boolean;\n}\n\nexport default function ChatMessagesPane({\n  scrollContainerRef,\n  onWheel,\n  onTouchMove,\n  isLoadingSessionMessages,\n  chatMessages,\n  selectedSession,\n  currentSessionId,\n  provider,\n  setProvider,\n  textareaRef,\n  claudeModel,\n  setClaudeModel,\n  cursorModel,\n  setCursorModel,\n  codexModel,\n  setCodexModel,\n  geminiModel,\n  setGeminiModel,\n  tasksEnabled,\n  isTaskMasterInstalled,\n  onShowAllTasks,\n  setInput,\n  isLoadingMoreMessages,\n  hasMoreMessages,\n  totalMessages,\n  sessionMessagesCount,\n  visibleMessageCount,\n  visibleMessages,\n  loadEarlierMessages,\n  loadAllMessages,\n  allMessagesLoaded,\n  isLoadingAllMessages,\n  loadAllJustFinished,\n  showLoadAllOverlay,\n  createDiff,\n  onFileOpen,\n  onShowSettings,\n  onGrantToolPermission,\n  autoExpandTools,\n  showRawParameters,\n  showThinking,\n  selectedProject,\n  isLoading,\n}: ChatMessagesPaneProps) {\n  const { t } = useTranslation('chat');\n  const messageKeyMapRef = useRef<WeakMap<ChatMessage, string>>(new WeakMap());\n  const allocatedKeysRef = useRef<Set<string>>(new Set());\n  const generatedMessageKeyCounterRef = useRef(0);\n\n  // Keep keys stable across prepends so existing MessageComponent instances retain local state.\n  const getMessageKey = useCallback((message: ChatMessage) => {\n    const existingKey = messageKeyMapRef.current.get(message);\n    if (existingKey) {\n      return existingKey;\n    }\n\n    const intrinsicKey = getIntrinsicMessageKey(message);\n    let candidateKey = intrinsicKey;\n\n    if (!candidateKey || allocatedKeysRef.current.has(candidateKey)) {\n      do {\n        generatedMessageKeyCounterRef.current += 1;\n        candidateKey = intrinsicKey\n          ? `${intrinsicKey}-${generatedMessageKeyCounterRef.current}`\n          : `message-generated-${generatedMessageKeyCounterRef.current}`;\n      } while (allocatedKeysRef.current.has(candidateKey));\n    }\n\n    allocatedKeysRef.current.add(candidateKey);\n    messageKeyMapRef.current.set(message, candidateKey);\n    return candidateKey;\n  }, []);\n\n  return (\n    <div\n      ref={scrollContainerRef}\n      onWheel={onWheel}\n      onTouchMove={onTouchMove}\n      className=\"relative flex-1 space-y-3 overflow-y-auto overflow-x-hidden px-0 py-3 sm:space-y-4 sm:p-4\"\n    >\n      {isLoadingSessionMessages && chatMessages.length === 0 ? (\n        <div className=\"mt-8 text-center text-gray-500 dark:text-gray-400\">\n          <div className=\"flex items-center justify-center space-x-2\">\n            <div className=\"h-4 w-4 animate-spin rounded-full border-b-2 border-gray-400\" />\n            <p>{t('session.loading.sessionMessages')}</p>\n          </div>\n        </div>\n      ) : chatMessages.length === 0 ? (\n        <ProviderSelectionEmptyState\n          selectedSession={selectedSession}\n          currentSessionId={currentSessionId}\n          provider={provider}\n          setProvider={setProvider}\n          textareaRef={textareaRef}\n          claudeModel={claudeModel}\n          setClaudeModel={setClaudeModel}\n          cursorModel={cursorModel}\n          setCursorModel={setCursorModel}\n          codexModel={codexModel}\n          setCodexModel={setCodexModel}\n          geminiModel={geminiModel}\n          setGeminiModel={setGeminiModel}\n          tasksEnabled={tasksEnabled}\n          isTaskMasterInstalled={isTaskMasterInstalled}\n          onShowAllTasks={onShowAllTasks}\n          setInput={setInput}\n        />\n      ) : (\n        <>\n          {/* Loading indicator for older messages (hide when load-all is active) */}\n          {isLoadingMoreMessages && !isLoadingAllMessages && !allMessagesLoaded && (\n            <div className=\"py-3 text-center text-gray-500 dark:text-gray-400\">\n              <div className=\"flex items-center justify-center space-x-2\">\n                <div className=\"h-4 w-4 animate-spin rounded-full border-b-2 border-gray-400\" />\n                <p className=\"text-sm\">{t('session.loading.olderMessages')}</p>\n              </div>\n            </div>\n          )}\n\n          {/* Indicator showing there are more messages to load (hide when all loaded) */}\n          {hasMoreMessages && !isLoadingMoreMessages && !allMessagesLoaded && (\n            <div className=\"border-b border-gray-200 py-2 text-center text-sm text-gray-500 dark:border-gray-700 dark:text-gray-400\">\n              {totalMessages > 0 && (\n                <span>\n                  {t('session.messages.showingOf', { shown: sessionMessagesCount, total: totalMessages })}{' '}\n                  <span className=\"text-xs\">{t('session.messages.scrollToLoad')}</span>\n                </span>\n              )}\n            </div>\n          )}\n\n          {/* Floating \"Load all messages\" overlay */}\n          {(showLoadAllOverlay || isLoadingAllMessages || loadAllJustFinished) && (\n            <div className=\"pointer-events-none sticky top-2 z-20 flex justify-center\">\n              {loadAllJustFinished ? (\n                <div className=\"flex items-center space-x-2 rounded-full bg-green-600 px-4 py-1.5 text-xs font-medium text-white shadow-lg dark:bg-green-500\">\n                  <svg className=\"h-3 w-3\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                    <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={3} d=\"M5 13l4 4L19 7\" />\n                  </svg>\n                  <span>{t('session.messages.allLoaded')}</span>\n                </div>\n              ) : (\n                <button\n                  className=\"pointer-events-auto flex items-center space-x-2 rounded-full bg-blue-600 px-4 py-1.5 text-xs font-medium text-white shadow-lg transition-all duration-200 hover:scale-105 hover:bg-blue-700 disabled:cursor-wait disabled:opacity-75 dark:bg-blue-500 dark:hover:bg-blue-600\"\n                  onClick={loadAllMessages}\n                  disabled={isLoadingAllMessages}\n                >\n                  {isLoadingAllMessages && (\n                    <div className=\"h-3 w-3 animate-spin rounded-full border-2 border-white/30 border-t-white\" />\n                  )}\n                  <span>\n                    {isLoadingAllMessages\n                      ? t('session.messages.loadingAll')\n                      : <>{t('session.messages.loadAll')} {totalMessages > 0 && `(${totalMessages})`}</>\n                    }\n                  </span>\n                </button>\n              )}\n            </div>\n          )}\n\n          {/* Performance warning when all messages are loaded */}\n          {allMessagesLoaded && (\n            <div className=\"border-b border-amber-200 bg-amber-50 py-1.5 text-center text-xs text-amber-600 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-400\">\n              {t('session.messages.perfWarning')}\n            </div>\n          )}\n\n          {/* Legacy message count indicator (for non-paginated view) */}\n          {!hasMoreMessages && chatMessages.length > visibleMessageCount && (\n            <div className=\"border-b border-gray-200 py-2 text-center text-sm text-gray-500 dark:border-gray-700 dark:text-gray-400\">\n              {t('session.messages.showingLast', { count: visibleMessageCount, total: chatMessages.length })} |\n              <button className=\"ml-1 text-blue-600 underline hover:text-blue-700\" onClick={loadEarlierMessages}>\n                {t('session.messages.loadEarlier')}\n              </button>\n              {' | '}\n              <button\n                className=\"text-blue-600 underline hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300\"\n                onClick={loadAllMessages}\n              >\n                {t('session.messages.loadAll')}\n              </button>\n            </div>\n          )}\n\n          {visibleMessages.map((message, index) => {\n            const prevMessage = index > 0 ? visibleMessages[index - 1] : null;\n            return (\n              <MessageComponent\n                key={getMessageKey(message)}\n                message={message}\n                prevMessage={prevMessage}\n                createDiff={createDiff}\n                onFileOpen={onFileOpen}\n                onShowSettings={onShowSettings}\n                onGrantToolPermission={onGrantToolPermission}\n                autoExpandTools={autoExpandTools}\n                showRawParameters={showRawParameters}\n                showThinking={showThinking}\n                selectedProject={selectedProject}\n                provider={provider}\n              />\n            );\n          })}\n        </>\n      )}\n\n      {isLoading && <AssistantThinkingIndicator selectedProvider={provider} />}\n    </div>\n  );\n}\n\n"
  },
  {
    "path": "src/components/chat/view/subcomponents/ClaudeStatus.tsx",
    "content": "import { useEffect, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { cn } from '../../../../lib/utils';\nimport SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';\n\ntype ClaudeStatusProps = {\n  status: {\n    text?: string;\n    tokens?: number;\n    can_interrupt?: boolean;\n  } | null;\n  onAbort?: () => void;\n  isLoading: boolean;\n  provider?: string;\n};\n\nconst ACTION_KEYS = [\n  'claudeStatus.actions.thinking',\n  'claudeStatus.actions.processing',\n  'claudeStatus.actions.analyzing',\n  'claudeStatus.actions.working',\n  'claudeStatus.actions.computing',\n  'claudeStatus.actions.reasoning',\n];\nconst DEFAULT_ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];\nconst ANIMATION_STEPS = 40;\n\nconst PROVIDER_LABEL_KEYS: Record<string, string> = {\n  claude: 'messageTypes.claude',\n  codex: 'messageTypes.codex',\n  cursor: 'messageTypes.cursor',\n  gemini: 'messageTypes.gemini',\n};\n\nfunction formatElapsedTime(totalSeconds: number, t: (key: string, options?: Record<string, unknown>) => string) {\n  const minutes = Math.floor(totalSeconds / 60);\n  const seconds = totalSeconds % 60;\n\n  if (minutes < 1) {\n    return t('claudeStatus.elapsed.seconds', { count: seconds, defaultValue: '{{count}}s' });\n  }\n\n  return t('claudeStatus.elapsed.minutesSeconds', {\n    minutes,\n    seconds,\n    defaultValue: '{{minutes}}m {{seconds}}s',\n  });\n}\n\nexport default function ClaudeStatus({\n  status,\n  onAbort,\n  isLoading,\n  provider = 'claude',\n}: ClaudeStatusProps) {\n  const { t } = useTranslation('chat');\n  const [elapsedTime, setElapsedTime] = useState(0);\n  const [animationPhase, setAnimationPhase] = useState(0);\n\n  useEffect(() => {\n    if (!isLoading) {\n      setElapsedTime(0);\n      return;\n    }\n\n    const startTime = Date.now();\n\n    const timer = window.setInterval(() => {\n      const elapsed = Math.floor((Date.now() - startTime) / 1000);\n      setElapsedTime(elapsed);\n    }, 1000);\n\n    return () => window.clearInterval(timer);\n  }, [isLoading]);\n\n  useEffect(() => {\n    if (!isLoading) {\n      return;\n    }\n\n    const timer = window.setInterval(() => {\n      setAnimationPhase((previous) => (previous + 1) % ANIMATION_STEPS);\n    }, 500);\n\n    return () => window.clearInterval(timer);\n  }, [isLoading]);\n\n  // Note: showThinking only controls the reasoning accordion in messages, not this processing indicator\n  if (!isLoading && !status) {\n    return null;\n  }\n\n  const actionWords = ACTION_KEYS.map((key, index) => t(key, { defaultValue: DEFAULT_ACTION_WORDS[index] }));\n  const actionIndex = Math.floor(elapsedTime / 3) % actionWords.length;\n  const statusText = status?.text || actionWords[actionIndex];\n  const cleanStatusText = statusText.replace(/[.]+$/, '');\n  const canInterrupt = isLoading && status?.can_interrupt !== false;\n  const providerLabelKey = PROVIDER_LABEL_KEYS[provider];\n  const providerLabel = providerLabelKey\n    ? t(providerLabelKey)\n    : t('claudeStatus.providers.assistant', { defaultValue: 'Assistant' });\n  const animatedDots = '.'.repeat((animationPhase % 3) + 1);\n  const elapsedLabel =\n    elapsedTime > 0\n      ? t('claudeStatus.elapsed.label', {\n          time: formatElapsedTime(elapsedTime, t),\n          defaultValue: '{{time}} elapsed',\n        })\n      : t('claudeStatus.elapsed.startingNow', { defaultValue: 'Starting now' });\n\n  return (\n    <div className=\"animate-in slide-in-from-bottom mb-3 w-full duration-300 sm:mb-6\">\n      <div className=\"relative mx-auto max-w-4xl overflow-hidden rounded-2xl border border-border/70 bg-card/90 shadow-md backdrop-blur-md\">\n        <div className=\"pointer-events-none absolute inset-0 bg-gradient-to-r from-primary/10 via-transparent to-sky-500/10 dark:from-primary/20 dark:to-sky-400/20\" />\n\n        <div className=\"relative px-3 py-3 sm:px-4 sm:py-3.5\">\n          <div className=\"flex flex-col gap-2.5 sm:flex-row sm:items-center sm:justify-between\">\n            <div className=\"flex min-w-0 items-start gap-3\" role=\"status\" aria-live=\"polite\">\n              <div className=\"relative mt-0.5 flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-xl border border-primary/25 bg-primary/10\">\n                <SessionProviderLogo provider={provider} className=\"h-5 w-5\" />\n                <span className=\"absolute -right-0.5 -top-0.5 flex h-2.5 w-2.5\">\n                  {isLoading && (\n                    <span className=\"absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400/70\" />\n                  )}\n                  <span\n                    className={cn(\n                      'relative inline-flex h-2.5 w-2.5 rounded-full',\n                      isLoading ? 'bg-emerald-400' : 'bg-amber-400',\n                    )}\n                  />\n                </span>\n              </div>\n\n              <div className=\"min-w-0\">\n                <div className=\"mb-0.5 flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[0.15em] text-muted-foreground\">\n                  <span>{providerLabel}</span>\n                  <span\n                    className={cn(\n                      'rounded-full px-2 py-0.5 text-[9px] tracking-[0.14em]',\n                      isLoading\n                        ? 'bg-emerald-500/15 text-emerald-500 dark:text-emerald-400'\n                        : 'bg-amber-500/15 text-amber-600 dark:text-amber-400',\n                    )}\n                  >\n                    {isLoading\n                      ? t('claudeStatus.state.live', { defaultValue: 'Live' })\n                      : t('claudeStatus.state.paused', { defaultValue: 'Paused' })}\n                  </span>\n                </div>\n\n                <p className=\"truncate text-sm font-semibold text-foreground sm:text-[15px]\">\n                  {cleanStatusText}\n                  {isLoading && (\n                    <span aria-hidden=\"true\" className=\"text-primary\">\n                      {animatedDots}\n                    </span>\n                  )}\n                </p>\n\n                <div className=\"mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground sm:text-xs\">\n                  <span\n                    aria-hidden=\"true\"\n                    className=\"-ml-2 inline-flex items-center rounded-full border border-border/70 bg-background/60 px-2 py-0.5\"\n                  >\n                    {elapsedLabel}\n                  </span>\n                </div>\n              </div>\n            </div>\n\n            {canInterrupt && onAbort && (\n              <div className=\"w-full sm:w-auto sm:text-right\">\n                <button\n                  type=\"button\"\n                  onClick={onAbort}\n                  className=\"inline-flex w-full items-center justify-center gap-2 rounded-xl bg-destructive px-3.5 py-2 text-sm font-semibold text-destructive-foreground shadow-sm ring-1 ring-destructive/40 transition-opacity hover:opacity-95 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive/70 active:opacity-90 sm:w-auto\"\n                >\n                  <svg className=\"h-3.5 w-3.5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                    <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M6 18L18 6M6 6l12 12\" />\n                  </svg>\n                  <span>{t('claudeStatus.controls.stopGeneration', { defaultValue: 'Stop Generation' })}</span>\n                  <span className=\"rounded-md bg-black/20 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-destructive-foreground/95\">\n                    Esc\n                  </span>\n                </button>\n\n                <p className=\"mt-1 hidden text-[11px] text-muted-foreground sm:block\">\n                  {t('claudeStatus.controls.pressEscToStop', { defaultValue: 'Press Esc anytime to stop' })}\n                </p>\n              </div>\n            )}\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/chat/view/subcomponents/CommandMenu.tsx",
    "content": "import { useEffect, useRef } from 'react';\nimport type { CSSProperties } from 'react';\n\ntype CommandMenuCommand = {\n  name: string;\n  description?: string;\n  namespace?: string;\n  path?: string;\n  type?: string;\n  metadata?: { type?: string; [key: string]: unknown };\n  [key: string]: unknown;\n};\n\ntype CommandMenuProps = {\n  commands?: CommandMenuCommand[];\n  selectedIndex?: number;\n  onSelect?: (command: CommandMenuCommand, index: number, isHover: boolean) => void;\n  onClose: () => void;\n  position?: { top: number; left: number; bottom?: number };\n  isOpen?: boolean;\n  frequentCommands?: CommandMenuCommand[];\n};\n\nconst menuBaseStyle: CSSProperties = {\n  maxHeight: '300px',\n  overflowY: 'auto',\n  borderRadius: '8px',\n  boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',\n  zIndex: 1000,\n  padding: '8px',\n  transition: 'opacity 150ms ease-in-out, transform 150ms ease-in-out',\n};\n\nconst namespaceLabels: Record<string, string> = {\n  frequent: 'Frequently Used',\n  builtin: 'Built-in Commands',\n  project: 'Project Commands',\n  user: 'User Commands',\n  other: 'Other Commands',\n};\n\nconst namespaceIcons: Record<string, string> = {\n  frequent: '[*]',\n  builtin: '[B]',\n  project: '[P]',\n  user: '[U]',\n  other: '[O]',\n};\n\nconst getCommandKey = (command: CommandMenuCommand) =>\n  `${command.name}::${command.namespace || command.type || 'other'}::${command.path || ''}`;\n\nconst getNamespace = (command: CommandMenuCommand) => command.namespace || command.type || 'other';\n\nconst getMenuPosition = (position: { top: number; left: number; bottom?: number }): CSSProperties => {\n  if (typeof window === 'undefined') {\n    return { position: 'fixed', top: '16px', left: '16px' };\n  }\n  if (window.innerWidth < 640) {\n    return {\n      position: 'fixed',\n      bottom: `${position.bottom ?? 90}px`,\n      left: '16px',\n      right: '16px',\n      width: 'auto',\n      maxWidth: 'calc(100vw - 32px)',\n      maxHeight: 'min(50vh, 300px)',\n    };\n  }\n  return {\n    position: 'fixed',\n    top: `${Math.max(16, Math.min(position.top, window.innerHeight - 316))}px`,\n    left: `${position.left}px`,\n    width: 'min(400px, calc(100vw - 32px))',\n    maxWidth: 'calc(100vw - 32px)',\n    maxHeight: '300px',\n  };\n};\n\nexport default function CommandMenu({\n  commands = [],\n  selectedIndex = -1,\n  onSelect,\n  onClose,\n  position = { top: 0, left: 0 },\n  isOpen = false,\n  frequentCommands = [],\n}: CommandMenuProps) {\n  const menuRef = useRef<HTMLDivElement | null>(null);\n  const selectedItemRef = useRef<HTMLDivElement | null>(null);\n  const menuPosition = getMenuPosition(position);\n\n  useEffect(() => {\n    if (!isOpen) {\n      return;\n    }\n    const handleClickOutside = (event: MouseEvent) => {\n      if (!menuRef.current || !(event.target instanceof Node)) {\n        return;\n      }\n      if (!menuRef.current.contains(event.target)) {\n        onClose();\n      }\n    };\n    document.addEventListener('mousedown', handleClickOutside);\n    return () => document.removeEventListener('mousedown', handleClickOutside);\n  }, [isOpen, onClose]);\n\n  useEffect(() => {\n    if (!selectedItemRef.current || !menuRef.current) {\n      return;\n    }\n    const menuRect = menuRef.current.getBoundingClientRect();\n    const itemRect = selectedItemRef.current.getBoundingClientRect();\n    if (itemRect.bottom > menuRect.bottom || itemRect.top < menuRect.top) {\n      selectedItemRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' });\n    }\n  }, [selectedIndex]);\n\n  if (!isOpen) {\n    return null;\n  }\n\n  const hasFrequentCommands = frequentCommands.length > 0;\n  const frequentCommandKeys = new Set(frequentCommands.map(getCommandKey));\n  const groupedCommands = commands.reduce<Record<string, CommandMenuCommand[]>>((groups, command) => {\n    if (hasFrequentCommands && frequentCommandKeys.has(getCommandKey(command))) {\n      return groups;\n    }\n    const namespace = getNamespace(command);\n    if (!groups[namespace]) {\n      groups[namespace] = [];\n    }\n    groups[namespace].push(command);\n    return groups;\n  }, {});\n  if (hasFrequentCommands) {\n    groupedCommands.frequent = frequentCommands;\n  }\n\n  const preferredOrder = hasFrequentCommands\n    ? ['frequent', 'builtin', 'project', 'user', 'other']\n    : ['builtin', 'project', 'user', 'other'];\n  const extraNamespaces = Object.keys(groupedCommands).filter((namespace) => !preferredOrder.includes(namespace));\n  const orderedNamespaces = [...preferredOrder, ...extraNamespaces].filter((namespace) => groupedCommands[namespace]);\n\n  const commandIndexByKey = new Map<string, number>();\n  commands.forEach((command, index) => {\n    const key = getCommandKey(command);\n    if (!commandIndexByKey.has(key)) {\n      commandIndexByKey.set(key, index);\n    }\n  });\n\n  if (commands.length === 0) {\n    return (\n      <div\n        ref={menuRef}\n        className=\"command-menu command-menu-empty border border-gray-200 bg-white text-gray-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400\"\n        style={{ ...menuPosition, ...menuBaseStyle, overflowY: 'hidden', padding: '20px', opacity: 1, transform: 'translateY(0)', textAlign: 'center' }}\n      >\n        No commands available\n      </div>\n    );\n  }\n\n  return (\n    <div\n      ref={menuRef}\n      role=\"listbox\"\n      aria-label=\"Available commands\"\n      className=\"command-menu border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800\"\n      style={{ ...menuPosition, ...menuBaseStyle, opacity: 1, transform: 'translateY(0)' }}\n    >\n      {orderedNamespaces.map((namespace) => (\n        <div key={namespace} className=\"command-group\">\n          {orderedNamespaces.length > 1 && (\n            <div className=\"px-3 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400\">\n              {namespaceLabels[namespace] || namespace}\n            </div>\n          )}\n\n          {(groupedCommands[namespace] || []).map((command) => {\n            const commandKey = getCommandKey(command);\n            const commandIndex = commandIndexByKey.get(commandKey) ?? -1;\n            const isSelected = commandIndex === selectedIndex;\n            return (\n              <div\n                key={`${namespace}-${command.name}-${command.path || ''}`}\n                ref={isSelected ? selectedItemRef : null}\n                role=\"option\"\n                aria-selected={isSelected}\n                className={`command-item mb-0.5 flex cursor-pointer items-start rounded-md px-3 py-2.5 transition-colors ${\n                  isSelected ? 'bg-blue-50 dark:bg-blue-900' : 'bg-transparent'\n                }`}\n                onMouseEnter={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, true)}\n                onClick={() => onSelect && commandIndex >= 0 && onSelect(command, commandIndex, false)}\n                onMouseDown={(event) => event.preventDefault()}\n              >\n                <div className=\"min-w-0 flex-1\">\n                  <div className={`flex items-center gap-2 ${command.description ? 'mb-1' : 'mb-0'}`}>\n                    <span className=\"shrink-0 text-xs text-gray-500 dark:text-gray-300\">{namespaceIcons[namespace] || namespaceIcons.other}</span>\n                    <span className=\"font-mono text-sm font-semibold text-gray-900 dark:text-gray-100\">{command.name}</span>\n                    {command.metadata?.type && (\n                      <span className=\"command-metadata-badge rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-500 dark:bg-gray-700 dark:text-gray-300\">\n                        {command.metadata.type}\n                      </span>\n                    )}\n                  </div>\n                  {command.description && (\n                    <div className=\"ml-6 truncate whitespace-nowrap text-[13px] text-gray-500 dark:text-gray-300\">\n                      {command.description}\n                    </div>\n                  )}\n                </div>\n                {isSelected && <span className=\"ml-2 text-xs font-semibold text-blue-500 dark:text-blue-300\">{'<-'}</span>}\n              </div>\n            );\n          })}\n        </div>\n      ))}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/chat/view/subcomponents/ImageAttachment.tsx",
    "content": "import { useEffect, useState } from 'react';\n\ninterface ImageAttachmentProps {\n  file: File;\n  onRemove: () => void;\n  uploadProgress?: number;\n  error?: string;\n}\n\nconst ImageAttachment = ({ file, onRemove, uploadProgress, error }: ImageAttachmentProps) => {\n  const [preview, setPreview] = useState<string | undefined>(undefined);\n  \n  useEffect(() => {\n    const url = URL.createObjectURL(file);\n    setPreview(url);\n    return () => URL.revokeObjectURL(url);\n  }, [file]);\n  \n  return (\n    <div className=\"group relative\">\n      <img src={preview} alt={file.name} className=\"h-20 w-20 rounded object-cover\" />\n      {uploadProgress !== undefined && uploadProgress < 100 && (\n        <div className=\"absolute inset-0 flex items-center justify-center bg-black/50\">\n          <div className=\"text-xs text-white\">{uploadProgress}%</div>\n        </div>\n      )}\n      {error && (\n        <div className=\"absolute inset-0 flex items-center justify-center bg-red-500/50\">\n          <svg className=\"h-6 w-6 text-white\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n            <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M6 18L18 6M6 6l12 12\" />\n          </svg>\n        </div>\n      )}\n      <button\n        type=\"button\"\n        onClick={onRemove}\n        className=\"absolute -right-2 -top-2 rounded-full bg-red-500 p-1 text-white opacity-100 transition-opacity focus:opacity-100 sm:opacity-0 sm:group-hover:opacity-100\"\n        aria-label=\"Remove image\"\n      >\n        <svg className=\"h-3 w-3\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n          <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M6 18L18 6M6 6l12 12\" />\n        </svg>\n      </button>\n    </div>\n  );\n};\n\nexport default ImageAttachment;\n\n\n"
  },
  {
    "path": "src/components/chat/view/subcomponents/Markdown.tsx",
    "content": "import React, { useMemo, useState } from 'react';\nimport ReactMarkdown from 'react-markdown';\nimport remarkGfm from 'remark-gfm';\nimport remarkMath from 'remark-math';\nimport rehypeKatex from 'rehype-katex';\nimport { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';\nimport { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';\nimport { useTranslation } from 'react-i18next';\nimport { normalizeInlineCodeFences } from '../../utils/chatFormatting';\nimport { copyTextToClipboard } from '../../../../utils/clipboard';\n\ntype MarkdownProps = {\n  children: React.ReactNode;\n  className?: string;\n};\n\ntype CodeBlockProps = {\n  node?: any;\n  inline?: boolean;\n  className?: string;\n  children?: React.ReactNode;\n};\n\nconst CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockProps) => {\n  const { t } = useTranslation('chat');\n  const [copied, setCopied] = useState(false);\n  const raw = Array.isArray(children) ? children.join('') : String(children ?? '');\n  const looksMultiline = /[\\r\\n]/.test(raw);\n  const inlineDetected = inline || (node && node.type === 'inlineCode');\n  const shouldInline = inlineDetected || !looksMultiline;\n\n  if (shouldInline) {\n    return (\n      <code\n        className={`whitespace-pre-wrap break-words rounded-md border border-gray-200 bg-gray-100 px-1.5 py-0.5 font-mono text-[0.9em] text-gray-900 dark:border-gray-700 dark:bg-gray-800/60 dark:text-gray-100 ${className || ''\n          }`}\n        {...props}\n      >\n        {children}\n      </code>\n    );\n  }\n\n  const match = /language-(\\w+)/.exec(className || '');\n  const language = match ? match[1] : 'text';\n\n  return (\n    <div className=\"group relative my-2\">\n      {language && language !== 'text' && (\n        <div className=\"absolute left-3 top-2 z-10 text-xs font-medium uppercase text-gray-400\">{language}</div>\n      )}\n\n      <button\n        type=\"button\"\n        onClick={() =>\n          copyTextToClipboard(raw).then((success) => {\n            if (success) {\n              setCopied(true);\n              setTimeout(() => setCopied(false), 2000);\n            }\n          })\n        }\n        className=\"absolute right-2 top-2 z-10 rounded-md border border-gray-600 bg-gray-700/80 px-2 py-1 text-xs text-white opacity-0 transition-opacity hover:bg-gray-700 focus:opacity-100 active:opacity-100 group-hover:opacity-100\"\n        title={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}\n        aria-label={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}\n      >\n        {copied ? (\n          <span className=\"flex items-center gap-1\">\n            <svg className=\"h-3.5 w-3.5\" viewBox=\"0 0 20 20\" fill=\"currentColor\">\n              <path\n                fillRule=\"evenodd\"\n                d=\"M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z\"\n                clipRule=\"evenodd\"\n              />\n            </svg>\n            {t('codeBlock.copied')}\n          </span>\n        ) : (\n          <span className=\"flex items-center gap-1\">\n            <svg\n              className=\"h-3.5 w-3.5\"\n              viewBox=\"0 0 24 24\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth=\"2\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n            >\n              <rect x=\"9\" y=\"9\" width=\"13\" height=\"13\" rx=\"2\" ry=\"2\"></rect>\n              <path d=\"M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1\"></path>\n            </svg>\n            {t('codeBlock.copy')}\n          </span>\n        )}\n      </button>\n\n      <SyntaxHighlighter\n        language={language}\n        style={oneDark}\n        customStyle={{\n          margin: 0,\n          borderRadius: '0.5rem',\n          fontSize: '0.875rem',\n          padding: language && language !== 'text' ? '2rem 1rem 1rem 1rem' : '1rem',\n        }}\n        codeTagProps={{\n          style: {\n            fontFamily:\n              'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace',\n          },\n        }}\n      >\n        {raw}\n      </SyntaxHighlighter>\n    </div>\n  );\n};\n\nconst markdownComponents = {\n  code: CodeBlock,\n  blockquote: ({ children }: { children?: React.ReactNode }) => (\n    <blockquote className=\"my-2 border-l-4 border-gray-300 pl-4 italic text-gray-600 dark:border-gray-600 dark:text-gray-400\">\n      {children}\n    </blockquote>\n  ),\n  a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (\n    <a href={href} className=\"text-blue-600 hover:underline dark:text-blue-400\" target=\"_blank\" rel=\"noopener noreferrer\">\n      {children}\n    </a>\n  ),\n  p: ({ children }: { children?: React.ReactNode }) => <div className=\"mb-2 last:mb-0\">{children}</div>,\n  table: ({ children }: { children?: React.ReactNode }) => (\n    <div className=\"my-2 overflow-x-auto\">\n      <table className=\"min-w-full border-collapse border border-gray-200 dark:border-gray-700\">{children}</table>\n    </div>\n  ),\n  thead: ({ children }: { children?: React.ReactNode }) => <thead className=\"bg-gray-50 dark:bg-gray-800\">{children}</thead>,\n  th: ({ children }: { children?: React.ReactNode }) => (\n    <th className=\"border border-gray-200 px-3 py-2 text-left text-sm font-semibold dark:border-gray-700\">{children}</th>\n  ),\n  td: ({ children }: { children?: React.ReactNode }) => (\n    <td className=\"border border-gray-200 px-3 py-2 align-top text-sm dark:border-gray-700\">{children}</td>\n  ),\n};\n\nexport function Markdown({ children, className }: MarkdownProps) {\n  const content = normalizeInlineCodeFences(String(children ?? ''));\n  const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []);\n  const rehypePlugins = useMemo(() => [rehypeKatex], []);\n\n  return (\n    <div className={className}>\n      <ReactMarkdown remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} components={markdownComponents as any}>\n        {content}\n      </ReactMarkdown>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/chat/view/subcomponents/MessageComponent.tsx",
    "content": "import { memo, useEffect, useMemo, useRef, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';\nimport type {\n  ChatMessage,\n  ClaudePermissionSuggestion,\n  PermissionGrantResult,\n  Provider,\n} from '../../types/types';\nimport { formatUsageLimitText } from '../../utils/chatFormatting';\nimport { getClaudePermissionSuggestion } from '../../utils/chatPermissions';\nimport type { Project } from '../../../../types/app';\nimport { ToolRenderer, shouldHideToolResult } from '../../tools';\nimport { Markdown } from './Markdown';\nimport MessageCopyControl from './MessageCopyControl';\n\ntype DiffLine = {\n  type: string;\n  content: string;\n  lineNum: number;\n};\n\ntype MessageComponentProps = {\n  message: ChatMessage;\n  prevMessage: ChatMessage | null;\n  createDiff: (oldStr: string, newStr: string) => DiffLine[];\n  onFileOpen?: (filePath: string, diffInfo?: unknown) => void;\n  onShowSettings?: () => void;\n  onGrantToolPermission?: (suggestion: ClaudePermissionSuggestion) => PermissionGrantResult | null | undefined;\n  autoExpandTools?: boolean;\n  showRawParameters?: boolean;\n  showThinking?: boolean;\n  selectedProject?: Project | null;\n  provider: Provider | string;\n};\n\ntype InteractiveOption = {\n  number: string;\n  text: string;\n  isSelected: boolean;\n};\n\ntype PermissionGrantState = 'idle' | 'granted' | 'error';\nconst COPY_HIDDEN_TOOL_NAMES = new Set(['Bash', 'Edit', 'Write', 'ApplyPatch']);\n\nconst MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {\n  const { t } = useTranslation('chat');\n  const isGrouped = prevMessage && prevMessage.type === message.type &&\n    ((prevMessage.type === 'assistant') ||\n      (prevMessage.type === 'user') ||\n      (prevMessage.type === 'tool') ||\n      (prevMessage.type === 'error'));\n  const messageRef = useRef<HTMLDivElement | null>(null);\n  const [isExpanded, setIsExpanded] = useState(false);\n  const permissionSuggestion = getClaudePermissionSuggestion(message, provider);\n  const [permissionGrantState, setPermissionGrantState] = useState<PermissionGrantState>('idle');\n  const userCopyContent = String(message.content || '');\n  const formattedMessageContent = useMemo(\n    () => formatUsageLimitText(String(message.content || '')),\n    [message.content]\n  );\n  const assistantCopyContent = message.isToolUse\n    ? String(message.displayText || message.content || '')\n    : formattedMessageContent;\n  const isCommandOrFileEditToolResponse = Boolean(\n    message.isToolUse && COPY_HIDDEN_TOOL_NAMES.has(String(message.toolName || ''))\n  );\n  const shouldShowUserCopyControl = message.type === 'user' && userCopyContent.trim().length > 0;\n  const shouldShowAssistantCopyControl = message.type === 'assistant' &&\n    assistantCopyContent.trim().length > 0 &&\n    !isCommandOrFileEditToolResponse;\n\n\n  useEffect(() => {\n    setPermissionGrantState('idle');\n  }, [permissionSuggestion?.entry, message.toolId]);\n\n  useEffect(() => {\n    const node = messageRef.current;\n    if (!autoExpandTools || !node || !message.isToolUse) return;\n\n    const observer = new IntersectionObserver(\n      (entries) => {\n        entries.forEach((entry) => {\n          if (entry.isIntersecting && !isExpanded) {\n            setIsExpanded(true);\n            const details = node.querySelectorAll<HTMLDetailsElement>('details');\n            details.forEach((detail) => {\n              detail.open = true;\n            });\n          }\n        });\n      },\n      { threshold: 0.1 }\n    );\n\n    observer.observe(node);\n\n    return () => {\n      observer.unobserve(node);\n    };\n  }, [autoExpandTools, isExpanded, message.isToolUse]);\n\n  const formattedTime = useMemo(() => new Date(message.timestamp).toLocaleTimeString(), [message.timestamp]);\n  const shouldHideThinkingMessage = Boolean(message.isThinking && !showThinking);\n\n  if (shouldHideThinkingMessage) {\n    return null;\n  }\n\n  return (\n    <div\n      ref={messageRef}\n      data-message-timestamp={message.timestamp || undefined}\n      className={`chat-message ${message.type} ${isGrouped ? 'grouped' : ''} ${message.type === 'user' ? 'flex justify-end px-3 sm:px-0' : 'px-3 sm:px-0'}`}\n    >\n      {message.type === 'user' ? (\n        /* User message bubble on the right */\n        <div className=\"flex w-full items-end space-x-0 sm:w-auto sm:max-w-[85%] sm:space-x-3 md:max-w-md lg:max-w-lg xl:max-w-xl\">\n          <div className=\"group flex-1 rounded-2xl rounded-br-md bg-blue-600 px-3 py-2 text-white shadow-sm sm:flex-initial sm:px-4\">\n            <div className=\"whitespace-pre-wrap break-words text-sm\">\n              {message.content}\n            </div>\n            {message.images && message.images.length > 0 && (\n              <div className=\"mt-2 grid grid-cols-2 gap-2\">\n                {message.images.map((img, idx) => (\n                  <img\n                    key={img.name || idx}\n                    src={img.data}\n                    alt={img.name}\n                    className=\"h-auto max-w-full cursor-pointer rounded-lg transition-opacity hover:opacity-90\"\n                    onClick={() => window.open(img.data, '_blank')}\n                  />\n                ))}\n              </div>\n            )}\n            <div className=\"mt-1 flex items-center justify-end gap-1 text-xs text-blue-100\">\n              {shouldShowUserCopyControl && (\n                <MessageCopyControl content={userCopyContent} messageType=\"user\" />\n              )}\n              <span>{formattedTime}</span>\n            </div>\n          </div>\n          {!isGrouped && (\n            <div className=\"hidden h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-sm text-white sm:flex\">\n              U\n            </div>\n          )}\n        </div>\n      ) : message.isTaskNotification ? (\n        /* Compact task notification on the left */\n        <div className=\"w-full\">\n          <div className=\"flex items-center gap-2 py-0.5\">\n            <span className={`inline-block h-1.5 w-1.5 flex-shrink-0 rounded-full ${message.taskStatus === 'completed' ? 'bg-green-400 dark:bg-green-500' : 'bg-amber-400 dark:bg-amber-500'}`} />\n            <span className=\"text-xs text-gray-500 dark:text-gray-400\">{message.content}</span>\n          </div>\n        </div>\n      ) : (\n        /* Claude/Error/Tool messages on the left */\n        <div className=\"w-full\">\n          {!isGrouped && (\n            <div className=\"mb-2 flex items-center space-x-3\">\n              {message.type === 'error' ? (\n                <div className=\"flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-red-600 text-sm text-white\">\n                  !\n                </div>\n              ) : message.type === 'tool' ? (\n                <div className=\"flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-gray-600 text-sm text-white dark:bg-gray-700\">\n                  🔧\n                </div>\n              ) : (\n                <div className=\"flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full p-1 text-sm text-white\">\n                  <SessionProviderLogo provider={provider} className=\"h-full w-full\" />\n                </div>\n              )}\n              <div className=\"text-sm font-medium text-gray-900 dark:text-white\">\n                {message.type === 'error' ? t('messageTypes.error') : message.type === 'tool' ? t('messageTypes.tool') : (provider === 'cursor' ? t('messageTypes.cursor') : provider === 'codex' ? t('messageTypes.codex') : provider === 'gemini' ? t('messageTypes.gemini') : t('messageTypes.claude'))}\n              </div>\n            </div>\n          )}\n\n          <div className=\"w-full\">\n\n            {message.isToolUse ? (\n              <>\n                <div className=\"flex flex-col\">\n                  <div className=\"flex flex-col\">\n                    <Markdown className=\"prose prose-sm max-w-none dark:prose-invert\">\n                      {String(message.displayText || '')}\n                    </Markdown>\n                  </div>\n                </div>\n\n                {message.toolInput && (\n                  <ToolRenderer\n                    toolName={message.toolName || 'UnknownTool'}\n                    toolInput={message.toolInput}\n                    toolResult={message.toolResult}\n                    toolId={message.toolId}\n                    mode=\"input\"\n                    onFileOpen={onFileOpen}\n                    createDiff={createDiff}\n                    selectedProject={selectedProject}\n                    autoExpandTools={autoExpandTools}\n                    showRawParameters={showRawParameters}\n                    rawToolInput={typeof message.toolInput === 'string' ? message.toolInput : undefined}\n                    isSubagentContainer={message.isSubagentContainer}\n                    subagentState={message.subagentState}\n                  />\n                )}\n\n                {/* Tool Result Section */}\n                {message.toolResult && !shouldHideToolResult(message.toolName || 'UnknownTool', message.toolResult) && (\n                  message.toolResult.isError ? (\n                    // Error results - red error box with content\n                    <div\n                      id={`tool-result-${message.toolId}`}\n                      className=\"relative mt-2 scroll-mt-4 rounded border border-red-200/60 bg-red-50/50 p-3 dark:border-red-800/40 dark:bg-red-950/10\"\n                    >\n                      <div className=\"relative mb-2 flex items-center gap-1.5\">\n                        <svg className=\"h-4 w-4 text-red-500 dark:text-red-400\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                          <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M6 18L18 6M6 6l12 12\" />\n                        </svg>\n                        <span className=\"text-xs font-medium text-red-700 dark:text-red-300\">{t('messageTypes.error')}</span>\n                      </div>\n                      <div className=\"relative text-sm text-red-900 dark:text-red-100\">\n                        <Markdown className=\"prose prose-sm prose-red max-w-none dark:prose-invert\">\n                          {String(message.toolResult.content || '')}\n                        </Markdown>\n                        {permissionSuggestion && (\n                          <div className=\"mt-4 border-t border-red-200/60 pt-3 dark:border-red-800/60\">\n                            <div className=\"flex flex-wrap items-center gap-2\">\n                              <button\n                                type=\"button\"\n                                onClick={() => {\n                                  if (!onGrantToolPermission) return;\n                                  const result = onGrantToolPermission(permissionSuggestion);\n                                  if (result?.success) {\n                                    setPermissionGrantState('granted');\n                                  } else {\n                                    setPermissionGrantState('error');\n                                  }\n                                }}\n                                disabled={permissionSuggestion.isAllowed || permissionGrantState === 'granted'}\n                                className={`inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors ${permissionSuggestion.isAllowed || permissionGrantState === 'granted'\n                                  ? 'cursor-default border-green-300/70 bg-green-100 text-green-800 dark:border-green-800/60 dark:bg-green-900/30 dark:text-green-200'\n                                  : 'border-red-300/70 bg-white/80 text-red-700 hover:bg-white dark:border-red-800/60 dark:bg-gray-900/40 dark:text-red-200 dark:hover:bg-gray-900/70'\n                                  }`}\n                              >\n                                {permissionSuggestion.isAllowed || permissionGrantState === 'granted'\n                                  ? t('permissions.added')\n                                  : t('permissions.grant', { tool: permissionSuggestion.toolName })}\n                              </button>\n                              {onShowSettings && (\n                                <button\n                                  type=\"button\"\n                                  onClick={(e) => { e.stopPropagation(); onShowSettings(); }}\n                                  className=\"text-xs text-red-700 underline hover:text-red-800 dark:text-red-200 dark:hover:text-red-100\"\n                                >\n                                  {t('permissions.openSettings')}\n                                </button>\n                              )}\n                            </div>\n                            <div className=\"mt-2 text-xs text-red-700/90 dark:text-red-200/80\">\n                              {t('permissions.addTo', { entry: permissionSuggestion.entry })}\n                            </div>\n                            {permissionGrantState === 'error' && (\n                              <div className=\"mt-2 text-xs text-red-700 dark:text-red-200\">\n                                {t('permissions.error')}\n                              </div>\n                            )}\n                            {(permissionSuggestion.isAllowed || permissionGrantState === 'granted') && (\n                              <div className=\"mt-2 text-xs text-green-700 dark:text-green-200\">\n                                {t('permissions.retry')}\n                              </div>\n                            )}\n                          </div>\n                        )}\n                      </div>\n                    </div>\n                  ) : (\n                    // Non-error results - route through ToolRenderer (single source of truth)\n                    <div id={`tool-result-${message.toolId}`} className=\"scroll-mt-4\">\n                      <ToolRenderer\n                        toolName={message.toolName || 'UnknownTool'}\n                        toolInput={message.toolInput}\n                        toolResult={message.toolResult}\n                        toolId={message.toolId}\n                        mode=\"result\"\n                        onFileOpen={onFileOpen}\n                        createDiff={createDiff}\n                        selectedProject={selectedProject}\n                        autoExpandTools={autoExpandTools}\n                      />\n                    </div>\n                  )\n                )}\n              </>\n            ) : message.isInteractivePrompt ? (\n              // Special handling for interactive prompts\n              <div className=\"rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-900/20\">\n                <div className=\"flex items-start gap-3\">\n                  <div className=\"mt-0.5 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-amber-500\">\n                    <svg className=\"h-5 w-5 text-white\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                      <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\" />\n                    </svg>\n                  </div>\n                  <div className=\"flex-1\">\n                    <h4 className=\"mb-3 text-base font-semibold text-amber-900 dark:text-amber-100\">\n                      {t('interactive.title')}\n                    </h4>\n                    {(() => {\n                      const lines = (message.content || '').split('\\n').filter((line) => line.trim());\n                      const questionLine = lines.find((line) => line.includes('?')) || lines[0] || '';\n                      const options: InteractiveOption[] = [];\n\n                      // Parse the menu options\n                      lines.forEach((line) => {\n                        // Match lines like \"❯ 1. Yes\" or \"  2. No\"\n                        const optionMatch = line.match(/[❯\\s]*(\\d+)\\.\\s+(.+)/);\n                        if (optionMatch) {\n                          const isSelected = line.includes('❯');\n                          options.push({\n                            number: optionMatch[1],\n                            text: optionMatch[2].trim(),\n                            isSelected\n                          });\n                        }\n                      });\n\n                      return (\n                        <>\n                          <p className=\"mb-4 text-sm text-amber-800 dark:text-amber-200\">\n                            {questionLine}\n                          </p>\n\n                          {/* Option buttons */}\n                          <div className=\"mb-4 space-y-2\">\n                            {options.map((option) => (\n                              <button\n                                key={option.number}\n                                className={`w-full rounded-lg border-2 px-4 py-3 text-left transition-all ${option.isSelected\n                                  ? 'border-amber-600 bg-amber-600 text-white shadow-md dark:border-amber-700 dark:bg-amber-700'\n                                  : 'border-amber-300 bg-white text-amber-900 dark:border-amber-700 dark:bg-gray-800 dark:text-amber-100'\n                                  } cursor-not-allowed opacity-75`}\n                                disabled\n                              >\n                                <div className=\"flex items-center gap-3\">\n                                  <span className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full text-sm font-bold ${option.isSelected\n                                    ? 'bg-white/20'\n                                    : 'bg-amber-100 dark:bg-amber-800/50'\n                                    }`}>\n                                    {option.number}\n                                  </span>\n                                  <span className=\"flex-1 text-sm font-medium sm:text-base\">\n                                    {option.text}\n                                  </span>\n                                  {option.isSelected && (\n                                    <span className=\"text-lg\">❯</span>\n                                  )}\n                                </div>\n                              </button>\n                            ))}\n                          </div>\n\n                          <div className=\"rounded-lg bg-amber-100 p-3 dark:bg-amber-800/30\">\n                            <p className=\"mb-1 text-sm font-medium text-amber-900 dark:text-amber-100\">\n                              {t('interactive.waiting')}\n                            </p>\n                            <p className=\"text-xs text-amber-800 dark:text-amber-200\">\n                              {t('interactive.instruction')}\n                            </p>\n                          </div>\n                        </>\n                      );\n                    })()}\n                  </div>\n                </div>\n              </div>\n            ) : message.isThinking ? (\n              /* Thinking messages - collapsible by default */\n              <div className=\"text-sm text-gray-700 dark:text-gray-300\">\n                <details className=\"group\">\n                  <summary className=\"flex cursor-pointer items-center gap-2 font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200\">\n                    <svg className=\"h-3 w-3 transition-transform group-open:rotate-90\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                      <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M9 5l7 7-7 7\" />\n                    </svg>\n                    <span>{t('thinking.emoji')}</span>\n                  </summary>\n                  <div className=\"mt-2 border-l-2 border-gray-300 pl-4 text-sm text-gray-600 dark:border-gray-600 dark:text-gray-400\">\n                    <Markdown className=\"prose prose-sm prose-gray max-w-none dark:prose-invert\">\n                      {message.content}\n                    </Markdown>\n                  </div>\n                </details>\n              </div>\n            ) : (\n              <div className=\"text-sm text-gray-700 dark:text-gray-300\">\n                {/* Thinking accordion for reasoning */}\n                {showThinking && message.reasoning && (\n                  <details className=\"mb-3\">\n                    <summary className=\"cursor-pointer font-medium text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200\">\n                      {t('thinking.emoji')}\n                    </summary>\n                    <div className=\"mt-2 border-l-2 border-gray-300 pl-4 text-sm italic text-gray-600 dark:border-gray-600 dark:text-gray-400\">\n                      <div className=\"whitespace-pre-wrap\">\n                        {message.reasoning}\n                      </div>\n                    </div>\n                  </details>\n                )}\n\n                {(() => {\n                  const content = formattedMessageContent;\n\n                  // Detect if content is pure JSON (starts with { or [)\n                  const trimmedContent = content.trim();\n                  if ((trimmedContent.startsWith('{') || trimmedContent.startsWith('[')) &&\n                    (trimmedContent.endsWith('}') || trimmedContent.endsWith(']'))) {\n                    try {\n                      const parsed = JSON.parse(trimmedContent);\n                      const formatted = JSON.stringify(parsed, null, 2);\n\n                      return (\n                        <div className=\"my-2\">\n                          <div className=\"mb-2 flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400\">\n                            <svg className=\"h-4 w-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                              <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z\" />\n                            </svg>\n                            <span className=\"font-medium\">{t('json.response')}</span>\n                          </div>\n                          <div className=\"overflow-hidden rounded-lg border border-gray-600/30 bg-gray-800 dark:border-gray-700 dark:bg-gray-900\">\n                            <pre className=\"overflow-x-auto p-4\">\n                              <code className=\"block whitespace-pre font-mono text-sm text-gray-100 dark:text-gray-200\">\n                                {formatted}\n                              </code>\n                            </pre>\n                          </div>\n                        </div>\n                      );\n                    } catch {\n                      // Not valid JSON, fall through to normal rendering\n                    }\n                  }\n\n                  // Normal rendering for non-JSON content\n                  return message.type === 'assistant' ? (\n                    <Markdown className=\"prose prose-sm prose-gray max-w-none dark:prose-invert\">\n                      {content}\n                    </Markdown>\n                  ) : (\n                    <div className=\"whitespace-pre-wrap\">\n                      {content}\n                    </div>\n                  );\n                })()}\n              </div>\n            )}\n\n            {(shouldShowAssistantCopyControl || !isGrouped) && (\n              <div className=\"mt-1 flex w-full items-center gap-2 text-[11px] text-gray-400 dark:text-gray-500\">\n                {shouldShowAssistantCopyControl && (\n                  <MessageCopyControl content={assistantCopyContent} messageType=\"assistant\" />\n                )}\n                {!isGrouped && <span>{formattedTime}</span>}\n              </div>\n            )}\n          </div>\n        </div>\n      )}\n    </div>\n  );\n});\n\nexport default MessageComponent;\n\n"
  },
  {
    "path": "src/components/chat/view/subcomponents/MessageCopyControl.tsx",
    "content": "import { useEffect, useMemo, useRef, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { copyTextToClipboard } from '../../../../utils/clipboard';\n\nconst COPY_SUCCESS_TIMEOUT_MS = 2000;\n\ntype CopyFormat = 'text' | 'markdown';\n\ntype CopyFormatOption = {\n  format: CopyFormat;\n  label: string;\n};\n\n// Converts markdown into readable plain text for \"Copy as text\".\nconst convertMarkdownToPlainText = (markdown: string): string => {\n  let plainText = markdown.replace(/\\r\\n/g, '\\n');\n  const codeBlocks: string[] = [];\n  plainText = plainText.replace(/```[\\w-]*\\n([\\s\\S]*?)```/g, (_match, code: string) => {\n    const placeholder = `@@CODEBLOCK${codeBlocks.length}@@`;\n    codeBlocks.push(code.replace(/\\n$/, ''));\n    return placeholder;\n  });\n  plainText = plainText.replace(/`([^`]+)`/g, '$1');\n  plainText = plainText.replace(/!\\[([^\\]]*)\\]\\(([^)]+)\\)/g, '$1');\n  plainText = plainText.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, '$1');\n  plainText = plainText.replace(/^>\\s?/gm, '');\n  plainText = plainText.replace(/^#{1,6}\\s+/gm, '');\n  plainText = plainText.replace(/^[-*+]\\s+/gm, '');\n  plainText = plainText.replace(/^\\d+\\.\\s+/gm, '');\n  plainText = plainText.replace(/(\\*\\*|__)(.*?)\\1/g, '$2');\n  plainText = plainText.replace(/(\\*|_)(.*?)\\1/g, '$2');\n  plainText = plainText.replace(/~~(.*?)~~/g, '$1');\n  plainText = plainText.replace(/<\\/?[^>]+(>|$)/g, '');\n  plainText = plainText.replace(/\\n{3,}/g, '\\n\\n');\n  plainText = plainText.replace(/@@CODEBLOCK(\\d+)@@/g, (_match, index: string) => codeBlocks[Number(index)] ?? '');\n  return plainText.trim();\n};\n\nconst MessageCopyControl = ({\n  content,\n  messageType,\n}: {\n  content: string;\n  messageType: 'user' | 'assistant';\n}) => {\n  const { t } = useTranslation('chat');\n  const canSelectCopyFormat = messageType === 'assistant';\n  const defaultFormat: CopyFormat = canSelectCopyFormat ? 'markdown' : 'text';\n  const [selectedFormat, setSelectedFormat] = useState<CopyFormat>(defaultFormat);\n  const [copied, setCopied] = useState(false);\n  const [isDropdownOpen, setIsDropdownOpen] = useState(false);\n  const dropdownRef = useRef<HTMLDivElement | null>(null);\n  const copyFeedbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  const copyFormatOptions: CopyFormatOption[] = useMemo(\n    () => [\n      {\n        format: 'markdown',\n        label: t('copyMessage.copyAsMarkdown', { defaultValue: 'Copy as markdown' }),\n      },\n      {\n        format: 'text',\n        label: t('copyMessage.copyAsText', { defaultValue: 'Copy as text' }),\n      },\n    ],\n    [t]\n  );\n\n  const selectedFormatTag = selectedFormat === 'markdown'\n    ? t('copyMessage.markdownShort', { defaultValue: 'MD' })\n    : t('copyMessage.textShort', { defaultValue: 'TXT' });\n\n  const copyPayload = useMemo(() => {\n    if (selectedFormat === 'markdown') {\n      return content;\n    }\n    return convertMarkdownToPlainText(content);\n  }, [content, selectedFormat]);\n\n  useEffect(() => {\n    setSelectedFormat(defaultFormat);\n    setIsDropdownOpen(false);\n  }, [defaultFormat]);\n\n  useEffect(() => {\n    // Close the dropdown when clicking anywhere outside this control.\n    const closeOnOutsideClick = (event: MouseEvent) => {\n      if (!isDropdownOpen) return;\n      const target = event.target as Node;\n      if (dropdownRef.current && !dropdownRef.current.contains(target)) {\n        setIsDropdownOpen(false);\n      }\n    };\n\n    window.addEventListener('mousedown', closeOnOutsideClick);\n    return () => {\n      window.removeEventListener('mousedown', closeOnOutsideClick);\n    };\n  }, [isDropdownOpen]);\n\n  useEffect(() => {\n    return () => {\n      if (copyFeedbackTimerRef.current) {\n        clearTimeout(copyFeedbackTimerRef.current);\n      }\n    };\n  }, []);\n\n  const handleCopyClick = async () => {\n    if (!copyPayload.trim()) return;\n    const didCopy = await copyTextToClipboard(copyPayload);\n    if (!didCopy) return;\n\n    setCopied(true);\n    if (copyFeedbackTimerRef.current) {\n      clearTimeout(copyFeedbackTimerRef.current);\n    }\n    copyFeedbackTimerRef.current = setTimeout(() => {\n      setCopied(false);\n    }, COPY_SUCCESS_TIMEOUT_MS);\n  };\n\n  const handleFormatChange = (format: CopyFormat) => {\n    setSelectedFormat(format);\n    setIsDropdownOpen(false);\n  };\n\n  const toneClass = messageType === 'user'\n    ? 'text-blue-100 hover:text-white'\n    : 'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300';\n  const copyTitle = copied ? t('copyMessage.copied') : t('copyMessage.copy');\n  const rootClassName = canSelectCopyFormat\n    ? 'relative flex min-w-0 flex-1 items-center gap-0.5 sm:min-w-max sm:flex-none sm:w-auto'\n    : 'relative flex items-center gap-0.5';\n\n  return (\n    <div ref={dropdownRef} className={rootClassName}>\n      <button\n        type=\"button\"\n        onClick={handleCopyClick}\n        title={copyTitle}\n        aria-label={copyTitle}\n        className={`inline-flex items-center gap-1 rounded px-1 py-0.5 transition-colors ${toneClass}`}\n      >\n        {copied ? (\n          <svg className=\"h-3.5 w-3.5\" viewBox=\"0 0 20 20\" fill=\"currentColor\">\n            <path\n              fillRule=\"evenodd\"\n              d=\"M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z\"\n              clipRule=\"evenodd\"\n            />\n          </svg>\n        ) : (\n          <svg\n            className=\"h-3.5 w-3.5\"\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth=\"2\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n          >\n            <rect x=\"9\" y=\"9\" width=\"13\" height=\"13\" rx=\"2\" ry=\"2\" />\n            <path d=\"M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1\" />\n          </svg>\n        )}\n        <span className=\"text-[10px] font-semibold uppercase tracking-wide\">{selectedFormatTag}</span>\n      </button>\n\n      {canSelectCopyFormat && (\n        <>\n          <button\n            type=\"button\"\n            onClick={() => setIsDropdownOpen((prev) => !prev)}\n            className={`rounded px-1 py-0.5 transition-colors ${toneClass}`}\n            aria-label={t('copyMessage.selectFormat', { defaultValue: 'Select copy format' })}\n            title={t('copyMessage.selectFormat', { defaultValue: 'Select copy format' })}\n          >\n            <svg\n              className={`h-3 w-3 transition-transform ${isDropdownOpen ? 'rotate-180' : ''}`}\n              fill=\"none\"\n              stroke=\"currentColor\"\n              viewBox=\"0 0 24 24\"\n            >\n              <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M19 9l-7 7-7-7\" />\n            </svg>\n          </button>\n\n          {isDropdownOpen && (\n            <div className=\"absolute left-auto top-full z-30 mt-1 min-w-36 rounded-md border border-gray-200 bg-white p-1 shadow-lg dark:border-gray-700 dark:bg-gray-900\">\n              {copyFormatOptions.map((option) => {\n                const isSelected = option.format === selectedFormat;\n                return (\n                  <button\n                    key={option.format}\n                    type=\"button\"\n                    onClick={() => handleFormatChange(option.format)}\n                    className={`block w-full rounded px-2 py-1.5 text-left transition-colors ${isSelected\n                      ? 'bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-100'\n                      : 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-800/60'\n                      }`}\n                  >\n                    <span className=\"block text-xs font-medium\">{option.label}</span>\n                  </button>\n                );\n              })}\n            </div>\n          )}\n        </>\n      )}\n    </div>\n  );\n};\n\nexport default MessageCopyControl;\n"
  },
  {
    "path": "src/components/chat/view/subcomponents/PermissionRequestsBanner.tsx",
    "content": "import React from 'react';\nimport type { PendingPermissionRequest } from '../../types/types';\nimport { buildClaudeToolPermissionEntry, formatToolInputForDisplay } from '../../utils/chatPermissions';\nimport { getClaudeSettings } from '../../utils/chatStorage';\nimport { getPermissionPanel, registerPermissionPanel } from '../../tools/configs/permissionPanelRegistry';\nimport { AskUserQuestionPanel } from '../../tools/components/InteractiveRenderers';\n\nregisterPermissionPanel('AskUserQuestion', AskUserQuestionPanel);\n\ninterface PermissionRequestsBannerProps {\n  pendingPermissionRequests: PendingPermissionRequest[];\n  handlePermissionDecision: (\n    requestIds: string | string[],\n    decision: { allow?: boolean; message?: string; rememberEntry?: string | null; updatedInput?: unknown },\n  ) => void;\n  handleGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean };\n}\n\nexport default function PermissionRequestsBanner({\n  pendingPermissionRequests,\n  handlePermissionDecision,\n  handleGrantToolPermission,\n}: PermissionRequestsBannerProps) {\n  if (!pendingPermissionRequests.length) {\n    return null;\n  }\n\n  return (\n    <div className=\"mb-3 space-y-2\">\n      {pendingPermissionRequests.map((request) => {\n        const CustomPanel = getPermissionPanel(request.toolName);\n        if (CustomPanel) {\n          return (\n            <CustomPanel\n              key={request.requestId}\n              request={request}\n              onDecision={handlePermissionDecision}\n            />\n          );\n        }\n\n        const rawInput = formatToolInputForDisplay(request.input);\n        const permissionEntry = buildClaudeToolPermissionEntry(request.toolName, rawInput);\n        const settings = getClaudeSettings();\n        const alreadyAllowed = permissionEntry ? settings.allowedTools.includes(permissionEntry) : false;\n        const rememberLabel = alreadyAllowed ? 'Allow (saved)' : 'Allow & remember';\n        const matchingRequestIds = permissionEntry\n          ? pendingPermissionRequests\n              .filter(\n                (item) =>\n                  buildClaudeToolPermissionEntry(item.toolName, formatToolInputForDisplay(item.input)) === permissionEntry,\n              )\n              .map((item) => item.requestId)\n          : [request.requestId];\n\n        return (\n          <div\n            key={request.requestId}\n            className=\"rounded-lg border border-amber-200 bg-amber-50 p-3 shadow-sm dark:border-amber-800 dark:bg-amber-900/20\"\n          >\n            <div className=\"flex flex-wrap items-start justify-between gap-3\">\n              <div>\n                <div className=\"text-sm font-semibold text-amber-900 dark:text-amber-100\">Permission required</div>\n                <div className=\"text-xs text-amber-800 dark:text-amber-200\">\n                  Tool: <span className=\"font-mono\">{request.toolName}</span>\n                </div>\n              </div>\n              {permissionEntry && (\n                <div className=\"text-xs text-amber-700 dark:text-amber-300\">\n                  Allow rule: <span className=\"font-mono\">{permissionEntry}</span>\n                </div>\n              )}\n            </div>\n\n            {rawInput && (\n              <details className=\"mt-2\">\n                <summary className=\"cursor-pointer text-xs text-amber-800 hover:text-amber-900 dark:text-amber-200 dark:hover:text-amber-100\">\n                  View tool input\n                </summary>\n                <pre className=\"mt-2 max-h-40 overflow-auto whitespace-pre-wrap rounded-md border border-amber-200/60 bg-white/80 p-2 text-xs text-amber-900 dark:border-amber-800/60 dark:bg-gray-900/60 dark:text-amber-100\">\n                  {rawInput}\n                </pre>\n              </details>\n            )}\n\n            <div className=\"mt-3 flex flex-wrap gap-2\">\n              <button\n                type=\"button\"\n                onClick={() => handlePermissionDecision(request.requestId, { allow: true })}\n                className=\"inline-flex items-center gap-2 rounded-md bg-amber-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-amber-700\"\n              >\n                Allow once\n              </button>\n              <button\n                type=\"button\"\n                onClick={() => {\n                  if (permissionEntry && !alreadyAllowed) {\n                    handleGrantToolPermission({ entry: permissionEntry, toolName: request.toolName });\n                  }\n                  handlePermissionDecision(matchingRequestIds, { allow: true, rememberEntry: permissionEntry });\n                }}\n                className={`inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors ${\n                  permissionEntry\n                    ? 'border-amber-300 text-amber-800 hover:bg-amber-100 dark:border-amber-700 dark:text-amber-100 dark:hover:bg-amber-900/30'\n                    : 'cursor-not-allowed border-gray-300 text-gray-400'\n                }`}\n                disabled={!permissionEntry}\n              >\n                {rememberLabel}\n              </button>\n              <button\n                type=\"button\"\n                onClick={() => handlePermissionDecision(request.requestId, { allow: false, message: 'User denied tool use' })}\n                className=\"inline-flex items-center gap-2 rounded-md border border-red-300 px-3 py-1.5 text-xs font-medium text-red-700 transition-colors hover:bg-red-50 dark:border-red-800 dark:text-red-200 dark:hover:bg-red-900/30\"\n              >\n                Deny\n              </button>\n            </div>\n          </div>\n        );\n      })}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx",
    "content": "import React from \"react\";\nimport { Check, ChevronDown } from \"lucide-react\";\nimport { useTranslation } from \"react-i18next\";\nimport SessionProviderLogo from \"../../../llm-logo-provider/SessionProviderLogo\";\nimport {\n  CLAUDE_MODELS,\n  CURSOR_MODELS,\n  CODEX_MODELS,\n  GEMINI_MODELS,\n} from \"../../../../../shared/modelConstants\";\nimport type { ProjectSession, SessionProvider } from \"../../../../types/app\";\nimport { NextTaskBanner } from \"../../../task-master\";\n\ntype ProviderSelectionEmptyStateProps = {\n  selectedSession: ProjectSession | null;\n  currentSessionId: string | null;\n  provider: SessionProvider;\n  setProvider: (next: SessionProvider) => void;\n  textareaRef: React.RefObject<HTMLTextAreaElement>;\n  claudeModel: string;\n  setClaudeModel: (model: string) => void;\n  cursorModel: string;\n  setCursorModel: (model: string) => void;\n  codexModel: string;\n  setCodexModel: (model: string) => void;\n  geminiModel: string;\n  setGeminiModel: (model: string) => void;\n  tasksEnabled: boolean;\n  isTaskMasterInstalled: boolean | null;\n  onShowAllTasks?: (() => void) | null;\n  setInput: React.Dispatch<React.SetStateAction<string>>;\n};\n\ntype ProviderDef = {\n  id: SessionProvider;\n  name: string;\n  infoKey: string;\n  accent: string;\n  ring: string;\n  check: string;\n};\n\nconst PROVIDERS: ProviderDef[] = [\n  {\n    id: \"claude\",\n    name: \"Claude Code\",\n    infoKey: \"providerSelection.providerInfo.anthropic\",\n    accent: \"border-primary\",\n    ring: \"ring-primary/15\",\n    check: \"bg-primary text-primary-foreground\",\n  },\n  {\n    id: \"cursor\",\n    name: \"Cursor\",\n    infoKey: \"providerSelection.providerInfo.cursorEditor\",\n    accent: \"border-violet-500 dark:border-violet-400\",\n    ring: \"ring-violet-500/15\",\n    check: \"bg-violet-500 text-white\",\n  },\n  {\n    id: \"codex\",\n    name: \"Codex\",\n    infoKey: \"providerSelection.providerInfo.openai\",\n    accent: \"border-emerald-600 dark:border-emerald-400\",\n    ring: \"ring-emerald-600/15\",\n    check: \"bg-emerald-600 dark:bg-emerald-500 text-white\",\n  },\n  {\n    id: \"gemini\",\n    name: \"Gemini\",\n    infoKey: \"providerSelection.providerInfo.google\",\n    accent: \"border-blue-500 dark:border-blue-400\",\n    ring: \"ring-blue-500/15\",\n    check: \"bg-blue-500 text-white\",\n  },\n];\n\nfunction getModelConfig(p: SessionProvider) {\n  if (p === \"claude\") return CLAUDE_MODELS;\n  if (p === \"codex\") return CODEX_MODELS;\n  if (p === \"gemini\") return GEMINI_MODELS;\n  return CURSOR_MODELS;\n}\n\nfunction getModelValue(\n  p: SessionProvider,\n  c: string,\n  cu: string,\n  co: string,\n  g: string,\n) {\n  if (p === \"claude\") return c;\n  if (p === \"codex\") return co;\n  if (p === \"gemini\") return g;\n  return cu;\n}\n\nexport default function ProviderSelectionEmptyState({\n  selectedSession,\n  currentSessionId,\n  provider,\n  setProvider,\n  textareaRef,\n  claudeModel,\n  setClaudeModel,\n  cursorModel,\n  setCursorModel,\n  codexModel,\n  setCodexModel,\n  geminiModel,\n  setGeminiModel,\n  tasksEnabled,\n  isTaskMasterInstalled,\n  onShowAllTasks,\n  setInput,\n}: ProviderSelectionEmptyStateProps) {\n  const { t } = useTranslation(\"chat\");\n  const nextTaskPrompt = t(\"tasks.nextTaskPrompt\", {\n    defaultValue: \"Start the next task\",\n  });\n\n  const selectProvider = (next: SessionProvider) => {\n    setProvider(next);\n    localStorage.setItem(\"selected-provider\", next);\n    setTimeout(() => textareaRef.current?.focus(), 100);\n  };\n\n  const handleModelChange = (value: string) => {\n    if (provider === \"claude\") {\n      setClaudeModel(value);\n      localStorage.setItem(\"claude-model\", value);\n    } else if (provider === \"codex\") {\n      setCodexModel(value);\n      localStorage.setItem(\"codex-model\", value);\n    } else if (provider === \"gemini\") {\n      setGeminiModel(value);\n      localStorage.setItem(\"gemini-model\", value);\n    } else {\n      setCursorModel(value);\n      localStorage.setItem(\"cursor-model\", value);\n    }\n  };\n\n  const modelConfig = getModelConfig(provider);\n  const currentModel = getModelValue(\n    provider,\n    claudeModel,\n    cursorModel,\n    codexModel,\n    geminiModel,\n  );\n\n  /* ── New session — provider picker ── */\n  if (!selectedSession && !currentSessionId) {\n    return (\n      <div className=\"flex h-full items-center justify-center px-4\">\n        <div className=\"w-full max-w-md\">\n          {/* Heading */}\n          <div className=\"mb-8 text-center\">\n            <h2 className=\"text-lg font-semibold tracking-tight text-foreground sm:text-xl\">\n              {t(\"providerSelection.title\")}\n            </h2>\n            <p className=\"mt-1 text-[13px] text-muted-foreground\">\n              {t(\"providerSelection.description\")}\n            </p>\n          </div>\n\n          {/* Provider cards — horizontal row, equal width */}\n          <div className=\"mb-6 grid grid-cols-2 gap-2 sm:grid-cols-4 sm:gap-2.5\">\n            {PROVIDERS.map((p) => {\n              const active = provider === p.id;\n              return (\n                <button\n                  key={p.id}\n                  onClick={() => selectProvider(p.id)}\n                  className={`\n                    relative flex flex-col items-center gap-2.5 rounded-xl border-[1.5px] px-2\n                    pb-4 pt-5 transition-all duration-150\n                    active:scale-[0.97]\n                    ${\n                      active\n                        ? `${p.accent} ${p.ring} bg-card shadow-sm ring-2`\n                        : \"border-border bg-card/60 hover:border-border/80 hover:bg-card\"\n                    }\n                  `}\n                >\n                  <SessionProviderLogo\n                    provider={p.id}\n                    className={`h-9 w-9 transition-transform duration-150 ${active ? \"scale-110\" : \"\"}`}\n                  />\n                  <div className=\"text-center\">\n                    <p className=\"text-[13px] font-semibold leading-none text-foreground\">\n                      {p.name}\n                    </p>\n                    <p className=\"mt-1 text-[10px] leading-tight text-muted-foreground\">\n                      {t(p.infoKey)}\n                    </p>\n                  </div>\n                  {/* Check badge */}\n                  {active && (\n                    <div\n                      className={`absolute -right-1 -top-1 h-[18px] w-[18px] rounded-full ${p.check} flex items-center justify-center shadow-sm`}\n                    >\n                      <Check className=\"h-2.5 w-2.5\" strokeWidth={3} />\n                    </div>\n                  )}\n                </button>\n              );\n            })}\n          </div>\n\n          {/* Model picker — appears after provider is chosen */}\n          <div\n            className={`transition-all duration-200 ${provider ? \"translate-y-0 opacity-100\" : \"pointer-events-none translate-y-1 opacity-0\"}`}\n          >\n            <div className=\"mb-5 flex items-center justify-center gap-2\">\n              <span className=\"text-sm text-muted-foreground\">\n                {t(\"providerSelection.selectModel\")}\n              </span>\n              <div className=\"relative\">\n                <select\n                  value={currentModel}\n                  onChange={(e) => handleModelChange(e.target.value)}\n                  tabIndex={-1}\n                  className=\"cursor-pointer appearance-none rounded-lg border border-border/60 bg-muted/50 py-1.5 pl-3 pr-7 text-sm font-medium text-foreground transition-colors hover:bg-muted focus:outline-none focus:ring-2 focus:ring-primary/20\"\n                >\n                  {modelConfig.OPTIONS.map(\n                    ({ value, label }: { value: string; label: string }) => (\n                      <option key={value + label} value={value}>\n                        {label}\n                      </option>\n                    ),\n                  )}\n                </select>\n                <ChevronDown className=\"pointer-events-none absolute right-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground\" />\n              </div>\n            </div>\n\n            <p className=\"text-center text-sm text-muted-foreground/70\">\n              {\n                {\n                  claude: t(\"providerSelection.readyPrompt.claude\", {\n                    model: claudeModel,\n                  }),\n                  cursor: t(\"providerSelection.readyPrompt.cursor\", {\n                    model: cursorModel,\n                  }),\n                  codex: t(\"providerSelection.readyPrompt.codex\", {\n                    model: codexModel,\n                  }),\n                  gemini: t(\"providerSelection.readyPrompt.gemini\", {\n                    model: geminiModel,\n                  }),\n                }[provider]\n              }\n            </p>\n          </div>\n\n          {/* Task banner */}\n          {provider && tasksEnabled && isTaskMasterInstalled && (\n            <div className=\"mt-5\">\n              <NextTaskBanner\n                onStartTask={() => setInput(nextTaskPrompt)}\n                onShowAllTasks={onShowAllTasks}\n              />\n            </div>\n          )}\n        </div>\n      </div>\n    );\n  }\n\n  /* ── Existing session — continue prompt ── */\n  if (selectedSession) {\n    return (\n      <div className=\"flex h-full items-center justify-center\">\n        <div className=\"max-w-md px-6 text-center\">\n          <p className=\"mb-1.5 text-lg font-semibold text-foreground\">\n            {t(\"session.continue.title\")}\n          </p>\n          <p className=\"text-sm leading-relaxed text-muted-foreground\">\n            {t(\"session.continue.description\")}\n          </p>\n\n          {tasksEnabled && isTaskMasterInstalled && (\n            <div className=\"mt-5\">\n              <NextTaskBanner\n                onStartTask={() => setInput(nextTaskPrompt)}\n                onShowAllTasks={onShowAllTasks}\n              />\n            </div>\n          )}\n        </div>\n      </div>\n    );\n  }\n\n  return null;\n}\n"
  },
  {
    "path": "src/components/chat/view/subcomponents/ThinkingModeSelector.tsx",
    "content": "import { useState, useRef, useEffect } from 'react';\nimport { Brain, X } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { thinkingModes } from '../../constants/thinkingModes';\n\ntype ThinkingModeSelectorProps = {\n  selectedMode: string;\n  onModeChange: (modeId: string) => void;\n  onClose?: () => void;\n  className?: string;\n};\n\nfunction ThinkingModeSelector({ selectedMode, onModeChange, onClose, className = '' }: ThinkingModeSelectorProps) {\n  const { t } = useTranslation('chat');\n\n  // Mapping from mode ID to translation key\n  const modeKeyMap: Record<string, string> = {\n    'think-hard': 'thinkHard',\n    'think-harder': 'thinkHarder'\n  };\n  // Create translated modes for display\n  const translatedModes = thinkingModes.map(mode => {\n    const modeKey = modeKeyMap[mode.id] || mode.id;\n    return {\n      ...mode,\n      name: t(`thinkingMode.modes.${modeKey}.name`),\n      description: t(`thinkingMode.modes.${modeKey}.description`),\n      prefix: t(`thinkingMode.modes.${modeKey}.prefix`)\n    };\n  });\n\n  const [isOpen, setIsOpen] = useState(false);\n  const dropdownRef = useRef<HTMLDivElement>(null);\n\n  useEffect(() => {\n    const handleClickOutside = (event: MouseEvent) => {\n      if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {\n        setIsOpen(false);\n        if (onClose) onClose();\n      }\n    };\n\n    document.addEventListener('mousedown', handleClickOutside);\n    return () => document.removeEventListener('mousedown', handleClickOutside);\n  }, [onClose]);\n\n  const currentMode = translatedModes.find(mode => mode.id === selectedMode) || translatedModes[0];\n  const IconComponent = currentMode.icon || Brain;\n\n  return (\n    <div className={`relative ${className}`} ref={dropdownRef}>\n      <button\n        type=\"button\"\n        onClick={() => setIsOpen(!isOpen)}\n        className={`flex h-10 w-10 items-center justify-center rounded-full transition-all duration-200 sm:h-10 sm:w-10 ${selectedMode === 'none'\n            ? 'bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600'\n            : 'bg-blue-100 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800'\n          }`}\n        title={t('thinkingMode.buttonTitle', { mode: currentMode.name })}\n      >\n        <IconComponent className={`h-5 w-5 ${currentMode.color}`} />\n      </button>\n\n      {isOpen && (\n        <div className=\"absolute bottom-full right-0 mb-2 w-64 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800\">\n          <div className=\"border-b border-gray-200 p-3 dark:border-gray-700\">\n            <div className=\"flex items-center justify-between\">\n              <h3 className=\"text-sm font-semibold text-gray-900 dark:text-white\">\n                {t('thinkingMode.selector.title')}\n              </h3>\n              <button\n                onClick={() => {\n                  setIsOpen(false);\n                  if (onClose) onClose();\n                }}\n                className=\"rounded p-1 hover:bg-gray-100 dark:hover:bg-gray-700\"\n              >\n                <X className=\"h-4 w-4 text-gray-500\" />\n              </button>\n            </div>\n            <p className=\"mt-1 text-xs text-gray-500 dark:text-gray-400\">\n              {t('thinkingMode.selector.description')}\n            </p>\n          </div>\n\n          <div className=\"py-1\">\n            {translatedModes.map((mode) => {\n              const ModeIcon = mode.icon;\n              const isSelected = mode.id === selectedMode;\n\n              return (\n                <button\n                  key={mode.id}\n                  onClick={() => {\n                    onModeChange(mode.id);\n                    setIsOpen(false);\n                    if (onClose) onClose();\n                  }}\n                  className={`w-full px-4 py-3 text-left transition-colors hover:bg-gray-50 dark:hover:bg-gray-700 ${isSelected ? 'bg-gray-50 dark:bg-gray-700' : ''\n                    }`}\n                >\n                  <div className=\"flex items-start gap-3\">\n                    <div className={`mt-0.5 ${mode.icon ? mode.color : 'text-gray-400'}`}>\n                      {ModeIcon ? <ModeIcon className=\"h-5 w-5\" /> : <div className=\"h-5 w-5\" />}\n                    </div>\n                    <div className=\"min-w-0 flex-1\">\n                      <div className=\"flex items-center gap-2\">\n                        <span className={`text-sm font-medium ${isSelected ? 'text-gray-900 dark:text-white' : 'text-gray-700 dark:text-gray-300'\n                          }`}>\n                          {mode.name}\n                        </span>\n                        {isSelected && (\n                          <span className=\"rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700 dark:bg-blue-900 dark:text-blue-300\">\n                            {t('thinkingMode.selector.active')}\n                          </span>\n                        )}\n                      </div>\n                      <p className=\"mt-0.5 text-xs text-gray-500 dark:text-gray-400\">\n                        {mode.description}\n                      </p>\n                      {mode.prefix && (\n                        <code className=\"mt-1 inline-block rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-700\">\n                          {mode.prefix}\n                        </code>\n                      )}\n                    </div>\n                  </div>\n                </button>\n              );\n            })}\n          </div>\n\n          <div className=\"border-t border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900\">\n            <p className=\"text-xs text-gray-600 dark:text-gray-400\">\n              <strong>Tip:</strong> {t('thinkingMode.selector.tip')}\n            </p>\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n\nexport default ThinkingModeSelector;"
  },
  {
    "path": "src/components/chat/view/subcomponents/TokenUsagePie.tsx",
    "content": "type TokenUsagePieProps = {\n  used: number;\n  total: number;\n};\n\nexport default function TokenUsagePie({ used, total }: TokenUsagePieProps) {\n  // Token usage visualization component\n  // Only bail out on missing values or non‐positive totals; allow used===0 to render 0%\n  if (used == null || total == null || total <= 0) return null;\n\n  const percentage = Math.min(100, (used / total) * 100);\n  const radius = 10;\n  const circumference = 2 * Math.PI * radius;\n  const offset = circumference - (percentage / 100) * circumference;\n\n  // Color based on usage level\n  const getColor = () => {\n    if (percentage < 50) return '#3b82f6'; // blue\n    if (percentage < 75) return '#f59e0b'; // orange\n    return '#ef4444'; // red\n  };\n\n  return (\n    <div className=\"flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400\">\n      <svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" className=\"-rotate-90 transform\">\n        {/* Background circle */}\n        <circle\n          cx=\"12\"\n          cy=\"12\"\n          r={radius}\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"2\"\n          className=\"text-gray-300 dark:text-gray-600\"\n        />\n        {/* Progress circle */}\n        <circle\n          cx=\"12\"\n          cy=\"12\"\n          r={radius}\n          fill=\"none\"\n          stroke={getColor()}\n          strokeWidth=\"2\"\n          strokeDasharray={circumference}\n          strokeDashoffset={offset}\n          strokeLinecap=\"round\"\n        />\n      </svg>\n      <span title={`${used.toLocaleString()} / ${total.toLocaleString()} tokens`}>\n        {percentage.toFixed(1)}%\n      </span>\n    </div>\n  );\n}"
  },
  {
    "path": "src/components/code-editor/constants/settings.ts",
    "content": "export const CODE_EDITOR_STORAGE_KEYS = {\n  theme: 'codeEditorTheme',\n  wordWrap: 'codeEditorWordWrap',\n  showMinimap: 'codeEditorShowMinimap',\n  lineNumbers: 'codeEditorLineNumbers',\n  fontSize: 'codeEditorFontSize',\n} as const;\n\nexport const CODE_EDITOR_DEFAULTS = {\n  isDarkMode: true,\n  wordWrap: false,\n  minimapEnabled: true,\n  showLineNumbers: true,\n  fontSize: '12',\n} as const;\n\nexport const CODE_EDITOR_SETTINGS_CHANGED_EVENT = 'codeEditorSettingsChanged';\n"
  },
  {
    "path": "src/components/code-editor/hooks/useCodeEditorDocument.ts",
    "content": "import { useCallback, useEffect, useState } from 'react';\nimport { api } from '../../../utils/api';\nimport type { CodeEditorFile } from '../types/types';\nimport { isBinaryFile } from '../utils/binaryFile';\n\ntype UseCodeEditorDocumentParams = {\n  file: CodeEditorFile;\n  projectPath?: string;\n};\n\nconst getErrorMessage = (error: unknown) => {\n  if (error instanceof Error) {\n    return error.message;\n  }\n\n  return String(error);\n};\n\nexport const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocumentParams) => {\n  const [content, setContent] = useState('');\n  const [loading, setLoading] = useState(true);\n  const [saving, setSaving] = useState(false);\n  const [saveSuccess, setSaveSuccess] = useState(false);\n  const [saveError, setSaveError] = useState<string | null>(null);\n  const [isBinary, setIsBinary] = useState(false);\n  const fileProjectName = file.projectName ?? projectPath;\n  const filePath = file.path;\n  const fileName = file.name;\n  const fileDiffNewString = file.diffInfo?.new_string;\n  const fileDiffOldString = file.diffInfo?.old_string;\n\n  useEffect(() => {\n    const loadFileContent = async () => {\n      try {\n        setLoading(true);\n        setIsBinary(false);\n\n        // Check if file is binary by extension\n        if (isBinaryFile(file.name)) {\n          setIsBinary(true);\n          setLoading(false);\n          return;\n        }\n\n        // Diff payload may already include full old/new snapshots, so avoid disk read.\n        if (file.diffInfo && fileDiffNewString !== undefined && fileDiffOldString !== undefined) {\n          setContent(fileDiffNewString);\n          setLoading(false);\n          return;\n        }\n\n        if (!fileProjectName) {\n          throw new Error('Missing project identifier');\n        }\n\n        const response = await api.readFile(fileProjectName, filePath);\n        if (!response.ok) {\n          throw new Error(`Failed to load file: ${response.status} ${response.statusText}`);\n        }\n\n        const data = await response.json();\n        setContent(data.content);\n      } catch (error) {\n        const message = getErrorMessage(error);\n        console.error('Error loading file:', error);\n        setContent(`// Error loading file: ${message}\\n// File: ${fileName}\\n// Path: ${filePath}`);\n      } finally {\n        setLoading(false);\n      }\n    };\n\n    loadFileContent();\n  }, [file.diffInfo, file.name, fileDiffNewString, fileDiffOldString, fileName, filePath, fileProjectName]);\n\n  const handleSave = useCallback(async () => {\n    setSaving(true);\n    setSaveError(null);\n\n    try {\n      if (!fileProjectName) {\n        throw new Error('Missing project identifier');\n      }\n\n      const response = await api.saveFile(fileProjectName, filePath, content);\n\n      if (!response.ok) {\n        const contentType = response.headers.get('content-type');\n        if (contentType?.includes('application/json')) {\n          const errorData = await response.json();\n          throw new Error(errorData.error || `Save failed: ${response.status}`);\n        }\n\n        const textError = await response.text();\n        console.error('Non-JSON error response:', textError);\n        throw new Error(`Save failed: ${response.status} ${response.statusText}`);\n      }\n\n      await response.json();\n\n      setSaveSuccess(true);\n      setTimeout(() => setSaveSuccess(false), 2000);\n    } catch (error) {\n      const message = getErrorMessage(error);\n      console.error('Error saving file:', error);\n      setSaveError(message);\n    } finally {\n      setSaving(false);\n    }\n  }, [content, filePath, fileProjectName]);\n\n  const handleDownload = useCallback(() => {\n    const blob = new Blob([content], { type: 'text/plain' });\n    const url = URL.createObjectURL(blob);\n    const anchor = document.createElement('a');\n\n    anchor.href = url;\n    anchor.download = file.name;\n\n    document.body.appendChild(anchor);\n    anchor.click();\n    document.body.removeChild(anchor);\n\n    URL.revokeObjectURL(url);\n  }, [content, file.name]);\n\n  return {\n    content,\n    setContent,\n    loading,\n    saving,\n    saveSuccess,\n    saveError,\n    isBinary,\n    handleSave,\n    handleDownload,\n  };\n};\n"
  },
  {
    "path": "src/components/code-editor/hooks/useCodeEditorSettings.ts",
    "content": "import { useEffect, useState } from 'react';\nimport {\n  CODE_EDITOR_DEFAULTS,\n  CODE_EDITOR_SETTINGS_CHANGED_EVENT,\n  CODE_EDITOR_STORAGE_KEYS,\n} from '../constants/settings';\n\nconst readTheme = () => {\n  const savedTheme = localStorage.getItem(CODE_EDITOR_STORAGE_KEYS.theme);\n  if (!savedTheme) {\n    return CODE_EDITOR_DEFAULTS.isDarkMode;\n  }\n\n  return savedTheme === 'dark';\n};\n\nconst readBoolean = (storageKey: string, defaultValue: boolean, falseValue = 'false') => {\n  const value = localStorage.getItem(storageKey);\n  if (value === null) {\n    return defaultValue;\n  }\n\n  return value !== falseValue;\n};\n\nconst readWordWrap = () => {\n  return localStorage.getItem(CODE_EDITOR_STORAGE_KEYS.wordWrap) === 'true';\n};\n\nconst readFontSize = () => {\n  const stored = localStorage.getItem(CODE_EDITOR_STORAGE_KEYS.fontSize);\n  return Number(stored ?? CODE_EDITOR_DEFAULTS.fontSize);\n};\n\nexport const useCodeEditorSettings = () => {\n  const [isDarkMode, setIsDarkMode] = useState(readTheme);\n  const [wordWrap, setWordWrap] = useState(readWordWrap);\n  const [minimapEnabled, setMinimapEnabled] = useState(() => (\n    readBoolean(CODE_EDITOR_STORAGE_KEYS.showMinimap, CODE_EDITOR_DEFAULTS.minimapEnabled)\n  ));\n  const [showLineNumbers, setShowLineNumbers] = useState(() => (\n    readBoolean(CODE_EDITOR_STORAGE_KEYS.lineNumbers, CODE_EDITOR_DEFAULTS.showLineNumbers)\n  ));\n  const [fontSize, setFontSize] = useState(readFontSize);\n\n  // Keep legacy behavior where the editor writes theme and wrap settings directly.\n  useEffect(() => {\n    localStorage.setItem(CODE_EDITOR_STORAGE_KEYS.theme, isDarkMode ? 'dark' : 'light');\n  }, [isDarkMode]);\n\n  useEffect(() => {\n    localStorage.setItem(CODE_EDITOR_STORAGE_KEYS.wordWrap, String(wordWrap));\n  }, [wordWrap]);\n\n  useEffect(() => {\n    const refreshFromStorage = () => {\n      setIsDarkMode(readTheme());\n      setWordWrap(readWordWrap());\n      setMinimapEnabled(readBoolean(CODE_EDITOR_STORAGE_KEYS.showMinimap, CODE_EDITOR_DEFAULTS.minimapEnabled));\n      setShowLineNumbers(readBoolean(CODE_EDITOR_STORAGE_KEYS.lineNumbers, CODE_EDITOR_DEFAULTS.showLineNumbers));\n      setFontSize(readFontSize());\n    };\n\n    window.addEventListener('storage', refreshFromStorage);\n    window.addEventListener(CODE_EDITOR_SETTINGS_CHANGED_EVENT, refreshFromStorage);\n\n    return () => {\n      window.removeEventListener('storage', refreshFromStorage);\n      window.removeEventListener(CODE_EDITOR_SETTINGS_CHANGED_EVENT, refreshFromStorage);\n    };\n  }, []);\n\n  return {\n    isDarkMode,\n    setIsDarkMode,\n    wordWrap,\n    setWordWrap,\n    minimapEnabled,\n    setMinimapEnabled,\n    showLineNumbers,\n    setShowLineNumbers,\n    fontSize,\n    setFontSize,\n  };\n};\n"
  },
  {
    "path": "src/components/code-editor/hooks/useEditorKeyboardShortcuts.ts",
    "content": "import { useEffect } from 'react';\n\ntype UseEditorKeyboardShortcutsParams = {\n  onSave: () => void;\n  onClose: () => void;\n  dependency: string;\n};\n\nexport const useEditorKeyboardShortcuts = ({\n  onSave,\n  onClose,\n  dependency,\n}: UseEditorKeyboardShortcutsParams) => {\n  useEffect(() => {\n    const handleKeyDown = (event: KeyboardEvent) => {\n      if (event.key === 'Escape') {\n        event.preventDefault();\n        onClose();\n        return;\n      }\n\n      if (!(event.ctrlKey || event.metaKey)) {\n        return;\n      }\n\n      if (event.key.toLowerCase() === 's') {\n        event.preventDefault();\n        onSave();\n      }\n    };\n\n    document.addEventListener('keydown', handleKeyDown);\n    return () => {\n      document.removeEventListener('keydown', handleKeyDown);\n    };\n  }, [dependency, onClose, onSave]);\n};\n"
  },
  {
    "path": "src/components/code-editor/hooks/useEditorSidebar.ts",
    "content": "import { useCallback, useEffect, useRef, useState } from 'react';\nimport type { MouseEvent as ReactMouseEvent } from 'react';\nimport type { Project } from '../../../types/app';\nimport type { CodeEditorDiffInfo, CodeEditorFile } from '../types/types';\n\ntype UseEditorSidebarOptions = {\n  selectedProject: Project | null;\n  isMobile: boolean;\n  initialWidth?: number;\n};\n\nexport const useEditorSidebar = ({\n  selectedProject,\n  isMobile,\n  initialWidth = 600,\n}: UseEditorSidebarOptions) => {\n  const [editingFile, setEditingFile] = useState<CodeEditorFile | null>(null);\n  const [editorWidth, setEditorWidth] = useState(initialWidth);\n  const [editorExpanded, setEditorExpanded] = useState(false);\n  const [isResizing, setIsResizing] = useState(false);\n  const [hasManualWidth, setHasManualWidth] = useState(false);\n  const resizeHandleRef = useRef<HTMLDivElement | null>(null);\n\n  const handleFileOpen = useCallback(\n    (filePath: string, diffInfo: CodeEditorDiffInfo | null = null) => {\n      const normalizedPath = filePath.replace(/\\\\/g, '/');\n      const fileName = normalizedPath.split('/').pop() || filePath;\n\n      setEditingFile({\n        name: fileName,\n        path: filePath,\n        projectName: selectedProject?.name,\n        diffInfo,\n      });\n    },\n    [selectedProject?.name],\n  );\n\n  const handleCloseEditor = useCallback(() => {\n    setEditingFile(null);\n    setEditorExpanded(false);\n  }, []);\n\n  const handleToggleEditorExpand = useCallback(() => {\n    setEditorExpanded((previous) => !previous);\n  }, []);\n\n  const handleResizeStart = useCallback(\n    (event: ReactMouseEvent<HTMLDivElement>) => {\n      if (isMobile) {\n        return;\n      }\n\n      // After first drag interaction, the editor width is user-controlled.\n      setHasManualWidth(true);\n      setIsResizing(true);\n      event.preventDefault();\n    },\n    [isMobile],\n  );\n\n  useEffect(() => {\n    const handleMouseMove = (event: globalThis.MouseEvent) => {\n      if (!isResizing) {\n        return;\n      }\n\n      // Get the main container (parent of EditorSidebar's parent) that contains both left content and editor\n      const editorContainer = resizeHandleRef.current?.parentElement;\n      const mainContainer = editorContainer?.parentElement;\n      if (!mainContainer) {\n        return;\n      }\n\n      const containerRect = mainContainer.getBoundingClientRect();\n      // Calculate new editor width: distance from mouse to right edge of main container\n      const newWidth = containerRect.right - event.clientX;\n\n      const minWidth = 300;\n      const maxWidth = containerRect.width * 0.8;\n\n      if (newWidth >= minWidth && newWidth <= maxWidth) {\n        setEditorWidth(newWidth);\n      }\n    };\n\n    const handleMouseUp = () => {\n      setIsResizing(false);\n    };\n\n    if (isResizing) {\n      document.addEventListener('mousemove', handleMouseMove);\n      document.addEventListener('mouseup', handleMouseUp);\n      document.body.style.cursor = 'col-resize';\n      document.body.style.userSelect = 'none';\n    }\n\n    return () => {\n      document.removeEventListener('mousemove', handleMouseMove);\n      document.removeEventListener('mouseup', handleMouseUp);\n      document.body.style.cursor = '';\n      document.body.style.userSelect = '';\n    };\n  }, [isResizing]);\n\n  return {\n    editingFile,\n    editorWidth,\n    editorExpanded,\n    hasManualWidth,\n    resizeHandleRef,\n    handleFileOpen,\n    handleCloseEditor,\n    handleToggleEditorExpand,\n    handleResizeStart,\n  };\n};\n"
  },
  {
    "path": "src/components/code-editor/types/types.ts",
    "content": "export type CodeEditorDiffInfo = {\n  old_string?: string;\n  new_string?: string;\n  [key: string]: unknown;\n};\n\nexport type CodeEditorFile = {\n  name: string;\n  path: string;\n  projectName?: string;\n  diffInfo?: CodeEditorDiffInfo | null;\n  [key: string]: unknown;\n};\n\nexport type CodeEditorSettingsState = {\n  isDarkMode: boolean;\n  wordWrap: boolean;\n  minimapEnabled: boolean;\n  showLineNumbers: boolean;\n  fontSize: string;\n};\n"
  },
  {
    "path": "src/components/code-editor/utils/binaryFile.ts",
    "content": "// Binary file extensions (images are handled by ImageViewer, not here)\nconst BINARY_EXTENSIONS = [\n  // Archives\n  'zip', 'tar', 'gz', 'rar', '7z', 'bz2', 'xz',\n  // Executables\n  'exe', 'dll', 'so', 'dylib', 'app', 'dmg', 'msi',\n  // Media\n  'mp3', 'mp4', 'wav', 'avi', 'mov', 'mkv', 'flv', 'wmv', 'm4a', 'ogg',\n  // Documents\n  'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp',\n  // Fonts\n  'ttf', 'otf', 'woff', 'woff2', 'eot',\n  // Database\n  'db', 'sqlite', 'sqlite3',\n  // Other binary\n  'bin', 'dat', 'iso', 'img', 'class', 'jar', 'war', 'pyc', 'pyo'\n];\n\nexport const isBinaryFile = (filename: string): boolean => {\n  const ext = filename.split('.').pop()?.toLowerCase();\n  return BINARY_EXTENSIONS.includes(ext ?? '');\n};\n"
  },
  {
    "path": "src/components/code-editor/utils/editorExtensions.ts",
    "content": "import { css } from '@codemirror/lang-css';\nimport { html } from '@codemirror/lang-html';\nimport { javascript } from '@codemirror/lang-javascript';\nimport { json } from '@codemirror/lang-json';\nimport { StreamLanguage } from '@codemirror/language';\nimport { markdown } from '@codemirror/lang-markdown';\nimport { python } from '@codemirror/lang-python';\nimport { getChunks } from '@codemirror/merge';\nimport { EditorView, ViewPlugin } from '@codemirror/view';\nimport { showMinimap } from '@replit/codemirror-minimap';\nimport type { CodeEditorFile } from '../types/types';\n\n// Lightweight lexer for `.env` files (including `.env.*` variants).\nconst envLanguage = StreamLanguage.define({\n  token(stream) {\n    if (stream.match(/^#.*/)) return 'comment';\n    if (stream.sol() && stream.match(/^[A-Za-z_][A-Za-z0-9_.]*(?==)/)) return 'variableName.definition';\n    if (stream.match(/^=/)) return 'operator';\n    if (stream.match(/^\"(?:[^\"\\\\]|\\\\.)*\"?/)) return 'string';\n    if (stream.match(/^'(?:[^'\\\\]|\\\\.)*'?/)) return 'string';\n    if (stream.match(/^\\$\\{[^}]*\\}?/)) return 'variableName.special';\n    if (stream.match(/^\\$[A-Za-z_][A-Za-z0-9_]*/)) return 'variableName.special';\n    if (stream.match(/^\\d+/)) return 'number';\n\n    stream.next();\n    return null;\n  },\n});\n\nexport const getLanguageExtensions = (filename: string) => {\n  const lowerName = filename.toLowerCase();\n  if (lowerName === '.env' || lowerName.startsWith('.env.')) {\n    return [envLanguage];\n  }\n\n  const ext = filename.split('.').pop()?.toLowerCase();\n  switch (ext) {\n    case 'js':\n    case 'jsx':\n    case 'ts':\n    case 'tsx':\n      return [javascript({ jsx: true, typescript: ext.includes('ts') })];\n    case 'py':\n      return [python()];\n    case 'html':\n    case 'htm':\n      return [html()];\n    case 'css':\n    case 'scss':\n    case 'less':\n      return [css()];\n    case 'json':\n      return [json()];\n    case 'md':\n    case 'markdown':\n      return [markdown()];\n    case 'env':\n      return [envLanguage];\n    default:\n      return [];\n  }\n};\n\nexport const createMinimapExtension = ({\n  file,\n  showDiff,\n  minimapEnabled,\n  isDarkMode,\n}: {\n  file: CodeEditorFile;\n  showDiff: boolean;\n  minimapEnabled: boolean;\n  isDarkMode: boolean;\n}) => {\n  if (!file.diffInfo || !showDiff || !minimapEnabled) {\n    return [];\n  }\n\n  const gutters: Record<number, string> = {};\n\n  return [\n    showMinimap.compute(['doc'], (state) => {\n      const chunksData = getChunks(state);\n      const chunks = chunksData?.chunks || [];\n\n      Object.keys(gutters).forEach((key) => {\n        delete gutters[Number(key)];\n      });\n\n      chunks.forEach((chunk) => {\n        const fromLine = state.doc.lineAt(chunk.fromB).number;\n        const toLine = state.doc.lineAt(Math.min(chunk.toB, state.doc.length)).number;\n\n        for (let lineNumber = fromLine; lineNumber <= toLine; lineNumber += 1) {\n          gutters[lineNumber] = isDarkMode ? 'rgba(34, 197, 94, 0.8)' : 'rgba(34, 197, 94, 1)';\n        }\n      });\n\n      return {\n        create: () => ({ dom: document.createElement('div') }),\n        displayText: 'blocks',\n        showOverlay: 'always',\n        gutters: [gutters],\n      };\n    }),\n  ];\n};\n\nexport const createScrollToFirstChunkExtension = ({\n  file,\n  showDiff,\n}: {\n  file: CodeEditorFile;\n  showDiff: boolean;\n}) => {\n  if (!file.diffInfo || !showDiff) {\n    return [];\n  }\n\n  return [\n    ViewPlugin.fromClass(class {\n      constructor(view: EditorView) {\n        // Wait for merge decorations so the first chunk location is stable.\n        setTimeout(() => {\n          const chunksData = getChunks(view.state);\n          const firstChunk = chunksData?.chunks?.[0];\n\n          if (firstChunk) {\n            view.dispatch({\n              effects: EditorView.scrollIntoView(firstChunk.fromB, { y: 'center' }),\n            });\n          }\n        }, 100);\n      }\n\n      update() {}\n\n      destroy() {}\n    }),\n  ];\n};\n"
  },
  {
    "path": "src/components/code-editor/utils/editorStyles.ts",
    "content": "export const getEditorLoadingStyles = (isDarkMode: boolean) => {\n  return `\n    .code-editor-loading {\n      background-color: ${isDarkMode ? '#111827' : '#ffffff'} !important;\n    }\n\n    .code-editor-loading:hover {\n      background-color: ${isDarkMode ? '#111827' : '#ffffff'} !important;\n    }\n  `;\n};\n\nexport const getEditorStyles = (isDarkMode: boolean) => {\n  return `\n    .cm-deletedChunk {\n      background-color: ${isDarkMode ? 'rgba(239, 68, 68, 0.15)' : 'rgba(255, 235, 235, 1)'} !important;\n      border-left: 3px solid ${isDarkMode ? 'rgba(239, 68, 68, 0.6)' : 'rgb(239, 68, 68)'} !important;\n      padding-left: 4px !important;\n    }\n\n    .cm-insertedChunk {\n      background-color: ${isDarkMode ? 'rgba(34, 197, 94, 0.15)' : 'rgba(230, 255, 237, 1)'} !important;\n      border-left: 3px solid ${isDarkMode ? 'rgba(34, 197, 94, 0.6)' : 'rgb(34, 197, 94)'} !important;\n      padding-left: 4px !important;\n    }\n\n    .cm-editor.cm-merge-b .cm-changedText {\n      background: ${isDarkMode ? 'rgba(34, 197, 94, 0.4)' : 'rgba(34, 197, 94, 0.3)'} !important;\n      padding-top: 2px !important;\n      padding-bottom: 2px !important;\n      margin-top: -2px !important;\n      margin-bottom: -2px !important;\n    }\n\n    .cm-editor .cm-deletedChunk .cm-changedText {\n      background: ${isDarkMode ? 'rgba(239, 68, 68, 0.4)' : 'rgba(239, 68, 68, 0.3)'} !important;\n      padding-top: 2px !important;\n      padding-bottom: 2px !important;\n      margin-top: -2px !important;\n      margin-bottom: -2px !important;\n    }\n\n    .cm-gutter.cm-gutter-minimap {\n      background-color: ${isDarkMode ? '#1e1e1e' : '#f5f5f5'};\n    }\n\n    .cm-editor-toolbar-panel {\n      padding: 4px 10px;\n      background-color: ${isDarkMode ? '#1f2937' : '#ffffff'};\n      border-bottom: 1px solid ${isDarkMode ? '#374151' : '#e5e7eb'};\n      color: ${isDarkMode ? '#d1d5db' : '#374151'};\n      font-size: 12px;\n    }\n\n    .cm-diff-nav-btn,\n    .cm-toolbar-btn {\n      padding: 3px;\n      background: transparent;\n      border: none;\n      cursor: pointer;\n      border-radius: 4px;\n      display: inline-flex;\n      align-items: center;\n      justify-content: center;\n      color: inherit;\n      transition: background-color 0.2s;\n    }\n\n    .cm-diff-nav-btn:hover,\n    .cm-toolbar-btn:hover {\n      background-color: ${isDarkMode ? '#374151' : '#f3f4f6'};\n    }\n\n    .cm-diff-nav-btn:disabled {\n      opacity: 0.5;\n      cursor: not-allowed;\n    }\n  `;\n};\n"
  },
  {
    "path": "src/components/code-editor/utils/editorToolbarPanel.ts",
    "content": "import { getChunks } from '@codemirror/merge';\nimport { EditorView, showPanel } from '@codemirror/view';\nimport type { CodeEditorFile } from '../types/types';\n\ntype EditorToolbarLabels = {\n  changes: string;\n  previousChange: string;\n  nextChange: string;\n  hideDiff: string;\n  showDiff: string;\n  collapse: string;\n  expand: string;\n};\n\ntype CreateEditorToolbarPanelParams = {\n  file: CodeEditorFile;\n  showDiff: boolean;\n  isSidebar: boolean;\n  isExpanded: boolean;\n  onToggleDiff: () => void;\n  onPopOut: (() => void) | null;\n  onToggleExpand: (() => void) | null;\n  labels: EditorToolbarLabels;\n};\n\nconst getDiffVisibilityIcon = (showDiff: boolean) => {\n  if (showDiff) {\n    return '<path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21\" />';\n  }\n\n  return '<path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\" /><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z\" />';\n};\n\nconst getExpandIcon = (isExpanded: boolean) => {\n  if (isExpanded) {\n    return '<path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25\" />';\n  }\n\n  return '<path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4\" />';\n};\n\nconst escapeHtml = (value: string): string => (\n  value\n    .replace(/&/g, '&amp;')\n    .replace(/</g, '&lt;')\n    .replace(/>/g, '&gt;')\n    .replace(/\"/g, '&quot;')\n    .replace(/'/g, '&#39;')\n);\n\nexport const createEditorToolbarPanelExtension = ({\n  file,\n  showDiff,\n  isSidebar,\n  isExpanded,\n  onToggleDiff,\n  onPopOut,\n  onToggleExpand,\n  labels,\n}: CreateEditorToolbarPanelParams) => {\n  const hasToolbarButtons = Boolean(file.diffInfo || (isSidebar && onPopOut) || (isSidebar && onToggleExpand));\n  if (!hasToolbarButtons) {\n    return [];\n  }\n\n  const createPanel = (view: EditorView) => {\n    const dom = document.createElement('div');\n    dom.className = 'cm-editor-toolbar-panel';\n\n    let currentIndex = 0;\n\n    const updatePanel = () => {\n      const hasDiff = Boolean(file.diffInfo && showDiff);\n      const chunksData = hasDiff ? getChunks(view.state) : null;\n      const chunks = chunksData?.chunks || [];\n      const chunkCount = chunks.length;\n      const maxChunkIndex = Math.max(0, chunkCount - 1);\n      currentIndex = Math.max(0, Math.min(currentIndex, maxChunkIndex));\n      const escapedLabels = {\n        changes: escapeHtml(labels.changes),\n        previousChange: escapeHtml(labels.previousChange),\n        nextChange: escapeHtml(labels.nextChange),\n        hideDiff: escapeHtml(labels.hideDiff),\n        showDiff: escapeHtml(labels.showDiff),\n        collapse: escapeHtml(labels.collapse),\n        expand: escapeHtml(labels.expand),\n      };\n      // Icons are static SVG path fragments controlled by this module.\n      const diffVisibilityIcon = getDiffVisibilityIcon(showDiff);\n      const expandIcon = getExpandIcon(isExpanded);\n\n      let toolbarHtml = '<div style=\"display: flex; align-items: center; justify-content: space-between; width: 100%;\">';\n      toolbarHtml += '<div style=\"display: flex; align-items: center; gap: 8px;\">';\n\n      if (hasDiff) {\n        toolbarHtml += `\n          <span style=\"font-weight: 500;\">${chunkCount > 0 ? `${currentIndex + 1}/${chunkCount}` : '0'} ${escapedLabels.changes}</span>\n          <button class=\"cm-diff-nav-btn cm-diff-nav-prev\" title=\"${escapedLabels.previousChange}\" ${chunkCount === 0 ? 'disabled' : ''}>\n            <svg width=\"16\" height=\"16\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 15l7-7 7 7\" />\n            </svg>\n          </button>\n          <button class=\"cm-diff-nav-btn cm-diff-nav-next\" title=\"${escapedLabels.nextChange}\" ${chunkCount === 0 ? 'disabled' : ''}>\n            <svg width=\"16\" height=\"16\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M19 9l-7 7-7-7\" />\n            </svg>\n          </button>\n        `;\n      }\n\n      toolbarHtml += '</div>';\n      toolbarHtml += '<div style=\"display: flex; align-items: center; gap: 4px;\">';\n\n      if (file.diffInfo) {\n        toolbarHtml += `\n          <button class=\"cm-toolbar-btn cm-toggle-diff-btn\" title=\"${showDiff ? escapedLabels.hideDiff : escapedLabels.showDiff}\">\n            <svg width=\"16\" height=\"16\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n              ${diffVisibilityIcon}\n            </svg>\n          </button>\n        `;\n      }\n\n      if (isSidebar && onPopOut) {\n        toolbarHtml += `\n          <button class=\"cm-toolbar-btn cm-popout-btn\" title=\"Open in modal\">\n            <svg width=\"16\" height=\"16\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n              <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6M15 3h6v6M10 14L21 3\" />\n            </svg>\n          </button>\n        `;\n      }\n\n      if (isSidebar && onToggleExpand) {\n        toolbarHtml += `\n          <button class=\"cm-toolbar-btn cm-expand-btn\" title=\"${isExpanded ? escapedLabels.collapse : escapedLabels.expand}\">\n            <svg width=\"16\" height=\"16\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n              ${expandIcon}\n            </svg>\n          </button>\n        `;\n      }\n\n      toolbarHtml += '</div>';\n      toolbarHtml += '</div>';\n\n      dom.innerHTML = toolbarHtml;\n\n      if (hasDiff) {\n        const previousButton = dom.querySelector<HTMLButtonElement>('.cm-diff-nav-prev');\n        const nextButton = dom.querySelector<HTMLButtonElement>('.cm-diff-nav-next');\n\n        previousButton?.addEventListener('click', () => {\n          if (chunks.length === 0) {\n            return;\n          }\n\n          currentIndex = currentIndex > 0 ? currentIndex - 1 : chunks.length - 1;\n          const chunk = chunks[currentIndex];\n\n          if (chunk) {\n            view.dispatch({\n              effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' }),\n            });\n          }\n\n          updatePanel();\n        });\n\n        nextButton?.addEventListener('click', () => {\n          if (chunks.length === 0) {\n            return;\n          }\n\n          currentIndex = currentIndex < chunks.length - 1 ? currentIndex + 1 : 0;\n          const chunk = chunks[currentIndex];\n\n          if (chunk) {\n            view.dispatch({\n              effects: EditorView.scrollIntoView(chunk.fromB, { y: 'center' }),\n            });\n          }\n\n          updatePanel();\n        });\n      }\n\n      const toggleDiffButton = dom.querySelector<HTMLButtonElement>('.cm-toggle-diff-btn');\n      toggleDiffButton?.addEventListener('click', onToggleDiff);\n\n      const popOutButton = dom.querySelector<HTMLButtonElement>('.cm-popout-btn');\n      popOutButton?.addEventListener('click', () => {\n        onPopOut?.();\n      });\n\n      const expandButton = dom.querySelector<HTMLButtonElement>('.cm-expand-btn');\n      expandButton?.addEventListener('click', () => {\n        onToggleExpand?.();\n      });\n    };\n\n    updatePanel();\n\n    return {\n      top: true,\n      dom,\n      update: updatePanel,\n    };\n  };\n\n  return [showPanel.of(createPanel)];\n};\n"
  },
  {
    "path": "src/components/code-editor/view/CodeEditor.tsx",
    "content": "import { EditorView } from '@codemirror/view';\nimport { unifiedMergeView } from '@codemirror/merge';\nimport type { Extension } from '@codemirror/state';\nimport { useMemo, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useCodeEditorDocument } from '../hooks/useCodeEditorDocument';\nimport { useCodeEditorSettings } from '../hooks/useCodeEditorSettings';\nimport { useEditorKeyboardShortcuts } from '../hooks/useEditorKeyboardShortcuts';\nimport type { CodeEditorFile } from '../types/types';\nimport { createMinimapExtension, createScrollToFirstChunkExtension, getLanguageExtensions } from '../utils/editorExtensions';\nimport { getEditorStyles } from '../utils/editorStyles';\nimport { createEditorToolbarPanelExtension } from '../utils/editorToolbarPanel';\nimport CodeEditorFooter from './subcomponents/CodeEditorFooter';\nimport CodeEditorHeader from './subcomponents/CodeEditorHeader';\nimport CodeEditorLoadingState from './subcomponents/CodeEditorLoadingState';\nimport CodeEditorSurface from './subcomponents/CodeEditorSurface';\nimport CodeEditorBinaryFile from './subcomponents/CodeEditorBinaryFile';\n\ntype CodeEditorProps = {\n  file: CodeEditorFile;\n  onClose: () => void;\n  projectPath?: string;\n  isSidebar?: boolean;\n  isExpanded?: boolean;\n  onToggleExpand?: (() => void) | null;\n  onPopOut?: (() => void) | null;\n};\n\nexport default function CodeEditor({\n  file,\n  onClose,\n  projectPath,\n  isSidebar = false,\n  isExpanded = false,\n  onToggleExpand = null,\n  onPopOut = null,\n}: CodeEditorProps) {\n  const { t } = useTranslation('codeEditor');\n  const [isFullscreen, setIsFullscreen] = useState(false);\n  const [showDiff, setShowDiff] = useState(Boolean(file.diffInfo));\n  const [markdownPreview, setMarkdownPreview] = useState(false);\n\n  const {\n    isDarkMode,\n    wordWrap,\n    minimapEnabled,\n    showLineNumbers,\n    fontSize,\n  } = useCodeEditorSettings();\n\n  const {\n    content,\n    setContent,\n    loading,\n    saving,\n    saveSuccess,\n    saveError,\n    isBinary,\n    handleSave,\n    handleDownload,\n  } = useCodeEditorDocument({\n    file,\n    projectPath,\n  });\n\n  const isMarkdownFile = useMemo(() => {\n    const extension = file.name.split('.').pop()?.toLowerCase();\n    return extension === 'md' || extension === 'markdown';\n  }, [file.name]);\n\n  const minimapExtension = useMemo(\n    () => (\n      createMinimapExtension({\n        file,\n        showDiff,\n        minimapEnabled,\n        isDarkMode,\n      })\n    ),\n    [file, isDarkMode, minimapEnabled, showDiff],\n  );\n\n  const scrollToFirstChunkExtension = useMemo(\n    () => createScrollToFirstChunkExtension({ file, showDiff }),\n    [file, showDiff],\n  );\n\n  const toolbarPanelExtension = useMemo(\n    () => (\n      createEditorToolbarPanelExtension({\n        file,\n        showDiff,\n        isSidebar,\n        isExpanded,\n        onToggleDiff: () => setShowDiff((previous) => !previous),\n        onPopOut,\n        onToggleExpand,\n        labels: {\n          changes: t('toolbar.changes'),\n          previousChange: t('toolbar.previousChange'),\n          nextChange: t('toolbar.nextChange'),\n          hideDiff: t('toolbar.hideDiff'),\n          showDiff: t('toolbar.showDiff'),\n          collapse: t('toolbar.collapse'),\n          expand: t('toolbar.expand'),\n        },\n      })\n    ),\n    [file, isExpanded, isSidebar, onPopOut, onToggleExpand, showDiff, t],\n  );\n\n  const extensions = useMemo(() => {\n    const allExtensions: Extension[] = [\n      ...getLanguageExtensions(file.name),\n      ...toolbarPanelExtension,\n    ];\n\n    if (file.diffInfo && showDiff && file.diffInfo.old_string !== undefined) {\n      allExtensions.push(\n        unifiedMergeView({\n          original: file.diffInfo.old_string,\n          mergeControls: false,\n          highlightChanges: true,\n          syntaxHighlightDeletions: false,\n          gutter: true,\n        }),\n      );\n      allExtensions.push(...minimapExtension);\n      allExtensions.push(...scrollToFirstChunkExtension);\n    }\n\n    if (wordWrap) {\n      allExtensions.push(EditorView.lineWrapping);\n    }\n\n    return allExtensions;\n  }, [\n    file.diffInfo,\n    file.name,\n    minimapExtension,\n    scrollToFirstChunkExtension,\n    showDiff,\n    toolbarPanelExtension,\n    wordWrap,\n  ]);\n\n  useEditorKeyboardShortcuts({\n    onSave: handleSave,\n    onClose,\n    dependency: content,\n  });\n\n  if (loading) {\n    return (\n      <CodeEditorLoadingState\n        isDarkMode={isDarkMode}\n        isSidebar={isSidebar}\n        loadingText={t('loading', { fileName: file.name })}\n      />\n    );\n  }\n\n  // Binary file display\n  if (isBinary) {\n    return (\n      <CodeEditorBinaryFile\n        file={file}\n        isSidebar={isSidebar}\n        isFullscreen={isFullscreen}\n        onClose={onClose}\n        onToggleFullscreen={() => setIsFullscreen((previous) => !previous)}\n        title={t('binaryFile.title', 'Binary File')}\n        message={t('binaryFile.message', 'The file \"{{fileName}}\" cannot be displayed in the text editor because it is a binary file.', { fileName: file.name })}\n      />\n    );\n  }\n\n  const outerContainerClassName = isSidebar\n    ? 'w-full h-full flex flex-col'\n    : `fixed inset-0 z-[9999] md:bg-black/50 md:flex md:items-center md:justify-center md:p-4 ${isFullscreen ? 'md:p-0' : ''}`;\n\n  const innerContainerClassName = isSidebar\n    ? 'bg-background flex flex-col w-full h-full'\n    : `bg-background shadow-2xl flex flex-col w-full h-full md:rounded-lg md:shadow-2xl${\n      isFullscreen ? ' md:w-full md:h-full md:rounded-none' : ' md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]'\n    }`;\n\n  return (\n    <>\n      <style>{getEditorStyles(isDarkMode)}</style>\n      <div className={outerContainerClassName}>\n        <div className={innerContainerClassName}>\n          <CodeEditorHeader\n            file={file}\n            isSidebar={isSidebar}\n            isFullscreen={isFullscreen}\n            isMarkdownFile={isMarkdownFile}\n            markdownPreview={markdownPreview}\n            saving={saving}\n            saveSuccess={saveSuccess}\n            onToggleMarkdownPreview={() => setMarkdownPreview((previous) => !previous)}\n            onOpenSettings={() => window.openSettings?.('appearance')}\n            onDownload={handleDownload}\n            onSave={handleSave}\n            onToggleFullscreen={() => setIsFullscreen((previous) => !previous)}\n            onClose={onClose}\n            labels={{\n              showingChanges: t('header.showingChanges'),\n              editMarkdown: t('actions.editMarkdown'),\n              previewMarkdown: t('actions.previewMarkdown'),\n              settings: t('toolbar.settings'),\n              download: t('actions.download'),\n              save: t('actions.save'),\n              saving: t('actions.saving'),\n              saved: t('actions.saved'),\n              fullscreen: t('actions.fullscreen'),\n              exitFullscreen: t('actions.exitFullscreen'),\n              close: t('actions.close'),\n            }}\n          />\n\n          {saveError && (\n            <div className=\"border-b border-red-200 bg-red-50 px-3 py-1.5 text-xs text-red-700 dark:border-red-900/40 dark:bg-red-900/20 dark:text-red-300\">\n              {saveError}\n            </div>\n          )}\n\n          <div className=\"flex-1 overflow-hidden\">\n            <CodeEditorSurface\n              content={content}\n              onChange={setContent}\n              markdownPreview={markdownPreview}\n              isMarkdownFile={isMarkdownFile}\n              isDarkMode={isDarkMode}\n              fontSize={fontSize}\n              showLineNumbers={showLineNumbers}\n              extensions={extensions}\n            />\n          </div>\n\n          <CodeEditorFooter\n            content={content}\n            linesLabel={t('footer.lines')}\n            charactersLabel={t('footer.characters')}\n            shortcutsLabel={t('footer.shortcuts')}\n          />\n        </div>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "src/components/code-editor/view/EditorSidebar.tsx",
    "content": "import { useState, useEffect, useRef } from 'react';\nimport type { MouseEvent, MutableRefObject } from 'react';\nimport type { CodeEditorFile } from '../types/types';\nimport CodeEditor from './CodeEditor';\n\ntype EditorSidebarProps = {\n  editingFile: CodeEditorFile | null;\n  isMobile: boolean;\n  editorExpanded: boolean;\n  editorWidth: number;\n  hasManualWidth: boolean;\n  resizeHandleRef: MutableRefObject<HTMLDivElement | null>;\n  onResizeStart: (event: MouseEvent<HTMLDivElement>) => void;\n  onCloseEditor: () => void;\n  onToggleEditorExpand: () => void;\n  projectPath?: string;\n  fillSpace?: boolean;\n};\n\n// Minimum width for the left content (file tree, chat, etc.)\nconst MIN_LEFT_CONTENT_WIDTH = 200;\n// Minimum width for the editor sidebar\nconst MIN_EDITOR_WIDTH = 280;\n\nexport default function EditorSidebar({\n  editingFile,\n  isMobile,\n  editorExpanded,\n  editorWidth,\n  hasManualWidth,\n  resizeHandleRef,\n  onResizeStart,\n  onCloseEditor,\n  onToggleEditorExpand,\n  projectPath,\n  fillSpace,\n}: EditorSidebarProps) {\n  const [poppedOut, setPoppedOut] = useState(false);\n  const containerRef = useRef<HTMLDivElement>(null);\n  const [effectiveWidth, setEffectiveWidth] = useState(editorWidth);\n\n  // Adjust editor width when container size changes to ensure buttons are always visible\n  useEffect(() => {\n    if (!editingFile || isMobile || poppedOut) return;\n\n    const updateWidth = () => {\n      if (!containerRef.current) return;\n      const parentElement = containerRef.current.parentElement;\n      if (!parentElement) return;\n\n      const containerWidth = parentElement.clientWidth;\n\n      // Calculate maximum allowed editor width\n      const maxEditorWidth = containerWidth - MIN_LEFT_CONTENT_WIDTH;\n\n      if (maxEditorWidth < MIN_EDITOR_WIDTH) {\n        // Not enough space - pop out the editor so user can still see everything\n        setPoppedOut(true);\n      } else if (editorWidth > maxEditorWidth) {\n        // Editor is too wide - constrain it to ensure left content has space\n        setEffectiveWidth(maxEditorWidth);\n      } else {\n        setEffectiveWidth(editorWidth);\n      }\n    };\n\n    updateWidth();\n    window.addEventListener('resize', updateWidth);\n\n    // Also use ResizeObserver for more accurate detection\n    const resizeObserver = new ResizeObserver(updateWidth);\n    const parentEl = containerRef.current?.parentElement;\n    if (parentEl) {\n      resizeObserver.observe(parentEl);\n    }\n\n    return () => {\n      window.removeEventListener('resize', updateWidth);\n      resizeObserver.disconnect();\n    };\n  }, [editingFile, isMobile, poppedOut, editorWidth]);\n\n  if (!editingFile) {\n    return null;\n  }\n\n  if (isMobile || poppedOut) {\n    return (\n      <CodeEditor\n        file={editingFile}\n        onClose={() => {\n          setPoppedOut(false);\n          onCloseEditor();\n        }}\n        projectPath={projectPath}\n        isSidebar={false}\n      />\n    );\n  }\n\n  // In files tab, fill the remaining width unless user has dragged manually.\n  const useFlexLayout = editorExpanded || (fillSpace && !hasManualWidth);\n\n  return (\n    <div ref={containerRef} className={`flex h-full min-w-0 flex-shrink-0 ${editorExpanded ? 'flex-1' : ''}`}>\n      {!editorExpanded && (\n        <div\n          ref={resizeHandleRef}\n          onMouseDown={onResizeStart}\n          className=\"group relative w-1 flex-shrink-0 cursor-col-resize bg-gray-200 transition-colors hover:bg-blue-500 dark:bg-gray-700 dark:hover:bg-blue-600\"\n          title=\"Drag to resize\"\n        >\n          <div className=\"absolute inset-y-0 left-1/2 w-1 -translate-x-1/2 bg-blue-500 opacity-0 transition-opacity group-hover:opacity-100 dark:bg-blue-600\" />\n        </div>\n      )}\n\n      <div\n        className={`h-full overflow-hidden border-l border-gray-200 dark:border-gray-700 ${useFlexLayout ? 'min-w-0 flex-1' : `min-w-[ flex-shrink-0${MIN_EDITOR_WIDTH}px]`}`}\n        style={useFlexLayout ? undefined : { width: `${effectiveWidth}px`, minWidth: `${MIN_EDITOR_WIDTH}px` }}\n      >\n        <CodeEditor\n          file={editingFile}\n          onClose={onCloseEditor}\n          projectPath={projectPath}\n          isSidebar\n          isExpanded={editorExpanded}\n          onToggleExpand={onToggleEditorExpand}\n          onPopOut={() => setPoppedOut(true)}\n        />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/code-editor/view/subcomponents/CodeEditorBinaryFile.tsx",
    "content": "import type { CodeEditorFile } from '../../types/types';\n\ntype CodeEditorBinaryFileProps = {\n  file: CodeEditorFile;\n  isSidebar: boolean;\n  isFullscreen: boolean;\n  onClose: () => void;\n  onToggleFullscreen: () => void;\n  title: string;\n  message: string;\n};\n\nexport default function CodeEditorBinaryFile({\n  file,\n  isSidebar,\n  isFullscreen,\n  onClose,\n  onToggleFullscreen,\n  title,\n  message,\n}: CodeEditorBinaryFileProps) {\n  const binaryContent = (\n    <div className=\"flex h-full w-full flex-col items-center justify-center bg-background p-8 text-muted-foreground\">\n      <div className=\"flex max-w-md flex-col items-center gap-4 text-center\">\n        <div className=\"flex h-16 w-16 items-center justify-center rounded-full bg-muted\">\n          <svg className=\"h-8 w-8 text-muted-foreground\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n            <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={1.5} d=\"M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z\" />\n          </svg>\n        </div>\n        <div>\n          <h3 className=\"mb-2 text-lg font-medium text-foreground\">{title}</h3>\n          <p className=\"text-sm text-muted-foreground\">{message}</p>\n        </div>\n        <button\n          onClick={onClose}\n          className=\"mt-4 rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground transition-colors hover:bg-primary/90\"\n        >\n          Close\n        </button>\n      </div>\n    </div>\n  );\n\n  if (isSidebar) {\n    return (\n      <div className=\"flex h-full w-full flex-col bg-background\">\n        <div className=\"flex flex-shrink-0 items-center justify-between border-b border-border px-3 py-1.5\">\n          <div className=\"flex min-w-0 flex-1 items-center gap-2\">\n            <h3 className=\"truncate text-sm font-medium text-gray-900 dark:text-white\">{file.name}</h3>\n          </div>\n          <button\n            type=\"button\"\n            onClick={onClose}\n            className=\"flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white\"\n            title=\"Close\"\n          >\n            <svg className=\"h-4 w-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n              <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M6 18L18 6M6 6l12 12\" />\n            </svg>\n          </button>\n        </div>\n        {binaryContent}\n      </div>\n    );\n  }\n\n  const containerClassName = isFullscreen\n    ? 'fixed inset-0 z-[9999] bg-background flex flex-col'\n    : 'fixed inset-0 z-[9999] md:bg-black/50 md:flex md:items-center md:justify-center md:p-4';\n\n  const innerClassName = isFullscreen\n    ? 'bg-background flex flex-col w-full h-full'\n    : 'bg-background shadow-2xl flex flex-col w-full h-full md:rounded-lg md:shadow-2xl md:w-full md:max-w-2xl md:h-auto md:max-h-[60vh]';\n\n  return (\n    <div className={containerClassName}>\n      <div className={innerClassName}>\n        <div className=\"flex flex-shrink-0 items-center justify-between border-b border-border px-3 py-1.5\">\n          <div className=\"flex min-w-0 flex-1 items-center gap-2\">\n            <h3 className=\"truncate text-sm font-medium text-gray-900 dark:text-white\">{file.name}</h3>\n          </div>\n          <div className=\"flex shrink-0 items-center gap-0.5\">\n            <button\n              type=\"button\"\n              onClick={onToggleFullscreen}\n              className=\"flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white\"\n              title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}\n            >\n              {isFullscreen ? (\n                <svg className=\"h-4 w-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                  <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M9 9V4.5M9 9H4.5M9 9L3.5 3.5M9 15v4.5M9 15H4.5M9 15l-5.5 5.5M15 9h4.5M15 9V4.5M15 9l5.5-5.5M15 15h4.5M15 15v4.5m0-4.5l5.5 5.5\" />\n                </svg>\n              ) : (\n                <svg className=\"h-4 w-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                  <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4\" />\n                </svg>\n              )}\n            </button>\n            <button\n              type=\"button\"\n              onClick={onClose}\n              className=\"flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white\"\n              title=\"Close\"\n            >\n              <svg className=\"h-4 w-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M6 18L18 6M6 6l12 12\" />\n              </svg>\n            </button>\n          </div>\n        </div>\n        {binaryContent}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/code-editor/view/subcomponents/CodeEditorFooter.tsx",
    "content": "type CodeEditorFooterProps = {\n  content: string;\n  linesLabel: string;\n  charactersLabel: string;\n  shortcutsLabel: string;\n};\n\nexport default function CodeEditorFooter({\n  content,\n  linesLabel,\n  charactersLabel,\n  shortcutsLabel,\n}: CodeEditorFooterProps) {\n  return (\n    <div className=\"flex flex-shrink-0 items-center justify-between border-t border-border bg-muted px-3 py-1.5\">\n      <div className=\"flex items-center gap-3 text-xs text-gray-600 dark:text-gray-400\">\n        <span>\n          {linesLabel} {content.split('\\n').length}\n        </span>\n        <span>\n          {charactersLabel} {content.length}\n        </span>\n      </div>\n\n      <div className=\"text-xs text-gray-500 dark:text-gray-400\">{shortcutsLabel}</div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/code-editor/view/subcomponents/CodeEditorHeader.tsx",
    "content": "import { Code2, Download, Eye, Maximize2, Minimize2, Save, Settings as SettingsIcon, X } from 'lucide-react';\nimport type { CodeEditorFile } from '../../types/types';\n\ntype CodeEditorHeaderProps = {\n  file: CodeEditorFile;\n  isSidebar: boolean;\n  isFullscreen: boolean;\n  isMarkdownFile: boolean;\n  markdownPreview: boolean;\n  saving: boolean;\n  saveSuccess: boolean;\n  onToggleMarkdownPreview: () => void;\n  onOpenSettings: () => void;\n  onDownload: () => void;\n  onSave: () => void;\n  onToggleFullscreen: () => void;\n  onClose: () => void;\n  labels: {\n    showingChanges: string;\n    editMarkdown: string;\n    previewMarkdown: string;\n    settings: string;\n    download: string;\n    save: string;\n    saving: string;\n    saved: string;\n    fullscreen: string;\n    exitFullscreen: string;\n    close: string;\n  };\n};\n\nexport default function CodeEditorHeader({\n  file,\n  isSidebar,\n  isFullscreen,\n  isMarkdownFile,\n  markdownPreview,\n  saving,\n  saveSuccess,\n  onToggleMarkdownPreview,\n  onOpenSettings,\n  onDownload,\n  onSave,\n  onToggleFullscreen,\n  onClose,\n  labels,\n}: CodeEditorHeaderProps) {\n  const saveTitle = saveSuccess ? labels.saved : saving ? labels.saving : labels.save;\n\n  return (\n    <div className=\"flex min-w-0 flex-shrink-0 items-center justify-between gap-2 border-b border-border px-3 py-1.5\">\n      {/* File info - can shrink */}\n      <div className=\"flex min-w-0 flex-1 shrink items-center gap-2\">\n        <div className=\"min-w-0 shrink\">\n          <div className=\"flex min-w-0 items-center gap-2\">\n            <h3 className=\"truncate text-sm font-medium text-gray-900 dark:text-white\">{file.name}</h3>\n            {file.diffInfo && (\n              <span className=\"shrink-0 whitespace-nowrap rounded bg-blue-100 px-1.5 py-0.5 text-[10px] text-blue-600 dark:bg-blue-900 dark:text-blue-300\">\n                {labels.showingChanges}\n              </span>\n            )}\n          </div>\n          <p className=\"truncate text-xs text-gray-500 dark:text-gray-400\">{file.path}</p>\n        </div>\n      </div>\n\n      {/* Buttons - don't shrink, always visible */}\n      <div className=\"flex shrink-0 items-center gap-0.5\">\n        {isMarkdownFile && (\n          <button\n            type=\"button\"\n            onClick={onToggleMarkdownPreview}\n            className={`flex items-center justify-center rounded-md p-1.5 transition-colors ${\n              markdownPreview\n                ? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'\n                : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white'\n            }`}\n            title={markdownPreview ? labels.editMarkdown : labels.previewMarkdown}\n          >\n            {markdownPreview ? <Code2 className=\"h-4 w-4\" /> : <Eye className=\"h-4 w-4\" />}\n          </button>\n        )}\n\n        <button\n          type=\"button\"\n          onClick={onOpenSettings}\n          className=\"flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white\"\n          title={labels.settings}\n        >\n          <SettingsIcon className=\"h-4 w-4\" />\n        </button>\n\n        <button\n          type=\"button\"\n          onClick={onDownload}\n          className=\"flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white\"\n          title={labels.download}\n        >\n          <Download className=\"h-4 w-4\" />\n        </button>\n\n        <button\n          type=\"button\"\n          onClick={onSave}\n          disabled={saving}\n          className={`flex items-center justify-center rounded-md p-1.5 transition-colors disabled:opacity-50 ${\n            saveSuccess\n              ? 'bg-green-50 text-green-600 dark:bg-green-900/30 dark:text-green-400'\n              : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white'\n          }`}\n          title={saveTitle}\n        >\n          {saveSuccess ? (\n            <svg className=\"h-4 w-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n              <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M5 13l4 4L19 7\" />\n            </svg>\n          ) : (\n            <Save className=\"h-4 w-4\" />\n          )}\n        </button>\n\n        {!isSidebar && (\n          <button\n            type=\"button\"\n            onClick={onToggleFullscreen}\n            className=\"flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white\"\n            title={isFullscreen ? labels.exitFullscreen : labels.fullscreen}\n          >\n            {isFullscreen ? <Minimize2 className=\"h-4 w-4\" /> : <Maximize2 className=\"h-4 w-4\" />}\n          </button>\n        )}\n\n        <button\n          type=\"button\"\n          onClick={onClose}\n          className=\"flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white\"\n          title={labels.close}\n        >\n          <X className=\"h-4 w-4\" />\n        </button>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/code-editor/view/subcomponents/CodeEditorLoadingState.tsx",
    "content": "import { getEditorLoadingStyles } from '../../utils/editorStyles';\n\ntype CodeEditorLoadingStateProps = {\n  isDarkMode: boolean;\n  isSidebar: boolean;\n  loadingText: string;\n};\n\nexport default function CodeEditorLoadingState({\n  isDarkMode,\n  isSidebar,\n  loadingText,\n}: CodeEditorLoadingStateProps) {\n  return (\n    <>\n      <style>{getEditorLoadingStyles(isDarkMode)}</style>\n      {isSidebar ? (\n        <div className=\"flex h-full w-full items-center justify-center bg-background\">\n          <div className=\"flex items-center gap-3\">\n            <div className=\"h-6 w-6 animate-spin rounded-full border-b-2 border-blue-600\" />\n            <span className=\"text-gray-900 dark:text-white\">{loadingText}</span>\n          </div>\n        </div>\n      ) : (\n        <div className=\"fixed inset-0 z-[9999] md:flex md:items-center md:justify-center md:bg-black/50\">\n          <div className=\"code-editor-loading flex h-full w-full items-center justify-center p-8 md:h-auto md:w-auto md:rounded-lg\">\n            <div className=\"flex items-center gap-3\">\n              <div className=\"h-6 w-6 animate-spin rounded-full border-b-2 border-blue-600\" />\n              <span className=\"text-gray-900 dark:text-white\">{loadingText}</span>\n            </div>\n          </div>\n        </div>\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "src/components/code-editor/view/subcomponents/CodeEditorSurface.tsx",
    "content": "import CodeMirror from '@uiw/react-codemirror';\nimport { oneDark } from '@codemirror/theme-one-dark';\nimport type { Extension } from '@codemirror/state';\nimport MarkdownPreview from './markdown/MarkdownPreview';\n\ntype CodeEditorSurfaceProps = {\n  content: string;\n  onChange: (value: string) => void;\n  markdownPreview: boolean;\n  isMarkdownFile: boolean;\n  isDarkMode: boolean;\n  fontSize: number;\n  showLineNumbers: boolean;\n  extensions: Extension[];\n};\n\nexport default function CodeEditorSurface({\n  content,\n  onChange,\n  markdownPreview,\n  isMarkdownFile,\n  isDarkMode,\n  fontSize,\n  showLineNumbers,\n  extensions,\n}: CodeEditorSurfaceProps) {\n  if (markdownPreview && isMarkdownFile) {\n    return (\n      <div className=\"h-full overflow-y-auto bg-white dark:bg-gray-900\">\n        <div className=\"prose prose-sm mx-auto max-w-4xl max-w-none px-8 py-6 dark:prose-invert prose-headings:font-semibold prose-a:text-blue-600 prose-code:text-sm prose-pre:bg-gray-900 prose-img:rounded-lg dark:prose-a:text-blue-400\">\n          <MarkdownPreview content={content} />\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <CodeMirror\n      value={content}\n      onChange={onChange}\n      extensions={extensions}\n      theme={isDarkMode ? oneDark : undefined}\n      height=\"100%\"\n      style={{\n        fontSize: `${fontSize}px`,\n        height: '100%',\n      }}\n      basicSetup={{\n        lineNumbers: showLineNumbers,\n        foldGutter: true,\n        dropCursor: false,\n        allowMultipleSelections: false,\n        indentOnInput: true,\n        bracketMatching: true,\n        closeBrackets: true,\n        autocompletion: true,\n        highlightSelectionMatches: true,\n        searchKeymap: true,\n      }}\n    />\n  );\n}\n"
  },
  {
    "path": "src/components/code-editor/view/subcomponents/markdown/MarkdownCodeBlock.tsx",
    "content": "import { useState } from 'react';\nimport type { ComponentProps } from 'react';\nimport { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';\nimport { oneDark as prismOneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';\nimport { copyTextToClipboard } from '../../../../../utils/clipboard';\n\ntype MarkdownCodeBlockProps = {\n  inline?: boolean;\n  node?: unknown;\n} & ComponentProps<'code'>;\n\nexport default function MarkdownCodeBlock({\n  inline,\n  className,\n  children,\n  node: _node,\n  ...props\n}: MarkdownCodeBlockProps) {\n  const [copied, setCopied] = useState(false);\n  const rawContent = Array.isArray(children) ? children.join('') : String(children ?? '');\n  const looksMultiline = /[\\r\\n]/.test(rawContent);\n  const shouldRenderInline = inline || !looksMultiline;\n\n  if (shouldRenderInline) {\n    return (\n      <code\n        className={`whitespace-pre-wrap break-words rounded-md border border-gray-200 bg-gray-100 px-1.5 py-0.5 font-mono text-[0.9em] text-gray-900 dark:border-gray-700 dark:bg-gray-800/60 dark:text-gray-100 ${className || ''}`}\n        {...props}\n      >\n        {children}\n      </code>\n    );\n  }\n\n  const languageMatch = /language-(\\w+)/.exec(className || '');\n  const language = languageMatch ? languageMatch[1] : 'text';\n\n  return (\n    <div className=\"group relative my-2\">\n      {language !== 'text' && (\n        <div className=\"absolute left-3 top-2 z-10 text-xs font-medium uppercase text-gray-400\">{language}</div>\n      )}\n\n      <button\n        type=\"button\"\n        onClick={() =>\n          copyTextToClipboard(rawContent).then((success) => {\n            if (success) {\n              setCopied(true);\n              setTimeout(() => setCopied(false), 2000);\n            }\n          })}\n        className=\"absolute right-2 top-2 z-10 rounded-md border border-gray-600 bg-gray-700/80 px-2 py-1 text-xs text-white opacity-0 transition-opacity hover:bg-gray-700 group-hover:opacity-100\"\n      >\n        {copied ? 'Copied!' : 'Copy'}\n      </button>\n\n      <SyntaxHighlighter\n        language={language}\n        style={prismOneDark}\n        customStyle={{\n          margin: 0,\n          borderRadius: '0.5rem',\n          fontSize: '0.875rem',\n          padding: language !== 'text' ? '2rem 1rem 1rem 1rem' : '1rem',\n        }}\n      >\n        {rawContent}\n      </SyntaxHighlighter>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/code-editor/view/subcomponents/markdown/MarkdownPreview.tsx",
    "content": "import { useMemo } from 'react';\nimport type { Components } from 'react-markdown';\nimport ReactMarkdown from 'react-markdown';\nimport rehypeKatex from 'rehype-katex';\nimport remarkGfm from 'remark-gfm';\nimport remarkMath from 'remark-math';\nimport MarkdownCodeBlock from './MarkdownCodeBlock';\n\ntype MarkdownPreviewProps = {\n  content: string;\n};\n\nconst markdownPreviewComponents: Components = {\n  code: MarkdownCodeBlock,\n  blockquote: ({ children }) => (\n    <blockquote className=\"my-2 border-l-4 border-gray-300 pl-4 italic text-gray-600 dark:border-gray-600 dark:text-gray-400\">\n      {children}\n    </blockquote>\n  ),\n  a: ({ href, children }) => (\n    <a href={href} className=\"text-blue-600 hover:underline dark:text-blue-400\" target=\"_blank\" rel=\"noopener noreferrer\">\n      {children}\n    </a>\n  ),\n  table: ({ children }) => (\n    <div className=\"my-2 overflow-x-auto\">\n      <table className=\"min-w-full border-collapse border border-gray-200 dark:border-gray-700\">{children}</table>\n    </div>\n  ),\n  thead: ({ children }) => <thead className=\"bg-gray-50 dark:bg-gray-800\">{children}</thead>,\n  th: ({ children }) => (\n    <th className=\"border border-gray-200 px-3 py-2 text-left text-sm font-semibold dark:border-gray-700\">{children}</th>\n  ),\n  td: ({ children }) => (\n    <td className=\"border border-gray-200 px-3 py-2 align-top text-sm dark:border-gray-700\">{children}</td>\n  ),\n};\n\nexport default function MarkdownPreview({ content }: MarkdownPreviewProps) {\n  const remarkPlugins = useMemo(() => [remarkGfm, remarkMath], []);\n  const rehypePlugins = useMemo(() => [rehypeKatex], []);\n\n  return (\n    <ReactMarkdown\n      remarkPlugins={remarkPlugins}\n      rehypePlugins={rehypePlugins}\n      components={markdownPreviewComponents}\n    >\n      {content}\n    </ReactMarkdown>\n  );\n}\n"
  },
  {
    "path": "src/components/file-tree/constants/constants.ts",
    "content": "import type { FileTreeViewMode } from '../types/types';\n\nexport const FILE_TREE_VIEW_MODE_STORAGE_KEY = 'file-tree-view-mode';\n\nexport const FILE_TREE_DEFAULT_VIEW_MODE: FileTreeViewMode = 'detailed';\n\nexport const FILE_TREE_VIEW_MODES: FileTreeViewMode[] = ['simple', 'compact', 'detailed'];\n\nexport const IMAGE_FILE_EXTENSIONS = new Set([\n  'png',\n  'jpg',\n  'jpeg',\n  'gif',\n  'svg',\n  'webp',\n  'ico',\n  'bmp',\n]);\n"
  },
  {
    "path": "src/components/file-tree/constants/fileIcons.ts",
    "content": "import {\n  Archive,\n  Binary,\n  Blocks,\n  BookOpen,\n  Box,\n  Braces,\n  Code2,\n  Cog,\n  Coffee,\n  Cpu,\n  Database,\n  File,\n  FileCheck,\n  FileCode,\n  FileCode2,\n  FileSpreadsheet,\n  FileText,\n  FileType,\n  Flame,\n  FlaskConical,\n  Gem,\n  Globe,\n  Hash,\n  Hexagon,\n  Image,\n  Lock,\n  Music2,\n  NotebookPen,\n  Palette,\n  Scroll,\n  Settings,\n  Shield,\n  SquareFunction,\n  Terminal,\n  Video,\n  Workflow,\n} from 'lucide-react';\nimport type { FileIconData, FileIconMap } from '../types/types';\n\nexport const ICON_SIZE_CLASS = 'w-4 h-4 flex-shrink-0';\n\nconst FILE_ICON_MAP: FileIconMap = {\n  js: { icon: FileCode, color: 'text-yellow-500' },\n  jsx: { icon: FileCode, color: 'text-yellow-500' },\n  mjs: { icon: FileCode, color: 'text-yellow-500' },\n  cjs: { icon: FileCode, color: 'text-yellow-500' },\n  ts: { icon: FileCode2, color: 'text-blue-500' },\n  tsx: { icon: FileCode2, color: 'text-blue-500' },\n  mts: { icon: FileCode2, color: 'text-blue-500' },\n  py: { icon: Code2, color: 'text-emerald-500' },\n  pyw: { icon: Code2, color: 'text-emerald-500' },\n  pyi: { icon: Code2, color: 'text-emerald-400' },\n  ipynb: { icon: NotebookPen, color: 'text-orange-500' },\n  rs: { icon: Cog, color: 'text-orange-600' },\n  toml: { icon: Settings, color: 'text-gray-500' },\n  go: { icon: Hexagon, color: 'text-cyan-500' },\n  rb: { icon: Gem, color: 'text-red-500' },\n  erb: { icon: Gem, color: 'text-red-400' },\n  php: { icon: Blocks, color: 'text-violet-500' },\n  java: { icon: Coffee, color: 'text-red-600' },\n  jar: { icon: Coffee, color: 'text-red-500' },\n  kt: { icon: Hexagon, color: 'text-violet-500' },\n  kts: { icon: Hexagon, color: 'text-violet-400' },\n  c: { icon: Cpu, color: 'text-blue-600' },\n  h: { icon: Cpu, color: 'text-blue-400' },\n  cpp: { icon: Cpu, color: 'text-blue-700' },\n  hpp: { icon: Cpu, color: 'text-blue-500' },\n  cc: { icon: Cpu, color: 'text-blue-700' },\n  cs: { icon: Hexagon, color: 'text-purple-600' },\n  swift: { icon: Flame, color: 'text-orange-500' },\n  lua: { icon: SquareFunction, color: 'text-blue-500' },\n  r: { icon: FlaskConical, color: 'text-blue-600' },\n  html: { icon: Globe, color: 'text-orange-600' },\n  htm: { icon: Globe, color: 'text-orange-600' },\n  css: { icon: Hash, color: 'text-blue-500' },\n  scss: { icon: Hash, color: 'text-pink-500' },\n  sass: { icon: Hash, color: 'text-pink-400' },\n  less: { icon: Hash, color: 'text-indigo-500' },\n  vue: { icon: FileCode2, color: 'text-emerald-500' },\n  svelte: { icon: FileCode2, color: 'text-orange-500' },\n  json: { icon: Braces, color: 'text-yellow-600' },\n  jsonc: { icon: Braces, color: 'text-yellow-500' },\n  json5: { icon: Braces, color: 'text-yellow-500' },\n  yaml: { icon: Settings, color: 'text-purple-400' },\n  yml: { icon: Settings, color: 'text-purple-400' },\n  xml: { icon: FileCode, color: 'text-orange-500' },\n  csv: { icon: FileSpreadsheet, color: 'text-green-600' },\n  tsv: { icon: FileSpreadsheet, color: 'text-green-500' },\n  sql: { icon: Database, color: 'text-blue-500' },\n  graphql: { icon: Workflow, color: 'text-pink-500' },\n  gql: { icon: Workflow, color: 'text-pink-500' },\n  proto: { icon: Box, color: 'text-green-500' },\n  env: { icon: Shield, color: 'text-yellow-600' },\n  md: { icon: BookOpen, color: 'text-blue-500' },\n  mdx: { icon: BookOpen, color: 'text-blue-400' },\n  txt: { icon: FileText, color: 'text-gray-500' },\n  doc: { icon: FileText, color: 'text-blue-600' },\n  docx: { icon: FileText, color: 'text-blue-600' },\n  pdf: { icon: FileCheck, color: 'text-red-600' },\n  rtf: { icon: FileText, color: 'text-gray-500' },\n  tex: { icon: Scroll, color: 'text-teal-600' },\n  rst: { icon: FileText, color: 'text-gray-400' },\n  sh: { icon: Terminal, color: 'text-green-500' },\n  bash: { icon: Terminal, color: 'text-green-500' },\n  zsh: { icon: Terminal, color: 'text-green-400' },\n  fish: { icon: Terminal, color: 'text-green-400' },\n  ps1: { icon: Terminal, color: 'text-blue-400' },\n  bat: { icon: Terminal, color: 'text-gray-500' },\n  cmd: { icon: Terminal, color: 'text-gray-500' },\n  png: { icon: Image, color: 'text-purple-500' },\n  jpg: { icon: Image, color: 'text-purple-500' },\n  jpeg: { icon: Image, color: 'text-purple-500' },\n  gif: { icon: Image, color: 'text-purple-400' },\n  webp: { icon: Image, color: 'text-purple-400' },\n  ico: { icon: Image, color: 'text-purple-400' },\n  bmp: { icon: Image, color: 'text-purple-400' },\n  tiff: { icon: Image, color: 'text-purple-400' },\n  svg: { icon: Palette, color: 'text-amber-500' },\n  mp3: { icon: Music2, color: 'text-pink-500' },\n  wav: { icon: Music2, color: 'text-pink-500' },\n  ogg: { icon: Music2, color: 'text-pink-400' },\n  flac: { icon: Music2, color: 'text-pink-400' },\n  aac: { icon: Music2, color: 'text-pink-400' },\n  m4a: { icon: Music2, color: 'text-pink-400' },\n  mp4: { icon: Video, color: 'text-rose-500' },\n  mov: { icon: Video, color: 'text-rose-500' },\n  avi: { icon: Video, color: 'text-rose-500' },\n  webm: { icon: Video, color: 'text-rose-400' },\n  mkv: { icon: Video, color: 'text-rose-400' },\n  ttf: { icon: FileType, color: 'text-red-500' },\n  otf: { icon: FileType, color: 'text-red-500' },\n  woff: { icon: FileType, color: 'text-red-400' },\n  woff2: { icon: FileType, color: 'text-red-400' },\n  eot: { icon: FileType, color: 'text-red-400' },\n  zip: { icon: Archive, color: 'text-amber-600' },\n  tar: { icon: Archive, color: 'text-amber-600' },\n  gz: { icon: Archive, color: 'text-amber-600' },\n  bz2: { icon: Archive, color: 'text-amber-600' },\n  rar: { icon: Archive, color: 'text-amber-500' },\n  '7z': { icon: Archive, color: 'text-amber-500' },\n  lock: { icon: Lock, color: 'text-gray-500' },\n  exe: { icon: Binary, color: 'text-gray-500' },\n  bin: { icon: Binary, color: 'text-gray-500' },\n  dll: { icon: Binary, color: 'text-gray-400' },\n  so: { icon: Binary, color: 'text-gray-400' },\n  dylib: { icon: Binary, color: 'text-gray-400' },\n  wasm: { icon: Binary, color: 'text-purple-500' },\n  ini: { icon: Settings, color: 'text-gray-500' },\n  cfg: { icon: Settings, color: 'text-gray-500' },\n  conf: { icon: Settings, color: 'text-gray-500' },\n  log: { icon: Scroll, color: 'text-gray-400' },\n  map: { icon: File, color: 'text-gray-400' },\n};\n\nconst FILENAME_ICON_MAP: FileIconMap = {\n  Dockerfile: { icon: Box, color: 'text-blue-500' },\n  'docker-compose.yml': { icon: Box, color: 'text-blue-500' },\n  'docker-compose.yaml': { icon: Box, color: 'text-blue-500' },\n  '.dockerignore': { icon: Box, color: 'text-gray-500' },\n  '.gitignore': { icon: Settings, color: 'text-gray-500' },\n  '.gitmodules': { icon: Settings, color: 'text-gray-500' },\n  '.gitattributes': { icon: Settings, color: 'text-gray-500' },\n  '.editorconfig': { icon: Settings, color: 'text-gray-500' },\n  '.prettierrc': { icon: Settings, color: 'text-pink-400' },\n  '.prettierignore': { icon: Settings, color: 'text-gray-500' },\n  '.eslintrc': { icon: Settings, color: 'text-violet-500' },\n  '.eslintrc.js': { icon: Settings, color: 'text-violet-500' },\n  '.eslintrc.json': { icon: Settings, color: 'text-violet-500' },\n  '.eslintrc.cjs': { icon: Settings, color: 'text-violet-500' },\n  'eslint.config.js': { icon: Settings, color: 'text-violet-500' },\n  'eslint.config.mjs': { icon: Settings, color: 'text-violet-500' },\n  '.env': { icon: Shield, color: 'text-yellow-600' },\n  '.env.local': { icon: Shield, color: 'text-yellow-600' },\n  '.env.development': { icon: Shield, color: 'text-yellow-500' },\n  '.env.production': { icon: Shield, color: 'text-yellow-600' },\n  '.env.example': { icon: Shield, color: 'text-yellow-400' },\n  'package.json': { icon: Braces, color: 'text-green-500' },\n  'package-lock.json': { icon: Lock, color: 'text-gray-500' },\n  'yarn.lock': { icon: Lock, color: 'text-blue-400' },\n  'pnpm-lock.yaml': { icon: Lock, color: 'text-orange-400' },\n  'bun.lockb': { icon: Lock, color: 'text-gray-400' },\n  'Cargo.toml': { icon: Cog, color: 'text-orange-600' },\n  'Cargo.lock': { icon: Lock, color: 'text-orange-400' },\n  Gemfile: { icon: Gem, color: 'text-red-500' },\n  'Gemfile.lock': { icon: Lock, color: 'text-red-400' },\n  Makefile: { icon: Terminal, color: 'text-gray-500' },\n  'CMakeLists.txt': { icon: Cog, color: 'text-blue-500' },\n  'tsconfig.json': { icon: Braces, color: 'text-blue-500' },\n  'jsconfig.json': { icon: Braces, color: 'text-yellow-500' },\n  'vite.config.ts': { icon: Flame, color: 'text-purple-500' },\n  'vite.config.js': { icon: Flame, color: 'text-purple-500' },\n  'webpack.config.js': { icon: Cog, color: 'text-blue-500' },\n  'tailwind.config.js': { icon: Hash, color: 'text-cyan-500' },\n  'tailwind.config.ts': { icon: Hash, color: 'text-cyan-500' },\n  'postcss.config.js': { icon: Cog, color: 'text-red-400' },\n  'babel.config.js': { icon: Settings, color: 'text-yellow-500' },\n  '.babelrc': { icon: Settings, color: 'text-yellow-500' },\n  'README.md': { icon: BookOpen, color: 'text-blue-500' },\n  LICENSE: { icon: FileCheck, color: 'text-gray-500' },\n  'LICENSE.md': { icon: FileCheck, color: 'text-gray-500' },\n  'CHANGELOG.md': { icon: Scroll, color: 'text-blue-400' },\n  'requirements.txt': { icon: FileText, color: 'text-emerald-400' },\n  'go.mod': { icon: Hexagon, color: 'text-cyan-500' },\n  'go.sum': { icon: Lock, color: 'text-cyan-400' },\n};\n\n// Icon resolution is deterministic: exact filename, then .env prefixes, then extension, then fallback.\nexport function getFileIconData(filename: string): FileIconData {\n  if (FILENAME_ICON_MAP[filename]) {\n    return FILENAME_ICON_MAP[filename];\n  }\n\n  if (filename.startsWith('.env')) {\n    return { icon: Shield, color: 'text-yellow-600' };\n  }\n\n  const extension = filename.split('.').pop()?.toLowerCase();\n  if (extension && FILE_ICON_MAP[extension]) {\n    return FILE_ICON_MAP[extension];\n  }\n\n  return { icon: File, color: 'text-muted-foreground' };\n}\n"
  },
  {
    "path": "src/components/file-tree/hooks/useExpandedDirectories.ts",
    "content": "import { useCallback, useState } from 'react';\n\ntype UseExpandedDirectoriesResult = {\n  expandedDirs: Set<string>;\n  toggleDirectory: (path: string) => void;\n  expandDirectories: (paths: string[]) => void;\n  collapseAll: () => void;\n};\n\nexport function useExpandedDirectories(): UseExpandedDirectoriesResult {\n  const [expandedDirs, setExpandedDirs] = useState<Set<string>>(() => new Set());\n\n  const toggleDirectory = useCallback((path: string) => {\n    setExpandedDirs((previous) => {\n      const next = new Set(previous);\n\n      if (next.has(path)) {\n        next.delete(path);\n      } else {\n        next.add(path);\n      }\n\n      return next;\n    });\n  }, []);\n\n  const expandDirectories = useCallback((paths: string[]) => {\n    if (paths.length === 0) {\n      return;\n    }\n\n    setExpandedDirs((previous) => {\n      const next = new Set(previous);\n      paths.forEach((path) => next.add(path));\n      return next;\n    });\n  }, []);\n\n  const collapseAll = useCallback(() => {\n    setExpandedDirs(new Set());\n  }, []);\n\n  return {\n    expandedDirs,\n    toggleDirectory,\n    expandDirectories,\n    collapseAll,\n  };\n}\n\n"
  },
  {
    "path": "src/components/file-tree/hooks/useFileTreeData.ts",
    "content": "import { useCallback, useEffect, useRef, useState } from 'react';\nimport { api } from '../../../utils/api';\nimport type { Project } from '../../../types/app';\nimport type { FileTreeNode } from '../types/types';\n\ntype UseFileTreeDataResult = {\n  files: FileTreeNode[];\n  loading: boolean;\n  refreshFiles: () => void;\n};\n\nexport function useFileTreeData(selectedProject: Project | null): UseFileTreeDataResult {\n  const [files, setFiles] = useState<FileTreeNode[]>([]);\n  const [loading, setLoading] = useState(false);\n  const [refreshKey, setRefreshKey] = useState(0);\n  const abortControllerRef = useRef<AbortController | null>(null);\n\n  const refreshFiles = useCallback(() => {\n    setRefreshKey((prev) => prev + 1);\n  }, []);\n\n  useEffect(() => {\n    const projectName = selectedProject?.name;\n\n    if (!projectName) {\n      setFiles([]);\n      setLoading(false);\n      return;\n    }\n\n    // Abort previous request\n    if (abortControllerRef.current) {\n      abortControllerRef.current.abort();\n    }\n    abortControllerRef.current = new AbortController();\n\n    // Track mount state so aborted or late responses do not enqueue stale state updates.\n    let isActive = true;\n\n    const fetchFiles = async () => {\n      if (isActive) {\n        setLoading(true);\n      }\n      try {\n        const response = await api.getFiles(projectName, { signal: abortControllerRef.current!.signal });\n\n        if (!response.ok) {\n          const errorText = await response.text();\n          console.error('File fetch failed:', response.status, errorText);\n          if (isActive) {\n            setFiles([]);\n          }\n          return;\n        }\n\n        const data = (await response.json()) as FileTreeNode[];\n        if (isActive) {\n          setFiles(data);\n        }\n      } catch (error) {\n        if ((error as { name?: string }).name === 'AbortError') {\n          return;\n        }\n\n        console.error('Error fetching files:', error);\n        if (isActive) {\n          setFiles([]);\n        }\n      } finally {\n        if (isActive) {\n          setLoading(false);\n        }\n      }\n    };\n\n    void fetchFiles();\n\n    return () => {\n      isActive = false;\n      abortControllerRef.current?.abort();\n    };\n  }, [selectedProject?.name, refreshKey]);\n\n  return {\n    files,\n    loading,\n    refreshFiles,\n  };\n}\n"
  },
  {
    "path": "src/components/file-tree/hooks/useFileTreeOperations.ts",
    "content": "import { useCallback, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport JSZip from 'jszip';\nimport { api } from '../../../utils/api';\nimport type { FileTreeNode } from '../types/types';\nimport type { Project } from '../../../types/app';\n\n// Invalid filename characters\nconst INVALID_FILENAME_CHARS = /[<>:\"/\\\\|?*\\x00-\\x1f]/;\nconst RESERVED_NAMES = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i;\n\nexport type ToastMessage = {\n  message: string;\n  type: 'success' | 'error';\n};\n\nexport type DeleteConfirmation = {\n  isOpen: boolean;\n  item: FileTreeNode | null;\n};\n\nexport type UseFileTreeOperationsOptions = {\n  selectedProject: Project | null;\n  onRefresh: () => void;\n  showToast: (message: string, type: 'success' | 'error') => void;\n};\n\nexport type UseFileTreeOperationsResult = {\n  // Rename operations\n  renamingItem: FileTreeNode | null;\n  renameValue: string;\n  handleStartRename: (item: FileTreeNode) => void;\n  handleCancelRename: () => void;\n  handleConfirmRename: () => Promise<void>;\n  setRenameValue: (value: string) => void;\n\n  // Delete operations\n  deleteConfirmation: DeleteConfirmation;\n  handleStartDelete: (item: FileTreeNode) => void;\n  handleCancelDelete: () => void;\n  handleConfirmDelete: () => Promise<void>;\n\n  // Create operations\n  isCreating: boolean;\n  newItemParent: string;\n  newItemType: 'file' | 'directory';\n  newItemName: string;\n  handleStartCreate: (parentPath: string, type: 'file' | 'directory') => void;\n  handleCancelCreate: () => void;\n  handleConfirmCreate: () => Promise<void>;\n  setNewItemName: (name: string) => void;\n\n  // Other operations\n  handleCopyPath: (item: FileTreeNode) => void;\n  handleDownload: (item: FileTreeNode) => Promise<void>;\n\n  // Loading state\n  operationLoading: boolean;\n\n  // Validation\n  validateFilename: (name: string) => string | null;\n};\n\nexport function useFileTreeOperations({\n  selectedProject,\n  onRefresh,\n  showToast,\n}: UseFileTreeOperationsOptions): UseFileTreeOperationsResult {\n  const { t } = useTranslation();\n\n  // State\n  const [renamingItem, setRenamingItem] = useState<FileTreeNode | null>(null);\n  const [renameValue, setRenameValue] = useState('');\n  const [deleteConfirmation, setDeleteConfirmation] = useState<DeleteConfirmation>({\n    isOpen: false,\n    item: null,\n  });\n  const [isCreating, setIsCreating] = useState(false);\n  const [newItemParent, setNewItemParent] = useState('');\n  const [newItemType, setNewItemType] = useState<'file' | 'directory'>('file');\n  const [newItemName, setNewItemName] = useState('');\n  const [operationLoading, setOperationLoading] = useState(false);\n\n  // Validation\n  const validateFilename = useCallback((name: string): string | null => {\n    if (!name || !name.trim()) {\n      return t('fileTree.validation.emptyName', 'Filename cannot be empty');\n    }\n    if (INVALID_FILENAME_CHARS.test(name)) {\n      return t('fileTree.validation.invalidChars', 'Filename contains invalid characters');\n    }\n    if (RESERVED_NAMES.test(name)) {\n      return t('fileTree.validation.reserved', 'Filename is a reserved name');\n    }\n    if (/^\\.+$/.test(name)) {\n      return t('fileTree.validation.dotsOnly', 'Filename cannot be only dots');\n    }\n    return null;\n  }, [t]);\n\n  // Rename operations\n  const handleStartRename = useCallback((item: FileTreeNode) => {\n    setRenamingItem(item);\n    setRenameValue(item.name);\n    setIsCreating(false);\n  }, []);\n\n  const handleCancelRename = useCallback(() => {\n    setRenamingItem(null);\n    setRenameValue('');\n  }, []);\n\n  const handleConfirmRename = useCallback(async () => {\n    if (!renamingItem || !selectedProject) return;\n\n    const error = validateFilename(renameValue);\n    if (error) {\n      showToast(error, 'error');\n      return;\n    }\n\n    if (renameValue === renamingItem.name) {\n      handleCancelRename();\n      return;\n    }\n\n    setOperationLoading(true);\n    try {\n      const response = await api.renameFile(selectedProject.name, {\n        oldPath: renamingItem.path,\n        newName: renameValue,\n      });\n\n      if (!response.ok) {\n        const data = await response.json();\n        throw new Error(data.error || 'Failed to rename');\n      }\n\n      showToast(t('fileTree.toast.renamed', 'Renamed successfully'), 'success');\n      onRefresh();\n      handleCancelRename();\n    } catch (err) {\n      showToast((err as Error).message, 'error');\n    } finally {\n      setOperationLoading(false);\n    }\n  }, [renamingItem, renameValue, selectedProject, validateFilename, showToast, t, onRefresh, handleCancelRename]);\n\n  // Delete operations\n  const handleStartDelete = useCallback((item: FileTreeNode) => {\n    setDeleteConfirmation({ isOpen: true, item });\n  }, []);\n\n  const handleCancelDelete = useCallback(() => {\n    setDeleteConfirmation({ isOpen: false, item: null });\n  }, []);\n\n  const handleConfirmDelete = useCallback(async () => {\n    const { item } = deleteConfirmation;\n    if (!item || !selectedProject) return;\n\n    setOperationLoading(true);\n    try {\n      const response = await api.deleteFile(selectedProject.name, {\n        path: item.path,\n        type: item.type,\n      });\n\n      if (!response.ok) {\n        const data = await response.json();\n        throw new Error(data.error || 'Failed to delete');\n      }\n\n      showToast(\n        item.type === 'directory'\n          ? t('fileTree.toast.folderDeleted', 'Folder deleted')\n          : t('fileTree.toast.fileDeleted', 'File deleted'),\n        'success'\n      );\n      onRefresh();\n      handleCancelDelete();\n    } catch (err) {\n      showToast((err as Error).message, 'error');\n    } finally {\n      setOperationLoading(false);\n    }\n  }, [deleteConfirmation, selectedProject, showToast, t, onRefresh, handleCancelDelete]);\n\n  // Create operations\n  const handleStartCreate = useCallback((parentPath: string, type: 'file' | 'directory') => {\n    setNewItemParent(parentPath || '');\n    setNewItemType(type);\n    setNewItemName(type === 'file' ? 'untitled.txt' : 'new-folder');\n    setIsCreating(true);\n    setRenamingItem(null);\n  }, []);\n\n  const handleCancelCreate = useCallback(() => {\n    setIsCreating(false);\n    setNewItemParent('');\n    setNewItemName('');\n  }, []);\n\n  const handleConfirmCreate = useCallback(async () => {\n    if (!selectedProject) return;\n\n    const error = validateFilename(newItemName);\n    if (error) {\n      showToast(error, 'error');\n      return;\n    }\n\n    setOperationLoading(true);\n    try {\n      const response = await api.createFile(selectedProject.name, {\n        path: newItemParent,\n        type: newItemType,\n        name: newItemName,\n      });\n\n      if (!response.ok) {\n        const data = await response.json();\n        throw new Error(data.error || 'Failed to create');\n      }\n\n      showToast(\n        newItemType === 'file'\n          ? t('fileTree.toast.fileCreated', 'File created successfully')\n          : t('fileTree.toast.folderCreated', 'Folder created successfully'),\n        'success'\n      );\n      onRefresh();\n      handleCancelCreate();\n    } catch (err) {\n      showToast((err as Error).message, 'error');\n    } finally {\n      setOperationLoading(false);\n    }\n  }, [selectedProject, newItemParent, newItemType, newItemName, validateFilename, showToast, t, onRefresh, handleCancelCreate]);\n\n  // Copy path to clipboard\n  const handleCopyPath = useCallback((item: FileTreeNode) => {\n    navigator.clipboard.writeText(item.path).catch(() => {\n      // Clipboard API may fail in some contexts (e.g., non-HTTPS)\n      showToast(t('fileTree.toast.copyFailed', 'Failed to copy path'), 'error');\n      return;\n    });\n    showToast(t('fileTree.toast.pathCopied', 'Path copied to clipboard'), 'success');\n  }, [showToast, t]);\n\n  // Download file or folder\n  const handleDownload = useCallback(async (item: FileTreeNode) => {\n    if (!selectedProject) return;\n\n    setOperationLoading(true);\n    try {\n      if (item.type === 'directory') {\n        // Download folder as ZIP\n        await downloadFolderAsZip(item);\n      } else {\n        // Download single file\n        await downloadSingleFile(item);\n      }\n    } catch (err) {\n      showToast((err as Error).message, 'error');\n    } finally {\n      setOperationLoading(false);\n    }\n  }, [selectedProject, showToast]);\n\n  // Download a single file\n  const downloadSingleFile = useCallback(async (item: FileTreeNode) => {\n    if (!selectedProject) return;\n\n    const response = await api.readFile(selectedProject.name, item.path);\n\n    if (!response.ok) {\n      throw new Error('Failed to download file');\n    }\n\n    const data = await response.json();\n    const content = data.content;\n\n    const blob = new Blob([content], { type: 'text/plain' });\n    const url = URL.createObjectURL(blob);\n    const anchor = document.createElement('a');\n\n    anchor.href = url;\n    anchor.download = item.name;\n\n    document.body.appendChild(anchor);\n    anchor.click();\n    document.body.removeChild(anchor);\n\n    URL.revokeObjectURL(url);\n  }, [selectedProject]);\n\n  // Download folder as ZIP\n  const downloadFolderAsZip = useCallback(async (folder: FileTreeNode) => {\n    if (!selectedProject) return;\n\n    const zip = new JSZip();\n\n    // Recursively get all files in the folder\n    const collectFiles = async (node: FileTreeNode, currentPath: string) => {\n      const fullPath = currentPath ? `${currentPath}/${node.name}` : node.name;\n\n      if (node.type === 'file') {\n        // Fetch file content\n        const response = await api.readFile(selectedProject.name, node.path);\n        if (response.ok) {\n          const data = await response.json();\n          zip.file(fullPath, data.content);\n        }\n      } else if (node.type === 'directory' && node.children) {\n        // Recursively process children\n        for (const child of node.children) {\n          await collectFiles(child, fullPath);\n        }\n      }\n    };\n\n    // If the folder has children, process them\n    if (folder.children && folder.children.length > 0) {\n      for (const child of folder.children) {\n        await collectFiles(child, '');\n      }\n    }\n\n    // Generate ZIP file\n    const zipBlob = await zip.generateAsync({ type: 'blob' });\n    const url = URL.createObjectURL(zipBlob);\n    const anchor = document.createElement('a');\n\n    anchor.href = url;\n    anchor.download = `${folder.name}.zip`;\n\n    document.body.appendChild(anchor);\n    anchor.click();\n    document.body.removeChild(anchor);\n\n    URL.revokeObjectURL(url);\n\n    showToast(t('fileTree.toast.folderDownloaded', 'Folder downloaded as ZIP'), 'success');\n  }, [selectedProject, showToast, t]);\n\n  return {\n    // Rename operations\n    renamingItem,\n    renameValue,\n    handleStartRename,\n    handleCancelRename,\n    handleConfirmRename,\n    setRenameValue,\n\n    // Delete operations\n    deleteConfirmation,\n    handleStartDelete,\n    handleCancelDelete,\n    handleConfirmDelete,\n\n    // Create operations\n    isCreating,\n    newItemParent,\n    newItemType,\n    newItemName,\n    handleStartCreate,\n    handleCancelCreate,\n    handleConfirmCreate,\n    setNewItemName,\n\n    // Other operations\n    handleCopyPath,\n    handleDownload,\n\n    // Loading state\n    operationLoading,\n\n    // Validation\n    validateFilename,\n  };\n}\n"
  },
  {
    "path": "src/components/file-tree/hooks/useFileTreeSearch.ts",
    "content": "import { useEffect, useState } from 'react';\nimport { collectExpandedDirectoryPaths, filterFileTree } from '../utils/fileTreeUtils';\nimport type { FileTreeNode } from '../types/types';\n\ntype UseFileTreeSearchArgs = {\n  files: FileTreeNode[];\n  expandDirectories: (paths: string[]) => void;\n};\n\ntype UseFileTreeSearchResult = {\n  searchQuery: string;\n  setSearchQuery: (query: string) => void;\n  filteredFiles: FileTreeNode[];\n};\n\nexport function useFileTreeSearch({\n  files,\n  expandDirectories,\n}: UseFileTreeSearchArgs): UseFileTreeSearchResult {\n  const [searchQuery, setSearchQuery] = useState('');\n  const [filteredFiles, setFilteredFiles] = useState<FileTreeNode[]>(files);\n\n  useEffect(() => {\n    const query = searchQuery.trim().toLowerCase();\n\n    if (!query) {\n      setFilteredFiles(files);\n      return;\n    }\n\n    const filtered = filterFileTree(files, query);\n    setFilteredFiles(filtered);\n    // Keep search results visible by opening every matching ancestor directory once per query update.\n    expandDirectories(collectExpandedDirectoryPaths(filtered));\n  }, [files, searchQuery, expandDirectories]);\n\n  return {\n    searchQuery,\n    setSearchQuery,\n    filteredFiles,\n  };\n}\n"
  },
  {
    "path": "src/components/file-tree/hooks/useFileTreeUpload.ts",
    "content": "import { useCallback, useState, useRef } from 'react';\nimport type { Project } from '../../../types/app';\nimport { api } from '../../../utils/api';\n\ntype UseFileTreeUploadOptions = {\n  selectedProject: Project | null;\n  onRefresh: () => void;\n  showToast: (message: string, type: 'success' | 'error') => void;\n};\n\n// Helper function to read all files from a directory entry recursively\nconst readAllDirectoryEntries = async (directoryEntry: FileSystemDirectoryEntry, basePath = ''): Promise<File[]> => {\n  const files: File[] = [];\n\n  const reader = directoryEntry.createReader();\n  let entries: FileSystemEntry[] = [];\n\n  // Read all entries from the directory (may need multiple reads)\n  let batch: FileSystemEntry[];\n  do {\n    batch = await new Promise<FileSystemEntry[]>((resolve, reject) => {\n      reader.readEntries(resolve, reject);\n    });\n    entries = entries.concat(batch);\n  } while (batch.length > 0);\n\n  // Files to ignore (system files)\n  const ignoredFiles = ['.DS_Store', 'Thumbs.db', 'desktop.ini'];\n\n  for (const entry of entries) {\n    const entryPath = basePath ? `${basePath}/${entry.name}` : entry.name;\n\n    if (entry.isFile) {\n      const fileEntry = entry as FileSystemFileEntry;\n      const file = await new Promise<File>((resolve, reject) => {\n        fileEntry.file(resolve, reject);\n      });\n\n      // Skip ignored files\n      if (ignoredFiles.includes(file.name)) {\n        continue;\n      }\n\n      // Create a new file with the relative path as the name\n      const fileWithPath = new File([file], entryPath, {\n        type: file.type,\n        lastModified: file.lastModified,\n      });\n      files.push(fileWithPath);\n    } else if (entry.isDirectory) {\n      const dirEntry = entry as FileSystemDirectoryEntry;\n      const subFiles = await readAllDirectoryEntries(dirEntry, entryPath);\n      files.push(...subFiles);\n    }\n  }\n\n  return files;\n};\n\nexport const useFileTreeUpload = ({\n  selectedProject,\n  onRefresh,\n  showToast,\n}: UseFileTreeUploadOptions) => {\n  const [isDragOver, setIsDragOver] = useState(false);\n  const [dropTarget, setDropTarget] = useState<string | null>(null);\n  const [operationLoading, setOperationLoading] = useState(false);\n  const treeRef = useRef<HTMLDivElement>(null);\n\n  const handleDragEnter = useCallback((e: React.DragEvent) => {\n    e.preventDefault();\n    e.stopPropagation();\n    setIsDragOver(true);\n  }, []);\n\n  const handleDragOver = useCallback((e: React.DragEvent) => {\n    e.preventDefault();\n    e.stopPropagation();\n  }, []);\n\n  const handleDragLeave = useCallback((e: React.DragEvent) => {\n    e.preventDefault();\n    e.stopPropagation();\n    // Only set isDragOver to false if we're leaving the entire tree\n    if (treeRef.current && !treeRef.current.contains(e.relatedTarget as Node)) {\n      setIsDragOver(false);\n      setDropTarget(null);\n    }\n  }, []);\n\n  const handleDrop = useCallback(async (e: React.DragEvent) => {\n    e.preventDefault();\n    e.stopPropagation();\n    setIsDragOver(false);\n\n    const targetPath = dropTarget || '';\n    setOperationLoading(true);\n\n    try {\n      const files: File[] = [];\n\n      // Use DataTransferItemList for folder support\n      const items = e.dataTransfer.items;\n      if (items) {\n        for (const item of Array.from(items)) {\n          if (item.kind === 'file') {\n            const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null;\n\n            if (entry) {\n              if (entry.isFile) {\n                const file = await new Promise<File>((resolve, reject) => {\n                  (entry as FileSystemFileEntry).file(resolve, reject);\n                });\n                files.push(file);\n              } else if (entry.isDirectory) {\n                // Pass the directory name as basePath so files include the folder path\n                const dirFiles = await readAllDirectoryEntries(entry as FileSystemDirectoryEntry, entry.name);\n                files.push(...dirFiles);\n              }\n            }\n          }\n        }\n      } else {\n        // Fallback for browsers that don't support webkitGetAsEntry\n        const fileList = e.dataTransfer.files;\n        for (const file of Array.from(fileList)) {\n          files.push(file);\n        }\n      }\n\n      if (files.length === 0) {\n        setOperationLoading(false);\n        setDropTarget(null);\n        return;\n      }\n\n      const formData = new FormData();\n      formData.append('targetPath', targetPath);\n\n      // Store relative paths separately since FormData strips path info from File.name\n      const relativePaths: string[] = [];\n      files.forEach((file) => {\n        // Create a new file with just the filename (without path) for FormData\n        // but store the relative path separately\n        const cleanFile = new File([file], file.name.split('/').pop()!, {\n          type: file.type,\n          lastModified: file.lastModified\n        });\n        formData.append('files', cleanFile);\n        relativePaths.push(file.name); // Keep the full relative path\n      });\n\n      // Send relative paths as a JSON array\n      formData.append('relativePaths', JSON.stringify(relativePaths));\n\n      const response = await api.post(\n        `/projects/${encodeURIComponent(selectedProject!.name)}/files/upload`,\n        formData\n      );\n\n      if (!response.ok) {\n        const data = await response.json();\n        throw new Error(data.error || 'Upload failed');\n      }\n\n      showToast(\n        `Uploaded ${files.length} file(s)`,\n        'success'\n      );\n      onRefresh();\n    } catch (err) {\n      console.error('Upload error:', err);\n      showToast(err instanceof Error ? err.message : 'Upload failed', 'error');\n    } finally {\n      setOperationLoading(false);\n      setDropTarget(null);\n    }\n  }, [dropTarget, selectedProject, onRefresh, showToast]);\n\n  const handleItemDragOver = useCallback((e: React.DragEvent, itemPath: string) => {\n    e.preventDefault();\n    e.stopPropagation();\n    setDropTarget(itemPath);\n  }, []);\n\n  const handleItemDrop = useCallback((e: React.DragEvent, itemPath: string) => {\n    e.preventDefault();\n    e.stopPropagation();\n    setDropTarget(itemPath);\n  }, []);\n\n  return {\n    isDragOver,\n    dropTarget,\n    operationLoading,\n    treeRef,\n    handleDragEnter,\n    handleDragOver,\n    handleDragLeave,\n    handleDrop,\n    handleItemDragOver,\n    handleItemDrop,\n    setDropTarget,\n  };\n};\n"
  },
  {
    "path": "src/components/file-tree/hooks/useFileTreeViewMode.ts",
    "content": "import { useCallback, useEffect, useState } from 'react';\nimport {\n  FILE_TREE_DEFAULT_VIEW_MODE,\n  FILE_TREE_VIEW_MODES,\n  FILE_TREE_VIEW_MODE_STORAGE_KEY,\n} from '../constants/constants';\nimport type { FileTreeViewMode } from '../types/types';\n\ntype UseFileTreeViewModeResult = {\n  viewMode: FileTreeViewMode;\n  changeViewMode: (mode: FileTreeViewMode) => void;\n};\n\nexport function useFileTreeViewMode(): UseFileTreeViewModeResult {\n  const [viewMode, setViewMode] = useState<FileTreeViewMode>(FILE_TREE_DEFAULT_VIEW_MODE);\n\n  useEffect(() => {\n    try {\n      const savedViewMode = localStorage.getItem(FILE_TREE_VIEW_MODE_STORAGE_KEY);\n      if (savedViewMode && FILE_TREE_VIEW_MODES.includes(savedViewMode as FileTreeViewMode)) {\n        setViewMode(savedViewMode as FileTreeViewMode);\n      }\n    } catch {\n      // Keep default view mode when storage is unavailable.\n    }\n  }, []);\n\n  const changeViewMode = useCallback((mode: FileTreeViewMode) => {\n    setViewMode(mode);\n\n    try {\n      localStorage.setItem(FILE_TREE_VIEW_MODE_STORAGE_KEY, mode);\n    } catch {\n      // Keep runtime state even when persistence fails.\n    }\n  }, []);\n\n  return {\n    viewMode,\n    changeViewMode,\n  };\n}\n\n"
  },
  {
    "path": "src/components/file-tree/types/types.ts",
    "content": "import type { LucideIcon } from 'lucide-react';\n\nexport type FileTreeViewMode = 'simple' | 'compact' | 'detailed';\n\nexport type FileTreeItemType = 'file' | 'directory';\n\nexport interface FileTreeNode {\n  name: string;\n  type: FileTreeItemType;\n  path: string;\n  size?: number;\n  modified?: string;\n  permissionsRwx?: string;\n  children?: FileTreeNode[];\n  [key: string]: unknown;\n}\n\nexport interface FileTreeImageSelection {\n  name: string;\n  path: string;\n  projectPath?: string;\n  projectName: string;\n}\n\nexport interface FileIconData {\n  icon: LucideIcon;\n  color: string;\n}\n\nexport type FileIconMap = Record<string, FileIconData>;\n"
  },
  {
    "path": "src/components/file-tree/utils/fileTreeUtils.ts",
    "content": "import type { TFunction } from 'i18next';\nimport { IMAGE_FILE_EXTENSIONS } from '../constants/constants';\nimport type { FileTreeNode } from '../types/types';\n\nexport function filterFileTree(items: FileTreeNode[], query: string): FileTreeNode[] {\n  return items.reduce<FileTreeNode[]>((filteredItems, item) => {\n    const matchesName = item.name.toLowerCase().includes(query);\n    const filteredChildren =\n      item.type === 'directory' && item.children ? filterFileTree(item.children, query) : [];\n\n    if (matchesName || filteredChildren.length > 0) {\n      filteredItems.push({\n        ...item,\n        children: filteredChildren,\n      });\n    }\n\n    return filteredItems;\n  }, []);\n}\n\n// During search we auto-expand every directory present in the filtered subtree.\nexport function collectExpandedDirectoryPaths(items: FileTreeNode[]): string[] {\n  const paths: string[] = [];\n\n  const visit = (nodes: FileTreeNode[]) => {\n    nodes.forEach((node) => {\n      if (node.type === 'directory' && node.children && node.children.length > 0) {\n        paths.push(node.path);\n        visit(node.children);\n      }\n    });\n  };\n\n  visit(items);\n  return paths;\n}\n\nexport function formatFileSize(bytes?: number): string {\n  if (!bytes || bytes === 0) {\n    return '0 B';\n  }\n\n  const base = 1024;\n  const sizes = ['B', 'KB', 'MB', 'GB'];\n  const index = Math.floor(Math.log(bytes) / Math.log(base));\n\n  return `${(bytes / Math.pow(base, index)).toFixed(1).replace(/\\.0$/, '')} ${sizes[index]}`;\n}\n\nexport function formatRelativeTime(date: string | undefined, t: TFunction): string {\n  if (!date) {\n    return '-';\n  }\n\n  const now = new Date();\n  const past = new Date(date);\n  const diffInSeconds = Math.floor((now.getTime() - past.getTime()) / 1000);\n\n  if (diffInSeconds < 60) {\n    return t('fileTree.justNow');\n  }\n\n  if (diffInSeconds < 3600) {\n    return t('fileTree.minAgo', { count: Math.floor(diffInSeconds / 60) });\n  }\n\n  if (diffInSeconds < 86400) {\n    return t('fileTree.hoursAgo', { count: Math.floor(diffInSeconds / 3600) });\n  }\n\n  if (diffInSeconds < 2592000) {\n    return t('fileTree.daysAgo', { count: Math.floor(diffInSeconds / 86400) });\n  }\n\n  return past.toLocaleDateString();\n}\n\nexport function isImageFile(filename: string): boolean {\n  const extension = filename.split('.').pop()?.toLowerCase();\n  return Boolean(extension && IMAGE_FILE_EXTENSIONS.has(extension));\n}\n\n"
  },
  {
    "path": "src/components/file-tree/view/FileContextMenu.tsx",
    "content": "import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Copy, Download, FileText, FolderPlus, Pencil, RefreshCw, Trash2, type LucideIcon } from 'lucide-react';\nimport { cn } from '../../../lib/utils';\n\ntype FileContextItem = {\n  name: string;\n  type: 'file' | 'directory';\n  path: string;\n  size?: number;\n  modified?: string;\n  permissionsRwx?: string;\n  children?: FileContextItem[];\n  [key: string]: unknown;\n};\n\ntype ContextMenuAction = {\n  key: string;\n  label: string;\n  icon?: LucideIcon;\n  onSelect?: () => void;\n  isDanger?: boolean;\n  isDisabled?: boolean;\n  shortcut?: string;\n  showDividerBefore?: boolean;\n};\n\nconst CONTEXT_MENU_WIDTH = 200;\nconst CONTEXT_MENU_HEIGHT = 300;\nconst VIEWPORT_PADDING = 10;\n\nfunction calculateViewportSafePosition(clientX: number, clientY: number) {\n  // Keep the context menu inside the visible viewport.\n  const safeX =\n    clientX + CONTEXT_MENU_WIDTH > window.innerWidth\n      ? window.innerWidth - CONTEXT_MENU_WIDTH - VIEWPORT_PADDING\n      : clientX;\n  const safeY =\n    clientY + CONTEXT_MENU_HEIGHT > window.innerHeight\n      ? window.innerHeight - CONTEXT_MENU_HEIGHT - VIEWPORT_PADDING\n      : clientY;\n\n  return { x: Math.max(VIEWPORT_PADDING, safeX), y: Math.max(VIEWPORT_PADDING, safeY) };\n}\n\nexport default function FileContextMenu({\n  children,\n  item,\n  onRename,\n  onDelete,\n  onNewFile,\n  onNewFolder,\n  onRefresh,\n  onCopyPath,\n  onDownload,\n  isLoading = false,\n  className = '',\n}: {\n  children: ReactNode;\n  item?: FileContextItem | null;\n  onRename?: (item: FileContextItem) => void;\n  onDelete?: (item: FileContextItem) => void;\n  onNewFile?: (path: string) => void;\n  onNewFolder?: (path: string) => void;\n  onRefresh?: () => void;\n  onCopyPath?: (item: FileContextItem) => void;\n  onDownload?: (item: FileContextItem) => void;\n  isLoading?: boolean;\n  className?: string;\n}) {\n  const { t } = useTranslation();\n  const [isMenuOpen, setIsMenuOpen] = useState(false);\n  const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });\n  const menuRef = useRef<HTMLDivElement>(null);\n\n  const closeContextMenu = useCallback(() => {\n    setIsMenuOpen(false);\n  }, []);\n\n  const openContextMenuAtCursor = useCallback((event: ReactMouseEvent<HTMLDivElement>) => {\n    event.preventDefault();\n    event.stopPropagation();\n\n    setMenuPosition(calculateViewportSafePosition(event.clientX, event.clientY));\n    setIsMenuOpen(true);\n  }, []);\n\n  const runMenuActionAndClose = useCallback((action?: () => void) => {\n    closeContextMenu();\n    action?.();\n  }, [closeContextMenu]);\n\n  const menuActions = useMemo<ContextMenuAction[]>(() => {\n    if (item?.type === 'file') {\n      return [\n        {\n          key: 'rename',\n          icon: Pencil,\n          label: t('fileTree.context.rename', 'Rename'),\n          onSelect: () => onRename?.(item),\n        },\n        {\n          key: 'delete',\n          icon: Trash2,\n          label: t('fileTree.context.delete', 'Delete'),\n          onSelect: () => onDelete?.(item),\n          isDanger: true,\n        },\n        {\n          key: 'copyPath',\n          icon: Copy,\n          label: t('fileTree.context.copyPath', 'Copy Path'),\n          onSelect: () => onCopyPath?.(item),\n          showDividerBefore: true,\n        },\n        {\n          key: 'download',\n          icon: Download,\n          label: t('fileTree.context.download', 'Download'),\n          onSelect: () => onDownload?.(item),\n        },\n      ];\n    }\n\n    if (item?.type === 'directory') {\n      return [\n        {\n          key: 'newFile',\n          icon: FileText,\n          label: t('fileTree.context.newFile', 'New File'),\n          onSelect: () => onNewFile?.(item.path),\n        },\n        {\n          key: 'newFolder',\n          icon: FolderPlus,\n          label: t('fileTree.context.newFolder', 'New Folder'),\n          onSelect: () => onNewFolder?.(item.path),\n        },\n        {\n          key: 'rename',\n          icon: Pencil,\n          label: t('fileTree.context.rename', 'Rename'),\n          onSelect: () => onRename?.(item),\n          showDividerBefore: true,\n        },\n        {\n          key: 'delete',\n          icon: Trash2,\n          label: t('fileTree.context.delete', 'Delete'),\n          onSelect: () => onDelete?.(item),\n          isDanger: true,\n        },\n        {\n          key: 'copyPath',\n          icon: Copy,\n          label: t('fileTree.context.copyPath', 'Copy Path'),\n          onSelect: () => onCopyPath?.(item),\n          showDividerBefore: true,\n        },\n        {\n          key: 'download',\n          icon: Download,\n          label: t('fileTree.context.download', 'Download'),\n          onSelect: () => onDownload?.(item),\n        },\n      ];\n    }\n\n    return [\n      {\n        key: 'newFile',\n        icon: FileText,\n        label: t('fileTree.context.newFile', 'New File'),\n        onSelect: () => onNewFile?.(''),\n      },\n      {\n        key: 'newFolder',\n        icon: FolderPlus,\n        label: t('fileTree.context.newFolder', 'New Folder'),\n        onSelect: () => onNewFolder?.(''),\n      },\n      {\n        key: 'refresh',\n        icon: RefreshCw,\n        label: t('fileTree.context.refresh', 'Refresh'),\n        onSelect: onRefresh,\n        showDividerBefore: true,\n      },\n    ];\n  }, [item, onCopyPath, onDelete, onDownload, onNewFile, onNewFolder, onRefresh, onRename, t]);\n\n  useEffect(() => {\n    if (!isMenuOpen) {\n      return;\n    }\n\n    const handleOutsideMouseDown = (event: MouseEvent) => {\n      const menuElement = menuRef.current;\n      if (menuElement && !menuElement.contains(event.target as Node)) {\n        closeContextMenu();\n      }\n    };\n\n    const handleEscapeKeyDown = (event: KeyboardEvent) => {\n      if (event.key === 'Escape') {\n        closeContextMenu();\n      }\n    };\n\n    document.addEventListener('mousedown', handleOutsideMouseDown);\n    document.addEventListener('keydown', handleEscapeKeyDown);\n\n    return () => {\n      document.removeEventListener('mousedown', handleOutsideMouseDown);\n      document.removeEventListener('keydown', handleEscapeKeyDown);\n    };\n  }, [closeContextMenu, isMenuOpen]);\n\n  useEffect(() => {\n    if (!isMenuOpen) {\n      return;\n    }\n\n    // Arrow key support keeps the menu accessible without a mouse.\n    const handleKeyboardMenuNavigation = (event: KeyboardEvent) => {\n      const menuItems = menuRef.current?.querySelectorAll<HTMLElement>('[role=\"menuitem\"]:not([disabled])');\n      if (!menuItems || menuItems.length === 0) {\n        return;\n      }\n\n      const activeElement = document.activeElement as HTMLElement | null;\n      const currentIndex = Array.from(menuItems).findIndex((menuItem) => menuItem === activeElement);\n\n      if (event.key === 'ArrowDown') {\n        event.preventDefault();\n        const nextIndex = currentIndex < menuItems.length - 1 ? currentIndex + 1 : 0;\n        menuItems[nextIndex]?.focus();\n      } else if (event.key === 'ArrowUp') {\n        event.preventDefault();\n        const previousIndex = currentIndex > 0 ? currentIndex - 1 : menuItems.length - 1;\n        menuItems[previousIndex]?.focus();\n      } else if (event.key === 'Enter' || event.key === ' ') {\n        if (activeElement?.hasAttribute('role')) {\n          event.preventDefault();\n          activeElement.click();\n        }\n      }\n    };\n\n    document.addEventListener('keydown', handleKeyboardMenuNavigation);\n\n    return () => {\n      document.removeEventListener('keydown', handleKeyboardMenuNavigation);\n    };\n  }, [isMenuOpen]);\n\n  return (\n    <>\n      <div onContextMenu={openContextMenuAtCursor} className={cn('contents', className)}>\n        {children}\n      </div>\n\n      {isMenuOpen && (\n        <div\n          ref={menuRef}\n          role=\"menu\"\n          aria-label={t('fileTree.context.menuLabel', 'File context menu')}\n          style={{ position: 'fixed', left: menuPosition.x, top: menuPosition.y, zIndex: 9999 }}\n          className={cn(\n            'min-w-[180px] py-1 px-1',\n            'bg-popover border border-border rounded-lg shadow-lg',\n            'animate-in fade-in-0 zoom-in-95',\n            'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',\n          )}\n        >\n          {isLoading ? (\n            <div className=\"flex items-center justify-center py-4\">\n              <RefreshCw className=\"h-4 w-4 animate-spin text-muted-foreground\" />\n              <span className=\"ml-2 text-sm text-muted-foreground\">{t('fileTree.context.loading', 'Loading...')}</span>\n            </div>\n          ) : (\n            menuActions.map((action) => (\n              <Fragment key={action.key}>\n                {action.showDividerBefore && <div className=\"mx-2 my-1 h-px bg-border\" />}\n                <button\n                  role=\"menuitem\"\n                  tabIndex={action.isDisabled ? -1 : 0}\n                  disabled={isLoading || action.isDisabled}\n                  onClick={() => runMenuActionAndClose(action.onSelect)}\n                  className={cn(\n                    'w-full flex items-center gap-3 px-3 py-2 text-sm text-left rounded-md transition-colors',\n                    'focus:outline-none focus:bg-accent',\n                    action.isDisabled\n                      ? 'opacity-50 cursor-not-allowed'\n                      : action.isDanger\n                      ? 'text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950'\n                      : 'hover:bg-accent',\n                    isLoading && 'pointer-events-none',\n                  )}\n                >\n                  {action.icon && <action.icon className=\"h-4 w-4 flex-shrink-0\" />}\n                  <span className=\"flex-1\">{action.label}</span>\n                  {action.shortcut && <span className=\"font-mono text-xs text-muted-foreground\">{action.shortcut}</span>}\n                </button>\n              </Fragment>\n            ))\n          )}\n        </div>\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "src/components/file-tree/view/FileTree.tsx",
    "content": "import { useCallback, useState, useEffect, useRef } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { AlertTriangle, Check, X, Loader2, Folder, Upload } from 'lucide-react';\nimport { cn } from '../../../lib/utils';\nimport { ICON_SIZE_CLASS, getFileIconData } from '../constants/fileIcons';\nimport { useExpandedDirectories } from '../hooks/useExpandedDirectories';\nimport { useFileTreeData } from '../hooks/useFileTreeData';\nimport { useFileTreeOperations } from '../hooks/useFileTreeOperations';\nimport { useFileTreeSearch } from '../hooks/useFileTreeSearch';\nimport { useFileTreeViewMode } from '../hooks/useFileTreeViewMode';\nimport { useFileTreeUpload } from '../hooks/useFileTreeUpload';\nimport type { FileTreeImageSelection, FileTreeNode } from '../types/types';\nimport { formatFileSize, formatRelativeTime, isImageFile } from '../utils/fileTreeUtils';\nimport { Project } from '../../../types/app';\nimport { ScrollArea, Input } from '../../../shared/view/ui';\nimport FileTreeBody from './FileTreeBody';\nimport FileTreeDetailedColumns from './FileTreeDetailedColumns';\nimport FileTreeHeader from './FileTreeHeader';\nimport FileTreeLoadingState from './FileTreeLoadingState';\nimport ImageViewer from './ImageViewer';\n\n\ntype FileTreeProps = {\n  selectedProject: Project | null;\n  onFileOpen?: (filePath: string) => void;\n};\n\nexport default function FileTree({ selectedProject, onFileOpen }: FileTreeProps) {\n  const { t } = useTranslation();\n  const [selectedImage, setSelectedImage] = useState<FileTreeImageSelection | null>(null);\n  const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);\n  const newItemInputRef = useRef<HTMLInputElement>(null);\n  const renameInputRef = useRef<HTMLInputElement>(null);\n\n  // Show toast notification\n  const showToast = useCallback((message: string, type: 'success' | 'error') => {\n    setToast({ message, type });\n  }, []);\n\n  // Auto-hide toast\n  useEffect(() => {\n    if (toast) {\n      const timer = setTimeout(() => setToast(null), 3000);\n      return () => clearTimeout(timer);\n    }\n  }, [toast]);\n\n  const { files, loading, refreshFiles } = useFileTreeData(selectedProject);\n  const { viewMode, changeViewMode } = useFileTreeViewMode();\n  const { expandedDirs, toggleDirectory, expandDirectories, collapseAll } = useExpandedDirectories();\n  const { searchQuery, setSearchQuery, filteredFiles } = useFileTreeSearch({\n    files,\n    expandDirectories,\n  });\n\n  // File operations\n  const operations = useFileTreeOperations({\n    selectedProject,\n    onRefresh: refreshFiles,\n    showToast,\n  });\n\n  // File upload (drag and drop)\n  const upload = useFileTreeUpload({\n    selectedProject,\n    onRefresh: refreshFiles,\n    showToast,\n  });\n\n  // Focus input when creating new item\n  useEffect(() => {\n    if (operations.isCreating && newItemInputRef.current) {\n      newItemInputRef.current.focus();\n      newItemInputRef.current.select();\n    }\n  }, [operations.isCreating]);\n\n  // Focus input when renaming\n  useEffect(() => {\n    if (operations.renamingItem && renameInputRef.current) {\n      renameInputRef.current.focus();\n      renameInputRef.current.select();\n    }\n  }, [operations.renamingItem]);\n\n  const renderFileIcon = useCallback((filename: string) => {\n    const { icon: Icon, color } = getFileIconData(filename);\n    return <Icon className={cn(ICON_SIZE_CLASS, color)} />;\n  }, []);\n\n  // Centralized click behavior keeps file actions identical across all presentation modes.\n  const handleItemClick = useCallback(\n    (item: FileTreeNode) => {\n      if (item.type === 'directory') {\n        toggleDirectory(item.path);\n        return;\n      }\n\n      if (isImageFile(item.name) && selectedProject) {\n        setSelectedImage({\n          name: item.name,\n          path: item.path,\n          projectPath: selectedProject.path,\n          projectName: selectedProject.name,\n        });\n        return;\n      }\n\n      onFileOpen?.(item.path);\n    },\n    [onFileOpen, selectedProject, toggleDirectory],\n  );\n\n  const formatRelativeTimeLabel = useCallback(\n    (date?: string) => formatRelativeTime(date, t),\n    [t],\n  );\n\n  if (loading) {\n    return <FileTreeLoadingState />;\n  }\n\n  return (\n    <div\n      ref={upload.treeRef}\n      className=\"relative flex h-full flex-col bg-background\"\n      onDragEnter={upload.handleDragEnter}\n      onDragOver={upload.handleDragOver}\n      onDragLeave={upload.handleDragLeave}\n      onDrop={upload.handleDrop}\n    >\n      {/* Drag overlay */}\n      {upload.isDragOver && (\n        <div className=\"absolute inset-0 z-50 flex items-center justify-center border-2 border-dashed border-blue-500 bg-blue-500/10\">\n          <div className=\"flex items-center gap-3 rounded-lg bg-background/95 px-6 py-4 shadow-lg\">\n            <Upload className=\"h-6 w-6 text-blue-500\" />\n            <span className=\"text-sm font-medium\">{t('fileTree.dropToUpload', 'Drop files to upload')}</span>\n          </div>\n        </div>\n      )}\n\n      <FileTreeHeader\n        viewMode={viewMode}\n        onViewModeChange={changeViewMode}\n        searchQuery={searchQuery}\n        onSearchQueryChange={setSearchQuery}\n        onNewFile={() => operations.handleStartCreate('', 'file')}\n        onNewFolder={() => operations.handleStartCreate('', 'directory')}\n        onRefresh={refreshFiles}\n        onCollapseAll={collapseAll}\n        loading={loading}\n        operationLoading={operations.operationLoading}\n      />\n\n      {viewMode === 'detailed' && filteredFiles.length > 0 && <FileTreeDetailedColumns />}\n\n      <ScrollArea className=\"flex-1 px-2 py-1\">\n        {/* New item input */}\n        {operations.isCreating && (\n          <div\n            className=\"mb-1 flex items-center gap-1.5 py-[3px] pr-2\"\n            style={{ paddingLeft: `${(operations.newItemParent.split('/').length - 1) * 16 + 4}px` }}\n          >\n            {operations.newItemType === 'directory' ? (\n              <Folder className={cn(ICON_SIZE_CLASS, 'text-blue-500')} />\n            ) : (\n              <span className=\"ml-[18px]\">{renderFileIcon(operations.newItemName)}</span>\n            )}\n            <Input\n              ref={newItemInputRef}\n              type=\"text\"\n              value={operations.newItemName}\n              onChange={(e) => operations.setNewItemName(e.target.value)}\n              onKeyDown={(e) => {\n                e.stopPropagation();\n                if (e.key === 'Enter') operations.handleConfirmCreate();\n                if (e.key === 'Escape') operations.handleCancelCreate();\n              }}\n              onBlur={() => {\n                setTimeout(() => {\n                  if (operations.isCreating) operations.handleConfirmCreate();\n                }, 100);\n              }}\n              className=\"h-6 flex-1 text-sm\"\n              disabled={operations.operationLoading}\n            />\n          </div>\n        )}\n\n        <FileTreeBody\n          files={files}\n          filteredFiles={filteredFiles}\n          searchQuery={searchQuery}\n          viewMode={viewMode}\n          expandedDirs={expandedDirs}\n          onItemClick={handleItemClick}\n          renderFileIcon={renderFileIcon}\n          formatFileSize={formatFileSize}\n          formatRelativeTime={formatRelativeTimeLabel}\n          onRename={operations.handleStartRename}\n          onDelete={operations.handleStartDelete}\n          onNewFile={(path) => operations.handleStartCreate(path, 'file')}\n          onNewFolder={(path) => operations.handleStartCreate(path, 'directory')}\n          onCopyPath={operations.handleCopyPath}\n          onDownload={operations.handleDownload}\n          onRefresh={refreshFiles}\n          // Pass rename state and handlers for inline editing\n          renamingItem={operations.renamingItem}\n          renameValue={operations.renameValue}\n          setRenameValue={operations.setRenameValue}\n          handleConfirmRename={operations.handleConfirmRename}\n          handleCancelRename={operations.handleCancelRename}\n          renameInputRef={renameInputRef}\n          operationLoading={operations.operationLoading}\n        />\n      </ScrollArea>\n\n      {selectedImage && (\n        <ImageViewer\n          file={selectedImage}\n          onClose={() => setSelectedImage(null)}\n        />\n      )}\n\n      {/* Delete Confirmation Dialog */}\n      {operations.deleteConfirmation.isOpen && operations.deleteConfirmation.item && (\n        <div className=\"fixed inset-0 z-[9999] flex items-center justify-center bg-black/50\">\n          <div className=\"mx-4 max-w-sm rounded-lg border border-border bg-background p-4 shadow-lg\">\n            <div className=\"mb-4 flex items-center gap-3\">\n              <div className=\"rounded-full bg-red-100 p-2 dark:bg-red-900/30\">\n                <AlertTriangle className=\"h-5 w-5 text-red-600 dark:text-red-400\" />\n              </div>\n              <div>\n                <h3 className=\"font-medium text-foreground\">\n                  {t('fileTree.delete.title', 'Delete {{type}}', {\n                    type: operations.deleteConfirmation.item.type === 'directory' ? 'Folder' : 'File'\n                  })}\n                </h3>\n                <p className=\"text-sm text-muted-foreground\">\n                  {operations.deleteConfirmation.item.name}\n                </p>\n              </div>\n            </div>\n            <p className=\"mb-4 text-sm text-muted-foreground\">\n              {operations.deleteConfirmation.item.type === 'directory'\n                ? t('fileTree.delete.folderWarning', 'This folder and all its contents will be permanently deleted.')\n                : t('fileTree.delete.fileWarning', 'This file will be permanently deleted.')}\n            </p>\n            <div className=\"flex justify-end gap-2\">\n              <button\n                onClick={operations.handleCancelDelete}\n                disabled={operations.operationLoading}\n                className=\"rounded-md px-3 py-1.5 text-sm transition-colors hover:bg-accent\"\n              >\n                {t('common.cancel', 'Cancel')}\n              </button>\n              <button\n                onClick={operations.handleConfirmDelete}\n                disabled={operations.operationLoading}\n                className=\"flex items-center gap-2 rounded-md bg-red-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-red-700 disabled:opacity-50\"\n              >\n                {operations.operationLoading && <Loader2 className=\"h-4 w-4 animate-spin\" />}\n                {t('fileTree.delete.confirm', 'Delete')}\n              </button>\n            </div>\n          </div>\n        </div>\n      )}\n\n      {/* Toast Notification */}\n      {toast && (\n        <div\n          className={cn(\n            'fixed bottom-4 right-4 z-[9999] px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 animate-in slide-in-from-bottom-2',\n            toast.type === 'success'\n              ? 'bg-green-600 text-white'\n              : 'bg-red-600 text-white'\n          )}\n        >\n          {toast.type === 'success' ? (\n            <Check className=\"h-4 w-4\" />\n          ) : (\n            <X className=\"h-4 w-4\" />\n          )}\n          <span className=\"text-sm\">{toast.message}</span>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/file-tree/view/FileTreeBody.tsx",
    "content": "import type { ReactNode, RefObject } from 'react';\nimport { Folder, Search } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport type { FileTreeNode, FileTreeViewMode } from '../types/types';\nimport FileTreeEmptyState from './FileTreeEmptyState';\nimport FileTreeList from './FileTreeList';\n\ntype FileTreeBodyProps = {\n  files: FileTreeNode[];\n  filteredFiles: FileTreeNode[];\n  searchQuery: string;\n  viewMode: FileTreeViewMode;\n  expandedDirs: Set<string>;\n  onItemClick: (item: FileTreeNode) => void;\n  renderFileIcon: (filename: string) => ReactNode;\n  formatFileSize: (bytes?: number) => string;\n  formatRelativeTime: (date?: string) => string;\n  onRename?: (item: FileTreeNode) => void;\n  onDelete?: (item: FileTreeNode) => void;\n  onNewFile?: (path: string) => void;\n  onNewFolder?: (path: string) => void;\n  onCopyPath?: (item: FileTreeNode) => void;\n  onDownload?: (item: FileTreeNode) => void;\n  onRefresh?: () => void;\n  // Rename state for inline editing\n  renamingItem?: FileTreeNode | null;\n  renameValue?: string;\n  setRenameValue?: (value: string) => void;\n  handleConfirmRename?: () => void;\n  handleCancelRename?: () => void;\n  renameInputRef?: RefObject<HTMLInputElement>;\n  operationLoading?: boolean;\n};\n\nexport default function FileTreeBody({\n  files,\n  filteredFiles,\n  searchQuery,\n  viewMode,\n  expandedDirs,\n  onItemClick,\n  renderFileIcon,\n  formatFileSize,\n  formatRelativeTime,\n  onRename,\n  onDelete,\n  onNewFile,\n  onNewFolder,\n  onCopyPath,\n  onDownload,\n  onRefresh,\n  renamingItem,\n  renameValue,\n  setRenameValue,\n  handleConfirmRename,\n  handleCancelRename,\n  renameInputRef,\n  operationLoading,\n}: FileTreeBodyProps) {\n  const { t } = useTranslation();\n\n  return (\n    <>\n      {files.length === 0 ? (\n        <FileTreeEmptyState\n          icon={Folder}\n          title={t('fileTree.noFilesFound')}\n          description={t('fileTree.checkProjectPath')}\n        />\n      ) : filteredFiles.length === 0 && searchQuery ? (\n        <FileTreeEmptyState\n          icon={Search}\n          title={t('fileTree.noMatchesFound')}\n          description={t('fileTree.tryDifferentSearch')}\n        />\n      ) : (\n        <FileTreeList\n          items={filteredFiles}\n          viewMode={viewMode}\n          expandedDirs={expandedDirs}\n          onItemClick={onItemClick}\n          renderFileIcon={renderFileIcon}\n          formatFileSize={formatFileSize}\n          formatRelativeTime={formatRelativeTime}\n          onRename={onRename}\n          onDelete={onDelete}\n          onNewFile={onNewFile}\n          onNewFolder={onNewFolder}\n          onCopyPath={onCopyPath}\n          onDownload={onDownload}\n          onRefresh={onRefresh}\n          renamingItem={renamingItem}\n          renameValue={renameValue}\n          setRenameValue={setRenameValue}\n          handleConfirmRename={handleConfirmRename}\n          handleCancelRename={handleCancelRename}\n          renameInputRef={renameInputRef}\n          operationLoading={operationLoading}\n        />\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "src/components/file-tree/view/FileTreeDetailedColumns.tsx",
    "content": "import { useTranslation } from 'react-i18next';\n\nexport default function FileTreeDetailedColumns() {\n  const { t } = useTranslation();\n\n  return (\n    <div className=\"border-b border-border px-3 pb-1 pt-1.5\">\n      <div className=\"grid grid-cols-12 gap-2 px-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/70\">\n        <div className=\"col-span-5\">{t('fileTree.name')}</div>\n        <div className=\"col-span-2\">{t('fileTree.size')}</div>\n        <div className=\"col-span-3\">{t('fileTree.modified')}</div>\n        <div className=\"col-span-2\">{t('fileTree.permissions')}</div>\n      </div>\n    </div>\n  );\n}\n\n"
  },
  {
    "path": "src/components/file-tree/view/FileTreeEmptyState.tsx",
    "content": "import type { LucideIcon } from 'lucide-react';\n\ntype FileTreeEmptyStateProps = {\n  icon: LucideIcon;\n  title: string;\n  description: string;\n};\n\nexport default function FileTreeEmptyState({ icon: Icon, title, description }: FileTreeEmptyStateProps) {\n  return (\n    <div className=\"py-8 text-center\">\n      <div className=\"mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-lg bg-muted\">\n        <Icon className=\"h-6 w-6 text-muted-foreground\" />\n      </div>\n      <h4 className=\"mb-1 font-medium text-foreground\">{title}</h4>\n      <p className=\"text-sm text-muted-foreground\">{description}</p>\n    </div>\n  );\n}\n\n"
  },
  {
    "path": "src/components/file-tree/view/FileTreeHeader.tsx",
    "content": "import { ChevronDown, Eye, FileText, FolderPlus, List, RefreshCw, Search, TableProperties, X } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { Button, Input } from '../../../shared/view/ui';\nimport { cn } from '../../../lib/utils';\nimport type { FileTreeViewMode } from '../types/types';\n\ntype FileTreeHeaderProps = {\n  viewMode: FileTreeViewMode;\n  onViewModeChange: (mode: FileTreeViewMode) => void;\n  searchQuery: string;\n  onSearchQueryChange: (query: string) => void;\n  // Toolbar actions\n  onNewFile?: () => void;\n  onNewFolder?: () => void;\n  onRefresh?: () => void;\n  onCollapseAll?: () => void;\n  // Loading state\n  loading?: boolean;\n  operationLoading?: boolean;\n};\n\nexport default function FileTreeHeader({\n  viewMode,\n  onViewModeChange,\n  searchQuery,\n  onSearchQueryChange,\n  onNewFile,\n  onNewFolder,\n  onRefresh,\n  onCollapseAll,\n  loading,\n  operationLoading,\n}: FileTreeHeaderProps) {\n  const { t } = useTranslation();\n\n  return (\n    <div className=\"space-y-2 border-b border-border px-3 pb-2 pt-3\">\n      {/* Title and Toolbar */}\n      <div className=\"flex items-center justify-between\">\n        <h3 className=\"text-sm font-medium text-foreground\">{t('fileTree.files')}</h3>\n        <div className=\"flex items-center gap-0.5\">\n          {/* Action buttons */}\n          {onNewFile && (\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              className=\"h-7 w-7 p-0\"\n              onClick={onNewFile}\n              title={t('fileTree.newFile', 'New File (Cmd+N)')}\n              aria-label={t('fileTree.newFile', 'New File (Cmd+N)')}\n              disabled={operationLoading}\n            >\n              <FileText className=\"h-3.5 w-3.5\" />\n            </Button>\n          )}\n          {onNewFolder && (\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              className=\"h-7 w-7 p-0\"\n              onClick={onNewFolder}\n              title={t('fileTree.newFolder', 'New Folder (Cmd+Shift+N)')}\n              aria-label={t('fileTree.newFolder', 'New Folder (Cmd+Shift+N)')}\n              disabled={operationLoading}\n            >\n              <FolderPlus className=\"h-3.5 w-3.5\" />\n            </Button>\n          )}\n          {onRefresh && (\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              className=\"h-7 w-7 p-0\"\n              onClick={onRefresh}\n              title={t('fileTree.refresh', 'Refresh')}\n              aria-label={t('fileTree.refresh', 'Refresh')}\n              disabled={operationLoading}\n            >\n              <RefreshCw className={cn('w-3.5 h-3.5', loading && 'animate-spin')} />\n            </Button>\n          )}\n          {onCollapseAll && (\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              className=\"h-7 w-7 p-0\"\n              onClick={onCollapseAll}\n              title={t('fileTree.collapseAll', 'Collapse All')}\n              aria-label={t('fileTree.collapseAll', 'Collapse All')}\n            >\n              <ChevronDown className=\"h-3.5 w-3.5\" />\n            </Button>\n          )}\n          {/* Divider */}\n          <div className=\"mx-0.5 h-4 w-px bg-border\" />\n          {/* View mode buttons */}\n          <Button\n            variant={viewMode === 'simple' ? 'default' : 'ghost'}\n            size=\"sm\"\n            className=\"h-7 w-7 p-0\"\n            onClick={() => onViewModeChange('simple')}\n            title={t('fileTree.simpleView')}\n            aria-label={t('fileTree.simpleView')}\n          >\n            <List className=\"h-3.5 w-3.5\" />\n          </Button>\n          <Button\n            variant={viewMode === 'compact' ? 'default' : 'ghost'}\n            size=\"sm\"\n            className=\"h-7 w-7 p-0\"\n            onClick={() => onViewModeChange('compact')}\n            title={t('fileTree.compactView')}\n            aria-label={t('fileTree.compactView')}\n          >\n            <Eye className=\"h-3.5 w-3.5\" />\n          </Button>\n          <Button\n            variant={viewMode === 'detailed' ? 'default' : 'ghost'}\n            size=\"sm\"\n            className=\"h-7 w-7 p-0\"\n            onClick={() => onViewModeChange('detailed')}\n            title={t('fileTree.detailedView')}\n            aria-label={t('fileTree.detailedView')}\n          >\n            <TableProperties className=\"h-3.5 w-3.5\" />\n          </Button>\n        </div>\n      </div>\n\n      {/* Search Bar */}\n      <div className=\"relative\">\n        <Search className=\"absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground\" />\n        <Input\n          type=\"text\"\n          placeholder={t('fileTree.searchPlaceholder')}\n          value={searchQuery}\n          onChange={(event) => onSearchQueryChange(event.target.value)}\n          className=\"h-8 pl-8 pr-8 text-sm\"\n        />\n        {searchQuery && (\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            className=\"absolute right-0.5 top-1/2 h-5 w-5 -translate-y-1/2 p-0 hover:bg-accent\"\n            onClick={() => onSearchQueryChange('')}\n            title={t('fileTree.clearSearch')}\n            aria-label={t('fileTree.clearSearch')}\n          >\n            <X className=\"h-3 w-3\" />\n          </Button>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/file-tree/view/FileTreeList.tsx",
    "content": "import type { ReactNode, RefObject } from 'react';\nimport type { FileTreeNode as FileTreeNodeType, FileTreeViewMode } from '../types/types';\nimport FileTreeNode from './FileTreeNode';\n\ntype FileTreeListProps = {\n  items: FileTreeNodeType[];\n  viewMode: FileTreeViewMode;\n  expandedDirs: Set<string>;\n  onItemClick: (item: FileTreeNodeType) => void;\n  renderFileIcon: (filename: string) => ReactNode;\n  formatFileSize: (bytes?: number) => string;\n  formatRelativeTime: (date?: string) => string;\n  onRename?: (item: FileTreeNodeType) => void;\n  onDelete?: (item: FileTreeNodeType) => void;\n  onNewFile?: (path: string) => void;\n  onNewFolder?: (path: string) => void;\n  onCopyPath?: (item: FileTreeNodeType) => void;\n  onDownload?: (item: FileTreeNodeType) => void;\n  onRefresh?: () => void;\n  // Rename state for inline editing\n  renamingItem?: FileTreeNodeType | null;\n  renameValue?: string;\n  setRenameValue?: (value: string) => void;\n  handleConfirmRename?: () => void;\n  handleCancelRename?: () => void;\n  renameInputRef?: RefObject<HTMLInputElement>;\n  operationLoading?: boolean;\n};\n\nexport default function FileTreeList({\n  items,\n  viewMode,\n  expandedDirs,\n  onItemClick,\n  renderFileIcon,\n  formatFileSize,\n  formatRelativeTime,\n  onRename,\n  onDelete,\n  onNewFile,\n  onNewFolder,\n  onCopyPath,\n  onDownload,\n  onRefresh,\n  renamingItem,\n  renameValue,\n  setRenameValue,\n  handleConfirmRename,\n  handleCancelRename,\n  renameInputRef,\n  operationLoading,\n}: FileTreeListProps) {\n  return (\n    <div>\n      {items.map((item) => (\n        <FileTreeNode\n          key={item.path}\n          item={item}\n          level={0}\n          viewMode={viewMode}\n          expandedDirs={expandedDirs}\n          onItemClick={onItemClick}\n          renderFileIcon={renderFileIcon}\n          formatFileSize={formatFileSize}\n          formatRelativeTime={formatRelativeTime}\n          onRename={onRename}\n          onDelete={onDelete}\n          onNewFile={onNewFile}\n          onNewFolder={onNewFolder}\n          onCopyPath={onCopyPath}\n          onDownload={onDownload}\n          onRefresh={onRefresh}\n          renamingItem={renamingItem}\n          renameValue={renameValue}\n          setRenameValue={setRenameValue}\n          handleConfirmRename={handleConfirmRename}\n          handleCancelRename={handleCancelRename}\n          renameInputRef={renameInputRef}\n          operationLoading={operationLoading}\n        />\n      ))}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/file-tree/view/FileTreeLoadingState.tsx",
    "content": "import { useTranslation } from 'react-i18next';\n\nexport default function FileTreeLoadingState() {\n  const { t } = useTranslation();\n\n  return (\n    <div className=\"flex h-full items-center justify-center\">\n      <div className=\"text-sm text-muted-foreground\">{t('fileTree.loading')}</div>\n    </div>\n  );\n}\n\n"
  },
  {
    "path": "src/components/file-tree/view/FileTreeNode.tsx",
    "content": "import type { ReactNode, RefObject } from 'react';\nimport { ChevronRight, Folder, FolderOpen } from 'lucide-react';\nimport { cn } from '../../../lib/utils';\nimport type { FileTreeNode as FileTreeNodeType, FileTreeViewMode } from '../types/types';\nimport { Input } from '../../../shared/view/ui';\nimport FileContextMenu from './FileContextMenu';\n\ntype FileTreeNodeProps = {\n  item: FileTreeNodeType;\n  level: number;\n  viewMode: FileTreeViewMode;\n  expandedDirs: Set<string>;\n  onItemClick: (item: FileTreeNodeType) => void;\n  renderFileIcon: (filename: string) => ReactNode;\n  formatFileSize: (bytes?: number) => string;\n  formatRelativeTime: (date?: string) => string;\n  onRename?: (item: FileTreeNodeType) => void;\n  onDelete?: (item: FileTreeNodeType) => void;\n  onNewFile?: (path: string) => void;\n  onNewFolder?: (path: string) => void;\n  onCopyPath?: (item: FileTreeNodeType) => void;\n  onDownload?: (item: FileTreeNodeType) => void;\n  onRefresh?: () => void;\n  // Rename state for inline editing\n  renamingItem?: FileTreeNodeType | null;\n  renameValue?: string;\n  setRenameValue?: (value: string) => void;\n  handleConfirmRename?: () => void;\n  handleCancelRename?: () => void;\n  renameInputRef?: RefObject<HTMLInputElement>;\n  operationLoading?: boolean;\n};\n\ntype TreeItemIconProps = {\n  item: FileTreeNodeType;\n  isOpen: boolean;\n  renderFileIcon: (filename: string) => ReactNode;\n};\n\nfunction TreeItemIcon({ item, isOpen, renderFileIcon }: TreeItemIconProps) {\n  if (item.type === 'directory') {\n    return (\n      <span className=\"flex flex-shrink-0 items-center gap-0.5\">\n        <ChevronRight\n          className={cn(\n            'w-3.5 h-3.5 text-muted-foreground/70 transition-transform duration-150',\n            isOpen && 'rotate-90',\n          )}\n        />\n        {isOpen ? (\n          <FolderOpen className=\"h-4 w-4 flex-shrink-0 text-blue-500\" />\n        ) : (\n          <Folder className=\"h-4 w-4 flex-shrink-0 text-muted-foreground\" />\n        )}\n      </span>\n    );\n  }\n\n  return <span className=\"ml-[18px] flex flex-shrink-0 items-center\">{renderFileIcon(item.name)}</span>;\n}\n\nexport default function FileTreeNode({\n  item,\n  level,\n  viewMode,\n  expandedDirs,\n  onItemClick,\n  renderFileIcon,\n  formatFileSize,\n  formatRelativeTime,\n  onRename,\n  onDelete,\n  onNewFile,\n  onNewFolder,\n  onCopyPath,\n  onDownload,\n  onRefresh,\n  renamingItem,\n  renameValue,\n  setRenameValue,\n  handleConfirmRename,\n  handleCancelRename,\n  renameInputRef,\n  operationLoading,\n}: FileTreeNodeProps) {\n  const isDirectory = item.type === 'directory';\n  const isOpen = isDirectory && expandedDirs.has(item.path);\n  const hasChildren = Boolean(isDirectory && item.children && item.children.length > 0);\n  const isRenaming = renamingItem?.path === item.path;\n\n  const nameClassName = cn(\n    'text-[13px] leading-tight truncate',\n    isDirectory ? 'font-medium text-foreground' : 'text-foreground/90',\n  );\n\n  // View mode only changes the row layout; selection, expansion, and recursion stay shared.\n  const rowClassName = cn(\n    viewMode === 'detailed'\n      ? 'group grid grid-cols-12 gap-2 py-[3px] pr-2 hover:bg-accent/60 cursor-pointer items-center rounded-sm transition-colors duration-100'\n      : viewMode === 'compact'\n      ? 'group flex items-center justify-between py-[3px] pr-2 hover:bg-accent/60 cursor-pointer rounded-sm transition-colors duration-100'\n      : 'group flex items-center gap-1.5 py-[3px] pr-2 cursor-pointer rounded-sm hover:bg-accent/60 transition-colors duration-100',\n    isDirectory && isOpen && 'border-l-2 border-primary/30',\n    (isDirectory && !isOpen) || !isDirectory ? 'border-l-2 border-transparent' : '',\n  );\n\n  // Render rename input if this item is being renamed\n  if (isRenaming && setRenameValue && handleConfirmRename && handleCancelRename) {\n    return (\n      <div\n        className={cn(rowClassName, 'bg-accent/30')}\n        style={{ paddingLeft: `${level * 16 + 4}px` }}\n        onClick={(e) => e.stopPropagation()}\n      >\n        <TreeItemIcon item={item} isOpen={isOpen} renderFileIcon={renderFileIcon} />\n        <Input\n          ref={renameInputRef}\n          type=\"text\"\n          value={renameValue || ''}\n          onChange={(e) => setRenameValue(e.target.value)}\n          onKeyDown={(e) => {\n            e.stopPropagation();\n            if (e.key === 'Enter') handleConfirmRename();\n            if (e.key === 'Escape') handleCancelRename();\n          }}\n          onBlur={() => {\n            setTimeout(() => {\n              handleConfirmRename();\n            }, 100);\n          }}\n          className=\"h-6 flex-1 text-sm\"\n          disabled={operationLoading}\n        />\n      </div>\n    );\n  }\n\n  const rowContent = (\n    <div\n      className={rowClassName}\n      style={{ paddingLeft: `${level * 16 + 4}px` }}\n      onClick={() => onItemClick(item)}\n    >\n      {viewMode === 'detailed' ? (\n        <>\n          <div className=\"col-span-5 flex min-w-0 items-center gap-1.5\">\n            <TreeItemIcon item={item} isOpen={isOpen} renderFileIcon={renderFileIcon} />\n            <span className={nameClassName}>{item.name}</span>\n          </div>\n          <div className=\"col-span-2 text-sm tabular-nums text-muted-foreground\">\n            {item.type === 'file' ? formatFileSize(item.size) : ''}\n          </div>\n          <div className=\"col-span-3 text-sm text-muted-foreground\">{formatRelativeTime(item.modified)}</div>\n          <div className=\"col-span-2 font-mono text-sm text-muted-foreground\">{item.permissionsRwx || ''}</div>\n        </>\n      ) : viewMode === 'compact' ? (\n        <>\n          <div className=\"flex min-w-0 items-center gap-1.5\">\n            <TreeItemIcon item={item} isOpen={isOpen} renderFileIcon={renderFileIcon} />\n            <span className={nameClassName}>{item.name}</span>\n          </div>\n          <div className=\"ml-2 flex flex-shrink-0 items-center gap-3 text-sm text-muted-foreground\">\n            {item.type === 'file' && (\n              <>\n                <span className=\"tabular-nums\">{formatFileSize(item.size)}</span>\n                <span className=\"font-mono\">{item.permissionsRwx}</span>\n              </>\n            )}\n          </div>\n        </>\n      ) : (\n        <>\n          <TreeItemIcon item={item} isOpen={isOpen} renderFileIcon={renderFileIcon} />\n          <span className={nameClassName}>{item.name}</span>\n        </>\n      )}\n    </div>\n  );\n\n  // Check if context menu callbacks are provided\n  const hasContextMenu = onRename || onDelete || onNewFile || onNewFolder || onCopyPath || onDownload || onRefresh;\n\n  return (\n    <div className=\"select-none\">\n      {hasContextMenu ? (\n        <FileContextMenu\n          item={item}\n          onRename={onRename}\n          onDelete={onDelete}\n          onNewFile={onNewFile}\n          onNewFolder={onNewFolder}\n          onCopyPath={onCopyPath}\n          onDownload={onDownload}\n          onRefresh={onRefresh}\n        >\n          {rowContent}\n        </FileContextMenu>\n      ) : (\n        rowContent\n      )}\n\n      {isDirectory && isOpen && hasChildren && (\n        <div className=\"relative\">\n          <span\n            className=\"absolute bottom-0 top-0 border-l border-border/40\"\n            style={{ left: `${level * 16 + 14}px` }}\n            aria-hidden=\"true\"\n          />\n          {item.children?.map((child) => (\n            <FileTreeNode\n              key={child.path}\n              item={child}\n              level={level + 1}\n              viewMode={viewMode}\n              expandedDirs={expandedDirs}\n              onItemClick={onItemClick}\n              renderFileIcon={renderFileIcon}\n              formatFileSize={formatFileSize}\n              formatRelativeTime={formatRelativeTime}\n              onRename={onRename}\n              onDelete={onDelete}\n              onNewFile={onNewFile}\n              onNewFolder={onNewFolder}\n              onCopyPath={onCopyPath}\n              onDownload={onDownload}\n              onRefresh={onRefresh}\n              renamingItem={renamingItem}\n              renameValue={renameValue}\n              setRenameValue={setRenameValue}\n              handleConfirmRename={handleConfirmRename}\n              handleCancelRename={handleCancelRename}\n              renameInputRef={renameInputRef}\n              operationLoading={operationLoading}\n            />\n          ))}\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/file-tree/view/ImageViewer.tsx",
    "content": "import { useEffect, useState } from 'react';\nimport { X } from 'lucide-react';\nimport { Button } from '../../../shared/view/ui';\nimport { authenticatedFetch } from '../../../utils/api';\nimport type { FileTreeImageSelection } from '../types/types';\n\ntype ImageViewerProps = {\n  file: FileTreeImageSelection;\n  onClose: () => void;\n};\n\nexport default function ImageViewer({ file, onClose }: ImageViewerProps) {\n  const imagePath = `/api/projects/${file.projectName}/files/content?path=${encodeURIComponent(file.path)}`;\n  const [imageUrl, setImageUrl] = useState<string | null>(null);\n  const [error, setError] = useState<string | null>(null);\n  const [loading, setLoading] = useState(true);\n\n  useEffect(() => {\n    let objectUrl: string | null = null;\n    const controller = new AbortController();\n\n    const loadImage = async () => {\n      try {\n        setLoading(true);\n        setError(null);\n        setImageUrl(null);\n\n        const response = await authenticatedFetch(imagePath, {\n          signal: controller.signal,\n        });\n\n        if (!response.ok) {\n          throw new Error(`Request failed with status ${response.status}`);\n        }\n\n        const blob = await response.blob();\n        objectUrl = URL.createObjectURL(blob);\n        setImageUrl(objectUrl);\n      } catch (loadError: unknown) {\n        if (loadError instanceof Error && loadError.name === 'AbortError') {\n          return;\n        }\n        console.error('Error loading image:', loadError);\n        setError('Unable to load image');\n      } finally {\n        setLoading(false);\n      }\n    };\n\n    loadImage();\n\n    return () => {\n      controller.abort();\n      if (objectUrl) {\n        URL.revokeObjectURL(objectUrl);\n      }\n    };\n  }, [imagePath]);\n\n  return (\n    <div className=\"fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50\">\n      <div className=\"mx-4 max-h-[90vh] w-full max-w-4xl overflow-hidden rounded-lg bg-white shadow-xl dark:bg-gray-800\">\n        <div className=\"flex items-center justify-between border-b p-4\">\n          <h3 className=\"text-lg font-semibold text-gray-900 dark:text-white\">{file.name}</h3>\n          <Button variant=\"ghost\" size=\"sm\" onClick={onClose} className=\"h-8 w-8 p-0\">\n            <X className=\"h-4 w-4\" />\n          </Button>\n        </div>\n\n        <div className=\"flex min-h-[400px] items-center justify-center bg-gray-50 p-4 dark:bg-gray-900\">\n          {loading && (\n            <div className=\"text-center text-gray-500 dark:text-gray-400\">\n              <p>Loading image...</p>\n            </div>\n          )}\n          {!loading && imageUrl && (\n            <img\n              src={imageUrl}\n              alt={file.name}\n              className=\"max-h-[70vh] max-w-full rounded-lg object-contain shadow-md\"\n            />\n          )}\n          {!loading && !imageUrl && (\n            <div className=\"text-center text-gray-500 dark:text-gray-400\">\n              <p>{error || 'Unable to load image'}</p>\n              <p className=\"mt-2 break-all text-sm\">{file.path}</p>\n            </div>\n          )}\n        </div>\n\n        <div className=\"border-t bg-gray-50 p-4 dark:bg-gray-800\">\n          <p className=\"text-sm text-gray-600 dark:text-gray-400\">{file.path}</p>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/git-panel/constants/constants.ts",
    "content": "import type { ConfirmActionType, FileStatusCode, GitStatusGroupEntry } from '../types/types';\n\nexport const DEFAULT_BRANCH = 'main';\nexport const RECENT_COMMITS_LIMIT = 10;\n\nexport const FILE_STATUS_GROUPS: GitStatusGroupEntry[] = [\n  { key: 'modified', status: 'M' },\n  { key: 'added', status: 'A' },\n  { key: 'deleted', status: 'D' },\n  { key: 'untracked', status: 'U' },\n];\n\nexport const FILE_STATUS_LABELS: Record<FileStatusCode, string> = {\n  M: 'Modified',\n  A: 'Added',\n  D: 'Deleted',\n  U: 'Untracked',\n};\n\nexport const FILE_STATUS_BADGE_CLASSES: Record<FileStatusCode, string> = {\n  M: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800/50',\n  A: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300 border-green-200 dark:border-green-800/50',\n  D: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300 border-red-200 dark:border-red-800/50',\n  U: 'bg-muted text-muted-foreground border-border',\n};\n\nexport const CONFIRMATION_TITLES: Record<ConfirmActionType, string> = {\n  discard: 'Discard Changes',\n  delete: 'Delete File',\n  commit: 'Confirm Action',\n  pull: 'Confirm Pull',\n  push: 'Confirm Push',\n  publish: 'Publish Branch',\n  revertLocalCommit: 'Revert Local Commit',\n  deleteBranch: 'Delete Branch',\n};\n\nexport const CONFIRMATION_ACTION_LABELS: Record<ConfirmActionType, string> = {\n  discard: 'Discard',\n  delete: 'Delete',\n  commit: 'Confirm',\n  pull: 'Pull',\n  push: 'Push',\n  publish: 'Publish',\n  revertLocalCommit: 'Revert Commit',\n  deleteBranch: 'Delete',\n};\n\nexport const CONFIRMATION_BUTTON_CLASSES: Record<ConfirmActionType, string> = {\n  discard: 'bg-red-600 hover:bg-red-700',\n  delete: 'bg-red-600 hover:bg-red-700',\n  commit: 'bg-primary hover:bg-primary/90',\n  pull: 'bg-green-600 hover:bg-green-700',\n  push: 'bg-orange-600 hover:bg-orange-700',\n  publish: 'bg-purple-600 hover:bg-purple-700',\n  revertLocalCommit: 'bg-yellow-600 hover:bg-yellow-700',\n  deleteBranch: 'bg-red-600 hover:bg-red-700',\n};\n\nexport const CONFIRMATION_ICON_CONTAINER_CLASSES: Record<ConfirmActionType, string> = {\n  discard: 'bg-red-100 dark:bg-red-900/30',\n  delete: 'bg-red-100 dark:bg-red-900/30',\n  commit: 'bg-yellow-100 dark:bg-yellow-900/30',\n  pull: 'bg-yellow-100 dark:bg-yellow-900/30',\n  push: 'bg-yellow-100 dark:bg-yellow-900/30',\n  publish: 'bg-yellow-100 dark:bg-yellow-900/30',\n  revertLocalCommit: 'bg-yellow-100 dark:bg-yellow-900/30',\n  deleteBranch: 'bg-red-100 dark:bg-red-900/30',\n};\n\nexport const CONFIRMATION_ICON_CLASSES: Record<ConfirmActionType, string> = {\n  discard: 'text-red-600 dark:text-red-400',\n  delete: 'text-red-600 dark:text-red-400',\n  commit: 'text-yellow-600 dark:text-yellow-400',\n  pull: 'text-yellow-600 dark:text-yellow-400',\n  push: 'text-yellow-600 dark:text-yellow-400',\n  publish: 'text-yellow-600 dark:text-yellow-400',\n  revertLocalCommit: 'text-yellow-600 dark:text-yellow-400',\n  deleteBranch: 'text-red-600 dark:text-red-400',\n};\n"
  },
  {
    "path": "src/components/git-panel/hooks/useGitPanelController.ts",
    "content": "import { useCallback, useEffect, useRef, useState } from 'react';\nimport { authenticatedFetch } from '../../../utils/api';\nimport { DEFAULT_BRANCH, RECENT_COMMITS_LIMIT } from '../constants/constants';\nimport type {\n  GitApiErrorResponse,\n  GitBranchesResponse,\n  GitCommitSummary,\n  GitCommitsResponse,\n  GitDiffMap,\n  GitDiffResponse,\n  GitFileWithDiffResponse,\n  GitGenerateMessageResponse,\n  GitOperationResponse,\n  GitPanelController,\n  GitRemoteStatus,\n  GitStatusResponse,\n  UseGitPanelControllerOptions,\n} from '../types/types';\nimport { getAllChangedFiles } from '../utils/gitPanelUtils';\nimport { useSelectedProvider } from './useSelectedProvider';\n\n// ! use authenticatedFetch directly. fetchWithAuth is redundant \nconst fetchWithAuth = authenticatedFetch as (url: string, options?: RequestInit) => Promise<Response>;\n\nfunction isAbortError(error: unknown): boolean {\n  return error instanceof DOMException && error.name === 'AbortError';\n}\n\nasync function readJson<T>(response: Response, signal?: AbortSignal): Promise<T> {\n  if (signal?.aborted) {\n    throw new DOMException('Request aborted', 'AbortError');\n  }\n\n  const data = (await response.json()) as T;\n\n  if (signal?.aborted) {\n    throw new DOMException('Request aborted', 'AbortError');\n  }\n\n  return data;\n}\n\nexport function useGitPanelController({\n  selectedProject,\n  activeView,\n  onFileOpen,\n}: UseGitPanelControllerOptions): GitPanelController {\n  const [gitStatus, setGitStatus] = useState<GitStatusResponse | null>(null);\n  const [gitDiff, setGitDiff] = useState<GitDiffMap>({});\n  const [isLoading, setIsLoading] = useState(false);\n  const [currentBranch, setCurrentBranch] = useState('');\n  const [branches, setBranches] = useState<string[]>([]);\n  const [recentCommits, setRecentCommits] = useState<GitCommitSummary[]>([]);\n  const [commitDiffs, setCommitDiffs] = useState<GitDiffMap>({});\n  const [remoteStatus, setRemoteStatus] = useState<GitRemoteStatus | null>(null);\n  const [localBranches, setLocalBranches] = useState<string[]>([]);\n  const [remoteBranches, setRemoteBranches] = useState<string[]>([]);\n  const [isCreatingBranch, setIsCreatingBranch] = useState(false);\n  const [isFetching, setIsFetching] = useState(false);\n  const [isPulling, setIsPulling] = useState(false);\n  const [isPushing, setIsPushing] = useState(false);\n  const [isPublishing, setIsPublishing] = useState(false);\n  const [isCreatingInitialCommit, setIsCreatingInitialCommit] = useState(false);\n  const [operationError, setOperationError] = useState<string | null>(null);\n\n  const clearOperationError = useCallback(() => setOperationError(null), []);\n  const selectedProjectNameRef = useRef<string | null>(selectedProject?.name ?? null);\n\n  useEffect(() => {\n    selectedProjectNameRef.current = selectedProject?.name ?? null;\n  }, [selectedProject]);\n\n  const provider = useSelectedProvider();\n\n  const fetchFileDiff = useCallback(\n    async (filePath: string, signal?: AbortSignal) => {\n      if (!selectedProject) {\n        return;\n      }\n\n      const projectName = selectedProject.name;\n\n      try {\n        const response = await fetchWithAuth(\n          `/api/git/diff?project=${encodeURIComponent(projectName)}&file=${encodeURIComponent(filePath)}`,\n          { signal },\n        );\n        const data = await readJson<GitDiffResponse>(response, signal);\n\n        if (\n          signal?.aborted ||\n          selectedProjectNameRef.current !== projectName\n        ) {\n          return;\n        }\n\n        if (!data.error && data.diff) {\n          setGitDiff((previous) => ({\n            ...previous,\n            [filePath]: data.diff as string,\n          }));\n        }\n      } catch (error) {\n        if (signal?.aborted || isAbortError(error)) {\n          return;\n        }\n\n        console.error('Error fetching file diff:', error);\n      }\n    },\n    [selectedProject],\n  );\n\n  const fetchGitStatus = useCallback(async (signal?: AbortSignal) => {\n    if (!selectedProject) {\n      return;\n    }\n\n    const projectName = selectedProject.name;\n\n    setIsLoading(true);\n    try {\n      const response = await fetchWithAuth(`/api/git/status?project=${encodeURIComponent(projectName)}`, { signal });\n      const data = await readJson<GitStatusResponse>(response, signal);\n\n      if (\n        signal?.aborted ||\n        selectedProjectNameRef.current !== projectName\n      ) {\n        return;\n      }\n\n      if (data.error) {\n        console.error('Git status error:', data.error);\n        setGitStatus({ error: data.error, details: data.details });\n        setCurrentBranch('');\n        return;\n      }\n\n      setGitStatus(data);\n      setCurrentBranch(data.branch || DEFAULT_BRANCH);\n\n      const changedFiles = getAllChangedFiles(data);\n      changedFiles.forEach((filePath) => {\n        void fetchFileDiff(filePath, signal);\n      });\n    } catch (error) {\n      if (signal?.aborted || isAbortError(error)) {\n        return;\n      }\n\n      if (\n        selectedProjectNameRef.current !== projectName\n      ) {\n        return;\n      }\n\n      console.error('Error fetching git status:', error);\n      setGitStatus({ error: 'Git operation failed', details: String(error) });\n      setCurrentBranch('');\n    } finally {\n      setIsLoading(false);\n    }\n  }, [fetchFileDiff, selectedProject]);\n\n  const fetchBranches = useCallback(async () => {\n    if (!selectedProject) {\n      return;\n    }\n\n    try {\n      const response = await fetchWithAuth(`/api/git/branches?project=${encodeURIComponent(selectedProject.name)}`);\n      const data = await readJson<GitBranchesResponse>(response);\n\n      if (!data.error && data.branches) {\n        setBranches(data.branches);\n        setLocalBranches(data.localBranches ?? data.branches);\n        setRemoteBranches(data.remoteBranches ?? []);\n        return;\n      }\n\n      setBranches([]);\n      setLocalBranches([]);\n      setRemoteBranches([]);\n    } catch (error) {\n      console.error('Error fetching branches:', error);\n      setBranches([]);\n      setLocalBranches([]);\n      setRemoteBranches([]);\n    }\n  }, [selectedProject]);\n\n  const fetchRemoteStatus = useCallback(async () => {\n    if (!selectedProject) {\n      return;\n    }\n\n    try {\n      const response = await fetchWithAuth(`/api/git/remote-status?project=${encodeURIComponent(selectedProject.name)}`);\n      const data = await readJson<GitRemoteStatus | GitApiErrorResponse>(response);\n\n      if (!data.error) {\n        setRemoteStatus(data as GitRemoteStatus);\n        return;\n      }\n\n      setRemoteStatus(null);\n    } catch (error) {\n      console.error('Error fetching remote status:', error);\n      setRemoteStatus(null);\n    }\n  }, [selectedProject]);\n\n  const switchBranch = useCallback(\n    async (branchName: string) => {\n      if (!selectedProject) {\n        return false;\n      }\n\n      try {\n        const response = await fetchWithAuth('/api/git/checkout', {\n          method: 'POST',\n          headers: { 'Content-Type': 'application/json' },\n          body: JSON.stringify({\n            project: selectedProject.name,\n            branch: branchName,\n          }),\n        });\n\n        const data = await readJson<GitOperationResponse>(response);\n        if (!data.success) {\n          console.error('Failed to switch branch:', data.error);\n          return false;\n        }\n\n        setCurrentBranch(branchName);\n        void fetchGitStatus();\n        return true;\n      } catch (error) {\n        console.error('Error switching branch:', error);\n        return false;\n      }\n    },\n    [fetchGitStatus, selectedProject],\n  );\n\n  const createBranch = useCallback(\n    async (branchName: string) => {\n      const trimmedBranchName = branchName.trim();\n      if (!selectedProject || !trimmedBranchName) {\n        return false;\n      }\n\n      setIsCreatingBranch(true);\n      try {\n        const response = await fetchWithAuth('/api/git/create-branch', {\n          method: 'POST',\n          headers: { 'Content-Type': 'application/json' },\n          body: JSON.stringify({\n            project: selectedProject.name,\n            branch: trimmedBranchName,\n          }),\n        });\n\n        const data = await readJson<GitOperationResponse>(response);\n        if (!data.success) {\n          console.error('Failed to create branch:', data.error);\n          return false;\n        }\n\n        setCurrentBranch(trimmedBranchName);\n        void fetchBranches();\n        void fetchGitStatus();\n        return true;\n      } catch (error) {\n        console.error('Error creating branch:', error);\n        return false;\n      } finally {\n        setIsCreatingBranch(false);\n      }\n    },\n    [fetchBranches, fetchGitStatus, selectedProject],\n  );\n\n  const deleteBranch = useCallback(\n    async (branchName: string) => {\n      if (!selectedProject) return false;\n\n      try {\n        const response = await fetchWithAuth('/api/git/delete-branch', {\n          method: 'POST',\n          headers: { 'Content-Type': 'application/json' },\n          body: JSON.stringify({ project: selectedProject.name, branch: branchName }),\n        });\n\n        const data = await readJson<GitOperationResponse>(response);\n        if (!data.success) {\n          setOperationError(data.error ?? 'Delete branch failed');\n          return false;\n        }\n\n        void fetchBranches();\n        return true;\n      } catch (error) {\n        setOperationError(error instanceof Error ? error.message : 'Delete branch failed');\n        return false;\n      }\n    },\n    [fetchBranches, selectedProject],\n  );\n\n  const handleFetch = useCallback(async () => {\n    if (!selectedProject) {\n      return;\n    }\n\n    setIsFetching(true);\n    try {\n      const response = await fetchWithAuth('/api/git/fetch', {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({\n          project: selectedProject.name,\n        }),\n      });\n\n      const data = await readJson<GitOperationResponse>(response);\n      if (data.success) {\n        void fetchGitStatus();\n        void fetchRemoteStatus();\n        void fetchBranches();\n        return;\n      }\n\n      setOperationError(data.error ?? 'Fetch failed');\n    } catch (error) {\n      setOperationError(error instanceof Error ? error.message : 'Fetch failed');\n    } finally {\n      setIsFetching(false);\n    }\n  }, [fetchBranches, fetchGitStatus, fetchRemoteStatus, selectedProject]);\n\n  const handlePull = useCallback(async () => {\n    if (!selectedProject) {\n      return;\n    }\n\n    setIsPulling(true);\n    try {\n      const response = await fetchWithAuth('/api/git/pull', {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({\n          project: selectedProject.name,\n        }),\n      });\n\n      const data = await readJson<GitOperationResponse>(response);\n      if (data.success) {\n        void fetchGitStatus();\n        void fetchRemoteStatus();\n        return;\n      }\n\n      setOperationError(data.error ?? 'Pull failed');\n    } catch (error) {\n      setOperationError(error instanceof Error ? error.message : 'Pull failed');\n    } finally {\n      setIsPulling(false);\n    }\n  }, [fetchGitStatus, fetchRemoteStatus, selectedProject]);\n\n  const handlePush = useCallback(async () => {\n    if (!selectedProject) {\n      return;\n    }\n\n    setIsPushing(true);\n    try {\n      const response = await fetchWithAuth('/api/git/push', {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({\n          project: selectedProject.name,\n        }),\n      });\n\n      const data = await readJson<GitOperationResponse>(response);\n      if (data.success) {\n        void fetchGitStatus();\n        void fetchRemoteStatus();\n        return;\n      }\n\n      setOperationError(data.error ?? 'Push failed');\n    } catch (error) {\n      setOperationError(error instanceof Error ? error.message : 'Push failed');\n    } finally {\n      setIsPushing(false);\n    }\n  }, [fetchGitStatus, fetchRemoteStatus, selectedProject]);\n\n  const handlePublish = useCallback(async () => {\n    if (!selectedProject) {\n      return;\n    }\n\n    setIsPublishing(true);\n    try {\n      const response = await fetchWithAuth('/api/git/publish', {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({\n          project: selectedProject.name,\n          branch: currentBranch,\n        }),\n      });\n\n      const data = await readJson<GitOperationResponse>(response);\n      if (data.success) {\n        void fetchGitStatus();\n        void fetchRemoteStatus();\n        return;\n      }\n\n      console.error('Publish failed:', data.error);\n    } catch (error) {\n      console.error('Error publishing branch:', error);\n    } finally {\n      setIsPublishing(false);\n    }\n  }, [currentBranch, fetchGitStatus, fetchRemoteStatus, selectedProject]);\n\n  const discardChanges = useCallback(\n    async (filePath: string) => {\n      if (!selectedProject) {\n        return;\n      }\n\n      try {\n        const response = await fetchWithAuth('/api/git/discard', {\n          method: 'POST',\n          headers: { 'Content-Type': 'application/json' },\n          body: JSON.stringify({\n            project: selectedProject.name,\n            file: filePath,\n          }),\n        });\n\n        const data = await readJson<GitOperationResponse>(response);\n        if (data.success) {\n          void fetchGitStatus();\n          return;\n        }\n\n        console.error('Discard failed:', data.error);\n      } catch (error) {\n        console.error('Error discarding changes:', error);\n      }\n    },\n    [fetchGitStatus, selectedProject],\n  );\n\n  const deleteUntrackedFile = useCallback(\n    async (filePath: string) => {\n      if (!selectedProject) {\n        return;\n      }\n\n      try {\n        const response = await fetchWithAuth('/api/git/delete-untracked', {\n          method: 'POST',\n          headers: { 'Content-Type': 'application/json' },\n          body: JSON.stringify({\n            project: selectedProject.name,\n            file: filePath,\n          }),\n        });\n\n        const data = await readJson<GitOperationResponse>(response);\n        if (data.success) {\n          void fetchGitStatus();\n          return;\n        }\n\n        console.error('Delete failed:', data.error);\n      } catch (error) {\n        console.error('Error deleting untracked file:', error);\n      }\n    },\n    [fetchGitStatus, selectedProject],\n  );\n\n  const fetchRecentCommits = useCallback(async () => {\n    if (!selectedProject) {\n      return;\n    }\n\n    try {\n      const response = await fetchWithAuth(\n        `/api/git/commits?project=${encodeURIComponent(selectedProject.name)}&limit=${RECENT_COMMITS_LIMIT}`,\n      );\n      const data = await readJson<GitCommitsResponse>(response);\n\n      if (!data.error && data.commits) {\n        setRecentCommits(data.commits);\n      }\n    } catch (error) {\n      console.error('Error fetching commits:', error);\n    }\n  }, [selectedProject]);\n\n  const fetchCommitDiff = useCallback(\n    async (commitHash: string) => {\n      if (!selectedProject) {\n        return;\n      }\n\n      try {\n        const response = await fetchWithAuth(\n          `/api/git/commit-diff?project=${encodeURIComponent(selectedProject.name)}&commit=${commitHash}`,\n        );\n        const data = await readJson<GitDiffResponse>(response);\n\n        if (!data.error && data.diff) {\n          setCommitDiffs((previous) => ({\n            ...previous,\n            [commitHash]: data.diff as string,\n          }));\n        }\n      } catch (error) {\n        console.error('Error fetching commit diff:', error);\n      }\n    },\n    [selectedProject],\n  );\n\n  const generateCommitMessage = useCallback(\n    async (files: string[]) => {\n      if (!selectedProject || files.length === 0) {\n        return null;\n      }\n\n      try {\n        const response = await authenticatedFetch('/api/git/generate-commit-message', {\n          method: 'POST',\n          headers: { 'Content-Type': 'application/json' },\n          body: JSON.stringify({\n            project: selectedProject.name,\n            files,\n            provider,\n          }),\n        });\n\n        const data = await readJson<GitGenerateMessageResponse>(response);\n        if (data.message) {\n          return data.message;\n        }\n\n        console.error('Failed to generate commit message:', data.error);\n        return null;\n      } catch (error) {\n        console.error('Error generating commit message:', error);\n        return null;\n      }\n    },\n    [provider, selectedProject],\n  );\n\n  const commitChanges = useCallback(\n    async (message: string, files: string[]) => {\n      if (!selectedProject || !message.trim() || files.length === 0) {\n        return false;\n      }\n\n      try {\n        const response = await fetchWithAuth('/api/git/commit', {\n          method: 'POST',\n          headers: { 'Content-Type': 'application/json' },\n          body: JSON.stringify({\n            project: selectedProject.name,\n            message,\n            files,\n          }),\n        });\n\n        const data = await readJson<GitOperationResponse>(response);\n        if (data.success) {\n          void fetchGitStatus();\n          void fetchRemoteStatus();\n          return true;\n        }\n\n        console.error('Commit failed:', data.error);\n        return false;\n      } catch (error) {\n        console.error('Error committing changes:', error);\n        return false;\n      }\n    },\n    [fetchGitStatus, fetchRemoteStatus, selectedProject],\n  );\n\n  const createInitialCommit = useCallback(async () => {\n    if (!selectedProject) {\n      throw new Error('No project selected');\n    }\n\n    setIsCreatingInitialCommit(true);\n    try {\n      const response = await fetchWithAuth('/api/git/initial-commit', {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({\n          project: selectedProject.name,\n        }),\n      });\n\n      const data = await readJson<GitOperationResponse>(response);\n      if (data.success) {\n        void fetchGitStatus();\n        void fetchRemoteStatus();\n        return true;\n      }\n\n      throw new Error(data.error || 'Failed to create initial commit');\n    } catch (error) {\n      console.error('Error creating initial commit:', error);\n      throw error;\n    } finally {\n      setIsCreatingInitialCommit(false);\n    }\n  }, [fetchGitStatus, fetchRemoteStatus, selectedProject]);\n\n  const openFile = useCallback(\n    async (filePath: string) => {\n      if (!onFileOpen) {\n        return;\n      }\n\n      if (!selectedProject) {\n        onFileOpen(filePath);\n        return;\n      }\n\n      try {\n        const response = await fetchWithAuth(\n          `/api/git/file-with-diff?project=${encodeURIComponent(selectedProject.name)}&file=${encodeURIComponent(filePath)}`,\n        );\n        const data = await readJson<GitFileWithDiffResponse>(response);\n\n        if (data.error) {\n          console.error('Error fetching file with diff:', data.error);\n          onFileOpen(filePath);\n          return;\n        }\n\n        onFileOpen(filePath, {\n          old_string: data.oldContent || '',\n          new_string: data.currentContent || '',\n        });\n      } catch (error) {\n        console.error('Error opening file:', error);\n        onFileOpen(filePath);\n      }\n    },\n    [onFileOpen, selectedProject],\n  );\n\n  const refreshAll = useCallback(() => {\n    void fetchGitStatus();\n    void fetchBranches();\n    void fetchRemoteStatus();\n  }, [fetchBranches, fetchGitStatus, fetchRemoteStatus]);\n\n  useEffect(() => {\n    const controller = new AbortController();\n\n    // Reset repository-scoped state when project changes to avoid stale UI.\n    setCurrentBranch('');\n    setBranches([]);\n    setLocalBranches([]);\n    setRemoteBranches([]);\n    setGitStatus(null);\n    setRemoteStatus(null);\n    setGitDiff({});\n    setRecentCommits([]);\n    setCommitDiffs({});\n    setIsLoading(false);\n    setOperationError(null);\n\n    if (!selectedProject) {\n      return () => {\n        controller.abort();\n      };\n    }\n\n    void fetchGitStatus(controller.signal);\n    void fetchBranches();\n    void fetchRemoteStatus();\n\n    return () => {\n      controller.abort();\n    };\n  }, [fetchBranches, fetchGitStatus, fetchRemoteStatus, selectedProject]);\n\n  useEffect(() => {\n    if (!selectedProject || activeView !== 'history') {\n      return;\n    }\n    void fetchRecentCommits();\n  }, [activeView, fetchRecentCommits, selectedProject]);\n\n  return {\n    gitStatus,\n    gitDiff,\n    isLoading,\n    currentBranch,\n    branches,\n    localBranches,\n    remoteBranches,\n    recentCommits,\n    commitDiffs,\n    remoteStatus,\n    isCreatingBranch,\n    isFetching,\n    isPulling,\n    isPushing,\n    isPublishing,\n    isCreatingInitialCommit,\n    operationError,\n    clearOperationError,\n    refreshAll,\n    switchBranch,\n    createBranch,\n    deleteBranch,\n    handleFetch,\n    handlePull,\n    handlePush,\n    handlePublish,\n    discardChanges,\n    deleteUntrackedFile,\n    fetchCommitDiff,\n    generateCommitMessage,\n    commitChanges,\n    createInitialCommit,\n    openFile,\n  };\n}\n"
  },
  {
    "path": "src/components/git-panel/hooks/useRevertLocalCommit.ts",
    "content": "import { useCallback, useState } from 'react';\nimport { authenticatedFetch } from '../../../utils/api';\nimport type { GitOperationResponse } from '../types/types';\n\ntype UseRevertLocalCommitOptions = {\n  projectName: string | null;\n  onSuccess?: () => void;\n};\n\nasync function readJson<T>(response: Response): Promise<T> {\n  return (await response.json()) as T;\n}\n\nexport function useRevertLocalCommit({ projectName, onSuccess }: UseRevertLocalCommitOptions) {\n  const [isRevertingLocalCommit, setIsRevertingLocalCommit] = useState(false);\n\n  const revertLatestLocalCommit = useCallback(async () => {\n    if (!projectName) {\n      return;\n    }\n\n    setIsRevertingLocalCommit(true);\n    try {\n      const response = await authenticatedFetch('/api/git/revert-local-commit', {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({ project: projectName }),\n      });\n      const data = await readJson<GitOperationResponse>(response);\n\n      if (!data.success) {\n        console.error('Revert local commit failed:', data.error || data.details || 'Unknown error');\n        return;\n      }\n\n      onSuccess?.();\n    } catch (error) {\n      console.error('Error reverting local commit:', error);\n    } finally {\n      setIsRevertingLocalCommit(false);\n    }\n  }, [onSuccess, projectName]);\n\n  return {\n    isRevertingLocalCommit,\n    revertLatestLocalCommit,\n  };\n}\n"
  },
  {
    "path": "src/components/git-panel/hooks/useSelectedProvider.ts",
    "content": "import { useEffect, useState } from 'react';\n\nexport function useSelectedProvider() {\n  const [provider, setProvider] = useState(() => {\n    return localStorage.getItem('selected-provider') || 'claude';\n  });\n\n  useEffect(() => {\n    // Keep provider in sync when another tab changes the selected provider.\n    const handleStorageChange = () => {\n      const nextProvider = localStorage.getItem('selected-provider') || 'claude';\n      setProvider(nextProvider);\n    };\n\n    window.addEventListener('storage', handleStorageChange);\n    return () => window.removeEventListener('storage', handleStorageChange);\n  }, []);\n\n  return provider;\n}\n"
  },
  {
    "path": "src/components/git-panel/types/types.ts",
    "content": "import type { Project } from '../../../types/app';\n\nexport type GitPanelView = 'changes' | 'history' | 'branches';\nexport type FileStatusCode = 'M' | 'A' | 'D' | 'U';\nexport type GitStatusFileGroup = 'modified' | 'added' | 'deleted' | 'untracked';\nexport type ConfirmActionType = 'discard' | 'delete' | 'commit' | 'pull' | 'push' | 'publish' | 'revertLocalCommit' | 'deleteBranch';\n\nexport type FileDiffInfo = {\n  old_string: string;\n  new_string: string;\n};\n\nexport type FileOpenHandler = (filePath: string, diffInfo?: FileDiffInfo) => void;\n\nexport type GitPanelProps = {\n  selectedProject: Project | null;\n  isMobile?: boolean;\n  onFileOpen?: FileOpenHandler;\n};\n\nexport type GitStatusResponse = {\n  branch?: string;\n  hasCommits?: boolean;\n  modified?: string[];\n  added?: string[];\n  deleted?: string[];\n  untracked?: string[];\n  error?: string;\n  details?: string;\n};\n\nexport type GitRemoteStatus = {\n  hasRemote?: boolean;\n  hasUpstream?: boolean;\n  branch?: string;\n  remoteBranch?: string;\n  remoteName?: string | null;\n  ahead?: number;\n  behind?: number;\n  isUpToDate?: boolean;\n  message?: string;\n  error?: string;\n};\n\nexport type GitCommitSummary = {\n  hash: string;\n  author: string;\n  email?: string;\n  date: string;\n  message: string;\n  stats?: string;\n};\n\nexport type GitDiffMap = Record<string, string>;\n\nexport type GitStatusGroupEntry = {\n  key: GitStatusFileGroup;\n  status: FileStatusCode;\n};\n\nexport type ConfirmationRequest = {\n  type: ConfirmActionType;\n  message: string;\n  onConfirm: () => Promise<void> | void;\n};\n\nexport type UseGitPanelControllerOptions = {\n  selectedProject: Project | null;\n  activeView: GitPanelView;\n  onFileOpen?: FileOpenHandler;\n};\n\nexport type GitPanelController = {\n  gitStatus: GitStatusResponse | null;\n  gitDiff: GitDiffMap;\n  isLoading: boolean;\n  currentBranch: string;\n  branches: string[];\n  localBranches: string[];\n  remoteBranches: string[];\n  recentCommits: GitCommitSummary[];\n  commitDiffs: GitDiffMap;\n  remoteStatus: GitRemoteStatus | null;\n  isCreatingBranch: boolean;\n  isFetching: boolean;\n  isPulling: boolean;\n  isPushing: boolean;\n  isPublishing: boolean;\n  isCreatingInitialCommit: boolean;\n  operationError: string | null;\n  clearOperationError: () => void;\n  refreshAll: () => void;\n  switchBranch: (branchName: string) => Promise<boolean>;\n  createBranch: (branchName: string) => Promise<boolean>;\n  deleteBranch: (branchName: string) => Promise<boolean>;\n  handleFetch: () => Promise<void>;\n  handlePull: () => Promise<void>;\n  handlePush: () => Promise<void>;\n  handlePublish: () => Promise<void>;\n  discardChanges: (filePath: string) => Promise<void>;\n  deleteUntrackedFile: (filePath: string) => Promise<void>;\n  fetchCommitDiff: (commitHash: string) => Promise<void>;\n  generateCommitMessage: (files: string[]) => Promise<string | null>;\n  commitChanges: (message: string, files: string[]) => Promise<boolean>;\n  createInitialCommit: () => Promise<boolean>;\n  openFile: (filePath: string) => Promise<void>;\n};\n\nexport type GitApiErrorResponse = {\n  error?: string;\n  details?: string;\n};\n\nexport type GitDiffResponse = GitApiErrorResponse & {\n  diff?: string;\n};\n\nexport type GitBranchesResponse = GitApiErrorResponse & {\n  branches?: string[];\n  localBranches?: string[];\n  remoteBranches?: string[];\n};\n\nexport type GitCommitsResponse = GitApiErrorResponse & {\n  commits?: GitCommitSummary[];\n};\n\nexport type GitOperationResponse = GitApiErrorResponse & {\n  success?: boolean;\n  output?: string;\n};\n\nexport type GitGenerateMessageResponse = GitApiErrorResponse & {\n  message?: string;\n};\n\nexport type GitFileWithDiffResponse = GitApiErrorResponse & {\n  oldContent?: string;\n  currentContent?: string;\n  isDeleted?: boolean;\n  isUntracked?: boolean;\n};\n"
  },
  {
    "path": "src/components/git-panel/utils/gitPanelUtils.ts",
    "content": "import { FILE_STATUS_BADGE_CLASSES, FILE_STATUS_GROUPS, FILE_STATUS_LABELS } from '../constants/constants';\nimport type { FileStatusCode, GitStatusResponse } from '../types/types';\n\nexport function getAllChangedFiles(gitStatus: GitStatusResponse | null): string[] {\n  if (!gitStatus) {\n    return [];\n  }\n\n  return FILE_STATUS_GROUPS.flatMap(({ key }) => gitStatus[key] || []);\n}\n\nexport function getChangedFileCount(gitStatus: GitStatusResponse | null): number {\n  return getAllChangedFiles(gitStatus).length;\n}\n\nexport function hasChangedFiles(gitStatus: GitStatusResponse | null): boolean {\n  return getChangedFileCount(gitStatus) > 0;\n}\n\nexport function getStatusLabel(status: FileStatusCode): string {\n  return FILE_STATUS_LABELS[status] || status;\n}\n\nexport function getStatusBadgeClass(status: FileStatusCode): string {\n  return FILE_STATUS_BADGE_CLASSES[status] || FILE_STATUS_BADGE_CLASSES.U;\n}\n\n// ---------------------------------------------------------------------------\n// Parse `git show` output to extract per-file change info\n// ---------------------------------------------------------------------------\n\nexport type CommitFileChange = {\n  path: string;\n  directory: string;\n  filename: string;\n  status: FileStatusCode;\n  insertions: number;\n  deletions: number;\n};\n\nexport type CommitFileSummary = {\n  files: CommitFileChange[];\n  totalFiles: number;\n  totalInsertions: number;\n  totalDeletions: number;\n};\n\nexport function parseCommitFiles(showOutput: string): CommitFileSummary {\n  const files: CommitFileChange[] = [];\n  // Split on file diff boundaries\n  const fileDiffs = showOutput.split(/^diff --git /m).slice(1);\n\n  for (const section of fileDiffs) {\n    const lines = section.split('\\n');\n    // Extract path from \"a/path b/path\"\n    const header = lines[0] ?? '';\n    const match = header.match(/^a\\/(.+?) b\\/(.+)/);\n    if (!match) continue;\n\n    const pathA = match[1];\n    const pathB = match[2];\n\n    // Determine status\n    let status: FileStatusCode = 'M';\n    const joined = lines.slice(0, 6).join('\\n');\n    if (joined.includes('new file mode')) status = 'A';\n    else if (joined.includes('deleted file mode')) status = 'D';\n\n    const filePath = status === 'D' ? pathA : pathB;\n\n    // Count insertions/deletions (lines starting with +/- but not +++/---)\n    let insertions = 0;\n    let deletions = 0;\n    for (const line of lines) {\n      if (line.startsWith('+++') || line.startsWith('---')) continue;\n      if (line.startsWith('+')) insertions++;\n      else if (line.startsWith('-')) deletions++;\n    }\n\n    const lastSlash = filePath.lastIndexOf('/');\n    const directory = lastSlash >= 0 ? filePath.substring(0, lastSlash + 1) : '';\n    const filename = lastSlash >= 0 ? filePath.substring(lastSlash + 1) : filePath;\n\n    files.push({ path: filePath, directory, filename, status, insertions, deletions });\n  }\n\n  return {\n    files,\n    totalFiles: files.length,\n    totalInsertions: files.reduce((sum, f) => sum + f.insertions, 0),\n    totalDeletions: files.reduce((sum, f) => sum + f.deletions, 0),\n  };\n}\n"
  },
  {
    "path": "src/components/git-panel/view/GitPanel.tsx",
    "content": "import { useCallback, useState } from 'react';\nimport { useGitPanelController } from '../hooks/useGitPanelController';\nimport { useRevertLocalCommit } from '../hooks/useRevertLocalCommit';\nimport type { ConfirmationRequest, GitPanelProps, GitPanelView } from '../types/types';\nimport { getChangedFileCount } from '../utils/gitPanelUtils';\nimport ChangesView from '../view/changes/ChangesView';\nimport HistoryView from '../view/history/HistoryView';\nimport BranchesView from '../view/branches/BranchesView';\nimport GitPanelHeader from '../view/GitPanelHeader';\nimport GitRepositoryErrorState from '../view/GitRepositoryErrorState';\nimport GitViewTabs from '../view/GitViewTabs';\nimport ConfirmActionModal from '../view/modals/ConfirmActionModal';\n\nexport default function GitPanel({ selectedProject, isMobile = false, onFileOpen }: GitPanelProps) {\n  const [activeView, setActiveView] = useState<GitPanelView>('changes');\n  const [wrapText, setWrapText] = useState(true);\n  const [hasExpandedFiles, setHasExpandedFiles] = useState(false);\n  const [confirmAction, setConfirmAction] = useState<ConfirmationRequest | null>(null);\n\n  const {\n    gitStatus,\n    gitDiff,\n    isLoading,\n    currentBranch,\n    branches,\n    localBranches,\n    remoteBranches,\n    recentCommits,\n    commitDiffs,\n    remoteStatus,\n    isCreatingBranch,\n    isFetching,\n    isPulling,\n    isPushing,\n    isPublishing,\n    isCreatingInitialCommit,\n    operationError,\n    clearOperationError,\n    refreshAll,\n    switchBranch,\n    createBranch,\n    deleteBranch,\n    handleFetch,\n    handlePull,\n    handlePush,\n    handlePublish,\n    discardChanges,\n    deleteUntrackedFile,\n    fetchCommitDiff,\n    generateCommitMessage,\n    commitChanges,\n    createInitialCommit,\n    openFile,\n  } = useGitPanelController({\n    selectedProject,\n    activeView,\n    onFileOpen,\n  });\n\n  const { isRevertingLocalCommit, revertLatestLocalCommit } = useRevertLocalCommit({\n    projectName: selectedProject?.name ?? null,\n    onSuccess: refreshAll,\n  });\n\n  const executeConfirmedAction = useCallback(async () => {\n    if (!confirmAction) return;\n    const actionToExecute = confirmAction;\n    setConfirmAction(null);\n    try {\n      await actionToExecute.onConfirm();\n    } catch (error) {\n      console.error('Error executing confirmation action:', error);\n    }\n  }, [confirmAction]);\n\n  const changeCount = getChangedFileCount(gitStatus);\n\n  if (!selectedProject) {\n    return (\n      <div className=\"flex h-full items-center justify-center text-muted-foreground\">\n        <p>Select a project to view source control</p>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"flex h-full flex-col bg-background\">\n      <GitPanelHeader\n        isMobile={isMobile}\n        currentBranch={currentBranch}\n        branches={branches}\n        remoteStatus={remoteStatus}\n        isLoading={isLoading}\n        isCreatingBranch={isCreatingBranch}\n        isFetching={isFetching}\n        isPulling={isPulling}\n        isPushing={isPushing}\n        isPublishing={isPublishing}\n        isRevertingLocalCommit={isRevertingLocalCommit}\n        operationError={operationError}\n        onRefresh={refreshAll}\n        onRevertLocalCommit={revertLatestLocalCommit}\n        onSwitchBranch={switchBranch}\n        onCreateBranch={createBranch}\n        onFetch={handleFetch}\n        onPull={handlePull}\n        onPush={handlePush}\n        onPublish={handlePublish}\n        onClearError={clearOperationError}\n        onRequestConfirmation={setConfirmAction}\n      />\n\n      {gitStatus?.error ? (\n        <GitRepositoryErrorState error={gitStatus.error} details={gitStatus.details} />\n      ) : (\n        <>\n          <GitViewTabs\n            activeView={activeView}\n            isHidden={hasExpandedFiles}\n            changeCount={changeCount}\n            onChange={setActiveView}\n          />\n\n          {activeView === 'changes' && (\n            <ChangesView\n              key={selectedProject.fullPath}\n              isMobile={isMobile}\n              projectPath={selectedProject.fullPath}\n              gitStatus={gitStatus}\n              gitDiff={gitDiff}\n              isLoading={isLoading}\n              wrapText={wrapText}\n              isCreatingInitialCommit={isCreatingInitialCommit}\n              onWrapTextChange={setWrapText}\n              onCreateInitialCommit={createInitialCommit}\n              onOpenFile={openFile}\n              onDiscardFile={discardChanges}\n              onDeleteFile={deleteUntrackedFile}\n              onCommitChanges={commitChanges}\n              onGenerateCommitMessage={generateCommitMessage}\n              onRequestConfirmation={setConfirmAction}\n              onExpandedFilesChange={setHasExpandedFiles}\n            />\n          )}\n\n          {activeView === 'history' && (\n            <HistoryView\n              isMobile={isMobile}\n              isLoading={isLoading}\n              recentCommits={recentCommits}\n              commitDiffs={commitDiffs}\n              wrapText={wrapText}\n              onFetchCommitDiff={fetchCommitDiff}\n            />\n          )}\n\n          {activeView === 'branches' && (\n            <BranchesView\n              isMobile={isMobile}\n              isLoading={isLoading}\n              currentBranch={currentBranch}\n              localBranches={localBranches}\n              remoteBranches={remoteBranches}\n              remoteStatus={remoteStatus}\n              isCreatingBranch={isCreatingBranch}\n              onSwitchBranch={switchBranch}\n              onCreateBranch={createBranch}\n              onDeleteBranch={deleteBranch}\n              onRequestConfirmation={setConfirmAction}\n            />\n          )}\n        </>\n      )}\n\n      <ConfirmActionModal\n        action={confirmAction}\n        onCancel={() => setConfirmAction(null)}\n        onConfirm={() => {\n          void executeConfirmedAction();\n        }}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/git-panel/view/GitPanelHeader.tsx",
    "content": "import { AlertCircle, Check, ChevronDown, Download, GitBranch, Plus, RefreshCw, RotateCcw, Upload, X } from 'lucide-react';\nimport { useEffect, useRef, useState } from 'react';\nimport type { ConfirmationRequest, GitRemoteStatus } from '../types/types';\nimport NewBranchModal from './modals/NewBranchModal';\n\ntype GitPanelHeaderProps = {\n  isMobile: boolean;\n  currentBranch: string;\n  branches: string[];\n  remoteStatus: GitRemoteStatus | null;\n  isLoading: boolean;\n  isCreatingBranch: boolean;\n  isFetching: boolean;\n  isPulling: boolean;\n  isPushing: boolean;\n  isPublishing: boolean;\n  isRevertingLocalCommit: boolean;\n  operationError: string | null;\n  onRefresh: () => void;\n  onRevertLocalCommit: () => Promise<void>;\n  onSwitchBranch: (branchName: string) => Promise<boolean>;\n  onCreateBranch: (branchName: string) => Promise<boolean>;\n  onFetch: () => Promise<void>;\n  onPull: () => Promise<void>;\n  onPush: () => Promise<void>;\n  onPublish: () => Promise<void>;\n  onClearError: () => void;\n  onRequestConfirmation: (request: ConfirmationRequest) => void;\n};\n\nexport default function GitPanelHeader({\n  isMobile,\n  currentBranch,\n  branches,\n  remoteStatus,\n  isLoading,\n  isCreatingBranch,\n  isFetching,\n  isPulling,\n  isPushing,\n  isPublishing,\n  isRevertingLocalCommit,\n  operationError,\n  onRefresh,\n  onRevertLocalCommit,\n  onSwitchBranch,\n  onCreateBranch,\n  onFetch,\n  onPull,\n  onPush,\n  onPublish,\n  onClearError,\n  onRequestConfirmation,\n}: GitPanelHeaderProps) {\n  const [showBranchDropdown, setShowBranchDropdown] = useState(false);\n  const [showNewBranchModal, setShowNewBranchModal] = useState(false);\n  const dropdownRef = useRef<HTMLDivElement | null>(null);\n\n  useEffect(() => {\n    const handleClickOutside = (event: MouseEvent) => {\n      if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {\n        setShowBranchDropdown(false);\n      }\n    };\n\n    document.addEventListener('mousedown', handleClickOutside);\n    return () => document.removeEventListener('mousedown', handleClickOutside);\n  }, []);\n\n  const aheadCount = remoteStatus?.ahead ?? 0;\n  const behindCount = remoteStatus?.behind ?? 0;\n  const remoteName = remoteStatus?.remoteName ?? 'remote';\n  const anyPending = isFetching || isPulling || isPushing || isPublishing;\n\n  const requestPullConfirmation = () => {\n    onRequestConfirmation({\n      type: 'pull',\n      message: `Pull ${behindCount} commit${behindCount !== 1 ? 's' : ''} from ${remoteName}?`,\n      onConfirm: onPull,\n    });\n  };\n\n  const requestPushConfirmation = () => {\n    onRequestConfirmation({\n      type: 'push',\n      message: `Push ${aheadCount} commit${aheadCount !== 1 ? 's' : ''} to ${remoteName}?`,\n      onConfirm: onPush,\n    });\n  };\n\n  const requestPublishConfirmation = () => {\n    onRequestConfirmation({\n      type: 'publish',\n      message: `Publish branch \"${currentBranch}\" to ${remoteName}?`,\n      onConfirm: onPublish,\n    });\n  };\n\n  const requestRevertLocalCommitConfirmation = () => {\n    onRequestConfirmation({\n      type: 'revertLocalCommit',\n      message: 'Revert the latest local commit? This removes the commit but keeps its changes staged.',\n      onConfirm: onRevertLocalCommit,\n    });\n  };\n\n  const handleSwitchBranch = async (branchName: string) => {\n    try {\n      const success = await onSwitchBranch(branchName);\n      if (success) setShowBranchDropdown(false);\n    } catch (error) {\n      console.error('[GitPanelHeader] Failed to switch branch:', error);\n    }\n  };\n\n  return (\n    <>\n      {/* Branch row + action buttons */}\n      <div className={`flex items-center justify-between border-b border-border/60 ${isMobile ? 'px-3 py-2' : 'px-4 py-3'}`}>\n        {/* Branch selector */}\n        <div className=\"relative\" ref={dropdownRef}>\n          <button\n            onClick={() => setShowBranchDropdown((prev) => !prev)}\n            className={`flex items-center rounded-lg transition-colors hover:bg-accent ${isMobile ? 'space-x-1 px-2 py-1' : 'space-x-2 px-3 py-1.5'}`}\n          >\n            <GitBranch className={`text-muted-foreground ${isMobile ? 'h-3 w-3' : 'h-4 w-4'}`} />\n            <span className=\"flex items-center gap-1\">\n              <span className={`font-medium ${isMobile ? 'text-xs' : 'text-sm'}`}>{currentBranch}</span>\n              {remoteStatus?.hasRemote && (\n                <span className=\"flex items-center gap-0.5 text-xs\">\n                  {aheadCount > 0 && (\n                    <span className=\"text-green-600 dark:text-green-400\" title={`${aheadCount} ahead`}>\n                      ↑{aheadCount}\n                    </span>\n                  )}\n                  {behindCount > 0 && (\n                    <span className=\"text-primary\" title={`${behindCount} behind`}>\n                      ↓{behindCount}\n                    </span>\n                  )}\n                  {remoteStatus.isUpToDate && (\n                    <span className=\"text-muted-foreground\" title=\"Up to date\">✓</span>\n                  )}\n                </span>\n              )}\n            </span>\n            <ChevronDown className={`h-3 w-3 text-muted-foreground transition-transform ${showBranchDropdown ? 'rotate-180' : ''}`} />\n          </button>\n\n          {showBranchDropdown && (\n            <div className=\"absolute left-0 top-full z-50 mt-1 w-64 overflow-hidden rounded-xl border border-border bg-card shadow-lg\">\n              <div className=\"max-h-64 overflow-y-auto py-1\">\n                {branches.map((branch) => (\n                  <button\n                    key={branch}\n                    onClick={() => void handleSwitchBranch(branch)}\n                    className={`w-full px-4 py-2 text-left text-sm transition-colors hover:bg-accent ${\n                      branch === currentBranch ? 'bg-accent/50 text-foreground' : 'text-muted-foreground'\n                    }`}\n                  >\n                    <span className=\"flex items-center space-x-2\">\n                      {branch === currentBranch && <Check className=\"h-3 w-3 text-primary\" />}\n                      <span className={branch === currentBranch ? 'font-medium' : ''}>{branch}</span>\n                    </span>\n                  </button>\n                ))}\n              </div>\n              <div className=\"border-t border-border py-1\">\n                <button\n                  onClick={() => {\n                    setShowNewBranchModal(true);\n                    setShowBranchDropdown(false);\n                  }}\n                  className=\"flex w-full items-center space-x-2 px-4 py-2 text-left text-sm transition-colors hover:bg-accent\"\n                >\n                  <Plus className=\"h-3 w-3\" />\n                  <span>Create new branch</span>\n                </button>\n              </div>\n            </div>\n          )}\n        </div>\n\n        {/* Action buttons */}\n        <div className={`flex items-center ${isMobile ? 'gap-1' : 'gap-2'}`}>\n          {remoteStatus?.hasRemote && (\n            <>\n              {!remoteStatus.hasUpstream ? (\n                <button\n                  onClick={requestPublishConfirmation}\n                  disabled={anyPending}\n                  className=\"flex items-center gap-1 rounded-lg bg-purple-600 px-2.5 py-1 text-sm text-white transition-colors hover:bg-purple-700 disabled:opacity-50\"\n                  title={`Publish \"${currentBranch}\" to ${remoteName}`}\n                >\n                  <Upload className={`h-3 w-3 ${isPublishing ? 'animate-pulse' : ''}`} />\n                  {!isMobile && <span>{isPublishing ? 'Publishing…' : 'Publish'}</span>}\n                </button>\n              ) : (\n                <>\n                  {/* Fetch — always visible when remote exists */}\n                  <button\n                    onClick={() => void onFetch()}\n                    disabled={anyPending}\n                    className=\"flex items-center gap-1 rounded-lg bg-primary px-2.5 py-1 text-sm text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50\"\n                    title={`Fetch from ${remoteName}`}\n                  >\n                    <RefreshCw className={`h-3 w-3 ${isFetching ? 'animate-spin' : ''}`} />\n                    {!isMobile && <span>{isFetching ? 'Fetching…' : 'Fetch'}</span>}\n                  </button>\n\n                  {behindCount > 0 && (\n                    <button\n                      onClick={requestPullConfirmation}\n                      disabled={anyPending}\n                      className=\"flex items-center gap-1 rounded-lg bg-green-600 px-2.5 py-1 text-sm text-white transition-colors hover:bg-green-700 disabled:opacity-50\"\n                      title={`Pull ${behindCount} from ${remoteName}`}\n                    >\n                      <Download className={`h-3 w-3 ${isPulling ? 'animate-pulse' : ''}`} />\n                      {!isMobile && <span>{isPulling ? 'Pulling…' : `Pull ${behindCount}`}</span>}\n                    </button>\n                  )}\n\n                  {aheadCount > 0 && (\n                    <button\n                      onClick={requestPushConfirmation}\n                      disabled={anyPending}\n                      className=\"flex items-center gap-1 rounded-lg bg-orange-600 px-2.5 py-1 text-sm text-white transition-colors hover:bg-orange-700 disabled:opacity-50\"\n                      title={`Push ${aheadCount} to ${remoteName}`}\n                    >\n                      <Upload className={`h-3 w-3 ${isPushing ? 'animate-pulse' : ''}`} />\n                      {!isMobile && <span>{isPushing ? 'Pushing…' : `Push ${aheadCount}`}</span>}\n                    </button>\n                  )}\n                </>\n              )}\n            </>\n          )}\n\n          <button\n            onClick={requestRevertLocalCommitConfirmation}\n            disabled={isRevertingLocalCommit}\n            className={`rounded-lg transition-colors hover:bg-accent disabled:opacity-50 ${isMobile ? 'p-1' : 'p-1.5'}`}\n            title=\"Revert latest local commit\"\n          >\n            <RotateCcw\n              className={`text-muted-foreground ${isRevertingLocalCommit ? 'animate-pulse' : ''} ${isMobile ? 'h-3 w-3' : 'h-4 w-4'}`}\n            />\n          </button>\n\n          <button\n            onClick={onRefresh}\n            disabled={isLoading}\n            className={`rounded-lg transition-colors hover:bg-accent ${isMobile ? 'p-1' : 'p-1.5'}`}\n            title=\"Refresh git status\"\n          >\n            <RefreshCw className={`text-muted-foreground ${isLoading ? 'animate-spin' : ''} ${isMobile ? 'h-3 w-3' : 'h-4 w-4'}`} />\n          </button>\n        </div>\n      </div>\n\n      {/* Inline error banner */}\n      {operationError && (\n        <div className=\"flex items-start gap-2 border-b border-destructive/20 bg-destructive/10 px-4 py-2.5 text-sm text-destructive\">\n          <AlertCircle className=\"mt-0.5 h-4 w-4 shrink-0\" />\n          <span className=\"flex-1 leading-snug\">{operationError}</span>\n          <button\n            onClick={onClearError}\n            className=\"shrink-0 rounded p-0.5 hover:bg-destructive/20\"\n            aria-label=\"Dismiss error\"\n          >\n            <X className=\"h-3.5 w-3.5\" />\n          </button>\n        </div>\n      )}\n\n      <NewBranchModal\n        isOpen={showNewBranchModal}\n        currentBranch={currentBranch}\n        isCreatingBranch={isCreatingBranch}\n        onClose={() => setShowNewBranchModal(false)}\n        onCreateBranch={onCreateBranch}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "src/components/git-panel/view/GitRepositoryErrorState.tsx",
    "content": "import { GitBranch } from 'lucide-react';\n\ntype GitRepositoryErrorStateProps = {\n  error: string;\n  details?: string;\n};\n\nexport default function GitRepositoryErrorState({ error, details }: GitRepositoryErrorStateProps) {\n  return (\n    <div className=\"flex flex-1 flex-col items-center justify-center px-6 py-12 text-muted-foreground\">\n      <div className=\"mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-muted/50\">\n        <GitBranch className=\"h-8 w-8 opacity-40\" />\n      </div>\n      <h3 className=\"mb-3 text-center text-lg font-medium text-foreground\">{error}</h3>\n      {details && (\n        <p className=\"mb-6 max-w-md text-center text-sm leading-relaxed\">{details}</p>\n      )}\n      <div className=\"max-w-md rounded-xl border border-primary/10 bg-primary/5 p-4\">\n        <p className=\"text-center text-sm text-primary\">\n          <strong>Tip:</strong> Run{' '}\n          <code className=\"rounded-md bg-primary/10 px-2 py-1 font-mono text-xs\">git init</code>{' '}\n          in your project directory to initialize git source control.\n        </p>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/git-panel/view/GitViewTabs.tsx",
    "content": "import { FileText, GitBranch, History } from 'lucide-react';\nimport type { GitPanelView } from '../types/types';\n\ntype GitViewTabsProps = {\n  activeView: GitPanelView;\n  isHidden: boolean;\n  changeCount: number;\n  onChange: (view: GitPanelView) => void;\n};\n\nconst TABS: { id: GitPanelView; label: string; Icon: typeof FileText }[] = [\n  { id: 'changes', label: 'Changes', Icon: FileText },\n  { id: 'history', label: 'Commits', Icon: History },\n  { id: 'branches', label: 'Branches', Icon: GitBranch },\n];\n\nexport default function GitViewTabs({ activeView, isHidden, changeCount, onChange }: GitViewTabsProps) {\n  return (\n    <div\n      className={`flex border-b border-border/60 transition-all duration-300 ease-in-out ${\n        isHidden ? 'max-h-0 -translate-y-2 overflow-hidden opacity-0' : 'max-h-16 translate-y-0 opacity-100'\n      }`}\n    >\n      {TABS.map(({ id, label, Icon }) => (\n        <button\n          key={id}\n          onClick={() => onChange(id)}\n          className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${\n            activeView === id\n              ? 'border-b-2 border-primary text-primary'\n              : 'text-muted-foreground hover:text-foreground'\n          }`}\n        >\n          <span className=\"flex items-center justify-center gap-2\">\n            <Icon className=\"h-4 w-4\" />\n            <span>{label}</span>\n            {id === 'changes' && changeCount > 0 && (\n              <span className=\"rounded-full bg-primary/15 px-1.5 py-0.5 text-xs font-semibold text-primary\">\n                {changeCount}\n              </span>\n            )}\n          </span>\n        </button>\n      ))}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/git-panel/view/branches/BranchesView.tsx",
    "content": "import { Check, GitBranch, Globe, Plus, RefreshCw, Trash2 } from 'lucide-react';\nimport { useState } from 'react';\nimport type { ConfirmationRequest, GitRemoteStatus } from '../../types/types';\nimport NewBranchModal from '../modals/NewBranchModal';\n\ntype BranchesViewProps = {\n  isMobile: boolean;\n  isLoading: boolean;\n  currentBranch: string;\n  localBranches: string[];\n  remoteBranches: string[];\n  remoteStatus: GitRemoteStatus | null;\n  isCreatingBranch: boolean;\n  onSwitchBranch: (branchName: string) => Promise<boolean>;\n  onCreateBranch: (branchName: string) => Promise<boolean>;\n  onDeleteBranch: (branchName: string) => Promise<boolean>;\n  onRequestConfirmation: (request: ConfirmationRequest) => void;\n};\n\n// ---------------------------------------------------------------------------\n// Branch row\n// ---------------------------------------------------------------------------\n\ntype BranchRowProps = {\n  name: string;\n  isCurrent: boolean;\n  isRemote: boolean;\n  aheadCount: number;\n  behindCount: number;\n  isMobile: boolean;\n  onSwitch: () => void;\n  onDelete: () => void;\n};\n\nfunction BranchRow({ name, isCurrent, isRemote, aheadCount, behindCount, isMobile, onSwitch, onDelete }: BranchRowProps) {\n  return (\n    <div\n      className={`group flex items-center gap-3 border-b border-border/40 px-4 transition-colors hover:bg-accent/40 ${\n        isMobile ? 'py-2.5' : 'py-3'\n      } ${isCurrent ? 'bg-primary/5' : ''}`}\n    >\n      {/* Branch icon */}\n      <div className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-md border ${\n        isCurrent\n          ? 'border-primary/30 bg-primary/10 text-primary'\n          : isRemote\n          ? 'border-border bg-muted text-muted-foreground'\n          : 'border-border bg-muted/50 text-muted-foreground'\n      }`}>\n        {isRemote ? <Globe className=\"h-3.5 w-3.5\" /> : <GitBranch className=\"h-3.5 w-3.5\" />}\n      </div>\n\n      {/* Name + pills */}\n      <div className=\"flex min-w-0 flex-1 flex-col gap-0.5\">\n        <div className=\"flex items-center gap-2\">\n          <span className={`truncate text-sm font-medium ${isCurrent ? 'text-foreground' : 'text-foreground/80'}`}>\n            {name}\n          </span>\n          {isCurrent && (\n            <span className=\"shrink-0 rounded-full bg-primary/15 px-1.5 py-0.5 text-xs font-semibold text-primary\">\n              current\n            </span>\n          )}\n          {isRemote && !isCurrent && (\n            <span className=\"shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-xs text-muted-foreground\">\n              remote\n            </span>\n          )}\n        </div>\n        {/* Ahead/behind — only meaningful for the current branch */}\n        {isCurrent && (aheadCount > 0 || behindCount > 0) && (\n          <div className=\"flex items-center gap-2 text-xs\">\n            {aheadCount > 0 && (\n              <span className=\"text-green-600 dark:text-green-400\">↑{aheadCount} ahead</span>\n            )}\n            {behindCount > 0 && (\n              <span className=\"text-primary\">↓{behindCount} behind</span>\n            )}\n          </div>\n        )}\n      </div>\n\n      {/* Actions */}\n      <div className={`flex shrink-0 items-center gap-1 ${isCurrent ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`}>\n        {isCurrent ? (\n          <Check className=\"h-4 w-4 text-primary\" />\n        ) : !isRemote ? (\n          <>\n            <button\n              onClick={onSwitch}\n              className=\"rounded-md px-2 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground\"\n              title={`Switch to ${name}`}\n            >\n              Switch\n            </button>\n            <button\n              onClick={onDelete}\n              className=\"rounded-md p-1 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive\"\n              title={`Delete ${name}`}\n            >\n              <Trash2 className=\"h-3.5 w-3.5\" />\n            </button>\n          </>\n        ) : null}\n      </div>\n    </div>\n  );\n}\n\n// ---------------------------------------------------------------------------\n// Section header\n// ---------------------------------------------------------------------------\n\nfunction SectionHeader({ label, count }: { label: string; count: number }) {\n  return (\n    <div className=\"sticky top-0 z-10 flex items-center justify-between bg-background/95 px-4 py-2 backdrop-blur-sm\">\n      <span className=\"text-xs font-semibold uppercase tracking-wider text-muted-foreground\">{label}</span>\n      <span className=\"rounded-full bg-muted px-1.5 py-0.5 text-xs text-muted-foreground\">{count}</span>\n    </div>\n  );\n}\n\n// ---------------------------------------------------------------------------\n// BranchesView\n// ---------------------------------------------------------------------------\n\nexport default function BranchesView({\n  isMobile,\n  isLoading,\n  currentBranch,\n  localBranches,\n  remoteBranches,\n  remoteStatus,\n  isCreatingBranch,\n  onSwitchBranch,\n  onCreateBranch,\n  onDeleteBranch,\n  onRequestConfirmation,\n}: BranchesViewProps) {\n  const [showNewBranchModal, setShowNewBranchModal] = useState(false);\n\n  const aheadCount = remoteStatus?.ahead ?? 0;\n  const behindCount = remoteStatus?.behind ?? 0;\n\n  const requestSwitch = (branch: string) => {\n    onRequestConfirmation({\n      type: 'commit', // reuse neutral type for switch\n      message: `Switch to branch \"${branch}\"? Make sure you have no uncommitted changes.`,\n      onConfirm: () => void onSwitchBranch(branch),\n    });\n  };\n\n  const requestDelete = (branch: string) => {\n    onRequestConfirmation({\n      type: 'deleteBranch',\n      message: `Delete branch \"${branch}\"? This cannot be undone.`,\n      onConfirm: () => void onDeleteBranch(branch),\n    });\n  };\n\n  if (isLoading && localBranches.length === 0) {\n    return (\n      <div className=\"flex h-32 items-center justify-center\">\n        <RefreshCw className=\"h-5 w-5 animate-spin text-muted-foreground\" />\n      </div>\n    );\n  }\n\n  return (\n    <div className={`flex flex-1 flex-col overflow-hidden ${isMobile ? 'pb-mobile-nav' : ''}`}>\n      {/* Create branch button */}\n      <div className=\"flex items-center justify-between border-b border-border/40 px-4 py-2.5\">\n        <span className=\"text-sm text-muted-foreground\">\n          {localBranches.length} local{remoteBranches.length > 0 ? `, ${remoteBranches.length} remote` : ''}\n        </span>\n        <button\n          onClick={() => setShowNewBranchModal(true)}\n          className=\"flex items-center gap-1.5 rounded-lg bg-primary/10 px-3 py-1.5 text-sm font-medium text-primary transition-colors hover:bg-primary/20\"\n        >\n          <Plus className=\"h-3.5 w-3.5\" />\n          New branch\n        </button>\n      </div>\n\n      {/* Branch list */}\n      <div className=\"flex-1 overflow-y-auto\">\n        {localBranches.length > 0 && (\n          <>\n            <SectionHeader label=\"Local\" count={localBranches.length} />\n            {localBranches.map((branch) => (\n              <BranchRow\n                key={`local:${branch}`}\n                name={branch}\n                isCurrent={branch === currentBranch}\n                isRemote={false}\n                aheadCount={branch === currentBranch ? aheadCount : 0}\n                behindCount={branch === currentBranch ? behindCount : 0}\n                isMobile={isMobile}\n                onSwitch={() => requestSwitch(branch)}\n                onDelete={() => requestDelete(branch)}\n              />\n            ))}\n          </>\n        )}\n\n        {remoteBranches.length > 0 && (\n          <>\n            <SectionHeader label=\"Remote\" count={remoteBranches.length} />\n            {remoteBranches.map((branch) => (\n              <BranchRow\n                key={`remote:${branch}`}\n                name={branch}\n                isCurrent={false}\n                isRemote={true}\n                aheadCount={0}\n                behindCount={0}\n                isMobile={isMobile}\n                onSwitch={() => requestSwitch(branch)}\n                onDelete={() => requestDelete(branch)}\n              />\n            ))}\n          </>\n        )}\n\n        {localBranches.length === 0 && remoteBranches.length === 0 && (\n          <div className=\"flex h-32 flex-col items-center justify-center gap-2 text-muted-foreground\">\n            <GitBranch className=\"h-10 w-10 opacity-30\" />\n            <p className=\"text-sm\">No branches found</p>\n          </div>\n        )}\n      </div>\n\n      <NewBranchModal\n        isOpen={showNewBranchModal}\n        currentBranch={currentBranch}\n        isCreatingBranch={isCreatingBranch}\n        onClose={() => setShowNewBranchModal(false)}\n        onCreateBranch={onCreateBranch}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/git-panel/view/changes/ChangesView.tsx",
    "content": "import { GitBranch, GitCommit, RefreshCw } from 'lucide-react';\nimport { useCallback, useEffect, useMemo, useState } from 'react';\nimport type { ConfirmationRequest, FileStatusCode, GitDiffMap, GitStatusResponse } from '../../types/types';\nimport { getAllChangedFiles, hasChangedFiles } from '../../utils/gitPanelUtils';\nimport CommitComposer from './CommitComposer';\nimport FileChangeList from './FileChangeList';\nimport FileStatusLegend from './FileStatusLegend';\n\ntype ChangesViewProps = {\n  isMobile: boolean;\n  projectPath: string;\n  gitStatus: GitStatusResponse | null;\n  gitDiff: GitDiffMap;\n  isLoading: boolean;\n  wrapText: boolean;\n  isCreatingInitialCommit: boolean;\n  onWrapTextChange: (wrapText: boolean) => void;\n  onCreateInitialCommit: () => Promise<boolean>;\n  onOpenFile: (filePath: string) => Promise<void>;\n  onDiscardFile: (filePath: string) => Promise<void>;\n  onDeleteFile: (filePath: string) => Promise<void>;\n  onCommitChanges: (message: string, files: string[]) => Promise<boolean>;\n  onGenerateCommitMessage: (files: string[]) => Promise<string | null>;\n  onRequestConfirmation: (request: ConfirmationRequest) => void;\n  onExpandedFilesChange: (hasExpandedFiles: boolean) => void;\n};\n\nexport default function ChangesView({\n  isMobile,\n  projectPath,\n  gitStatus,\n  gitDiff,\n  isLoading,\n  wrapText,\n  isCreatingInitialCommit,\n  onWrapTextChange,\n  onCreateInitialCommit,\n  onOpenFile,\n  onDiscardFile,\n  onDeleteFile,\n  onCommitChanges,\n  onGenerateCommitMessage,\n  onRequestConfirmation,\n  onExpandedFilesChange,\n}: ChangesViewProps) {\n  const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());\n  const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());\n\n  const changedFiles = useMemo(() => getAllChangedFiles(gitStatus), [gitStatus]);\n  const hasExpandedFiles = expandedFiles.size > 0;\n\n  useEffect(() => {\n    if (!gitStatus || gitStatus.error) {\n      setSelectedFiles(new Set());\n      return;\n    }\n\n    // Remove any selected files that no longer exist in the status\n    setSelectedFiles((prev) => {\n      const allFiles = new Set(getAllChangedFiles(gitStatus));\n      const next = new Set([...prev].filter((f) => allFiles.has(f)));\n      return next;\n    });\n  }, [gitStatus]);\n\n  useEffect(() => {\n    onExpandedFilesChange(hasExpandedFiles);\n  }, [hasExpandedFiles, onExpandedFilesChange]);\n\n  useEffect(() => {\n    return () => {\n      onExpandedFilesChange(false);\n    };\n  }, [onExpandedFilesChange]);\n\n  const toggleFileExpanded = useCallback((filePath: string) => {\n    setExpandedFiles((previous) => {\n      const next = new Set(previous);\n      if (next.has(filePath)) {\n        next.delete(filePath);\n      } else {\n        next.add(filePath);\n      }\n      return next;\n    });\n  }, []);\n\n  const toggleFileSelected = useCallback((filePath: string) => {\n    setSelectedFiles((previous) => {\n      const next = new Set(previous);\n      if (next.has(filePath)) {\n        next.delete(filePath);\n      } else {\n        next.add(filePath);\n      }\n      return next;\n    });\n  }, []);\n\n  const requestFileAction = useCallback(\n    (filePath: string, status: FileStatusCode) => {\n      if (status === 'U') {\n        onRequestConfirmation({\n          type: 'delete',\n          message: `Delete untracked file \"${filePath}\"? This action cannot be undone.`,\n          onConfirm: async () => {\n            await onDeleteFile(filePath);\n          },\n        });\n        return;\n      }\n\n      onRequestConfirmation({\n        type: 'discard',\n        message: `Discard all changes to \"${filePath}\"? This action cannot be undone.`,\n        onConfirm: async () => {\n          await onDiscardFile(filePath);\n        },\n      });\n    },\n    [onDeleteFile, onDiscardFile, onRequestConfirmation],\n  );\n\n  const commitSelectedFiles = useCallback(\n    (message: string) => {\n      return onCommitChanges(message, Array.from(selectedFiles));\n    },\n    [onCommitChanges, selectedFiles],\n  );\n\n  const generateMessageForSelection = useCallback(() => {\n    return onGenerateCommitMessage(Array.from(selectedFiles));\n  }, [onGenerateCommitMessage, selectedFiles]);\n\n  const unstagedFiles = useMemo(\n    () => new Set(changedFiles.filter((f) => !selectedFiles.has(f))),\n    [changedFiles, selectedFiles],\n  );\n\n  return (\n    <>\n      <CommitComposer\n        isMobile={isMobile}\n        projectPath={projectPath}\n        selectedFileCount={selectedFiles.size}\n        isHidden={hasExpandedFiles}\n        onCommit={commitSelectedFiles}\n        onGenerateMessage={generateMessageForSelection}\n        onRequestConfirmation={onRequestConfirmation}\n      />\n\n      {!gitStatus?.error && <FileStatusLegend isMobile={isMobile} />}\n\n      <div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-mobile-nav' : ''}`}>\n        {isLoading ? (\n          <div className=\"flex h-32 items-center justify-center\">\n            <RefreshCw className=\"h-5 w-5 animate-spin text-muted-foreground\" />\n          </div>\n        ) : gitStatus?.hasCommits === false ? (\n          <div className=\"flex flex-col items-center justify-center p-8 text-center\">\n            <div className=\"mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-muted/50\">\n              <GitBranch className=\"h-7 w-7 text-muted-foreground/50\" />\n            </div>\n            <h3 className=\"mb-2 text-lg font-medium text-foreground\">No commits yet</h3>\n            <p className=\"mb-6 max-w-md text-sm text-muted-foreground\">\n              This repository doesn&apos;t have any commits yet. Create your first commit to start tracking changes.\n            </p>\n            <button\n              onClick={() => void onCreateInitialCommit()}\n              disabled={isCreatingInitialCommit}\n              className=\"flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50\"\n            >\n              {isCreatingInitialCommit ? (\n                <>\n                  <RefreshCw className=\"h-4 w-4 animate-spin\" />\n                  <span>Creating Initial Commit...</span>\n                </>\n              ) : (\n                <>\n                  <GitCommit className=\"h-4 w-4\" />\n                  <span>Create Initial Commit</span>\n                </>\n              )}\n            </button>\n          </div>\n        ) : !gitStatus || !hasChangedFiles(gitStatus) ? (\n          <div className=\"flex h-32 flex-col items-center justify-center text-muted-foreground\">\n            <GitCommit className=\"mb-2 h-10 w-10 opacity-40\" />\n            <p className=\"text-sm\">No changes detected</p>\n          </div>\n        ) : (\n          <div className={isMobile ? 'pb-4' : ''}>\n            {/* STAGED section */}\n            <div className=\"flex items-center justify-between border-b border-border/60 bg-muted/30 px-3 py-1.5\">\n              <span className=\"text-xs font-semibold uppercase tracking-wide text-muted-foreground\">\n                Staged ({selectedFiles.size})\n              </span>\n              {selectedFiles.size > 0 && (\n                <button\n                  onClick={() => setSelectedFiles(new Set())}\n                  className=\"text-xs text-primary transition-colors hover:text-primary/80\"\n                >\n                  Unstage All\n                </button>\n              )}\n            </div>\n            {selectedFiles.size === 0 ? (\n              <div className=\"px-3 py-2 text-xs text-muted-foreground italic\">No staged files</div>\n            ) : (\n              <FileChangeList\n                gitStatus={gitStatus}\n                gitDiff={gitDiff}\n                expandedFiles={expandedFiles}\n                selectedFiles={selectedFiles}\n                isMobile={isMobile}\n                wrapText={wrapText}\n                filePaths={selectedFiles}\n                onToggleSelected={toggleFileSelected}\n                onToggleExpanded={toggleFileExpanded}\n                onOpenFile={(filePath) => { void onOpenFile(filePath); }}\n                onToggleWrapText={() => onWrapTextChange(!wrapText)}\n                onRequestFileAction={requestFileAction}\n              />\n            )}\n\n            {/* CHANGES section */}\n            <div className=\"flex items-center justify-between border-b border-border/60 bg-muted/30 px-3 py-1.5\">\n              <span className=\"text-xs font-semibold uppercase tracking-wide text-muted-foreground\">\n                Changes ({unstagedFiles.size})\n              </span>\n              {unstagedFiles.size > 0 && (\n                <button\n                  onClick={() => setSelectedFiles(new Set(changedFiles))}\n                  className=\"text-xs text-primary transition-colors hover:text-primary/80\"\n                >\n                  Stage All\n                </button>\n              )}\n            </div>\n            {unstagedFiles.size === 0 ? (\n              <div className=\"px-3 py-2 text-xs text-muted-foreground italic\">All changes staged</div>\n            ) : (\n              <FileChangeList\n                gitStatus={gitStatus}\n                gitDiff={gitDiff}\n                expandedFiles={expandedFiles}\n                selectedFiles={selectedFiles}\n                isMobile={isMobile}\n                wrapText={wrapText}\n                filePaths={unstagedFiles}\n                onToggleSelected={toggleFileSelected}\n                onToggleExpanded={toggleFileExpanded}\n                onOpenFile={(filePath) => { void onOpenFile(filePath); }}\n                onToggleWrapText={() => onWrapTextChange(!wrapText)}\n                onRequestFileAction={requestFileAction}\n              />\n            )}\n          </div>\n        )}\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "src/components/git-panel/view/changes/CommitComposer.tsx",
    "content": "import { Check, ChevronDown, GitCommit, RefreshCw, Sparkles } from 'lucide-react';\nimport { useState } from 'react';\nimport MicButton from '../../../mic-button/view/MicButton';\nimport type { ConfirmationRequest } from '../../types/types';\n\n// Persists commit messages across unmount/remount, keyed by project path\nconst commitMessageCache = new Map<string, string>();\n\ntype CommitComposerProps = {\n  isMobile: boolean;\n  projectPath: string;\n  selectedFileCount: number;\n  isHidden: boolean;\n  onCommit: (message: string) => Promise<boolean>;\n  onGenerateMessage: () => Promise<string | null>;\n  onRequestConfirmation: (request: ConfirmationRequest) => void;\n};\n\nexport default function CommitComposer({\n  isMobile,\n  projectPath,\n  selectedFileCount,\n  isHidden,\n  onCommit,\n  onGenerateMessage,\n  onRequestConfirmation,\n}: CommitComposerProps) {\n  const [commitMessage, setCommitMessageRaw] = useState(() => commitMessageCache.get(projectPath) ?? '');\n\n  const setCommitMessage = (msg: string) => {\n    setCommitMessageRaw(msg);\n    if (msg) {\n      commitMessageCache.set(projectPath, msg);\n    } else {\n      commitMessageCache.delete(projectPath);\n    }\n  };\n\n  const [isCommitting, setIsCommitting] = useState(false);\n  const [isGeneratingMessage, setIsGeneratingMessage] = useState(false);\n  const [isCollapsed, setIsCollapsed] = useState(isMobile);\n\n  const handleCommit = async (message = commitMessage) => {\n    const trimmedMessage = message.trim();\n    if (!trimmedMessage || selectedFileCount === 0 || isCommitting) {\n      return false;\n    }\n\n    setIsCommitting(true);\n    try {\n      const success = await onCommit(trimmedMessage);\n      if (success) {\n        setCommitMessage('');\n      }\n      return success;\n    } finally {\n      setIsCommitting(false);\n    }\n  };\n\n  const handleGenerateMessage = async () => {\n    if (selectedFileCount === 0 || isGeneratingMessage) {\n      return;\n    }\n\n    setIsGeneratingMessage(true);\n    try {\n      const generatedMessage = await onGenerateMessage();\n      if (generatedMessage) {\n        setCommitMessage(generatedMessage);\n      }\n    } finally {\n      setIsGeneratingMessage(false);\n    }\n  };\n\n  const requestCommitConfirmation = () => {\n    const trimmedMessage = commitMessage.trim();\n    if (!trimmedMessage || selectedFileCount === 0 || isCommitting) {\n      return;\n    }\n\n    onRequestConfirmation({\n      type: 'commit',\n      message: `Commit ${selectedFileCount} file${selectedFileCount !== 1 ? 's' : ''} with message: \"${trimmedMessage}\"?`,\n      onConfirm: async () => {\n        await handleCommit(trimmedMessage);\n      },\n    });\n  };\n\n  return (\n    <div\n      className={`transition-all duration-300 ease-in-out ${\n        isHidden ? 'max-h-0 -translate-y-2 overflow-hidden opacity-0' : 'max-h-96 translate-y-0 opacity-100'\n      }`}\n    >\n      {isMobile && isCollapsed ? (\n        <div className=\"border-b border-border/60 px-4 py-2\">\n          <button\n            onClick={() => setIsCollapsed(false)}\n            className=\"flex w-full items-center justify-center gap-2 rounded-lg bg-primary px-3 py-2 text-sm text-primary-foreground transition-colors hover:bg-primary/90\"\n          >\n            <GitCommit className=\"h-4 w-4\" />\n            <span>Commit {selectedFileCount} file{selectedFileCount !== 1 ? 's' : ''}</span>\n            <ChevronDown className=\"h-3 w-3\" />\n          </button>\n        </div>\n      ) : (\n        <div className=\"border-b border-border/60 px-4 py-3\">\n          {isMobile && (\n            <div className=\"mb-2 flex items-center justify-between\">\n              <span className=\"text-sm font-medium text-foreground\">Commit Changes</span>\n              <button\n                onClick={() => setIsCollapsed(true)}\n                className=\"rounded-lg p-1 transition-colors hover:bg-accent\"\n              >\n                <ChevronDown className=\"h-4 w-4 rotate-180\" />\n              </button>\n            </div>\n          )}\n\n          <div className=\"relative\">\n            <textarea\n              value={commitMessage}\n              onChange={(event) => setCommitMessage(event.target.value)}\n              placeholder=\"Message (Ctrl+Enter to commit)\"\n              className=\"w-full resize-none rounded-xl border border-border bg-background px-3 py-2 pr-20 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary/30 focus:outline-none focus:ring-2 focus:ring-primary/20\"\n              rows={3}\n              onKeyDown={(event) => {\n                if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {\n                  event.preventDefault();\n                  void handleCommit();\n                }\n              }}\n            />\n            <div className=\"absolute right-2 top-2 flex gap-1\">\n              <button\n                onClick={() => void handleGenerateMessage()}\n                disabled={selectedFileCount === 0 || isGeneratingMessage}\n                className=\"p-1.5 text-muted-foreground transition-colors hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50\"\n                title=\"Generate commit message\"\n              >\n                {isGeneratingMessage ? (\n                  <RefreshCw className=\"h-4 w-4 animate-spin\" />\n                ) : (\n                  <Sparkles className=\"h-4 w-4\" />\n                )}\n              </button>\n              <div style={{ display: 'none' }}>\n                <MicButton\n                  onTranscript={(transcript) => setCommitMessage(transcript)}\n                  mode=\"default\"\n                  className=\"p-1.5\"\n                />\n              </div>\n            </div>\n          </div>\n\n          <div className=\"mt-2 flex items-center justify-between\">\n            <span className=\"text-sm text-muted-foreground\">\n              {selectedFileCount} file{selectedFileCount !== 1 ? 's' : ''} selected\n            </span>\n            <button\n              onClick={requestCommitConfirmation}\n              disabled={!commitMessage.trim() || selectedFileCount === 0 || isCommitting}\n              className=\"flex items-center space-x-1 rounded-lg bg-primary px-3 py-1.5 text-sm text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50\"\n            >\n              <Check className=\"h-3 w-3\" />\n              <span>{isCommitting ? 'Committing...' : 'Commit'}</span>\n            </button>\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/git-panel/view/changes/FileChangeItem.tsx",
    "content": "import { ChevronRight, Trash2 } from 'lucide-react';\nimport type { FileStatusCode } from '../../types/types';\nimport { getStatusBadgeClass, getStatusLabel } from '../../utils/gitPanelUtils';\nimport GitDiffViewer from '../shared/GitDiffViewer';\n\ntype FileChangeItemProps = {\n  filePath: string;\n  status: FileStatusCode;\n  isMobile: boolean;\n  isExpanded: boolean;\n  isSelected: boolean;\n  diff?: string;\n  wrapText: boolean;\n  onToggleSelected: (filePath: string) => void;\n  onToggleExpanded: (filePath: string) => void;\n  onOpenFile: (filePath: string) => void;\n  onToggleWrapText: () => void;\n  onRequestFileAction: (filePath: string, status: FileStatusCode) => void;\n};\n\nexport default function FileChangeItem({\n  filePath,\n  status,\n  isMobile,\n  isExpanded,\n  isSelected,\n  diff,\n  wrapText,\n  onToggleSelected,\n  onToggleExpanded,\n  onOpenFile,\n  onToggleWrapText,\n  onRequestFileAction,\n}: FileChangeItemProps) {\n  const statusLabel = getStatusLabel(status);\n  const badgeClass = getStatusBadgeClass(status);\n\n  return (\n    <div className=\"border-b border-border last:border-0\">\n      <div className={`flex items-center transition-colors hover:bg-accent/50 ${isMobile ? 'px-2 py-1.5' : 'px-3 py-2'}`}>\n        <input\n          type=\"checkbox\"\n          checked={isSelected}\n          onChange={() => onToggleSelected(filePath)}\n          onClick={(event) => event.stopPropagation()}\n          className={`rounded border-border bg-background text-primary checked:bg-primary focus:ring-primary/40 ${isMobile ? 'mr-1.5' : 'mr-2'}`}\n        />\n\n        <div className=\"flex min-w-0 flex-1 items-center\">\n          <button\n            onClick={(event) => {\n              event.stopPropagation();\n              onToggleExpanded(filePath);\n            }}\n            className={`cursor-pointer rounded p-0.5 hover:bg-accent ${isMobile ? 'mr-1' : 'mr-2'}`}\n            title={isExpanded ? 'Collapse diff' : 'Expand diff'}\n          >\n            <ChevronRight className={`h-3 w-3 transition-transform duration-200 ease-in-out ${isExpanded ? 'rotate-90' : 'rotate-0'}`} />\n          </button>\n\n          <span\n            className={`flex-1 truncate ${isMobile ? 'text-xs' : 'text-sm'} cursor-pointer hover:text-primary hover:underline`}\n            onClick={(event) => {\n              event.stopPropagation();\n              onOpenFile(filePath);\n            }}\n            title=\"Click to open file\"\n          >\n            {filePath}\n          </span>\n\n          <span className=\"flex items-center gap-1\">\n            {(status === 'M' || status === 'D' || status === 'U') && (\n              <button\n                onClick={(event) => {\n                  event.stopPropagation();\n                  onRequestFileAction(filePath, status);\n                }}\n                className={`${isMobile ? 'px-2 py-1 text-xs' : 'p-1'} flex items-center gap-1 rounded font-medium text-destructive hover:bg-destructive/10`}\n                title={status === 'U' ? 'Delete untracked file' : 'Discard changes'}\n              >\n                <Trash2 className=\"h-3 w-3\" />\n                {isMobile && <span>{status === 'U' ? 'Delete' : 'Discard'}</span>}\n              </button>\n            )}\n\n            <span\n              className={`inline-flex h-5 w-5 items-center justify-center rounded border text-[10px] font-bold ${badgeClass}`}\n              title={statusLabel}\n            >\n              {status}\n            </span>\n          </span>\n        </div>\n      </div>\n\n      <div\n        className={`duration-400 overflow-hidden bg-muted/50 transition-all ease-in-out ${isExpanded && diff ? 'max-h-[600px] translate-y-0 opacity-100' : 'max-h-0 -translate-y-1 opacity-0'\n          }`}\n      >\n        <div className=\"flex items-center justify-between border-b border-border p-2\">\n          <span className=\"flex items-center gap-2\">\n            <span className={`inline-flex h-5 w-5 items-center justify-center rounded border text-[10px] font-bold ${badgeClass}`}>\n              {status}\n            </span>\n            <span className=\"text-sm font-medium text-foreground\">{statusLabel}</span>\n          </span>\n          {isMobile && (\n            <button\n              onClick={(event) => {\n                event.stopPropagation();\n                onToggleWrapText();\n              }}\n              className=\"text-sm text-muted-foreground transition-colors hover:text-foreground\"\n              title={wrapText ? 'Switch to horizontal scroll' : 'Switch to text wrap'}\n            >\n              {wrapText ? 'Scroll' : 'Wrap'}\n            </button>\n          )}\n        </div>\n\n        <div className=\"max-h-96 overflow-y-auto\">\n          {diff && <GitDiffViewer diff={diff} isMobile={isMobile} wrapText={wrapText} />}\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/git-panel/view/changes/FileChangeList.tsx",
    "content": "import { FILE_STATUS_GROUPS } from '../../constants/constants';\nimport type { FileStatusCode, GitDiffMap, GitStatusResponse } from '../../types/types';\nimport FileChangeItem from './FileChangeItem';\n\ntype FileChangeListProps = {\n  gitStatus: GitStatusResponse;\n  gitDiff: GitDiffMap;\n  expandedFiles: Set<string>;\n  selectedFiles: Set<string>;\n  isMobile: boolean;\n  wrapText: boolean;\n  filePaths?: Set<string>;\n  onToggleSelected: (filePath: string) => void;\n  onToggleExpanded: (filePath: string) => void;\n  onOpenFile: (filePath: string) => void;\n  onToggleWrapText: () => void;\n  onRequestFileAction: (filePath: string, status: FileStatusCode) => void;\n};\n\nexport default function FileChangeList({\n  gitStatus,\n  gitDiff,\n  expandedFiles,\n  selectedFiles,\n  isMobile,\n  wrapText,\n  filePaths,\n  onToggleSelected,\n  onToggleExpanded,\n  onOpenFile,\n  onToggleWrapText,\n  onRequestFileAction,\n}: FileChangeListProps) {\n  return (\n    <>\n      {FILE_STATUS_GROUPS.map(({ key, status }) =>\n        (gitStatus[key] || [])\n          .filter((filePath) => !filePaths || filePaths.has(filePath))\n          .map((filePath) => (\n            <FileChangeItem\n              key={filePath}\n              filePath={filePath}\n              status={status}\n              isMobile={isMobile}\n              isExpanded={expandedFiles.has(filePath)}\n              isSelected={selectedFiles.has(filePath)}\n              diff={gitDiff[filePath]}\n              wrapText={wrapText}\n              onToggleSelected={onToggleSelected}\n              onToggleExpanded={onToggleExpanded}\n              onOpenFile={onOpenFile}\n              onToggleWrapText={onToggleWrapText}\n              onRequestFileAction={onRequestFileAction}\n            />\n          )),\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "src/components/git-panel/view/changes/FileSelectionControls.tsx",
    "content": "type FileSelectionControlsProps = {\n  isMobile: boolean;\n  selectedCount: number;\n  totalCount: number;\n  isHidden: boolean;\n  onSelectAll: () => void;\n  onDeselectAll: () => void;\n};\n\nexport default function FileSelectionControls({\n  isMobile,\n  selectedCount,\n  totalCount,\n  isHidden,\n  onSelectAll,\n  onDeselectAll,\n}: FileSelectionControlsProps) {\n  return (\n    <div\n      className={`flex items-center justify-between border-b border-border/60 transition-all duration-300 ease-in-out ${\n        isMobile ? 'px-3 py-1.5' : 'px-4 py-2'\n      } ${isHidden ? 'max-h-0 -translate-y-2 overflow-hidden opacity-0' : 'max-h-16 translate-y-0 opacity-100'}`}\n    >\n      <span className=\"text-sm text-muted-foreground\">\n        {selectedCount} of {totalCount} {isMobile ? '' : 'files'} selected\n      </span>\n      <span className={`flex ${isMobile ? 'gap-1' : 'gap-2'}`}>\n        <button\n          onClick={onSelectAll}\n          className=\"text-sm text-primary transition-colors hover:text-primary/80\"\n        >\n          {isMobile ? 'All' : 'Select All'}\n        </button>\n        <span className=\"text-border\">|</span>\n        <button\n          onClick={onDeselectAll}\n          className=\"text-sm text-primary transition-colors hover:text-primary/80\"\n        >\n          {isMobile ? 'None' : 'Deselect All'}\n        </button>\n      </span>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/git-panel/view/changes/FileStatusLegend.tsx",
    "content": "import { ChevronDown, ChevronRight, Info } from 'lucide-react';\nimport { useState } from 'react';\nimport { getStatusBadgeClass } from '../../utils/gitPanelUtils';\n\ntype FileStatusLegendProps = {\n  isMobile: boolean;\n};\n\nconst LEGEND_ITEMS = [\n  { status: 'M', label: 'Modified' },\n  { status: 'A', label: 'Added' },\n  { status: 'D', label: 'Deleted' },\n  { status: 'U', label: 'Untracked' },\n] as const;\n\nexport default function FileStatusLegend({ isMobile }: FileStatusLegendProps) {\n  const [isOpen, setIsOpen] = useState(false);\n\n  if (isMobile) {\n    return null;\n  }\n\n  return (\n    <div className=\"border-b border-border/60\">\n      <button\n        onClick={() => setIsOpen((previous) => !previous)}\n        className=\"flex w-full items-center justify-center gap-1 bg-muted/30 px-4 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted/50\"\n      >\n        <Info className=\"h-3 w-3\" />\n        <span>File Status Guide</span>\n        {isOpen ? <ChevronDown className=\"h-3 w-3\" /> : <ChevronRight className=\"h-3 w-3\" />}\n      </button>\n\n      {isOpen && (\n        <div className=\"bg-muted/30 px-4 py-3 text-sm\">\n          <div className=\"flex justify-center gap-6\">\n            {LEGEND_ITEMS.map((item) => (\n              <span key={item.status} className=\"flex items-center gap-2\">\n                <span\n                  className={`inline-flex h-5 w-5 items-center justify-center rounded border text-[10px] font-bold ${getStatusBadgeClass(item.status)}`}\n                >\n                  {item.status}\n                </span>\n                <span className=\"italic text-muted-foreground\">{item.label}</span>\n              </span>\n            ))}\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/git-panel/view/history/CommitHistoryItem.tsx",
    "content": "import { ChevronDown, ChevronRight } from 'lucide-react';\nimport { useMemo } from 'react';\nimport type { GitCommitSummary } from '../../types/types';\nimport { getStatusBadgeClass, parseCommitFiles } from '../../utils/gitPanelUtils';\nimport GitDiffViewer from '../shared/GitDiffViewer';\n\nfunction formatDate(dateString: string): string {\n  return new Date(dateString).toLocaleDateString('en-US', {\n    year: 'numeric',\n    month: 'short',\n    day: 'numeric',\n  });\n}\n\ntype CommitHistoryItemProps = {\n  commit: GitCommitSummary;\n  isExpanded: boolean;\n  diff?: string;\n  isMobile: boolean;\n  wrapText: boolean;\n  onToggle: () => void;\n};\n\nexport default function CommitHistoryItem({\n  commit,\n  isExpanded,\n  diff,\n  isMobile,\n  wrapText,\n  onToggle,\n}: CommitHistoryItemProps) {\n  const fileSummary = useMemo(() => {\n    if (!diff) return null;\n    return parseCommitFiles(diff);\n  }, [diff]);\n\n  return (\n    <div className=\"border-b border-border last:border-0\">\n      <button\n        type=\"button\"\n        aria-expanded={isExpanded}\n        className=\"flex w-full cursor-pointer items-start border-0 bg-transparent p-3 text-left transition-colors hover:bg-accent/50\"\n        onClick={onToggle}\n      >\n        <span className=\"mr-2 mt-1 rounded p-0.5 hover:bg-accent\">\n          {isExpanded ? <ChevronDown className=\"h-3 w-3\" /> : <ChevronRight className=\"h-3 w-3\" />}\n        </span>\n        <div className=\"min-w-0 flex-1\">\n          <div className=\"flex items-start justify-between gap-2\">\n            <div className=\"min-w-0 flex-1\">\n              <p className=\"truncate text-sm font-medium text-foreground\">{commit.message}</p>\n              <p className=\"mt-1 text-sm text-muted-foreground\">\n                {commit.author}\n                {' \\u2022 '}\n                {commit.date}\n              </p>\n            </div>\n            <span className=\"flex-shrink-0 font-mono text-sm text-muted-foreground/60\">\n              {commit.hash.substring(0, 7)}\n            </span>\n          </div>\n        </div>\n      </button>\n\n      {isExpanded && diff && (\n        <div className=\"bg-muted/50\">\n          <div className=\"max-h-[32rem] overflow-y-auto p-3\">\n            {/* Full hash */}\n            <p className=\"mb-2 select-all font-mono text-xs text-muted-foreground/70\">\n              {commit.hash}\n            </p>\n\n            {/* Author + Date */}\n            <div className=\"mb-3 flex gap-4 text-xs text-muted-foreground\">\n              <span>\n                <span className=\"text-muted-foreground/60\">Author </span>\n                {commit.author}\n              </span>\n              <span>\n                <span className=\"text-muted-foreground/60\">Date </span>\n                {formatDate(commit.date)}\n              </span>\n            </div>\n\n            {/* Stats card */}\n            {fileSummary && (\n              <div className=\"mb-3 flex gap-4 rounded-md bg-muted/80 px-4 py-2 text-center text-xs\">\n                <div>\n                  <div className=\"text-muted-foreground/60\">Files</div>\n                  <div className=\"font-semibold text-foreground\">{fileSummary.totalFiles}</div>\n                </div>\n                <div>\n                  <div className=\"text-muted-foreground/60\">Added</div>\n                  <div className=\"font-semibold text-green-600 dark:text-green-400\">+{fileSummary.totalInsertions}</div>\n                </div>\n                <div>\n                  <div className=\"text-muted-foreground/60\">Removed</div>\n                  <div className=\"font-semibold text-red-600 dark:text-red-400\">-{fileSummary.totalDeletions}</div>\n                </div>\n              </div>\n            )}\n\n            {/* Changed files list */}\n            {fileSummary && fileSummary.files.length > 0 && (\n              <div className=\"mb-3\">\n                <p className=\"mb-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground/60\">\n                  Changed Files\n                </p>\n                <div className=\"rounded-md border border-border/60\">\n                  {fileSummary.files.map((file, idx) => (\n                    <div\n                      key={file.path}\n                      className={`flex items-center gap-2 px-2.5 py-1.5 text-xs ${\n                        idx < fileSummary.files.length - 1 ? 'border-b border-border/40' : ''\n                      }`}\n                    >\n                      <span\n                        className={`inline-flex h-4 w-4 flex-shrink-0 items-center justify-center rounded border text-[9px] font-bold ${getStatusBadgeClass(file.status)}`}\n                      >\n                        {file.status}\n                      </span>\n                      <span className=\"min-w-0 flex-1 truncate\">\n                        {file.directory && (\n                          <span className=\"text-muted-foreground/60\">{file.directory}</span>\n                        )}\n                        <span className=\"font-medium text-foreground\">{file.filename}</span>\n                      </span>\n                      <span className=\"flex-shrink-0 font-mono text-muted-foreground/60\">\n                        {file.insertions > 0 && (\n                          <span className=\"text-green-600 dark:text-green-400\">+{file.insertions}</span>\n                        )}\n                        {file.insertions > 0 && file.deletions > 0 && '/'}\n                        {file.deletions > 0 && (\n                          <span className=\"text-red-600 dark:text-red-400\">-{file.deletions}</span>\n                        )}\n                      </span>\n                    </div>\n                  ))}\n                </div>\n              </div>\n            )}\n\n            {/* Diff viewer */}\n            <GitDiffViewer diff={diff} isMobile={isMobile} wrapText={wrapText} />\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/git-panel/view/history/HistoryView.tsx",
    "content": "import { History, RefreshCw } from 'lucide-react';\nimport { useCallback, useState } from 'react';\nimport type { GitDiffMap, GitCommitSummary } from '../../types/types';\nimport CommitHistoryItem from './CommitHistoryItem';\n\ntype HistoryViewProps = {\n  isMobile: boolean;\n  isLoading: boolean;\n  recentCommits: GitCommitSummary[];\n  commitDiffs: GitDiffMap;\n  wrapText: boolean;\n  onFetchCommitDiff: (commitHash: string) => Promise<void>;\n};\n\nexport default function HistoryView({\n  isMobile,\n  isLoading,\n  recentCommits,\n  commitDiffs,\n  wrapText,\n  onFetchCommitDiff,\n}: HistoryViewProps) {\n  const [expandedCommits, setExpandedCommits] = useState<Set<string>>(new Set());\n\n  const toggleCommitExpanded = useCallback(\n    (commitHash: string) => {\n      const isExpanding = !expandedCommits.has(commitHash);\n\n      setExpandedCommits((previous) => {\n        const next = new Set(previous);\n        if (next.has(commitHash)) {\n          next.delete(commitHash);\n        } else {\n          next.add(commitHash);\n        }\n        return next;\n      });\n\n      // Load commit diff lazily only the first time a commit is expanded.\n      if (isExpanding && !commitDiffs[commitHash]) {\n        onFetchCommitDiff(commitHash).catch((err) => {\n          console.error('Failed to fetch commit diff:', err);\n        });\n      }\n    },\n    [commitDiffs, expandedCommits, onFetchCommitDiff, setExpandedCommits],\n  );\n\n  return (\n    <div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-mobile-nav' : ''}`}>\n      {isLoading ? (\n        <div className=\"flex h-32 items-center justify-center\">\n          <RefreshCw className=\"h-5 w-5 animate-spin text-muted-foreground\" />\n        </div>\n      ) : recentCommits.length === 0 ? (\n        <div className=\"flex h-32 flex-col items-center justify-center text-muted-foreground\">\n          <History className=\"mb-2 h-10 w-10 opacity-40\" />\n          <p className=\"text-sm\">No commits found</p>\n        </div>\n      ) : (\n        <div className={isMobile ? 'pb-4' : ''}>\n          {recentCommits.map((commit) => (\n            <CommitHistoryItem\n              key={commit.hash}\n              commit={commit}\n              isExpanded={expandedCommits.has(commit.hash)}\n              diff={commitDiffs[commit.hash]}\n              isMobile={isMobile}\n              wrapText={wrapText}\n              onToggle={() => toggleCommitExpanded(commit.hash)}\n            />\n          ))}\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/git-panel/view/modals/ConfirmActionModal.tsx",
    "content": "import { useEffect } from 'react';\nimport { Check, Download, RotateCcw, Trash2, Upload } from 'lucide-react';\nimport {\n  CONFIRMATION_ACTION_LABELS,\n  CONFIRMATION_BUTTON_CLASSES,\n  CONFIRMATION_ICON_CONTAINER_CLASSES,\n  CONFIRMATION_TITLES,\n} from '../../constants/constants';\nimport type { ConfirmationRequest } from '../../types/types';\n\ntype ConfirmActionModalProps = {\n  action: ConfirmationRequest | null;\n  onCancel: () => void;\n  onConfirm: () => void;\n};\n\nfunction renderConfirmActionIcon(actionType: ConfirmationRequest['type']) {\n  if (actionType === 'discard' || actionType === 'delete') {\n    return <Trash2 className=\"h-4 w-4\" />;\n  }\n\n  if (actionType === 'commit') {\n    return <Check className=\"h-4 w-4\" />;\n  }\n\n  if (actionType === 'pull') {\n    return <Download className=\"h-4 w-4\" />;\n  }\n\n  if (actionType === 'revertLocalCommit') {\n    return <RotateCcw className=\"h-4 w-4\" />;\n  }\n\n  return <Upload className=\"h-4 w-4\" />;\n}\n\nexport default function ConfirmActionModal({ action, onCancel, onConfirm }: ConfirmActionModalProps) {\n  const titleId = action ? `confirmation-title-${action.type}` : undefined;\n\n  useEffect(() => {\n    if (!action) {\n      return;\n    }\n\n    const handleKeyDown = (event: KeyboardEvent) => {\n      if (event.key === 'Escape') {\n        onCancel();\n      }\n    };\n\n    window.addEventListener('keydown', handleKeyDown);\n    return () => {\n      window.removeEventListener('keydown', handleKeyDown);\n    };\n  }, [action, onCancel]);\n\n  if (!action) {\n    return null;\n  }\n\n  return (\n    <div className=\"fixed inset-0 z-50 flex items-center justify-center p-4\">\n      <div className=\"fixed inset-0 bg-black/60 backdrop-blur-sm\" onClick={onCancel} />\n      <div\n        className=\"relative w-full max-w-md overflow-hidden rounded-xl border border-border bg-card shadow-2xl\"\n        role=\"dialog\"\n        aria-modal=\"true\"\n        aria-labelledby={titleId}\n      >\n        <div className=\"p-6\">\n          <div className=\"mb-4 flex items-center\">\n            <div className={`mr-3 rounded-full p-2 ${CONFIRMATION_ICON_CONTAINER_CLASSES[action.type]}`}>\n              {renderConfirmActionIcon(action.type)}\n            </div>\n            <h3 id={titleId} className=\"text-lg font-semibold text-foreground\">\n              {CONFIRMATION_TITLES[action.type]}\n            </h3>\n          </div>\n\n          <p className=\"mb-6 text-sm text-muted-foreground\">{action.message}</p>\n\n          <div className=\"flex justify-end space-x-3\">\n            <button\n              onClick={onCancel}\n              className=\"rounded-lg px-4 py-2 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground\"\n            >\n              Cancel\n            </button>\n            <button\n              onClick={onConfirm}\n              className={`flex items-center space-x-2 rounded-lg px-4 py-2 text-sm text-white transition-colors ${CONFIRMATION_BUTTON_CLASSES[action.type]}`}\n            >\n              {renderConfirmActionIcon(action.type)}\n              <span>{CONFIRMATION_ACTION_LABELS[action.type]}</span>\n            </button>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/git-panel/view/modals/NewBranchModal.tsx",
    "content": "import { Plus, RefreshCw } from 'lucide-react';\nimport { useEffect, useState } from 'react';\n\ntype NewBranchModalProps = {\n  isOpen: boolean;\n  currentBranch: string;\n  isCreatingBranch: boolean;\n  onClose: () => void;\n  onCreateBranch: (branchName: string) => Promise<boolean>;\n};\n\nexport default function NewBranchModal({\n  isOpen,\n  currentBranch,\n  isCreatingBranch,\n  onClose,\n  onCreateBranch,\n}: NewBranchModalProps) {\n  const [newBranchName, setNewBranchName] = useState('');\n\n  useEffect(() => {\n    if (!isOpen) {\n      setNewBranchName('');\n    }\n  }, [isOpen]);\n\n  const handleCreateBranch = async (): Promise<boolean> => {\n    const branchName = newBranchName.trim();\n    if (!branchName) {\n      return false;\n    }\n\n    try {\n      const success = await onCreateBranch(branchName);\n      if (success) {\n        setNewBranchName('');\n        onClose();\n      }\n      return success;\n    } catch (error) {\n      console.error('Failed to create branch:', error);\n      return false;\n    }\n  };\n\n  if (!isOpen) {\n    return null;\n  }\n\n  return (\n    <div className=\"fixed inset-0 z-50 flex items-center justify-center p-4\">\n      <div className=\"fixed inset-0 bg-black/60 backdrop-blur-sm\" onClick={onClose} />\n      <div\n        className=\"relative w-full max-w-md overflow-hidden rounded-xl border border-border bg-card shadow-2xl\"\n        role=\"dialog\"\n        aria-modal=\"true\"\n        aria-labelledby=\"new-branch-title\"\n      >\n        <div className=\"p-6\">\n          <h3 className=\"mb-4 text-lg font-semibold text-foreground\">Create New Branch</h3>\n\n          <div className=\"mb-4\">\n            <label htmlFor=\"git-new-branch-name\" className=\"mb-2 block text-sm font-medium text-foreground/80\">\n              Branch Name\n            </label>\n            <input\n              id=\"git-new-branch-name\"\n              type=\"text\"\n              value={newBranchName}\n              onChange={(event) => setNewBranchName(event.target.value)}\n              onKeyDown={(event) => {\n                if (event.key === 'Enter' && !isCreatingBranch) {\n                  event.preventDefault();\n                  event.stopPropagation();\n                  void handleCreateBranch();\n                  return;\n                }\n\n                if (event.key === 'Escape' && !isCreatingBranch) {\n                  event.preventDefault();\n                  event.stopPropagation();\n                  onClose();\n                }\n              }}\n              placeholder=\"feature/new-feature\"\n              className=\"w-full rounded-xl border border-border bg-background px-3 py-2 text-foreground placeholder:text-muted-foreground focus:border-primary/30 focus:outline-none focus:ring-2 focus:ring-primary/20\"\n              autoFocus\n            />\n          </div>\n\n          <p className=\"mb-4 text-sm text-muted-foreground\">\n            This will create a new branch from the current branch ({currentBranch})\n          </p>\n\n          <div className=\"flex justify-end space-x-3\">\n            <button\n              onClick={onClose}\n              className=\"rounded-lg px-4 py-2 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground\"\n            >\n              Cancel\n            </button>\n            <button\n              onClick={() => void handleCreateBranch()}\n              disabled={!newBranchName.trim() || isCreatingBranch}\n              className=\"flex items-center space-x-2 rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50\"\n            >\n              {isCreatingBranch ? (\n                <>\n                  <RefreshCw className=\"h-3 w-3 animate-spin\" />\n                  <span>Creating...</span>\n                </>\n              ) : (\n                <>\n                  <Plus className=\"h-3 w-3\" />\n                  <span>Create Branch</span>\n                </>\n              )}\n            </button>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/git-panel/view/shared/GitDiffViewer.tsx",
    "content": "import { useMemo } from 'react';\n\ntype GitDiffViewerProps = {\n  diff: string | null;\n  isMobile: boolean;\n  wrapText: boolean;\n};\n\nconst PREVIEW_CHARACTER_LIMIT = 200_000;\nconst PREVIEW_LINE_LIMIT = 1_500;\n\ntype DiffPreview = {\n  lines: string[];\n  isCharacterTruncated: boolean;\n  isLineTruncated: boolean;\n};\n\nfunction buildDiffPreview(diff: string): DiffPreview {\n  const isCharacterTruncated = diff.length > PREVIEW_CHARACTER_LIMIT;\n  const previewText = isCharacterTruncated ? diff.slice(0, PREVIEW_CHARACTER_LIMIT) : diff;\n  const previewLines = previewText.split('\\n');\n  const isLineTruncated = previewLines.length > PREVIEW_LINE_LIMIT;\n\n  return {\n    lines: isLineTruncated ? previewLines.slice(0, PREVIEW_LINE_LIMIT) : previewLines,\n    isCharacterTruncated,\n    isLineTruncated,\n  };\n}\n\nexport default function GitDiffViewer({ diff, isMobile, wrapText }: GitDiffViewerProps) {\n  // Render a bounded preview to keep huge commit diffs from freezing the UI thread.\n  const preview = useMemo(() => buildDiffPreview(diff || ''), [diff]);\n  const isPreviewTruncated = preview.isCharacterTruncated || preview.isLineTruncated;\n\n  if (!diff) {\n    return (\n      <div className=\"p-4 text-center text-sm text-muted-foreground\">\n        No diff available\n      </div>\n    );\n  }\n\n  const renderDiffLine = (line: string, index: number) => {\n    const isAddition = line.startsWith('+') && !line.startsWith('+++');\n    const isDeletion = line.startsWith('-') && !line.startsWith('---');\n    const isHeader = line.startsWith('@@');\n\n    return (\n      <div\n        key={index}\n        className={`px-3 py-0.5 font-mono text-xs ${isMobile && wrapText ? 'whitespace-pre-wrap break-all' : 'overflow-x-auto whitespace-pre'\n          } ${isAddition ? 'bg-green-50 text-green-700 dark:bg-green-950/50 dark:text-green-300' :\n            isDeletion ? 'bg-red-50 text-red-700 dark:bg-red-950/50 dark:text-red-300' :\n              isHeader ? 'bg-primary/5 text-primary' :\n                'text-muted-foreground/70'\n          }`}\n      >\n        {line}\n      </div>\n    );\n  };\n\n  return (\n    <div className=\"diff-viewer\">\n      {isPreviewTruncated && (\n        <div className=\"mb-2 rounded-md border border-border bg-card px-3 py-2 text-xs text-muted-foreground\">\n          Large diff preview: rendering is limited to keep the tab responsive.\n        </div>\n      )}\n      {preview.lines.map((line, index) => renderDiffLine(line, index))}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/llm-logo-provider/ClaudeLogo.tsx",
    "content": "import React from 'react';\n\ntype ClaudeLogoProps = {\n  className?: string;\n};\n\nconst ClaudeLogo = ({ className = 'w-5 h-5' }: ClaudeLogoProps) => {\n  return (\n    <img src=\"/icons/claude-ai-icon.svg\" alt=\"Claude\" className={className} />\n  );\n};\n\nexport default ClaudeLogo;\n\n\n"
  },
  {
    "path": "src/components/llm-logo-provider/CodexLogo.tsx",
    "content": "import React from 'react';\nimport { useTheme } from '../../contexts/ThemeContext';\n\ntype CodexLogoProps = {\n  className?: string;\n};\n\nconst CodexLogo = ({ className = 'w-5 h-5' }: CodexLogoProps) => {\n  const { isDarkMode } = useTheme();\n\n  return (\n    <img\n      src={isDarkMode ? \"/icons/codex-white.svg\" : \"/icons/codex.svg\"}\n      alt=\"Codex\"\n      className={className}\n    />\n  );\n};\n\nexport default CodexLogo;\n"
  },
  {
    "path": "src/components/llm-logo-provider/CursorLogo.tsx",
    "content": "import React from 'react';\nimport { useTheme } from '../../contexts/ThemeContext';\n\ntype CursorLogoProps = {\n  className?: string;\n};\n\nconst CursorLogo = ({ className = 'w-5 h-5' }: CursorLogoProps) => {\n  const { isDarkMode } = useTheme();\n\n  return (\n    <img\n      src={isDarkMode ? \"/icons/cursor-white.svg\" : \"/icons/cursor.svg\"}\n      alt=\"Cursor\"\n      className={className}\n    />\n  );\n};\n\nexport default CursorLogo;\n"
  },
  {
    "path": "src/components/llm-logo-provider/GeminiLogo.tsx",
    "content": "const GeminiLogo = ({className = 'w-5 h-5'}) => {\n  return (\n    <img src=\"/icons/gemini-ai-icon.svg\" alt=\"Gemini\" className={className} />\n  );\n};\n\nexport default GeminiLogo;"
  },
  {
    "path": "src/components/llm-logo-provider/SessionProviderLogo.tsx",
    "content": "import type { SessionProvider } from '../../types/app';\nimport ClaudeLogo from './ClaudeLogo';\nimport CodexLogo from './CodexLogo';\nimport CursorLogo from './CursorLogo';\nimport GeminiLogo from './GeminiLogo';\n\ntype SessionProviderLogoProps = {\n  provider?: SessionProvider | string | null;\n  className?: string;\n};\n\nexport default function SessionProviderLogo({\n  provider = 'claude',\n  className = 'w-5 h-5',\n}: SessionProviderLogoProps) {\n  if (provider === 'cursor') {\n    return <CursorLogo className={className} />;\n  }\n\n  if (provider === 'codex') {\n    return <CodexLogo className={className} />;\n  }\n\n  if (provider === 'gemini') {\n    return <GeminiLogo className={className} />;\n  }\n\n  return <ClaudeLogo className={className} />;\n}\n"
  },
  {
    "path": "src/components/main-content/hooks/useMobileMenuHandlers.ts",
    "content": "import { useCallback, useRef } from 'react';\nimport type { MouseEvent, TouchEvent } from 'react';\n\ntype MenuEvent = MouseEvent<HTMLButtonElement> | TouchEvent<HTMLButtonElement>;\n\nexport function useMobileMenuHandlers(onMenuClick: () => void) {\n  const suppressNextMenuClickRef = useRef(false);\n\n  const openMobileMenu = useCallback(\n    (event?: MenuEvent) => {\n      if (event) {\n        event.preventDefault();\n        event.stopPropagation();\n      }\n\n      onMenuClick();\n    },\n    [onMenuClick],\n  );\n\n  const handleMobileMenuTouchEnd = useCallback(\n    (event: TouchEvent<HTMLButtonElement>) => {\n      suppressNextMenuClickRef.current = true;\n      openMobileMenu(event);\n\n      window.setTimeout(() => {\n        suppressNextMenuClickRef.current = false;\n      }, 350);\n    },\n    [openMobileMenu],\n  );\n\n  const handleMobileMenuClick = useCallback(\n    (event: MouseEvent<HTMLButtonElement>) => {\n      if (suppressNextMenuClickRef.current) {\n        event.preventDefault();\n        event.stopPropagation();\n        return;\n      }\n\n      openMobileMenu(event);\n    },\n    [openMobileMenu],\n  );\n\n  return {\n    handleMobileMenuClick,\n    handleMobileMenuTouchEnd,\n  };\n}\n"
  },
  {
    "path": "src/components/main-content/types/types.ts",
    "content": "import type { Dispatch, SetStateAction } from 'react';\nimport type { AppTab, Project, ProjectSession } from '../../../types/app';\n\nexport type SessionLifecycleHandler = (sessionId?: string | null) => void;\n\nexport type TaskMasterTask = {\n  id: string | number;\n  title?: string;\n  description?: string;\n  status?: string;\n  priority?: string;\n  details?: string;\n  testStrategy?: string;\n  parentId?: string | number;\n  dependencies?: Array<string | number>;\n  subtasks?: TaskMasterTask[];\n  [key: string]: unknown;\n};\n\nexport type TaskReference = {\n  id: string | number;\n  title?: string;\n  [key: string]: unknown;\n};\n\nexport type TaskSelection = TaskMasterTask | TaskReference;\n\nexport type PrdFile = {\n  name: string;\n  content?: string;\n  isExisting?: boolean;\n  [key: string]: unknown;\n};\n\nexport type MainContentProps = {\n  selectedProject: Project | null;\n  selectedSession: ProjectSession | null;\n  activeTab: AppTab;\n  setActiveTab: Dispatch<SetStateAction<AppTab>>;\n  ws: WebSocket | null;\n  sendMessage: (message: unknown) => void;\n  latestMessage: unknown;\n  isMobile: boolean;\n  onMenuClick: () => void;\n  isLoading: boolean;\n  onInputFocusChange: (focused: boolean) => void;\n  onSessionActive: SessionLifecycleHandler;\n  onSessionInactive: SessionLifecycleHandler;\n  onSessionProcessing: SessionLifecycleHandler;\n  onSessionNotProcessing: SessionLifecycleHandler;\n  processingSessions: Set<string>;\n  onReplaceTemporarySession: SessionLifecycleHandler;\n  onNavigateToSession: (targetSessionId: string) => void;\n  onShowSettings: () => void;\n  externalMessageUpdate: number;\n};\n\nexport type MainContentHeaderProps = {\n  activeTab: AppTab;\n  setActiveTab: Dispatch<SetStateAction<AppTab>>;\n  selectedProject: Project;\n  selectedSession: ProjectSession | null;\n  shouldShowTasksTab: boolean;\n  isMobile: boolean;\n  onMenuClick: () => void;\n};\n\nexport type MainContentStateViewProps = {\n  mode: 'loading' | 'empty';\n  isMobile: boolean;\n  onMenuClick: () => void;\n};\n\nexport type MobileMenuButtonProps = {\n  onMenuClick: () => void;\n  compact?: boolean;\n};\n\nexport type TaskMasterPanelProps = {\n  isVisible: boolean;\n};\n"
  },
  {
    "path": "src/components/main-content/view/ErrorBoundary.tsx",
    "content": "import { useCallback, useState, type ErrorInfo, type ReactNode } from 'react';\nimport {\n  ErrorBoundary as ReactErrorBoundary,\n  type FallbackProps,\n} from 'react-error-boundary';\n\ntype ErrorFallbackProps = FallbackProps & {\n  showDetails: boolean;\n  componentStack: string | null;\n};\n\ntype ErrorBoundaryProps = {\n  children: ReactNode;\n  showDetails?: boolean;\n  onRetry?: () => void;\n  resetKeys?: unknown[];\n};\n\nfunction formatError(error: unknown): string {\n  if (error instanceof Error) {\n    return `${error.name}: ${error.message}`;\n  }\n\n  return String(error);\n}\n\nfunction ErrorFallback({\n  error,\n  resetErrorBoundary,\n  showDetails,\n  componentStack,\n}: ErrorFallbackProps) {\n  return (\n    <div className=\"flex flex-col items-center justify-center p-8 text-center\">\n      <div className=\"max-w-md rounded-lg border border-red-200 bg-red-50 p-6\">\n        <div className=\"mb-4 flex items-center\">\n          <div className=\"flex-shrink-0\">\n            <svg className=\"h-5 w-5 text-red-400\" viewBox=\"0 0 20 20\" fill=\"currentColor\">\n              <path\n                fillRule=\"evenodd\"\n                d=\"M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z\"\n                clipRule=\"evenodd\"\n              />\n            </svg>\n          </div>\n          <h3 className=\"ml-3 text-sm font-medium text-red-800\">Something went wrong</h3>\n        </div>\n        <div className=\"text-sm text-red-700\">\n          <p className=\"mb-2\">An error occurred while loading the chat interface.</p>\n          {showDetails && (\n            <details className=\"mt-4\">\n              <summary className=\"cursor-pointer font-mono text-xs\">Error Details</summary>\n              <pre className=\"mt-2 max-h-40 overflow-auto rounded bg-red-100 p-2 text-xs\">\n                {formatError(error)}\n                {componentStack}\n              </pre>\n            </details>\n          )}\n        </div>\n        <div className=\"mt-4\">\n          <button\n            onClick={resetErrorBoundary}\n            className=\"rounded bg-red-600 px-4 py-2 text-sm text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500\"\n          >\n            Try Again\n          </button>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction ErrorBoundary({\n  children,\n  showDetails = false,\n  onRetry = undefined,\n  resetKeys = undefined,\n}: ErrorBoundaryProps) {\n  const [componentStack, setComponentStack] = useState<string | null>(null);\n\n  const handleError = useCallback((error: Error, errorInfo: ErrorInfo) => {\n    console.error('ErrorBoundary caught an error:', error, errorInfo);\n    // Keep component stack for optional debug rendering in fallback UI.\n    setComponentStack(errorInfo?.componentStack ?? null);\n  }, []);\n\n  const handleReset = useCallback(() => {\n    setComponentStack(null);\n    onRetry?.();\n  }, [onRetry]);\n\n  const renderFallback = useCallback(\n    ({ error, resetErrorBoundary }: FallbackProps) => (\n      <ErrorFallback\n        error={error}\n        resetErrorBoundary={resetErrorBoundary}\n        showDetails={showDetails}\n        componentStack={componentStack}\n      />\n    ),\n    [showDetails, componentStack]\n  );\n\n  return (\n    <ReactErrorBoundary\n      fallbackRender={renderFallback}\n      onError={handleError}\n      onReset={handleReset}\n      resetKeys={resetKeys}\n    >\n      {children}\n    </ReactErrorBoundary>\n  );\n}\n\nexport default ErrorBoundary;\n"
  },
  {
    "path": "src/components/main-content/view/MainContent.tsx",
    "content": "import React, { useEffect } from 'react';\nimport ChatInterface from '../../chat/view/ChatInterface';\nimport FileTree from '../../file-tree/view/FileTree';\nimport StandaloneShell from '../../standalone-shell/view/StandaloneShell';\nimport GitPanel from '../../git-panel/view/GitPanel';\nimport PluginTabContent from '../../plugins/view/PluginTabContent';\nimport type { MainContentProps } from '../types/types';\nimport { useTaskMaster } from '../../../contexts/TaskMasterContext';\nimport { useTasksSettings } from '../../../contexts/TasksSettingsContext';\nimport { useUiPreferences } from '../../../hooks/useUiPreferences';\nimport { useEditorSidebar } from '../../code-editor/hooks/useEditorSidebar';\nimport EditorSidebar from '../../code-editor/view/EditorSidebar';\nimport type { Project } from '../../../types/app';\nimport { TaskMasterPanel } from '../../task-master';\nimport MainContentHeader from './subcomponents/MainContentHeader';\nimport MainContentStateView from './subcomponents/MainContentStateView';\nimport ErrorBoundary from './ErrorBoundary';\n\ntype TaskMasterContextValue = {\n  currentProject?: Project | null;\n  setCurrentProject?: ((project: Project) => void) | null;\n};\n\ntype TasksSettingsContextValue = {\n  tasksEnabled: boolean;\n  isTaskMasterInstalled: boolean | null;\n  isTaskMasterReady: boolean | null;\n};\n\nfunction MainContent({\n  selectedProject,\n  selectedSession,\n  activeTab,\n  setActiveTab,\n  ws,\n  sendMessage,\n  latestMessage,\n  isMobile,\n  onMenuClick,\n  isLoading,\n  onInputFocusChange,\n  onSessionActive,\n  onSessionInactive,\n  onSessionProcessing,\n  onSessionNotProcessing,\n  processingSessions,\n  onReplaceTemporarySession,\n  onNavigateToSession,\n  onShowSettings,\n  externalMessageUpdate,\n}: MainContentProps) {\n  const { preferences } = useUiPreferences();\n  const { autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter } = preferences;\n\n  const { currentProject, setCurrentProject } = useTaskMaster() as TaskMasterContextValue;\n  const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings() as TasksSettingsContextValue;\n\n  const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled);\n\n  const {\n    editingFile,\n    editorWidth,\n    editorExpanded,\n    hasManualWidth,\n    resizeHandleRef,\n    handleFileOpen,\n    handleCloseEditor,\n    handleToggleEditorExpand,\n    handleResizeStart,\n  } = useEditorSidebar({\n    selectedProject,\n    isMobile,\n  });\n\n  useEffect(() => {\n    const selectedProjectName = selectedProject?.name;\n    const currentProjectName = currentProject?.name;\n\n    if (selectedProject && selectedProjectName !== currentProjectName) {\n      setCurrentProject?.(selectedProject);\n    }\n  }, [selectedProject, currentProject?.name, setCurrentProject]);\n\n  useEffect(() => {\n    if (!shouldShowTasksTab && activeTab === 'tasks') {\n      setActiveTab('chat');\n    }\n  }, [shouldShowTasksTab, activeTab, setActiveTab]);\n\n  if (isLoading) {\n    return <MainContentStateView mode=\"loading\" isMobile={isMobile} onMenuClick={onMenuClick} />;\n  }\n\n  if (!selectedProject) {\n    return <MainContentStateView mode=\"empty\" isMobile={isMobile} onMenuClick={onMenuClick} />;\n  }\n\n  return (\n    <div className=\"flex h-full flex-col\">\n      <MainContentHeader\n        activeTab={activeTab}\n        setActiveTab={setActiveTab}\n        selectedProject={selectedProject}\n        selectedSession={selectedSession}\n        shouldShowTasksTab={shouldShowTasksTab}\n        isMobile={isMobile}\n        onMenuClick={onMenuClick}\n      />\n\n      <div className=\"flex min-h-0 flex-1 overflow-hidden\">\n        <div className={`flex min-h-0 min-w-[200px] flex-col overflow-hidden ${editorExpanded ? 'hidden' : ''} flex-1`}>\n          <div className={`h-full ${activeTab === 'chat' ? 'block' : 'hidden'}`}>\n            <ErrorBoundary showDetails>\n              <ChatInterface\n                selectedProject={selectedProject}\n                selectedSession={selectedSession}\n                ws={ws}\n                sendMessage={sendMessage}\n                latestMessage={latestMessage}\n                onFileOpen={handleFileOpen}\n                onInputFocusChange={onInputFocusChange}\n                onSessionActive={onSessionActive}\n                onSessionInactive={onSessionInactive}\n                onSessionProcessing={onSessionProcessing}\n                onSessionNotProcessing={onSessionNotProcessing}\n                processingSessions={processingSessions}\n                onReplaceTemporarySession={onReplaceTemporarySession}\n                onNavigateToSession={onNavigateToSession}\n                onShowSettings={onShowSettings}\n                autoExpandTools={autoExpandTools}\n                showRawParameters={showRawParameters}\n                showThinking={showThinking}\n                autoScrollToBottom={autoScrollToBottom}\n                sendByCtrlEnter={sendByCtrlEnter}\n                externalMessageUpdate={externalMessageUpdate}\n                onShowAllTasks={tasksEnabled ? () => setActiveTab('tasks') : null}\n              />\n            </ErrorBoundary>\n          </div>\n\n          {activeTab === 'files' && (\n            <div className=\"h-full overflow-hidden\">\n              <FileTree selectedProject={selectedProject} onFileOpen={handleFileOpen} />\n            </div>\n          )}\n\n          {activeTab === 'shell' && (\n            <div className=\"h-full w-full overflow-hidden\">\n              <StandaloneShell\n                project={selectedProject}\n                session={selectedSession}\n                showHeader={false}\n                isActive={activeTab === 'shell'}\n              />\n            </div>\n          )}\n\n          {activeTab === 'git' && (\n            <div className=\"h-full overflow-hidden\">\n              <GitPanel selectedProject={selectedProject} isMobile={isMobile} onFileOpen={handleFileOpen} />\n            </div>\n          )}\n\n          {shouldShowTasksTab && <TaskMasterPanel isVisible={activeTab === 'tasks'} />}\n\n          <div className={`h-full overflow-hidden ${activeTab === 'preview' ? 'block' : 'hidden'}`} />\n\n          {activeTab.startsWith('plugin:') && (\n            <div className=\"h-full overflow-hidden\">\n              <PluginTabContent\n                pluginName={activeTab.replace('plugin:', '')}\n                selectedProject={selectedProject}\n                selectedSession={selectedSession}\n              />\n            </div>\n          )}\n        </div>\n\n        <EditorSidebar\n          editingFile={editingFile}\n          isMobile={isMobile}\n          editorExpanded={editorExpanded}\n          editorWidth={editorWidth}\n          hasManualWidth={hasManualWidth}\n          resizeHandleRef={resizeHandleRef}\n          onResizeStart={handleResizeStart}\n          onCloseEditor={handleCloseEditor}\n          onToggleEditorExpand={handleToggleEditorExpand}\n          projectPath={selectedProject.path}\n          fillSpace={activeTab === 'files'}\n        />\n      </div>\n    </div>\n  );\n}\n\nexport default React.memo(MainContent);\n"
  },
  {
    "path": "src/components/main-content/view/subcomponents/MainContentHeader.tsx",
    "content": "import { useCallback, useRef, useState, useEffect } from 'react';\nimport type { MainContentHeaderProps } from '../../types/types';\nimport MobileMenuButton from './MobileMenuButton';\nimport MainContentTabSwitcher from './MainContentTabSwitcher';\nimport MainContentTitle from './MainContentTitle';\n\nexport default function MainContentHeader({\n  activeTab,\n  setActiveTab,\n  selectedProject,\n  selectedSession,\n  shouldShowTasksTab,\n  isMobile,\n  onMenuClick,\n}: MainContentHeaderProps) {\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const [canScrollLeft, setCanScrollLeft] = useState(false);\n  const [canScrollRight, setCanScrollRight] = useState(false);\n\n  const updateScrollState = useCallback(() => {\n    const el = scrollRef.current;\n    if (!el) return;\n    setCanScrollLeft(el.scrollLeft > 2);\n    setCanScrollRight(el.scrollLeft < el.scrollWidth - el.clientWidth - 2);\n  }, []);\n\n  useEffect(() => {\n    const el = scrollRef.current;\n    if (!el) return;\n    updateScrollState();\n    const observer = new ResizeObserver(updateScrollState);\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [updateScrollState]);\n\n  return (\n    <div className=\"pwa-header-safe flex-shrink-0 border-b border-border/60 bg-background px-3 py-1.5 sm:px-4 sm:py-2\">\n      <div className=\"flex items-center justify-between gap-3\">\n        <div className=\"flex min-w-0 flex-1 items-center gap-2\">\n          {isMobile && <MobileMenuButton onMenuClick={onMenuClick} />}\n          <MainContentTitle\n            activeTab={activeTab}\n            selectedProject={selectedProject}\n            selectedSession={selectedSession}\n            shouldShowTasksTab={shouldShowTasksTab}\n          />\n        </div>\n\n        <div className=\"relative min-w-0 flex-shrink overflow-hidden sm:flex-shrink-0\">\n          {canScrollLeft && (\n            <div className=\"pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-gradient-to-r from-background to-transparent\" />\n          )}\n          <div\n            ref={scrollRef}\n            onScroll={updateScrollState}\n            className=\"scrollbar-hide overflow-x-auto\"\n          >\n            <MainContentTabSwitcher\n              activeTab={activeTab}\n              setActiveTab={setActiveTab}\n              shouldShowTasksTab={shouldShowTasksTab}\n            />\n          </div>\n          {canScrollRight && (\n            <div className=\"pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-gradient-to-l from-background to-transparent\" />\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/main-content/view/subcomponents/MainContentStateView.tsx",
    "content": "import { Folder } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport type { MainContentStateViewProps } from '../../types/types';\nimport MobileMenuButton from './MobileMenuButton';\n\nexport default function MainContentStateView({ mode, isMobile, onMenuClick }: MainContentStateViewProps) {\n  const { t } = useTranslation();\n\n  const isLoading = mode === 'loading';\n\n  return (\n    <div className=\"flex h-full flex-col\">\n      {isMobile && (\n        <div className=\"pwa-header-safe flex-shrink-0 border-b border-border/50 bg-background/80 p-2 backdrop-blur-sm sm:p-3\">\n          <MobileMenuButton onMenuClick={onMenuClick} compact />\n        </div>\n      )}\n\n      {isLoading ? (\n        <div className=\"flex flex-1 items-center justify-center\">\n          <div className=\"text-center text-muted-foreground\">\n            <div className=\"mx-auto mb-4 h-10 w-10\">\n              <div\n                className=\"h-full w-full rounded-full border-[3px] border-muted border-t-primary\"\n                style={{\n                  animation: 'spin 1s linear infinite',\n                  WebkitAnimation: 'spin 1s linear infinite',\n                  MozAnimation: 'spin 1s linear infinite',\n                }}\n              />\n            </div>\n            <h2 className=\"mb-1 text-lg font-semibold text-foreground\">{t('mainContent.loading')}</h2>\n            <p className=\"text-sm\">{t('mainContent.settingUpWorkspace')}</p>\n          </div>\n        </div>\n      ) : (\n        <div className=\"flex flex-1 items-center justify-center\">\n          <div className=\"mx-auto max-w-md px-6 text-center\">\n            <div className=\"mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-muted/50\">\n              <Folder className=\"h-7 w-7 text-muted-foreground\" />\n            </div>\n            <h2 className=\"mb-2 text-xl font-semibold text-foreground\">{t('mainContent.chooseProject')}</h2>\n            <p className=\"mb-5 text-sm leading-relaxed text-muted-foreground\">{t('mainContent.selectProjectDescription')}</p>\n            <div className=\"rounded-xl border border-primary/10 bg-primary/5 p-3.5\">\n              <p className=\"text-sm text-primary\">\n                <strong>{t('mainContent.tip')}:</strong> {isMobile ? t('mainContent.createProjectMobile') : t('mainContent.createProjectDesktop')}\n              </p>\n            </div>\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/main-content/view/subcomponents/MainContentTabSwitcher.tsx",
    "content": "import { MessageSquare, Terminal, Folder, GitBranch, ClipboardCheck, type LucideIcon } from 'lucide-react';\nimport type { Dispatch, SetStateAction } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Tooltip, PillBar, Pill } from '../../../../shared/view/ui';\nimport type { AppTab } from '../../../../types/app';\nimport { usePlugins } from '../../../../contexts/PluginsContext';\nimport PluginIcon from '../../../plugins/view/PluginIcon';\n\ntype MainContentTabSwitcherProps = {\n  activeTab: AppTab;\n  setActiveTab: Dispatch<SetStateAction<AppTab>>;\n  shouldShowTasksTab: boolean;\n};\n\ntype BuiltInTab = {\n  kind: 'builtin';\n  id: AppTab;\n  labelKey: string;\n  icon: LucideIcon;\n};\n\ntype PluginTab = {\n  kind: 'plugin';\n  id: AppTab;\n  label: string;\n  pluginName: string;\n  iconFile: string;\n};\n\ntype TabDefinition = BuiltInTab | PluginTab;\n\nconst BASE_TABS: BuiltInTab[] = [\n  { kind: 'builtin', id: 'chat',  labelKey: 'tabs.chat',  icon: MessageSquare },\n  { kind: 'builtin', id: 'shell', labelKey: 'tabs.shell', icon: Terminal },\n  { kind: 'builtin', id: 'files', labelKey: 'tabs.files', icon: Folder },\n  { kind: 'builtin', id: 'git',   labelKey: 'tabs.git',   icon: GitBranch },\n];\n\nconst TASKS_TAB: BuiltInTab = {\n  kind: 'builtin',\n  id: 'tasks',\n  labelKey: 'tabs.tasks',\n  icon: ClipboardCheck,\n};\n\nexport default function MainContentTabSwitcher({\n  activeTab,\n  setActiveTab,\n  shouldShowTasksTab,\n}: MainContentTabSwitcherProps) {\n  const { t } = useTranslation();\n  const { plugins } = usePlugins();\n\n  const builtInTabs: BuiltInTab[] = shouldShowTasksTab ? [...BASE_TABS, TASKS_TAB] : BASE_TABS;\n\n  const pluginTabs: PluginTab[] = plugins\n    .filter((p) => p.enabled)\n    .map((p) => ({\n      kind: 'plugin',\n      id: `plugin:${p.name}` as AppTab,\n      label: p.displayName,\n      pluginName: p.name,\n      iconFile: p.icon,\n    }));\n\n  const tabs: TabDefinition[] = [...builtInTabs, ...pluginTabs];\n\n  return (\n    <PillBar>\n      {tabs.map((tab) => {\n        const isActive = tab.id === activeTab;\n        const displayLabel = tab.kind === 'builtin' ? t(tab.labelKey) : tab.label;\n\n        return (\n          <Tooltip key={tab.id} content={displayLabel} position=\"bottom\">\n            <Pill\n              isActive={isActive}\n              onClick={() => setActiveTab(tab.id)}\n              className=\"px-2.5 py-[5px]\"\n            >\n              {tab.kind === 'builtin' ? (\n                <tab.icon className=\"h-3.5 w-3.5\" strokeWidth={isActive ? 2.2 : 1.8} />\n              ) : (\n                <PluginIcon\n                  pluginName={tab.pluginName}\n                  iconFile={tab.iconFile}\n                  className=\"flex h-3.5 w-3.5 items-center justify-center [&>svg]:h-full [&>svg]:w-full\"\n                />\n              )}\n              <span className=\"hidden lg:inline\">{displayLabel}</span>\n            </Pill>\n          </Tooltip>\n        );\n      })}\n    </PillBar>\n  );\n}\n"
  },
  {
    "path": "src/components/main-content/view/subcomponents/MainContentTitle.tsx",
    "content": "import { useTranslation } from 'react-i18next';\nimport SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';\nimport type { AppTab, Project, ProjectSession } from '../../../../types/app';\nimport { usePlugins } from '../../../../contexts/PluginsContext';\n\ntype MainContentTitleProps = {\n  activeTab: AppTab;\n  selectedProject: Project;\n  selectedSession: ProjectSession | null;\n  shouldShowTasksTab: boolean;\n};\n\nfunction getTabTitle(activeTab: AppTab, shouldShowTasksTab: boolean, t: (key: string) => string, pluginDisplayName?: string) {\n  if (activeTab.startsWith('plugin:') && pluginDisplayName) {\n    return pluginDisplayName;\n  }\n\n  if (activeTab === 'files') {\n    return t('mainContent.projectFiles');\n  }\n\n  if (activeTab === 'git') {\n    return t('tabs.git');\n  }\n\n  if (activeTab === 'tasks' && shouldShowTasksTab) {\n    return 'TaskMaster';\n  }\n\n  return 'Project';\n}\n\nfunction getSessionTitle(session: ProjectSession): string {\n  if (session.__provider === 'cursor') {\n    return (session.name as string) || 'Untitled Session';\n  }\n\n  return (session.summary as string) || 'New Session';\n}\n\nexport default function MainContentTitle({\n  activeTab,\n  selectedProject,\n  selectedSession,\n  shouldShowTasksTab,\n}: MainContentTitleProps) {\n  const { t } = useTranslation();\n  const { plugins } = usePlugins();\n\n  const pluginDisplayName = activeTab.startsWith('plugin:')\n    ? plugins.find((p) => p.name === activeTab.replace('plugin:', ''))?.displayName\n    : undefined;\n\n  const showSessionIcon = activeTab === 'chat' && Boolean(selectedSession);\n  const showChatNewSession = activeTab === 'chat' && !selectedSession;\n\n  return (\n    <div className=\"scrollbar-hide flex min-w-0 flex-1 items-center gap-2 overflow-x-auto\">\n      {showSessionIcon && (\n        <div className=\"flex h-5 w-5 flex-shrink-0 items-center justify-center\">\n          <SessionProviderLogo provider={selectedSession?.__provider} className=\"h-4 w-4\" />\n        </div>\n      )}\n\n      <div className=\"min-w-0 flex-1\">\n        {activeTab === 'chat' && selectedSession ? (\n          <div className=\"min-w-0\">\n            <h2 className=\"scrollbar-hide overflow-x-auto whitespace-nowrap text-sm font-semibold leading-tight text-foreground\">\n              {getSessionTitle(selectedSession)}\n            </h2>\n            <div className=\"truncate text-[11px] leading-tight text-muted-foreground\">{selectedProject.displayName}</div>\n          </div>\n        ) : showChatNewSession ? (\n          <div className=\"min-w-0\">\n            <h2 className=\"text-base font-semibold leading-tight text-foreground\">{t('mainContent.newSession')}</h2>\n            <div className=\"truncate text-xs leading-tight text-muted-foreground\">{selectedProject.displayName}</div>\n          </div>\n        ) : (\n          <div className=\"min-w-0\">\n            <h2 className=\"text-sm font-semibold leading-tight text-foreground\">\n              {getTabTitle(activeTab, shouldShowTasksTab, t, pluginDisplayName)}\n            </h2>\n            <div className=\"truncate text-[11px] leading-tight text-muted-foreground\">{selectedProject.displayName}</div>\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/main-content/view/subcomponents/MobileMenuButton.tsx",
    "content": "import type { MobileMenuButtonProps } from '../../types/types';\nimport { useMobileMenuHandlers } from '../../hooks/useMobileMenuHandlers';\n\nexport default function MobileMenuButton({ onMenuClick, compact = false }: MobileMenuButtonProps) {\n  const { handleMobileMenuClick, handleMobileMenuTouchEnd } = useMobileMenuHandlers(onMenuClick);\n\n  const buttonClasses = compact\n    ? 'p-1.5 text-muted-foreground hover:text-foreground rounded-lg hover:bg-accent/60 pwa-menu-button'\n    : 'p-1.5 text-muted-foreground hover:text-foreground rounded-lg hover:bg-accent/60 touch-manipulation active:scale-95 pwa-menu-button flex-shrink-0';\n\n  return (\n    <button\n      onClick={handleMobileMenuClick}\n      onTouchEnd={handleMobileMenuTouchEnd}\n      className={buttonClasses}\n      aria-label=\"Open menu\"\n    >\n      <svg className=\"h-5 w-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n        <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M4 6h16M4 12h16M4 18h16\" />\n      </svg>\n    </button>\n  );\n}\n"
  },
  {
    "path": "src/components/mic-button/constants/constants.ts",
    "content": "import type { MicButtonState } from '../types/types';\n\nexport const MIC_BUTTON_STATES = {\n  IDLE: 'idle',\n  RECORDING: 'recording',\n  TRANSCRIBING: 'transcribing',\n  PROCESSING: 'processing',\n} as const;\n\nexport const MIC_TAP_DEBOUNCE_MS = 300;\nexport const PROCESSING_STATE_DELAY_MS = 2000;\n\nexport const DEFAULT_WHISPER_MODE = 'default';\n\n// Modes that use post-transcription enhancement on the backend.\nexport const ENHANCEMENT_WHISPER_MODES = new Set([\n  'prompt',\n  'vibe',\n  'instructions',\n  'architect',\n]);\n\nexport const BUTTON_BACKGROUND_BY_STATE: Record<MicButtonState, string> = {\n  idle: '#374151',\n  recording: '#ef4444',\n  transcribing: '#3b82f6',\n  processing: '#a855f7',\n};\n\nexport const MIC_ERROR_BY_NAME = {\n  NotAllowedError: 'Microphone access denied. Please allow microphone permissions.',\n  NotFoundError: 'No microphone found. Please check your audio devices.',\n  NotSupportedError: 'Microphone not supported by this browser.',\n  NotReadableError: 'Microphone is being used by another application.',\n} as const;\n\nexport const MIC_NOT_AVAILABLE_ERROR =\n  'Microphone access not available. Please use HTTPS or a supported browser.';\n\nexport const MIC_NOT_SUPPORTED_ERROR =\n  'Microphone not supported. Please use HTTPS or a modern browser.';\n\nexport const MIC_SECURE_CONTEXT_ERROR =\n  'Microphone requires HTTPS. Please use a secure connection.';\n\n"
  },
  {
    "path": "src/components/mic-button/data/whisper.ts",
    "content": "import { api } from '../../../utils/api';\n\ntype WhisperStatus = 'transcribing';\n\ntype WhisperResponse = {\n  text?: string;\n  error?: string;\n};\n\nexport async function transcribeWithWhisper(\n  audioBlob: Blob,\n  onStatusChange?: (status: WhisperStatus) => void,\n): Promise<string> {\n  const formData = new FormData();\n  const fileName = `recording_${Date.now()}.webm`;\n  const file = new File([audioBlob], fileName, { type: audioBlob.type });\n\n  formData.append('audio', file);\n\n  const whisperMode = window.localStorage.getItem('whisperMode') || 'default';\n  formData.append('mode', whisperMode);\n\n  try {\n    // Keep existing status callback behavior.\n    if (onStatusChange) {\n      onStatusChange('transcribing');\n    }\n\n    const response = (await api.transcribe(formData)) as Response;\n\n    if (!response.ok) {\n      const errorData = (await response.json().catch(() => ({}))) as WhisperResponse;\n      throw new Error(\n        errorData.error ||\n          `Transcription error: ${response.status} ${response.statusText}`,\n      );\n    }\n\n    const data = (await response.json()) as WhisperResponse;\n    return data.text || '';\n  } catch (error) {\n    if (\n      error instanceof Error\n      && error.name === 'TypeError'\n      && error.message.includes('fetch')\n    ) {\n      throw new Error('Cannot connect to server. Please ensure the backend is running.');\n    }\n    throw error;\n  }\n}\n\n"
  },
  {
    "path": "src/components/mic-button/hooks/useMicButtonController.ts",
    "content": "import { useEffect, useRef, useState } from 'react';\nimport type { MouseEvent } from 'react';\nimport { transcribeWithWhisper } from '../data/whisper';\nimport {\n  DEFAULT_WHISPER_MODE,\n  ENHANCEMENT_WHISPER_MODES,\n  MIC_BUTTON_STATES,\n  MIC_ERROR_BY_NAME,\n  MIC_NOT_AVAILABLE_ERROR,\n  MIC_NOT_SUPPORTED_ERROR,\n  MIC_SECURE_CONTEXT_ERROR,\n  MIC_TAP_DEBOUNCE_MS,\n  PROCESSING_STATE_DELAY_MS,\n} from '../constants/constants';\nimport type { MicButtonState } from '../types/types';\n\ntype UseMicButtonControllerArgs = {\n  onTranscript?: (transcript: string) => void;\n};\n\ntype UseMicButtonControllerResult = {\n  state: MicButtonState;\n  error: string | null;\n  isSupported: boolean;\n  handleButtonClick: (event?: MouseEvent<HTMLButtonElement>) => void;\n};\n\nconst getRecordingErrorMessage = (error: unknown): string => {\n  if (error instanceof Error && error.message.includes('HTTPS')) {\n    return error.message;\n  }\n\n  if (error instanceof DOMException) {\n    return MIC_ERROR_BY_NAME[error.name as keyof typeof MIC_ERROR_BY_NAME] || 'Microphone access failed';\n  }\n\n  return 'Microphone access failed';\n};\n\nconst getRecorderMimeType = (): string => (\n  MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : 'audio/mp4'\n);\n\nexport function useMicButtonController({\n  onTranscript,\n}: UseMicButtonControllerArgs): UseMicButtonControllerResult {\n  const [state, setState] = useState<MicButtonState>(MIC_BUTTON_STATES.IDLE);\n  const [error, setError] = useState<string | null>(null);\n  const [isSupported, setIsSupported] = useState(true);\n\n  const mediaRecorderRef = useRef<MediaRecorder | null>(null);\n  const streamRef = useRef<MediaStream | null>(null);\n  const chunksRef = useRef<BlobPart[]>([]);\n  const lastTapRef = useRef(0);\n  const processingTimerRef = useRef<number | null>(null);\n\n  const clearProcessingTimer = (): void => {\n    if (processingTimerRef.current !== null) {\n      window.clearTimeout(processingTimerRef.current);\n      processingTimerRef.current = null;\n    }\n  };\n\n  const stopStreamTracks = (): void => {\n    if (!streamRef.current) {\n      return;\n    }\n\n    streamRef.current.getTracks().forEach((track) => track.stop());\n    streamRef.current = null;\n  };\n\n  const handleStopRecording = async (mimeType: string): Promise<void> => {\n    const audioBlob = new Blob(chunksRef.current, { type: mimeType });\n\n    // Release the microphone immediately once recording ends.\n    stopStreamTracks();\n    setState(MIC_BUTTON_STATES.TRANSCRIBING);\n\n    const whisperMode = window.localStorage.getItem('whisperMode') || DEFAULT_WHISPER_MODE;\n    const shouldShowProcessingState = ENHANCEMENT_WHISPER_MODES.has(whisperMode);\n\n    if (shouldShowProcessingState) {\n      processingTimerRef.current = window.setTimeout(() => {\n        setState(MIC_BUTTON_STATES.PROCESSING);\n      }, PROCESSING_STATE_DELAY_MS);\n    }\n\n    try {\n      const transcript = await transcribeWithWhisper(audioBlob);\n      if (transcript && onTranscript) {\n        onTranscript(transcript);\n      }\n    } catch (transcriptionError) {\n      const message = transcriptionError instanceof Error ? transcriptionError.message : 'Transcription error';\n      setError(message);\n    } finally {\n      clearProcessingTimer();\n      setState(MIC_BUTTON_STATES.IDLE);\n    }\n  };\n\n  const startRecording = async (): Promise<void> => {\n    try {\n      setError(null);\n      chunksRef.current = [];\n\n      if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {\n        throw new Error(MIC_NOT_AVAILABLE_ERROR);\n      }\n\n      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });\n      streamRef.current = stream;\n\n      const mimeType = getRecorderMimeType();\n      const recorder = new MediaRecorder(stream, { mimeType });\n      mediaRecorderRef.current = recorder;\n\n      recorder.ondataavailable = (event: BlobEvent) => {\n        if (event.data.size > 0) {\n          chunksRef.current.push(event.data);\n        }\n      };\n\n      recorder.onstop = () => {\n        void handleStopRecording(mimeType);\n      };\n\n      recorder.start();\n      setState(MIC_BUTTON_STATES.RECORDING);\n    } catch (recordingError) {\n      stopStreamTracks();\n      setError(getRecordingErrorMessage(recordingError));\n      setState(MIC_BUTTON_STATES.IDLE);\n    }\n  };\n\n  const stopRecording = (): void => {\n    if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {\n      mediaRecorderRef.current.stop();\n      return;\n    }\n\n    stopStreamTracks();\n    setState(MIC_BUTTON_STATES.IDLE);\n  };\n\n  const handleButtonClick = (event?: MouseEvent<HTMLButtonElement>): void => {\n    if (event) {\n      event.preventDefault();\n      event.stopPropagation();\n    }\n\n    if (!isSupported) {\n      return;\n    }\n\n    // Mobile tap handling can trigger duplicate click events in quick succession.\n    const now = Date.now();\n    if (now - lastTapRef.current < MIC_TAP_DEBOUNCE_MS) {\n      return;\n    }\n    lastTapRef.current = now;\n\n    if (state === MIC_BUTTON_STATES.IDLE) {\n      void startRecording();\n      return;\n    }\n\n    if (state === MIC_BUTTON_STATES.RECORDING) {\n      stopRecording();\n    }\n  };\n\n  useEffect(() => {\n    // getUserMedia needs both browser support and a secure context.\n    if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {\n      setIsSupported(false);\n      setError(MIC_NOT_SUPPORTED_ERROR);\n      return;\n    }\n\n    if (location.protocol !== 'https:' && location.hostname !== 'localhost') {\n      setIsSupported(false);\n      setError(MIC_SECURE_CONTEXT_ERROR);\n      return;\n    }\n\n    setIsSupported(true);\n    setError(null);\n  }, []);\n\n  useEffect(() => () => {\n    clearProcessingTimer();\n    stopStreamTracks();\n  }, []);\n\n  return {\n    state,\n    error,\n    isSupported,\n    handleButtonClick,\n  };\n}\n"
  },
  {
    "path": "src/components/mic-button/types/types.ts",
    "content": "export type MicButtonState = 'idle' | 'recording' | 'transcribing' | 'processing';\n\n"
  },
  {
    "path": "src/components/mic-button/view/MicButton.tsx",
    "content": "import { useMicButtonController } from '../hooks/useMicButtonController';\nimport MicButtonView from './MicButtonView';\n\ntype MicButtonProps = {\n  onTranscript?: (transcript: string) => void;\n  className?: string;\n  mode?: string;\n};\n\nexport default function MicButton({\n  onTranscript,\n  className = '',\n  mode: _mode,\n}: MicButtonProps) {\n  const { state, error, isSupported, handleButtonClick } = useMicButtonController({\n    onTranscript,\n  });\n\n  // Keep `mode` in the public props for backwards compatibility.\n  void _mode;\n\n  return (\n    <MicButtonView\n      state={state}\n      error={error}\n      isSupported={isSupported}\n      className={className}\n      onButtonClick={handleButtonClick}\n    />\n  );\n}\n\n"
  },
  {
    "path": "src/components/mic-button/view/MicButtonView.tsx",
    "content": "import { Brain, Loader2, Mic } from 'lucide-react';\nimport type { MouseEvent, ReactElement } from 'react';\nimport { BUTTON_BACKGROUND_BY_STATE, MIC_BUTTON_STATES } from '../constants/constants';\nimport type { MicButtonState } from '../types/types';\n\ntype MicButtonViewProps = {\n  state: MicButtonState;\n  error: string | null;\n  isSupported: boolean;\n  className: string;\n  onButtonClick: (event?: MouseEvent<HTMLButtonElement>) => void;\n};\n\nconst getButtonIcon = (state: MicButtonState, isSupported: boolean): ReactElement => {\n  if (!isSupported) {\n    return <Mic className=\"h-5 w-5\" />;\n  }\n\n  if (state === MIC_BUTTON_STATES.TRANSCRIBING) {\n    return <Loader2 className=\"h-5 w-5 animate-spin\" />;\n  }\n\n  if (state === MIC_BUTTON_STATES.PROCESSING) {\n    return <Brain className=\"h-5 w-5 animate-pulse\" />;\n  }\n\n  if (state === MIC_BUTTON_STATES.RECORDING) {\n    return <Mic className=\"h-5 w-5 text-white\" />;\n  }\n\n  return <Mic className=\"h-5 w-5\" />;\n};\n\nexport default function MicButtonView({\n  state,\n  error,\n  isSupported,\n  className,\n  onButtonClick,\n}: MicButtonViewProps) {\n  const isDisabled = !isSupported || state === MIC_BUTTON_STATES.TRANSCRIBING || state === MIC_BUTTON_STATES.PROCESSING;\n  const icon = getButtonIcon(state, isSupported);\n\n  return (\n    <div className=\"relative\">\n      <button\n        type=\"button\"\n        style={{ backgroundColor: BUTTON_BACKGROUND_BY_STATE[state] }}\n        className={`\n          touch-action-manipulation flex h-12\n          w-12 items-center justify-center\n          rounded-full text-white transition-all\n          duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500\n          focus:ring-offset-2\n          dark:ring-offset-gray-800\n          ${isDisabled ? 'cursor-not-allowed opacity-75' : 'cursor-pointer'}\n          ${state === MIC_BUTTON_STATES.RECORDING ? 'animate-pulse' : ''}\n          hover:opacity-90\n          ${className}\n        `}\n        onClick={onButtonClick}\n        disabled={isDisabled}\n      >\n        {icon}\n      </button>\n\n      {error && (\n        <div\n          className=\"animate-fade-in absolute left-1/2 top-full z-10 mt-2\n                        -translate-x-1/2 transform whitespace-nowrap rounded bg-red-500 px-2 py-1 text-xs\n                        text-white\"\n        >\n          {error}\n        </div>\n      )}\n\n      {state === MIC_BUTTON_STATES.RECORDING && (\n        <div className=\"pointer-events-none absolute -inset-1 animate-ping rounded-full border-2 border-red-500\" />\n      )}\n\n      {state === MIC_BUTTON_STATES.PROCESSING && (\n        <div className=\"pointer-events-none absolute -inset-1 animate-ping rounded-full border-2 border-purple-500\" />\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/onboarding/view/Onboarding.tsx",
    "content": "import { Check, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';\nimport { useCallback, useEffect, useRef, useState } from 'react';\nimport { authenticatedFetch } from '../../../utils/api';\nimport ProviderLoginModal from '../../provider-auth/view/ProviderLoginModal';\nimport AgentConnectionsStep from './subcomponents/AgentConnectionsStep';\nimport GitConfigurationStep from './subcomponents/GitConfigurationStep';\nimport OnboardingStepProgress from './subcomponents/OnboardingStepProgress';\nimport type { CliProvider, ProviderStatusMap } from './types';\nimport {\n  cliProviders,\n  createInitialProviderStatuses,\n  gitEmailPattern,\n  readErrorMessageFromResponse,\n  selectedProject,\n} from './utils';\n\ntype OnboardingProps = {\n  onComplete?: () => void | Promise<void>;\n};\n\nexport default function Onboarding({ onComplete }: OnboardingProps) {\n  const [currentStep, setCurrentStep] = useState(0);\n  const [gitName, setGitName] = useState('');\n  const [gitEmail, setGitEmail] = useState('');\n  const [isSubmitting, setIsSubmitting] = useState(false);\n  const [errorMessage, setErrorMessage] = useState('');\n  const [activeLoginProvider, setActiveLoginProvider] = useState<CliProvider | null>(null);\n  const [providerStatuses, setProviderStatuses] = useState<ProviderStatusMap>(createInitialProviderStatuses);\n\n  const previousActiveLoginProviderRef = useRef<CliProvider | null | undefined>(undefined);\n\n  const checkProviderAuthStatus = useCallback(async (provider: CliProvider) => {\n    try {\n      const response = await authenticatedFetch(`/api/cli/${provider}/status`);\n      if (!response.ok) {\n        setProviderStatuses((previous) => ({\n          ...previous,\n          [provider]: {\n            authenticated: false,\n            email: null,\n            loading: false,\n            error: 'Failed to check authentication status',\n          },\n        }));\n        return;\n      }\n\n      const payload = (await response.json()) as {\n        authenticated?: boolean;\n        email?: string | null;\n        error?: string | null;\n      };\n\n      setProviderStatuses((previous) => ({\n        ...previous,\n        [provider]: {\n          authenticated: Boolean(payload.authenticated),\n          email: payload.email ?? null,\n          loading: false,\n          error: payload.error ?? null,\n        },\n      }));\n    } catch (caughtError) {\n      console.error(`Error checking ${provider} auth status:`, caughtError);\n      setProviderStatuses((previous) => ({\n        ...previous,\n        [provider]: {\n          authenticated: false,\n          email: null,\n          loading: false,\n          error: caughtError instanceof Error ? caughtError.message : 'Unknown error',\n        },\n      }));\n    }\n  }, []);\n\n  const refreshAllProviderStatuses = useCallback(async () => {\n    await Promise.all(cliProviders.map((provider) => checkProviderAuthStatus(provider)));\n  }, [checkProviderAuthStatus]);\n\n  const loadGitConfig = useCallback(async () => {\n    try {\n      const response = await authenticatedFetch('/api/user/git-config');\n      if (!response.ok) {\n        return;\n      }\n\n      const payload = (await response.json()) as { gitName?: string; gitEmail?: string };\n      if (payload.gitName) {\n        setGitName(payload.gitName);\n      }\n      if (payload.gitEmail) {\n        setGitEmail(payload.gitEmail);\n      }\n    } catch (caughtError) {\n      console.error('Error loading git config:', caughtError);\n    }\n  }, []);\n\n  useEffect(() => {\n    void loadGitConfig();\n    void refreshAllProviderStatuses();\n  }, [loadGitConfig, refreshAllProviderStatuses]);\n\n  useEffect(() => {\n    const previousProvider = previousActiveLoginProviderRef.current;\n    previousActiveLoginProviderRef.current = activeLoginProvider;\n\n    const isInitialMount = previousProvider === undefined;\n    const didCloseModal = previousProvider !== null && activeLoginProvider === null;\n\n    // Refresh statuses once on mount and again after the login modal is closed.\n    if (isInitialMount || didCloseModal) {\n      void refreshAllProviderStatuses();\n    }\n  }, [activeLoginProvider, refreshAllProviderStatuses]);\n\n  const handleProviderLoginOpen = (provider: CliProvider) => {\n    setActiveLoginProvider(provider);\n  };\n\n  const handleLoginComplete = (exitCode: number) => {\n    if (exitCode === 0 && activeLoginProvider) {\n      void checkProviderAuthStatus(activeLoginProvider);\n    }\n  };\n\n  const handleNextStep = async () => {\n    setErrorMessage('');\n\n    if (currentStep !== 0) {\n      setCurrentStep((previous) => previous + 1);\n      return;\n    }\n\n    if (!gitName.trim() || !gitEmail.trim()) {\n      setErrorMessage('Both git name and email are required.');\n      return;\n    }\n\n    if (!gitEmailPattern.test(gitEmail)) {\n      setErrorMessage('Please enter a valid email address.');\n      return;\n    }\n\n    setIsSubmitting(true);\n    try {\n      const response = await authenticatedFetch('/api/user/git-config', {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({ gitName, gitEmail }),\n      });\n\n      if (!response.ok) {\n        const message = await readErrorMessageFromResponse(response, 'Failed to save git configuration');\n        throw new Error(message);\n      }\n\n      setCurrentStep((previous) => previous + 1);\n    } catch (caughtError) {\n      setErrorMessage(caughtError instanceof Error ? caughtError.message : 'Failed to save git configuration');\n    } finally {\n      setIsSubmitting(false);\n    }\n  };\n\n  const handlePreviousStep = () => {\n    setErrorMessage('');\n    setCurrentStep((previous) => previous - 1);\n  };\n\n  const handleFinish = async () => {\n    setIsSubmitting(true);\n    setErrorMessage('');\n\n    try {\n      const response = await authenticatedFetch('/api/user/complete-onboarding', { method: 'POST' });\n      if (!response.ok) {\n        const message = await readErrorMessageFromResponse(response, 'Failed to complete onboarding');\n        throw new Error(message);\n      }\n\n      await onComplete?.();\n    } catch (caughtError) {\n      setErrorMessage(caughtError instanceof Error ? caughtError.message : 'Failed to complete onboarding');\n    } finally {\n      setIsSubmitting(false);\n    }\n  };\n\n  const isCurrentStepValid = currentStep === 0\n    ? Boolean(gitName.trim() && gitEmail.trim() && gitEmailPattern.test(gitEmail))\n    : true;\n\n  return (\n    <>\n      <div className=\"flex min-h-screen items-center justify-center bg-background p-4\">\n        <div className=\"w-full max-w-2xl\">\n          <OnboardingStepProgress currentStep={currentStep} />\n\n          <div className=\"rounded-lg border border-border bg-card p-8 shadow-lg\">\n            {currentStep === 0 ? (\n              <GitConfigurationStep\n                gitName={gitName}\n                gitEmail={gitEmail}\n                isSubmitting={isSubmitting}\n                onGitNameChange={setGitName}\n                onGitEmailChange={setGitEmail}\n              />\n            ) : (\n              <AgentConnectionsStep\n                providerStatuses={providerStatuses}\n                onOpenProviderLogin={handleProviderLoginOpen}\n              />\n            )}\n\n            {errorMessage && (\n              <div className=\"mt-6 rounded-lg border border-red-300 bg-red-100 p-4 dark:border-red-800 dark:bg-red-900/20\">\n                <p className=\"text-sm text-red-700 dark:text-red-400\">{errorMessage}</p>\n              </div>\n            )}\n\n            <div className=\"mt-8 flex items-center justify-between border-t border-border pt-6\">\n              <button\n                onClick={handlePreviousStep}\n                disabled={currentStep === 0 || isSubmitting}\n                className=\"flex items-center gap-2 px-4 py-2 text-sm font-medium text-muted-foreground transition-colors duration-200 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50\"\n              >\n                <ChevronLeft className=\"h-4 w-4\" />\n                Previous\n              </button>\n\n              <div className=\"flex items-center gap-3\">\n                {currentStep < 1 ? (\n                  <button\n                    onClick={handleNextStep}\n                    disabled={!isCurrentStepValid || isSubmitting}\n                    className=\"flex items-center gap-2 rounded-lg bg-blue-600 px-6 py-3 font-medium text-white transition-colors duration-200 hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-blue-400\"\n                  >\n                    {isSubmitting ? (\n                      <>\n                        <Loader2 className=\"h-4 w-4 animate-spin\" />\n                        Saving...\n                      </>\n                    ) : (\n                      <>\n                        Next\n                        <ChevronRight className=\"h-4 w-4\" />\n                      </>\n                    )}\n                  </button>\n                ) : (\n                  <button\n                    onClick={handleFinish}\n                    disabled={isSubmitting}\n                    className=\"flex items-center gap-2 rounded-lg bg-green-600 px-6 py-3 font-medium text-white transition-colors duration-200 hover:bg-green-700 disabled:cursor-not-allowed disabled:bg-green-400\"\n                  >\n                    {isSubmitting ? (\n                      <>\n                        <Loader2 className=\"h-4 w-4 animate-spin\" />\n                        Completing...\n                      </>\n                    ) : (\n                      <>\n                        <Check className=\"h-4 w-4\" />\n                        Complete Setup\n                      </>\n                    )}\n                  </button>\n                )}\n              </div>\n            </div>\n          </div>\n        </div>\n      </div>\n\n      {activeLoginProvider && (\n        <ProviderLoginModal\n          isOpen={Boolean(activeLoginProvider)}\n          onClose={() => setActiveLoginProvider(null)}\n          provider={activeLoginProvider}\n          project={selectedProject}\n          onComplete={handleLoginComplete}\n        />\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "src/components/onboarding/view/subcomponents/AgentConnectionCard.tsx",
    "content": "import { Check } from 'lucide-react';\nimport SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';\nimport type { CliProvider, ProviderAuthStatus } from '../types';\n\ntype AgentConnectionCardProps = {\n  provider: CliProvider;\n  title: string;\n  status: ProviderAuthStatus;\n  connectedClassName: string;\n  iconContainerClassName: string;\n  loginButtonClassName: string;\n  onLogin: () => void;\n};\n\nexport default function AgentConnectionCard({\n  provider,\n  title,\n  status,\n  connectedClassName,\n  iconContainerClassName,\n  loginButtonClassName,\n  onLogin,\n}: AgentConnectionCardProps) {\n  const containerClassName = status.authenticated ? connectedClassName : 'border-border bg-card';\n\n  const statusText = status.loading\n    ? 'Checking...'\n    : status.authenticated\n      ? status.email || 'Connected'\n      : status.error || 'Not connected';\n\n  return (\n    <div className={`rounded-lg border p-4 transition-colors ${containerClassName}`}>\n      <div className=\"flex items-center justify-between\">\n        <div className=\"flex items-center gap-3\">\n          <div className={`flex h-10 w-10 items-center justify-center rounded-full ${iconContainerClassName}`}>\n            <SessionProviderLogo provider={provider} className=\"h-5 w-5\" />\n          </div>\n\n          <div>\n            <div className=\"flex items-center gap-2 font-medium text-foreground\">\n              {title}\n              {status.authenticated && <Check className=\"h-4 w-4 text-green-500\" />}\n            </div>\n            <div className=\"text-xs text-muted-foreground\">{statusText}</div>\n          </div>\n        </div>\n\n        {!status.authenticated && !status.loading && (\n          <button\n            onClick={onLogin}\n            className={`${loginButtonClassName} rounded-lg px-4 py-2 text-sm font-medium text-white transition-colors`}\n          >\n            Login\n          </button>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/onboarding/view/subcomponents/AgentConnectionsStep.tsx",
    "content": "import type { CliProvider, ProviderStatusMap } from '../types';\nimport AgentConnectionCard from './AgentConnectionCard';\n\ntype AgentConnectionsStepProps = {\n  providerStatuses: ProviderStatusMap;\n  onOpenProviderLogin: (provider: CliProvider) => void;\n};\n\nconst providerCards = [\n  {\n    provider: 'claude' as const,\n    title: 'Claude Code',\n    connectedClassName: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800',\n    iconContainerClassName: 'bg-blue-100 dark:bg-blue-900/30',\n    loginButtonClassName: 'bg-blue-600 hover:bg-blue-700',\n  },\n  {\n    provider: 'cursor' as const,\n    title: 'Cursor',\n    connectedClassName: 'bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800',\n    iconContainerClassName: 'bg-purple-100 dark:bg-purple-900/30',\n    loginButtonClassName: 'bg-purple-600 hover:bg-purple-700',\n  },\n  {\n    provider: 'codex' as const,\n    title: 'OpenAI Codex',\n    connectedClassName: 'bg-gray-100 dark:bg-gray-800/50 border-gray-300 dark:border-gray-600',\n    iconContainerClassName: 'bg-gray-100 dark:bg-gray-800',\n    loginButtonClassName: 'bg-gray-800 hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600',\n  },\n  {\n    provider: 'gemini' as const,\n    title: 'Gemini',\n    connectedClassName: 'bg-teal-50 dark:bg-teal-900/20 border-teal-200 dark:border-teal-800',\n    iconContainerClassName: 'bg-teal-100 dark:bg-teal-900/30',\n    loginButtonClassName: 'bg-teal-600 hover:bg-teal-700',\n  },\n];\n\nexport default function AgentConnectionsStep({\n  providerStatuses,\n  onOpenProviderLogin,\n}: AgentConnectionsStepProps) {\n  return (\n    <div className=\"space-y-6\">\n      <div className=\"mb-6 text-center\">\n        <h2 className=\"mb-2 text-2xl font-bold text-foreground\">Connect Your AI Agents</h2>\n        <p className=\"text-muted-foreground\">\n          Login to one or more AI coding assistants. All are optional.\n        </p>\n      </div>\n\n      <div className=\"space-y-3\">\n        {providerCards.map((providerCard) => (\n          <AgentConnectionCard\n            key={providerCard.provider}\n            provider={providerCard.provider}\n            title={providerCard.title}\n            status={providerStatuses[providerCard.provider]}\n            connectedClassName={providerCard.connectedClassName}\n            iconContainerClassName={providerCard.iconContainerClassName}\n            loginButtonClassName={providerCard.loginButtonClassName}\n            onLogin={() => onOpenProviderLogin(providerCard.provider)}\n          />\n        ))}\n      </div>\n\n      <div className=\"pt-2 text-center text-sm text-muted-foreground\">\n        <p>You can configure these later in Settings.</p>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/onboarding/view/subcomponents/GitConfigurationStep.tsx",
    "content": "import { GitBranch, Mail, User } from 'lucide-react';\n\ntype GitConfigurationStepProps = {\n  gitName: string;\n  gitEmail: string;\n  isSubmitting: boolean;\n  onGitNameChange: (value: string) => void;\n  onGitEmailChange: (value: string) => void;\n};\n\nexport default function GitConfigurationStep({\n  gitName,\n  gitEmail,\n  isSubmitting,\n  onGitNameChange,\n  onGitEmailChange,\n}: GitConfigurationStepProps) {\n  return (\n    <div className=\"space-y-6\">\n      <div className=\"mb-8 text-center\">\n        <div className=\"mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/30\">\n          <GitBranch className=\"h-8 w-8 text-blue-600 dark:text-blue-400\" />\n        </div>\n        <h2 className=\"mb-2 text-2xl font-bold text-foreground\">Git Configuration</h2>\n        <p className=\"text-muted-foreground\">\n          Configure your git identity to ensure proper attribution for commits.\n        </p>\n      </div>\n\n      <div className=\"space-y-4\">\n        <div>\n          <label htmlFor=\"gitName\" className=\"mb-2 flex items-center gap-2 text-sm font-medium text-foreground\">\n            <User className=\"h-4 w-4\" />\n            Git Name <span className=\"text-red-500\">*</span>\n          </label>\n          <input\n            type=\"text\"\n            id=\"gitName\"\n            value={gitName}\n            onChange={(event) => onGitNameChange(event.target.value)}\n            className=\"w-full rounded-lg border border-border bg-background px-4 py-3 text-foreground focus:border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500\"\n            placeholder=\"John Doe\"\n            required\n            disabled={isSubmitting}\n          />\n          <p className=\"mt-1 text-xs text-muted-foreground\">Saved as `git config --global user.name`.</p>\n        </div>\n\n        <div>\n          <label htmlFor=\"gitEmail\" className=\"mb-2 flex items-center gap-2 text-sm font-medium text-foreground\">\n            <Mail className=\"h-4 w-4\" />\n            Git Email <span className=\"text-red-500\">*</span>\n          </label>\n          <input\n            type=\"email\"\n            id=\"gitEmail\"\n            value={gitEmail}\n            onChange={(event) => onGitEmailChange(event.target.value)}\n            className=\"w-full rounded-lg border border-border bg-background px-4 py-3 text-foreground focus:border-transparent focus:outline-none focus:ring-2 focus:ring-blue-500\"\n            placeholder=\"john@example.com\"\n            required\n            disabled={isSubmitting}\n          />\n          <p className=\"mt-1 text-xs text-muted-foreground\">Saved as `git config --global user.email`.</p>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/onboarding/view/subcomponents/OnboardingStepProgress.tsx",
    "content": "import { Check, GitBranch, LogIn } from 'lucide-react';\n\ntype OnboardingStepProgressProps = {\n  currentStep: number;\n};\n\nconst onboardingSteps = [\n  { title: 'Git Configuration', icon: GitBranch, required: true },\n  { title: 'Connect Agents', icon: LogIn, required: false },\n];\n\nexport default function OnboardingStepProgress({ currentStep }: OnboardingStepProgressProps) {\n  return (\n    <div className=\"mb-8\">\n      <div className=\"flex items-center justify-between\">\n        {onboardingSteps.map((step, index) => {\n          const isCompleted = index < currentStep;\n          const isActive = index === currentStep;\n          const Icon = step.icon;\n\n          return (\n            <div key={step.title} className=\"contents\">\n              <div className=\"flex flex-1 flex-col items-center\">\n                <div\n                  className={`flex h-12 w-12 items-center justify-center rounded-full border-2 transition-colors duration-200 ${\n                    isCompleted\n                      ? 'border-green-500 bg-green-500 text-white'\n                      : isActive\n                        ? 'border-blue-600 bg-blue-600 text-white'\n                        : 'border-border bg-background text-muted-foreground'\n                  }`}\n                >\n                  {isCompleted ? <Check className=\"h-6 w-6\" /> : <Icon className=\"h-6 w-6\" />}\n                </div>\n\n                <div className=\"mt-2 text-center\">\n                  <p className={`text-sm font-medium ${isActive ? 'text-foreground' : 'text-muted-foreground'}`}>\n                    {step.title}\n                  </p>\n                  {step.required && <span className=\"text-xs text-red-500\">Required</span>}\n                </div>\n              </div>\n\n              {index < onboardingSteps.length - 1 && (\n                <div className={`mx-2 h-0.5 flex-1 transition-colors duration-200 ${isCompleted ? 'bg-green-500' : 'bg-border'}`} />\n              )}\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/onboarding/view/types.ts",
    "content": "import type { CliProvider } from '../../provider-auth/types';\n\nexport type { CliProvider };\n\nexport type ProviderAuthStatus = {\n  authenticated: boolean;\n  email: string | null;\n  loading: boolean;\n  error: string | null;\n};\n\nexport type ProviderStatusMap = Record<CliProvider, ProviderAuthStatus>;\n"
  },
  {
    "path": "src/components/onboarding/view/utils.ts",
    "content": "import { IS_PLATFORM } from '../../../constants/config';\nimport type { CliProvider, ProviderStatusMap } from './types';\n\nexport const cliProviders: CliProvider[] = ['claude', 'cursor', 'codex', 'gemini'];\n\nexport const gitEmailPattern = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n\nexport const selectedProject = {\n  name: 'default',\n  displayName: 'default',\n  fullPath: IS_PLATFORM ? '/workspace' : '',\n  path: IS_PLATFORM ? '/workspace' : '',\n};\n\nexport const createInitialProviderStatuses = (): ProviderStatusMap => ({\n  claude: { authenticated: false, email: null, loading: true, error: null },\n  cursor: { authenticated: false, email: null, loading: true, error: null },\n  codex: { authenticated: false, email: null, loading: true, error: null },\n  gemini: { authenticated: false, email: null, loading: true, error: null },\n});\n\nexport const readErrorMessageFromResponse = async (response: Response, fallback: string) => {\n  try {\n    const payload = (await response.json()) as { error?: string };\n    return payload.error || fallback;\n  } catch {\n    return fallback;\n  }\n};\n"
  },
  {
    "path": "src/components/plugins/view/PluginIcon.tsx",
    "content": "import { useState, useEffect } from 'react';\nimport { authenticatedFetch } from '../../../utils/api';\n\ntype Props = {\n  pluginName: string;\n  iconFile: string;\n  className?: string;\n};\n\n// Module-level cache so repeated renders don't re-fetch\nconst svgCache = new Map<string, string>();\n\nexport default function PluginIcon({ pluginName, iconFile, className }: Props) {\n  const url = iconFile\n    ? `/api/plugins/${encodeURIComponent(pluginName)}/assets/${encodeURIComponent(iconFile)}`\n    : '';\n  const [svg, setSvg] = useState<string | null>(url ? (svgCache.get(url) ?? null) : null);\n\n  useEffect(() => {\n    if (!url || svgCache.has(url)) return;\n    authenticatedFetch(url)\n      .then((r) => {\n        if (!r.ok) return;\n        return r.text();\n      })\n      .then((text) => {\n        if (text && text.trimStart().startsWith('<svg')) {\n          svgCache.set(url, text);\n          setSvg(text);\n        }\n      })\n      .catch(() => {});\n  }, [url]);\n\n  if (!svg) return <span className={className} />;\n\n  return (\n    <span\n      className={className}\n      // SVG is fetched from the user's own installed plugin — same trust level as the plugin code itself\n      dangerouslySetInnerHTML={{ __html: svg }}\n    />\n  );\n}\n"
  },
  {
    "path": "src/components/plugins/view/PluginSettingsTab.tsx",
    "content": "import { useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Trash2, RefreshCw, GitBranch, Loader2, ServerCrash, ShieldAlert, ExternalLink, BookOpen, Download, BarChart3 } from 'lucide-react';\nimport { usePlugins } from '../../../contexts/PluginsContext';\nimport type { Plugin } from '../../../contexts/PluginsContext';\nimport PluginIcon from './PluginIcon';\n\nconst STARTER_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-starter';\n\n/* ─── Toggle Switch ─────────────────────────────────────────────────────── */\nfunction ToggleSwitch({ checked, onChange, ariaLabel }: { checked: boolean; onChange: (v: boolean) => void; ariaLabel: string }) {\n  return (\n    <label className=\"relative inline-flex cursor-pointer select-none items-center\">\n      <input\n        type=\"checkbox\"\n        className=\"peer sr-only\"\n        checked={checked}\n        onChange={(e) => onChange(e.target.checked)}\n        aria-label={ariaLabel}\n      />\n      <div\n        className={`\n          relative h-5 w-9 rounded-full bg-muted transition-colors\n          duration-200 after:absolute\n          after:left-[2px] after:top-[2px] after:h-4 after:w-4\n          after:rounded-full after:bg-white after:shadow-sm after:transition-transform after:duration-200\n          after:content-[''] peer-checked:bg-emerald-500\n          peer-checked:after:translate-x-4\n        `}\n      />\n    </label>\n  );\n}\n\n/* ─── Server Dot ────────────────────────────────────────────────────────── */\nfunction ServerDot({ running, t }: { running: boolean; t: any }) {\n  if (!running) return null;\n  return (\n    <span className=\"relative flex items-center gap-1.5\">\n      <span className=\"relative flex h-1.5 w-1.5\">\n        <span className=\"absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75\" />\n        <span className=\"relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500\" />\n      </span>\n      <span className=\"font-mono text-[10px] uppercase tracking-wide text-emerald-600 dark:text-emerald-400\">\n        {t('pluginSettings.runningStatus')}\n      </span>\n    </span>\n  );\n}\n\n/* ─── Plugin Card ───────────────────────────────────────────────────────── */\ntype PluginCardProps = {\n  plugin: Plugin;\n  index: number;\n  onToggle: (enabled: boolean) => void;\n  onUpdate: () => void;\n  onUninstall: () => void;\n  updating: boolean;\n  confirmingUninstall: boolean;\n  onCancelUninstall: () => void;\n  updateError: string | null;\n};\n\nfunction PluginCard({\n  plugin,\n  index,\n  onToggle,\n  onUpdate,\n  onUninstall,\n  updating,\n  confirmingUninstall,\n  onCancelUninstall,\n  updateError,\n}: PluginCardProps) {\n  const { t } = useTranslation('settings');\n  const accentColor = plugin.enabled\n    ? 'bg-emerald-500'\n    : 'bg-muted-foreground/20';\n\n  return (\n    <div\n      className=\"relative flex overflow-hidden rounded-lg border border-border bg-card transition-opacity duration-200\"\n      style={{\n        opacity: plugin.enabled ? 1 : 0.65,\n        animationDelay: `${index * 40}ms`,\n      }}\n    >\n      {/* Left accent bar */}\n      <div className={`w-[3px] flex-shrink-0 ${accentColor} transition-colors duration-300`} />\n\n      <div className=\"min-w-0 flex-1 p-4\">\n        {/* Header row */}\n        <div className=\"flex items-start justify-between gap-3\">\n          <div className=\"flex min-w-0 items-center gap-2.5\">\n            <div className=\"h-5 w-5 flex-shrink-0 text-foreground/80\">\n              <PluginIcon\n                pluginName={plugin.name}\n                iconFile={plugin.icon}\n                className=\"h-5 w-5 [&>svg]:h-full [&>svg]:w-full\"\n              />\n            </div>\n            <div className=\"min-w-0\">\n              <div className=\"flex flex-wrap items-center gap-2\">\n                <span className=\"text-sm font-semibold leading-none text-foreground\">\n                  {plugin.displayName}\n                </span>\n                <span className=\"rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground\">\n                  v{plugin.version}\n                </span>\n                <span className=\"rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground\">\n                  {plugin.slot}\n                </span>\n                <ServerDot running={!!plugin.serverRunning} t={t} />\n              </div>\n              {plugin.description && (\n                <p className=\"mt-1 text-sm leading-snug text-muted-foreground\">\n                  {plugin.description}\n                </p>\n              )}\n              <div className=\"mt-1 flex items-center gap-3\">\n                {plugin.author && (\n                  <span className=\"text-xs text-muted-foreground/60\">\n                    {plugin.author}\n                  </span>\n                )}\n                {plugin.repoUrl && (\n                  <a\n                    href={plugin.repoUrl}\n                    target=\"_blank\"\n                    rel=\"noopener noreferrer\"\n                    className=\"inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground\"\n                  >\n                    <GitBranch className=\"h-3 w-3\" />\n                    <span className=\"max-w-[200px] truncate\">\n                      {plugin.repoUrl.replace(/^https?:\\/\\/(www\\.)?github\\.com\\//, '')}\n                    </span>\n                  </a>\n                )}\n              </div>\n            </div>\n          </div>\n\n          {/* Controls */}\n          <div className=\"flex flex-shrink-0 items-center gap-2\">\n            <button\n              onClick={onUpdate}\n              disabled={updating || !plugin.repoUrl}\n              title={plugin.repoUrl ? t('pluginSettings.pullLatest') : t('pluginSettings.noGitRemote')}\n              aria-label={t('pluginSettings.pullLatest')}\n              className=\"rounded p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40\"\n            >\n              {updating ? (\n                <Loader2 className=\"h-3.5 w-3.5 animate-spin\" />\n              ) : (\n                <RefreshCw className=\"h-3.5 w-3.5\" />\n              )}\n            </button>\n\n            <button\n              onClick={onUninstall}\n              title={confirmingUninstall ? t('pluginSettings.confirmUninstall') : t('pluginSettings.uninstallPlugin')}\n              aria-label={t('pluginSettings.uninstallPlugin')}\n              className={`rounded p-1.5 transition-colors ${confirmingUninstall\n                ? 'bg-red-50 text-red-500 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/30'\n                : 'text-muted-foreground hover:bg-muted hover:text-red-500'\n                }`}\n            >\n              <Trash2 className=\"h-3.5 w-3.5\" />\n            </button>\n\n            <ToggleSwitch checked={plugin.enabled} onChange={onToggle} ariaLabel={`${plugin.enabled ? t('pluginSettings.disable') : t('pluginSettings.enable')} ${plugin.displayName}`} />\n          </div>\n        </div>\n\n        {/* Confirm uninstall banner */}\n        {confirmingUninstall && (\n          <div className=\"mt-3 flex items-center justify-between gap-3 rounded border border-red-200 bg-red-50 px-3 py-2 dark:border-red-800/50 dark:bg-red-950/30\">\n            <span className=\"text-sm text-red-600 dark:text-red-400\">\n              {t('pluginSettings.confirmUninstallMessage', { name: plugin.displayName })}\n            </span>\n            <div className=\"flex gap-1.5\">\n              <button\n                onClick={onCancelUninstall}\n                className=\"rounded border border-border px-2.5 py-1 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground\"\n              >\n                {t('pluginSettings.cancel')}\n              </button>\n              <button\n                onClick={onUninstall}\n                className=\"rounded border border-red-300 px-2.5 py-1 text-sm font-medium text-red-600 transition-colors hover:bg-red-100 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/30\"\n              >\n                {t('pluginSettings.remove')}\n              </button>\n            </div>\n          </div>\n        )}\n\n        {/* Update error */}\n        {updateError && (\n          <div className=\"mt-2 flex items-center gap-1.5 text-sm text-red-500\">\n            <ServerCrash className=\"h-3.5 w-3.5 flex-shrink-0\" />\n            <span>{updateError}</span>\n          </div>\n        )}\n      </div>\n    </div>\n  );\n}\n\n/* ─── Starter Plugin Card ───────────────────────────────────────────────── */\nfunction StarterPluginCard({ onInstall, installing }: { onInstall: () => void; installing: boolean }) {\n  const { t } = useTranslation('settings');\n\n  return (\n    <div className=\"relative flex overflow-hidden rounded-lg border border-dashed border-border bg-card transition-all duration-200 hover:border-blue-400 dark:hover:border-blue-500\">\n      <div className=\"w-[3px] flex-shrink-0 bg-blue-500/30\" />\n      <div className=\"min-w-0 flex-1 p-4\">\n        <div className=\"flex items-start justify-between gap-3\">\n          <div className=\"flex min-w-0 items-center gap-2.5\">\n            <div className=\"h-5 w-5 flex-shrink-0 text-blue-500\">\n              <BarChart3 className=\"h-5 w-5\" />\n            </div>\n            <div className=\"min-w-0\">\n              <div className=\"flex flex-wrap items-center gap-2\">\n                <span className=\"text-sm font-semibold leading-none text-foreground\">\n                  {t('pluginSettings.starterPlugin.name')}\n                </span>\n                <span className=\"rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:bg-blue-950/50 dark:text-blue-400\">\n                  {t('pluginSettings.starterPlugin.badge')}\n                </span>\n                <span className=\"rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground\">\n                  {t('pluginSettings.tab')}\n                </span>\n              </div>\n              <p className=\"mt-1 text-sm leading-snug text-muted-foreground\">\n                {t('pluginSettings.starterPlugin.description')}\n              </p>\n              <a\n                href={STARTER_PLUGIN_URL}\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n                className=\"mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground\"\n              >\n                <GitBranch className=\"h-3 w-3\" />\n                cloudcli-ai/cloudcli-plugin-starter\n              </a>\n            </div>\n          </div>\n          <button\n            onClick={onInstall}\n            disabled={installing}\n            className=\"flex flex-shrink-0 items-center gap-1.5 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:opacity-50\"\n          >\n            {installing ? (\n              <Loader2 className=\"h-3.5 w-3.5 animate-spin\" />\n            ) : (\n              <Download className=\"h-3.5 w-3.5\" />\n            )}\n            {installing ? t('pluginSettings.installing') : t('pluginSettings.starterPlugin.install')}\n          </button>\n        </div>\n      </div>\n    </div>\n  );\n}\n\n/* ─── Main Component ────────────────────────────────────────────────────── */\nexport default function PluginSettingsTab() {\n  const { t } = useTranslation('settings');\n  const { plugins, loading, installPlugin, uninstallPlugin, updatePlugin, togglePlugin } =\n    usePlugins();\n\n  const [gitUrl, setGitUrl] = useState('');\n  const [installing, setInstalling] = useState(false);\n  const [installingStarter, setInstallingStarter] = useState(false);\n  const [installError, setInstallError] = useState<string | null>(null);\n  const [confirmUninstall, setConfirmUninstall] = useState<string | null>(null);\n  const [updatingPlugins, setUpdatingPlugins] = useState<Set<string>>(new Set());\n  const [updateErrors, setUpdateErrors] = useState<Record<string, string>>({});\n\n  const handleUpdate = async (name: string) => {\n    setUpdatingPlugins((prev) => new Set(prev).add(name));\n    setUpdateErrors((prev) => { const next = { ...prev }; delete next[name]; return next; });\n    const result = await updatePlugin(name);\n    if (!result.success) {\n      setUpdateErrors((prev) => ({ ...prev, [name]: result.error || t('pluginSettings.updateFailed') }));\n    }\n    setUpdatingPlugins((prev) => { const next = new Set(prev); next.delete(name); return next; });\n  };\n\n  const handleInstall = async () => {\n    if (!gitUrl.trim()) return;\n    setInstalling(true);\n    setInstallError(null);\n    const result = await installPlugin(gitUrl.trim());\n    if (result.success) {\n      setGitUrl('');\n    } else {\n      setInstallError(result.error || t('pluginSettings.installFailed'));\n    }\n    setInstalling(false);\n  };\n\n  const handleInstallStarter = async () => {\n    setInstallingStarter(true);\n    setInstallError(null);\n    const result = await installPlugin(STARTER_PLUGIN_URL);\n    if (!result.success) {\n      setInstallError(result.error || t('pluginSettings.installFailed'));\n    }\n    setInstallingStarter(false);\n  };\n\n  const handleUninstall = async (name: string) => {\n    if (confirmUninstall !== name) {\n      setConfirmUninstall(name);\n      return;\n    }\n    const result = await uninstallPlugin(name);\n    if (result.success) {\n      setConfirmUninstall(null);\n    } else {\n      setInstallError(result.error || t('pluginSettings.uninstallFailed'));\n      setConfirmUninstall(null);\n    }\n  };\n\n  const hasStarterInstalled = plugins.some((p) => p.name === 'project-stats');\n\n  return (\n    <div className=\"space-y-6\">\n      {/* Header */}\n      <div>\n        <h3 className=\"mb-1 text-base font-semibold text-foreground\">\n          {t('pluginSettings.title')}\n        </h3>\n        <p className=\"text-sm text-muted-foreground\">\n          {t('pluginSettings.description')}\n        </p>\n      </div>\n\n      {/* Install from Git — compact */}\n      <div className=\"flex items-center gap-0 overflow-hidden rounded-lg border border-border bg-card\">\n        <span className=\"flex-shrink-0 pl-3 pr-1 text-muted-foreground/40\">\n          <GitBranch className=\"h-3.5 w-3.5\" />\n        </span>\n        <input\n          type=\"text\"\n          value={gitUrl}\n          onChange={(e) => {\n            setGitUrl(e.target.value);\n            setInstallError(null);\n          }}\n          placeholder={t('pluginSettings.installPlaceholder')}\n          aria-label={t('pluginSettings.installAriaLabel')}\n          className=\"flex-1 bg-transparent px-2 py-2.5 text-sm text-foreground placeholder:text-muted-foreground/40 focus:outline-none\"\n          onKeyDown={(e) => {\n            if (e.key === 'Enter') void handleInstall();\n          }}\n        />\n        <button\n          onClick={handleInstall}\n          disabled={installing || !gitUrl.trim()}\n          className=\"flex-shrink-0 border-l border-border bg-foreground px-4 py-2.5 text-sm font-medium text-background transition-opacity hover:opacity-90 disabled:opacity-30\"\n        >\n          {installing ? (\n            <Loader2 className=\"h-4 w-4 animate-spin\" />\n          ) : (\n            t('pluginSettings.installButton')\n          )}\n        </button>\n      </div>\n\n      {installError && (\n        <p className=\"-mt-4 text-sm text-red-500\">{installError}</p>\n      )}\n\n      <p className=\"-mt-4 flex items-start gap-1.5 text-xs leading-snug text-muted-foreground/50\">\n        <ShieldAlert className=\"mt-px h-3 w-3 flex-shrink-0\" />\n        <span>\n          {t('pluginSettings.securityWarning')}\n        </span>\n      </p>\n\n      {/* Starter plugin suggestion — above the list */}\n      {!loading && !hasStarterInstalled && (\n        <StarterPluginCard onInstall={handleInstallStarter} installing={installingStarter} />\n      )}\n\n      {/* Plugin List */}\n      {loading ? (\n        <div className=\"flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground\">\n          <Loader2 className=\"h-4 w-4 animate-spin\" />\n          {t('pluginSettings.scanningPlugins')}\n        </div>\n      ) : plugins.length === 0 ? (\n        <p className=\"py-8 text-center text-sm text-muted-foreground\">{t('pluginSettings.noPluginsInstalled')}</p>\n      ) : (\n        <div className=\"space-y-2\">\n          {plugins.map((plugin, index) => {\n            const handleToggle = async (enabled: boolean) => {\n              const r = await togglePlugin(plugin.name, enabled);\n              if (!r.success) {\n                setInstallError(r.error || t('pluginSettings.toggleFailed'));\n              }\n            };\n\n            return (\n              <PluginCard\n                key={plugin.name}\n                plugin={plugin}\n                index={index}\n                onToggle={(enabled) => void handleToggle(enabled)}\n                onUpdate={() => void handleUpdate(plugin.name)}\n                onUninstall={() => void handleUninstall(plugin.name)}\n                updating={updatingPlugins.has(plugin.name)}\n                confirmingUninstall={confirmUninstall === plugin.name}\n                onCancelUninstall={() => setConfirmUninstall(null)}\n                updateError={updateErrors[plugin.name] ?? null}\n              />\n            );\n          })}\n        </div>\n      )}\n\n      {/* Build your own */}\n      <div className=\"flex items-center justify-between gap-4 border-t border-border/50 pt-2\">\n        <div className=\"flex min-w-0 items-center gap-2\">\n          <BookOpen className=\"h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/40\" />\n          <span className=\"text-xs text-muted-foreground/60\">\n            {t('pluginSettings.buildYourOwn')}\n          </span>\n        </div>\n        <div className=\"flex flex-shrink-0 items-center gap-3\">\n          <a\n            href={STARTER_PLUGIN_URL}\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className=\"inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground\"\n          >\n            {t('pluginSettings.starter')} <ExternalLink className=\"h-2.5 w-2.5\" />\n          </a>\n          <span className=\"text-muted-foreground/20\">·</span>\n          <a\n            href=\"https://cloudcli.ai/docs/plugin-overview\"\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className=\"inline-flex items-center gap-1 text-xs text-muted-foreground/60 transition-colors hover:text-foreground\"\n          >\n            {t('pluginSettings.docs')} <ExternalLink className=\"h-2.5 w-2.5\" />\n          </a>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/plugins/view/PluginTabContent.tsx",
    "content": "import { useEffect, useRef } from 'react';\nimport { useTheme } from '../../../contexts/ThemeContext';\nimport { authenticatedFetch } from '../../../utils/api';\nimport { usePlugins } from '../../../contexts/PluginsContext';\nimport type { Project, ProjectSession } from '../../../types/app';\n\ntype PluginTabContentProps = {\n  pluginName: string;\n  selectedProject: Project | null;\n  selectedSession: ProjectSession | null;\n};\n\ntype PluginContext = {\n  theme: 'dark' | 'light';\n  project: { name: string; path: string } | null;\n  session: { id: string; title: string } | null;\n};\n\nfunction buildContext(\n  isDarkMode: boolean,\n  selectedProject: Project | null,\n  selectedSession: ProjectSession | null,\n): PluginContext {\n  return {\n    theme: isDarkMode ? 'dark' : 'light',\n    project: selectedProject\n      ? {\n        name: selectedProject.name,\n        path: selectedProject.fullPath || selectedProject.path || '',\n      }\n      : null,\n    session: selectedSession\n      ? {\n        id: selectedSession.id,\n        title: selectedSession.title || selectedSession.name || selectedSession.id,\n      }\n      : null,\n  };\n}\n\nexport default function PluginTabContent({\n  pluginName,\n  selectedProject,\n  selectedSession,\n}: PluginTabContentProps) {\n  const containerRef = useRef<HTMLDivElement>(null);\n  const { isDarkMode } = useTheme();\n  const { plugins } = usePlugins();\n\n  // Stable refs so effects don't need context values in their dep arrays\n  const contextRef = useRef<PluginContext>(buildContext(isDarkMode, selectedProject, selectedSession));\n  const contextCallbacksRef = useRef<Set<(ctx: PluginContext) => void>>(new Set());\n\n  const moduleRef = useRef<any>(null);\n\n  const plugin = plugins.find(p => p.name === pluginName);\n\n  // Keep contextRef current and notify the mounted plugin on every context change\n  useEffect(() => {\n    const ctx = buildContext(isDarkMode, selectedProject, selectedSession);\n    contextRef.current = ctx;\n\n    for (const cb of contextCallbacksRef.current) {\n      try { cb(ctx); } catch { /* plugin error — ignore */ }\n    }\n  }, [isDarkMode, selectedProject, selectedSession]);\n\n  useEffect(() => {\n    if (!containerRef.current || !plugin?.enabled) return;\n\n    let active = true;\n    const container = containerRef.current;\n    const entryFile = plugin?.entry ?? 'index.js';\n    const contextCallbacks = contextCallbacksRef.current;\n\n    (async () => {\n      try {\n        // Fetch the plugin JS with auth headers (Cloudflare Worker requires auth on all routes).\n        // Then import it via a Blob URL so the browser never makes an unauthenticated request.\n        const assetUrl = `/api/plugins/${encodeURIComponent(pluginName)}/assets/${encodeURIComponent(entryFile)}`;\n        const res = await authenticatedFetch(assetUrl);\n        if (!res.ok) throw new Error(`Failed to fetch plugin (HTTP ${res.status})`);\n        const jsText = await res.text();\n        const blob = new Blob([jsText], { type: 'application/javascript' });\n        const blobUrl = URL.createObjectURL(blob);\n        // @vite-ignore\n        const mod = await import(/* @vite-ignore */ blobUrl).finally(() => URL.revokeObjectURL(blobUrl));\n        if (!active || !containerRef.current) return;\n\n        moduleRef.current = mod;\n\n        const api = {\n          get context(): PluginContext { return contextRef.current; },\n\n          onContextChange(cb: (ctx: PluginContext) => void): () => void {\n            contextCallbacks.add(cb);\n            return () => contextCallbacks.delete(cb);\n          },\n\n          async rpc(method: string, path: string, body?: unknown): Promise<unknown> {\n            const cleanPath = String(path).replace(/^\\//, '');\n            const res = await authenticatedFetch(\n              `/api/plugins/${encodeURIComponent(pluginName)}/rpc/${cleanPath}`,\n              {\n                method: method || 'GET',\n                ...(body !== undefined ? { body: JSON.stringify(body) } : {}),\n              },\n            );\n            if (!res.ok) throw new Error(`RPC error ${res.status}`);\n            return res.json();\n          },\n        };\n\n        await mod.mount?.(container, api);\n        if (!active) {\n          try { mod.unmount?.(container); } catch { /* ignore */ }\n          moduleRef.current = null;\n          return;\n        }\n      } catch (err) {\n        if (!active) return;\n        console.error(`[Plugin:${pluginName}] Failed to load:`, err);\n        if (containerRef.current) {\n          const errDiv = document.createElement('div');\n          errDiv.style.cssText = 'padding:16px;font-size:13px;color:#dc2626';\n          errDiv.textContent = `Plugin failed to load: ${String(err)}`;\n          containerRef.current.replaceChildren(errDiv);\n        }\n      }\n    })();\n\n    return () => {\n      active = false;\n      try { moduleRef.current?.unmount?.(container); } catch { /* ignore */ }\n      contextCallbacks.clear();\n      moduleRef.current = null;\n    };\n  }, [pluginName, plugin?.entry, plugin?.enabled]); // re-mount when plugin or enabled state changes\n\n  return <div ref={containerRef} className=\"h-full w-full overflow-auto\" />;\n}\n"
  },
  {
    "path": "src/components/prd-editor/PRDEditor.tsx",
    "content": "import { useCallback, useMemo, useState } from 'react';\nimport type { Project } from '../../types/app';\nimport { usePrdDocument } from './hooks/usePrdDocument';\nimport { usePrdKeyboardShortcuts } from './hooks/usePrdKeyboardShortcuts';\nimport { usePrdRegistry } from './hooks/usePrdRegistry';\nimport { usePrdSave } from './hooks/usePrdSave';\nimport type { PrdFile } from './types';\nimport { ensurePrdExtension } from './utils/fileName';\nimport OverwriteConfirmModal from './view/OverwriteConfirmModal';\nimport PrdEditorLoadingState from './view/PrdEditorLoadingState';\nimport PrdEditorWorkspace from './view/PrdEditorWorkspace';\n\ntype PRDEditorProps = {\n  file?: PrdFile | null;\n  onClose: () => void;\n  projectPath?: string;\n  project?: Project | null;\n  initialContent?: string;\n  isNewFile?: boolean;\n  onSave?: () => Promise<void> | void;\n};\n\nexport default function PRDEditor({\n  file,\n  onClose,\n  projectPath,\n  project,\n  initialContent = '',\n  isNewFile = false,\n  onSave,\n}: PRDEditorProps) {\n  const [showOverwriteConfirm, setShowOverwriteConfirm] = useState<boolean>(false);\n  const [overwriteFileName, setOverwriteFileName] = useState<string>('');\n\n  const { content, setContent, fileName, setFileName, loading, loadError } = usePrdDocument({\n    file,\n    isNewFile,\n    initialContent,\n    projectPath,\n  });\n\n  const { existingPrds, refreshExistingPrds } = usePrdRegistry({\n    projectName: project?.name,\n  });\n\n  const isExistingFile = useMemo(() => !isNewFile || Boolean(file?.isExisting), [file?.isExisting, isNewFile]);\n\n  const { savePrd, saving, saveSuccess } = usePrdSave({\n    projectName: project?.name,\n    existingPrds,\n    isExistingFile,\n    onAfterSave: async () => {\n      await refreshExistingPrds();\n      await onSave?.();\n    },\n  });\n\n  const handleDownload = useCallback(() => {\n    const blob = new Blob([content], { type: 'text/markdown' });\n    const url = URL.createObjectURL(blob);\n    const anchor = document.createElement('a');\n    const downloadedFileName = ensurePrdExtension(fileName || 'prd');\n\n    anchor.href = url;\n    anchor.download = downloadedFileName;\n    document.body.appendChild(anchor);\n    anchor.click();\n    document.body.removeChild(anchor);\n    URL.revokeObjectURL(url);\n  }, [content, fileName]);\n\n  const handleSave = useCallback(\n    async (allowOverwrite = false) => {\n      const result = await savePrd({\n        content,\n        fileName,\n        allowOverwrite,\n      });\n\n      if (result.status === 'needs-overwrite') {\n        setOverwriteFileName(result.fileName);\n        setShowOverwriteConfirm(true);\n        return;\n      }\n\n      if (result.status === 'failed') {\n        alert(result.message);\n      }\n    },\n    [content, fileName, savePrd],\n  );\n\n  const confirmOverwrite = useCallback(async () => {\n    setShowOverwriteConfirm(false);\n    await handleSave(true);\n  }, [handleSave]);\n\n  usePrdKeyboardShortcuts({\n    onSave: () => {\n      void handleSave();\n    },\n    onClose,\n  });\n\n  if (loading) {\n    return <PrdEditorLoadingState />;\n  }\n\n  return (\n    <>\n      <PrdEditorWorkspace\n        content={content}\n        onContentChange={setContent}\n        fileName={fileName}\n        onFileNameChange={setFileName}\n        isNewFile={isNewFile}\n        saving={saving}\n        saveSuccess={saveSuccess}\n        onSave={() => {\n          void handleSave();\n        }}\n        onDownload={handleDownload}\n        onClose={onClose}\n        loadError={loadError}\n      />\n\n      <OverwriteConfirmModal\n        isOpen={showOverwriteConfirm}\n        fileName={overwriteFileName || ensurePrdExtension(fileName || 'prd')}\n        saving={saving}\n        onCancel={() => setShowOverwriteConfirm(false)}\n        onConfirm={() => {\n          void confirmOverwrite();\n        }}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "src/components/prd-editor/constants.ts",
    "content": "export const PRD_TEMPLATE = `# Product Requirements Document\n\n## 1. Overview\n- Product Name:\n- Owner:\n- Last Updated:\n- Version:\n\nDescribe what problem this product solves and who it serves.\n\n## 2. Objectives\n- Primary objective:\n- Secondary objective:\n- Out-of-scope:\n\n## 3. User Stories\n- As a <role>, I want <capability>, so that <benefit>.\n- As a <role>, I want <capability>, so that <benefit>.\n\n## 4. Functional Requirements\n### Core Requirements\n- Requirement 1\n- Requirement 2\n\n### Edge Cases\n- Edge case 1\n- Edge case 2\n\n## 5. Non-Functional Requirements\n- Performance:\n- Security:\n- Reliability:\n- Accessibility:\n\n## 6. Success Metrics\n- Metric 1:\n- Metric 2:\n\n## 7. Technical Notes\n- Architecture constraints:\n- Dependencies:\n- Integration points:\n\n## 8. Release Plan\n### Milestone 1\n- Scope:\n- Exit criteria:\n\n### Milestone 2\n- Scope:\n- Exit criteria:\n\n## 9. Risks and Mitigations\n- Risk:\n  - Impact:\n  - Mitigation:\n\n## 10. Open Questions\n- Question 1\n- Question 2\n`;\n\nexport const PRD_DOCS_URL =\n  'https://github.com/eyaltoledano/claude-task-master/blob/main/docs/examples.md';\n\nexport const INVALID_FILE_NAME_CHARACTERS = /[<>:\"/\\\\|?*]/g;\nexport const PRD_EXTENSION_PATTERN = /\\.(txt|md)$/i;\n"
  },
  {
    "path": "src/components/prd-editor/hooks/usePrdDocument.ts",
    "content": "import { useCallback, useEffect, useState } from 'react';\nimport { api } from '../../../utils/api';\nimport { PRD_TEMPLATE } from '../constants';\nimport type { PrdFile } from '../types';\nimport { createDefaultPrdName, sanitizeFileName, stripPrdExtension } from '../utils/fileName';\n\ntype UsePrdDocumentArgs = {\n  file?: PrdFile | null;\n  isNewFile: boolean;\n  initialContent: string;\n  projectPath?: string;\n};\n\ntype UsePrdDocumentResult = {\n  content: string;\n  setContent: (nextContent: string) => void;\n  fileName: string;\n  setFileName: (nextFileName: string) => void;\n  loading: boolean;\n  loadError: string | null;\n};\n\nexport function usePrdDocument({\n  file,\n  isNewFile,\n  initialContent,\n  projectPath,\n}: UsePrdDocumentArgs): UsePrdDocumentResult {\n  const [content, setContent] = useState<string>(initialContent || '');\n  const [fileName, setFileNameState] = useState<string>('');\n  const [loading, setLoading] = useState<boolean>(!isNewFile);\n  const [loadError, setLoadError] = useState<string | null>(null);\n\n  const setFileName = useCallback((nextFileName: string) => {\n    setFileNameState(sanitizeFileName(nextFileName));\n  }, []);\n\n  useEffect(() => {\n    let isMounted = true;\n\n    const initialize = async () => {\n      const defaultName = file?.name\n        ? stripPrdExtension(file.name)\n        : createDefaultPrdName(new Date());\n\n      if (isMounted) {\n        setFileNameState(defaultName);\n      }\n\n      // Loading precedence:\n      // 1) new file -> initial content or template\n      // 2) provided content -> use it directly\n      // 3) legacy file path -> fetch from API\n      if (isNewFile) {\n        if (!isMounted) {\n          return;\n        }\n\n        setContent(initialContent || PRD_TEMPLATE);\n        setLoadError(null);\n        setLoading(false);\n        return;\n      }\n\n      if (file?.content) {\n        if (!isMounted) {\n          return;\n        }\n\n        setContent(file.content);\n        setLoadError(null);\n        setLoading(false);\n        return;\n      }\n\n      if (!file?.projectName || !file?.path) {\n        if (!isMounted) {\n          return;\n        }\n\n        setContent(initialContent || PRD_TEMPLATE);\n        setLoadError(null);\n        setLoading(false);\n        return;\n      }\n\n      try {\n        setLoading(true);\n\n        const response = await api.readFile(file.projectName, file.path);\n        if (!response.ok) {\n          throw new Error(`Failed to load file: ${response.status} ${response.statusText}`);\n        }\n\n        const data = (await response.json()) as { content?: string };\n        if (!isMounted) {\n          return;\n        }\n\n        setContent(data.content || PRD_TEMPLATE);\n        setLoadError(null);\n      } catch (error) {\n        const message = error instanceof Error ? error.message : 'Unknown error';\n        if (!isMounted) {\n          return;\n        }\n\n        setContent(initialContent || PRD_TEMPLATE);\n        setLoadError(`Unable to load file content: ${message}`);\n      } finally {\n        if (isMounted) {\n          setLoading(false);\n        }\n      }\n    };\n\n    void initialize();\n\n    return () => {\n      isMounted = false;\n    };\n  }, [file, initialContent, isNewFile, projectPath]);\n\n  return {\n    content,\n    setContent,\n    fileName,\n    setFileName,\n    loading,\n    loadError,\n  };\n}\n"
  },
  {
    "path": "src/components/prd-editor/hooks/usePrdKeyboardShortcuts.ts",
    "content": "import { useEffect } from 'react';\n\ntype UsePrdKeyboardShortcutsArgs = {\n  onSave: () => void;\n  onClose: () => void;\n};\n\nexport function usePrdKeyboardShortcuts({\n  onSave,\n  onClose,\n}: UsePrdKeyboardShortcutsArgs): void {\n  useEffect(() => {\n    // Keep shortcuts global so the editor behaves consistently in fullscreen and modal mode.\n    const handleKeyDown = (event: KeyboardEvent) => {\n      const loweredKey = event.key.toLowerCase();\n\n      if ((event.ctrlKey || event.metaKey) && loweredKey === 's') {\n        event.preventDefault();\n        onSave();\n        return;\n      }\n\n      if (event.key === 'Escape') {\n        event.preventDefault();\n        onClose();\n      }\n    };\n\n    document.addEventListener('keydown', handleKeyDown);\n    return () => {\n      document.removeEventListener('keydown', handleKeyDown);\n    };\n  }, [onClose, onSave]);\n}\n"
  },
  {
    "path": "src/components/prd-editor/hooks/usePrdRegistry.ts",
    "content": "import { useCallback, useEffect, useState } from 'react';\nimport { api } from '../../../utils/api';\nimport type { ExistingPrdFile, PrdListResponse } from '../types';\n\ntype UsePrdRegistryArgs = {\n  projectName?: string;\n};\n\ntype UsePrdRegistryResult = {\n  existingPrds: ExistingPrdFile[];\n  refreshExistingPrds: () => Promise<void>;\n};\n\nfunction getPrdFiles(data: PrdListResponse): ExistingPrdFile[] {\n  return data.prdFiles || data.prds || [];\n}\n\nexport function usePrdRegistry({ projectName }: UsePrdRegistryArgs): UsePrdRegistryResult {\n  const [existingPrds, setExistingPrds] = useState<ExistingPrdFile[]>([]);\n\n  const refreshExistingPrds = useCallback(async () => {\n    if (!projectName) {\n      setExistingPrds([]);\n      return;\n    }\n\n    try {\n      const response = await api.get(`/taskmaster/prd/${encodeURIComponent(projectName)}`);\n      if (!response.ok) {\n        setExistingPrds([]);\n        return;\n      }\n\n      const data = (await response.json()) as PrdListResponse;\n      setExistingPrds(getPrdFiles(data));\n    } catch (error) {\n      console.error('Failed to fetch existing PRDs:', error);\n      setExistingPrds([]);\n    }\n  }, [projectName]);\n\n  useEffect(() => {\n    void refreshExistingPrds();\n  }, [refreshExistingPrds]);\n\n  return {\n    existingPrds,\n    refreshExistingPrds,\n  };\n}\n"
  },
  {
    "path": "src/components/prd-editor/hooks/usePrdSave.ts",
    "content": "import { useCallback, useEffect, useRef, useState } from 'react';\nimport { authenticatedFetch } from '../../../utils/api';\nimport type { ExistingPrdFile, SavePrdInput, SavePrdResult } from '../types';\nimport { ensurePrdExtension } from '../utils/fileName';\n\ntype UsePrdSaveArgs = {\n  projectName?: string;\n  existingPrds: ExistingPrdFile[];\n  isExistingFile: boolean;\n  onAfterSave?: () => Promise<void>;\n};\n\ntype UsePrdSaveResult = {\n  savePrd: (input: SavePrdInput) => Promise<SavePrdResult>;\n  saving: boolean;\n  saveSuccess: boolean;\n};\n\nexport function usePrdSave({\n  projectName,\n  existingPrds,\n  isExistingFile,\n  onAfterSave,\n}: UsePrdSaveArgs): UsePrdSaveResult {\n  const [saving, setSaving] = useState<boolean>(false);\n  const [saveSuccess, setSaveSuccess] = useState<boolean>(false);\n  const saveSuccessTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  useEffect(() => {\n    return () => {\n      if (saveSuccessTimeoutRef.current) {\n        clearTimeout(saveSuccessTimeoutRef.current);\n      }\n    };\n  }, []);\n\n  const savePrd = useCallback(\n    async ({ content, fileName, allowOverwrite = false }: SavePrdInput): Promise<SavePrdResult> => {\n      if (!content.trim()) {\n        return { status: 'failed', message: 'Please add content before saving.' };\n      }\n\n      if (!fileName.trim()) {\n        return { status: 'failed', message: 'Please provide a filename for the PRD.' };\n      }\n\n      if (!projectName) {\n        return { status: 'failed', message: 'No project selected. Please reopen the editor.' };\n      }\n\n      const finalFileName = ensurePrdExtension(fileName.trim());\n      const hasConflict = existingPrds.some((prd) => prd.name === finalFileName);\n\n      // Overwrite confirmation is only required when creating a brand-new PRD.\n      if (hasConflict && !allowOverwrite && !isExistingFile) {\n        return { status: 'needs-overwrite', fileName: finalFileName };\n      }\n\n      setSaving(true);\n\n      try {\n        const response = await authenticatedFetch(`/api/taskmaster/prd/${encodeURIComponent(projectName)}`, {\n          method: 'POST',\n          body: JSON.stringify({\n            fileName: finalFileName,\n            content,\n          }),\n        });\n\n        if (!response.ok) {\n          const fallbackMessage = `Save failed: ${response.status}`;\n\n          try {\n            const errorData = (await response.json()) as { message?: string };\n            return { status: 'failed', message: errorData.message || fallbackMessage };\n          } catch {\n            return { status: 'failed', message: fallbackMessage };\n          }\n        }\n\n        if (saveSuccessTimeoutRef.current) {\n          clearTimeout(saveSuccessTimeoutRef.current);\n        }\n\n        setSaveSuccess(true);\n        saveSuccessTimeoutRef.current = setTimeout(() => {\n          setSaveSuccess(false);\n          saveSuccessTimeoutRef.current = null;\n        }, 2000);\n\n        if (onAfterSave) {\n          await onAfterSave();\n        }\n\n        return { status: 'saved', fileName: finalFileName };\n      } catch (error) {\n        const message = error instanceof Error ? error.message : 'Unknown error';\n        return { status: 'failed', message: `Error saving PRD: ${message}` };\n      } finally {\n        setSaving(false);\n      }\n    },\n    [existingPrds, isExistingFile, onAfterSave, projectName],\n  );\n\n  return {\n    savePrd,\n    saving,\n    saveSuccess,\n  };\n}\n"
  },
  {
    "path": "src/components/prd-editor/index.ts",
    "content": "export { default } from './PRDEditor';\n"
  },
  {
    "path": "src/components/prd-editor/types.ts",
    "content": "export type PrdFile = {\n  name?: string;\n  path?: string;\n  projectName?: string;\n  content?: string;\n  isExisting?: boolean;\n};\n\nexport type ExistingPrdFile = {\n  name: string;\n  content?: string;\n  isExisting?: boolean;\n  [key: string]: unknown;\n};\n\nexport type PrdListResponse = {\n  prdFiles?: ExistingPrdFile[];\n  prds?: ExistingPrdFile[];\n};\n\nexport type SavePrdInput = {\n  content: string;\n  fileName: string;\n  allowOverwrite?: boolean;\n};\n\nexport type SavePrdResult =\n  | { status: 'saved'; fileName: string }\n  | { status: 'needs-overwrite'; fileName: string }\n  | { status: 'failed'; message: string };\n"
  },
  {
    "path": "src/components/prd-editor/utils/fileName.ts",
    "content": "import { INVALID_FILE_NAME_CHARACTERS, PRD_EXTENSION_PATTERN } from '../constants';\n\nexport function sanitizeFileName(value: string): string {\n  return value.replace(INVALID_FILE_NAME_CHARACTERS, '');\n}\n\nexport function stripPrdExtension(value: string): string {\n  return value.replace(PRD_EXTENSION_PATTERN, '');\n}\n\nexport function ensurePrdExtension(value: string): string {\n  return PRD_EXTENSION_PATTERN.test(value) ? value : `${value}.txt`;\n}\n\nexport function createDefaultPrdName(date: Date): string {\n  const isoDate = date.toISOString().split('T')[0];\n  return `prd-${isoDate}`;\n}\n"
  },
  {
    "path": "src/components/prd-editor/view/GenerateTasksModal.tsx",
    "content": "import { Sparkles, X } from 'lucide-react';\nimport { PRD_DOCS_URL } from '../constants';\n\ntype GenerateTasksModalProps = {\n  isOpen: boolean;\n  fileName: string;\n  onClose: () => void;\n};\n\nexport default function GenerateTasksModal({\n  isOpen,\n  fileName,\n  onClose,\n}: GenerateTasksModalProps) {\n  if (!isOpen) {\n    return null;\n  }\n\n  return (\n    <div className=\"fixed inset-0 z-[300] flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm\">\n      <div className=\"w-full max-w-md rounded-lg border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800\">\n        <div className=\"flex items-center justify-between border-b border-gray-200 p-6 dark:border-gray-700\">\n          <div className=\"flex items-center gap-3\">\n            <div className=\"flex h-8 w-8 items-center justify-center rounded-lg bg-purple-100 dark:bg-purple-900/50\">\n              <Sparkles className=\"h-4 w-4 text-purple-600 dark:text-purple-400\" />\n            </div>\n            <h3 className=\"text-lg font-semibold text-gray-900 dark:text-white\">\n              Generate Tasks from PRD\n            </h3>\n          </div>\n          <button\n            onClick={onClose}\n            className=\"rounded-md p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300\"\n          >\n            <X className=\"h-5 w-5\" />\n          </button>\n        </div>\n\n        <div className=\"space-y-4 p-6\">\n          <div className=\"rounded-lg border border-purple-200 bg-purple-50 p-4 dark:border-purple-800 dark:bg-purple-900/20\">\n            <h4 className=\"mb-2 font-semibold text-purple-900 dark:text-purple-100\">\n              Ask Claude Code directly\n            </h4>\n            <p className=\"mb-3 text-sm text-purple-800 dark:text-purple-200\">\n              Save this PRD, then ask Claude Code in chat to parse the file and create your initial tasks.\n            </p>\n\n            <div className=\"rounded border border-purple-200 bg-white p-3 dark:border-purple-700 dark:bg-gray-800\">\n              <p className=\"mb-1 text-xs font-medium text-gray-600 dark:text-gray-400\">Example prompt</p>\n              <p className=\"font-mono text-xs text-gray-900 dark:text-white\">\n                I have a PRD at .taskmaster/docs/{fileName}. Parse it and create the initial tasks.\n              </p>\n            </div>\n          </div>\n\n          <div className=\"border-t border-gray-200 pt-4 text-center dark:border-gray-700\">\n            <a\n              href={PRD_DOCS_URL}\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"inline-block text-sm font-medium text-purple-600 underline hover:text-purple-700 dark:text-purple-400 dark:hover:text-purple-300\"\n            >\n              View TaskMaster documentation\n            </a>\n          </div>\n\n          <button\n            onClick={onClose}\n            className=\"w-full rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600\"\n          >\n            Got it\n          </button>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/prd-editor/view/OverwriteConfirmModal.tsx",
    "content": "import { AlertTriangle, Save } from 'lucide-react';\n\ntype OverwriteConfirmModalProps = {\n  isOpen: boolean;\n  fileName: string;\n  saving: boolean;\n  onCancel: () => void;\n  onConfirm: () => void;\n};\n\nexport default function OverwriteConfirmModal({\n  isOpen,\n  fileName,\n  saving,\n  onCancel,\n  onConfirm,\n}: OverwriteConfirmModalProps) {\n  if (!isOpen) {\n    return null;\n  }\n\n  return (\n    <div className=\"fixed inset-0 z-[300] flex items-center justify-center p-4\">\n      <div className=\"fixed inset-0 bg-black/50\" onClick={onCancel} />\n\n      <div className=\"relative w-full max-w-md rounded-lg border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800\">\n        <div className=\"p-6\">\n          <div className=\"mb-4 flex items-center\">\n            <div className=\"mr-3 rounded-full bg-yellow-100 p-2 dark:bg-yellow-900\">\n              <AlertTriangle className=\"h-5 w-5 text-yellow-600 dark:text-yellow-400\" />\n            </div>\n            <h3 className=\"text-lg font-semibold text-gray-900 dark:text-white\">File Already Exists</h3>\n          </div>\n\n          <p className=\"mb-6 text-sm text-gray-600 dark:text-gray-400\">\n            A PRD named \"{fileName}\" already exists. Do you want to overwrite it?\n          </p>\n\n          <div className=\"flex justify-end gap-3\">\n            <button\n              onClick={onCancel}\n              disabled={saving}\n              className=\"rounded-md border border-gray-300 bg-white px-4 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600\"\n            >\n              Cancel\n            </button>\n            <button\n              onClick={onConfirm}\n              disabled={saving}\n              className=\"flex items-center gap-2 rounded-md bg-yellow-600 px-4 py-2 text-sm text-white transition-colors hover:bg-yellow-700 disabled:opacity-50\"\n            >\n              <Save className=\"h-4 w-4\" />\n              <span>{saving ? 'Saving...' : 'Overwrite'}</span>\n            </button>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/prd-editor/view/PrdEditorBody.tsx",
    "content": "import { useMemo } from 'react';\nimport { markdown } from '@codemirror/lang-markdown';\nimport { oneDark } from '@codemirror/theme-one-dark';\nimport { EditorView } from '@codemirror/view';\nimport CodeMirror from '@uiw/react-codemirror';\nimport MarkdownPreview from '../../code-editor/view/subcomponents/markdown/MarkdownPreview';\n\ntype PrdEditorBodyProps = {\n  content: string;\n  onContentChange: (nextContent: string) => void;\n  previewMode: boolean;\n  isDarkMode: boolean;\n  wordWrap: boolean;\n};\n\nexport default function PrdEditorBody({\n  content,\n  onContentChange,\n  previewMode,\n  isDarkMode,\n  wordWrap,\n}: PrdEditorBodyProps) {\n  const extensions = useMemo(\n    () => [markdown(), ...(wordWrap ? [EditorView.lineWrapping] : [])],\n    [wordWrap],\n  );\n\n  if (previewMode) {\n    return (\n      <div className=\"prose prose-gray h-full max-w-none overflow-y-auto p-6 dark:prose-invert\">\n        <MarkdownPreview content={content} />\n      </div>\n    );\n  }\n\n  return (\n    <CodeMirror\n      value={content}\n      onChange={onContentChange}\n      extensions={extensions}\n      theme={isDarkMode ? oneDark : undefined}\n      height=\"100%\"\n      style={{\n        fontSize: '14px',\n        height: '100%',\n      }}\n      basicSetup={{\n        lineNumbers: true,\n        foldGutter: true,\n        dropCursor: false,\n        allowMultipleSelections: false,\n        indentOnInput: true,\n        bracketMatching: true,\n        closeBrackets: true,\n        autocompletion: true,\n        highlightSelectionMatches: true,\n        searchKeymap: true,\n      }}\n    />\n  );\n}\n"
  },
  {
    "path": "src/components/prd-editor/view/PrdEditorFooter.tsx",
    "content": "import { useMemo } from 'react';\n\ntype PrdEditorFooterProps = {\n  content: string;\n};\n\ntype ContentStats = {\n  lines: number;\n  characters: number;\n  words: number;\n};\n\nfunction getContentStats(content: string): ContentStats {\n  return {\n    lines: content.split('\\n').length,\n    characters: content.length,\n    words: content.split(/\\s+/).filter(Boolean).length,\n  };\n}\n\nexport default function PrdEditorFooter({ content }: PrdEditorFooterProps) {\n  const stats = useMemo(() => getContentStats(content), [content]);\n\n  return (\n    <div className=\"flex flex-shrink-0 items-center justify-between border-t border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800\">\n      <div className=\"flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400\">\n        <span>Lines: {stats.lines}</span>\n        <span>Characters: {stats.characters}</span>\n        <span>Words: {stats.words}</span>\n        <span>Format: Markdown</span>\n      </div>\n\n      <div className=\"text-sm text-gray-500 dark:text-gray-400\">Press Ctrl+S to save and Esc to close</div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/prd-editor/view/PrdEditorHeader.tsx",
    "content": "import { useRef } from 'react';\nimport type { ReactNode } from 'react';\nimport {\n  Download,\n  Eye,\n  FileText,\n  Maximize2,\n  Minimize2,\n  Moon,\n  Save,\n  Sparkles,\n  Sun,\n  X,\n} from 'lucide-react';\nimport { cn } from '../../../lib/utils';\n\ntype PrdEditorHeaderProps = {\n  fileName: string;\n  onFileNameChange: (nextFileName: string) => void;\n  isNewFile: boolean;\n  previewMode: boolean;\n  onTogglePreview: () => void;\n  wordWrap: boolean;\n  onToggleWordWrap: () => void;\n  isDarkMode: boolean;\n  onToggleTheme: () => void;\n  onDownload: () => void;\n  onOpenGenerateTasks: () => void;\n  canGenerateTasks: boolean;\n  onSave: () => void;\n  saving: boolean;\n  saveSuccess: boolean;\n  isFullscreen: boolean;\n  onToggleFullscreen: () => void;\n  onClose: () => void;\n};\n\ntype HeaderIconButtonProps = {\n  title: string;\n  onClick: () => void;\n  icon: ReactNode;\n  active?: boolean;\n};\n\nfunction HeaderIconButton({ title, onClick, icon, active = false }: HeaderIconButtonProps) {\n  return (\n    <button\n      onClick={onClick}\n      title={title}\n      className={cn(\n        'p-2 rounded-md min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center transition-colors',\n        active\n          ? 'text-purple-600 dark:text-purple-400 bg-purple-50 dark:bg-purple-900/50'\n          : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800',\n      )}\n    >\n      {icon}\n    </button>\n  );\n}\n\nexport default function PrdEditorHeader({\n  fileName,\n  onFileNameChange,\n  isNewFile,\n  previewMode,\n  onTogglePreview,\n  wordWrap,\n  onToggleWordWrap,\n  isDarkMode,\n  onToggleTheme,\n  onDownload,\n  onOpenGenerateTasks,\n  canGenerateTasks,\n  onSave,\n  saving,\n  saveSuccess,\n  isFullscreen,\n  onToggleFullscreen,\n  onClose,\n}: PrdEditorHeaderProps) {\n  const fileNameInputRef = useRef<HTMLInputElement | null>(null);\n\n  return (\n    <div className=\"flex min-w-0 flex-shrink-0 items-center justify-between border-b border-gray-200 p-4 dark:border-gray-700\">\n      <div className=\"flex min-w-0 flex-1 items-center gap-3\">\n        <div className=\"flex h-8 w-8 flex-shrink-0 items-center justify-center rounded bg-purple-600\">\n          <FileText className=\"h-4 w-4 text-white\" />\n        </div>\n\n        <div className=\"min-w-0 flex-1\">\n          <div className=\"flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center\">\n            <div className=\"flex min-w-0 flex-1 items-center gap-1\">\n              <div className=\"flex min-w-0 flex-1 items-center rounded-md border border-gray-200 bg-gray-50 px-3 py-2 focus-within:border-purple-500 focus-within:ring-2 focus-within:ring-purple-500 dark:border-gray-600 dark:bg-gray-700 dark:focus-within:border-purple-400 dark:focus-within:ring-purple-400\">\n                <input\n                  ref={fileNameInputRef}\n                  type=\"text\"\n                  value={fileName}\n                  onChange={(event) => onFileNameChange(event.target.value)}\n                  className=\"min-w-0 flex-1 border-none bg-transparent text-base font-medium text-gray-900 placeholder-gray-400 outline-none dark:text-white dark:placeholder-gray-500 sm:text-sm\"\n                  placeholder=\"Enter PRD filename\"\n                  maxLength={100}\n                />\n                <span className=\"ml-1 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 sm:text-xs\">\n                  .txt\n                </span>\n              </div>\n\n              <button\n                onClick={() => fileNameInputRef.current?.focus()}\n                className=\"p-1 text-gray-400 transition-colors hover:text-purple-600 dark:hover:text-purple-400\"\n                title=\"Focus filename input\"\n              >\n                <svg className=\"h-4 w-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                  <path\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    strokeWidth={2}\n                    d=\"M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z\"\n                  />\n                </svg>\n              </button>\n            </div>\n\n            <div className=\"flex flex-shrink-0 items-center gap-2\">\n              <span className=\"whitespace-nowrap rounded bg-purple-100 px-2 py-1 text-xs text-purple-600 dark:bg-purple-900 dark:text-purple-300\">\n                PRD\n              </span>\n              {isNewFile && (\n                <span className=\"whitespace-nowrap rounded bg-green-100 px-2 py-1 text-xs text-green-600 dark:bg-green-900 dark:text-green-300\">\n                  New\n                </span>\n              )}\n            </div>\n          </div>\n\n          <p className=\"mt-1 truncate text-xs text-gray-500 dark:text-gray-400 sm:text-sm\">\n            Product Requirements Document\n          </p>\n        </div>\n      </div>\n\n      <div className=\"flex flex-shrink-0 items-center gap-1 md:gap-2\">\n        <HeaderIconButton\n          title={previewMode ? 'Switch to edit mode' : 'Preview markdown'}\n          onClick={onTogglePreview}\n          icon={<Eye className=\"h-5 w-5 md:h-4 md:w-4\" />}\n          active={previewMode}\n        />\n\n        <HeaderIconButton\n          title={wordWrap ? 'Disable word wrap' : 'Enable word wrap'}\n          onClick={onToggleWordWrap}\n          icon={<span className=\"font-mono text-sm font-bold md:text-xs\">WRAP</span>}\n          active={wordWrap}\n        />\n\n        <HeaderIconButton\n          title=\"Toggle theme\"\n          onClick={onToggleTheme}\n          icon={\n            isDarkMode ? (\n              <Sun className=\"h-5 w-5 md:h-4 md:w-4\" />\n            ) : (\n              <Moon className=\"h-5 w-5 md:h-4 md:w-4\" />\n            )\n          }\n        />\n\n        <HeaderIconButton\n          title=\"Download PRD\"\n          onClick={onDownload}\n          icon={<Download className=\"h-5 w-5 md:h-4 md:w-4\" />}\n        />\n\n        <button\n          onClick={onOpenGenerateTasks}\n          disabled={!canGenerateTasks}\n          className={cn(\n            'px-3 py-2 rounded-md disabled:opacity-50 flex items-center gap-2 transition-colors text-sm font-medium text-white min-h-[44px] md:min-h-0',\n            'bg-purple-600 hover:bg-purple-700',\n          )}\n          title=\"Generate tasks from PRD content\"\n        >\n          <Sparkles className=\"h-4 w-4\" />\n          <span className=\"hidden md:inline\">Generate Tasks</span>\n        </button>\n\n        <button\n          onClick={onSave}\n          disabled={saving}\n          className={cn(\n            'px-3 py-2 text-white rounded-md disabled:opacity-50 flex items-center gap-2 transition-colors min-h-[44px] md:min-h-0',\n            saveSuccess ? 'bg-green-600 hover:bg-green-700' : 'bg-purple-600 hover:bg-purple-700',\n          )}\n        >\n          {saveSuccess ? (\n            <>\n              <svg className=\"h-5 w-5 md:h-4 md:w-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M5 13l4 4L19 7\" />\n              </svg>\n              <span className=\"hidden sm:inline\">Saved!</span>\n            </>\n          ) : (\n            <>\n              <Save className=\"h-5 w-5 md:h-4 md:w-4\" />\n              <span className=\"hidden sm:inline\">{saving ? 'Saving...' : 'Save PRD'}</span>\n            </>\n          )}\n        </button>\n\n        <button\n          onClick={onToggleFullscreen}\n          className=\"hidden items-center justify-center rounded-md p-2 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white md:flex\"\n          title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}\n        >\n          {isFullscreen ? <Minimize2 className=\"h-4 w-4\" /> : <Maximize2 className=\"h-4 w-4\" />}\n        </button>\n\n        <HeaderIconButton\n          title=\"Close\"\n          onClick={onClose}\n          icon={<X className=\"h-6 w-6 md:h-4 md:w-4\" />}\n        />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/prd-editor/view/PrdEditorLoadingState.tsx",
    "content": "export default function PrdEditorLoadingState() {\n  return (\n    <div className=\"fixed inset-0 z-[200] md:flex md:items-center md:justify-center md:bg-black/50\">\n      <div className=\"flex h-full w-full items-center justify-center bg-white p-8 dark:bg-gray-900 md:h-auto md:w-auto md:rounded-lg\">\n        <div className=\"flex items-center gap-3\">\n          <div className=\"h-6 w-6 animate-spin rounded-full border-b-2 border-blue-600\" />\n          <span className=\"text-gray-900 dark:text-white\">Loading PRD...</span>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/prd-editor/view/PrdEditorWorkspace.tsx",
    "content": "import { useState } from 'react';\nimport { cn } from '../../../lib/utils';\nimport { ensurePrdExtension } from '../utils/fileName';\nimport GenerateTasksModal from './GenerateTasksModal';\nimport PrdEditorBody from './PrdEditorBody';\nimport PrdEditorFooter from './PrdEditorFooter';\nimport PrdEditorHeader from './PrdEditorHeader';\n\ntype PrdEditorWorkspaceProps = {\n  content: string;\n  onContentChange: (nextContent: string) => void;\n  fileName: string;\n  onFileNameChange: (nextFileName: string) => void;\n  isNewFile: boolean;\n  saving: boolean;\n  saveSuccess: boolean;\n  onSave: () => void;\n  onDownload: () => void;\n  onClose: () => void;\n  loadError: string | null;\n};\n\nexport default function PrdEditorWorkspace({\n  content,\n  onContentChange,\n  fileName,\n  onFileNameChange,\n  isNewFile,\n  saving,\n  saveSuccess,\n  onSave,\n  onDownload,\n  onClose,\n  loadError,\n}: PrdEditorWorkspaceProps) {\n  const [isFullscreen, setIsFullscreen] = useState<boolean>(false);\n  const [isDarkMode, setIsDarkMode] = useState<boolean>(true);\n  const [previewMode, setPreviewMode] = useState<boolean>(false);\n  const [wordWrap, setWordWrap] = useState<boolean>(true);\n  const [showGenerateModal, setShowGenerateModal] = useState<boolean>(false);\n\n  const handleOpenGenerateTasks = () => {\n    if (!content.trim()) {\n      alert('Please add content to the PRD before generating tasks.');\n      return;\n    }\n\n    setShowGenerateModal(true);\n  };\n\n  return (\n    <div\n      className={cn(\n        'fixed inset-0 z-[200] md:bg-black/50 md:flex md:items-center md:justify-center',\n        isFullscreen ? 'md:p-0' : 'md:p-4',\n      )}\n    >\n      <div\n        className={cn(\n          'bg-white dark:bg-gray-900 shadow-2xl flex flex-col',\n          'w-full h-full md:rounded-lg md:shadow-2xl',\n          isFullscreen\n            ? 'md:w-full md:h-full md:rounded-none'\n            : 'md:w-full md:max-w-6xl md:h-[85vh] md:max-h-[85vh]',\n        )}\n      >\n        {loadError && (\n          <div className=\"border-b border-yellow-200 bg-yellow-50 px-4 py-3 text-sm text-yellow-800 dark:border-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-200\">\n            {loadError}\n          </div>\n        )}\n\n        <PrdEditorHeader\n          fileName={fileName}\n          onFileNameChange={onFileNameChange}\n          isNewFile={isNewFile}\n          previewMode={previewMode}\n          onTogglePreview={() => setPreviewMode((current) => !current)}\n          wordWrap={wordWrap}\n          onToggleWordWrap={() => setWordWrap((current) => !current)}\n          isDarkMode={isDarkMode}\n          onToggleTheme={() => setIsDarkMode((current) => !current)}\n          onDownload={onDownload}\n          onOpenGenerateTasks={handleOpenGenerateTasks}\n          canGenerateTasks={Boolean(content.trim())}\n          onSave={onSave}\n          saving={saving}\n          saveSuccess={saveSuccess}\n          isFullscreen={isFullscreen}\n          onToggleFullscreen={() => setIsFullscreen((current) => !current)}\n          onClose={onClose}\n        />\n\n        <div className=\"flex-1 overflow-hidden\">\n          <PrdEditorBody\n            content={content}\n            onContentChange={onContentChange}\n            previewMode={previewMode}\n            isDarkMode={isDarkMode}\n            wordWrap={wordWrap}\n          />\n        </div>\n\n        <PrdEditorFooter content={content} />\n      </div>\n\n      <GenerateTasksModal\n        isOpen={showGenerateModal}\n        fileName={ensurePrdExtension(fileName || 'prd')}\n        onClose={() => setShowGenerateModal(false)}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/project-creation-wizard/ProjectCreationWizard.tsx",
    "content": "import { useCallback, useMemo, useState } from 'react';\nimport { FolderPlus, X } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport ErrorBanner from './components/ErrorBanner';\nimport StepConfiguration from './components/StepConfiguration';\nimport StepReview from './components/StepReview';\nimport StepTypeSelection from './components/StepTypeSelection';\nimport WizardFooter from './components/WizardFooter';\nimport WizardProgress from './components/WizardProgress';\nimport { useGithubTokens } from './hooks/useGithubTokens';\nimport { cloneWorkspaceWithProgress, createWorkspaceRequest } from './data/workspaceApi';\nimport { isCloneWorkflow, shouldShowGithubAuthentication } from './utils/pathUtils';\nimport type { TokenMode, WizardFormState, WizardStep, WorkspaceType } from './types';\n\ntype ProjectCreationWizardProps = {\n  onClose: () => void;\n  onProjectCreated?: (project?: Record<string, unknown>) => void;\n};\n\nconst initialFormState: WizardFormState = {\n  workspaceType: 'existing',\n  workspacePath: '',\n  githubUrl: '',\n  tokenMode: 'stored',\n  selectedGithubToken: '',\n  newGithubToken: '',\n};\n\nexport default function ProjectCreationWizard({\n  onClose,\n  onProjectCreated,\n}: ProjectCreationWizardProps) {\n  const { t } = useTranslation();\n  const [step, setStep] = useState<WizardStep>(1);\n  const [formState, setFormState] = useState<WizardFormState>(initialFormState);\n  const [isCreating, setIsCreating] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n  const [cloneProgress, setCloneProgress] = useState('');\n\n  const shouldLoadTokens =\n    step === 2 && shouldShowGithubAuthentication(formState.workspaceType, formState.githubUrl);\n\n  const autoSelectToken = useCallback((tokenId: string) => {\n    setFormState((previous) => ({ ...previous, selectedGithubToken: tokenId }));\n  }, []);\n\n  const {\n    tokens: availableTokens,\n    loading: loadingTokens,\n    loadError: tokenLoadError,\n    selectedTokenName,\n  } = useGithubTokens({\n    shouldLoad: shouldLoadTokens,\n    selectedTokenId: formState.selectedGithubToken,\n    onAutoSelectToken: autoSelectToken,\n  });\n\n  // Keep cross-step values in this component; local UI state lives in child components.\n  const updateField = useCallback(<K extends keyof WizardFormState>(key: K, value: WizardFormState[K]) => {\n    setFormState((previous) => ({ ...previous, [key]: value }));\n  }, []);\n\n  const updateWorkspaceType = useCallback(\n    (workspaceType: WorkspaceType) => updateField('workspaceType', workspaceType),\n    [updateField],\n  );\n\n  const updateTokenMode = useCallback(\n    (tokenMode: TokenMode) => updateField('tokenMode', tokenMode),\n    [updateField],\n  );\n\n  const handleNext = useCallback(() => {\n    setError(null);\n\n    if (step === 1) {\n      if (!formState.workspaceType) {\n        setError(t('projectWizard.errors.selectType'));\n        return;\n      }\n      setStep(2);\n      return;\n    }\n\n    if (step === 2) {\n      if (!formState.workspacePath.trim()) {\n        setError(t('projectWizard.errors.providePath'));\n        return;\n      }\n      setStep(3);\n    }\n  }, [formState.workspacePath, formState.workspaceType, step, t]);\n\n  const handleBack = useCallback(() => {\n    setError(null);\n    setStep((previousStep) => (previousStep > 1 ? ((previousStep - 1) as WizardStep) : previousStep));\n  }, []);\n\n  const handleCreate = useCallback(async () => {\n    setIsCreating(true);\n    setError(null);\n    setCloneProgress('');\n\n    try {\n      const shouldCloneRepository = isCloneWorkflow(formState.workspaceType, formState.githubUrl);\n\n      if (shouldCloneRepository) {\n        const project = await cloneWorkspaceWithProgress(\n          {\n            workspacePath: formState.workspacePath,\n            githubUrl: formState.githubUrl,\n            tokenMode: formState.tokenMode,\n            selectedGithubToken: formState.selectedGithubToken,\n            newGithubToken: formState.newGithubToken,\n          },\n          {\n            onProgress: setCloneProgress,\n          },\n        );\n\n        onProjectCreated?.(project);\n        onClose();\n        return;\n      }\n\n      const project = await createWorkspaceRequest({\n        workspaceType: formState.workspaceType,\n        path: formState.workspacePath.trim(),\n      });\n\n      onProjectCreated?.(project);\n      onClose();\n    } catch (createError) {\n      const errorMessage =\n        createError instanceof Error\n          ? createError.message\n          : t('projectWizard.errors.failedToCreate');\n      setError(errorMessage);\n    } finally {\n      setIsCreating(false);\n    }\n  }, [formState, onClose, onProjectCreated, t]);\n\n  const shouldCloneRepository = useMemo(\n    () => isCloneWorkflow(formState.workspaceType, formState.githubUrl),\n    [formState.githubUrl, formState.workspaceType],\n  );\n\n  return (\n    <div className=\"fixed bottom-0 left-0 right-0 top-0 z-[60] flex items-center justify-center bg-black/50 p-0 backdrop-blur-sm sm:p-4\">\n      <div className=\"h-full w-full overflow-y-auto rounded-none border-0 border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800 sm:h-auto sm:max-w-2xl sm:rounded-lg sm:border\">\n        <div className=\"flex items-center justify-between border-b border-gray-200 p-6 dark:border-gray-700\">\n          <div className=\"flex items-center gap-3\">\n            <div className=\"flex h-8 w-8 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/50\">\n              <FolderPlus className=\"h-4 w-4 text-blue-600 dark:text-blue-400\" />\n            </div>\n            <h3 className=\"text-lg font-semibold text-gray-900 dark:text-white\">\n              {t('projectWizard.title')}\n            </h3>\n          </div>\n          <button\n            onClick={onClose}\n            className=\"rounded-md p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300\"\n            disabled={isCreating}\n          >\n            <X className=\"h-5 w-5\" />\n          </button>\n        </div>\n\n        <WizardProgress step={step} />\n\n        <div className=\"min-h-[300px] space-y-6 p-6\">\n          {error && <ErrorBanner message={error} />}\n\n          {step === 1 && (\n            <StepTypeSelection\n              workspaceType={formState.workspaceType}\n              onWorkspaceTypeChange={updateWorkspaceType}\n            />\n          )}\n\n          {step === 2 && (\n            <StepConfiguration\n              workspaceType={formState.workspaceType}\n              workspacePath={formState.workspacePath}\n              githubUrl={formState.githubUrl}\n              tokenMode={formState.tokenMode}\n              selectedGithubToken={formState.selectedGithubToken}\n              newGithubToken={formState.newGithubToken}\n              availableTokens={availableTokens}\n              loadingTokens={loadingTokens}\n              tokenLoadError={tokenLoadError}\n              isCreating={isCreating}\n              onWorkspacePathChange={(workspacePath) => updateField('workspacePath', workspacePath)}\n              onGithubUrlChange={(githubUrl) => updateField('githubUrl', githubUrl)}\n              onTokenModeChange={updateTokenMode}\n              onSelectedGithubTokenChange={(selectedGithubToken) =>\n                updateField('selectedGithubToken', selectedGithubToken)\n              }\n              onNewGithubTokenChange={(newGithubToken) =>\n                updateField('newGithubToken', newGithubToken)\n              }\n              onAdvanceToConfirm={() => setStep(3)}\n            />\n          )}\n\n          {step === 3 && (\n            <StepReview\n              formState={formState}\n              selectedTokenName={selectedTokenName}\n              isCreating={isCreating}\n              cloneProgress={cloneProgress}\n            />\n          )}\n        </div>\n\n        <WizardFooter\n          step={step}\n          isCreating={isCreating}\n          isCloneWorkflow={shouldCloneRepository}\n          onClose={onClose}\n          onBack={handleBack}\n          onNext={handleNext}\n          onCreate={handleCreate}\n        />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/project-creation-wizard/components/ErrorBanner.tsx",
    "content": "import { AlertCircle } from 'lucide-react';\n\ntype ErrorBannerProps = {\n  message: string;\n};\n\nexport default function ErrorBanner({ message }: ErrorBannerProps) {\n  return (\n    <div className=\"flex items-start gap-3 rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-900/20\">\n      <AlertCircle className=\"mt-0.5 h-5 w-5 flex-shrink-0 text-red-600 dark:text-red-400\" />\n      <p className=\"text-sm text-red-800 dark:text-red-200\">{message}</p>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/project-creation-wizard/components/FolderBrowserModal.tsx",
    "content": "import { useCallback, useEffect, useMemo, useState } from 'react';\nimport { Eye, EyeOff, FolderOpen, FolderPlus, Loader2, Plus, X } from 'lucide-react';\nimport { Button, Input } from '../../../shared/view/ui';\nimport { browseFilesystemFolders, createFolderInFilesystem } from '../data/workspaceApi';\nimport { getParentPath, joinFolderPath } from '../utils/pathUtils';\nimport type { FolderSuggestion } from '../types';\n\ntype FolderBrowserModalProps = {\n  isOpen: boolean;\n  autoAdvanceOnSelect: boolean;\n  onClose: () => void;\n  onFolderSelected: (folderPath: string, advanceToConfirm: boolean) => void;\n};\n\nexport default function FolderBrowserModal({\n  isOpen,\n  autoAdvanceOnSelect,\n  onClose,\n  onFolderSelected,\n}: FolderBrowserModalProps) {\n  const [currentPath, setCurrentPath] = useState('~');\n  const [folders, setFolders] = useState<FolderSuggestion[]>([]);\n  const [loadingFolders, setLoadingFolders] = useState(false);\n  const [showHiddenFolders, setShowHiddenFolders] = useState(false);\n  const [showNewFolderInput, setShowNewFolderInput] = useState(false);\n  const [newFolderName, setNewFolderName] = useState('');\n  const [creatingFolder, setCreatingFolder] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n\n  const loadFolders = useCallback(async (pathToLoad: string) => {\n    setLoadingFolders(true);\n    setError(null);\n\n    try {\n      const result = await browseFilesystemFolders(pathToLoad);\n      setCurrentPath(result.path);\n      setFolders(result.suggestions);\n    } catch (loadError) {\n      setError(loadError instanceof Error ? loadError.message : 'Failed to load folders');\n    } finally {\n      setLoadingFolders(false);\n    }\n  }, []);\n\n  useEffect(() => {\n    if (!isOpen) {\n      return;\n    }\n    loadFolders('~');\n  }, [isOpen, loadFolders]);\n\n  const visibleFolders = useMemo(\n    () =>\n      folders\n        .filter((folder) => showHiddenFolders || !folder.name.startsWith('.'))\n        .sort((firstFolder, secondFolder) =>\n          firstFolder.name.toLowerCase().localeCompare(secondFolder.name.toLowerCase()),\n        ),\n    [folders, showHiddenFolders],\n  );\n\n  const resetNewFolderState = () => {\n    setShowNewFolderInput(false);\n    setNewFolderName('');\n  };\n\n  const handleClose = () => {\n    setError(null);\n    resetNewFolderState();\n    onClose();\n  };\n\n  const handleCreateFolder = useCallback(async () => {\n    if (!newFolderName.trim()) {\n      return;\n    }\n\n    setCreatingFolder(true);\n    setError(null);\n\n    try {\n      const folderPath = joinFolderPath(currentPath, newFolderName);\n      const createdPath = await createFolderInFilesystem(folderPath);\n      resetNewFolderState();\n      await loadFolders(createdPath);\n    } catch (createError) {\n      setError(createError instanceof Error ? createError.message : 'Failed to create folder');\n    } finally {\n      setCreatingFolder(false);\n    }\n  }, [currentPath, loadFolders, newFolderName]);\n\n  const parentPath = getParentPath(currentPath);\n\n  if (!isOpen) {\n    return null;\n  }\n\n  return (\n    <div className=\"fixed inset-0 z-[70] flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm\">\n      <div className=\"flex max-h-[80vh] w-full max-w-2xl flex-col rounded-lg border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800\">\n        <div className=\"flex items-center justify-between border-b border-gray-200 p-4 dark:border-gray-700\">\n          <div className=\"flex items-center gap-3\">\n            <div className=\"flex h-8 w-8 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/50\">\n              <FolderOpen className=\"h-4 w-4 text-blue-600 dark:text-blue-400\" />\n            </div>\n            <h3 className=\"text-lg font-semibold text-gray-900 dark:text-white\">Select Folder</h3>\n          </div>\n\n          <div className=\"flex items-center gap-2\">\n            <button\n              onClick={() => setShowHiddenFolders((previous) => !previous)}\n              className={`rounded-md p-2 transition-colors ${\n                showHiddenFolders\n                  ? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'\n                  : 'text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300'\n              }`}\n              title={showHiddenFolders ? 'Hide hidden folders' : 'Show hidden folders'}\n            >\n              {showHiddenFolders ? <Eye className=\"h-5 w-5\" /> : <EyeOff className=\"h-5 w-5\" />}\n            </button>\n            <button\n              onClick={() => setShowNewFolderInput((previous) => !previous)}\n              className={`rounded-md p-2 transition-colors ${\n                showNewFolderInput\n                  ? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'\n                  : 'text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300'\n              }`}\n              title=\"Create new folder\"\n            >\n              <Plus className=\"h-5 w-5\" />\n            </button>\n            <button\n              onClick={handleClose}\n              className=\"rounded-md p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300\"\n            >\n              <X className=\"h-5 w-5\" />\n            </button>\n          </div>\n        </div>\n\n        {showNewFolderInput && (\n          <div className=\"border-b border-gray-200 bg-blue-50 px-4 py-3 dark:border-gray-700 dark:bg-blue-900/20\">\n            <div className=\"flex items-center gap-2\">\n              <Input\n                type=\"text\"\n                value={newFolderName}\n                onChange={(event) => setNewFolderName(event.target.value)}\n                placeholder=\"New folder name\"\n                className=\"flex-1\"\n                onKeyDown={(event) => {\n                  if (event.key === 'Enter') {\n                    handleCreateFolder();\n                  }\n                  if (event.key === 'Escape') {\n                    resetNewFolderState();\n                  }\n                }}\n                autoFocus\n              />\n              <Button\n                size=\"sm\"\n                onClick={handleCreateFolder}\n                disabled={!newFolderName.trim() || creatingFolder}\n              >\n                {creatingFolder ? <Loader2 className=\"h-4 w-4 animate-spin\" /> : 'Create'}\n              </Button>\n              <Button size=\"sm\" variant=\"ghost\" onClick={resetNewFolderState}>\n                Cancel\n              </Button>\n            </div>\n          </div>\n        )}\n\n        {error && (\n          <div className=\"px-4 pt-3\">\n            <p className=\"text-sm text-red-600 dark:text-red-400\">{error}</p>\n          </div>\n        )}\n\n        <div className=\"flex-1 overflow-y-auto p-4\">\n          {loadingFolders ? (\n            <div className=\"flex items-center justify-center py-8\">\n              <Loader2 className=\"h-6 w-6 animate-spin text-gray-400\" />\n            </div>\n          ) : (\n            <div className=\"space-y-1\">\n              {parentPath && (\n                <button\n                  onClick={() => loadFolders(parentPath)}\n                  className=\"flex w-full items-center gap-3 rounded-lg px-4 py-3 text-left hover:bg-gray-100 dark:hover:bg-gray-700\"\n                >\n                  <FolderOpen className=\"h-5 w-5 text-gray-400\" />\n                  <span className=\"font-medium text-gray-700 dark:text-gray-300\">..</span>\n                </button>\n              )}\n\n              {visibleFolders.length === 0 ? (\n                <div className=\"py-8 text-center text-gray-500 dark:text-gray-400\">\n                  No subfolders found\n                </div>\n              ) : (\n                visibleFolders.map((folder) => (\n                  <div key={folder.path} className=\"flex items-center gap-2\">\n                    <button\n                      onClick={() => loadFolders(folder.path)}\n                      className=\"flex flex-1 items-center gap-3 rounded-lg px-4 py-3 text-left hover:bg-gray-100 dark:hover:bg-gray-700\"\n                    >\n                      <FolderPlus className=\"h-5 w-5 text-blue-500\" />\n                      <span className=\"font-medium text-gray-900 dark:text-white\">\n                        {folder.name}\n                      </span>\n                    </button>\n                    <Button\n                      variant=\"ghost\"\n                      size=\"sm\"\n                      onClick={() => onFolderSelected(folder.path, autoAdvanceOnSelect)}\n                      className=\"px-3 text-xs\"\n                    >\n                      Select\n                    </Button>\n                  </div>\n                ))\n              )}\n            </div>\n          )}\n        </div>\n\n        <div className=\"border-t border-gray-200 dark:border-gray-700\">\n          <div className=\"flex items-center gap-2 bg-gray-50 px-4 py-3 dark:bg-gray-900/50\">\n            <span className=\"text-sm text-gray-600 dark:text-gray-400\">Path:</span>\n            <code className=\"flex-1 truncate font-mono text-sm text-gray-900 dark:text-white\">\n              {currentPath}\n            </code>\n          </div>\n          <div className=\"flex items-center justify-end gap-2 p-4\">\n            <Button variant=\"outline\" onClick={handleClose}>\n              Cancel\n            </Button>\n            <Button\n              variant=\"outline\"\n              onClick={() => onFolderSelected(currentPath, autoAdvanceOnSelect)}\n            >\n              Use this folder\n            </Button>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/project-creation-wizard/components/GithubAuthenticationCard.tsx",
    "content": "import { Key, Loader2 } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { Input } from '../../../shared/view/ui';\nimport type { GithubTokenCredential, TokenMode } from '../types';\n\ntype GithubAuthenticationCardProps = {\n  tokenMode: TokenMode;\n  selectedGithubToken: string;\n  newGithubToken: string;\n  availableTokens: GithubTokenCredential[];\n  loadingTokens: boolean;\n  tokenLoadError: string | null;\n  onTokenModeChange: (tokenMode: TokenMode) => void;\n  onSelectedGithubTokenChange: (tokenId: string) => void;\n  onNewGithubTokenChange: (tokenValue: string) => void;\n};\n\nconst getModeClassName = (mode: TokenMode, selectedMode: TokenMode) =>\n  `px-3 py-2 text-sm font-medium rounded-lg transition-colors ${\n    mode === selectedMode\n      ? mode === 'none'\n        ? 'bg-green-500 text-white'\n        : 'bg-blue-500 text-white'\n      : 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'\n  }`;\n\nexport default function GithubAuthenticationCard({\n  tokenMode,\n  selectedGithubToken,\n  newGithubToken,\n  availableTokens,\n  loadingTokens,\n  tokenLoadError,\n  onTokenModeChange,\n  onSelectedGithubTokenChange,\n  onNewGithubTokenChange,\n}: GithubAuthenticationCardProps) {\n  const { t } = useTranslation();\n\n  return (\n    <div className=\"rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/50\">\n      <div className=\"mb-4 flex items-start gap-3\">\n        <Key className=\"mt-0.5 h-5 w-5 flex-shrink-0 text-gray-600 dark:text-gray-400\" />\n        <div className=\"flex-1\">\n          <h5 className=\"mb-1 font-medium text-gray-900 dark:text-white\">\n            {t('projectWizard.step2.githubAuth')}\n          </h5>\n          <p className=\"text-sm text-gray-600 dark:text-gray-400\">\n            {t('projectWizard.step2.githubAuthHelp')}\n          </p>\n        </div>\n      </div>\n\n      {loadingTokens && (\n        <div className=\"flex items-center gap-2 text-sm text-gray-500\">\n          <Loader2 className=\"h-4 w-4 animate-spin\" />\n          {t('projectWizard.step2.loadingTokens')}\n        </div>\n      )}\n\n      {!loadingTokens && tokenLoadError && (\n        <p className=\"mb-3 text-sm text-red-600 dark:text-red-400\">{tokenLoadError}</p>\n      )}\n\n      {!loadingTokens && availableTokens.length > 0 && (\n        <>\n          <div className=\"mb-4 grid grid-cols-3 gap-2\">\n            <button\n              onClick={() => onTokenModeChange('stored')}\n              className={getModeClassName(tokenMode, 'stored')}\n            >\n              {t('projectWizard.step2.storedToken')}\n            </button>\n            <button\n              onClick={() => onTokenModeChange('new')}\n              className={getModeClassName(tokenMode, 'new')}\n            >\n              {t('projectWizard.step2.newToken')}\n            </button>\n            <button\n              onClick={() => {\n                onTokenModeChange('none');\n                onSelectedGithubTokenChange('');\n                onNewGithubTokenChange('');\n              }}\n              className={getModeClassName(tokenMode, 'none')}\n            >\n              {t('projectWizard.step2.nonePublic')}\n            </button>\n          </div>\n\n          {tokenMode === 'stored' ? (\n            <div>\n              <label className=\"mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300\">\n                {t('projectWizard.step2.selectToken')}\n              </label>\n              <select\n                value={selectedGithubToken}\n                onChange={(event) => onSelectedGithubTokenChange(event.target.value)}\n                className=\"w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-800\"\n              >\n                <option value=\"\">{t('projectWizard.step2.selectTokenPlaceholder')}</option>\n                {availableTokens.map((token) => (\n                  <option key={token.id} value={String(token.id)}>\n                    {token.credential_name}\n                  </option>\n                ))}\n              </select>\n            </div>\n          ) : tokenMode === 'new' ? (\n            <div>\n              <label className=\"mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300\">\n                {t('projectWizard.step2.newToken')}\n              </label>\n              <Input\n                type=\"password\"\n                value={newGithubToken}\n                onChange={(event) => onNewGithubTokenChange(event.target.value)}\n                placeholder=\"ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\"\n                className=\"w-full\"\n              />\n              <p className=\"mt-1 text-xs text-gray-500 dark:text-gray-400\">\n                {t('projectWizard.step2.tokenHelp')}\n              </p>\n            </div>\n          ) : null}\n        </>\n      )}\n\n      {!loadingTokens && availableTokens.length === 0 && (\n        <div className=\"space-y-4\">\n          <div className=\"rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-800 dark:bg-blue-900/20\">\n            <p className=\"text-sm text-blue-800 dark:text-blue-200\">\n              {t('projectWizard.step2.publicRepoInfo')}\n            </p>\n          </div>\n\n          <div>\n            <label className=\"mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300\">\n              {t('projectWizard.step2.optionalTokenPublic')}\n            </label>\n            <Input\n              type=\"password\"\n              value={newGithubToken}\n              onChange={(event) => {\n                const tokenValue = event.target.value;\n                onNewGithubTokenChange(tokenValue);\n                onTokenModeChange(tokenValue.trim() ? 'new' : 'none');\n              }}\n              placeholder={t('projectWizard.step2.tokenPublicPlaceholder')}\n              className=\"w-full\"\n            />\n            <p className=\"mt-1 text-xs text-gray-500 dark:text-gray-400\">\n              {t('projectWizard.step2.noTokensHelp')}\n            </p>\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/project-creation-wizard/components/StepConfiguration.tsx",
    "content": "import { useTranslation } from 'react-i18next';\nimport { Input } from '../../../shared/view/ui';\nimport { shouldShowGithubAuthentication } from '../utils/pathUtils';\nimport type { GithubTokenCredential, TokenMode, WorkspaceType } from '../types';\nimport GithubAuthenticationCard from './GithubAuthenticationCard';\nimport WorkspacePathField from './WorkspacePathField';\n\ntype StepConfigurationProps = {\n  workspaceType: WorkspaceType;\n  workspacePath: string;\n  githubUrl: string;\n  tokenMode: TokenMode;\n  selectedGithubToken: string;\n  newGithubToken: string;\n  availableTokens: GithubTokenCredential[];\n  loadingTokens: boolean;\n  tokenLoadError: string | null;\n  isCreating: boolean;\n  onWorkspacePathChange: (workspacePath: string) => void;\n  onGithubUrlChange: (githubUrl: string) => void;\n  onTokenModeChange: (tokenMode: TokenMode) => void;\n  onSelectedGithubTokenChange: (tokenId: string) => void;\n  onNewGithubTokenChange: (tokenValue: string) => void;\n  onAdvanceToConfirm: () => void;\n};\n\nexport default function StepConfiguration({\n  workspaceType,\n  workspacePath,\n  githubUrl,\n  tokenMode,\n  selectedGithubToken,\n  newGithubToken,\n  availableTokens,\n  loadingTokens,\n  tokenLoadError,\n  isCreating,\n  onWorkspacePathChange,\n  onGithubUrlChange,\n  onTokenModeChange,\n  onSelectedGithubTokenChange,\n  onNewGithubTokenChange,\n  onAdvanceToConfirm,\n}: StepConfigurationProps) {\n  const { t } = useTranslation();\n  const showGithubAuth = shouldShowGithubAuthentication(workspaceType, githubUrl);\n\n  return (\n    <div className=\"space-y-4\">\n      <div>\n        <label className=\"mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300\">\n          {workspaceType === 'existing'\n            ? t('projectWizard.step2.existingPath')\n            : t('projectWizard.step2.newPath')}\n        </label>\n\n        <WorkspacePathField\n          workspaceType={workspaceType}\n          value={workspacePath}\n          disabled={isCreating}\n          onChange={onWorkspacePathChange}\n          onAdvanceToConfirm={onAdvanceToConfirm}\n        />\n\n        <p className=\"mt-1 text-xs text-gray-500 dark:text-gray-400\">\n          {workspaceType === 'existing'\n            ? t('projectWizard.step2.existingHelp')\n            : t('projectWizard.step2.newHelp')}\n        </p>\n      </div>\n\n      {workspaceType === 'new' && (\n        <>\n          <div>\n            <label className=\"mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300\">\n              {t('projectWizard.step2.githubUrl')}\n            </label>\n            <Input\n              type=\"text\"\n              value={githubUrl}\n              onChange={(event) => onGithubUrlChange(event.target.value)}\n              placeholder=\"https://github.com/username/repository\"\n              className=\"w-full\"\n              disabled={isCreating}\n            />\n            <p className=\"mt-1 text-xs text-gray-500 dark:text-gray-400\">\n              {t('projectWizard.step2.githubHelp')}\n            </p>\n          </div>\n\n          {showGithubAuth && (\n            <GithubAuthenticationCard\n              tokenMode={tokenMode}\n              selectedGithubToken={selectedGithubToken}\n              newGithubToken={newGithubToken}\n              availableTokens={availableTokens}\n              loadingTokens={loadingTokens}\n              tokenLoadError={tokenLoadError}\n              onTokenModeChange={onTokenModeChange}\n              onSelectedGithubTokenChange={onSelectedGithubTokenChange}\n              onNewGithubTokenChange={onNewGithubTokenChange}\n            />\n          )}\n        </>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/project-creation-wizard/components/StepReview.tsx",
    "content": "import { useMemo } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { isSshGitUrl } from '../utils/pathUtils';\nimport type { WizardFormState } from '../types';\n\ntype StepReviewProps = {\n  formState: WizardFormState;\n  selectedTokenName: string | null;\n  isCreating: boolean;\n  cloneProgress: string;\n};\n\nexport default function StepReview({\n  formState,\n  selectedTokenName,\n  isCreating,\n  cloneProgress,\n}: StepReviewProps) {\n  const { t } = useTranslation();\n\n  const authenticationLabel = useMemo(() => {\n    if (formState.tokenMode === 'stored' && formState.selectedGithubToken) {\n      return `${t('projectWizard.step3.usingStoredToken')} ${selectedTokenName || 'Unknown'}`;\n    }\n\n    if (formState.tokenMode === 'new' && formState.newGithubToken.trim()) {\n      return t('projectWizard.step3.usingProvidedToken');\n    }\n\n    if (isSshGitUrl(formState.githubUrl)) {\n      return t('projectWizard.step3.sshKey', { defaultValue: 'SSH Key' });\n    }\n\n    return t('projectWizard.step3.noAuthentication');\n  }, [formState, selectedTokenName, t]);\n\n  return (\n    <div className=\"space-y-4\">\n      <div className=\"rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/50\">\n        <h4 className=\"mb-3 text-sm font-semibold text-gray-900 dark:text-white\">\n          {t('projectWizard.step3.reviewConfig')}\n        </h4>\n\n        <div className=\"space-y-2\">\n          <div className=\"flex justify-between text-sm\">\n            <span className=\"text-gray-600 dark:text-gray-400\">\n              {t('projectWizard.step3.workspaceType')}\n            </span>\n            <span className=\"font-medium text-gray-900 dark:text-white\">\n              {formState.workspaceType === 'existing'\n                ? t('projectWizard.step3.existingWorkspace')\n                : t('projectWizard.step3.newWorkspace')}\n            </span>\n          </div>\n\n          <div className=\"flex justify-between text-sm\">\n            <span className=\"text-gray-600 dark:text-gray-400\">{t('projectWizard.step3.path')}</span>\n            <span className=\"break-all font-mono text-xs text-gray-900 dark:text-white\">\n              {formState.workspacePath}\n            </span>\n          </div>\n\n          {formState.workspaceType === 'new' && formState.githubUrl && (\n            <>\n              <div className=\"flex justify-between text-sm\">\n                <span className=\"text-gray-600 dark:text-gray-400\">\n                  {t('projectWizard.step3.cloneFrom')}\n                </span>\n                <span className=\"break-all font-mono text-xs text-gray-900 dark:text-white\">\n                  {formState.githubUrl}\n                </span>\n              </div>\n\n              <div className=\"flex justify-between text-sm\">\n                <span className=\"text-gray-600 dark:text-gray-400\">\n                  {t('projectWizard.step3.authentication')}\n                </span>\n                <span className=\"text-xs text-gray-900 dark:text-white\">{authenticationLabel}</span>\n              </div>\n            </>\n          )}\n        </div>\n      </div>\n\n      <div className=\"rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-900/20\">\n        {isCreating && cloneProgress ? (\n          <div className=\"space-y-2\">\n            <p className=\"text-sm font-medium text-blue-800 dark:text-blue-200\">\n              {t('projectWizard.step3.cloningRepository', { defaultValue: 'Cloning repository...' })}\n            </p>\n            <code className=\"block whitespace-pre-wrap break-all font-mono text-xs text-blue-700 dark:text-blue-300\">\n              {cloneProgress}\n            </code>\n          </div>\n        ) : (\n          <p className=\"text-sm text-blue-800 dark:text-blue-200\">\n            {formState.workspaceType === 'existing'\n              ? t('projectWizard.step3.existingInfo')\n              : formState.githubUrl\n                ? t('projectWizard.step3.newWithClone')\n                : t('projectWizard.step3.newEmpty')}\n          </p>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/project-creation-wizard/components/StepTypeSelection.tsx",
    "content": "import { FolderPlus, GitBranch } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport type { WorkspaceType } from '../types';\n\ntype StepTypeSelectionProps = {\n  workspaceType: WorkspaceType;\n  onWorkspaceTypeChange: (workspaceType: WorkspaceType) => void;\n};\n\nexport default function StepTypeSelection({\n  workspaceType,\n  onWorkspaceTypeChange,\n}: StepTypeSelectionProps) {\n  const { t } = useTranslation();\n\n  return (\n    <div className=\"space-y-4\">\n      <h4 className=\"mb-3 text-sm font-medium text-gray-700 dark:text-gray-300\">\n        {t('projectWizard.step1.question')}\n      </h4>\n\n      <div className=\"grid grid-cols-1 gap-4 md:grid-cols-2\">\n        <button\n          onClick={() => onWorkspaceTypeChange('existing')}\n          className={`rounded-lg border-2 p-4 text-left transition-all ${\n            workspaceType === 'existing'\n              ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'\n              : 'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600'\n          }`}\n        >\n          <div className=\"flex items-start gap-3\">\n            <div className=\"flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-green-100 dark:bg-green-900/50\">\n              <FolderPlus className=\"h-5 w-5 text-green-600 dark:text-green-400\" />\n            </div>\n            <div className=\"flex-1\">\n              <h5 className=\"mb-1 font-semibold text-gray-900 dark:text-white\">\n                {t('projectWizard.step1.existing.title')}\n              </h5>\n              <p className=\"text-sm text-gray-600 dark:text-gray-400\">\n                {t('projectWizard.step1.existing.description')}\n              </p>\n            </div>\n          </div>\n        </button>\n\n        <button\n          onClick={() => onWorkspaceTypeChange('new')}\n          className={`rounded-lg border-2 p-4 text-left transition-all ${\n            workspaceType === 'new'\n              ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'\n              : 'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600'\n          }`}\n        >\n          <div className=\"flex items-start gap-3\">\n            <div className=\"flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-purple-100 dark:bg-purple-900/50\">\n              <GitBranch className=\"h-5 w-5 text-purple-600 dark:text-purple-400\" />\n            </div>\n            <div className=\"flex-1\">\n              <h5 className=\"mb-1 font-semibold text-gray-900 dark:text-white\">\n                {t('projectWizard.step1.new.title')}\n              </h5>\n              <p className=\"text-sm text-gray-600 dark:text-gray-400\">\n                {t('projectWizard.step1.new.description')}\n              </p>\n            </div>\n          </div>\n        </button>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/project-creation-wizard/components/WizardFooter.tsx",
    "content": "import { Check, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { Button } from '../../../shared/view/ui';\nimport type { WizardStep } from '../types';\n\ntype WizardFooterProps = {\n  step: WizardStep;\n  isCreating: boolean;\n  isCloneWorkflow: boolean;\n  onClose: () => void;\n  onBack: () => void;\n  onNext: () => void;\n  onCreate: () => void;\n};\n\nexport default function WizardFooter({\n  step,\n  isCreating,\n  isCloneWorkflow,\n  onClose,\n  onBack,\n  onNext,\n  onCreate,\n}: WizardFooterProps) {\n  const { t } = useTranslation();\n\n  return (\n    <div className=\"flex items-center justify-between border-t border-gray-200 p-6 dark:border-gray-700\">\n      <Button variant=\"outline\" onClick={step === 1 ? onClose : onBack} disabled={isCreating}>\n        {step === 1 ? (\n          t('projectWizard.buttons.cancel')\n        ) : (\n          <>\n            <ChevronLeft className=\"mr-1 h-4 w-4\" />\n            {t('projectWizard.buttons.back')}\n          </>\n        )}\n      </Button>\n\n      <Button onClick={step === 3 ? onCreate : onNext} disabled={isCreating}>\n        {isCreating ? (\n          <>\n            <Loader2 className=\"mr-2 h-4 w-4 animate-spin\" />\n            {isCloneWorkflow\n              ? t('projectWizard.buttons.cloning', { defaultValue: 'Cloning...' })\n              : t('projectWizard.buttons.creating')}\n          </>\n        ) : step === 3 ? (\n          <>\n            <Check className=\"mr-1 h-4 w-4\" />\n            {t('projectWizard.buttons.createProject')}\n          </>\n        ) : (\n          <>\n            {t('projectWizard.buttons.next')}\n            <ChevronRight className=\"ml-1 h-4 w-4\" />\n          </>\n        )}\n      </Button>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/project-creation-wizard/components/WizardProgress.tsx",
    "content": "import { Fragment } from 'react';\nimport { Check } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport type { WizardStep } from '../types';\n\ntype WizardProgressProps = {\n  step: WizardStep;\n};\n\nexport default function WizardProgress({ step }: WizardProgressProps) {\n  const { t } = useTranslation();\n  const steps: WizardStep[] = [1, 2, 3];\n\n  return (\n    <div className=\"px-6 pb-2 pt-4\">\n      <div className=\"flex items-center justify-between\">\n        {steps.map((currentStep) => (\n          <Fragment key={currentStep}>\n            <div className=\"flex items-center gap-2\">\n              <div\n                className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${\n                  currentStep < step\n                    ? 'bg-green-500 text-white'\n                    : currentStep === step\n                      ? 'bg-blue-500 text-white'\n                      : 'bg-gray-200 text-gray-500 dark:bg-gray-700'\n                }`}\n              >\n                {currentStep < step ? <Check className=\"h-4 w-4\" /> : currentStep}\n              </div>\n              <span className=\"hidden text-sm font-medium text-gray-700 dark:text-gray-300 sm:inline\">\n                {currentStep === 1\n                  ? t('projectWizard.steps.type')\n                  : currentStep === 2\n                    ? t('projectWizard.steps.configure')\n                    : t('projectWizard.steps.confirm')}\n              </span>\n            </div>\n\n            {currentStep < 3 && (\n              <div\n                className={`mx-2 h-1 flex-1 rounded ${\n                  currentStep < step ? 'bg-green-500' : 'bg-gray-200 dark:bg-gray-700'\n                }`}\n              />\n            )}\n          </Fragment>\n        ))}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/project-creation-wizard/components/WorkspacePathField.tsx",
    "content": "import { useCallback, useEffect, useState } from 'react';\nimport { FolderOpen } from 'lucide-react';\nimport { Button, Input } from '../../../shared/view/ui';\nimport { browseFilesystemFolders } from '../data/workspaceApi';\nimport { getSuggestionRootPath } from '../utils/pathUtils';\nimport type { FolderSuggestion, WorkspaceType } from '../types';\nimport FolderBrowserModal from './FolderBrowserModal';\n\ntype WorkspacePathFieldProps = {\n  workspaceType: WorkspaceType;\n  value: string;\n  disabled?: boolean;\n  onChange: (path: string) => void;\n  onAdvanceToConfirm: () => void;\n};\n\nexport default function WorkspacePathField({\n  workspaceType,\n  value,\n  disabled = false,\n  onChange,\n  onAdvanceToConfirm,\n}: WorkspacePathFieldProps) {\n  const [pathSuggestions, setPathSuggestions] = useState<FolderSuggestion[]>([]);\n  const [showPathDropdown, setShowPathDropdown] = useState(false);\n  const [showFolderBrowser, setShowFolderBrowser] = useState(false);\n\n  useEffect(() => {\n    if (value.trim().length <= 2) {\n      setPathSuggestions([]);\n      setShowPathDropdown(false);\n      return;\n    }\n\n    // Debounce path lookup to avoid firing a request for every keystroke.\n    const timerId = window.setTimeout(async () => {\n      try {\n        const directoryPath = getSuggestionRootPath(value);\n        const result = await browseFilesystemFolders(directoryPath);\n        const normalizedInput = value.toLowerCase();\n\n        const matchingSuggestions = result.suggestions\n          .filter((suggestion) => {\n            const normalizedSuggestion = suggestion.path.toLowerCase();\n            return (\n              normalizedSuggestion.startsWith(normalizedInput) &&\n              normalizedSuggestion !== normalizedInput\n            );\n          })\n          .slice(0, 5);\n\n        setPathSuggestions(matchingSuggestions);\n        setShowPathDropdown(matchingSuggestions.length > 0);\n      } catch (error) {\n        console.error('Failed to load path suggestions:', error);\n      }\n    }, 200);\n\n    return () => {\n      window.clearTimeout(timerId);\n    };\n  }, [value]);\n\n  const handleSuggestionSelect = useCallback(\n    (suggestion: FolderSuggestion) => {\n      onChange(suggestion.path);\n      setShowPathDropdown(false);\n    },\n    [onChange],\n  );\n\n  const handleFolderSelected = useCallback(\n    (selectedPath: string, advanceToConfirm: boolean) => {\n      onChange(selectedPath);\n      setShowFolderBrowser(false);\n      if (advanceToConfirm) {\n        onAdvanceToConfirm();\n      }\n    },\n    [onAdvanceToConfirm, onChange],\n  );\n\n  return (\n    <>\n      <div className=\"relative flex gap-2\">\n        <div className=\"relative flex-1\">\n          <Input\n            type=\"text\"\n            value={value}\n            onChange={(event) => onChange(event.target.value)}\n            placeholder={\n              workspaceType === 'existing'\n                ? '/path/to/existing/workspace'\n                : '/path/to/new/workspace'\n            }\n            className=\"w-full\"\n            disabled={disabled}\n          />\n\n          {showPathDropdown && pathSuggestions.length > 0 && (\n            <div className=\"absolute z-10 mt-1 max-h-60 w-full overflow-y-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800\">\n              {pathSuggestions.map((suggestion) => (\n                <button\n                  key={suggestion.path}\n                  onClick={() => handleSuggestionSelect(suggestion)}\n                  className=\"w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700\"\n                >\n                  <div className=\"font-medium text-gray-900 dark:text-white\">{suggestion.name}</div>\n                  <div className=\"text-xs text-gray-500 dark:text-gray-400\">{suggestion.path}</div>\n                </button>\n              ))}\n            </div>\n          )}\n        </div>\n\n        <Button\n          type=\"button\"\n          variant=\"outline\"\n          onClick={() => setShowFolderBrowser(true)}\n          className=\"px-3\"\n          title=\"Browse folders\"\n          disabled={disabled}\n        >\n          <FolderOpen className=\"h-4 w-4\" />\n        </Button>\n      </div>\n\n      <FolderBrowserModal\n        isOpen={showFolderBrowser}\n        autoAdvanceOnSelect={workspaceType === 'existing'}\n        onClose={() => setShowFolderBrowser(false)}\n        onFolderSelected={handleFolderSelected}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "src/components/project-creation-wizard/data/workspaceApi.ts",
    "content": "import { api } from '../../../utils/api';\nimport type {\n  BrowseFilesystemResponse,\n  CloneProgressEvent,\n  CreateFolderResponse,\n  CreateWorkspacePayload,\n  CreateWorkspaceResponse,\n  CredentialsResponse,\n  FolderSuggestion,\n  TokenMode,\n} from '../types';\n\ntype CloneWorkspaceParams = {\n  workspacePath: string;\n  githubUrl: string;\n  tokenMode: TokenMode;\n  selectedGithubToken: string;\n  newGithubToken: string;\n};\n\ntype CloneProgressHandlers = {\n  onProgress: (message: string) => void;\n};\n\nconst parseJson = async <T>(response: Response): Promise<T> => {\n  const data = (await response.json()) as T;\n  return data;\n};\n\nexport const fetchGithubTokenCredentials = async () => {\n  const response = await api.get('/settings/credentials?type=github_token');\n  const data = await parseJson<CredentialsResponse>(response);\n\n  if (!response.ok) {\n    throw new Error(data.error || 'Failed to load GitHub tokens');\n  }\n\n  return (data.credentials || []).filter((credential) => credential.is_active);\n};\n\nexport const browseFilesystemFolders = async (pathToBrowse: string) => {\n  const endpoint = `/browse-filesystem?path=${encodeURIComponent(pathToBrowse)}`;\n  const response = await api.get(endpoint);\n  const data = await parseJson<BrowseFilesystemResponse>(response);\n\n  if (!response.ok) {\n    throw new Error(data.error || 'Failed to browse filesystem');\n  }\n\n  return {\n    path: data.path || pathToBrowse,\n    suggestions: (data.suggestions || []) as FolderSuggestion[],\n  };\n};\n\nexport const createFolderInFilesystem = async (folderPath: string) => {\n  const response = await api.createFolder(folderPath);\n  const data = await parseJson<CreateFolderResponse>(response);\n\n  if (!response.ok) {\n    throw new Error(data.error || 'Failed to create folder');\n  }\n\n  return data.path || folderPath;\n};\n\nexport const createWorkspaceRequest = async (payload: CreateWorkspacePayload) => {\n  const response = await api.createWorkspace(payload);\n  const data = await parseJson<CreateWorkspaceResponse>(response);\n\n  if (!response.ok) {\n    throw new Error(data.details || data.error || 'Failed to create workspace');\n  }\n\n  return data.project;\n};\n\nconst buildCloneProgressQuery = ({\n  workspacePath,\n  githubUrl,\n  tokenMode,\n  selectedGithubToken,\n  newGithubToken,\n}: CloneWorkspaceParams) => {\n  const query = new URLSearchParams({\n    path: workspacePath.trim(),\n    githubUrl: githubUrl.trim(),\n  });\n\n  if (tokenMode === 'stored' && selectedGithubToken) {\n    query.set('githubTokenId', selectedGithubToken);\n  }\n\n  if (tokenMode === 'new' && newGithubToken.trim()) {\n    query.set('newGithubToken', newGithubToken.trim());\n  }\n\n  // EventSource cannot send custom headers, so the auth token is passed as query.\n  const authToken = localStorage.getItem('auth-token');\n  if (authToken) {\n    query.set('token', authToken);\n  }\n\n  return query.toString();\n};\n\nexport const cloneWorkspaceWithProgress = (\n  params: CloneWorkspaceParams,\n  handlers: CloneProgressHandlers,\n) =>\n  new Promise<Record<string, unknown> | undefined>((resolve, reject) => {\n    const query = buildCloneProgressQuery(params);\n    const eventSource = new EventSource(`/api/projects/clone-progress?${query}`);\n    let settled = false;\n\n    const settle = (callback: () => void) => {\n      if (settled) {\n        return;\n      }\n      settled = true;\n      eventSource.close();\n      callback();\n    };\n\n    eventSource.onmessage = (event) => {\n      try {\n        const payload = JSON.parse(event.data) as CloneProgressEvent;\n\n        if (payload.type === 'progress' && payload.message) {\n          handlers.onProgress(payload.message);\n          return;\n        }\n\n        if (payload.type === 'complete') {\n          settle(() => resolve(payload.project));\n          return;\n        }\n\n        if (payload.type === 'error') {\n          settle(() => reject(new Error(payload.message || 'Failed to clone repository')));\n        }\n      } catch (error) {\n        console.error('Error parsing clone progress event:', error);\n      }\n    };\n\n    eventSource.onerror = () => {\n      settle(() => reject(new Error('Connection lost during clone')));\n    };\n  });\n"
  },
  {
    "path": "src/components/project-creation-wizard/hooks/useGithubTokens.ts",
    "content": "import { useEffect, useMemo, useRef, useState } from 'react';\nimport { fetchGithubTokenCredentials } from '../data/workspaceApi';\nimport type { GithubTokenCredential } from '../types';\n\ntype UseGithubTokensParams = {\n  shouldLoad: boolean;\n  selectedTokenId: string;\n  onAutoSelectToken: (tokenId: string) => void;\n};\n\nexport const useGithubTokens = ({\n  shouldLoad,\n  selectedTokenId,\n  onAutoSelectToken,\n}: UseGithubTokensParams) => {\n  const [tokens, setTokens] = useState<GithubTokenCredential[]>([]);\n  const [loading, setLoading] = useState(false);\n  const [loadError, setLoadError] = useState<string | null>(null);\n  const hasLoadedRef = useRef(false);\n\n  useEffect(() => {\n    if (!shouldLoad || hasLoadedRef.current) {\n      return;\n    }\n\n    let isDisposed = false;\n\n    const loadTokens = async () => {\n      setLoading(true);\n      setLoadError(null);\n\n      try {\n        const activeTokens = await fetchGithubTokenCredentials();\n        if (isDisposed) {\n          return;\n        }\n\n        setTokens(activeTokens);\n        hasLoadedRef.current = true;\n\n        if (activeTokens.length > 0 && !selectedTokenId) {\n          onAutoSelectToken(String(activeTokens[0].id));\n        }\n      } catch (error) {\n        if (!isDisposed) {\n          setLoadError(error instanceof Error ? error.message : 'Failed to load GitHub tokens');\n        }\n      } finally {\n        if (!isDisposed) {\n          setLoading(false);\n        }\n      }\n    };\n\n    loadTokens();\n\n    return () => {\n      isDisposed = true;\n    };\n  }, [onAutoSelectToken, selectedTokenId, shouldLoad]);\n\n  const selectedTokenName = useMemo(\n    () => tokens.find((token) => String(token.id) === selectedTokenId)?.credential_name || null,\n    [selectedTokenId, tokens],\n  );\n\n  return {\n    tokens,\n    loading,\n    loadError,\n    selectedTokenName,\n  };\n};\n"
  },
  {
    "path": "src/components/project-creation-wizard/index.ts",
    "content": "export { default } from './ProjectCreationWizard';\n"
  },
  {
    "path": "src/components/project-creation-wizard/types.ts",
    "content": "export type WizardStep = 1 | 2 | 3;\n\nexport type WorkspaceType = 'existing' | 'new';\n\nexport type TokenMode = 'stored' | 'new' | 'none';\n\nexport type FolderSuggestion = {\n  name: string;\n  path: string;\n  type?: string;\n};\n\nexport type GithubTokenCredential = {\n  id: number;\n  credential_name: string;\n  is_active: boolean;\n};\n\nexport type CredentialsResponse = {\n  credentials?: GithubTokenCredential[];\n  error?: string;\n};\n\nexport type BrowseFilesystemResponse = {\n  path?: string;\n  suggestions?: FolderSuggestion[];\n  error?: string;\n};\n\nexport type CreateFolderResponse = {\n  success?: boolean;\n  path?: string;\n  error?: string;\n  details?: string;\n};\n\nexport type CreateWorkspacePayload = {\n  workspaceType: WorkspaceType;\n  path: string;\n};\n\nexport type CreateWorkspaceResponse = {\n  success?: boolean;\n  project?: Record<string, unknown>;\n  error?: string;\n  details?: string;\n};\n\nexport type CloneProgressEvent = {\n  type?: string;\n  message?: string;\n  project?: Record<string, unknown>;\n};\n\nexport type WizardFormState = {\n  workspaceType: WorkspaceType;\n  workspacePath: string;\n  githubUrl: string;\n  tokenMode: TokenMode;\n  selectedGithubToken: string;\n  newGithubToken: string;\n};\n"
  },
  {
    "path": "src/components/project-creation-wizard/utils/pathUtils.ts",
    "content": "import type { WorkspaceType } from '../types';\n\nconst SSH_PREFIXES = ['git@', 'ssh://'];\nconst WINDOWS_DRIVE_PATTERN = /^[A-Za-z]:\\\\?$/;\n\nexport const isSshGitUrl = (url: string): boolean => {\n  const trimmedUrl = url.trim();\n  return SSH_PREFIXES.some((prefix) => trimmedUrl.startsWith(prefix));\n};\n\nexport const shouldShowGithubAuthentication = (\n  workspaceType: WorkspaceType,\n  githubUrl: string,\n): boolean => workspaceType === 'new' && githubUrl.trim().length > 0 && !isSshGitUrl(githubUrl);\n\nexport const isCloneWorkflow = (workspaceType: WorkspaceType, githubUrl: string): boolean =>\n  workspaceType === 'new' && githubUrl.trim().length > 0;\n\nexport const getSuggestionRootPath = (inputPath: string): string => {\n  const trimmedPath = inputPath.trim();\n  const lastSeparatorIndex = Math.max(trimmedPath.lastIndexOf('/'), trimmedPath.lastIndexOf('\\\\'));\n  if (lastSeparatorIndex === 2 && /^[A-Za-z]:/.test(trimmedPath)) {\n    return `${trimmedPath.slice(0, 2)}\\\\`;\n  }\n\n  return lastSeparatorIndex > 0 ? trimmedPath.slice(0, lastSeparatorIndex) : '~';\n};\n\n// Handles root edge cases for Unix-like and Windows paths.\nexport const getParentPath = (currentPath: string): string | null => {\n  if (currentPath === '~' || currentPath === '/' || WINDOWS_DRIVE_PATTERN.test(currentPath)) {\n    return null;\n  }\n\n  const lastSeparatorIndex = Math.max(currentPath.lastIndexOf('/'), currentPath.lastIndexOf('\\\\'));\n  if (lastSeparatorIndex <= 0) {\n    return '/';\n  }\n\n  if (lastSeparatorIndex === 2 && /^[A-Za-z]:/.test(currentPath)) {\n    return `${currentPath.slice(0, 2)}\\\\`;\n  }\n\n  return currentPath.slice(0, lastSeparatorIndex);\n};\n\nexport const joinFolderPath = (basePath: string, folderName: string): string => {\n  const normalizedBasePath = basePath.trim().replace(/[\\\\/]+$/, '');\n  const separator =\n    normalizedBasePath.includes('\\\\') && !normalizedBasePath.includes('/') ? '\\\\' : '/';\n  return `${normalizedBasePath}${separator}${folderName.trim()}`;\n};\n"
  },
  {
    "path": "src/components/provider-auth/types.ts",
    "content": "export type CliProvider = 'claude' | 'cursor' | 'codex' | 'gemini';\n"
  },
  {
    "path": "src/components/provider-auth/view/ProviderLoginModal.tsx",
    "content": "import { ExternalLink, KeyRound, X } from 'lucide-react';\nimport StandaloneShell from '../../standalone-shell/view/StandaloneShell';\nimport { IS_PLATFORM } from '../../../constants/config';\nimport type { CliProvider } from '../types';\n\ntype LoginModalProject = {\n  name?: string;\n  displayName?: string;\n  fullPath?: string;\n  path?: string;\n  [key: string]: unknown;\n};\n\ntype ProviderLoginModalProps = {\n  isOpen: boolean;\n  onClose: () => void;\n  provider?: CliProvider;\n  project?: LoginModalProject | null;\n  onComplete?: (exitCode: number) => void;\n  customCommand?: string;\n  isAuthenticated?: boolean;\n};\n\nconst getProviderCommand = ({\n  provider,\n  customCommand,\n  isAuthenticated,\n}: {\n  provider: CliProvider;\n  customCommand?: string;\n  isAuthenticated: boolean;\n}) => {\n  if (customCommand) {\n    return customCommand;\n  }\n\n  if (provider === 'claude') {\n    if (isAuthenticated) {\n      return 'claude setup-token --dangerously-skip-permissions';\n    }\n    return 'claude /login --dangerously-skip-permissions';\n  }\n\n  if (provider === 'cursor') {\n    return 'cursor-agent login';\n  }\n\n  if (provider === 'codex') {\n    return IS_PLATFORM ? 'codex login --device-auth' : 'codex login';\n  }\n\n  return 'gemini status';\n};\n\nconst getProviderTitle = (provider: CliProvider) => {\n  if (provider === 'claude') return 'Claude CLI Login';\n  if (provider === 'cursor') return 'Cursor CLI Login';\n  if (provider === 'codex') return 'Codex CLI Login';\n  return 'Gemini CLI Configuration';\n};\n\nconst normalizeProject = (project?: LoginModalProject | null) => {\n  const normalizedName = project?.name || 'default';\n  const normalizedFullPath = project?.fullPath ?? project?.path ?? (IS_PLATFORM ? '/workspace' : '');\n\n  return {\n    name: normalizedName,\n    displayName: project?.displayName || normalizedName,\n    fullPath: normalizedFullPath,\n    path: project?.path ?? normalizedFullPath,\n  };\n};\n\nexport default function ProviderLoginModal({\n  isOpen,\n  onClose,\n  provider = 'claude',\n  project = null,\n  onComplete,\n  customCommand,\n  isAuthenticated = false,\n}: ProviderLoginModalProps) {\n  if (!isOpen) {\n    return null;\n  }\n\n  const command = getProviderCommand({ provider, customCommand, isAuthenticated });\n  const title = getProviderTitle(provider);\n  const shellProject = normalizeProject(project);\n\n  const handleComplete = (exitCode: number) => {\n    onComplete?.(exitCode);\n    // Keep the modal open so users can read terminal output before closing.\n  };\n\n  return (\n    <div className=\"fixed inset-0 z-[9999] flex items-center justify-center bg-black bg-opacity-50 max-md:items-stretch max-md:justify-stretch\">\n      <div className=\"flex h-3/4 w-full max-w-4xl flex-col rounded-lg bg-white shadow-xl dark:bg-gray-800 max-md:m-0 max-md:h-full max-md:max-w-none max-md:rounded-none md:m-4 md:h-3/4 md:max-w-4xl md:rounded-lg\">\n        <div className=\"flex items-center justify-between border-b border-gray-200 p-4 dark:border-gray-700\">\n          <h3 className=\"text-lg font-semibold text-gray-900 dark:text-white\">{title}</h3>\n          <button\n            onClick={onClose}\n            className=\"text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-gray-300\"\n            aria-label=\"Close login modal\"\n          >\n            <X className=\"h-6 w-6\" />\n          </button>\n        </div>\n\n        <div className=\"flex-1 overflow-hidden\">\n          {provider === 'gemini' ? (\n            <div className=\"flex h-full flex-col items-center justify-center bg-gray-50 p-8 text-center dark:bg-gray-900/50\">\n              <div className=\"mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/30\">\n                <KeyRound className=\"h-8 w-8 text-blue-600 dark:text-blue-400\" />\n              </div>\n\n              <h4 className=\"mb-3 text-xl font-medium text-gray-900 dark:text-white\">Setup Gemini API Access</h4>\n\n              <p className=\"mb-8 max-w-md text-gray-600 dark:text-gray-400\">\n                The Gemini CLI requires an API key to function. Configure it in your terminal first.\n              </p>\n\n              <div className=\"w-full max-w-lg rounded-xl border border-gray-200 bg-white p-6 text-left shadow-sm dark:border-gray-700 dark:bg-gray-800\">\n                <ol className=\"space-y-4\">\n                  <li className=\"flex gap-4\">\n                    <div className=\"flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 text-sm font-medium text-blue-600 dark:bg-blue-900/50 dark:text-blue-400\">\n                      1\n                    </div>\n                    <div>\n                      <p className=\"mb-1 text-sm font-medium text-gray-900 dark:text-white\">Get your API key</p>\n                      <a\n                        href=\"https://aistudio.google.com/app/apikey\"\n                        target=\"_blank\"\n                        rel=\"noreferrer\"\n                        className=\"flex inline-flex items-center gap-1 text-sm text-blue-600 hover:underline dark:text-blue-400\"\n                      >\n                        Google AI Studio <ExternalLink className=\"h-3 w-3\" />\n                      </a>\n                    </div>\n                  </li>\n                  <li className=\"flex gap-4\">\n                    <div className=\"flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 text-sm font-medium text-blue-600 dark:bg-blue-900/50 dark:text-blue-400\">\n                      2\n                    </div>\n                    <div>\n                      <p className=\"mb-1 text-sm font-medium text-gray-900 dark:text-white\">Run configuration</p>\n                      <p className=\"mb-2 text-sm text-gray-600 dark:text-gray-400\">Open your terminal and run:</p>\n                      <code className=\"block rounded bg-gray-100 px-3 py-2 font-mono text-sm text-pink-600 dark:bg-gray-900 dark:text-pink-400\">\n                        gemini config set api_key YOUR_KEY\n                      </code>\n                    </div>\n                  </li>\n                </ol>\n              </div>\n\n              <button\n                onClick={onClose}\n                className=\"mt-8 rounded-lg bg-blue-600 px-6 py-2.5 font-medium text-white transition-colors hover:bg-blue-700\"\n              >\n                Done\n              </button>\n            </div>\n          ) : (\n            <StandaloneShell project={shellProject} command={command} onComplete={handleComplete} minimal={true} />\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/quick-settings-panel/constants.ts",
    "content": "import {\n  ArrowDown,\n  Brain,\n  Eye,\n  FileText,\n  Languages,\n  Maximize2,\n  Mic,\n  Sparkles,\n} from 'lucide-react';\nimport type {\n  PreferenceToggleItem,\n  WhisperMode,\n  WhisperOption,\n} from './types';\n\nexport const HANDLE_POSITION_STORAGE_KEY = 'quickSettingsHandlePosition';\nexport const WHISPER_MODE_STORAGE_KEY = 'whisperMode';\nexport const WHISPER_MODE_CHANGED_EVENT = 'whisperModeChanged';\n\nexport const DEFAULT_HANDLE_POSITION = 50;\nexport const HANDLE_POSITION_MIN = 10;\nexport const HANDLE_POSITION_MAX = 90;\nexport const DRAG_THRESHOLD_PX = 5;\n\nexport const SETTING_ROW_CLASS =\n  'flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600';\n\nexport const TOGGLE_ROW_CLASS = `${SETTING_ROW_CLASS} cursor-pointer`;\n\nexport const CHECKBOX_CLASS =\n  'h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 focus:ring-2 dark:focus:ring-blue-400 bg-gray-100 dark:bg-gray-800 checked:bg-blue-600 dark:checked:bg-blue-600';\n\nexport const TOOL_DISPLAY_TOGGLES: PreferenceToggleItem[] = [\n  {\n    key: 'autoExpandTools',\n    labelKey: 'quickSettings.autoExpandTools',\n    icon: Maximize2,\n  },\n  {\n    key: 'showRawParameters',\n    labelKey: 'quickSettings.showRawParameters',\n    icon: Eye,\n  },\n  {\n    key: 'showThinking',\n    labelKey: 'quickSettings.showThinking',\n    icon: Brain,\n  },\n];\n\nexport const VIEW_OPTION_TOGGLES: PreferenceToggleItem[] = [\n  {\n    key: 'autoScrollToBottom',\n    labelKey: 'quickSettings.autoScrollToBottom',\n    icon: ArrowDown,\n  },\n];\n\nexport const INPUT_SETTING_TOGGLES: PreferenceToggleItem[] = [\n  {\n    key: 'sendByCtrlEnter',\n    labelKey: 'quickSettings.sendByCtrlEnter',\n    icon: Languages,\n  },\n];\n\nexport const WHISPER_OPTIONS: WhisperOption[] = [\n  {\n    value: 'default',\n    titleKey: 'quickSettings.whisper.modes.default',\n    descriptionKey: 'quickSettings.whisper.modes.defaultDescription',\n    icon: Mic,\n  },\n  {\n    value: 'prompt',\n    titleKey: 'quickSettings.whisper.modes.prompt',\n    descriptionKey: 'quickSettings.whisper.modes.promptDescription',\n    icon: Sparkles,\n  },\n  {\n    value: 'vibe',\n    titleKey: 'quickSettings.whisper.modes.vibe',\n    descriptionKey: 'quickSettings.whisper.modes.vibeDescription',\n    icon: FileText,\n  },\n];\n\nexport const VIBE_MODE_ALIASES: WhisperMode[] = [\n  'vibe',\n  'instructions',\n  'architect',\n];\n"
  },
  {
    "path": "src/components/quick-settings-panel/hooks/useQuickSettingsDrag.ts",
    "content": "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport type { MouseEvent as ReactMouseEvent, TouchEvent as ReactTouchEvent } from 'react';\nimport {\n  DEFAULT_HANDLE_POSITION,\n  DRAG_THRESHOLD_PX,\n  HANDLE_POSITION_MAX,\n  HANDLE_POSITION_MIN,\n  HANDLE_POSITION_STORAGE_KEY,\n} from '../constants';\nimport type { QuickSettingsHandleStyle } from '../types';\n\ntype UseQuickSettingsDragProps = {\n  isMobile: boolean;\n};\n\ntype StartDragEvent = ReactMouseEvent<HTMLButtonElement> | ReactTouchEvent<HTMLButtonElement>;\ntype MoveDragEvent = MouseEvent | TouchEvent;\ntype EventWithClientY = StartDragEvent | MoveDragEvent;\n\nconst clampPosition = (value: number): number => (\n  Math.max(HANDLE_POSITION_MIN, Math.min(HANDLE_POSITION_MAX, value))\n);\n\nconst readHandlePosition = (): number => {\n  if (typeof window === 'undefined') {\n    return DEFAULT_HANDLE_POSITION;\n  }\n\n  const saved = localStorage.getItem(HANDLE_POSITION_STORAGE_KEY);\n  if (!saved) {\n    return DEFAULT_HANDLE_POSITION;\n  }\n\n  try {\n    const parsed = JSON.parse(saved) as { y?: unknown };\n    if (typeof parsed.y === 'number' && Number.isFinite(parsed.y)) {\n      return clampPosition(parsed.y);\n    }\n  } catch {\n    localStorage.removeItem(HANDLE_POSITION_STORAGE_KEY);\n    return DEFAULT_HANDLE_POSITION;\n  }\n\n  return DEFAULT_HANDLE_POSITION;\n};\n\nconst isTouchEvent = (event: { type: string }): boolean => event.type.includes('touch');\n\nconst getClientY = (event: EventWithClientY): number | null => {\n  if ('touches' in event) {\n    return event.touches[0]?.clientY ?? null;\n  }\n\n  return 'clientY' in event && typeof event.clientY === 'number'\n    ? event.clientY\n    : null;\n};\n\nexport function useQuickSettingsDrag({ isMobile }: UseQuickSettingsDragProps) {\n  const [handlePosition, setHandlePosition] = useState<number>(readHandlePosition);\n  const [isPointerDown, setIsPointerDown] = useState(false);\n  const [isDragging, setIsDragging] = useState(false);\n\n  const dragStartYRef = useRef<number | null>(null);\n  const dragStartPositionRef = useRef(DEFAULT_HANDLE_POSITION);\n  const didDragRef = useRef(false);\n  const suppressNextClickRef = useRef(false);\n  const bodyStylesAppliedRef = useRef(false);\n\n  const clearBodyDragStyles = useCallback(() => {\n    if (!bodyStylesAppliedRef.current) {\n      return;\n    }\n\n    document.body.style.cursor = '';\n    document.body.style.userSelect = '';\n    document.body.style.overflow = '';\n    document.body.style.position = '';\n    document.body.style.width = '';\n    bodyStylesAppliedRef.current = false;\n  }, []);\n\n  const applyBodyDragStyles = useCallback((isTouchDragging: boolean) => {\n    if (bodyStylesAppliedRef.current) {\n      return;\n    }\n\n    document.body.style.cursor = 'grabbing';\n    document.body.style.userSelect = 'none';\n\n    // Touch drag should lock body scroll so the handle movement stays smooth.\n    if (isTouchDragging) {\n      document.body.style.overflow = 'hidden';\n      document.body.style.position = 'fixed';\n      document.body.style.width = '100%';\n    }\n\n    bodyStylesAppliedRef.current = true;\n  }, []);\n\n  const endDrag = useCallback(() => {\n    if (!isPointerDown && dragStartYRef.current === null) {\n      return;\n    }\n\n    suppressNextClickRef.current = didDragRef.current;\n    didDragRef.current = false;\n    dragStartYRef.current = null;\n    setIsPointerDown(false);\n    setIsDragging(false);\n    clearBodyDragStyles();\n  }, [clearBodyDragStyles, isPointerDown]);\n\n  const handleMove = useCallback(\n    (event: MoveDragEvent) => {\n      if (!isPointerDown || dragStartYRef.current === null) {\n        return;\n      }\n\n      const clientY = getClientY(event);\n      if (clientY === null) {\n        return;\n      }\n\n      const rawDelta = clientY - dragStartYRef.current;\n      const movedPastThreshold = Math.abs(rawDelta) > DRAG_THRESHOLD_PX;\n\n      if (!didDragRef.current && movedPastThreshold) {\n        didDragRef.current = true;\n        setIsDragging(true);\n        applyBodyDragStyles(isTouchEvent(event));\n      }\n\n      if (!didDragRef.current) {\n        return;\n      }\n\n      if (isTouchEvent(event)) {\n        event.preventDefault();\n      }\n\n      const viewportHeight = Math.max(window.innerHeight, 1);\n      const normalizedDelta = (rawDelta / viewportHeight) * 100;\n      const positionDelta = isMobile ? -normalizedDelta : normalizedDelta;\n      setHandlePosition(clampPosition(dragStartPositionRef.current + positionDelta));\n    },\n    [applyBodyDragStyles, isMobile, isPointerDown],\n  );\n\n  const startDrag = useCallback((event: StartDragEvent) => {\n    event.stopPropagation();\n\n    const clientY = getClientY(event);\n    if (clientY === null) {\n      return;\n    }\n\n    dragStartYRef.current = clientY;\n    dragStartPositionRef.current = handlePosition;\n    didDragRef.current = false;\n    setIsDragging(false);\n    setIsPointerDown(true);\n  }, [handlePosition]);\n\n  // Persist drag-handle position so users keep their preferred quick-access location.\n  useEffect(() => {\n    localStorage.setItem(\n      HANDLE_POSITION_STORAGE_KEY,\n      JSON.stringify({ y: handlePosition }),\n    );\n  }, [handlePosition]);\n\n  useEffect(() => {\n    if (!isPointerDown) {\n      return undefined;\n    }\n\n    const handleMouseMove = (event: MouseEvent) => {\n      handleMove(event);\n    };\n    const handleMouseUp = () => {\n      endDrag();\n    };\n    const handleTouchMove = (event: TouchEvent) => {\n      handleMove(event);\n    };\n    const handleTouchEnd = () => {\n      endDrag();\n    };\n\n    document.addEventListener('mousemove', handleMouseMove);\n    document.addEventListener('mouseup', handleMouseUp);\n    document.addEventListener('touchmove', handleTouchMove, { passive: false });\n    document.addEventListener('touchend', handleTouchEnd);\n\n    return () => {\n      document.removeEventListener('mousemove', handleMouseMove);\n      document.removeEventListener('mouseup', handleMouseUp);\n      document.removeEventListener('touchmove', handleTouchMove);\n      document.removeEventListener('touchend', handleTouchEnd);\n    };\n  }, [endDrag, handleMove, isPointerDown]);\n\n  useEffect(() => (\n    () => {\n      clearBodyDragStyles();\n    }\n  ), [clearBodyDragStyles]);\n\n  const consumeSuppressedClick = useCallback((): boolean => {\n    if (!suppressNextClickRef.current) {\n      return false;\n    }\n\n    suppressNextClickRef.current = false;\n    return true;\n  }, []);\n\n  const handleStyle = useMemo<QuickSettingsHandleStyle>(() => {\n    if (!isMobile || typeof window === 'undefined') {\n      return {\n        top: `${handlePosition}%`,\n        transform: 'translateY(-50%)',\n      };\n    }\n\n    return {\n      bottom: `${(window.innerHeight * handlePosition) / 100}px`,\n    };\n  }, [handlePosition, isMobile]);\n\n  return {\n    isDragging,\n    handleStyle,\n    startDrag,\n    endDrag,\n    consumeSuppressedClick,\n  };\n}\n"
  },
  {
    "path": "src/components/quick-settings-panel/hooks/useWhisperMode.ts",
    "content": "import { useCallback, useState } from 'react';\nimport {\n  VIBE_MODE_ALIASES,\n  WHISPER_MODE_CHANGED_EVENT,\n  WHISPER_MODE_STORAGE_KEY,\n} from '../constants';\nimport type { WhisperMode, WhisperOptionValue } from '../types';\n\nconst ALL_VALID_MODES: WhisperMode[] = [\n  'default',\n  'prompt',\n  'vibe',\n  'instructions',\n  'architect',\n];\n\nconst isWhisperMode = (value: string): value is WhisperMode => (\n  ALL_VALID_MODES.includes(value as WhisperMode)\n);\n\nconst readStoredMode = (): WhisperMode => {\n  if (typeof window === 'undefined') {\n    return 'default';\n  }\n\n  const storedValue = localStorage.getItem(WHISPER_MODE_STORAGE_KEY);\n  if (!storedValue) {\n    return 'default';\n  }\n\n  return isWhisperMode(storedValue) ? storedValue : 'default';\n};\n\nexport function useWhisperMode() {\n  const [whisperMode, setWhisperModeState] = useState<WhisperMode>(readStoredMode);\n\n  const setWhisperMode = useCallback((value: WhisperOptionValue) => {\n    setWhisperModeState(value);\n    localStorage.setItem(WHISPER_MODE_STORAGE_KEY, value);\n    window.dispatchEvent(new Event(WHISPER_MODE_CHANGED_EVENT));\n  }, []);\n\n  const isOptionSelected = useCallback(\n    (value: WhisperOptionValue) => {\n      if (value === 'vibe') {\n        return VIBE_MODE_ALIASES.includes(whisperMode);\n      }\n\n      return whisperMode === value;\n    },\n    [whisperMode],\n  );\n\n  return {\n    whisperMode,\n    setWhisperMode,\n    isOptionSelected,\n  };\n}\n"
  },
  {
    "path": "src/components/quick-settings-panel/index.ts",
    "content": "export { default as QuickSettingsPanel } from './view/QuickSettingsPanelView';"
  },
  {
    "path": "src/components/quick-settings-panel/types.ts",
    "content": "import type { CSSProperties } from 'react';\nimport type { LucideIcon } from 'lucide-react';\n\nexport type PreferenceToggleKey =\n  | 'autoExpandTools'\n  | 'showRawParameters'\n  | 'showThinking'\n  | 'autoScrollToBottom'\n  | 'sendByCtrlEnter';\n\nexport type QuickSettingsPreferences = Record<PreferenceToggleKey, boolean>;\n\nexport type PreferenceToggleItem = {\n  key: PreferenceToggleKey;\n  labelKey: string;\n  icon: LucideIcon;\n};\n\nexport type WhisperMode =\n  | 'default'\n  | 'prompt'\n  | 'vibe'\n  | 'instructions'\n  | 'architect';\n\nexport type WhisperOptionValue = 'default' | 'prompt' | 'vibe';\n\nexport type WhisperOption = {\n  value: WhisperOptionValue;\n  titleKey: string;\n  descriptionKey: string;\n  icon: LucideIcon;\n};\n\nexport type QuickSettingsHandleStyle = CSSProperties;\n"
  },
  {
    "path": "src/components/quick-settings-panel/view/QuickSettingsContent.tsx",
    "content": "import { Moon, Sun } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { DarkModeToggle } from '../../../shared/view/ui';\nimport LanguageSelector from '../../../shared/view/ui/LanguageSelector';\nimport {\n  INPUT_SETTING_TOGGLES,\n  SETTING_ROW_CLASS,\n  TOOL_DISPLAY_TOGGLES,\n  VIEW_OPTION_TOGGLES,\n} from '../constants';\nimport type {\n  PreferenceToggleItem,\n  PreferenceToggleKey,\n  QuickSettingsPreferences,\n} from '../types';\nimport QuickSettingsSection from './QuickSettingsSection';\nimport QuickSettingsToggleRow from './QuickSettingsToggleRow';\nimport QuickSettingsWhisperSection from './QuickSettingsWhisperSection';\n\ntype QuickSettingsContentProps = {\n  isDarkMode: boolean;\n  isMobile: boolean;\n  preferences: QuickSettingsPreferences;\n  onPreferenceChange: (key: PreferenceToggleKey, value: boolean) => void;\n};\n\nexport default function QuickSettingsContent({\n  isDarkMode,\n  isMobile,\n  preferences,\n  onPreferenceChange,\n}: QuickSettingsContentProps) {\n  const { t } = useTranslation('settings');\n\n  const renderToggleRows = (items: PreferenceToggleItem[]) => (\n    items.map(({ key, labelKey, icon }) => (\n      <QuickSettingsToggleRow\n        key={key}\n        label={t(labelKey)}\n        icon={icon}\n        checked={preferences[key]}\n        onCheckedChange={(value) => onPreferenceChange(key, value)}\n      />\n    ))\n  );\n\n  return (\n    <div className={`flex-1 space-y-6 overflow-y-auto overflow-x-hidden bg-background p-4 ${isMobile ? 'pb-mobile-nav' : ''}`}>\n      <QuickSettingsSection title={t('quickSettings.sections.appearance')}>\n        <div className={SETTING_ROW_CLASS}>\n          <span className=\"flex items-center gap-2 text-sm text-gray-900 dark:text-white\">\n            {isDarkMode ? (\n              <Moon className=\"h-4 w-4 text-gray-600 dark:text-gray-400\" />\n            ) : (\n              <Sun className=\"h-4 w-4 text-gray-600 dark:text-gray-400\" />\n            )}\n            {t('quickSettings.darkMode')}\n          </span>\n          <DarkModeToggle />\n        </div>\n        <LanguageSelector compact />\n      </QuickSettingsSection>\n\n      <QuickSettingsSection title={t('quickSettings.sections.toolDisplay')}>\n        {renderToggleRows(TOOL_DISPLAY_TOGGLES)}\n      </QuickSettingsSection>\n\n      <QuickSettingsSection title={t('quickSettings.sections.viewOptions')}>\n        {renderToggleRows(VIEW_OPTION_TOGGLES)}\n      </QuickSettingsSection>\n\n      <QuickSettingsSection title={t('quickSettings.sections.inputSettings')}>\n        {renderToggleRows(INPUT_SETTING_TOGGLES)}\n        <p className=\"ml-3 text-xs text-gray-500 dark:text-gray-400\">\n          {t('quickSettings.sendByCtrlEnterDescription')}\n        </p>\n      </QuickSettingsSection>\n\n      <QuickSettingsWhisperSection />\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/quick-settings-panel/view/QuickSettingsHandle.tsx",
    "content": "import type {\n  MouseEvent as ReactMouseEvent,\n  TouchEvent as ReactTouchEvent,\n} from 'react';\nimport {\n  ChevronLeft,\n  ChevronRight,\n  GripVertical,\n} from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport type { QuickSettingsHandleStyle } from '../types';\n\ntype QuickSettingsHandleProps = {\n  isOpen: boolean;\n  isDragging: boolean;\n  style: QuickSettingsHandleStyle;\n  onClick: (event: ReactMouseEvent<HTMLButtonElement>) => void;\n  onMouseDown: (event: ReactMouseEvent<HTMLButtonElement>) => void;\n  onTouchStart: (event: ReactTouchEvent<HTMLButtonElement>) => void;\n};\n\nexport default function QuickSettingsHandle({\n  isOpen,\n  isDragging,\n  style,\n  onClick,\n  onMouseDown,\n  onTouchStart,\n}: QuickSettingsHandleProps) {\n  const { t } = useTranslation('settings');\n\n  const placementClass = isOpen ? 'right-64' : 'right-0';\n  const borderClass = isDragging\n    ? 'border-blue-500 dark:border-blue-400'\n    : 'border-gray-200 dark:border-gray-700';\n  const transitionClass = isDragging\n    ? ''\n    : 'transition-all duration-150 ease-out';\n  const cursorClass = isDragging ? 'cursor-grabbing' : 'cursor-pointer';\n  const ariaLabel = isDragging\n    ? t('quickSettings.dragHandle.dragging')\n    : isOpen\n      ? t('quickSettings.dragHandle.closePanel')\n      : t('quickSettings.dragHandle.openPanel');\n  const title = isDragging\n    ? t('quickSettings.dragHandle.draggingStatus')\n    : t('quickSettings.dragHandle.toggleAndMove');\n\n  return (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      onMouseDown={onMouseDown}\n      onTouchStart={onTouchStart}\n      className={`fixed ${placementClass} z-50 ${transitionClass} border bg-white dark:bg-gray-800 ${borderClass} rounded-l-md p-2 shadow-lg transition-colors hover:bg-gray-100 dark:hover:bg-gray-700 ${cursorClass} touch-none`}\n      style={{\n        ...style,\n        touchAction: 'none',\n        WebkitTouchCallout: 'none',\n        WebkitUserSelect: 'none',\n      }}\n      aria-label={ariaLabel}\n      title={title}\n    >\n      {isDragging ? (\n        <GripVertical className=\"h-5 w-5 text-blue-500 dark:text-blue-400\" />\n      ) : isOpen ? (\n        <ChevronRight className=\"h-5 w-5 text-gray-600 dark:text-gray-400\" />\n      ) : (\n        <ChevronLeft className=\"h-5 w-5 text-gray-600 dark:text-gray-400\" />\n      )}\n    </button>\n  );\n}\n"
  },
  {
    "path": "src/components/quick-settings-panel/view/QuickSettingsPanelHeader.tsx",
    "content": "import { Settings2 } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\n\nexport default function QuickSettingsPanelHeader() {\n  const { t } = useTranslation('settings');\n\n  return (\n    <div className=\"border-b border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900\">\n      <h3 className=\"flex items-center gap-2 text-lg font-semibold text-gray-900 dark:text-white\">\n        <Settings2 className=\"h-5 w-5 text-gray-600 dark:text-gray-400\" />\n        {t('quickSettings.title')}\n      </h3>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/quick-settings-panel/view/QuickSettingsPanelView.tsx",
    "content": "import { useCallback, useMemo, useState } from 'react';\nimport type { MouseEvent as ReactMouseEvent } from 'react';\nimport { useDeviceSettings } from '../../../hooks/useDeviceSettings';\nimport { useUiPreferences } from '../../../hooks/useUiPreferences';\nimport { useTheme } from '../../../contexts/ThemeContext';\nimport { useQuickSettingsDrag } from '../hooks/useQuickSettingsDrag';\nimport type { PreferenceToggleKey, QuickSettingsPreferences } from '../types';\nimport QuickSettingsContent from './QuickSettingsContent';\nimport QuickSettingsHandle from './QuickSettingsHandle';\nimport QuickSettingsPanelHeader from './QuickSettingsPanelHeader';\n\nexport default function QuickSettingsPanelView() {\n  const [isOpen, setIsOpen] = useState(false);\n  const { isMobile } = useDeviceSettings({ trackPWA: false });\n  const { isDarkMode } = useTheme();\n  const { preferences, setPreference } = useUiPreferences();\n  const {\n    isDragging,\n    handleStyle,\n    startDrag,\n    consumeSuppressedClick,\n  } = useQuickSettingsDrag({ isMobile });\n\n  const quickSettingsPreferences = useMemo<QuickSettingsPreferences>(() => ({\n    autoExpandTools: preferences.autoExpandTools,\n    showRawParameters: preferences.showRawParameters,\n    showThinking: preferences.showThinking,\n    autoScrollToBottom: preferences.autoScrollToBottom,\n    sendByCtrlEnter: preferences.sendByCtrlEnter,\n  }), [\n    preferences.autoExpandTools,\n    preferences.autoScrollToBottom,\n    preferences.sendByCtrlEnter,\n    preferences.showRawParameters,\n    preferences.showThinking,\n  ]);\n\n  const handlePreferenceChange = useCallback(\n    (key: PreferenceToggleKey, value: boolean) => {\n      setPreference(key, value);\n    },\n    [setPreference],\n  );\n\n  const handleToggleFromHandle = useCallback(\n    (event: ReactMouseEvent<HTMLButtonElement>) => {\n      // A drag releases a click event as well; this guard prevents accidental toggles.\n      if (consumeSuppressedClick()) {\n        event.preventDefault();\n        return;\n      }\n\n      setIsOpen((previous) => !previous);\n    },\n    [consumeSuppressedClick],\n  );\n\n  return (\n    <>\n      <QuickSettingsHandle\n        isOpen={isOpen}\n        isDragging={isDragging}\n        style={handleStyle}\n        onClick={handleToggleFromHandle}\n        onMouseDown={startDrag}\n        onTouchStart={startDrag}\n      />\n\n      <div\n        className={`fixed right-0 top-0 z-40 h-full w-64 transform border-l border-border bg-background shadow-xl transition-transform duration-150 ease-out ${isOpen ? 'translate-x-0' : 'translate-x-full'} ${isMobile ? 'h-screen' : ''}`}\n      >\n        <div className=\"flex h-full flex-col\">\n          <QuickSettingsPanelHeader />\n          <QuickSettingsContent\n            isDarkMode={isDarkMode}\n            isMobile={isMobile}\n            preferences={quickSettingsPreferences}\n            onPreferenceChange={handlePreferenceChange}\n          />\n        </div>\n      </div>\n\n      {isOpen && (\n        <div\n          className=\"fixed inset-0 z-30 bg-background/80 backdrop-blur-sm transition-opacity duration-150 ease-out\"\n          onClick={() => setIsOpen(false)}\n        />\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "src/components/quick-settings-panel/view/QuickSettingsSection.tsx",
    "content": "import type { ReactNode } from 'react';\n\ntype QuickSettingsSectionProps = {\n  title: string;\n  children: ReactNode;\n  className?: string;\n};\n\nexport default function QuickSettingsSection({\n  title,\n  children,\n  className = '',\n}: QuickSettingsSectionProps) {\n  return (\n    <div className={`space-y-2 ${className}`}>\n      <h4 className=\"mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400\">\n        {title}\n      </h4>\n      {children}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/quick-settings-panel/view/QuickSettingsToggleRow.tsx",
    "content": "import { memo } from 'react';\nimport type { LucideIcon } from 'lucide-react';\nimport { CHECKBOX_CLASS, TOGGLE_ROW_CLASS } from '../constants';\n\ntype QuickSettingsToggleRowProps = {\n  label: string;\n  icon: LucideIcon;\n  checked: boolean;\n  onCheckedChange: (checked: boolean) => void;\n};\n\nfunction QuickSettingsToggleRow({\n  label,\n  icon: Icon,\n  checked,\n  onCheckedChange,\n}: QuickSettingsToggleRowProps) {\n  return (\n    <label className={TOGGLE_ROW_CLASS}>\n      <span className=\"flex items-center gap-2 text-sm text-gray-900 dark:text-white\">\n        <Icon className=\"h-4 w-4 text-gray-600 dark:text-gray-400\" />\n        {label}\n      </span>\n      <input\n        type=\"checkbox\"\n        checked={checked}\n        onChange={(event) => onCheckedChange(event.target.checked)}\n        className={CHECKBOX_CLASS}\n      />\n    </label>\n  );\n}\n\nexport default memo(QuickSettingsToggleRow);\n"
  },
  {
    "path": "src/components/quick-settings-panel/view/QuickSettingsWhisperSection.tsx",
    "content": "import { useTranslation } from 'react-i18next';\nimport { TOGGLE_ROW_CLASS, WHISPER_OPTIONS } from '../constants';\nimport { useWhisperMode } from '../hooks/useWhisperMode';\nimport QuickSettingsSection from './QuickSettingsSection';\n\nexport default function QuickSettingsWhisperSection() {\n  const { t } = useTranslation('settings');\n  const { setWhisperMode, isOptionSelected } = useWhisperMode();\n\n  return (\n    // This section stays hidden intentionally until dictation modes are reintroduced.\n    <QuickSettingsSection\n      title={t('quickSettings.sections.whisperDictation')}\n      className=\"hidden\"\n    >\n      <div className=\"space-y-2\">\n        {WHISPER_OPTIONS.map(({ value, icon: Icon, titleKey, descriptionKey }) => (\n          <label\n            key={value}\n            className={`${TOGGLE_ROW_CLASS} flex items-start`}\n          >\n            <input\n              type=\"radio\"\n              name=\"whisperMode\"\n              value={value}\n              checked={isOptionSelected(value)}\n              onChange={() => setWhisperMode(value)}\n              className=\"mt-0.5 h-4 w-4 border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-blue-500 dark:checked:bg-blue-600 dark:focus:ring-blue-400\"\n            />\n            <div className=\"ml-3 flex-1\">\n              <span className=\"flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white\">\n                <Icon className=\"h-4 w-4 text-gray-600 dark:text-gray-400\" />\n                {t(titleKey)}\n              </span>\n              <p className=\"mt-1 text-xs text-gray-500 dark:text-gray-400\">\n                {t(descriptionKey)}\n              </p>\n            </div>\n          </label>\n        ))}\n      </div>\n    </QuickSettingsSection>\n  );\n}\n"
  },
  {
    "path": "src/components/settings/constants/constants.ts",
    "content": "import type {\n  AgentCategory,\n  AgentProvider,\n  AuthStatus,\n  ClaudeMcpFormState,\n  CodexMcpFormState,\n  CodeEditorSettingsState,\n  CursorPermissionsState,\n  McpToolsResult,\n  McpTestResult,\n  ProjectSortOrder,\n  SettingsMainTab,\n} from '../types/types';\n\nexport const SETTINGS_MAIN_TABS: SettingsMainTab[] = [\n  'agents',\n  'appearance',\n  'git',\n  'api',\n  'tasks',\n  'notifications',\n];\n\nexport const AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex'];\nexport const AGENT_CATEGORIES: AgentCategory[] = ['account', 'permissions', 'mcp'];\n\nexport const DEFAULT_PROJECT_SORT_ORDER: ProjectSortOrder = 'name';\nexport const DEFAULT_SAVE_STATUS = null;\nexport const DEFAULT_CODE_EDITOR_SETTINGS: CodeEditorSettingsState = {\n  theme: 'dark',\n  wordWrap: false,\n  showMinimap: true,\n  lineNumbers: true,\n  fontSize: '14',\n};\n\nexport const DEFAULT_AUTH_STATUS: AuthStatus = {\n  authenticated: false,\n  email: null,\n  loading: true,\n  error: null,\n};\n\nexport const DEFAULT_MCP_TEST_RESULT: McpTestResult = {\n  success: false,\n  message: '',\n  details: [],\n  loading: false,\n};\n\nexport const DEFAULT_MCP_TOOLS_RESULT: McpToolsResult = {\n  success: false,\n  tools: [],\n  resources: [],\n  prompts: [],\n};\n\nexport const DEFAULT_CLAUDE_MCP_FORM: ClaudeMcpFormState = {\n  name: '',\n  type: 'stdio',\n  scope: 'user',\n  projectPath: '',\n  config: {\n    command: '',\n    args: [],\n    env: {},\n    url: '',\n    headers: {},\n    timeout: 30000,\n  },\n  importMode: 'form',\n  jsonInput: '',\n};\n\nexport const DEFAULT_CODEX_MCP_FORM: CodexMcpFormState = {\n  name: '',\n  type: 'stdio',\n  config: {\n    command: '',\n    args: [],\n    env: {},\n  },\n};\n\nexport const DEFAULT_CURSOR_PERMISSIONS: CursorPermissionsState = {\n  allowedCommands: [],\n  disallowedCommands: [],\n  skipPermissions: false,\n};\n\nexport const AUTH_STATUS_ENDPOINTS: Record<AgentProvider, string> = {\n  claude: '/api/cli/claude/status',\n  cursor: '/api/cli/cursor/status',\n  codex: '/api/cli/codex/status',\n  gemini: '/api/cli/gemini/status',\n};\n"
  },
  {
    "path": "src/components/settings/hooks/useCredentialsSettings.ts",
    "content": "import { useCallback, useEffect, useState } from 'react';\nimport { authenticatedFetch } from '../../../utils/api';\nimport type {\n  ApiKeyItem,\n  ApiKeysResponse,\n  CreatedApiKey,\n  GithubCredentialItem,\n  GithubCredentialsResponse,\n} from '../view/tabs/api-settings/types';\nimport { copyTextToClipboard } from '../../../utils/clipboard';\n\ntype UseCredentialsSettingsArgs = {\n  confirmDeleteApiKeyText: string;\n  confirmDeleteGithubCredentialText: string;\n};\n\nconst getApiError = (payload: { error?: string } | undefined, fallback: string) => (\n  payload?.error || fallback\n);\n\nexport function useCredentialsSettings({\n  confirmDeleteApiKeyText,\n  confirmDeleteGithubCredentialText,\n}: UseCredentialsSettingsArgs) {\n  const [apiKeys, setApiKeys] = useState<ApiKeyItem[]>([]);\n  const [githubCredentials, setGithubCredentials] = useState<GithubCredentialItem[]>([]);\n  const [loading, setLoading] = useState(true);\n\n  const [showNewKeyForm, setShowNewKeyForm] = useState(false);\n  const [newKeyName, setNewKeyName] = useState('');\n\n  const [showNewGithubForm, setShowNewGithubForm] = useState(false);\n  const [newGithubName, setNewGithubName] = useState('');\n  const [newGithubToken, setNewGithubToken] = useState('');\n  const [newGithubDescription, setNewGithubDescription] = useState('');\n\n  const [showToken, setShowToken] = useState<Record<string, boolean>>({});\n  const [copiedKey, setCopiedKey] = useState<string | null>(null);\n  const [newlyCreatedKey, setNewlyCreatedKey] = useState<CreatedApiKey | null>(null);\n\n  const fetchData = useCallback(async () => {\n    try {\n      setLoading(true);\n\n      const [apiKeysResponse, credentialsResponse] = await Promise.all([\n        authenticatedFetch('/api/settings/api-keys'),\n        authenticatedFetch('/api/settings/credentials?type=github_token'),\n      ]);\n\n      const [apiKeysPayload, credentialsPayload] = await Promise.all([\n        apiKeysResponse.json() as Promise<ApiKeysResponse>,\n        credentialsResponse.json() as Promise<GithubCredentialsResponse>,\n      ]);\n\n      setApiKeys(apiKeysPayload.apiKeys || []);\n      setGithubCredentials(credentialsPayload.credentials || []);\n    } catch (error) {\n      console.error('Error fetching settings:', error);\n    } finally {\n      setLoading(false);\n    }\n  }, []);\n\n  const createApiKey = useCallback(async () => {\n    if (!newKeyName.trim()) {\n      return;\n    }\n\n    try {\n      const response = await authenticatedFetch('/api/settings/api-keys', {\n        method: 'POST',\n        body: JSON.stringify({ keyName: newKeyName.trim() }),\n      });\n\n      const payload = await response.json() as ApiKeysResponse;\n      if (!response.ok || !payload.success) {\n        console.error('Error creating API key:', getApiError(payload, 'Failed to create API key'));\n        return;\n      }\n\n      if (payload.apiKey) {\n        setNewlyCreatedKey(payload.apiKey);\n      }\n      setNewKeyName('');\n      setShowNewKeyForm(false);\n      await fetchData();\n    } catch (error) {\n      console.error('Error creating API key:', error);\n    }\n  }, [fetchData, newKeyName]);\n\n  const deleteApiKey = useCallback(async (keyId: string) => {\n    if (!window.confirm(confirmDeleteApiKeyText)) {\n      return;\n    }\n\n    try {\n      const response = await authenticatedFetch(`/api/settings/api-keys/${keyId}`, {\n        method: 'DELETE',\n      });\n\n      if (!response.ok) {\n        const payload = await response.json() as ApiKeysResponse;\n        console.error('Error deleting API key:', getApiError(payload, 'Failed to delete API key'));\n        return;\n      }\n\n      await fetchData();\n    } catch (error) {\n      console.error('Error deleting API key:', error);\n    }\n  }, [confirmDeleteApiKeyText, fetchData]);\n\n  const toggleApiKey = useCallback(async (keyId: string, isActive: boolean) => {\n    try {\n      const response = await authenticatedFetch(`/api/settings/api-keys/${keyId}/toggle`, {\n        method: 'PATCH',\n        body: JSON.stringify({ isActive: !isActive }),\n      });\n\n      if (!response.ok) {\n        const payload = await response.json() as ApiKeysResponse;\n        console.error('Error toggling API key:', getApiError(payload, 'Failed to toggle API key'));\n        return;\n      }\n\n      await fetchData();\n    } catch (error) {\n      console.error('Error toggling API key:', error);\n    }\n  }, [fetchData]);\n\n  const createGithubCredential = useCallback(async () => {\n    if (!newGithubName.trim() || !newGithubToken.trim()) {\n      return;\n    }\n\n    try {\n      const response = await authenticatedFetch('/api/settings/credentials', {\n        method: 'POST',\n        body: JSON.stringify({\n          credentialName: newGithubName.trim(),\n          credentialType: 'github_token',\n          credentialValue: newGithubToken,\n          description: newGithubDescription.trim(),\n        }),\n      });\n\n      const payload = await response.json() as GithubCredentialsResponse;\n      if (!response.ok || !payload.success) {\n        console.error('Error creating GitHub credential:', getApiError(payload, 'Failed to create GitHub credential'));\n        return;\n      }\n\n      setNewGithubName('');\n      setNewGithubToken('');\n      setNewGithubDescription('');\n      setShowNewGithubForm(false);\n      setShowToken((prev) => ({ ...prev, new: false }));\n      await fetchData();\n    } catch (error) {\n      console.error('Error creating GitHub credential:', error);\n    }\n  }, [fetchData, newGithubDescription, newGithubName, newGithubToken]);\n\n  const deleteGithubCredential = useCallback(async (credentialId: string) => {\n    if (!window.confirm(confirmDeleteGithubCredentialText)) {\n      return;\n    }\n\n    try {\n      const response = await authenticatedFetch(`/api/settings/credentials/${credentialId}`, {\n        method: 'DELETE',\n      });\n\n      if (!response.ok) {\n        const payload = await response.json() as GithubCredentialsResponse;\n        console.error('Error deleting GitHub credential:', getApiError(payload, 'Failed to delete GitHub credential'));\n        return;\n      }\n\n      await fetchData();\n    } catch (error) {\n      console.error('Error deleting GitHub credential:', error);\n    }\n  }, [confirmDeleteGithubCredentialText, fetchData]);\n\n  const toggleGithubCredential = useCallback(async (credentialId: string, isActive: boolean) => {\n    try {\n      const response = await authenticatedFetch(`/api/settings/credentials/${credentialId}/toggle`, {\n        method: 'PATCH',\n        body: JSON.stringify({ isActive: !isActive }),\n      });\n\n      if (!response.ok) {\n        const payload = await response.json() as GithubCredentialsResponse;\n        console.error('Error toggling GitHub credential:', getApiError(payload, 'Failed to toggle GitHub credential'));\n        return;\n      }\n\n      await fetchData();\n    } catch (error) {\n      console.error('Error toggling GitHub credential:', error);\n    }\n  }, [fetchData]);\n\n  const copyToClipboard = useCallback(async (text: string, id: string) => {\n    try {\n      await copyTextToClipboard(text);\n      setCopiedKey(id);\n      window.setTimeout(() => setCopiedKey(null), 2000);\n    } catch (error) {\n      console.error('Failed to copy to clipboard:', error);\n    }\n  }, []);\n\n  const dismissNewlyCreatedKey = useCallback(() => {\n    setNewlyCreatedKey(null);\n  }, []);\n\n  const cancelNewApiKeyForm = useCallback(() => {\n    setShowNewKeyForm(false);\n    setNewKeyName('');\n  }, []);\n\n  const cancelNewGithubForm = useCallback(() => {\n    setShowNewGithubForm(false);\n    setNewGithubName('');\n    setNewGithubToken('');\n    setNewGithubDescription('');\n    setShowToken((prev) => ({ ...prev, new: false }));\n  }, []);\n\n  const toggleNewGithubTokenVisibility = useCallback(() => {\n    setShowToken((prev) => ({ ...prev, new: !prev.new }));\n  }, []);\n\n  useEffect(() => {\n    void fetchData();\n  }, [fetchData]);\n\n  return {\n    apiKeys,\n    githubCredentials,\n    loading,\n    showNewKeyForm,\n    setShowNewKeyForm,\n    newKeyName,\n    setNewKeyName,\n    showNewGithubForm,\n    setShowNewGithubForm,\n    newGithubName,\n    setNewGithubName,\n    newGithubToken,\n    setNewGithubToken,\n    newGithubDescription,\n    setNewGithubDescription,\n    showToken,\n    copiedKey,\n    newlyCreatedKey,\n    createApiKey,\n    deleteApiKey,\n    toggleApiKey,\n    createGithubCredential,\n    deleteGithubCredential,\n    toggleGithubCredential,\n    copyToClipboard,\n    dismissNewlyCreatedKey,\n    cancelNewApiKeyForm,\n    cancelNewGithubForm,\n    toggleNewGithubTokenVisibility,\n  };\n}\n"
  },
  {
    "path": "src/components/settings/hooks/useGitSettings.ts",
    "content": "import { useCallback, useEffect, useRef, useState } from 'react';\nimport { authenticatedFetch } from '../../../utils/api';\n\ntype GitConfigResponse = {\n  gitName?: string;\n  gitEmail?: string;\n  error?: string;\n};\n\ntype SaveStatus = 'success' | 'error' | null;\n\nexport function useGitSettings() {\n  const [gitName, setGitName] = useState('');\n  const [gitEmail, setGitEmail] = useState('');\n  const [isLoading, setIsLoading] = useState(false);\n  const [isSaving, setIsSaving] = useState(false);\n  const [saveStatus, setSaveStatus] = useState<SaveStatus>(null);\n  const clearStatusTimerRef = useRef<number | null>(null);\n\n  const clearSaveStatus = useCallback(() => {\n    if (clearStatusTimerRef.current !== null) {\n      window.clearTimeout(clearStatusTimerRef.current);\n      clearStatusTimerRef.current = null;\n    }\n    setSaveStatus(null);\n  }, []);\n\n  const loadGitConfig = useCallback(async () => {\n    try {\n      setIsLoading(true);\n      const response = await authenticatedFetch('/api/user/git-config');\n      if (!response.ok) {\n        return;\n      }\n\n      const data = await response.json() as GitConfigResponse;\n      setGitName(data.gitName || '');\n      setGitEmail(data.gitEmail || '');\n    } catch (error) {\n      console.error('Error loading git config:', error);\n    } finally {\n      setIsLoading(false);\n    }\n  }, []);\n\n  const saveGitConfig = useCallback(async () => {\n    try {\n      setIsSaving(true);\n      const response = await authenticatedFetch('/api/user/git-config', {\n        method: 'POST',\n        headers: { 'Content-Type': 'application/json' },\n        body: JSON.stringify({ gitName, gitEmail }),\n      });\n\n      if (response.ok) {\n        setSaveStatus('success');\n        clearStatusTimerRef.current = window.setTimeout(() => {\n          setSaveStatus(null);\n          clearStatusTimerRef.current = null;\n        }, 3000);\n        return;\n      }\n\n      const data = await response.json() as GitConfigResponse;\n      console.error('Failed to save git config:', data.error);\n      setSaveStatus('error');\n    } catch (error) {\n      console.error('Error saving git config:', error);\n      setSaveStatus('error');\n    } finally {\n      setIsSaving(false);\n    }\n  }, [gitEmail, gitName]);\n\n  useEffect(() => {\n    void loadGitConfig();\n  }, [loadGitConfig]);\n\n  useEffect(() => () => {\n    if (clearStatusTimerRef.current !== null) {\n      window.clearTimeout(clearStatusTimerRef.current);\n    }\n  }, []);\n\n  return {\n    gitName,\n    setGitName,\n    gitEmail,\n    setGitEmail,\n    isLoading,\n    isSaving,\n    saveStatus,\n    clearSaveStatus,\n    saveGitConfig,\n  };\n}\n"
  },
  {
    "path": "src/components/settings/hooks/useSettingsController.ts",
    "content": "import { useCallback, useEffect, useRef, useState } from 'react';\nimport { useTheme } from '../../../contexts/ThemeContext';\nimport { authenticatedFetch } from '../../../utils/api';\nimport {\n  AUTH_STATUS_ENDPOINTS,\n  DEFAULT_AUTH_STATUS,\n  DEFAULT_CODE_EDITOR_SETTINGS,\n  DEFAULT_CURSOR_PERMISSIONS,\n} from '../constants/constants';\nimport type {\n  AgentProvider,\n  AuthStatus,\n  ClaudeMcpFormState,\n  ClaudePermissionsState,\n  CodeEditorSettingsState,\n  CodexMcpFormState,\n  CodexPermissionMode,\n  CursorPermissionsState,\n  GeminiPermissionMode,\n  McpServer,\n  McpToolsResult,\n  McpTestResult,\n  NotificationPreferencesState,\n  ProjectSortOrder,\n  SettingsMainTab,\n  SettingsProject,\n} from '../types/types';\n\ntype ThemeContextValue = {\n  isDarkMode: boolean;\n  toggleDarkMode: () => void;\n};\n\ntype UseSettingsControllerArgs = {\n  isOpen: boolean;\n  initialTab: string;\n  projects: SettingsProject[];\n  onClose: () => void;\n};\n\ntype StatusApiResponse = {\n  authenticated?: boolean;\n  email?: string | null;\n  error?: string | null;\n  method?: string;\n};\n\ntype JsonResult = {\n  success?: boolean;\n  error?: string;\n};\n\ntype McpReadResponse = {\n  success?: boolean;\n  servers?: McpServer[];\n};\n\ntype McpCliServer = {\n  name: string;\n  type?: string;\n  command?: string;\n  args?: string[];\n  env?: Record<string, string>;\n  url?: string;\n  headers?: Record<string, string>;\n};\n\ntype McpCliReadResponse = {\n  success?: boolean;\n  servers?: McpCliServer[];\n};\n\ntype McpTestResponse = {\n  testResult?: McpTestResult;\n  error?: string;\n};\n\ntype McpToolsResponse = {\n  toolsResult?: McpToolsResult;\n  error?: string;\n};\n\ntype ClaudeSettingsStorage = {\n  allowedTools?: string[];\n  disallowedTools?: string[];\n  skipPermissions?: boolean;\n  projectSortOrder?: ProjectSortOrder;\n};\n\ntype CursorSettingsStorage = {\n  allowedCommands?: string[];\n  disallowedCommands?: string[];\n  skipPermissions?: boolean;\n};\n\ntype CodexSettingsStorage = {\n  permissionMode?: CodexPermissionMode;\n};\n\ntype NotificationPreferencesResponse = {\n  success?: boolean;\n  preferences?: NotificationPreferencesState;\n};\n\ntype ActiveLoginProvider = AgentProvider | '';\n\nconst KNOWN_MAIN_TABS: SettingsMainTab[] = ['agents', 'appearance', 'git', 'api', 'tasks', 'notifications', 'plugins'];\n\nconst normalizeMainTab = (tab: string): SettingsMainTab => {\n  // Keep backwards compatibility with older callers that still pass \"tools\".\n  if (tab === 'tools') {\n    return 'agents';\n  }\n\n  return KNOWN_MAIN_TABS.includes(tab as SettingsMainTab) ? (tab as SettingsMainTab) : 'agents';\n};\n\nconst getErrorMessage = (error: unknown): string => (\n  error instanceof Error ? error.message : 'Unknown error'\n);\n\nconst parseJson = <T>(value: string | null, fallback: T): T => {\n  if (!value) {\n    return fallback;\n  }\n\n  try {\n    return JSON.parse(value) as T;\n  } catch {\n    return fallback;\n  }\n};\n\nconst toCodexPermissionMode = (value: unknown): CodexPermissionMode => {\n  if (value === 'acceptEdits' || value === 'bypassPermissions') {\n    return value;\n  }\n\n  return 'default';\n};\n\nconst readCodeEditorSettings = (): CodeEditorSettingsState => ({\n  theme: localStorage.getItem('codeEditorTheme') === 'light' ? 'light' : 'dark',\n  wordWrap: localStorage.getItem('codeEditorWordWrap') === 'true',\n  showMinimap: localStorage.getItem('codeEditorShowMinimap') !== 'false',\n  lineNumbers: localStorage.getItem('codeEditorLineNumbers') !== 'false',\n  fontSize: localStorage.getItem('codeEditorFontSize') ?? DEFAULT_CODE_EDITOR_SETTINGS.fontSize,\n});\n\nconst mapCliServersToMcpServers = (servers: McpCliServer[] = []): McpServer[] => (\n  servers.map((server) => ({\n    id: server.name,\n    name: server.name,\n    type: server.type || 'stdio',\n    scope: 'user',\n    config: {\n      command: server.command || '',\n      args: server.args || [],\n      env: server.env || {},\n      url: server.url || '',\n      headers: server.headers || {},\n      timeout: 30000,\n    },\n    created: new Date().toISOString(),\n    updated: new Date().toISOString(),\n  }))\n);\n\nconst getDefaultProject = (projects: SettingsProject[]): SettingsProject => {\n  if (projects.length > 0) {\n    return projects[0];\n  }\n\n  const cwd = typeof process !== 'undefined' && process.cwd ? process.cwd() : '';\n  return {\n    name: 'default',\n    displayName: 'default',\n    fullPath: cwd,\n    path: cwd,\n  };\n};\n\nconst toResponseJson = async <T>(response: Response): Promise<T> => response.json() as Promise<T>;\n\nconst createEmptyClaudePermissions = (): ClaudePermissionsState => ({\n  allowedTools: [],\n  disallowedTools: [],\n  skipPermissions: false,\n});\n\nconst createEmptyCursorPermissions = (): CursorPermissionsState => ({\n  ...DEFAULT_CURSOR_PERMISSIONS,\n});\n\nconst createDefaultNotificationPreferences = (): NotificationPreferencesState => ({\n  channels: {\n    inApp: true,\n    webPush: false,\n  },\n  events: {\n    actionRequired: true,\n    stop: true,\n    error: true,\n  },\n});\n\nexport function useSettingsController({ isOpen, initialTab, projects, onClose }: UseSettingsControllerArgs) {\n  const { isDarkMode, toggleDarkMode } = useTheme() as ThemeContextValue;\n  const closeTimerRef = useRef<number | null>(null);\n\n  const [activeTab, setActiveTab] = useState<SettingsMainTab>(() => normalizeMainTab(initialTab));\n  const [saveStatus, setSaveStatus] = useState<'success' | 'error' | null>(null);\n  const [deleteError, setDeleteError] = useState<string | null>(null);\n  const [projectSortOrder, setProjectSortOrder] = useState<ProjectSortOrder>('name');\n  const [codeEditorSettings, setCodeEditorSettings] = useState<CodeEditorSettingsState>(() => (\n    readCodeEditorSettings()\n  ));\n\n  const [claudePermissions, setClaudePermissions] = useState<ClaudePermissionsState>(() => (\n    createEmptyClaudePermissions()\n  ));\n  const [cursorPermissions, setCursorPermissions] = useState<CursorPermissionsState>(() => (\n    createEmptyCursorPermissions()\n  ));\n  const [notificationPreferences, setNotificationPreferences] = useState<NotificationPreferencesState>(() => (\n    createDefaultNotificationPreferences()\n  ));\n  const [codexPermissionMode, setCodexPermissionMode] = useState<CodexPermissionMode>('default');\n  const [geminiPermissionMode, setGeminiPermissionMode] = useState<GeminiPermissionMode>('default');\n\n  const [mcpServers, setMcpServers] = useState<McpServer[]>([]);\n  const [cursorMcpServers, setCursorMcpServers] = useState<McpServer[]>([]);\n  const [codexMcpServers, setCodexMcpServers] = useState<McpServer[]>([]);\n  const [mcpTestResults, setMcpTestResults] = useState<Record<string, McpTestResult>>({});\n  const [mcpServerTools, setMcpServerTools] = useState<Record<string, McpToolsResult>>({});\n  const [mcpToolsLoading, setMcpToolsLoading] = useState<Record<string, boolean>>({});\n\n  const [showMcpForm, setShowMcpForm] = useState(false);\n  const [editingMcpServer, setEditingMcpServer] = useState<McpServer | null>(null);\n  const [showCodexMcpForm, setShowCodexMcpForm] = useState(false);\n  const [editingCodexMcpServer, setEditingCodexMcpServer] = useState<McpServer | null>(null);\n\n  const [showLoginModal, setShowLoginModal] = useState(false);\n  const [loginProvider, setLoginProvider] = useState<ActiveLoginProvider>('');\n  const [selectedProject, setSelectedProject] = useState<SettingsProject | null>(null);\n\n  const [claudeAuthStatus, setClaudeAuthStatus] = useState<AuthStatus>(DEFAULT_AUTH_STATUS);\n  const [cursorAuthStatus, setCursorAuthStatus] = useState<AuthStatus>(DEFAULT_AUTH_STATUS);\n  const [codexAuthStatus, setCodexAuthStatus] = useState<AuthStatus>(DEFAULT_AUTH_STATUS);\n  const [geminiAuthStatus, setGeminiAuthStatus] = useState<AuthStatus>(DEFAULT_AUTH_STATUS);\n\n  const setAuthStatusByProvider = useCallback((provider: AgentProvider, status: AuthStatus) => {\n    if (provider === 'claude') {\n      setClaudeAuthStatus(status);\n      return;\n    }\n\n    if (provider === 'cursor') {\n      setCursorAuthStatus(status);\n      return;\n    }\n\n    if (provider === 'gemini') {\n      setGeminiAuthStatus(status);\n      return;\n    }\n\n    setCodexAuthStatus(status);\n  }, []);\n\n  const checkAuthStatus = useCallback(async (provider: AgentProvider) => {\n    try {\n      const response = await authenticatedFetch(AUTH_STATUS_ENDPOINTS[provider]);\n\n      if (!response.ok) {\n        setAuthStatusByProvider(provider, {\n          authenticated: false,\n          email: null,\n          loading: false,\n          error: 'Failed to check authentication status',\n        });\n        return;\n      }\n\n      const data = await toResponseJson<StatusApiResponse>(response);\n      setAuthStatusByProvider(provider, {\n        authenticated: Boolean(data.authenticated),\n        email: data.email || null,\n        loading: false,\n        error: data.error || null,\n        method: data.method,\n      });\n    } catch (error) {\n      console.error(`Error checking ${provider} auth status:`, error);\n      setAuthStatusByProvider(provider, {\n        authenticated: false,\n        email: null,\n        loading: false,\n        error: getErrorMessage(error),\n      });\n    }\n  }, [setAuthStatusByProvider]);\n\n  const fetchCursorMcpServers = useCallback(async () => {\n    try {\n      const response = await authenticatedFetch('/api/cursor/mcp');\n      if (!response.ok) {\n        console.error('Failed to fetch Cursor MCP servers');\n        return;\n      }\n\n      const data = await toResponseJson<{ servers?: McpServer[] }>(response);\n      setCursorMcpServers(data.servers || []);\n    } catch (error) {\n      console.error('Error fetching Cursor MCP servers:', error);\n    }\n  }, []);\n\n  const fetchCodexMcpServers = useCallback(async () => {\n    try {\n      const configResponse = await authenticatedFetch('/api/codex/mcp/config/read');\n\n      if (configResponse.ok) {\n        const configData = await toResponseJson<McpReadResponse>(configResponse);\n        if (configData.success && configData.servers) {\n          setCodexMcpServers(configData.servers);\n          return;\n        }\n      }\n\n      const cliResponse = await authenticatedFetch('/api/codex/mcp/cli/list');\n      if (!cliResponse.ok) {\n        return;\n      }\n\n      const cliData = await toResponseJson<McpCliReadResponse>(cliResponse);\n      if (!cliData.success || !cliData.servers) {\n        return;\n      }\n\n      setCodexMcpServers(mapCliServersToMcpServers(cliData.servers));\n    } catch (error) {\n      console.error('Error fetching Codex MCP servers:', error);\n    }\n  }, []);\n\n  const fetchMcpServers = useCallback(async () => {\n    try {\n      const configResponse = await authenticatedFetch('/api/mcp/config/read');\n      if (configResponse.ok) {\n        const configData = await toResponseJson<McpReadResponse>(configResponse);\n        if (configData.success && configData.servers) {\n          setMcpServers(configData.servers);\n          return;\n        }\n      }\n\n      const cliResponse = await authenticatedFetch('/api/mcp/cli/list');\n      if (cliResponse.ok) {\n        const cliData = await toResponseJson<McpCliReadResponse>(cliResponse);\n        if (cliData.success && cliData.servers) {\n          setMcpServers(mapCliServersToMcpServers(cliData.servers));\n          return;\n        }\n      }\n\n      const fallbackResponse = await authenticatedFetch('/api/mcp/servers?scope=user');\n      if (!fallbackResponse.ok) {\n        console.error('Failed to fetch MCP servers');\n        return;\n      }\n\n      const fallbackData = await toResponseJson<{ servers?: McpServer[] }>(fallbackResponse);\n      setMcpServers(fallbackData.servers || []);\n    } catch (error) {\n      console.error('Error fetching MCP servers:', error);\n    }\n  }, []);\n\n  const deleteMcpServer = useCallback(async (serverId: string, scope = 'user') => {\n    const response = await authenticatedFetch(`/api/mcp/cli/remove/${serverId}?scope=${scope}`, {\n      method: 'DELETE',\n    });\n\n    if (!response.ok) {\n      const error = await toResponseJson<JsonResult>(response);\n      throw new Error(error.error || 'Failed to delete server');\n    }\n\n    const result = await toResponseJson<JsonResult>(response);\n    if (!result.success) {\n      throw new Error(result.error || 'Failed to delete server via Claude CLI');\n    }\n  }, []);\n\n  const saveMcpServer = useCallback(\n    async (serverData: ClaudeMcpFormState, editingServer: McpServer | null) => {\n      const newServerScope = serverData.scope || 'user';\n\n      const response = await authenticatedFetch('/api/mcp/cli/add', {\n        method: 'POST',\n        body: JSON.stringify({\n          name: serverData.name,\n          type: serverData.type,\n          scope: newServerScope,\n          projectPath: serverData.projectPath,\n          command: serverData.config.command,\n          args: serverData.config.args || [],\n          url: serverData.config.url,\n          headers: serverData.config.headers || {},\n          env: serverData.config.env || {},\n        }),\n      });\n\n      if (!response.ok) {\n        const error = await toResponseJson<JsonResult>(response);\n        throw new Error(error.error || 'Failed to save server');\n      }\n\n      const result = await toResponseJson<JsonResult>(response);\n      if (!result.success) {\n        throw new Error(result.error || 'Failed to save server via Claude CLI');\n      }\n\n      if (!editingServer?.id) {\n        return;\n      }\n\n      const previousServerScope = editingServer.scope || 'user';\n      const didServerIdentityChange =\n        editingServer.id !== serverData.name || previousServerScope !== newServerScope;\n\n      if (!didServerIdentityChange) {\n        return;\n      }\n\n      try {\n        await deleteMcpServer(editingServer.id, previousServerScope);\n      } catch (error) {\n        console.warn('Saved MCP server update but failed to remove the previous server entry.', {\n          previousServerId: editingServer.id,\n          previousServerScope,\n          error: getErrorMessage(error),\n        });\n      }\n    },\n    [deleteMcpServer],\n  );\n\n  const submitMcpForm = useCallback(\n    async (formData: ClaudeMcpFormState, editingServer: McpServer | null) => {\n      if (formData.importMode === 'json') {\n        const response = await authenticatedFetch('/api/mcp/cli/add-json', {\n          method: 'POST',\n          body: JSON.stringify({\n            name: formData.name,\n            jsonConfig: formData.jsonInput,\n            scope: formData.scope,\n            projectPath: formData.projectPath,\n          }),\n        });\n\n        if (!response.ok) {\n          const error = await toResponseJson<JsonResult>(response);\n          throw new Error(error.error || 'Failed to add server');\n        }\n\n        const result = await toResponseJson<JsonResult>(response);\n        if (!result.success) {\n          throw new Error(result.error || 'Failed to add server via JSON');\n        }\n      } else {\n        await saveMcpServer(formData, editingServer);\n      }\n\n      await fetchMcpServers();\n      setSaveStatus('success');\n      setShowMcpForm(false);\n      setEditingMcpServer(null);\n    },\n    [fetchMcpServers, saveMcpServer],\n  );\n\n  const handleMcpDelete = useCallback(\n    async (serverId: string, scope = 'user') => {\n      if (!window.confirm('Are you sure you want to delete this MCP server?')) {\n        return;\n      }\n\n      setDeleteError(null);\n      try {\n        await deleteMcpServer(serverId, scope);\n        await fetchMcpServers();\n        setDeleteError(null);\n        setSaveStatus('success');\n      } catch (error) {\n        setDeleteError(getErrorMessage(error));\n        setSaveStatus('error');\n      }\n    },\n    [deleteMcpServer, fetchMcpServers],\n  );\n\n  const testMcpServer = useCallback(async (serverId: string, scope = 'user') => {\n    const response = await authenticatedFetch(`/api/mcp/servers/${serverId}/test?scope=${scope}`, {\n      method: 'POST',\n    });\n\n    if (!response.ok) {\n      const error = await toResponseJson<McpTestResponse>(response);\n      throw new Error(error.error || 'Failed to test server');\n    }\n\n    const data = await toResponseJson<McpTestResponse>(response);\n    return data.testResult || { success: false, message: 'No test result returned' };\n  }, []);\n\n  const discoverMcpTools = useCallback(async (serverId: string, scope = 'user') => {\n    const response = await authenticatedFetch(`/api/mcp/servers/${serverId}/tools?scope=${scope}`, {\n      method: 'POST',\n    });\n\n    if (!response.ok) {\n      const error = await toResponseJson<McpToolsResponse>(response);\n      throw new Error(error.error || 'Failed to discover tools');\n    }\n\n    const data = await toResponseJson<McpToolsResponse>(response);\n    return data.toolsResult || { success: false, tools: [], resources: [], prompts: [] };\n  }, []);\n\n  const handleMcpTest = useCallback(\n    async (serverId: string, scope = 'user') => {\n      try {\n        setMcpTestResults((prev) => ({\n          ...prev,\n          [serverId]: { success: false, message: 'Testing server...', details: [], loading: true },\n        }));\n\n        const result = await testMcpServer(serverId, scope);\n        setMcpTestResults((prev) => ({ ...prev, [serverId]: result }));\n      } catch (error) {\n        setMcpTestResults((prev) => ({\n          ...prev,\n          [serverId]: {\n            success: false,\n            message: getErrorMessage(error),\n            details: [],\n          },\n        }));\n      }\n    },\n    [testMcpServer],\n  );\n\n  const handleMcpToolsDiscovery = useCallback(\n    async (serverId: string, scope = 'user') => {\n      try {\n        setMcpToolsLoading((prev) => ({ ...prev, [serverId]: true }));\n        const result = await discoverMcpTools(serverId, scope);\n        setMcpServerTools((prev) => ({ ...prev, [serverId]: result }));\n      } catch {\n        setMcpServerTools((prev) => ({\n          ...prev,\n          [serverId]: { success: false, tools: [], resources: [], prompts: [] },\n        }));\n      } finally {\n        setMcpToolsLoading((prev) => ({ ...prev, [serverId]: false }));\n      }\n    },\n    [discoverMcpTools],\n  );\n\n  const deleteCodexMcpServer = useCallback(async (serverId: string) => {\n    const response = await authenticatedFetch(`/api/codex/mcp/cli/remove/${serverId}`, {\n      method: 'DELETE',\n    });\n\n    if (!response.ok) {\n      const error = await toResponseJson<JsonResult>(response);\n      throw new Error(error.error || 'Failed to delete server');\n    }\n\n    const result = await toResponseJson<JsonResult>(response);\n    if (!result.success) {\n      throw new Error(result.error || 'Failed to delete Codex MCP server');\n    }\n  }, []);\n\n  const saveCodexMcpServer = useCallback(\n    async (serverData: CodexMcpFormState, editingServer: McpServer | null) => {\n      const response = await authenticatedFetch('/api/codex/mcp/cli/add', {\n        method: 'POST',\n        body: JSON.stringify({\n          name: serverData.name,\n          command: serverData.config.command,\n          args: serverData.config.args || [],\n          env: serverData.config.env || {},\n        }),\n      });\n\n      if (!response.ok) {\n        const error = await toResponseJson<JsonResult>(response);\n        throw new Error(error.error || 'Failed to save server');\n      }\n\n      const result = await toResponseJson<JsonResult>(response);\n      if (!result.success) {\n        throw new Error(result.error || 'Failed to save Codex MCP server');\n      }\n\n      if (!editingServer?.name || editingServer.name === serverData.name) {\n        return;\n      }\n\n      try {\n        await deleteCodexMcpServer(editingServer.name);\n      } catch (error) {\n        console.warn('Saved Codex MCP server update but failed to remove the previous server entry.', {\n          previousServerName: editingServer.name,\n          error: getErrorMessage(error),\n        });\n      }\n    },\n    [deleteCodexMcpServer],\n  );\n\n  const submitCodexMcpForm = useCallback(\n    async (formData: CodexMcpFormState, editingServer: McpServer | null) => {\n      await saveCodexMcpServer(formData, editingServer);\n      await fetchCodexMcpServers();\n      setSaveStatus('success');\n      setShowCodexMcpForm(false);\n      setEditingCodexMcpServer(null);\n    },\n    [fetchCodexMcpServers, saveCodexMcpServer],\n  );\n\n  const handleCodexMcpDelete = useCallback(\n    async (serverName: string) => {\n      if (!window.confirm('Are you sure you want to delete this MCP server?')) {\n        return;\n      }\n\n      setDeleteError(null);\n      try {\n        await deleteCodexMcpServer(serverName);\n        await fetchCodexMcpServers();\n        setDeleteError(null);\n        setSaveStatus('success');\n      } catch (error) {\n        setDeleteError(getErrorMessage(error));\n        setSaveStatus('error');\n      }\n    },\n    [deleteCodexMcpServer, fetchCodexMcpServers],\n  );\n\n  const loadSettings = useCallback(async () => {\n    try {\n      const savedClaudeSettings = parseJson<ClaudeSettingsStorage>(\n        localStorage.getItem('claude-settings'),\n        {},\n      );\n      setClaudePermissions({\n        allowedTools: savedClaudeSettings.allowedTools || [],\n        disallowedTools: savedClaudeSettings.disallowedTools || [],\n        skipPermissions: Boolean(savedClaudeSettings.skipPermissions),\n      });\n      setProjectSortOrder(savedClaudeSettings.projectSortOrder === 'date' ? 'date' : 'name');\n\n      const savedCursorSettings = parseJson<CursorSettingsStorage>(\n        localStorage.getItem('cursor-tools-settings'),\n        {},\n      );\n      setCursorPermissions({\n        allowedCommands: savedCursorSettings.allowedCommands || [],\n        disallowedCommands: savedCursorSettings.disallowedCommands || [],\n        skipPermissions: Boolean(savedCursorSettings.skipPermissions),\n      });\n\n      const savedCodexSettings = parseJson<CodexSettingsStorage>(\n        localStorage.getItem('codex-settings'),\n        {},\n      );\n      setCodexPermissionMode(toCodexPermissionMode(savedCodexSettings.permissionMode));\n\n      const savedGeminiSettings = parseJson<{ permissionMode?: GeminiPermissionMode }>(\n        localStorage.getItem('gemini-settings'),\n        {},\n      );\n      setGeminiPermissionMode(savedGeminiSettings.permissionMode || 'default');\n\n      try {\n        const notificationResponse = await authenticatedFetch('/api/settings/notification-preferences');\n        if (notificationResponse.ok) {\n          const notificationData = await toResponseJson<NotificationPreferencesResponse>(notificationResponse);\n          if (notificationData.success && notificationData.preferences) {\n            setNotificationPreferences(notificationData.preferences);\n          } else {\n            setNotificationPreferences(createDefaultNotificationPreferences());\n          }\n        } else {\n          setNotificationPreferences(createDefaultNotificationPreferences());\n        }\n      } catch {\n        setNotificationPreferences(createDefaultNotificationPreferences());\n      }\n\n      await Promise.all([\n        fetchMcpServers(),\n        fetchCursorMcpServers(),\n        fetchCodexMcpServers(),\n      ]);\n    } catch (error) {\n      console.error('Error loading settings:', error);\n      setClaudePermissions(createEmptyClaudePermissions());\n      setCursorPermissions(createEmptyCursorPermissions());\n      setNotificationPreferences(createDefaultNotificationPreferences());\n      setCodexPermissionMode('default');\n      setProjectSortOrder('name');\n    }\n  }, [fetchCodexMcpServers, fetchCursorMcpServers, fetchMcpServers]);\n\n  const openLoginForProvider = useCallback((provider: AgentProvider) => {\n    setLoginProvider(provider);\n    setSelectedProject(getDefaultProject(projects));\n    setShowLoginModal(true);\n  }, [projects]);\n\n  const handleLoginComplete = useCallback((exitCode: number) => {\n    if (exitCode !== 0 || !loginProvider) {\n      return;\n    }\n\n    setSaveStatus('success');\n    void checkAuthStatus(loginProvider);\n  }, [checkAuthStatus, loginProvider]);\n\n  const saveSettings = useCallback(async () => {\n    setSaveStatus(null);\n\n    try {\n      const now = new Date().toISOString();\n      localStorage.setItem('claude-settings', JSON.stringify({\n        allowedTools: claudePermissions.allowedTools,\n        disallowedTools: claudePermissions.disallowedTools,\n        skipPermissions: claudePermissions.skipPermissions,\n        projectSortOrder,\n        lastUpdated: now,\n      }));\n\n      localStorage.setItem('cursor-tools-settings', JSON.stringify({\n        allowedCommands: cursorPermissions.allowedCommands,\n        disallowedCommands: cursorPermissions.disallowedCommands,\n        skipPermissions: cursorPermissions.skipPermissions,\n        lastUpdated: now,\n      }));\n\n      localStorage.setItem('codex-settings', JSON.stringify({\n        permissionMode: codexPermissionMode,\n        lastUpdated: now,\n      }));\n\n      localStorage.setItem('gemini-settings', JSON.stringify({\n        permissionMode: geminiPermissionMode,\n        lastUpdated: now,\n      }));\n\n      const notificationResponse = await authenticatedFetch('/api/settings/notification-preferences', {\n        method: 'PUT',\n        body: JSON.stringify(notificationPreferences),\n      });\n      if (!notificationResponse.ok) {\n        throw new Error('Failed to save notification preferences');\n      }\n\n      setSaveStatus('success');\n    } catch (error) {\n      console.error('Error saving settings:', error);\n      setSaveStatus('error');\n    }\n  }, [\n    claudePermissions.allowedTools,\n    claudePermissions.disallowedTools,\n    claudePermissions.skipPermissions,\n    codexPermissionMode,\n    cursorPermissions.allowedCommands,\n    cursorPermissions.disallowedCommands,\n    cursorPermissions.skipPermissions,\n    notificationPreferences,\n    geminiPermissionMode,\n    projectSortOrder,\n  ]);\n\n  const updateCodeEditorSetting = useCallback(\n    <K extends keyof CodeEditorSettingsState>(key: K, value: CodeEditorSettingsState[K]) => {\n      setCodeEditorSettings((prev) => ({ ...prev, [key]: value }));\n    },\n    [],\n  );\n\n  const openMcpForm = useCallback((server?: McpServer) => {\n    setEditingMcpServer(server || null);\n    setShowMcpForm(true);\n  }, []);\n\n  const closeMcpForm = useCallback(() => {\n    setShowMcpForm(false);\n    setEditingMcpServer(null);\n  }, []);\n\n  const openCodexMcpForm = useCallback((server?: McpServer) => {\n    setEditingCodexMcpServer(server || null);\n    setShowCodexMcpForm(true);\n  }, []);\n\n  const closeCodexMcpForm = useCallback(() => {\n    setShowCodexMcpForm(false);\n    setEditingCodexMcpServer(null);\n  }, []);\n\n  useEffect(() => {\n    if (!isOpen) {\n      return;\n    }\n\n    setActiveTab(normalizeMainTab(initialTab));\n    void loadSettings();\n    void checkAuthStatus('claude');\n    void checkAuthStatus('cursor');\n    void checkAuthStatus('codex');\n    void checkAuthStatus('gemini');\n  }, [checkAuthStatus, initialTab, isOpen, loadSettings]);\n\n  useEffect(() => {\n    localStorage.setItem('codeEditorTheme', codeEditorSettings.theme);\n    localStorage.setItem('codeEditorWordWrap', String(codeEditorSettings.wordWrap));\n    localStorage.setItem('codeEditorShowMinimap', String(codeEditorSettings.showMinimap));\n    localStorage.setItem('codeEditorLineNumbers', String(codeEditorSettings.lineNumbers));\n    localStorage.setItem('codeEditorFontSize', codeEditorSettings.fontSize);\n    window.dispatchEvent(new Event('codeEditorSettingsChanged'));\n  }, [codeEditorSettings]);\n\n  // Auto-save permissions and sort order with debounce\n  const autoSaveTimerRef = useRef<number | null>(null);\n  const isInitialLoadRef = useRef(true);\n\n  useEffect(() => {\n    // Skip auto-save on initial load (settings are being loaded from localStorage)\n    if (isInitialLoadRef.current) {\n      isInitialLoadRef.current = false;\n      return;\n    }\n\n    if (autoSaveTimerRef.current !== null) {\n      window.clearTimeout(autoSaveTimerRef.current);\n    }\n\n    autoSaveTimerRef.current = window.setTimeout(() => {\n      saveSettings();\n    }, 500);\n\n    return () => {\n      if (autoSaveTimerRef.current !== null) {\n        window.clearTimeout(autoSaveTimerRef.current);\n      }\n    };\n  }, [saveSettings]);\n\n  // Clear save status after 2 seconds\n  useEffect(() => {\n    if (saveStatus === null) {\n      return;\n    }\n\n    const timer = window.setTimeout(() => setSaveStatus(null), 2000);\n    return () => window.clearTimeout(timer);\n  }, [saveStatus]);\n\n  // Reset initial load flag when settings dialog opens\n  useEffect(() => {\n    if (isOpen) {\n      isInitialLoadRef.current = true;\n    }\n  }, [isOpen]);\n\n  useEffect(() => () => {\n    if (closeTimerRef.current !== null) {\n      window.clearTimeout(closeTimerRef.current);\n      closeTimerRef.current = null;\n    }\n    if (autoSaveTimerRef.current !== null) {\n      window.clearTimeout(autoSaveTimerRef.current);\n      autoSaveTimerRef.current = null;\n    }\n  }, []);\n\n  return {\n    activeTab,\n    setActiveTab,\n    isDarkMode,\n    toggleDarkMode,\n    saveStatus,\n    deleteError,\n    projectSortOrder,\n    setProjectSortOrder,\n    codeEditorSettings,\n    updateCodeEditorSetting,\n    claudePermissions,\n    setClaudePermissions,\n    cursorPermissions,\n    setCursorPermissions,\n    notificationPreferences,\n    setNotificationPreferences,\n    codexPermissionMode,\n    setCodexPermissionMode,\n    mcpServers,\n    cursorMcpServers,\n    codexMcpServers,\n    mcpTestResults,\n    mcpServerTools,\n    mcpToolsLoading,\n    showMcpForm,\n    editingMcpServer,\n    openMcpForm,\n    closeMcpForm,\n    submitMcpForm,\n    handleMcpDelete,\n    handleMcpTest,\n    handleMcpToolsDiscovery,\n    showCodexMcpForm,\n    editingCodexMcpServer,\n    openCodexMcpForm,\n    closeCodexMcpForm,\n    submitCodexMcpForm,\n    handleCodexMcpDelete,\n    claudeAuthStatus,\n    cursorAuthStatus,\n    codexAuthStatus,\n    geminiAuthStatus,\n    geminiPermissionMode,\n    setGeminiPermissionMode,\n    openLoginForProvider,\n    showLoginModal,\n    setShowLoginModal,\n    loginProvider,\n    selectedProject,\n    handleLoginComplete,\n  };\n}\n"
  },
  {
    "path": "src/components/settings/types/types.ts",
    "content": "import type { Dispatch, SetStateAction } from 'react';\n\nexport type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'notifications' | 'plugins';\nexport type AgentProvider = 'claude' | 'cursor' | 'codex' | 'gemini';\nexport type AgentCategory = 'account' | 'permissions' | 'mcp';\nexport type ProjectSortOrder = 'name' | 'date';\nexport type SaveStatus = 'success' | 'error' | null;\nexport type CodexPermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions';\nexport type GeminiPermissionMode = 'default' | 'auto_edit' | 'yolo';\nexport type McpImportMode = 'form' | 'json';\nexport type McpScope = 'user' | 'local';\nexport type McpTransportType = 'stdio' | 'sse' | 'http';\n\nexport type SettingsProject = {\n  name: string;\n  displayName?: string;\n  fullPath?: string;\n  path?: string;\n};\n\nexport type AuthStatus = {\n  authenticated: boolean;\n  email: string | null;\n  loading: boolean;\n  error: string | null;\n  method?: string;\n};\n\nexport type KeyValueMap = Record<string, string>;\n\nexport type McpServerConfig = {\n  command?: string;\n  args?: string[];\n  env?: KeyValueMap;\n  url?: string;\n  headers?: KeyValueMap;\n  timeout?: number;\n};\n\nexport type McpServer = {\n  id?: string;\n  name: string;\n  type?: string;\n  scope?: string;\n  projectPath?: string;\n  config?: McpServerConfig;\n  raw?: unknown;\n  created?: string;\n  updated?: string;\n};\n\nexport type ClaudeMcpFormConfig = {\n  command: string;\n  args: string[];\n  env: KeyValueMap;\n  url: string;\n  headers: KeyValueMap;\n  timeout: number;\n};\n\nexport type ClaudeMcpFormState = {\n  name: string;\n  type: McpTransportType;\n  scope: McpScope;\n  projectPath: string;\n  config: ClaudeMcpFormConfig;\n  importMode: McpImportMode;\n  jsonInput: string;\n  raw?: unknown;\n};\n\nexport type CodexMcpFormConfig = {\n  command: string;\n  args: string[];\n  env: KeyValueMap;\n};\n\nexport type CodexMcpFormState = {\n  name: string;\n  type: 'stdio';\n  config: CodexMcpFormConfig;\n};\n\nexport type McpTestResult = {\n  success: boolean;\n  message: string;\n  details?: string[];\n  loading?: boolean;\n};\n\nexport type McpTool = {\n  name: string;\n  [key: string]: unknown;\n};\n\nexport type McpToolsResult = {\n  success?: boolean;\n  tools?: McpTool[];\n  resources?: unknown[];\n  prompts?: unknown[];\n};\n\nexport type ClaudePermissionsState = {\n  allowedTools: string[];\n  disallowedTools: string[];\n  skipPermissions: boolean;\n};\n\nexport type NotificationPreferencesState = {\n  channels: {\n    inApp: boolean;\n    webPush: boolean;\n  };\n  events: {\n    actionRequired: boolean;\n    stop: boolean;\n    error: boolean;\n  };\n};\n\nexport type CursorPermissionsState = {\n  allowedCommands: string[];\n  disallowedCommands: string[];\n  skipPermissions: boolean;\n};\n\nexport type CodeEditorSettingsState = {\n  theme: 'dark' | 'light';\n  wordWrap: boolean;\n  showMinimap: boolean;\n  lineNumbers: boolean;\n  fontSize: string;\n};\n\nexport type SettingsStoragePayload = {\n  claude: ClaudePermissionsState & { projectSortOrder: ProjectSortOrder; lastUpdated: string };\n  cursor: CursorPermissionsState & { lastUpdated: string };\n  codex: { permissionMode: CodexPermissionMode; lastUpdated: string };\n};\n\nexport type SettingsProps = {\n  isOpen: boolean;\n  onClose: () => void;\n  projects?: SettingsProject[];\n  initialTab?: string;\n};\n\nexport type SetState<T> = Dispatch<SetStateAction<T>>;\n"
  },
  {
    "path": "src/components/settings/view/Settings.tsx",
    "content": "import { X } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport ProviderLoginModal from '../../provider-auth/view/ProviderLoginModal';\nimport { Button } from '../../../shared/view/ui';\nimport ClaudeMcpFormModal from '../view/modals/ClaudeMcpFormModal';\nimport CodexMcpFormModal from '../view/modals/CodexMcpFormModal';\nimport SettingsSidebar from '../view/SettingsSidebar';\nimport AgentsSettingsTab from '../view/tabs/agents-settings/AgentsSettingsTab';\nimport AppearanceSettingsTab from '../view/tabs/AppearanceSettingsTab';\nimport CredentialsSettingsTab from '../view/tabs/api-settings/CredentialsSettingsTab';\nimport GitSettingsTab from '../view/tabs/git-settings/GitSettingsTab';\nimport NotificationsSettingsTab from '../view/tabs/NotificationsSettingsTab';\nimport TasksSettingsTab from '../view/tabs/tasks-settings/TasksSettingsTab';\nimport PluginSettingsTab from '../../plugins/view/PluginSettingsTab';\nimport { useSettingsController } from '../hooks/useSettingsController';\nimport { useWebPush } from '../../../hooks/useWebPush';\nimport type { SettingsProps } from '../types/types';\n\nfunction Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: SettingsProps) {\n  const { t } = useTranslation('settings');\n  const {\n    activeTab,\n    setActiveTab,\n    saveStatus,\n    deleteError,\n    projectSortOrder,\n    setProjectSortOrder,\n    codeEditorSettings,\n    updateCodeEditorSetting,\n    claudePermissions,\n    setClaudePermissions,\n    notificationPreferences,\n    setNotificationPreferences,\n    cursorPermissions,\n    setCursorPermissions,\n    codexPermissionMode,\n    setCodexPermissionMode,\n    mcpServers,\n    cursorMcpServers,\n    codexMcpServers,\n    mcpTestResults,\n    mcpServerTools,\n    mcpToolsLoading,\n    showMcpForm,\n    editingMcpServer,\n    openMcpForm,\n    closeMcpForm,\n    submitMcpForm,\n    handleMcpDelete,\n    handleMcpTest,\n    handleMcpToolsDiscovery,\n    showCodexMcpForm,\n    editingCodexMcpServer,\n    openCodexMcpForm,\n    closeCodexMcpForm,\n    submitCodexMcpForm,\n    handleCodexMcpDelete,\n    claudeAuthStatus,\n    cursorAuthStatus,\n    codexAuthStatus,\n    geminiAuthStatus,\n    geminiPermissionMode,\n    setGeminiPermissionMode,\n    openLoginForProvider,\n    showLoginModal,\n    setShowLoginModal,\n    loginProvider,\n    selectedProject,\n    handleLoginComplete,\n  } = useSettingsController({\n    isOpen,\n    initialTab,\n    projects,\n    onClose,\n  });\n\n  const {\n    permission: pushPermission,\n    isSubscribed: isPushSubscribed,\n    isLoading: isPushLoading,\n    subscribe: pushSubscribe,\n    unsubscribe: pushUnsubscribe,\n  } = useWebPush();\n\n  const handleEnablePush = async () => {\n    await pushSubscribe();\n    // Server sets webPush: true in preferences on subscribe; sync local state\n    setNotificationPreferences({\n      ...notificationPreferences,\n      channels: { ...notificationPreferences.channels, webPush: true },\n    });\n  };\n\n  const handleDisablePush = async () => {\n    await pushUnsubscribe();\n    // Server sets webPush: false in preferences on unsubscribe; sync local state\n    setNotificationPreferences({\n      ...notificationPreferences,\n      channels: { ...notificationPreferences.channels, webPush: false },\n    });\n  };\n\n  if (!isOpen) {\n    return null;\n  }\n\n  const isAuthenticated = loginProvider === 'claude'\n    ? claudeAuthStatus.authenticated\n    : loginProvider === 'cursor'\n      ? cursorAuthStatus.authenticated\n      : loginProvider === 'codex'\n        ? codexAuthStatus.authenticated\n        : false;\n\n  return (\n    <div className=\"modal-backdrop fixed inset-0 z-[9999] flex items-center justify-center bg-background/80 backdrop-blur-sm md:p-4\">\n      <div className=\"flex h-full w-full flex-col overflow-hidden border border-border bg-background shadow-2xl md:h-[90vh] md:max-w-4xl md:rounded-xl\">\n        {/* Header */}\n        <div className=\"flex flex-shrink-0 items-center justify-between border-b border-border px-4 py-3 md:px-5\">\n          <h2 className=\"text-base font-semibold text-foreground\">{t('title')}</h2>\n          <div className=\"flex items-center gap-2\">\n            {saveStatus === 'success' && (\n              <span className=\"text-xs text-muted-foreground animate-in fade-in\">{t('saveStatus.success')}</span>\n            )}\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              onClick={onClose}\n              className=\"h-10 w-10 touch-manipulation p-0 text-muted-foreground hover:text-foreground active:bg-accent/50\"\n            >\n              <X className=\"h-5 w-5\" />\n            </Button>\n          </div>\n        </div>\n\n        {/* Body: sidebar + content */}\n        <div className=\"flex min-h-0 flex-1 flex-col md:flex-row\">\n          <SettingsSidebar activeTab={activeTab} onChange={setActiveTab} />\n\n          {/* Content */}\n          <main className=\"flex-1 overflow-y-auto\">\n            <div key={activeTab} className=\"settings-content-enter space-y-6 p-4 pb-safe-area-inset-bottom md:space-y-8 md:p-6\">\n              {activeTab === 'appearance' && (\n                <AppearanceSettingsTab\n                  projectSortOrder={projectSortOrder}\n                  onProjectSortOrderChange={setProjectSortOrder}\n                  codeEditorSettings={codeEditorSettings}\n                  onCodeEditorThemeChange={(value) => updateCodeEditorSetting('theme', value)}\n                  onCodeEditorWordWrapChange={(value) => updateCodeEditorSetting('wordWrap', value)}\n                  onCodeEditorShowMinimapChange={(value) => updateCodeEditorSetting('showMinimap', value)}\n                  onCodeEditorLineNumbersChange={(value) => updateCodeEditorSetting('lineNumbers', value)}\n                  onCodeEditorFontSizeChange={(value) => updateCodeEditorSetting('fontSize', value)}\n                />\n              )}\n\n              {activeTab === 'git' && <GitSettingsTab />}\n\n              {activeTab === 'agents' && (\n                <AgentsSettingsTab\n                  claudeAuthStatus={claudeAuthStatus}\n                  cursorAuthStatus={cursorAuthStatus}\n                  codexAuthStatus={codexAuthStatus}\n                  geminiAuthStatus={geminiAuthStatus}\n                  onClaudeLogin={() => openLoginForProvider('claude')}\n                  onCursorLogin={() => openLoginForProvider('cursor')}\n                  onCodexLogin={() => openLoginForProvider('codex')}\n                  onGeminiLogin={() => openLoginForProvider('gemini')}\n                  claudePermissions={claudePermissions}\n                  onClaudePermissionsChange={setClaudePermissions}\n                  cursorPermissions={cursorPermissions}\n                  onCursorPermissionsChange={setCursorPermissions}\n                  codexPermissionMode={codexPermissionMode}\n                  onCodexPermissionModeChange={setCodexPermissionMode}\n                  geminiPermissionMode={geminiPermissionMode}\n                  onGeminiPermissionModeChange={setGeminiPermissionMode}\n                  mcpServers={mcpServers}\n                  cursorMcpServers={cursorMcpServers}\n                  codexMcpServers={codexMcpServers}\n                  mcpTestResults={mcpTestResults}\n                  mcpServerTools={mcpServerTools}\n                  mcpToolsLoading={mcpToolsLoading}\n                  onOpenMcpForm={openMcpForm}\n                  onDeleteMcpServer={handleMcpDelete}\n                  onTestMcpServer={handleMcpTest}\n                  onDiscoverMcpTools={handleMcpToolsDiscovery}\n                  onOpenCodexMcpForm={openCodexMcpForm}\n                  onDeleteCodexMcpServer={handleCodexMcpDelete}\n                  deleteError={deleteError}\n                />\n              )}\n\n              {activeTab === 'tasks' && <TasksSettingsTab />}\n\n            {activeTab === 'notifications' && (\n              <NotificationsSettingsTab\n                notificationPreferences={notificationPreferences}\n                onNotificationPreferencesChange={setNotificationPreferences}\n                pushPermission={pushPermission}\n                isPushSubscribed={isPushSubscribed}\n                isPushLoading={isPushLoading}\n                onEnablePush={handleEnablePush}\n                onDisablePush={handleDisablePush}\n              />\n            )}\n\n              {activeTab === 'api' && <CredentialsSettingsTab />}\n\n              {activeTab === 'plugins' && <PluginSettingsTab />}\n            </div>\n          </main>\n        </div>\n      </div>\n\n      <ProviderLoginModal\n        key={loginProvider || 'claude'}\n        isOpen={showLoginModal}\n        onClose={() => setShowLoginModal(false)}\n        provider={loginProvider || 'claude'}\n        project={selectedProject}\n        onComplete={handleLoginComplete}\n        isAuthenticated={isAuthenticated}\n      />\n\n      <ClaudeMcpFormModal\n        isOpen={showMcpForm}\n        editingServer={editingMcpServer}\n        projects={projects}\n        onClose={closeMcpForm}\n        onSubmit={submitMcpForm}\n      />\n\n      <CodexMcpFormModal\n        isOpen={showCodexMcpForm}\n        editingServer={editingCodexMcpServer}\n        onClose={closeCodexMcpForm}\n        onSubmit={submitCodexMcpForm}\n      />\n    </div>\n  );\n}\n\nexport default Settings;\n"
  },
  {
    "path": "src/components/settings/view/SettingsCard.tsx",
    "content": "import type { ReactNode } from 'react';\nimport { cn } from '../../../lib/utils';\n\ntype SettingsCardProps = {\n  children: ReactNode;\n  className?: string;\n  divided?: boolean;\n};\n\nexport default function SettingsCard({ children, className, divided }: SettingsCardProps) {\n  return (\n    <div\n      className={cn(\n        'rounded-xl border border-border bg-card/50',\n        divided && 'divide-y divide-border',\n        className,\n      )}\n    >\n      {children}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/settings/view/SettingsMainTabs.tsx",
    "content": "import { GitBranch, Key, Puzzle } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport type { SettingsMainTab } from '../types/types';\n\ntype SettingsMainTabsProps = {\n  activeTab: SettingsMainTab;\n  onChange: (tab: SettingsMainTab) => void;\n};\n\ntype MainTabConfig = {\n  id: SettingsMainTab;\n  labelKey?: string;\n  label?: string;\n  icon?: typeof GitBranch;\n};\n\nconst TAB_CONFIG: MainTabConfig[] = [\n  { id: 'agents', labelKey: 'mainTabs.agents' },\n  { id: 'appearance', labelKey: 'mainTabs.appearance' },\n  { id: 'git', labelKey: 'mainTabs.git', icon: GitBranch },\n  { id: 'api', labelKey: 'mainTabs.apiTokens', icon: Key },\n  { id: 'tasks', labelKey: 'mainTabs.tasks' },\n  { id: 'notifications', labelKey: 'mainTabs.notifications' },\n  { id: 'plugins', labelKey: 'mainTabs.plugins', icon: Puzzle },\n];\n\nexport default function SettingsMainTabs({ activeTab, onChange }: SettingsMainTabsProps) {\n  const { t } = useTranslation('settings');\n\n  return (\n    <div className=\"border-b border-border\">\n       <div className=\"flex px-4 md:px-6 overflow-x-auto scrollbar-hide\" role=\"tablist\" aria-label={t('mainTabs.label', { defaultValue: 'Settings' })}>\n        {TAB_CONFIG.map((tab) => {\n          const Icon = tab.icon;\n          const isActive = activeTab === tab.id;\n\n          return (\n            <button\n              key={tab.id}\n              role=\"tab\"\n              aria-selected={isActive}\n              onClick={() => onChange(tab.id)}\n              className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${\n                isActive\n                  ? 'border-blue-600 text-blue-600 dark:text-blue-400'\n                  : 'border-transparent text-muted-foreground hover:text-foreground'\n              }`}\n            >\n              {Icon && <Icon className=\"mr-2 inline h-4 w-4\" />}\n              {tab.labelKey ? t(tab.labelKey) : tab.label}\n            </button>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/settings/view/SettingsRow.tsx",
    "content": "import type { ReactNode } from 'react';\nimport { cn } from '../../../lib/utils';\n\ntype SettingsRowProps = {\n  label: string;\n  description?: string;\n  children: ReactNode;\n  className?: string;\n};\n\nexport default function SettingsRow({ label, description, children, className }: SettingsRowProps) {\n  return (\n    <div className={cn('flex items-center justify-between gap-4 px-4 py-4', className)}>\n      <div className=\"min-w-0 flex-1\">\n        <div className=\"text-sm font-medium text-foreground\">{label}</div>\n        {description && (\n          <div className=\"mt-0.5 text-sm text-muted-foreground\">{description}</div>\n        )}\n      </div>\n      <div className=\"flex-shrink-0\">{children}</div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/settings/view/SettingsSection.tsx",
    "content": "import type { ReactNode } from 'react';\nimport { cn } from '../../../lib/utils';\n\ntype SettingsSectionProps = {\n  title: string;\n  description?: string;\n  children: ReactNode;\n  className?: string;\n};\n\nexport default function SettingsSection({ title, description, children, className }: SettingsSectionProps) {\n  return (\n    <div className={cn('space-y-3', className)}>\n      <div>\n        <h3 className=\"text-sm font-semibold uppercase tracking-wider text-muted-foreground\">\n          {title}\n        </h3>\n        {description && (\n          <p className=\"mt-1 text-sm text-muted-foreground\">{description}</p>\n        )}\n      </div>\n      {children}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/settings/view/SettingsSidebar.tsx",
    "content": "import { Bell, Bot, GitBranch, Key, ListChecks, Palette, Puzzle } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { cn } from '../../../lib/utils';\nimport { PillBar, Pill } from '../../../shared/view/ui';\nimport type { SettingsMainTab } from '../types/types';\n\ntype SettingsSidebarProps = {\n  activeTab: SettingsMainTab;\n  onChange: (tab: SettingsMainTab) => void;\n};\n\ntype NavItem = {\n  id: SettingsMainTab;\n  labelKey: string;\n  icon: typeof Bot;\n};\n\nconst NAV_ITEMS: NavItem[] = [\n  { id: 'agents', labelKey: 'mainTabs.agents', icon: Bot },\n  { id: 'appearance', labelKey: 'mainTabs.appearance', icon: Palette },\n  { id: 'git', labelKey: 'mainTabs.git', icon: GitBranch },\n  { id: 'api', labelKey: 'mainTabs.apiTokens', icon: Key },\n  { id: 'tasks', labelKey: 'mainTabs.tasks', icon: ListChecks },\n  { id: 'plugins', labelKey: 'mainTabs.plugins', icon: Puzzle },\n  { id: 'notifications', labelKey: 'mainTabs.notifications', icon: Bell },\n];\n\nexport default function SettingsSidebar({ activeTab, onChange }: SettingsSidebarProps) {\n  const { t } = useTranslation('settings');\n\n  return (\n    <>\n      {/* Desktop sidebar */}\n      <aside className=\"hidden w-56 flex-shrink-0 border-r border-border bg-muted/30 md:flex md:flex-col\">\n        <nav className=\"flex flex-col gap-1 p-3\">\n          {NAV_ITEMS.map((item) => {\n            const Icon = item.icon;\n            const isActive = activeTab === item.id;\n\n            return (\n              <button\n                key={item.id}\n                onClick={() => onChange(item.id)}\n                className={cn(\n                  'flex items-center gap-3 rounded-lg px-3 py-2.5 text-left text-sm font-medium transition-colors duration-150',\n                  isActive\n                    ? 'bg-accent text-accent-foreground'\n                    : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground active:bg-accent/50',\n                )}\n              >\n                <Icon className=\"h-4 w-4 flex-shrink-0\" />\n                {t(item.labelKey)}\n              </button>\n            );\n          })}\n        </nav>\n      </aside>\n\n      {/* Mobile horizontal nav — pill bar */}\n      <div className=\"flex-shrink-0 border-b border-border px-3 py-2 md:hidden\">\n        <PillBar className=\"scrollbar-hide w-full overflow-x-auto\">\n          {NAV_ITEMS.map((item) => {\n            const Icon = item.icon;\n\n            return (\n              <Pill\n                key={item.id}\n                isActive={activeTab === item.id}\n                onClick={() => onChange(item.id)}\n                className=\"flex-shrink-0\"\n              >\n                <Icon className=\"h-3.5 w-3.5\" />\n                {t(item.labelKey)}\n              </Pill>\n            );\n          })}\n        </PillBar>\n      </div>\n    </>\n  );\n}\n"
  },
  {
    "path": "src/components/settings/view/SettingsToggle.tsx",
    "content": "import { cn } from '../../../lib/utils';\n\ntype SettingsToggleProps = {\n  checked: boolean;\n  onChange: (value: boolean) => void;\n  ariaLabel: string;\n  disabled?: boolean;\n};\n\nexport default function SettingsToggle({ checked, onChange, ariaLabel, disabled }: SettingsToggleProps) {\n  return (\n    <button\n      type=\"button\"\n      role=\"switch\"\n      aria-checked={checked}\n      aria-label={ariaLabel}\n      disabled={disabled}\n      onClick={() => onChange(!checked)}\n      className={cn(\n        'relative inline-flex h-7 w-12 flex-shrink-0 touch-manipulation cursor-pointer items-center rounded-full border-2 transition-colors duration-200',\n        'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',\n        checked ? 'border-primary bg-primary' : 'border-border bg-muted',\n        disabled && 'cursor-not-allowed opacity-50',\n      )}\n    >\n      <span\n        className={cn(\n          'pointer-events-none inline-block h-5 w-5 rounded-full shadow-sm transition-transform duration-200',\n          checked ? 'translate-x-[22px] bg-white' : 'translate-x-[2px] bg-foreground/60 dark:bg-foreground/80',\n        )}\n      />\n    </button>\n  );\n}\n"
  },
  {
    "path": "src/components/settings/view/modals/ClaudeMcpFormModal.tsx",
    "content": "import { FolderOpen, Globe, X } from 'lucide-react';\nimport { useEffect, useMemo, useState } from 'react';\nimport type { FormEvent } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { Button, Input } from '../../../../shared/view/ui';\nimport { DEFAULT_CLAUDE_MCP_FORM } from '../../constants/constants';\nimport type { ClaudeMcpFormState, McpServer, McpScope, McpTransportType, SettingsProject } from '../../types/types';\n\ntype ClaudeMcpFormModalProps = {\n  isOpen: boolean;\n  editingServer: McpServer | null;\n  projects: SettingsProject[];\n  onClose: () => void;\n  onSubmit: (formData: ClaudeMcpFormState, editingServer: McpServer | null) => Promise<void>;\n};\n\nconst getSafeTransportType = (value: unknown): McpTransportType => {\n  if (value === 'sse' || value === 'http') {\n    return value;\n  }\n\n  return 'stdio';\n};\n\nconst getSafeScope = (value: unknown): McpScope => (value === 'local' ? 'local' : 'user');\n\nconst getErrorMessage = (error: unknown): string => (\n  error instanceof Error ? error.message : 'Unknown error'\n);\n\nconst createFormStateFromServer = (server: McpServer): ClaudeMcpFormState => ({\n  name: server.name || '',\n  type: getSafeTransportType(server.type),\n  scope: getSafeScope(server.scope),\n  projectPath: server.projectPath || '',\n  config: {\n    command: server.config?.command || '',\n    args: server.config?.args || [],\n    env: server.config?.env || {},\n    url: server.config?.url || '',\n    headers: server.config?.headers || {},\n    timeout: server.config?.timeout || 30000,\n  },\n  importMode: 'form',\n  jsonInput: '',\n  raw: server.raw,\n});\n\nexport default function ClaudeMcpFormModal({\n  isOpen,\n  editingServer,\n  projects,\n  onClose,\n  onSubmit,\n}: ClaudeMcpFormModalProps) {\n  const { t } = useTranslation('settings');\n  const [formData, setFormData] = useState<ClaudeMcpFormState>(DEFAULT_CLAUDE_MCP_FORM);\n  const [jsonValidationError, setJsonValidationError] = useState('');\n  const [isSubmitting, setIsSubmitting] = useState(false);\n\n  const isEditing = Boolean(editingServer);\n\n  useEffect(() => {\n    if (!isOpen) {\n      return;\n    }\n\n    setJsonValidationError('');\n    if (editingServer) {\n      setFormData(createFormStateFromServer(editingServer));\n      return;\n    }\n\n    setFormData(DEFAULT_CLAUDE_MCP_FORM);\n  }, [editingServer, isOpen]);\n\n  const canSubmit = useMemo(() => {\n    if (!formData.name.trim()) {\n      return false;\n    }\n\n    if (formData.importMode === 'json') {\n      return Boolean(formData.jsonInput.trim()) && !jsonValidationError;\n    }\n\n    if (formData.scope === 'local' && !formData.projectPath.trim()) {\n      return false;\n    }\n\n    if (formData.type === 'stdio') {\n      return Boolean(formData.config.command.trim());\n    }\n\n    return Boolean(formData.config.url.trim());\n  }, [formData, jsonValidationError]);\n\n  if (!isOpen) {\n    return null;\n  }\n\n  const updateConfig = <K extends keyof ClaudeMcpFormState['config']>(\n    key: K,\n    value: ClaudeMcpFormState['config'][K],\n  ) => {\n    setFormData((prev) => ({\n      ...prev,\n      config: {\n        ...prev.config,\n        [key]: value,\n      },\n    }));\n  };\n\n  const handleJsonValidation = (value: string) => {\n    if (!value.trim()) {\n      setJsonValidationError('');\n      return;\n    }\n\n    try {\n      const parsed = JSON.parse(value) as { type?: string; command?: string; url?: string };\n      if (!parsed.type) {\n        setJsonValidationError(t('mcpForm.validation.missingType'));\n      } else if (parsed.type === 'stdio' && !parsed.command) {\n        setJsonValidationError(t('mcpForm.validation.stdioRequiresCommand'));\n      } else if ((parsed.type === 'http' || parsed.type === 'sse') && !parsed.url) {\n        setJsonValidationError(t('mcpForm.validation.httpRequiresUrl', { type: parsed.type }));\n      } else {\n        setJsonValidationError('');\n      }\n    } catch {\n      setJsonValidationError(t('mcpForm.validation.invalidJson'));\n    }\n  };\n\n  const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {\n    event.preventDefault();\n    setIsSubmitting(true);\n\n    try {\n      await onSubmit(formData, editingServer);\n    } catch (error) {\n      alert(`Error: ${getErrorMessage(error)}`);\n    } finally {\n      setIsSubmitting(false);\n    }\n  };\n\n  return (\n    <div className=\"fixed inset-0 z-[110] flex items-center justify-center bg-black/50 p-4\">\n      <div className=\"max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-lg border border-border bg-background\">\n        <div className=\"flex items-center justify-between border-b border-border p-4\">\n          <h3 className=\"text-lg font-medium text-foreground\">\n            {isEditing ? t('mcpForm.title.edit') : t('mcpForm.title.add')}\n          </h3>\n          <Button variant=\"ghost\" size=\"sm\" onClick={onClose}>\n            <X className=\"h-4 w-4\" />\n          </Button>\n        </div>\n\n        <form onSubmit={handleSubmit} className=\"space-y-4 p-4\">\n          {!isEditing && (\n            <div className=\"mb-4 flex gap-2\">\n              <button\n                type=\"button\"\n                onClick={() => setFormData((prev) => ({ ...prev, importMode: 'form' }))}\n                className={`rounded-lg px-4 py-2 font-medium transition-colors ${\n                  formData.importMode === 'form'\n                    ? 'bg-blue-600 text-white'\n                    : 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'\n                }`}\n              >\n                {t('mcpForm.importMode.form')}\n              </button>\n              <button\n                type=\"button\"\n                onClick={() => setFormData((prev) => ({ ...prev, importMode: 'json' }))}\n                className={`rounded-lg px-4 py-2 font-medium transition-colors ${\n                  formData.importMode === 'json'\n                    ? 'bg-blue-600 text-white'\n                    : 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'\n                }`}\n              >\n                {t('mcpForm.importMode.json')}\n              </button>\n            </div>\n          )}\n\n          {formData.importMode === 'form' && isEditing && (\n            <div className=\"rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/50\">\n              <label className=\"mb-2 block text-sm font-medium text-foreground\">\n                {t('mcpForm.scope.label')}\n              </label>\n              <div className=\"flex items-center gap-2\">\n                {formData.scope === 'user' ? <Globe className=\"h-4 w-4\" /> : <FolderOpen className=\"h-4 w-4\" />}\n                <span className=\"text-sm\">\n                  {formData.scope === 'user' ? t('mcpForm.scope.userGlobal') : t('mcpForm.scope.projectLocal')}\n                </span>\n                {formData.scope === 'local' && formData.projectPath && (\n                  <span className=\"text-xs text-muted-foreground\">- {formData.projectPath}</span>\n                )}\n              </div>\n              <p className=\"mt-2 text-xs text-muted-foreground\">{t('mcpForm.scope.cannotChange')}</p>\n            </div>\n          )}\n\n          {formData.importMode === 'form' && !isEditing && (\n            <div className=\"space-y-4\">\n              <div>\n                <label className=\"mb-2 block text-sm font-medium text-foreground\">\n                  {t('mcpForm.scope.label')} *\n                </label>\n                <div className=\"flex gap-2\">\n                  <button\n                    type=\"button\"\n                    onClick={() => setFormData((prev) => ({ ...prev, scope: 'user', projectPath: '' }))}\n                    className={`flex-1 rounded-lg px-4 py-2 font-medium transition-colors ${\n                      formData.scope === 'user'\n                        ? 'bg-blue-600 text-white'\n                        : 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'\n                    }`}\n                  >\n                    <div className=\"flex items-center justify-center gap-2\">\n                      <Globe className=\"h-4 w-4\" />\n                      <span>{t('mcpForm.scope.userGlobal')}</span>\n                    </div>\n                  </button>\n                  <button\n                    type=\"button\"\n                    onClick={() => setFormData((prev) => ({ ...prev, scope: 'local' }))}\n                    className={`flex-1 rounded-lg px-4 py-2 font-medium transition-colors ${\n                      formData.scope === 'local'\n                        ? 'bg-blue-600 text-white'\n                        : 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'\n                    }`}\n                  >\n                    <div className=\"flex items-center justify-center gap-2\">\n                      <FolderOpen className=\"h-4 w-4\" />\n                      <span>{t('mcpForm.scope.projectLocal')}</span>\n                    </div>\n                  </button>\n                </div>\n                <p className=\"mt-2 text-xs text-muted-foreground\">\n                  {formData.scope === 'user'\n                    ? t('mcpForm.scope.userDescription')\n                    : t('mcpForm.scope.projectDescription')}\n                </p>\n              </div>\n\n              {formData.scope === 'local' && (\n                <div>\n                  <label className=\"mb-2 block text-sm font-medium text-foreground\">\n                    {t('mcpForm.fields.selectProject')} *\n                  </label>\n                  <select\n                    value={formData.projectPath}\n                    onChange={(event) => {\n                      setFormData((prev) => ({ ...prev, projectPath: event.target.value }));\n                    }}\n                    className=\"w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100\"\n                    required\n                  >\n                    <option value=\"\">{t('mcpForm.fields.selectProject')}...</option>\n                    {projects.map((project) => (\n                      <option key={project.name} value={project.path || project.fullPath}>\n                        {project.displayName || project.name}\n                      </option>\n                    ))}\n                  </select>\n                  {formData.projectPath && (\n                    <p className=\"mt-1 text-xs text-muted-foreground\">\n                      {t('mcpForm.projectPath', { path: formData.projectPath })}\n                    </p>\n                  )}\n                </div>\n              )}\n            </div>\n          )}\n\n          <div className=\"grid grid-cols-1 gap-4 md:grid-cols-2\">\n            <div className={formData.importMode === 'json' ? 'md:col-span-2' : ''}>\n              <label className=\"mb-2 block text-sm font-medium text-foreground\">\n                {t('mcpForm.fields.serverName')} *\n              </label>\n              <Input\n                value={formData.name}\n                onChange={(event) => setFormData((prev) => ({ ...prev, name: event.target.value }))}\n                placeholder={t('mcpForm.placeholders.serverName')}\n                required\n              />\n            </div>\n\n            {formData.importMode === 'form' && (\n              <div>\n                <label className=\"mb-2 block text-sm font-medium text-foreground\">\n                  {t('mcpForm.fields.transportType')} *\n                </label>\n                <select\n                  value={formData.type}\n                  onChange={(event) => {\n                    setFormData((prev) => ({\n                      ...prev,\n                      type: getSafeTransportType(event.target.value),\n                    }));\n                  }}\n                  className=\"w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100\"\n                >\n                  <option value=\"stdio\">stdio</option>\n                  <option value=\"sse\">SSE</option>\n                  <option value=\"http\">HTTP</option>\n                </select>\n              </div>\n            )}\n          </div>\n\n          {isEditing && Boolean(formData.raw) && formData.importMode === 'form' && (\n            <div className=\"rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/50\">\n              <h4 className=\"mb-2 text-sm font-medium text-foreground\">\n                {t('mcpForm.configDetails', {\n                  configFile: editingServer?.scope === 'global' ? '~/.claude.json' : 'project config',\n                })}\n              </h4>\n              <pre className=\"overflow-x-auto rounded bg-gray-100 p-3 text-xs dark:bg-gray-800\">\n                {JSON.stringify(formData.raw, null, 2)}\n              </pre>\n            </div>\n          )}\n\n          {formData.importMode === 'json' && (\n            <div className=\"space-y-4\">\n              <div>\n                <label className=\"mb-2 block text-sm font-medium text-foreground\">\n                  {t('mcpForm.fields.jsonConfig')} *\n                </label>\n                <textarea\n                  value={formData.jsonInput}\n                  onChange={(event) => {\n                    const value = event.target.value;\n                    setFormData((prev) => ({ ...prev, jsonInput: value }));\n                    handleJsonValidation(value);\n                  }}\n                  className={`w-full border px-3 py-2 ${\n                    jsonValidationError ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'\n                  } rounded-lg bg-gray-50 font-mono text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:bg-gray-800 dark:text-gray-100`}\n                  rows={8}\n                  placeholder={'{\\n  \"type\": \"stdio\",\\n  \"command\": \"/path/to/server\",\\n  \"args\": [\"--api-key\", \"abc123\"],\\n  \"env\": {\\n    \"CACHE_DIR\": \"/tmp\"\\n  }\\n}'}\n                  required\n                />\n                {jsonValidationError && (\n                  <p className=\"mt-1 text-xs text-red-500\">{jsonValidationError}</p>\n                )}\n                <p className=\"mt-2 text-xs text-muted-foreground\">\n                  {t('mcpForm.validation.jsonHelp')}\n                  <br />\n                  - stdio: {`{\"type\":\"stdio\",\"command\":\"npx\",\"args\":[\"@upstash/context7-mcp\"]}`}\n                  <br />\n                  - http/sse: {`{\"type\":\"http\",\"url\":\"https://api.example.com/mcp\"}`}\n                </p>\n              </div>\n            </div>\n          )}\n\n          {formData.importMode === 'form' && formData.type === 'stdio' && (\n            <div className=\"space-y-4\">\n              <div>\n                <label className=\"mb-2 block text-sm font-medium text-foreground\">\n                  {t('mcpForm.fields.command')} *\n                </label>\n                <Input\n                  value={formData.config.command}\n                  onChange={(event) => updateConfig('command', event.target.value)}\n                  placeholder=\"/path/to/mcp-server\"\n                  required\n                />\n              </div>\n\n              <div>\n                <label className=\"mb-2 block text-sm font-medium text-foreground\">\n                  {t('mcpForm.fields.arguments')}\n                </label>\n                <textarea\n                  value={formData.config.args.join('\\n')}\n                  onChange={(event) => {\n                    const args = event.target.value.split('\\n').filter((arg) => arg.trim());\n                    updateConfig('args', args);\n                  }}\n                  className=\"w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100\"\n                  rows={3}\n                  placeholder=\"--api-key&#10;abc123\"\n                />\n              </div>\n            </div>\n          )}\n\n          {formData.importMode === 'form' && (formData.type === 'sse' || formData.type === 'http') && (\n            <div>\n              <label className=\"mb-2 block text-sm font-medium text-foreground\">\n                {t('mcpForm.fields.url')} *\n              </label>\n              <Input\n                value={formData.config.url}\n                onChange={(event) => updateConfig('url', event.target.value)}\n                placeholder=\"https://api.example.com/mcp\"\n                type=\"url\"\n                required\n              />\n            </div>\n          )}\n\n          {formData.importMode === 'form' && (\n            <div>\n              <label className=\"mb-2 block text-sm font-medium text-foreground\">\n                {t('mcpForm.fields.envVars')}\n              </label>\n              <textarea\n                value={Object.entries(formData.config.env).map(([key, value]) => `${key}=${value}`).join('\\n')}\n                onChange={(event) => {\n                  const env: Record<string, string> = {};\n                  event.target.value.split('\\n').forEach((line) => {\n                    const [key, ...valueParts] = line.split('=');\n                    if (key && key.trim()) {\n                      env[key.trim()] = valueParts.join('=').trim();\n                    }\n                  });\n                  updateConfig('env', env);\n                }}\n                className=\"w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100\"\n                rows={3}\n                placeholder=\"API_KEY=your-key&#10;DEBUG=true\"\n              />\n            </div>\n          )}\n\n          {formData.importMode === 'form' && (formData.type === 'sse' || formData.type === 'http') && (\n            <div>\n              <label className=\"mb-2 block text-sm font-medium text-foreground\">\n                {t('mcpForm.fields.headers')}\n              </label>\n              <textarea\n                value={Object.entries(formData.config.headers).map(([key, value]) => `${key}=${value}`).join('\\n')}\n                onChange={(event) => {\n                  const headers: Record<string, string> = {};\n                  event.target.value.split('\\n').forEach((line) => {\n                    const [key, ...valueParts] = line.split('=');\n                    if (key && key.trim()) {\n                      headers[key.trim()] = valueParts.join('=').trim();\n                    }\n                  });\n                  updateConfig('headers', headers);\n                }}\n                className=\"w-full rounded-lg border border-gray-300 bg-gray-50 px-3 py-2 text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100\"\n                rows={3}\n                placeholder=\"Authorization=Bearer token&#10;X-API-Key=your-key\"\n              />\n            </div>\n          )}\n\n          <div className=\"flex justify-end gap-2 pt-4\">\n            <Button type=\"button\" variant=\"outline\" onClick={onClose}>\n              {t('mcpForm.actions.cancel')}\n            </Button>\n            <Button\n              type=\"submit\"\n              disabled={isSubmitting || !canSubmit}\n              className=\"bg-purple-600 hover:bg-purple-700 disabled:opacity-50\"\n            >\n              {isSubmitting\n                ? t('mcpForm.actions.saving')\n                : isEditing\n                ? t('mcpForm.actions.updateServer')\n                : t('mcpForm.actions.addServer')}\n            </Button>\n          </div>\n        </form>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/settings/view/modals/CodexMcpFormModal.tsx",
    "content": "import { useEffect, useState } from 'react';\nimport type { FormEvent } from 'react';\nimport { X } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { Button, Input } from '../../../../shared/view/ui';\nimport { DEFAULT_CODEX_MCP_FORM } from '../../constants/constants';\nimport type { CodexMcpFormState, McpServer } from '../../types/types';\n\ntype CodexMcpFormModalProps = {\n  isOpen: boolean;\n  editingServer: McpServer | null;\n  onClose: () => void;\n  onSubmit: (formData: CodexMcpFormState, editingServer: McpServer | null) => Promise<void>;\n};\n\nconst getErrorMessage = (error: unknown): string => (\n  error instanceof Error ? error.message : 'Unknown error'\n);\n\nconst createFormStateFromServer = (server: McpServer): CodexMcpFormState => ({\n  name: server.name || '',\n  type: 'stdio',\n  config: {\n    command: server.config?.command || '',\n    args: server.config?.args || [],\n    env: server.config?.env || {},\n  },\n});\n\nexport default function CodexMcpFormModal({\n  isOpen,\n  editingServer,\n  onClose,\n  onSubmit,\n}: CodexMcpFormModalProps) {\n  const { t } = useTranslation('settings');\n  const [formData, setFormData] = useState<CodexMcpFormState>(DEFAULT_CODEX_MCP_FORM);\n  const [isSubmitting, setIsSubmitting] = useState(false);\n\n  useEffect(() => {\n    if (!isOpen) {\n      return;\n    }\n\n    if (editingServer) {\n      setFormData(createFormStateFromServer(editingServer));\n      return;\n    }\n\n    setFormData(DEFAULT_CODEX_MCP_FORM);\n  }, [editingServer, isOpen]);\n\n  if (!isOpen) {\n    return null;\n  }\n\n  const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {\n    event.preventDefault();\n    setIsSubmitting(true);\n\n    try {\n      await onSubmit(formData, editingServer);\n    } catch (error) {\n      alert(`Error: ${getErrorMessage(error)}`);\n    } finally {\n      setIsSubmitting(false);\n    }\n  };\n\n  return (\n    <div className=\"fixed inset-0 z-[110] flex items-center justify-center bg-black/50 p-4\">\n      <div className=\"max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-lg border border-border bg-background\">\n        <div className=\"flex items-center justify-between border-b border-border p-4\">\n          <h3 className=\"text-lg font-medium text-foreground\">\n            {editingServer ? t('mcpForm.title.edit') : t('mcpForm.title.add')}\n          </h3>\n          <Button variant=\"ghost\" size=\"sm\" onClick={onClose}>\n            <X className=\"h-4 w-4\" />\n          </Button>\n        </div>\n\n        <form onSubmit={handleSubmit} className=\"space-y-4 p-4\">\n          <div>\n            <label className=\"mb-2 block text-sm font-medium text-foreground\">\n              {t('mcpForm.fields.serverName')} *\n            </label>\n            <Input\n              value={formData.name}\n              onChange={(event) => setFormData((prev) => ({ ...prev, name: event.target.value }))}\n              placeholder={t('mcpForm.placeholders.serverName')}\n              required\n            />\n          </div>\n\n          <div>\n            <label className=\"mb-2 block text-sm font-medium text-foreground\">\n              {t('mcpForm.fields.command')} *\n            </label>\n            <Input\n              value={formData.config.command}\n              onChange={(event) => {\n                const command = event.target.value;\n                setFormData((prev) => ({\n                  ...prev,\n                  config: { ...prev.config, command },\n                }));\n              }}\n              placeholder=\"npx @my-org/mcp-server\"\n              required\n            />\n          </div>\n\n          <div>\n            <label className=\"mb-2 block text-sm font-medium text-foreground\">\n              {t('mcpForm.fields.arguments')}\n            </label>\n            <textarea\n              value={formData.config.args.join('\\n')}\n              onChange={(event) => {\n                const args = event.target.value.split('\\n').filter((arg) => arg.trim());\n                setFormData((prev) => ({\n                  ...prev,\n                  config: { ...prev.config, args },\n                }));\n              }}\n              placeholder=\"--port&#10;3000\"\n              rows={3}\n              className=\"w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring\"\n            />\n          </div>\n\n          <div>\n            <label className=\"mb-2 block text-sm font-medium text-foreground\">\n              {t('mcpForm.fields.envVars')}\n            </label>\n            <textarea\n              value={Object.entries(formData.config.env).map(([key, value]) => `${key}=${value}`).join('\\n')}\n              onChange={(event) => {\n                const env: Record<string, string> = {};\n                event.target.value.split('\\n').forEach((line) => {\n                  const [key, ...valueParts] = line.split('=');\n                  if (key && valueParts.length > 0) {\n                    env[key.trim()] = valueParts.join('=').trim();\n                  }\n                });\n                setFormData((prev) => ({\n                  ...prev,\n                  config: { ...prev.config, env },\n                }));\n              }}\n              placeholder=\"API_KEY=xxx&#10;DEBUG=true\"\n              rows={3}\n              className=\"w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring\"\n            />\n          </div>\n\n          <div className=\"flex justify-end gap-2 border-t border-border pt-4\">\n            <Button type=\"button\" variant=\"outline\" onClick={onClose}>\n              {t('mcpForm.actions.cancel')}\n            </Button>\n            <Button\n              type=\"submit\"\n              disabled={isSubmitting || !formData.name.trim() || !formData.config.command.trim()}\n              className=\"bg-green-600 text-white hover:bg-green-700\"\n            >\n              {isSubmitting\n                ? t('mcpForm.actions.saving')\n                : editingServer\n                ? t('mcpForm.actions.updateServer')\n                : t('mcpForm.actions.addServer')}\n            </Button>\n          </div>\n        </form>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/settings/view/tabs/AppearanceSettingsTab.tsx",
    "content": "import { useTranslation } from 'react-i18next';\nimport { DarkModeToggle } from '../../../../shared/view/ui';\nimport type { CodeEditorSettingsState, ProjectSortOrder } from '../../types/types';\nimport LanguageSelector from '../../../../shared/view/ui/LanguageSelector';\nimport SettingsCard from '../SettingsCard';\nimport SettingsRow from '../SettingsRow';\nimport SettingsSection from '../SettingsSection';\nimport SettingsToggle from '../SettingsToggle';\n\ntype AppearanceSettingsTabProps = {\n  projectSortOrder: ProjectSortOrder;\n  onProjectSortOrderChange: (value: ProjectSortOrder) => void;\n  codeEditorSettings: CodeEditorSettingsState;\n  onCodeEditorThemeChange: (value: 'dark' | 'light') => void;\n  onCodeEditorWordWrapChange: (value: boolean) => void;\n  onCodeEditorShowMinimapChange: (value: boolean) => void;\n  onCodeEditorLineNumbersChange: (value: boolean) => void;\n  onCodeEditorFontSizeChange: (value: string) => void;\n};\n\nexport default function AppearanceSettingsTab({\n  projectSortOrder,\n  onProjectSortOrderChange,\n  codeEditorSettings,\n  onCodeEditorThemeChange,\n  onCodeEditorWordWrapChange,\n  onCodeEditorShowMinimapChange,\n  onCodeEditorLineNumbersChange,\n  onCodeEditorFontSizeChange,\n}: AppearanceSettingsTabProps) {\n  const { t } = useTranslation('settings');\n\n  return (\n    <div className=\"space-y-8\">\n      <SettingsSection title={t('appearanceSettings.darkMode.label')}>\n        <SettingsCard>\n          <SettingsRow\n            label={t('appearanceSettings.darkMode.label')}\n            description={t('appearanceSettings.darkMode.description')}\n          >\n            <DarkModeToggle ariaLabel={t('appearanceSettings.darkMode.label')} />\n          </SettingsRow>\n        </SettingsCard>\n      </SettingsSection>\n\n      <SettingsSection title={t('mainTabs.appearance')}>\n        <SettingsCard>\n          <LanguageSelector />\n        </SettingsCard>\n      </SettingsSection>\n\n      <SettingsSection title={t('appearanceSettings.projectSorting.label')}>\n        <SettingsCard>\n          <SettingsRow\n            label={t('appearanceSettings.projectSorting.label')}\n            description={t('appearanceSettings.projectSorting.description')}\n          >\n            <select\n              value={projectSortOrder}\n              onChange={(event) => onProjectSortOrderChange(event.target.value as ProjectSortOrder)}\n              className=\"w-full rounded-lg border border-input bg-card p-2.5 text-sm text-foreground touch-manipulation focus:border-primary focus:ring-1 focus:ring-primary sm:w-36\"\n            >\n              <option value=\"name\">{t('appearanceSettings.projectSorting.alphabetical')}</option>\n              <option value=\"date\">{t('appearanceSettings.projectSorting.recentActivity')}</option>\n            </select>\n          </SettingsRow>\n        </SettingsCard>\n      </SettingsSection>\n\n      <SettingsSection title={t('appearanceSettings.codeEditor.title')}>\n        <SettingsCard divided>\n          <SettingsRow\n            label={t('appearanceSettings.codeEditor.theme.label')}\n            description={t('appearanceSettings.codeEditor.theme.description')}\n          >\n            <DarkModeToggle\n              checked={codeEditorSettings.theme === 'dark'}\n              onToggle={(enabled) => onCodeEditorThemeChange(enabled ? 'dark' : 'light')}\n              ariaLabel={t('appearanceSettings.codeEditor.theme.label')}\n            />\n          </SettingsRow>\n\n          <SettingsRow\n            label={t('appearanceSettings.codeEditor.wordWrap.label')}\n            description={t('appearanceSettings.codeEditor.wordWrap.description')}\n          >\n            <SettingsToggle\n              checked={codeEditorSettings.wordWrap}\n              onChange={onCodeEditorWordWrapChange}\n              ariaLabel={t('appearanceSettings.codeEditor.wordWrap.label')}\n            />\n          </SettingsRow>\n\n          <SettingsRow\n            label={t('appearanceSettings.codeEditor.showMinimap.label')}\n            description={t('appearanceSettings.codeEditor.showMinimap.description')}\n          >\n            <SettingsToggle\n              checked={codeEditorSettings.showMinimap}\n              onChange={onCodeEditorShowMinimapChange}\n              ariaLabel={t('appearanceSettings.codeEditor.showMinimap.label')}\n            />\n          </SettingsRow>\n\n          <SettingsRow\n            label={t('appearanceSettings.codeEditor.lineNumbers.label')}\n            description={t('appearanceSettings.codeEditor.lineNumbers.description')}\n          >\n            <SettingsToggle\n              checked={codeEditorSettings.lineNumbers}\n              onChange={onCodeEditorLineNumbersChange}\n              ariaLabel={t('appearanceSettings.codeEditor.lineNumbers.label')}\n            />\n          </SettingsRow>\n\n          <SettingsRow\n            label={t('appearanceSettings.codeEditor.fontSize.label')}\n            description={t('appearanceSettings.codeEditor.fontSize.description')}\n          >\n            <select\n              value={codeEditorSettings.fontSize}\n              onChange={(event) => onCodeEditorFontSizeChange(event.target.value)}\n              className=\"w-full rounded-lg border border-input bg-card p-2.5 text-sm text-foreground touch-manipulation focus:border-primary focus:ring-1 focus:ring-primary sm:w-28\"\n            >\n              <option value=\"10\">10px</option>\n              <option value=\"11\">11px</option>\n              <option value=\"12\">12px</option>\n              <option value=\"13\">13px</option>\n              <option value=\"14\">14px</option>\n              <option value=\"15\">15px</option>\n              <option value=\"16\">16px</option>\n              <option value=\"18\">18px</option>\n              <option value=\"20\">20px</option>\n            </select>\n          </SettingsRow>\n        </SettingsCard>\n      </SettingsSection>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/settings/view/tabs/NotificationsSettingsTab.tsx",
    "content": "import { Bell, BellOff, BellRing, Loader2 } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport type { NotificationPreferencesState } from '../../types/types';\n\ntype NotificationsSettingsTabProps = {\n  notificationPreferences: NotificationPreferencesState;\n  onNotificationPreferencesChange: (value: NotificationPreferencesState) => void;\n  pushPermission: NotificationPermission | 'unsupported';\n  isPushSubscribed: boolean;\n  isPushLoading: boolean;\n  onEnablePush: () => void;\n  onDisablePush: () => void;\n};\n\nexport default function NotificationsSettingsTab({\n  notificationPreferences,\n  onNotificationPreferencesChange,\n  pushPermission,\n  isPushSubscribed,\n  isPushLoading,\n  onEnablePush,\n  onDisablePush,\n}: NotificationsSettingsTabProps) {\n  const { t } = useTranslation('settings');\n\n  const pushSupported = pushPermission !== 'unsupported';\n  const pushDenied = pushPermission === 'denied';\n\n  return (\n    <div className=\"space-y-6 md:space-y-8\">\n      <div className=\"space-y-4\">\n        <div className=\"flex items-center gap-3\">\n          <Bell className=\"w-5 h-5 text-blue-600\" />\n          <h3 className=\"text-lg font-medium text-foreground\">{t('notifications.title')}</h3>\n        </div>\n        <p className=\"text-sm text-muted-foreground\">{t('notifications.description')}</p>\n      </div>\n\n      <div className=\"space-y-4 bg-card border border-border rounded-lg p-4\">\n        <h4 className=\"font-medium text-foreground\">{t('notifications.webPush.title')}</h4>\n        {!pushSupported ? (\n          <p className=\"text-sm text-muted-foreground\">{t('notifications.webPush.unsupported')}</p>\n        ) : pushDenied ? (\n          <p className=\"text-sm text-muted-foreground\">{t('notifications.webPush.denied')}</p>\n        ) : (\n          <div className=\"flex items-center gap-3\">\n            <button\n              type=\"button\"\n              disabled={isPushLoading}\n              onClick={() => {\n                if (isPushSubscribed) {\n                  onDisablePush();\n                } else {\n                  onEnablePush();\n                }\n              }}\n              className={`inline-flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${\n                isPushSubscribed\n                  ? 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50'\n                  : 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600'\n              }`}\n            >\n              {isPushLoading ? (\n                <Loader2 className=\"w-4 h-4 animate-spin\" />\n              ) : isPushSubscribed ? (\n                <BellOff className=\"w-4 h-4\" />\n              ) : (\n                <BellRing className=\"w-4 h-4\" />\n              )}\n              {isPushLoading\n                ? t('notifications.webPush.loading')\n                : isPushSubscribed\n                  ? t('notifications.webPush.disable')\n                  : t('notifications.webPush.enable')}\n            </button>\n            {isPushSubscribed && (\n              <span className=\"text-sm text-green-600 dark:text-green-400\">\n                {t('notifications.webPush.enabled')}\n              </span>\n            )}\n          </div>\n        )}\n      </div>\n\n      <div className=\"space-y-4 bg-card border border-border rounded-lg p-4\">\n        <h4 className=\"font-medium text-foreground\">{t('notifications.events.title')}</h4>\n        <div className=\"space-y-3\">\n          <label className=\"flex items-center gap-2 text-sm text-foreground\">\n            <input\n              type=\"checkbox\"\n              checked={notificationPreferences.events.actionRequired}\n              onChange={(event) =>\n                onNotificationPreferencesChange({\n                  ...notificationPreferences,\n                  events: {\n                    ...notificationPreferences.events,\n                    actionRequired: event.target.checked,\n                  },\n                })\n              }\n              className=\"w-4 h-4\"\n            />\n            {t('notifications.events.actionRequired')}\n          </label>\n\n          <label className=\"flex items-center gap-2 text-sm text-foreground\">\n            <input\n              type=\"checkbox\"\n              checked={notificationPreferences.events.stop}\n              onChange={(event) =>\n                onNotificationPreferencesChange({\n                  ...notificationPreferences,\n                  events: {\n                    ...notificationPreferences.events,\n                    stop: event.target.checked,\n                  },\n                })\n              }\n              className=\"w-4 h-4\"\n            />\n            {t('notifications.events.stop')}\n          </label>\n\n          <label className=\"flex items-center gap-2 text-sm text-foreground\">\n            <input\n              type=\"checkbox\"\n              checked={notificationPreferences.events.error}\n              onChange={(event) =>\n                onNotificationPreferencesChange({\n                  ...notificationPreferences,\n                  events: {\n                    ...notificationPreferences.events,\n                    error: event.target.checked,\n                  },\n                })\n              }\n              className=\"w-4 h-4\"\n            />\n            {t('notifications.events.error')}\n          </label>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/settings/view/tabs/agents-settings/AgentListItem.tsx",
    "content": "import { cn } from '../../../../../lib/utils';\nimport SessionProviderLogo from '../../../../llm-logo-provider/SessionProviderLogo';\nimport type { AgentProvider, AuthStatus } from '../../../types/types';\n\ntype AgentListItemProps = {\n  agentId: AgentProvider;\n  authStatus: AuthStatus;\n  isSelected: boolean;\n  onClick: () => void;\n  isMobile?: boolean;\n};\n\ntype AgentConfig = {\n  name: string;\n  color: 'blue' | 'purple' | 'gray' | 'indigo';\n};\n\nconst agentConfig: Record<AgentProvider, AgentConfig> = {\n  claude: {\n    name: 'Claude',\n    color: 'blue',\n  },\n  cursor: {\n    name: 'Cursor',\n    color: 'purple',\n  },\n  codex: {\n    name: 'Codex',\n    color: 'gray',\n  },\n  gemini: {\n    name: 'Gemini',\n    color: 'indigo',\n  }\n};\n\nconst colorClasses = {\n  blue: {\n    dot: 'bg-blue-500',\n  },\n  purple: {\n    dot: 'bg-purple-500',\n  },\n  gray: {\n    dot: 'bg-foreground/60',\n  },\n  indigo: {\n    dot: 'bg-indigo-500',\n  },\n} as const;\n\nexport default function AgentListItem({\n  agentId,\n  authStatus,\n  isSelected,\n  onClick,\n  isMobile = false,\n}: AgentListItemProps) {\n  const config = agentConfig[agentId];\n  const colors = colorClasses[config.color];\n\n  if (isMobile) {\n    return (\n      <button\n        onClick={onClick}\n        className={cn(\n          'min-w-0 flex-1 touch-manipulation rounded-md px-2 py-2 text-center transition-all duration-150',\n          isSelected\n            ? 'bg-background text-foreground shadow-sm'\n            : 'text-muted-foreground active:bg-background/50',\n        )}\n      >\n        <div className=\"flex items-center justify-center gap-1.5\">\n          <SessionProviderLogo provider={agentId} className=\"h-4 w-4 flex-shrink-0\" />\n          <span className=\"truncate text-xs font-medium\">{config.name}</span>\n          {authStatus.authenticated && (\n            <span className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${colors.dot}`} />\n          )}\n        </div>\n      </button>\n    );\n  }\n\n  return (\n    <button\n      onClick={onClick}\n      className={cn(\n        'flex touch-manipulation items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-all duration-150',\n        isSelected\n          ? 'bg-background text-foreground shadow-sm'\n          : 'text-muted-foreground active:bg-background/50',\n      )}\n    >\n      <SessionProviderLogo provider={agentId} className=\"h-4 w-4 flex-shrink-0\" />\n      <span>{config.name}</span>\n      {authStatus.authenticated ? (\n        <span className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${colors.dot}`} />\n      ) : authStatus.loading ? (\n        <span className=\"h-1.5 w-1.5 flex-shrink-0 rounded-full bg-muted-foreground/30 animate-pulse\" />\n      ) : null}\n    </button>\n  );\n}\n"
  },
  {
    "path": "src/components/settings/view/tabs/agents-settings/AgentsSettingsTab.tsx",
    "content": "import { useMemo, useState } from 'react';\nimport type { AgentCategory, AgentProvider } from '../../../types/types';\nimport AgentCategoryContentSection from './sections/AgentCategoryContentSection';\nimport AgentCategoryTabsSection from './sections/AgentCategoryTabsSection';\nimport AgentSelectorSection from './sections/AgentSelectorSection';\nimport type { AgentContext, AgentsSettingsTabProps } from './types';\n\nexport default function AgentsSettingsTab({\n  claudeAuthStatus,\n  cursorAuthStatus,\n  codexAuthStatus,\n  geminiAuthStatus,\n  onClaudeLogin,\n  onCursorLogin,\n  onCodexLogin,\n  onGeminiLogin,\n  claudePermissions,\n  onClaudePermissionsChange,\n  cursorPermissions,\n  onCursorPermissionsChange,\n  codexPermissionMode,\n  onCodexPermissionModeChange,\n  geminiPermissionMode,\n  onGeminiPermissionModeChange,\n  mcpServers,\n  cursorMcpServers,\n  codexMcpServers,\n  mcpTestResults,\n  mcpServerTools,\n  mcpToolsLoading,\n  deleteError,\n  onOpenMcpForm,\n  onDeleteMcpServer,\n  onTestMcpServer,\n  onDiscoverMcpTools,\n  onOpenCodexMcpForm,\n  onDeleteCodexMcpServer,\n}: AgentsSettingsTabProps) {\n  const [selectedAgent, setSelectedAgent] = useState<AgentProvider>('claude');\n  const [selectedCategory, setSelectedCategory] = useState<AgentCategory>('account');\n\n  const agentContextById = useMemo<Record<AgentProvider, AgentContext>>(() => ({\n    claude: {\n      authStatus: claudeAuthStatus,\n      onLogin: onClaudeLogin,\n    },\n    cursor: {\n      authStatus: cursorAuthStatus,\n      onLogin: onCursorLogin,\n    },\n    codex: {\n      authStatus: codexAuthStatus,\n      onLogin: onCodexLogin,\n    },\n    gemini: {\n      authStatus: geminiAuthStatus,\n      onLogin: onGeminiLogin,\n    },\n  }), [\n    claudeAuthStatus,\n    codexAuthStatus,\n    cursorAuthStatus,\n    geminiAuthStatus,\n    onClaudeLogin,\n    onCodexLogin,\n    onCursorLogin,\n    onGeminiLogin,\n  ]);\n\n  return (\n    <div className=\"-mx-4 -mb-4 -mt-2 flex min-h-[300px] flex-col overflow-hidden md:-mx-6 md:-mb-6 md:-mt-2 md:min-h-[500px]\">\n      <AgentSelectorSection\n        selectedAgent={selectedAgent}\n        onSelectAgent={setSelectedAgent}\n        agentContextById={agentContextById}\n      />\n\n      <div className=\"flex flex-1 flex-col overflow-hidden\">\n        <AgentCategoryTabsSection\n          selectedCategory={selectedCategory}\n          onSelectCategory={setSelectedCategory}\n        />\n\n        <AgentCategoryContentSection\n          selectedAgent={selectedAgent}\n          selectedCategory={selectedCategory}\n          agentContextById={agentContextById}\n          claudePermissions={claudePermissions}\n          onClaudePermissionsChange={onClaudePermissionsChange}\n          cursorPermissions={cursorPermissions}\n          onCursorPermissionsChange={onCursorPermissionsChange}\n          codexPermissionMode={codexPermissionMode}\n          onCodexPermissionModeChange={onCodexPermissionModeChange}\n          geminiPermissionMode={geminiPermissionMode}\n          onGeminiPermissionModeChange={onGeminiPermissionModeChange}\n          mcpServers={mcpServers}\n          cursorMcpServers={cursorMcpServers}\n          codexMcpServers={codexMcpServers}\n          mcpTestResults={mcpTestResults}\n          mcpServerTools={mcpServerTools}\n          mcpToolsLoading={mcpToolsLoading}\n          deleteError={deleteError}\n          onOpenMcpForm={onOpenMcpForm}\n          onDeleteMcpServer={onDeleteMcpServer}\n          onTestMcpServer={onTestMcpServer}\n          onDiscoverMcpTools={onDiscoverMcpTools}\n          onOpenCodexMcpForm={onOpenCodexMcpForm}\n          onDeleteCodexMcpServer={onDeleteCodexMcpServer}\n        />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/settings/view/tabs/agents-settings/sections/AgentCategoryContentSection.tsx",
    "content": "import type { AgentCategoryContentSectionProps } from '../types';\nimport AccountContent from './content/AccountContent';\nimport McpServersContent from './content/McpServersContent';\nimport PermissionsContent from './content/PermissionsContent';\n\nexport default function AgentCategoryContentSection({\n  selectedAgent,\n  selectedCategory,\n  agentContextById,\n  claudePermissions,\n  onClaudePermissionsChange,\n  cursorPermissions,\n  onCursorPermissionsChange,\n  codexPermissionMode,\n  onCodexPermissionModeChange,\n  mcpServers,\n  cursorMcpServers,\n  codexMcpServers,\n  mcpTestResults,\n  mcpServerTools,\n  mcpToolsLoading,\n  deleteError,\n  onOpenMcpForm,\n  onDeleteMcpServer,\n  onTestMcpServer,\n  onDiscoverMcpTools,\n  onOpenCodexMcpForm,\n  onDeleteCodexMcpServer,\n}: AgentCategoryContentSectionProps) {\n  // Cursor MCP add/edit/delete was previously a placeholder and is intentionally preserved.\n  const noopCursorMcpAction = () => {};\n\n  return (\n    <div className=\"flex-1 overflow-y-auto p-3 md:p-4\">\n      {selectedCategory === 'account' && (\n        <AccountContent\n          agent={selectedAgent}\n          authStatus={agentContextById[selectedAgent].authStatus}\n          onLogin={agentContextById[selectedAgent].onLogin}\n        />\n      )}\n\n      {selectedCategory === 'permissions' && selectedAgent === 'claude' && (\n        <PermissionsContent\n          agent=\"claude\"\n          skipPermissions={claudePermissions.skipPermissions}\n          onSkipPermissionsChange={(value) => {\n            onClaudePermissionsChange({ ...claudePermissions, skipPermissions: value });\n          }}\n          allowedTools={claudePermissions.allowedTools}\n          onAllowedToolsChange={(value) => {\n            onClaudePermissionsChange({ ...claudePermissions, allowedTools: value });\n          }}\n          disallowedTools={claudePermissions.disallowedTools}\n          onDisallowedToolsChange={(value) => {\n            onClaudePermissionsChange({ ...claudePermissions, disallowedTools: value });\n          }}\n        />\n      )}\n\n      {selectedCategory === 'permissions' && selectedAgent === 'cursor' && (\n        <PermissionsContent\n          agent=\"cursor\"\n          skipPermissions={cursorPermissions.skipPermissions}\n          onSkipPermissionsChange={(value) => {\n            onCursorPermissionsChange({ ...cursorPermissions, skipPermissions: value });\n          }}\n          allowedCommands={cursorPermissions.allowedCommands}\n          onAllowedCommandsChange={(value) => {\n            onCursorPermissionsChange({ ...cursorPermissions, allowedCommands: value });\n          }}\n          disallowedCommands={cursorPermissions.disallowedCommands}\n          onDisallowedCommandsChange={(value) => {\n            onCursorPermissionsChange({ ...cursorPermissions, disallowedCommands: value });\n          }}\n        />\n      )}\n\n      {selectedCategory === 'permissions' && selectedAgent === 'codex' && (\n        <PermissionsContent\n          agent=\"codex\"\n          permissionMode={codexPermissionMode}\n          onPermissionModeChange={onCodexPermissionModeChange}\n        />\n      )}\n\n      {selectedCategory === 'mcp' && selectedAgent === 'claude' && (\n        <McpServersContent\n          agent=\"claude\"\n          servers={mcpServers}\n          onAdd={() => onOpenMcpForm()}\n          onEdit={(server) => onOpenMcpForm(server)}\n          onDelete={onDeleteMcpServer}\n          onTest={onTestMcpServer}\n          onDiscoverTools={onDiscoverMcpTools}\n          testResults={mcpTestResults}\n          serverTools={mcpServerTools}\n          toolsLoading={mcpToolsLoading}\n          deleteError={deleteError}\n        />\n      )}\n\n      {selectedCategory === 'mcp' && selectedAgent === 'cursor' && (\n        <McpServersContent\n          agent=\"cursor\"\n          servers={cursorMcpServers}\n          onAdd={noopCursorMcpAction}\n          onEdit={noopCursorMcpAction}\n          onDelete={noopCursorMcpAction}\n        />\n      )}\n\n      {selectedCategory === 'mcp' && selectedAgent === 'codex' && (\n        <McpServersContent\n          agent=\"codex\"\n          servers={codexMcpServers}\n          onAdd={() => onOpenCodexMcpForm()}\n          onEdit={(server) => onOpenCodexMcpForm(server)}\n          onDelete={(serverId) => onDeleteCodexMcpServer(serverId)}\n          deleteError={deleteError}\n        />\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/settings/view/tabs/agents-settings/sections/AgentCategoryTabsSection.tsx",
    "content": "import { useTranslation } from 'react-i18next';\nimport { cn } from '../../../../../../lib/utils';\nimport type { AgentCategory } from '../../../../types/types';\nimport type { AgentCategoryTabsSectionProps } from '../types';\n\nconst AGENT_CATEGORIES: AgentCategory[] = ['account', 'permissions', 'mcp'];\n\nexport default function AgentCategoryTabsSection({\n  selectedCategory,\n  onSelectCategory,\n}: AgentCategoryTabsSectionProps) {\n  const { t } = useTranslation('settings');\n\n  return (\n    <div className=\"flex-shrink-0 border-b border-border\">\n      <div role=\"tablist\" className=\"flex overflow-x-auto px-2 md:px-4\">\n        {AGENT_CATEGORIES.map((category) => (\n          <button\n            key={category}\n            role=\"tab\"\n            aria-selected={selectedCategory === category}\n            onClick={() => onSelectCategory(category)}\n            className={cn(\n              'whitespace-nowrap border-b-2 px-4 py-3 text-sm font-medium touch-manipulation transition-colors duration-150',\n              selectedCategory === category\n                ? 'border-primary text-primary'\n                : 'border-transparent text-muted-foreground hover:text-foreground',\n            )}\n          >\n            {category === 'account' && t('tabs.account')}\n            {category === 'permissions' && t('tabs.permissions')}\n            {category === 'mcp' && t('tabs.mcpServers')}\n          </button>\n        ))}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/settings/view/tabs/agents-settings/sections/AgentSelectorSection.tsx",
    "content": "import { PillBar, Pill } from '../../../../../../shared/view/ui';\nimport SessionProviderLogo from '../../../../../llm-logo-provider/SessionProviderLogo';\nimport type { AgentProvider } from '../../../../types/types';\nimport type { AgentSelectorSectionProps } from '../types';\n\nconst AGENT_PROVIDERS: AgentProvider[] = ['claude', 'cursor', 'codex', 'gemini'];\n\nconst AGENT_NAMES: Record<AgentProvider, string> = {\n  claude: 'Claude',\n  cursor: 'Cursor',\n  codex: 'Codex',\n  gemini: 'Gemini',\n};\n\nexport default function AgentSelectorSection({\n  selectedAgent,\n  onSelectAgent,\n  agentContextById,\n}: AgentSelectorSectionProps) {\n  return (\n    <div className=\"flex-shrink-0 border-b border-border px-3 py-2 md:px-4 md:py-3\">\n      <PillBar className=\"w-full md:w-auto\">\n        {AGENT_PROVIDERS.map((agent) => {\n          const dotColor =\n            agent === 'claude' ? 'bg-blue-500' :\n            agent === 'cursor' ? 'bg-purple-500' :\n            agent === 'gemini' ? 'bg-indigo-500' : 'bg-foreground/60';\n\n          return (\n            <Pill\n              key={agent}\n              isActive={selectedAgent === agent}\n              onClick={() => onSelectAgent(agent)}\n              className=\"min-w-0 flex-1 justify-center md:flex-initial\"\n            >\n              <SessionProviderLogo provider={agent} className=\"h-4 w-4 flex-shrink-0\" />\n              <span className=\"truncate\">{AGENT_NAMES[agent]}</span>\n              {agentContextById[agent].authStatus.authenticated && (\n                <span className={`h-1.5 w-1.5 flex-shrink-0 rounded-full ${dotColor}`} />\n              )}\n            </Pill>\n          );\n        })}\n      </PillBar>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/settings/view/tabs/agents-settings/sections/content/AccountContent.tsx",
    "content": "import { LogIn } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { Badge, Button } from '../../../../../../../shared/view/ui';\nimport SessionProviderLogo from '../../../../../../llm-logo-provider/SessionProviderLogo';\nimport type { AgentProvider, AuthStatus } from '../../../../../types/types';\n\ntype AccountContentProps = {\n  agent: AgentProvider;\n  authStatus: AuthStatus;\n  onLogin: () => void;\n};\n\ntype AgentVisualConfig = {\n  name: string;\n  bgClass: string;\n  borderClass: string;\n  textClass: string;\n  subtextClass: string;\n  buttonClass: string;\n  description?: string;\n};\n\nconst agentConfig: Record<AgentProvider, AgentVisualConfig> = {\n  claude: {\n    name: 'Claude',\n    bgClass: 'bg-blue-50 dark:bg-blue-900/20',\n    borderClass: 'border-blue-200 dark:border-blue-800',\n    textClass: 'text-blue-900 dark:text-blue-100',\n    subtextClass: 'text-blue-700 dark:text-blue-300',\n    buttonClass: 'bg-blue-600 hover:bg-blue-700 active:bg-blue-800',\n  },\n  cursor: {\n    name: 'Cursor',\n    bgClass: 'bg-purple-50 dark:bg-purple-900/20',\n    borderClass: 'border-purple-200 dark:border-purple-800',\n    textClass: 'text-purple-900 dark:text-purple-100',\n    subtextClass: 'text-purple-700 dark:text-purple-300',\n    buttonClass: 'bg-purple-600 hover:bg-purple-700 active:bg-purple-800',\n  },\n  codex: {\n    name: 'Codex',\n    bgClass: 'bg-muted/50',\n    borderClass: 'border-gray-300 dark:border-gray-600',\n    textClass: 'text-gray-900 dark:text-gray-100',\n    subtextClass: 'text-gray-700 dark:text-gray-300',\n    buttonClass: 'bg-gray-800 hover:bg-gray-900 active:bg-gray-950 dark:bg-gray-700 dark:hover:bg-gray-600 dark:active:bg-gray-500',\n  },\n  gemini: {\n    name: 'Gemini',\n    description: 'Google Gemini AI assistant',\n    bgClass: 'bg-indigo-50 dark:bg-indigo-900/20',\n    borderClass: 'border-indigo-200 dark:border-indigo-800',\n    textClass: 'text-indigo-900 dark:text-indigo-100',\n    subtextClass: 'text-indigo-700 dark:text-indigo-300',\n    buttonClass: 'bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800',\n  },\n};\n\nexport default function AccountContent({ agent, authStatus, onLogin }: AccountContentProps) {\n  const { t } = useTranslation('settings');\n  const config = agentConfig[agent];\n\n  return (\n    <div className=\"space-y-6\">\n      <div className=\"mb-4 flex items-center gap-3\">\n        <SessionProviderLogo provider={agent} className=\"h-6 w-6\" />\n        <div>\n          <h3 className=\"text-lg font-medium text-foreground\">{config.name}</h3>\n          <p className=\"text-sm text-muted-foreground\">{t(`agents.account.${agent}.description`)}</p>\n        </div>\n      </div>\n\n      <div className={`${config.bgClass} border ${config.borderClass} rounded-lg p-4`}>\n        <div className=\"space-y-4\">\n          <div className=\"flex items-center gap-3\">\n            <div className=\"flex-1\">\n              <div className={`font-medium ${config.textClass}`}>\n                {t('agents.connectionStatus')}\n              </div>\n              <div className={`text-sm ${config.subtextClass}`}>\n                {authStatus.loading ? (\n                  t('agents.authStatus.checkingAuth')\n                ) : authStatus.authenticated ? (\n                  t('agents.authStatus.loggedInAs', {\n                    email: authStatus.email || t('agents.authStatus.authenticatedUser'),\n                  })\n                ) : (\n                  t('agents.authStatus.notConnected')\n                )}\n              </div>\n            </div>\n            <div>\n              {authStatus.loading ? (\n                <Badge variant=\"secondary\" className=\"bg-muted\">\n                  {t('agents.authStatus.checking')}\n                </Badge>\n              ) : authStatus.authenticated ? (\n                <Badge variant=\"secondary\" className=\"bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300\">\n                  {t('agents.authStatus.connected')}\n                </Badge>\n              ) : (\n                <Badge variant=\"secondary\" className=\"bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300\">\n                  {t('agents.authStatus.disconnected')}\n                </Badge>\n              )}\n            </div>\n          </div>\n\n          {authStatus.method !== 'api_key' && (\n            <div className=\"border-t border-border/50 pt-4\">\n              <div className=\"flex items-center justify-between\">\n                <div>\n                  <div className={`font-medium ${config.textClass}`}>\n                    {authStatus.authenticated ? t('agents.login.reAuthenticate') : t('agents.login.title')}\n                  </div>\n                  <div className={`text-sm ${config.subtextClass}`}>\n                    {authStatus.authenticated\n                      ? t('agents.login.reAuthDescription')\n                      : t('agents.login.description', { agent: config.name })}\n                  </div>\n                </div>\n                <Button\n                  onClick={onLogin}\n                  className={`${config.buttonClass} text-white`}\n                  size=\"sm\"\n                >\n                  <LogIn className=\"mr-2 h-4 w-4\" />\n                  {authStatus.authenticated ? t('agents.login.reLoginButton') : t('agents.login.button')}\n                </Button>\n              </div>\n            </div>\n          )}\n\n          {authStatus.error && (\n            <div className=\"border-t border-border/50 pt-4\">\n              <div className=\"text-sm text-red-600 dark:text-red-400\">\n                {t('agents.error', { error: authStatus.error })}\n              </div>\n            </div>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/settings/view/tabs/agents-settings/sections/content/McpServersContent.tsx",
    "content": "import { Edit3, Globe, Plus, Server, Terminal, Trash2, Zap } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { Badge, Button } from '../../../../../../../shared/view/ui';\nimport type { McpServer, McpToolsResult, McpTestResult } from '../../../../../types/types';\n\nconst getTransportIcon = (type: string | undefined) => {\n  if (type === 'stdio') {\n    return <Terminal className=\"h-4 w-4\" />;\n  }\n\n  if (type === 'sse') {\n    return <Zap className=\"h-4 w-4\" />;\n  }\n\n  if (type === 'http') {\n    return <Globe className=\"h-4 w-4\" />;\n  }\n\n  return <Server className=\"h-4 w-4\" />;\n};\n\nconst maskSecret = (value: unknown): string => {\n  const normalizedValue = String(value ?? '');\n  if (normalizedValue.length <= 4) {\n    return '****';\n  }\n\n  return `${normalizedValue.slice(0, 2)}****${normalizedValue.slice(-2)}`;\n};\n\ntype ClaudeMcpServersProps = {\n  agent: 'claude';\n  servers: McpServer[];\n  onAdd: () => void;\n  onEdit: (server: McpServer) => void;\n  onDelete: (serverId: string, scope?: string) => void;\n  onTest: (serverId: string, scope?: string) => void;\n  onDiscoverTools: (serverId: string, scope?: string) => void;\n  testResults: Record<string, McpTestResult>;\n  serverTools: Record<string, McpToolsResult>;\n  toolsLoading: Record<string, boolean>;\n  deleteError?: string | null;\n};\n\nfunction ClaudeMcpServers({\n  servers,\n  onAdd,\n  onEdit,\n  onDelete,\n  testResults,\n  serverTools,\n  deleteError,\n}: Omit<ClaudeMcpServersProps, 'agent' | 'onTest' | 'onDiscoverTools' | 'toolsLoading'>) {\n  const { t } = useTranslation('settings');\n\n  return (\n    <div className=\"space-y-4\">\n      <div className=\"flex items-center gap-3\">\n        <Server className=\"h-5 w-5 text-purple-500\" />\n        <h3 className=\"text-lg font-medium text-foreground\">{t('mcpServers.title')}</h3>\n      </div>\n      <p className=\"text-sm text-muted-foreground\">{t('mcpServers.description.claude')}</p>\n\n      <div className=\"flex items-center justify-between\">\n        <Button onClick={onAdd} className=\"bg-purple-600 text-white hover:bg-purple-700\" size=\"sm\">\n          <Plus className=\"mr-2 h-4 w-4\" />\n          {t('mcpServers.addButton')}\n        </Button>\n      </div>\n      {deleteError && (\n        <div className=\"rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800/60 dark:bg-red-900/20 dark:text-red-200\">\n          {deleteError}\n        </div>\n      )}\n\n      <div className=\"space-y-2\">\n        {servers.map((server) => {\n          const serverId = server.id || server.name;\n          const testResult = testResults[serverId];\n          const toolsResult = serverTools[serverId];\n\n          return (\n            <div key={serverId} className=\"rounded-lg border border-border bg-card/50 p-4\">\n              <div className=\"flex items-start justify-between\">\n                <div className=\"flex-1\">\n                  <div className=\"mb-2 flex items-center gap-2\">\n                    {getTransportIcon(server.type)}\n                    <span className=\"font-medium text-foreground\">{server.name}</span>\n                    <Badge variant=\"outline\" className=\"text-xs\">\n                      {server.type || 'stdio'}\n                    </Badge>\n                    <Badge variant=\"outline\" className=\"text-xs\">\n                      {server.scope === 'local'\n                        ? t('mcpServers.scope.local')\n                        : server.scope === 'user'\n                        ? t('mcpServers.scope.user')\n                        : server.scope}\n                    </Badge>\n                  </div>\n\n                  <div className=\"space-y-1 text-sm text-muted-foreground\">\n                    {server.type === 'stdio' && server.config?.command && (\n                      <div>\n                        {t('mcpServers.config.command')}:{' '}\n                        <code className=\"rounded bg-muted px-1 text-xs\">{server.config.command}</code>\n                      </div>\n                    )}\n                    {(server.type === 'sse' || server.type === 'http') && server.config?.url && (\n                      <div>\n                        {t('mcpServers.config.url')}:{' '}\n                        <code className=\"rounded bg-muted px-1 text-xs\">{server.config.url}</code>\n                      </div>\n                    )}\n                    {server.config?.args && server.config.args.length > 0 && (\n                      <div>\n                        {t('mcpServers.config.args')}:{' '}\n                        <code className=\"rounded bg-muted px-1 text-xs\">{server.config.args.join(' ')}</code>\n                      </div>\n                    )}\n                  </div>\n\n                  {testResult && (\n                    <div className={`mt-2 rounded p-2 text-xs ${\n                      testResult.success\n                        ? 'bg-green-50 text-green-800 dark:bg-green-900/20 dark:text-green-200'\n                        : 'bg-red-50 text-red-800 dark:bg-red-900/20 dark:text-red-200'\n                    }`}\n                    >\n                      <div className=\"font-medium\">{testResult.message}</div>\n                    </div>\n                  )}\n\n                  {toolsResult && toolsResult.tools && toolsResult.tools.length > 0 && (\n                    <div className=\"mt-2 rounded bg-blue-50 p-2 text-xs text-blue-800 dark:bg-blue-900/20 dark:text-blue-200\">\n                      <div className=\"font-medium\">\n                        {t('mcpServers.tools.title')} {t('mcpServers.tools.count', { count: toolsResult.tools.length })}\n                      </div>\n                      <div className=\"mt-1 flex flex-wrap gap-1\">\n                        {toolsResult.tools.slice(0, 5).map((tool, index) => (\n                          <code key={`${tool.name}-${index}`} className=\"rounded bg-blue-100 px-1 dark:bg-blue-800\">\n                            {tool.name}\n                          </code>\n                        ))}\n                        {toolsResult.tools.length > 5 && (\n                          <span className=\"text-xs opacity-75\">\n                            {t('mcpServers.tools.more', { count: toolsResult.tools.length - 5 })}\n                          </span>\n                        )}\n                      </div>\n                    </div>\n                  )}\n                </div>\n\n                <div className=\"ml-4 flex items-center gap-2\">\n                  <Button\n                    onClick={() => onEdit(server)}\n                    variant=\"ghost\"\n                    size=\"sm\"\n                    className=\"text-muted-foreground hover:text-foreground\"\n                    title={t('mcpServers.actions.edit')}\n                  >\n                    <Edit3 className=\"h-4 w-4\" />\n                  </Button>\n                  <Button\n                    onClick={() => onDelete(serverId, server.scope)}\n                    variant=\"ghost\"\n                    size=\"sm\"\n                    className=\"text-red-600 hover:text-red-700\"\n                    title={t('mcpServers.actions.delete')}\n                  >\n                    <Trash2 className=\"h-4 w-4\" />\n                  </Button>\n                </div>\n              </div>\n            </div>\n          );\n        })}\n        {servers.length === 0 && (\n          <div className=\"py-8 text-center text-muted-foreground\">{t('mcpServers.empty')}</div>\n        )}\n      </div>\n    </div>\n  );\n}\n\ntype CursorMcpServersProps = {\n  agent: 'cursor';\n  servers: McpServer[];\n  onAdd: () => void;\n  onEdit: (server: McpServer) => void;\n  onDelete: (serverId: string) => void;\n};\n\nfunction CursorMcpServers({ servers, onAdd, onEdit, onDelete }: Omit<CursorMcpServersProps, 'agent'>) {\n  const { t } = useTranslation('settings');\n\n  return (\n    <div className=\"space-y-4\">\n      <div className=\"flex items-center gap-3\">\n        <Server className=\"h-5 w-5 text-purple-500\" />\n        <h3 className=\"text-lg font-medium text-foreground\">{t('mcpServers.title')}</h3>\n      </div>\n      <p className=\"text-sm text-muted-foreground\">{t('mcpServers.description.cursor')}</p>\n\n      <div className=\"flex items-center justify-between\">\n        <Button onClick={onAdd} className=\"bg-purple-600 text-white hover:bg-purple-700\" size=\"sm\">\n          <Plus className=\"mr-2 h-4 w-4\" />\n          {t('mcpServers.addButton')}\n        </Button>\n      </div>\n\n      <div className=\"space-y-2\">\n        {servers.map((server) => {\n          const serverId = server.id || server.name;\n\n          return (\n            <div key={serverId} className=\"rounded-lg border border-border bg-card/50 p-4\">\n              <div className=\"flex items-start justify-between\">\n                <div className=\"flex-1\">\n                  <div className=\"mb-2 flex items-center gap-2\">\n                    <Terminal className=\"h-4 w-4\" />\n                    <span className=\"font-medium text-foreground\">{server.name}</span>\n                    <Badge variant=\"outline\" className=\"text-xs\">stdio</Badge>\n                  </div>\n                  <div className=\"text-sm text-muted-foreground\">\n                    {server.config?.command && (\n                      <div>\n                        {t('mcpServers.config.command')}:{' '}\n                        <code className=\"rounded bg-muted px-1 text-xs\">{server.config.command}</code>\n                      </div>\n                    )}\n                  </div>\n                </div>\n                <div className=\"ml-4 flex items-center gap-2\">\n                  <Button\n                    onClick={() => onEdit(server)}\n                    variant=\"ghost\"\n                    size=\"sm\"\n                    className=\"text-muted-foreground hover:text-foreground\"\n                    title={t('mcpServers.actions.edit')}\n                  >\n                    <Edit3 className=\"h-4 w-4\" />\n                  </Button>\n                  <Button\n                    onClick={() => onDelete(serverId)}\n                    variant=\"ghost\"\n                    size=\"sm\"\n                    className=\"text-red-600 hover:text-red-700\"\n                    title={t('mcpServers.actions.delete')}\n                  >\n                    <Trash2 className=\"h-4 w-4\" />\n                  </Button>\n                </div>\n              </div>\n            </div>\n          );\n        })}\n        {servers.length === 0 && (\n          <div className=\"py-8 text-center text-muted-foreground\">{t('mcpServers.empty')}</div>\n        )}\n      </div>\n    </div>\n  );\n}\n\ntype CodexMcpServersProps = {\n  agent: 'codex';\n  servers: McpServer[];\n  onAdd: () => void;\n  onEdit: (server: McpServer) => void;\n  onDelete: (serverId: string) => void;\n  deleteError?: string | null;\n};\n\nfunction CodexMcpServers({ servers, onAdd, onEdit, onDelete, deleteError }: Omit<CodexMcpServersProps, 'agent'>) {\n  const { t } = useTranslation('settings');\n\n  return (\n    <div className=\"space-y-4\">\n      <div className=\"flex items-center gap-3\">\n        <Server className=\"h-5 w-5 text-muted-foreground\" />\n        <h3 className=\"text-lg font-medium text-foreground\">{t('mcpServers.title')}</h3>\n      </div>\n      <p className=\"text-sm text-muted-foreground\">{t('mcpServers.description.codex')}</p>\n\n      <div className=\"flex items-center justify-between\">\n        <Button onClick={onAdd} className=\"bg-gray-800 text-white hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-600\" size=\"sm\">\n          <Plus className=\"mr-2 h-4 w-4\" />\n          {t('mcpServers.addButton')}\n        </Button>\n      </div>\n      {deleteError && (\n        <div className=\"rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800/60 dark:bg-red-900/20 dark:text-red-200\">\n          {deleteError}\n        </div>\n      )}\n\n      <div className=\"space-y-2\">\n        {servers.map((server) => (\n          <div key={server.name} className=\"rounded-lg border border-border bg-card/50 p-4\">\n            <div className=\"flex items-start justify-between\">\n              <div className=\"flex-1\">\n                <div className=\"mb-2 flex items-center gap-2\">\n                  <Terminal className=\"h-4 w-4\" />\n                  <span className=\"font-medium text-foreground\">{server.name}</span>\n                  <Badge variant=\"outline\" className=\"text-xs\">stdio</Badge>\n                </div>\n\n                <div className=\"space-y-1 text-sm text-muted-foreground\">\n                  {server.config?.command && (\n                    <div>\n                      {t('mcpServers.config.command')}:{' '}\n                      <code className=\"rounded bg-muted px-1 text-xs\">{server.config.command}</code>\n                    </div>\n                  )}\n                  {server.config?.args && server.config.args.length > 0 && (\n                    <div>\n                      {t('mcpServers.config.args')}:{' '}\n                      <code className=\"rounded bg-muted px-1 text-xs\">{server.config.args.join(' ')}</code>\n                    </div>\n                  )}\n                  {server.config?.env && Object.keys(server.config.env).length > 0 && (\n                    <div>\n                      {t('mcpServers.config.environment')}:{' '}\n                      <code className=\"rounded bg-muted px-1 text-xs\">\n                        {Object.entries(server.config.env).map(([key, value]) => `${key}=${maskSecret(value)}`).join(', ')}\n                      </code>\n                    </div>\n                  )}\n                </div>\n              </div>\n\n              <div className=\"ml-4 flex items-center gap-2\">\n                <Button\n                  onClick={() => onEdit(server)}\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  className=\"text-muted-foreground hover:text-foreground\"\n                  title={t('mcpServers.actions.edit')}\n                >\n                  <Edit3 className=\"h-4 w-4\" />\n                </Button>\n                <Button\n                  onClick={() => onDelete(server.name)}\n                  variant=\"ghost\"\n                  size=\"sm\"\n                  className=\"text-red-600 hover:text-red-700\"\n                  title={t('mcpServers.actions.delete')}\n                >\n                  <Trash2 className=\"h-4 w-4\" />\n                </Button>\n              </div>\n            </div>\n          </div>\n        ))}\n        {servers.length === 0 && (\n          <div className=\"py-8 text-center text-muted-foreground\">{t('mcpServers.empty')}</div>\n        )}\n      </div>\n\n      <div className=\"rounded-lg border border-border bg-muted/50 p-4\">\n        <h4 className=\"mb-2 font-medium text-foreground\">{t('mcpServers.help.title')}</h4>\n        <p className=\"text-sm text-muted-foreground\">{t('mcpServers.help.description')}</p>\n      </div>\n    </div>\n  );\n}\n\ntype McpServersContentProps = ClaudeMcpServersProps | CursorMcpServersProps | CodexMcpServersProps;\n\nexport default function McpServersContent(props: McpServersContentProps) {\n  if (props.agent === 'claude') {\n    return <ClaudeMcpServers {...props} />;\n  }\n\n  if (props.agent === 'cursor') {\n    return <CursorMcpServers {...props} />;\n  }\n\n  return <CodexMcpServers {...props} />;\n}\n"
  },
  {
    "path": "src/components/settings/view/tabs/agents-settings/sections/content/PermissionsContent.tsx",
    "content": "import { useState } from 'react';\nimport { AlertTriangle, Plus, Shield, X } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { Button, Input } from '../../../../../../../shared/view/ui';\nimport type { CodexPermissionMode, GeminiPermissionMode } from '../../../../../types/types';\n\nconst COMMON_CLAUDE_TOOLS = [\n  'Bash(git log:*)',\n  'Bash(git diff:*)',\n  'Bash(git status:*)',\n  'Write',\n  'Read',\n  'Edit',\n  'Glob',\n  'Grep',\n  'MultiEdit',\n  'Task',\n  'TodoWrite',\n  'TodoRead',\n  'WebFetch',\n  'WebSearch',\n];\n\nconst COMMON_CURSOR_COMMANDS = [\n  'Shell(ls)',\n  'Shell(mkdir)',\n  'Shell(cd)',\n  'Shell(cat)',\n  'Shell(echo)',\n  'Shell(git status)',\n  'Shell(git diff)',\n  'Shell(git log)',\n  'Shell(npm install)',\n  'Shell(npm run)',\n  'Shell(python)',\n  'Shell(node)',\n];\n\nconst addUnique = (items: string[], value: string): string[] => {\n  const normalizedValue = value.trim();\n  if (!normalizedValue || items.includes(normalizedValue)) {\n    return items;\n  }\n\n  return [...items, normalizedValue];\n};\n\nconst removeValue = (items: string[], value: string): string[] => (\n  items.filter((item) => item !== value)\n);\n\ntype ClaudePermissionsProps = {\n  agent: 'claude';\n  skipPermissions: boolean;\n  onSkipPermissionsChange: (value: boolean) => void;\n  allowedTools: string[];\n  onAllowedToolsChange: (value: string[]) => void;\n  disallowedTools: string[];\n  onDisallowedToolsChange: (value: string[]) => void;\n};\n\nfunction ClaudePermissions({\n  skipPermissions,\n  onSkipPermissionsChange,\n  allowedTools,\n  onAllowedToolsChange,\n  disallowedTools,\n  onDisallowedToolsChange,\n}: Omit<ClaudePermissionsProps, 'agent'>) {\n  const { t } = useTranslation('settings');\n  const [newAllowedTool, setNewAllowedTool] = useState('');\n  const [newDisallowedTool, setNewDisallowedTool] = useState('');\n\n  const handleAddAllowedTool = (tool: string) => {\n    const updated = addUnique(allowedTools, tool);\n    if (updated.length === allowedTools.length) {\n      return;\n    }\n\n    onAllowedToolsChange(updated);\n    setNewAllowedTool('');\n  };\n\n  const handleAddDisallowedTool = (tool: string) => {\n    const updated = addUnique(disallowedTools, tool);\n    if (updated.length === disallowedTools.length) {\n      return;\n    }\n\n    onDisallowedToolsChange(updated);\n    setNewDisallowedTool('');\n  };\n\n  return (\n    <div className=\"space-y-6\">\n      <div className=\"space-y-4\">\n        <div className=\"flex items-center gap-3\">\n          <AlertTriangle className=\"h-5 w-5 text-orange-500\" />\n          <h3 className=\"text-lg font-medium text-foreground\">{t('permissions.title')}</h3>\n        </div>\n        <div className=\"rounded-lg border border-orange-200 bg-orange-50 p-4 dark:border-orange-800 dark:bg-orange-900/20\">\n          <label className=\"flex items-center gap-3\">\n            <input\n              type=\"checkbox\"\n              checked={skipPermissions}\n              onChange={(event) => onSkipPermissionsChange(event.target.checked)}\n              className=\"h-4 w-4 rounded border-input bg-card text-primary focus:ring-2 focus:ring-primary\"\n            />\n            <div>\n              <div className=\"font-medium text-orange-900 dark:text-orange-100\">\n                {t('permissions.skipPermissions.label')}\n              </div>\n              <div className=\"text-sm text-orange-700 dark:text-orange-300\">\n                {t('permissions.skipPermissions.claudeDescription')}\n              </div>\n            </div>\n          </label>\n        </div>\n      </div>\n\n      <div className=\"space-y-4\">\n        <div className=\"flex items-center gap-3\">\n          <Shield className=\"h-5 w-5 text-green-500\" />\n          <h3 className=\"text-lg font-medium text-foreground\">{t('permissions.allowedTools.title')}</h3>\n        </div>\n        <p className=\"text-sm text-muted-foreground\">{t('permissions.allowedTools.description')}</p>\n\n        <div className=\"flex flex-col gap-2 sm:flex-row\">\n          <Input\n            value={newAllowedTool}\n            onChange={(event) => setNewAllowedTool(event.target.value)}\n            placeholder={t('permissions.allowedTools.placeholder')}\n            onKeyDown={(event) => {\n              if (event.key === 'Enter') {\n                event.preventDefault();\n                handleAddAllowedTool(newAllowedTool);\n              }\n            }}\n            className=\"h-10 flex-1\"\n          />\n          <Button\n            onClick={() => handleAddAllowedTool(newAllowedTool)}\n            disabled={!newAllowedTool.trim()}\n            size=\"sm\"\n            className=\"h-10 px-4\"\n          >\n            <Plus className=\"mr-2 h-4 w-4 sm:mr-0\" />\n            <span className=\"sm:hidden\">{t('permissions.actions.add')}</span>\n          </Button>\n        </div>\n\n        <div className=\"space-y-2\">\n          <p className=\"text-sm font-medium text-muted-foreground\">\n            {t('permissions.allowedTools.quickAdd')}\n          </p>\n          <div className=\"flex flex-wrap gap-2\">\n            {COMMON_CLAUDE_TOOLS.map((tool) => (\n              <Button\n                key={tool}\n                variant=\"outline\"\n                size=\"sm\"\n                onClick={() => handleAddAllowedTool(tool)}\n                disabled={allowedTools.includes(tool)}\n                className=\"h-8 text-xs\"\n              >\n                {tool}\n              </Button>\n            ))}\n          </div>\n        </div>\n\n        <div className=\"space-y-2\">\n          {allowedTools.map((tool) => (\n            <div key={tool} className=\"flex items-center justify-between rounded-lg border border-green-200 bg-green-50 p-3 dark:border-green-800 dark:bg-green-900/20\">\n              <span className=\"font-mono text-sm text-green-800 dark:text-green-200\">{tool}</span>\n              <Button\n                variant=\"ghost\"\n                size=\"sm\"\n                onClick={() => onAllowedToolsChange(removeValue(allowedTools, tool))}\n                className=\"text-green-600 hover:text-green-700\"\n              >\n                <X className=\"h-4 w-4\" />\n              </Button>\n            </div>\n          ))}\n          {allowedTools.length === 0 && (\n            <div className=\"py-6 text-center text-muted-foreground\">\n              {t('permissions.allowedTools.empty')}\n            </div>\n          )}\n        </div>\n      </div>\n\n      <div className=\"space-y-4\">\n        <div className=\"flex items-center gap-3\">\n          <AlertTriangle className=\"h-5 w-5 text-red-500\" />\n          <h3 className=\"text-lg font-medium text-foreground\">{t('permissions.blockedTools.title')}</h3>\n        </div>\n        <p className=\"text-sm text-muted-foreground\">{t('permissions.blockedTools.description')}</p>\n\n        <div className=\"flex flex-col gap-2 sm:flex-row\">\n          <Input\n            value={newDisallowedTool}\n            onChange={(event) => setNewDisallowedTool(event.target.value)}\n            placeholder={t('permissions.blockedTools.placeholder')}\n            onKeyDown={(event) => {\n              if (event.key === 'Enter') {\n                event.preventDefault();\n                handleAddDisallowedTool(newDisallowedTool);\n              }\n            }}\n            className=\"h-10 flex-1\"\n          />\n          <Button\n            onClick={() => handleAddDisallowedTool(newDisallowedTool)}\n            disabled={!newDisallowedTool.trim()}\n            size=\"sm\"\n            className=\"h-10 px-4\"\n          >\n            <Plus className=\"mr-2 h-4 w-4 sm:mr-0\" />\n            <span className=\"sm:hidden\">{t('permissions.actions.add')}</span>\n          </Button>\n        </div>\n\n        <div className=\"space-y-2\">\n          {disallowedTools.map((tool) => (\n            <div key={tool} className=\"flex items-center justify-between rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-800 dark:bg-red-900/20\">\n              <span className=\"font-mono text-sm text-red-800 dark:text-red-200\">{tool}</span>\n              <Button\n                variant=\"ghost\"\n                size=\"sm\"\n                onClick={() => onDisallowedToolsChange(removeValue(disallowedTools, tool))}\n                className=\"text-red-600 hover:text-red-700\"\n              >\n                <X className=\"h-4 w-4\" />\n              </Button>\n            </div>\n          ))}\n          {disallowedTools.length === 0 && (\n            <div className=\"py-6 text-center text-muted-foreground\">\n              {t('permissions.blockedTools.empty')}\n            </div>\n          )}\n        </div>\n      </div>\n\n      <div className=\"rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-900/20\">\n        <h4 className=\"mb-2 font-medium text-blue-900 dark:text-blue-100\">\n          {t('permissions.toolExamples.title')}\n        </h4>\n        <ul className=\"space-y-1 text-sm text-blue-800 dark:text-blue-200\">\n          <li><code className=\"rounded bg-blue-100 px-1 dark:bg-blue-800\">\"Bash(git log:*)\"</code> {t('permissions.toolExamples.bashGitLog')}</li>\n          <li><code className=\"rounded bg-blue-100 px-1 dark:bg-blue-800\">\"Bash(git diff:*)\"</code> {t('permissions.toolExamples.bashGitDiff')}</li>\n          <li><code className=\"rounded bg-blue-100 px-1 dark:bg-blue-800\">\"Write\"</code> {t('permissions.toolExamples.write')}</li>\n          <li><code className=\"rounded bg-blue-100 px-1 dark:bg-blue-800\">\"Bash(rm:*)\"</code> {t('permissions.toolExamples.bashRm')}</li>\n        </ul>\n      </div>\n\n    </div>\n  );\n}\n\ntype CursorPermissionsProps = {\n  agent: 'cursor';\n  skipPermissions: boolean;\n  onSkipPermissionsChange: (value: boolean) => void;\n  allowedCommands: string[];\n  onAllowedCommandsChange: (value: string[]) => void;\n  disallowedCommands: string[];\n  onDisallowedCommandsChange: (value: string[]) => void;\n};\n\nfunction CursorPermissions({\n  skipPermissions,\n  onSkipPermissionsChange,\n  allowedCommands,\n  onAllowedCommandsChange,\n  disallowedCommands,\n  onDisallowedCommandsChange,\n}: Omit<CursorPermissionsProps, 'agent'>) {\n  const { t } = useTranslation('settings');\n  const [newAllowedCommand, setNewAllowedCommand] = useState('');\n  const [newDisallowedCommand, setNewDisallowedCommand] = useState('');\n\n  const handleAddAllowedCommand = (command: string) => {\n    const updated = addUnique(allowedCommands, command);\n    if (updated.length === allowedCommands.length) {\n      return;\n    }\n\n    onAllowedCommandsChange(updated);\n    setNewAllowedCommand('');\n  };\n\n  const handleAddDisallowedCommand = (command: string) => {\n    const updated = addUnique(disallowedCommands, command);\n    if (updated.length === disallowedCommands.length) {\n      return;\n    }\n\n    onDisallowedCommandsChange(updated);\n    setNewDisallowedCommand('');\n  };\n\n  return (\n    <div className=\"space-y-6\">\n      <div className=\"space-y-4\">\n        <div className=\"flex items-center gap-3\">\n          <AlertTriangle className=\"h-5 w-5 text-orange-500\" />\n          <h3 className=\"text-lg font-medium text-foreground\">{t('permissions.title')}</h3>\n        </div>\n        <div className=\"rounded-lg border border-orange-200 bg-orange-50 p-4 dark:border-orange-800 dark:bg-orange-900/20\">\n          <label className=\"flex items-center gap-3\">\n            <input\n              type=\"checkbox\"\n              checked={skipPermissions}\n              onChange={(event) => onSkipPermissionsChange(event.target.checked)}\n              className=\"h-4 w-4 rounded border-input bg-card text-primary focus:ring-2 focus:ring-primary\"\n            />\n            <div>\n              <div className=\"font-medium text-orange-900 dark:text-orange-100\">\n                {t('permissions.skipPermissions.label')}\n              </div>\n              <div className=\"text-sm text-orange-700 dark:text-orange-300\">\n                {t('permissions.skipPermissions.cursorDescription')}\n              </div>\n            </div>\n          </label>\n        </div>\n      </div>\n\n      <div className=\"space-y-4\">\n        <div className=\"flex items-center gap-3\">\n          <Shield className=\"h-5 w-5 text-green-500\" />\n          <h3 className=\"text-lg font-medium text-foreground\">{t('permissions.allowedCommands.title')}</h3>\n        </div>\n        <p className=\"text-sm text-muted-foreground\">{t('permissions.allowedCommands.description')}</p>\n\n        <div className=\"flex flex-col gap-2 sm:flex-row\">\n          <Input\n            value={newAllowedCommand}\n            onChange={(event) => setNewAllowedCommand(event.target.value)}\n            placeholder={t('permissions.allowedCommands.placeholder')}\n            onKeyDown={(event) => {\n              if (event.key === 'Enter') {\n                event.preventDefault();\n                handleAddAllowedCommand(newAllowedCommand);\n              }\n            }}\n            className=\"h-10 flex-1\"\n          />\n          <Button\n            onClick={() => handleAddAllowedCommand(newAllowedCommand)}\n            disabled={!newAllowedCommand.trim()}\n            size=\"sm\"\n            className=\"h-10 px-4\"\n          >\n            <Plus className=\"mr-2 h-4 w-4 sm:mr-0\" />\n            <span className=\"sm:hidden\">{t('permissions.actions.add')}</span>\n          </Button>\n        </div>\n\n        <div className=\"space-y-2\">\n          <p className=\"text-sm font-medium text-muted-foreground\">\n            {t('permissions.allowedCommands.quickAdd')}\n          </p>\n          <div className=\"flex flex-wrap gap-2\">\n            {COMMON_CURSOR_COMMANDS.map((command) => (\n              <Button\n                key={command}\n                variant=\"outline\"\n                size=\"sm\"\n                onClick={() => handleAddAllowedCommand(command)}\n                disabled={allowedCommands.includes(command)}\n                className=\"h-8 text-xs\"\n              >\n                {command}\n              </Button>\n            ))}\n          </div>\n        </div>\n\n        <div className=\"space-y-2\">\n          {allowedCommands.map((command) => (\n            <div key={command} className=\"flex items-center justify-between rounded-lg border border-green-200 bg-green-50 p-3 dark:border-green-800 dark:bg-green-900/20\">\n              <span className=\"font-mono text-sm text-green-800 dark:text-green-200\">{command}</span>\n              <Button\n                variant=\"ghost\"\n                size=\"sm\"\n                onClick={() => onAllowedCommandsChange(removeValue(allowedCommands, command))}\n                className=\"text-green-600 hover:text-green-700\"\n              >\n                <X className=\"h-4 w-4\" />\n              </Button>\n            </div>\n          ))}\n          {allowedCommands.length === 0 && (\n            <div className=\"py-6 text-center text-muted-foreground\">\n              {t('permissions.allowedCommands.empty')}\n            </div>\n          )}\n        </div>\n      </div>\n\n      <div className=\"space-y-4\">\n        <div className=\"flex items-center gap-3\">\n          <AlertTriangle className=\"h-5 w-5 text-red-500\" />\n          <h3 className=\"text-lg font-medium text-foreground\">{t('permissions.blockedCommands.title')}</h3>\n        </div>\n        <p className=\"text-sm text-muted-foreground\">{t('permissions.blockedCommands.description')}</p>\n\n        <div className=\"flex flex-col gap-2 sm:flex-row\">\n          <Input\n            value={newDisallowedCommand}\n            onChange={(event) => setNewDisallowedCommand(event.target.value)}\n            placeholder={t('permissions.blockedCommands.placeholder')}\n            onKeyDown={(event) => {\n              if (event.key === 'Enter') {\n                event.preventDefault();\n                handleAddDisallowedCommand(newDisallowedCommand);\n              }\n            }}\n            className=\"h-10 flex-1\"\n          />\n          <Button\n            onClick={() => handleAddDisallowedCommand(newDisallowedCommand)}\n            disabled={!newDisallowedCommand.trim()}\n            size=\"sm\"\n            className=\"h-10 px-4\"\n          >\n            <Plus className=\"mr-2 h-4 w-4 sm:mr-0\" />\n            <span className=\"sm:hidden\">{t('permissions.actions.add')}</span>\n          </Button>\n        </div>\n\n        <div className=\"space-y-2\">\n          {disallowedCommands.map((command) => (\n            <div key={command} className=\"flex items-center justify-between rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-800 dark:bg-red-900/20\">\n              <span className=\"font-mono text-sm text-red-800 dark:text-red-200\">{command}</span>\n              <Button\n                variant=\"ghost\"\n                size=\"sm\"\n                onClick={() => onDisallowedCommandsChange(removeValue(disallowedCommands, command))}\n                className=\"text-red-600 hover:text-red-700\"\n              >\n                <X className=\"h-4 w-4\" />\n              </Button>\n            </div>\n          ))}\n          {disallowedCommands.length === 0 && (\n            <div className=\"py-6 text-center text-muted-foreground\">\n              {t('permissions.blockedCommands.empty')}\n            </div>\n          )}\n        </div>\n      </div>\n\n      <div className=\"rounded-lg border border-purple-200 bg-purple-50 p-4 dark:border-purple-800 dark:bg-purple-900/20\">\n        <h4 className=\"mb-2 font-medium text-purple-900 dark:text-purple-100\">\n          {t('permissions.shellExamples.title')}\n        </h4>\n        <ul className=\"space-y-1 text-sm text-purple-800 dark:text-purple-200\">\n          <li><code className=\"rounded bg-purple-100 px-1 dark:bg-purple-800\">\"Shell(ls)\"</code> {t('permissions.shellExamples.ls')}</li>\n          <li><code className=\"rounded bg-purple-100 px-1 dark:bg-purple-800\">\"Shell(git status)\"</code> {t('permissions.shellExamples.gitStatus')}</li>\n          <li><code className=\"rounded bg-purple-100 px-1 dark:bg-purple-800\">\"Shell(npm install)\"</code> {t('permissions.shellExamples.npmInstall')}</li>\n          <li><code className=\"rounded bg-purple-100 px-1 dark:bg-purple-800\">\"Shell(rm -rf)\"</code> {t('permissions.shellExamples.rmRf')}</li>\n        </ul>\n      </div>\n    </div>\n  );\n}\n\ntype CodexPermissionsProps = {\n  agent: 'codex';\n  permissionMode: CodexPermissionMode;\n  onPermissionModeChange: (value: CodexPermissionMode) => void;\n};\n\nfunction CodexPermissions({ permissionMode, onPermissionModeChange }: Omit<CodexPermissionsProps, 'agent'>) {\n  const { t } = useTranslation('settings');\n\n  return (\n    <div className=\"space-y-6\">\n      <div className=\"space-y-4\">\n        <div className=\"flex items-center gap-3\">\n          <Shield className=\"h-5 w-5 text-green-500\" />\n          <h3 className=\"text-lg font-medium text-foreground\">{t('permissions.codex.permissionMode')}</h3>\n        </div>\n        <p className=\"text-sm text-muted-foreground\">{t('permissions.codex.description')}</p>\n\n        <div\n          className={`cursor-pointer rounded-lg border p-4 transition-all ${permissionMode === 'default'\n            ? 'border-border bg-accent'\n            : 'border-border bg-card/50 active:border-border active:bg-accent/50'\n            }`}\n          onClick={() => onPermissionModeChange('default')}\n        >\n          <label className=\"flex cursor-pointer items-start gap-3\">\n            <input\n              type=\"radio\"\n              name=\"codexPermissionMode\"\n              checked={permissionMode === 'default'}\n              onChange={() => onPermissionModeChange('default')}\n              className=\"mt-1 h-4 w-4 text-green-600\"\n            />\n            <div>\n              <div className=\"font-medium text-foreground\">{t('permissions.codex.modes.default.title')}</div>\n              <div className=\"text-sm text-muted-foreground\">\n                {t('permissions.codex.modes.default.description')}\n              </div>\n            </div>\n          </label>\n        </div>\n\n        <div\n          className={`cursor-pointer rounded-lg border p-4 transition-all ${permissionMode === 'acceptEdits'\n            ? 'border-green-400 bg-green-50 dark:border-green-600 dark:bg-green-900/20'\n            : 'border-border bg-card/50 active:border-border active:bg-accent/50'\n            }`}\n          onClick={() => onPermissionModeChange('acceptEdits')}\n        >\n          <label className=\"flex cursor-pointer items-start gap-3\">\n            <input\n              type=\"radio\"\n              name=\"codexPermissionMode\"\n              checked={permissionMode === 'acceptEdits'}\n              onChange={() => onPermissionModeChange('acceptEdits')}\n              className=\"mt-1 h-4 w-4 text-green-600\"\n            />\n            <div>\n              <div className=\"font-medium text-green-900 dark:text-green-100\">{t('permissions.codex.modes.acceptEdits.title')}</div>\n              <div className=\"text-sm text-green-700 dark:text-green-300\">\n                {t('permissions.codex.modes.acceptEdits.description')}\n              </div>\n            </div>\n          </label>\n        </div>\n\n        <div\n          className={`cursor-pointer rounded-lg border p-4 transition-all ${permissionMode === 'bypassPermissions'\n            ? 'border-orange-400 bg-orange-50 dark:border-orange-600 dark:bg-orange-900/20'\n            : 'border-border bg-card/50 active:border-border active:bg-accent/50'\n            }`}\n          onClick={() => onPermissionModeChange('bypassPermissions')}\n        >\n          <label className=\"flex cursor-pointer items-start gap-3\">\n            <input\n              type=\"radio\"\n              name=\"codexPermissionMode\"\n              checked={permissionMode === 'bypassPermissions'}\n              onChange={() => onPermissionModeChange('bypassPermissions')}\n              className=\"mt-1 h-4 w-4 text-orange-600\"\n            />\n            <div>\n              <div className=\"flex items-center gap-2 font-medium text-orange-900 dark:text-orange-100\">\n                {t('permissions.codex.modes.bypassPermissions.title')}\n                <AlertTriangle className=\"h-4 w-4\" />\n              </div>\n              <div className=\"text-sm text-orange-700 dark:text-orange-300\">\n                {t('permissions.codex.modes.bypassPermissions.description')}\n              </div>\n            </div>\n          </label>\n        </div>\n\n        <details className=\"text-sm\">\n          <summary className=\"cursor-pointer text-muted-foreground hover:text-foreground\">\n            {t('permissions.codex.technicalDetails')}\n          </summary>\n          <div className=\"mt-2 space-y-2 rounded-lg bg-muted/50 p-3 text-xs text-muted-foreground\">\n            <p><strong>{t('permissions.codex.modes.default.title')}:</strong> {t('permissions.codex.technicalInfo.default')}</p>\n            <p><strong>{t('permissions.codex.modes.acceptEdits.title')}:</strong> {t('permissions.codex.technicalInfo.acceptEdits')}</p>\n            <p><strong>{t('permissions.codex.modes.bypassPermissions.title')}:</strong> {t('permissions.codex.technicalInfo.bypassPermissions')}</p>\n            <p className=\"text-xs opacity-75\">{t('permissions.codex.technicalInfo.overrideNote')}</p>\n          </div>\n        </details>\n      </div>\n    </div>\n  );\n}\n\ntype GeminiPermissionsProps = {\n  agent: 'gemini';\n  permissionMode: GeminiPermissionMode;\n  onPermissionModeChange: (value: GeminiPermissionMode) => void;\n};\n\n// Gemini Permissions\nfunction GeminiPermissions({ permissionMode, onPermissionModeChange }: Omit<GeminiPermissionsProps, 'agent'>) {\n  const { t } = useTranslation(['settings', 'chat']);\n  return (\n    <div className=\"space-y-6\">\n      <div className=\"space-y-4\">\n        <div className=\"flex items-center gap-3\">\n          <Shield className=\"h-5 w-5 text-green-500\" />\n          <h3 className=\"text-lg font-medium text-foreground\">\n            {t('gemini.permissionMode')}\n          </h3>\n        </div>\n        <p className=\"text-sm text-muted-foreground\">\n          {t('gemini.description')}\n        </p>\n\n        {/* Default Mode */}\n        <div\n          className={`cursor-pointer rounded-lg border p-4 transition-all ${permissionMode === 'default'\n            ? 'border-border bg-accent'\n            : 'border-border bg-card/50 active:border-border active:bg-accent/50'\n            }`}\n          onClick={() => onPermissionModeChange('default')}\n        >\n          <label className=\"flex cursor-pointer items-start gap-3\">\n            <input\n              type=\"radio\"\n              name=\"geminiPermissionMode\"\n              checked={permissionMode === 'default'}\n              onChange={() => onPermissionModeChange('default')}\n              className=\"mt-1 h-4 w-4 text-green-600\"\n            />\n            <div>\n              <div className=\"font-medium text-foreground\">{t('gemini.modes.default.title')}</div>\n              <div className=\"text-sm text-muted-foreground\">\n                {t('gemini.modes.default.description')}\n              </div>\n            </div>\n          </label>\n        </div>\n\n        {/* Auto Edit Mode */}\n        <div\n          className={`cursor-pointer rounded-lg border p-4 transition-all ${permissionMode === 'auto_edit'\n            ? 'border-green-400 bg-green-50 dark:border-green-600 dark:bg-green-900/20'\n            : 'border-border bg-card/50 active:border-border active:bg-accent/50'\n            }`}\n          onClick={() => onPermissionModeChange('auto_edit')}\n        >\n          <label className=\"flex cursor-pointer items-start gap-3\">\n            <input\n              type=\"radio\"\n              name=\"geminiPermissionMode\"\n              checked={permissionMode === 'auto_edit'}\n              onChange={() => onPermissionModeChange('auto_edit')}\n              className=\"mt-1 h-4 w-4 text-green-600\"\n            />\n            <div>\n              <div className=\"font-medium text-green-900 dark:text-green-100\">{t('gemini.modes.autoEdit.title')}</div>\n              <div className=\"text-sm text-green-700 dark:text-green-300\">\n                {t('gemini.modes.autoEdit.description')}\n              </div>\n            </div>\n          </label>\n        </div>\n\n        {/* YOLO Mode */}\n        <div\n          className={`cursor-pointer rounded-lg border p-4 transition-all ${permissionMode === 'yolo'\n            ? 'border-orange-400 bg-orange-50 dark:border-orange-600 dark:bg-orange-900/20'\n            : 'border-border bg-card/50 active:border-border active:bg-accent/50'\n            }`}\n          onClick={() => onPermissionModeChange('yolo')}\n        >\n          <label className=\"flex cursor-pointer items-start gap-3\">\n            <input\n              type=\"radio\"\n              name=\"geminiPermissionMode\"\n              checked={permissionMode === 'yolo'}\n              onChange={() => onPermissionModeChange('yolo')}\n              className=\"mt-1 h-4 w-4 text-orange-600\"\n            />\n            <div>\n              <div className=\"flex items-center gap-2 font-medium text-orange-900 dark:text-orange-100\">\n                {t('gemini.modes.yolo.title')}\n                <AlertTriangle className=\"h-4 w-4\" />\n              </div>\n              <div className=\"text-sm text-orange-700 dark:text-orange-300\">\n                {t('gemini.modes.yolo.description')}\n              </div>\n            </div>\n          </label>\n        </div>\n      </div>\n    </div>\n  );\n}\n\ntype PermissionsContentProps = ClaudePermissionsProps | CursorPermissionsProps | CodexPermissionsProps | GeminiPermissionsProps;\n\nexport default function PermissionsContent(props: PermissionsContentProps) {\n  if (props.agent === 'claude') {\n    return <ClaudePermissions {...props} />;\n  }\n\n  if (props.agent === 'cursor') {\n    return <CursorPermissions {...props} />;\n  }\n\n  if (props.agent === 'gemini') {\n    return <GeminiPermissions {...props} />;\n  }\n\n  return <CodexPermissions {...props} />;\n}\n"
  },
  {
    "path": "src/components/settings/view/tabs/agents-settings/types.ts",
    "content": "import type {\n  AgentProvider,\n  AuthStatus,\n  AgentCategory,\n  ClaudePermissionsState,\n  CursorPermissionsState,\n  CodexPermissionMode,\n  GeminiPermissionMode,\n  McpServer,\n  McpToolsResult,\n  McpTestResult,\n} from '../../../types/types';\n\nexport type AgentContext = {\n  authStatus: AuthStatus;\n  onLogin: () => void;\n};\n\nexport type AgentContextByProvider = Record<AgentProvider, AgentContext>;\n\nexport type AgentsSettingsTabProps = {\n  claudeAuthStatus: AuthStatus;\n  cursorAuthStatus: AuthStatus;\n  codexAuthStatus: AuthStatus;\n  geminiAuthStatus: AuthStatus;\n  onClaudeLogin: () => void;\n  onCursorLogin: () => void;\n  onCodexLogin: () => void;\n  onGeminiLogin: () => void;\n  claudePermissions: ClaudePermissionsState;\n  onClaudePermissionsChange: (value: ClaudePermissionsState) => void;\n  cursorPermissions: CursorPermissionsState;\n  onCursorPermissionsChange: (value: CursorPermissionsState) => void;\n  codexPermissionMode: CodexPermissionMode;\n  onCodexPermissionModeChange: (value: CodexPermissionMode) => void;\n  geminiPermissionMode: GeminiPermissionMode;\n  onGeminiPermissionModeChange: (value: GeminiPermissionMode) => void;\n  mcpServers: McpServer[];\n  cursorMcpServers: McpServer[];\n  codexMcpServers: McpServer[];\n  mcpTestResults: Record<string, McpTestResult>;\n  mcpServerTools: Record<string, McpToolsResult>;\n  mcpToolsLoading: Record<string, boolean>;\n  deleteError: string | null;\n  onOpenMcpForm: (server?: McpServer) => void;\n  onDeleteMcpServer: (serverId: string, scope?: string) => void;\n  onTestMcpServer: (serverId: string, scope?: string) => void;\n  onDiscoverMcpTools: (serverId: string, scope?: string) => void;\n  onOpenCodexMcpForm: (server?: McpServer) => void;\n  onDeleteCodexMcpServer: (serverId: string) => void;\n};\n\nexport type AgentCategoryTabsSectionProps = {\n  selectedCategory: AgentCategory;\n  onSelectCategory: (category: AgentCategory) => void;\n};\n\nexport type AgentSelectorSectionProps = {\n  selectedAgent: AgentProvider;\n  onSelectAgent: (agent: AgentProvider) => void;\n  agentContextById: AgentContextByProvider;\n};\n\nexport type AgentCategoryContentSectionProps = {\n  selectedAgent: AgentProvider;\n  selectedCategory: AgentCategory;\n  agentContextById: AgentContextByProvider;\n  claudePermissions: ClaudePermissionsState;\n  onClaudePermissionsChange: (value: ClaudePermissionsState) => void;\n  cursorPermissions: CursorPermissionsState;\n  onCursorPermissionsChange: (value: CursorPermissionsState) => void;\n  codexPermissionMode: CodexPermissionMode;\n  onCodexPermissionModeChange: (value: CodexPermissionMode) => void;\n  geminiPermissionMode: GeminiPermissionMode;\n  onGeminiPermissionModeChange: (value: GeminiPermissionMode) => void;\n  mcpServers: McpServer[];\n  cursorMcpServers: McpServer[];\n  codexMcpServers: McpServer[];\n  mcpTestResults: Record<string, McpTestResult>;\n  mcpServerTools: Record<string, McpToolsResult>;\n  mcpToolsLoading: Record<string, boolean>;\n  deleteError: string | null;\n  onOpenMcpForm: (server?: McpServer) => void;\n  onDeleteMcpServer: (serverId: string, scope?: string) => void;\n  onTestMcpServer: (serverId: string, scope?: string) => void;\n  onDiscoverMcpTools: (serverId: string, scope?: string) => void;\n  onOpenCodexMcpForm: (server?: McpServer) => void;\n  onDeleteCodexMcpServer: (serverId: string) => void;\n};\n"
  },
  {
    "path": "src/components/settings/view/tabs/api-settings/CredentialsSettingsTab.tsx",
    "content": "import { useTranslation } from 'react-i18next';\nimport { useVersionCheck } from '../../../../../hooks/useVersionCheck';\nimport { useCredentialsSettings } from '../../../hooks/useCredentialsSettings';\nimport ApiKeysSection from './sections/ApiKeysSection';\nimport GithubCredentialsSection from './sections/GithubCredentialsSection';\nimport NewApiKeyAlert from './sections/NewApiKeyAlert';\nimport VersionInfoSection from './sections/VersionInfoSection';\n\nexport default function CredentialsSettingsTab() {\n  const { t } = useTranslation('settings');\n  const { updateAvailable, latestVersion, currentVersion, releaseInfo } = useVersionCheck('siteboon', 'claudecodeui');\n  const {\n    apiKeys,\n    githubCredentials,\n    loading,\n    showNewKeyForm,\n    setShowNewKeyForm,\n    newKeyName,\n    setNewKeyName,\n    showNewGithubForm,\n    setShowNewGithubForm,\n    newGithubName,\n    setNewGithubName,\n    newGithubToken,\n    setNewGithubToken,\n    newGithubDescription,\n    setNewGithubDescription,\n    showToken,\n    copiedKey,\n    newlyCreatedKey,\n    createApiKey,\n    deleteApiKey,\n    toggleApiKey,\n    createGithubCredential,\n    deleteGithubCredential,\n    toggleGithubCredential,\n    copyToClipboard,\n    dismissNewlyCreatedKey,\n    cancelNewApiKeyForm,\n    cancelNewGithubForm,\n    toggleNewGithubTokenVisibility,\n  } = useCredentialsSettings({\n    confirmDeleteApiKeyText: t('apiKeys.confirmDelete'),\n    confirmDeleteGithubCredentialText: t('apiKeys.github.confirmDelete'),\n  });\n\n  if (loading) {\n    return <div className=\"text-muted-foreground\">{t('apiKeys.loading')}</div>;\n  }\n\n  return (\n    <div className=\"space-y-8\">\n      {newlyCreatedKey && (\n        <NewApiKeyAlert\n          apiKey={newlyCreatedKey}\n          copiedKey={copiedKey}\n          onCopy={copyToClipboard}\n          onDismiss={dismissNewlyCreatedKey}\n        />\n      )}\n\n      <ApiKeysSection\n        apiKeys={apiKeys}\n        showNewKeyForm={showNewKeyForm}\n        newKeyName={newKeyName}\n        onShowNewKeyFormChange={setShowNewKeyForm}\n        onNewKeyNameChange={setNewKeyName}\n        onCreateApiKey={createApiKey}\n        onCancelCreateApiKey={cancelNewApiKeyForm}\n        onToggleApiKey={toggleApiKey}\n        onDeleteApiKey={deleteApiKey}\n      />\n\n      <GithubCredentialsSection\n        githubCredentials={githubCredentials}\n        showNewGithubForm={showNewGithubForm}\n        showNewTokenPlainText={Boolean(showToken.new)}\n        newGithubName={newGithubName}\n        newGithubToken={newGithubToken}\n        newGithubDescription={newGithubDescription}\n        onShowNewGithubFormChange={setShowNewGithubForm}\n        onNewGithubNameChange={setNewGithubName}\n        onNewGithubTokenChange={setNewGithubToken}\n        onNewGithubDescriptionChange={setNewGithubDescription}\n        onToggleNewTokenVisibility={toggleNewGithubTokenVisibility}\n        onCreateGithubCredential={createGithubCredential}\n        onCancelCreateGithubCredential={cancelNewGithubForm}\n        onToggleGithubCredential={toggleGithubCredential}\n        onDeleteGithubCredential={deleteGithubCredential}\n      />\n\n      <VersionInfoSection\n        currentVersion={currentVersion}\n        updateAvailable={updateAvailable}\n        latestVersion={latestVersion}\n        releaseInfo={releaseInfo}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/settings/view/tabs/api-settings/sections/ApiKeysSection.tsx",
    "content": "import { ExternalLink, Key, Plus, Trash2 } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { Button, Input } from '../../../../../../shared/view/ui';\nimport type { ApiKeyItem } from '../types';\n\ntype ApiKeysSectionProps = {\n  apiKeys: ApiKeyItem[];\n  showNewKeyForm: boolean;\n  newKeyName: string;\n  onShowNewKeyFormChange: (value: boolean) => void;\n  onNewKeyNameChange: (value: string) => void;\n  onCreateApiKey: () => void;\n  onCancelCreateApiKey: () => void;\n  onToggleApiKey: (keyId: string, isActive: boolean) => void;\n  onDeleteApiKey: (keyId: string) => void;\n};\n\nexport default function ApiKeysSection({\n  apiKeys,\n  showNewKeyForm,\n  newKeyName,\n  onShowNewKeyFormChange,\n  onNewKeyNameChange,\n  onCreateApiKey,\n  onCancelCreateApiKey,\n  onToggleApiKey,\n  onDeleteApiKey,\n}: ApiKeysSectionProps) {\n  const { t } = useTranslation('settings');\n\n  return (\n    <div>\n      <div className=\"mb-4 flex items-center justify-between\">\n        <div className=\"flex items-center gap-2\">\n          <Key className=\"h-5 w-5\" />\n          <h3 className=\"text-lg font-semibold\">{t('apiKeys.title')}</h3>\n        </div>\n        <Button size=\"sm\" onClick={() => onShowNewKeyFormChange(!showNewKeyForm)}>\n          <Plus className=\"mr-1 h-4 w-4\" />\n          {t('apiKeys.newButton')}\n        </Button>\n      </div>\n\n      <div className=\"mb-4\">\n        <p className=\"mb-2 text-sm text-muted-foreground\">{t('apiKeys.description')}</p>\n        <a\n          href=\"/api-docs.html\"\n          target=\"_blank\"\n          rel=\"noopener noreferrer\"\n          className=\"inline-flex items-center gap-1 text-sm text-primary hover:underline\"\n        >\n          {t('apiKeys.apiDocsLink')}\n          <ExternalLink className=\"h-3 w-3\" />\n        </a>\n      </div>\n\n      {showNewKeyForm && (\n        <div className=\"mb-4 rounded-lg border bg-card p-4\">\n          <Input\n            placeholder={t('apiKeys.form.placeholder')}\n            value={newKeyName}\n            onChange={(event) => onNewKeyNameChange(event.target.value)}\n            className=\"mb-2\"\n          />\n          <div className=\"flex gap-2\">\n            <Button onClick={onCreateApiKey}>{t('apiKeys.form.createButton')}</Button>\n            <Button variant=\"outline\" onClick={onCancelCreateApiKey}>\n              {t('apiKeys.form.cancelButton')}\n            </Button>\n          </div>\n        </div>\n      )}\n\n      <div className=\"space-y-2\">\n        {apiKeys.length === 0 ? (\n          <p className=\"text-sm italic text-muted-foreground\">{t('apiKeys.empty')}</p>\n        ) : (\n          apiKeys.map((key) => (\n            <div key={key.id} className=\"flex items-center justify-between rounded-lg border p-3\">\n              <div className=\"flex-1\">\n                <div className=\"font-medium\">{key.key_name}</div>\n                <code className=\"text-xs text-muted-foreground\">{key.api_key}</code>\n                <div className=\"mt-1 text-xs text-muted-foreground\">\n                  {t('apiKeys.list.created')} {new Date(key.created_at).toLocaleDateString()}\n                  {key.last_used\n                    ? ` - ${t('apiKeys.list.lastUsed')} ${new Date(key.last_used).toLocaleDateString()}`\n                    : ''}\n                </div>\n              </div>\n              <div className=\"flex items-center gap-2\">\n                <Button\n                  size=\"sm\"\n                  variant={key.is_active ? 'outline' : 'secondary'}\n                  onClick={() => onToggleApiKey(key.id, key.is_active)}\n                >\n                  {key.is_active ? t('apiKeys.status.active') : t('apiKeys.status.inactive')}\n                </Button>\n                <Button size=\"sm\" variant=\"ghost\" onClick={() => onDeleteApiKey(key.id)}>\n                  <Trash2 className=\"h-4 w-4\" />\n                </Button>\n              </div>\n            </div>\n          ))\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/settings/view/tabs/api-settings/sections/GithubCredentialsSection.tsx",
    "content": "import { Eye, EyeOff, Github, Plus, Trash2 } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { Button, Input } from '../../../../../../shared/view/ui';\nimport type { GithubCredentialItem } from '../types';\n\ntype GithubCredentialsSectionProps = {\n  githubCredentials: GithubCredentialItem[];\n  showNewGithubForm: boolean;\n  showNewTokenPlainText: boolean;\n  newGithubName: string;\n  newGithubToken: string;\n  newGithubDescription: string;\n  onShowNewGithubFormChange: (value: boolean) => void;\n  onNewGithubNameChange: (value: string) => void;\n  onNewGithubTokenChange: (value: string) => void;\n  onNewGithubDescriptionChange: (value: string) => void;\n  onToggleNewTokenVisibility: () => void;\n  onCreateGithubCredential: () => void;\n  onCancelCreateGithubCredential: () => void;\n  onToggleGithubCredential: (credentialId: string, isActive: boolean) => void;\n  onDeleteGithubCredential: (credentialId: string) => void;\n};\n\nexport default function GithubCredentialsSection({\n  githubCredentials,\n  showNewGithubForm,\n  showNewTokenPlainText,\n  newGithubName,\n  newGithubToken,\n  newGithubDescription,\n  onShowNewGithubFormChange,\n  onNewGithubNameChange,\n  onNewGithubTokenChange,\n  onNewGithubDescriptionChange,\n  onToggleNewTokenVisibility,\n  onCreateGithubCredential,\n  onCancelCreateGithubCredential,\n  onToggleGithubCredential,\n  onDeleteGithubCredential,\n}: GithubCredentialsSectionProps) {\n  const { t } = useTranslation('settings');\n\n  return (\n    <div>\n      <div className=\"mb-4 flex items-center justify-between\">\n        <div className=\"flex items-center gap-2\">\n          <Github className=\"h-5 w-5\" />\n          <h3 className=\"text-lg font-semibold\">{t('apiKeys.github.title')}</h3>\n        </div>\n        <Button size=\"sm\" onClick={() => onShowNewGithubFormChange(!showNewGithubForm)}>\n          <Plus className=\"mr-1 h-4 w-4\" />\n          {t('apiKeys.github.addButton')}\n        </Button>\n      </div>\n\n      <p className=\"mb-4 text-sm text-muted-foreground\">{t('apiKeys.github.descriptionAlt')}</p>\n\n      {showNewGithubForm && (\n        <div className=\"mb-4 space-y-3 rounded-lg border bg-card p-4\">\n          <Input\n            placeholder={t('apiKeys.github.form.namePlaceholder')}\n            value={newGithubName}\n            onChange={(event) => onNewGithubNameChange(event.target.value)}\n          />\n\n          <div className=\"relative\">\n            <Input\n              type={showNewTokenPlainText ? 'text' : 'password'}\n              placeholder={t('apiKeys.github.form.tokenPlaceholder')}\n              value={newGithubToken}\n              onChange={(event) => onNewGithubTokenChange(event.target.value)}\n              className=\"pr-10\"\n            />\n            <button\n              type=\"button\"\n              onClick={onToggleNewTokenVisibility}\n              aria-label={showNewTokenPlainText ? 'Hide token' : 'Show token'}\n              className=\"absolute right-3 top-2.5 text-muted-foreground hover:text-foreground\"\n            >\n              {showNewTokenPlainText ? <EyeOff className=\"h-4 w-4\" /> : <Eye className=\"h-4 w-4\" />}\n            </button>\n          </div>\n\n          <Input\n            placeholder={t('apiKeys.github.form.descriptionPlaceholder')}\n            value={newGithubDescription}\n            onChange={(event) => onNewGithubDescriptionChange(event.target.value)}\n          />\n\n          <div className=\"flex gap-2\">\n            <Button onClick={onCreateGithubCredential}>{t('apiKeys.github.form.addButton')}</Button>\n            <Button variant=\"outline\" onClick={onCancelCreateGithubCredential}>\n              {t('apiKeys.github.form.cancelButton')}\n            </Button>\n          </div>\n\n          <a\n            href=\"https://github.com/settings/tokens\"\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className=\"block text-xs text-primary hover:underline\"\n          >\n            {t('apiKeys.github.form.howToCreate')}\n          </a>\n        </div>\n      )}\n\n      <div className=\"space-y-2\">\n        {githubCredentials.length === 0 ? (\n          <p className=\"text-sm italic text-muted-foreground\">{t('apiKeys.github.empty')}</p>\n        ) : (\n          githubCredentials.map((credential) => (\n            <div key={credential.id} className=\"flex items-center justify-between rounded-lg border p-3\">\n              <div className=\"flex-1\">\n                <div className=\"font-medium\">{credential.credential_name}</div>\n                {credential.description && (\n                  <div className=\"text-xs text-muted-foreground\">{credential.description}</div>\n                )}\n                <div className=\"mt-1 text-xs text-muted-foreground\">\n                  {t('apiKeys.github.added')} {new Date(credential.created_at).toLocaleDateString()}\n                </div>\n              </div>\n              <div className=\"flex items-center gap-2\">\n                <Button\n                  size=\"sm\"\n                  variant={credential.is_active ? 'outline' : 'secondary'}\n                  onClick={() => onToggleGithubCredential(credential.id, credential.is_active)}\n                >\n                  {credential.is_active ? t('apiKeys.status.active') : t('apiKeys.status.inactive')}\n                </Button>\n                <Button size=\"sm\" variant=\"ghost\" onClick={() => onDeleteGithubCredential(credential.id)}>\n                  <Trash2 className=\"h-4 w-4\" />\n                </Button>\n              </div>\n            </div>\n          ))\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/settings/view/tabs/api-settings/sections/NewApiKeyAlert.tsx",
    "content": "import { Check, Copy } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { Button } from '../../../../../../shared/view/ui';\nimport type { CreatedApiKey } from '../types';\n\ntype NewApiKeyAlertProps = {\n  apiKey: CreatedApiKey;\n  copiedKey: string | null;\n  onCopy: (text: string, id: string) => void;\n  onDismiss: () => void;\n};\n\nexport default function NewApiKeyAlert({\n  apiKey,\n  copiedKey,\n  onCopy,\n  onDismiss,\n}: NewApiKeyAlertProps) {\n  const { t } = useTranslation('settings');\n\n  return (\n    <div className=\"rounded-lg border border-yellow-500/20 bg-yellow-500/10 p-4\">\n      <h4 className=\"mb-2 font-semibold text-yellow-500\">{t('apiKeys.newKey.alertTitle')}</h4>\n      <p className=\"mb-3 text-sm text-muted-foreground\">{t('apiKeys.newKey.alertMessage')}</p>\n      <div className=\"flex items-center gap-2\">\n        <code className=\"flex-1 break-all rounded bg-background/50 px-3 py-2 font-mono text-sm\">\n          {apiKey.apiKey}\n        </code>\n        <Button\n          size=\"sm\"\n          variant=\"outline\"\n          onClick={() => onCopy(apiKey.apiKey, 'new')}\n        >\n          {copiedKey === 'new' ? <Check className=\"h-4 w-4\" /> : <Copy className=\"h-4 w-4\" />}\n        </Button>\n      </div>\n      <Button size=\"sm\" variant=\"ghost\" className=\"mt-3\" onClick={onDismiss}>\n        {t('apiKeys.newKey.iveSavedIt')}\n      </Button>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/settings/view/tabs/api-settings/sections/VersionInfoSection.tsx",
    "content": "import { ExternalLink } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport type { ReleaseInfo } from '../../../../../../types/sharedTypes';\n\ntype VersionInfoSectionProps = {\n  currentVersion: string;\n  updateAvailable: boolean;\n  latestVersion: string | null;\n  releaseInfo: ReleaseInfo | null;\n};\n\nexport default function VersionInfoSection({\n  currentVersion,\n  updateAvailable,\n  latestVersion,\n  releaseInfo,\n}: VersionInfoSectionProps) {\n  const { t } = useTranslation('settings');\n  const releasesUrl = releaseInfo?.htmlUrl || 'https://github.com/siteboon/claudecodeui/releases';\n\n  return (\n    <div className=\"border-t border-border/50 pt-6\">\n      <div className=\"flex items-center justify-between text-xs italic text-muted-foreground/60\">\n        <a\n          href={releasesUrl}\n          target=\"_blank\"\n          rel=\"noopener noreferrer\"\n          className=\"transition-colors hover:text-muted-foreground\"\n        >\n          v{currentVersion}\n        </a>\n        {updateAvailable && latestVersion && (\n          <a\n            href={releasesUrl}\n            target=\"_blank\"\n            rel=\"noopener noreferrer\"\n            className=\"flex items-center gap-1.5 rounded-full bg-green-500/10 px-2 py-0.5 font-medium not-italic text-green-600 transition-colors hover:bg-green-500/20 dark:text-green-400\"\n          >\n            <span className=\"text-[10px]\">{t('apiKeys.version.updateAvailable', { version: latestVersion })}</span>\n            <ExternalLink className=\"h-2.5 w-2.5\" />\n          </a>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/settings/view/tabs/api-settings/types.ts",
    "content": "export type ApiKeyItem = {\n  id: string;\n  key_name: string;\n  api_key: string;\n  created_at: string;\n  last_used?: string | null;\n  is_active: boolean;\n};\n\nexport type CreatedApiKey = {\n  id: string;\n  keyName: string;\n  apiKey: string;\n  createdAt?: string;\n};\n\nexport type GithubCredentialItem = {\n  id: string;\n  credential_name: string;\n  description?: string | null;\n  created_at: string;\n  is_active: boolean;\n};\n\nexport type ApiKeysResponse = {\n  apiKeys?: ApiKeyItem[];\n  success?: boolean;\n  error?: string;\n  apiKey?: CreatedApiKey;\n};\n\nexport type GithubCredentialsResponse = {\n  credentials?: GithubCredentialItem[];\n  success?: boolean;\n  error?: string;\n};\n"
  },
  {
    "path": "src/components/settings/view/tabs/git-settings/GitSettingsTab.tsx",
    "content": "import { Check } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { useGitSettings } from '../../../hooks/useGitSettings';\nimport { Button, Input } from '../../../../../shared/view/ui';\nimport SettingsCard from '../../SettingsCard';\nimport SettingsSection from '../../SettingsSection';\n\nexport default function GitSettingsTab() {\n  const { t } = useTranslation('settings');\n  const {\n    gitName,\n    setGitName,\n    gitEmail,\n    setGitEmail,\n    isLoading,\n    isSaving,\n    saveStatus,\n    saveGitConfig,\n  } = useGitSettings();\n\n  return (\n    <div className=\"space-y-8\">\n      <SettingsSection\n        title={t('git.title')}\n        description={t('git.description')}\n      >\n        <SettingsCard className=\"p-4\">\n          <div className=\"space-y-4\">\n            <div>\n              <label htmlFor=\"settings-git-name\" className=\"mb-2 block text-sm font-medium text-foreground\">\n                {t('git.name.label')}\n              </label>\n              <Input\n                id=\"settings-git-name\"\n                type=\"text\"\n                value={gitName}\n                onChange={(event) => setGitName(event.target.value)}\n                placeholder=\"John Doe\"\n                disabled={isLoading}\n                className=\"w-full\"\n              />\n              <p className=\"mt-1 text-xs text-muted-foreground\">{t('git.name.help')}</p>\n            </div>\n\n            <div>\n              <label htmlFor=\"settings-git-email\" className=\"mb-2 block text-sm font-medium text-foreground\">\n                {t('git.email.label')}\n              </label>\n              <Input\n                id=\"settings-git-email\"\n                type=\"email\"\n                value={gitEmail}\n                onChange={(event) => setGitEmail(event.target.value)}\n                placeholder=\"john@example.com\"\n                disabled={isLoading}\n                className=\"w-full\"\n              />\n              <p className=\"mt-1 text-xs text-muted-foreground\">{t('git.email.help')}</p>\n            </div>\n\n            <div className=\"flex items-center gap-2\">\n              <Button\n                onClick={saveGitConfig}\n                disabled={isSaving || !gitName.trim() || !gitEmail.trim()}\n              >\n                {isSaving ? t('git.actions.saving') : t('git.actions.save')}\n              </Button>\n\n              {saveStatus === 'success' && (\n                <div className=\"flex items-center gap-2 text-sm text-green-600 dark:text-green-400\">\n                  <Check className=\"h-4 w-4\" />\n                  {t('git.status.success')}\n                </div>\n              )}\n            </div>\n          </div>\n        </SettingsCard>\n      </SettingsSection>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/settings/view/tabs/tasks-settings/TasksSettingsTab.tsx",
    "content": "import { useTranslation } from 'react-i18next';\nimport { useTasksSettings } from '../../../../../contexts/TasksSettingsContext';\nimport SettingsCard from '../../SettingsCard';\nimport SettingsRow from '../../SettingsRow';\nimport SettingsSection from '../../SettingsSection';\nimport SettingsToggle from '../../SettingsToggle';\n\ntype TasksSettingsContextValue = {\n  tasksEnabled: boolean;\n  setTasksEnabled: (enabled: boolean) => void;\n  isTaskMasterInstalled: boolean | null;\n  isCheckingInstallation: boolean;\n};\n\nexport default function TasksSettingsTab() {\n  const { t } = useTranslation('settings');\n  const {\n    tasksEnabled,\n    setTasksEnabled,\n    isTaskMasterInstalled,\n    isCheckingInstallation,\n  } = useTasksSettings() as TasksSettingsContextValue;\n\n  return (\n    <div className=\"space-y-8\">\n      <SettingsSection title={t('mainTabs.tasks')}>\n        {isCheckingInstallation ? (\n          <SettingsCard className=\"p-4\">\n            <div className=\"flex items-center gap-3\">\n              <div className=\"h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent\" />\n              <span className=\"text-sm text-muted-foreground\">{t('tasks.checking')}</span>\n            </div>\n          </SettingsCard>\n        ) : (\n          <>\n            {!isTaskMasterInstalled && (\n              <div className=\"rounded-xl border border-orange-200 bg-orange-50 p-4 dark:border-orange-800/50 dark:bg-orange-950/30\">\n                <div className=\"flex items-start gap-3\">\n                  <div className=\"mt-0.5 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-orange-100 dark:bg-orange-900/50\">\n                    <svg className=\"h-4 w-4 text-orange-600 dark:text-orange-400\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                      <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z\" />\n                    </svg>\n                  </div>\n                  <div className=\"flex-1\">\n                    <div className=\"mb-2 font-medium text-orange-900 dark:text-orange-100\">\n                      {t('tasks.notInstalled.title')}\n                    </div>\n                    <div className=\"space-y-3 text-sm text-orange-800 dark:text-orange-200\">\n                      <p>{t('tasks.notInstalled.description')}</p>\n\n                      <div className=\"rounded-lg bg-orange-100 p-3 font-mono text-sm dark:bg-orange-900/40\">\n                        <code>{t('tasks.notInstalled.installCommand')}</code>\n                      </div>\n\n                      <div>\n                        <a\n                          href=\"https://github.com/eyaltoledano/claude-task-master\"\n                          target=\"_blank\"\n                          rel=\"noopener noreferrer\"\n                          className=\"inline-flex items-center gap-2 text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300\"\n                        >\n                          <svg className=\"h-4 w-4\" fill=\"currentColor\" viewBox=\"0 0 20 20\">\n                            <path fillRule=\"evenodd\" d=\"M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z\" clipRule=\"evenodd\" />\n                          </svg>\n                          {t('tasks.notInstalled.viewOnGitHub')}\n                          <svg className=\"h-3 w-3\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14\" />\n                          </svg>\n                        </a>\n                      </div>\n\n                      <div className=\"space-y-2\">\n                        <p className=\"font-medium\">{t('tasks.notInstalled.afterInstallation')}</p>\n                        <ol className=\"list-inside list-decimal space-y-1 text-xs\">\n                          <li>{t('tasks.notInstalled.steps.restart')}</li>\n                          <li>{t('tasks.notInstalled.steps.autoAvailable')}</li>\n                          <li>{t('tasks.notInstalled.steps.initCommand')}</li>\n                        </ol>\n                      </div>\n                    </div>\n                  </div>\n                </div>\n              </div>\n            )}\n\n            {isTaskMasterInstalled && (\n              <SettingsCard>\n                <SettingsRow\n                  label={t('tasks.settings.enableLabel')}\n                  description={t('tasks.settings.enableDescription')}\n                >\n                  <SettingsToggle\n                    checked={tasksEnabled}\n                    onChange={setTasksEnabled}\n                    ariaLabel={t('tasks.settings.enableLabel')}\n                  />\n                </SettingsRow>\n              </SettingsCard>\n            )}\n          </>\n        )}\n      </SettingsSection>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/shell/constants/constants.ts",
    "content": "import type { ITerminalOptions } from '@xterm/xterm';\n\nexport const CODEX_DEVICE_AUTH_URL = 'https://auth.openai.com/codex/device';\nexport const SHELL_RESTART_DELAY_MS = 200;\nexport const TERMINAL_INIT_DELAY_MS = 100;\nexport const TERMINAL_RESIZE_DELAY_MS = 50;\n\n// CLI prompt overlay detection\nexport const PROMPT_DEBOUNCE_MS = 500;\nexport const PROMPT_BUFFER_SCAN_LINES = 20;\nexport const PROMPT_OPTION_SCAN_LINES = 15;\nexport const PROMPT_MAX_OPTIONS = 5;\nexport const PROMPT_MIN_OPTIONS = 2;\n\nexport const TERMINAL_OPTIONS: ITerminalOptions = {\n  cursorBlink: true,\n  fontSize: 14,\n  fontFamily: 'Menlo, Monaco, \"Courier New\", monospace',\n  allowProposedApi: true,\n  allowTransparency: false,\n  convertEol: true,\n  scrollback: 10000,\n  tabStopWidth: 4,\n  windowsMode: false,\n  macOptionIsMeta: true,\n  macOptionClickForcesSelection: true,\n  // Keep the runtime theme keys used by the previous JSX implementation.\n  theme: {\n    background: '#1e1e1e',\n    foreground: '#d4d4d4',\n    cursor: '#ffffff',\n    cursorAccent: '#1e1e1e',\n    selectionBackground: '#264f78',\n    selectionForeground: '#ffffff',\n    black: '#000000',\n    red: '#cd3131',\n    green: '#0dbc79',\n    yellow: '#e5e510',\n    blue: '#2472c8',\n    magenta: '#bc3fbc',\n    cyan: '#11a8cd',\n    white: '#e5e5e5',\n    brightBlack: '#666666',\n    brightRed: '#f14c4c',\n    brightGreen: '#23d18b',\n    brightYellow: '#f5f543',\n    brightBlue: '#3b8eea',\n    brightMagenta: '#d670d6',\n    brightCyan: '#29b8db',\n    brightWhite: '#ffffff',\n    extendedAnsi: [\n      '#000000',\n      '#800000',\n      '#008000',\n      '#808000',\n      '#000080',\n      '#800080',\n      '#008080',\n      '#c0c0c0',\n      '#808080',\n      '#ff0000',\n      '#00ff00',\n      '#ffff00',\n      '#0000ff',\n      '#ff00ff',\n      '#00ffff',\n      '#ffffff',\n    ],\n  },\n};\n"
  },
  {
    "path": "src/components/shell/hooks/useShellConnection.ts",
    "content": "import { useCallback, useEffect, useRef, useState } from 'react';\nimport type { MutableRefObject } from 'react';\nimport type { FitAddon } from '@xterm/addon-fit';\nimport type { Terminal } from '@xterm/xterm';\nimport type { Project, ProjectSession } from '../../../types/app';\nimport { TERMINAL_INIT_DELAY_MS } from '../constants/constants';\nimport { getShellWebSocketUrl, parseShellMessage, sendSocketMessage } from '../utils/socket';\n\nconst ANSI_ESCAPE_REGEX =\n  /(?:\\u001B\\[[0-?]*[ -/]*[@-~]|\\u009B[0-?]*[ -/]*[@-~]|\\u001B\\][^\\u0007\\u001B]*(?:\\u0007|\\u001B\\\\)|\\u009D[^\\u0007\\u009C]*(?:\\u0007|\\u009C)|\\u001B[PX^_][^\\u001B]*\\u001B\\\\|[\\u0090\\u0098\\u009E\\u009F][^\\u009C]*\\u009C|\\u001B[@-Z\\\\-_])/g;\nconst PROCESS_EXIT_REGEX = /Process exited with code (\\d+)/;\n\ntype UseShellConnectionOptions = {\n  wsRef: MutableRefObject<WebSocket | null>;\n  terminalRef: MutableRefObject<Terminal | null>;\n  fitAddonRef: MutableRefObject<FitAddon | null>;\n  selectedProjectRef: MutableRefObject<Project | null | undefined>;\n  selectedSessionRef: MutableRefObject<ProjectSession | null | undefined>;\n  initialCommandRef: MutableRefObject<string | null | undefined>;\n  isPlainShellRef: MutableRefObject<boolean>;\n  onProcessCompleteRef: MutableRefObject<((exitCode: number) => void) | null | undefined>;\n  isInitialized: boolean;\n  autoConnect: boolean;\n  closeSocket: () => void;\n  clearTerminalScreen: () => void;\n  setAuthUrl: (nextAuthUrl: string) => void;\n  onOutputRef?: MutableRefObject<(() => void) | null>;\n};\n\ntype UseShellConnectionResult = {\n  isConnected: boolean;\n  isConnecting: boolean;\n  closeSocket: () => void;\n  connectToShell: () => void;\n  disconnectFromShell: () => void;\n};\n\nexport function useShellConnection({\n  wsRef,\n  terminalRef,\n  fitAddonRef,\n  selectedProjectRef,\n  selectedSessionRef,\n  initialCommandRef,\n  isPlainShellRef,\n  onProcessCompleteRef,\n  isInitialized,\n  autoConnect,\n  closeSocket,\n  clearTerminalScreen,\n  setAuthUrl,\n  onOutputRef,\n}: UseShellConnectionOptions): UseShellConnectionResult {\n  const [isConnected, setIsConnected] = useState(false);\n  const [isConnecting, setIsConnecting] = useState(false);\n  const connectingRef = useRef(false);\n\n  const handleProcessCompletion = useCallback(\n    (output: string) => {\n      if (!isPlainShellRef.current || !onProcessCompleteRef.current) {\n        return;\n      }\n\n      const sanitizedOutput = output.replace(ANSI_ESCAPE_REGEX, '');\n      const cleanOutput = sanitizedOutput;\n      if (cleanOutput.includes('Process exited with code 0')) {\n        onProcessCompleteRef.current(0);\n        return;\n      }\n\n      const match = cleanOutput.match(PROCESS_EXIT_REGEX);\n      if (!match) {\n        return;\n      }\n\n      const exitCode = Number.parseInt(match[1], 10);\n      if (!Number.isNaN(exitCode) && exitCode !== 0) {\n        onProcessCompleteRef.current(exitCode);\n      }\n    },\n    [isPlainShellRef, onProcessCompleteRef],\n  );\n\n  const handleSocketMessage = useCallback(\n    (rawPayload: string) => {\n      const message = parseShellMessage(rawPayload);\n      if (!message) {\n        console.error('[Shell] Error handling WebSocket message:', rawPayload);\n        return;\n      }\n\n      if (message.type === 'output') {\n        const output = typeof message.data === 'string' ? message.data : '';\n        handleProcessCompletion(output);\n        terminalRef.current?.write(output);\n        onOutputRef?.current?.();\n        return;\n      }\n\n      if (message.type === 'auth_url' || message.type === 'url_open') {\n        const nextAuthUrl = typeof message.url === 'string' ? message.url : '';\n        if (nextAuthUrl) {\n          setAuthUrl(nextAuthUrl);\n        }\n      }\n    },\n    [handleProcessCompletion, onOutputRef, setAuthUrl, terminalRef],\n  );\n\n  const connectWebSocket = useCallback(\n    (isConnectionLocked = false) => {\n      if ((connectingRef.current && !isConnectionLocked) || isConnecting || isConnected) {\n        return;\n      }\n\n      try {\n        const wsUrl = getShellWebSocketUrl();\n        if (!wsUrl) {\n          connectingRef.current = false;\n          setIsConnecting(false);\n          return;\n        }\n\n        connectingRef.current = true;\n\n        const socket = new WebSocket(wsUrl);\n        wsRef.current = socket;\n\n        socket.onopen = () => {\n          setIsConnected(true);\n          setIsConnecting(false);\n          connectingRef.current = false;\n          setAuthUrl('');\n\n          window.setTimeout(() => {\n            const currentTerminal = terminalRef.current;\n            const currentFitAddon = fitAddonRef.current;\n            const currentProject = selectedProjectRef.current;\n            if (!currentTerminal || !currentFitAddon || !currentProject) {\n              return;\n            }\n\n            currentFitAddon.fit();\n\n            sendSocketMessage(socket, {\n              type: 'init',\n              projectPath: currentProject.fullPath || currentProject.path || '',\n              sessionId: isPlainShellRef.current ? null : selectedSessionRef.current?.id || null,\n              hasSession: isPlainShellRef.current ? false : Boolean(selectedSessionRef.current),\n              provider: isPlainShellRef.current ? 'plain-shell' : (selectedSessionRef.current?.__provider || localStorage.getItem('selected-provider') || 'claude'),\n              cols: currentTerminal.cols,\n              rows: currentTerminal.rows,\n              initialCommand: initialCommandRef.current,\n              isPlainShell: isPlainShellRef.current,\n            });\n          }, TERMINAL_INIT_DELAY_MS);\n        };\n\n        socket.onmessage = (event) => {\n          const rawPayload = typeof event.data === 'string' ? event.data : String(event.data ?? '');\n          handleSocketMessage(rawPayload);\n        };\n\n        socket.onclose = () => {\n          setIsConnected(false);\n          setIsConnecting(false);\n          connectingRef.current = false;\n          clearTerminalScreen();\n        };\n\n        socket.onerror = () => {\n          setIsConnected(false);\n          setIsConnecting(false);\n          connectingRef.current = false;\n        };\n      } catch {\n        setIsConnected(false);\n        setIsConnecting(false);\n        connectingRef.current = false;\n      }\n    },\n    [\n      clearTerminalScreen,\n      fitAddonRef,\n      handleSocketMessage,\n      initialCommandRef,\n      isConnected,\n      isConnecting,\n      isPlainShellRef,\n      selectedProjectRef,\n      selectedSessionRef,\n      setAuthUrl,\n      terminalRef,\n      wsRef,\n    ],\n  );\n\n  const connectToShell = useCallback(() => {\n    if (!isInitialized || isConnected || isConnecting || connectingRef.current) {\n      return;\n    }\n\n    connectingRef.current = true;\n    setIsConnecting(true);\n    connectWebSocket(true);\n  }, [connectWebSocket, isConnected, isConnecting, isInitialized]);\n\n  const disconnectFromShell = useCallback(() => {\n    closeSocket();\n    clearTerminalScreen();\n    setIsConnected(false);\n    setIsConnecting(false);\n    connectingRef.current = false;\n    setAuthUrl('');\n  }, [clearTerminalScreen, closeSocket, setAuthUrl]);\n\n  useEffect(() => {\n    if (!autoConnect || !isInitialized || isConnecting || isConnected) {\n      return;\n    }\n\n    connectToShell();\n  }, [autoConnect, connectToShell, isConnected, isConnecting, isInitialized]);\n\n  return {\n    isConnected,\n    isConnecting,\n    closeSocket,\n    connectToShell,\n    disconnectFromShell,\n  };\n}\n"
  },
  {
    "path": "src/components/shell/hooks/useShellRuntime.ts",
    "content": "import { useCallback, useEffect, useRef, useState } from 'react';\nimport type { FitAddon } from '@xterm/addon-fit';\nimport type { Terminal } from '@xterm/xterm';\nimport type { UseShellRuntimeOptions, UseShellRuntimeResult } from '../types/types';\nimport { copyTextToClipboard } from '../../../utils/clipboard';\nimport { useShellConnection } from './useShellConnection';\nimport { useShellTerminal } from './useShellTerminal';\n\nexport function useShellRuntime({\n  selectedProject,\n  selectedSession,\n  initialCommand,\n  isPlainShell,\n  minimal,\n  autoConnect,\n  isRestarting,\n  onProcessComplete,\n  onOutputRef,\n}: UseShellRuntimeOptions): UseShellRuntimeResult {\n  const terminalContainerRef = useRef<HTMLDivElement>(null);\n  const terminalRef = useRef<Terminal | null>(null);\n  const fitAddonRef = useRef<FitAddon | null>(null);\n  const wsRef = useRef<WebSocket | null>(null);\n\n  const [authUrl, setAuthUrl] = useState('');\n  const [authUrlVersion, setAuthUrlVersion] = useState(0);\n\n  const selectedProjectRef = useRef(selectedProject);\n  const selectedSessionRef = useRef(selectedSession);\n  const initialCommandRef = useRef(initialCommand);\n  const isPlainShellRef = useRef(isPlainShell);\n  const onProcessCompleteRef = useRef(onProcessComplete);\n  const authUrlRef = useRef('');\n  const lastSessionIdRef = useRef<string | null>(selectedSession?.id ?? null);\n\n  // Keep mutable values in refs so websocket handlers always read current data.\n  useEffect(() => {\n    selectedProjectRef.current = selectedProject;\n    selectedSessionRef.current = selectedSession;\n    initialCommandRef.current = initialCommand;\n    isPlainShellRef.current = isPlainShell;\n    onProcessCompleteRef.current = onProcessComplete;\n  }, [selectedProject, selectedSession, initialCommand, isPlainShell, onProcessComplete]);\n\n  const setCurrentAuthUrl = useCallback((nextAuthUrl: string) => {\n    authUrlRef.current = nextAuthUrl;\n    setAuthUrl(nextAuthUrl);\n    setAuthUrlVersion((previous) => previous + 1);\n  }, []);\n\n  const closeSocket = useCallback(() => {\n    const activeSocket = wsRef.current;\n    if (!activeSocket) {\n      return;\n    }\n\n    if (\n      activeSocket.readyState === WebSocket.OPEN ||\n      activeSocket.readyState === WebSocket.CONNECTING\n    ) {\n      activeSocket.close();\n    }\n\n    wsRef.current = null;\n  }, []);\n\n  const openAuthUrlInBrowser = useCallback((url = authUrlRef.current) => {\n    if (!url) {\n      return false;\n    }\n\n    const popup = window.open(url, '_blank');\n    if (popup) {\n      try {\n        popup.opener = null;\n      } catch {\n        // Ignore cross-origin restrictions when trying to null opener.\n      }\n      return true;\n    }\n\n    return false;\n  }, []);\n\n  const copyAuthUrlToClipboard = useCallback(async (url = authUrlRef.current) => {\n    if (!url) {\n      return false;\n    }\n\n    return copyTextToClipboard(url);\n  }, []);\n\n  const { isInitialized, clearTerminalScreen, disposeTerminal } = useShellTerminal({\n    terminalContainerRef,\n    terminalRef,\n    fitAddonRef,\n    wsRef,\n    selectedProject,\n    minimal,\n    isRestarting,\n    initialCommandRef,\n    isPlainShellRef,\n    authUrlRef,\n    copyAuthUrlToClipboard,\n    closeSocket,\n  });\n\n  const { isConnected, isConnecting, connectToShell, disconnectFromShell } = useShellConnection({\n    wsRef,\n    terminalRef,\n    fitAddonRef,\n    selectedProjectRef,\n    selectedSessionRef,\n    initialCommandRef,\n    isPlainShellRef,\n    onProcessCompleteRef,\n    isInitialized,\n    autoConnect,\n    closeSocket,\n    clearTerminalScreen,\n    setAuthUrl: setCurrentAuthUrl,\n    onOutputRef,\n  });\n\n  useEffect(() => {\n    if (!isRestarting) {\n      return;\n    }\n\n    disconnectFromShell();\n    disposeTerminal();\n  }, [disconnectFromShell, disposeTerminal, isRestarting]);\n\n  useEffect(() => {\n    if (selectedProject) {\n      return;\n    }\n\n    disconnectFromShell();\n    disposeTerminal();\n  }, [disconnectFromShell, disposeTerminal, selectedProject]);\n\n  useEffect(() => {\n    const currentSessionId = selectedSession?.id ?? null;\n    if (lastSessionIdRef.current !== currentSessionId && isInitialized) {\n      disconnectFromShell();\n    }\n\n    lastSessionIdRef.current = currentSessionId;\n  }, [disconnectFromShell, isInitialized, selectedSession?.id]);\n\n  return {\n    terminalContainerRef,\n    terminalRef,\n    wsRef,\n    isConnected,\n    isInitialized,\n    isConnecting,\n    authUrl,\n    authUrlVersion,\n    connectToShell,\n    disconnectFromShell,\n    openAuthUrlInBrowser,\n    copyAuthUrlToClipboard,\n  };\n}\n"
  },
  {
    "path": "src/components/shell/hooks/useShellTerminal.ts",
    "content": "import { useCallback, useEffect, useRef, useState } from 'react';\nimport type { MutableRefObject, RefObject } from 'react';\nimport { FitAddon } from '@xterm/addon-fit';\nimport { WebLinksAddon } from '@xterm/addon-web-links';\nimport { WebglAddon } from '@xterm/addon-webgl';\nimport { Terminal } from '@xterm/xterm';\nimport type { Project } from '../../../types/app';\nimport {\n  CODEX_DEVICE_AUTH_URL,\n  TERMINAL_INIT_DELAY_MS,\n  TERMINAL_OPTIONS,\n  TERMINAL_RESIZE_DELAY_MS,\n} from '../constants/constants';\nimport { copyTextToClipboard } from '../../../utils/clipboard';\nimport { isCodexLoginCommand } from '../utils/auth';\nimport { sendSocketMessage } from '../utils/socket';\nimport { ensureXtermFocusStyles } from '../utils/terminalStyles';\n\ntype UseShellTerminalOptions = {\n  terminalContainerRef: RefObject<HTMLDivElement>;\n  terminalRef: MutableRefObject<Terminal | null>;\n  fitAddonRef: MutableRefObject<FitAddon | null>;\n  wsRef: MutableRefObject<WebSocket | null>;\n  selectedProject: Project | null | undefined;\n  minimal: boolean;\n  isRestarting: boolean;\n  initialCommandRef: MutableRefObject<string | null | undefined>;\n  isPlainShellRef: MutableRefObject<boolean>;\n  authUrlRef: MutableRefObject<string>;\n  copyAuthUrlToClipboard: (url?: string) => Promise<boolean>;\n  closeSocket: () => void;\n};\n\ntype UseShellTerminalResult = {\n  isInitialized: boolean;\n  clearTerminalScreen: () => void;\n  disposeTerminal: () => void;\n};\n\nexport function useShellTerminal({\n  terminalContainerRef,\n  terminalRef,\n  fitAddonRef,\n  wsRef,\n  selectedProject,\n  minimal,\n  isRestarting,\n  initialCommandRef,\n  isPlainShellRef,\n  authUrlRef,\n  copyAuthUrlToClipboard,\n  closeSocket,\n}: UseShellTerminalOptions): UseShellTerminalResult {\n  const [isInitialized, setIsInitialized] = useState(false);\n  const resizeTimeoutRef = useRef<number | null>(null);\n  const selectedProjectKey = selectedProject?.fullPath || selectedProject?.path || '';\n  const hasSelectedProject = Boolean(selectedProject);\n\n  useEffect(() => {\n    ensureXtermFocusStyles();\n  }, []);\n\n  const clearTerminalScreen = useCallback(() => {\n    if (!terminalRef.current) {\n      return;\n    }\n\n    terminalRef.current.clear();\n    terminalRef.current.write('\\x1b[2J\\x1b[H');\n  }, [terminalRef]);\n\n  const disposeTerminal = useCallback(() => {\n    if (terminalRef.current) {\n      terminalRef.current.dispose();\n      terminalRef.current = null;\n    }\n\n    fitAddonRef.current = null;\n    setIsInitialized(false);\n  }, [fitAddonRef, terminalRef]);\n\n  useEffect(() => {\n    if (!terminalContainerRef.current || !hasSelectedProject || isRestarting || terminalRef.current) {\n      return;\n    }\n\n    const nextTerminal = new Terminal(TERMINAL_OPTIONS);\n    terminalRef.current = nextTerminal;\n\n    const nextFitAddon = new FitAddon();\n    fitAddonRef.current = nextFitAddon;\n    nextTerminal.loadAddon(nextFitAddon);\n\n    // Avoid wrapped partial links in compact login flows.\n    if (!minimal) {\n      nextTerminal.loadAddon(new WebLinksAddon());\n    }\n\n    try {\n      nextTerminal.loadAddon(new WebglAddon());\n    } catch {\n      console.warn('[Shell] WebGL renderer unavailable, using Canvas fallback');\n    }\n\n    nextTerminal.open(terminalContainerRef.current);\n\n    const copyTerminalSelection = async () => {\n      const selection = nextTerminal.getSelection();\n      if (!selection) {\n        return false;\n      }\n\n      return copyTextToClipboard(selection);\n    };\n\n    const handleTerminalCopy = (event: ClipboardEvent) => {\n      if (!nextTerminal.hasSelection()) {\n        return;\n      }\n\n      const selection = nextTerminal.getSelection();\n      if (!selection) {\n        return;\n      }\n\n      event.preventDefault();\n\n      if (event.clipboardData) {\n        event.clipboardData.setData('text/plain', selection);\n        return;\n      }\n\n      void copyTextToClipboard(selection);\n    };\n\n    terminalContainerRef.current.addEventListener('copy', handleTerminalCopy);\n\n    nextTerminal.attachCustomKeyEventHandler((event) => {\n      const activeAuthUrl = isCodexLoginCommand(initialCommandRef.current)\n        ? CODEX_DEVICE_AUTH_URL\n        : authUrlRef.current;\n\n      if (\n        event.type === 'keydown' &&\n        minimal &&\n        isPlainShellRef.current &&\n        activeAuthUrl &&\n        !event.ctrlKey &&\n        !event.metaKey &&\n        !event.altKey &&\n        event.key?.toLowerCase() === 'c'\n      ) {\n        event.preventDefault();\n        event.stopPropagation();\n        void copyAuthUrlToClipboard(activeAuthUrl);\n        return false;\n      }\n\n      if (\n        event.type === 'keydown' &&\n        (event.ctrlKey || event.metaKey) &&\n        event.key?.toLowerCase() === 'c' &&\n        nextTerminal.hasSelection()\n      ) {\n        event.preventDefault();\n        event.stopPropagation();\n        void copyTerminalSelection();\n        return false;\n      }\n\n      if (\n        event.type === 'keydown' &&\n        (event.ctrlKey || event.metaKey) &&\n        event.key?.toLowerCase() === 'v'\n      ) {\n        // Block native paste so data is only injected after clipboard-read resolves.\n        event.preventDefault();\n        event.stopPropagation();\n\n        if (typeof navigator !== 'undefined' && navigator.clipboard?.readText) {\n          navigator.clipboard\n            .readText()\n            .then((text) => {\n              sendSocketMessage(wsRef.current, {\n                type: 'input',\n                data: text,\n              });\n            })\n            .catch(() => {});\n        }\n\n        return false;\n      }\n\n      return true;\n    });\n\n    window.setTimeout(() => {\n      const currentFitAddon = fitAddonRef.current;\n      const currentTerminal = terminalRef.current;\n      if (!currentFitAddon || !currentTerminal) {\n        return;\n      }\n\n      currentFitAddon.fit();\n      sendSocketMessage(wsRef.current, {\n        type: 'resize',\n        cols: currentTerminal.cols,\n        rows: currentTerminal.rows,\n      });\n    }, TERMINAL_INIT_DELAY_MS);\n\n    setIsInitialized(true);\n\n    const dataSubscription = nextTerminal.onData((data) => {\n      sendSocketMessage(wsRef.current, {\n        type: 'input',\n        data,\n      });\n    });\n\n    const resizeObserver = new ResizeObserver(() => {\n      if (resizeTimeoutRef.current !== null) {\n        window.clearTimeout(resizeTimeoutRef.current);\n      }\n\n      resizeTimeoutRef.current = window.setTimeout(() => {\n        const currentFitAddon = fitAddonRef.current;\n        const currentTerminal = terminalRef.current;\n        if (!currentFitAddon || !currentTerminal) {\n          return;\n        }\n\n        currentFitAddon.fit();\n        sendSocketMessage(wsRef.current, {\n          type: 'resize',\n          cols: currentTerminal.cols,\n          rows: currentTerminal.rows,\n        });\n      }, TERMINAL_RESIZE_DELAY_MS);\n    });\n\n    resizeObserver.observe(terminalContainerRef.current);\n\n    return () => {\n      terminalContainerRef.current?.removeEventListener('copy', handleTerminalCopy);\n      resizeObserver.disconnect();\n      if (resizeTimeoutRef.current !== null) {\n        window.clearTimeout(resizeTimeoutRef.current);\n        resizeTimeoutRef.current = null;\n      }\n      dataSubscription.dispose();\n      closeSocket();\n      disposeTerminal();\n    };\n  }, [\n    authUrlRef,\n    closeSocket,\n    copyAuthUrlToClipboard,\n    disposeTerminal,\n    fitAddonRef,\n    initialCommandRef,\n    isPlainShellRef,\n    isRestarting,\n    minimal,\n    hasSelectedProject,\n    selectedProjectKey,\n    terminalContainerRef,\n    terminalRef,\n    wsRef,\n  ]);\n\n  return {\n    isInitialized,\n    clearTerminalScreen,\n    disposeTerminal,\n  };\n}\n"
  },
  {
    "path": "src/components/shell/types/types.ts",
    "content": "import type { MutableRefObject, RefObject } from 'react';\nimport type { FitAddon } from '@xterm/addon-fit';\nimport type { Terminal } from '@xterm/xterm';\nimport type { Project, ProjectSession } from '../../../types/app';\n\nexport type AuthCopyStatus = 'idle' | 'copied' | 'failed';\n\nexport type ShellInitMessage = {\n  type: 'init';\n  projectPath: string;\n  sessionId: string | null;\n  hasSession: boolean;\n  provider: string;\n  cols: number;\n  rows: number;\n  initialCommand: string | null | undefined;\n  isPlainShell: boolean;\n};\n\nexport type ShellResizeMessage = {\n  type: 'resize';\n  cols: number;\n  rows: number;\n};\n\nexport type ShellInputMessage = {\n  type: 'input';\n  data: string;\n};\n\nexport type ShellOutgoingMessage = ShellInitMessage | ShellResizeMessage | ShellInputMessage;\n\nexport type ShellIncomingMessage =\n  | { type: 'output'; data: string }\n  | { type: 'auth_url'; url?: string }\n  | { type: 'url_open'; url?: string }\n  | { type: string; [key: string]: unknown };\n\nexport type UseShellRuntimeOptions = {\n  selectedProject: Project | null | undefined;\n  selectedSession: ProjectSession | null | undefined;\n  initialCommand: string | null | undefined;\n  isPlainShell: boolean;\n  minimal: boolean;\n  autoConnect: boolean;\n  isRestarting: boolean;\n  onProcessComplete?: ((exitCode: number) => void) | null;\n  onOutputRef?: MutableRefObject<(() => void) | null>;\n};\n\nexport type ShellSharedRefs = {\n  wsRef: MutableRefObject<WebSocket | null>;\n  terminalRef: MutableRefObject<Terminal | null>;\n  fitAddonRef: MutableRefObject<FitAddon | null>;\n  authUrlRef: MutableRefObject<string>;\n  selectedProjectRef: MutableRefObject<Project | null | undefined>;\n  selectedSessionRef: MutableRefObject<ProjectSession | null | undefined>;\n  initialCommandRef: MutableRefObject<string | null | undefined>;\n  isPlainShellRef: MutableRefObject<boolean>;\n  onProcessCompleteRef: MutableRefObject<((exitCode: number) => void) | null | undefined>;\n};\n\nexport type UseShellRuntimeResult = {\n  terminalContainerRef: RefObject<HTMLDivElement>;\n  terminalRef: MutableRefObject<Terminal | null>;\n  wsRef: MutableRefObject<WebSocket | null>;\n  isConnected: boolean;\n  isInitialized: boolean;\n  isConnecting: boolean;\n  authUrl: string;\n  authUrlVersion: number;\n  connectToShell: () => void;\n  disconnectFromShell: () => void;\n  openAuthUrlInBrowser: (url?: string) => boolean;\n  copyAuthUrlToClipboard: (url?: string) => Promise<boolean>;\n};\n"
  },
  {
    "path": "src/components/shell/utils/auth.ts",
    "content": "import type { ProjectSession } from '../../../types/app';\nimport { CODEX_DEVICE_AUTH_URL } from '../constants/constants';\n\nexport function isCodexLoginCommand(command: string | null | undefined): boolean {\n  return typeof command === 'string' && /\\bcodex\\s+login\\b/i.test(command);\n}\n\nexport function resolveAuthUrlForDisplay(command: string | null | undefined, authUrl: string): string {\n  if (isCodexLoginCommand(command)) {\n    return CODEX_DEVICE_AUTH_URL;\n  }\n\n  return authUrl;\n}\n\nexport function getSessionDisplayName(session: ProjectSession | null | undefined): string | null {\n  if (!session) {\n    return null;\n  }\n\n  return session.__provider === 'cursor'\n    ? session.name || 'Untitled Session'\n    : session.summary || 'New Session';\n}"
  },
  {
    "path": "src/components/shell/utils/socket.ts",
    "content": "import { IS_PLATFORM } from '../../../constants/config';\nimport type { ShellIncomingMessage, ShellOutgoingMessage } from '../types/types';\n\nexport function getShellWebSocketUrl(): string | null {\n  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';\n\n  if (IS_PLATFORM) {\n    return `${protocol}//${window.location.host}/shell`;\n  }\n\n  const token = localStorage.getItem('auth-token');\n  if (!token) {\n    console.error('No authentication token found for Shell WebSocket connection');\n    return null;\n  }\n\n  return `${protocol}//${window.location.host}/shell?token=${encodeURIComponent(token)}`;\n}\n\nexport function parseShellMessage(payload: string): ShellIncomingMessage | null {\n  try {\n    return JSON.parse(payload) as ShellIncomingMessage;\n  } catch {\n    return null;\n  }\n}\n\nexport function sendSocketMessage(ws: WebSocket | null, message: ShellOutgoingMessage): void {\n  if (ws && ws.readyState === WebSocket.OPEN) {\n    ws.send(JSON.stringify(message));\n  }\n}"
  },
  {
    "path": "src/components/shell/utils/terminalStyles.ts",
    "content": "const XTERM_STYLE_ELEMENT_ID = 'shell-xterm-focus-style';\n\nconst XTERM_FOCUS_STYLES = `\n  .xterm .xterm-screen {\n    outline: none !important;\n  }\n  .xterm:focus .xterm-screen {\n    outline: none !important;\n  }\n  .xterm-screen:focus {\n    outline: none !important;\n  }\n`;\n\nexport function ensureXtermFocusStyles(): void {\n  if (typeof document === 'undefined') {\n    return;\n  }\n\n  if (document.getElementById(XTERM_STYLE_ELEMENT_ID)) {\n    return;\n  }\n\n  const styleSheet = document.createElement('style');\n  styleSheet.id = XTERM_STYLE_ELEMENT_ID;\n  styleSheet.type = 'text/css';\n  styleSheet.innerText = XTERM_FOCUS_STYLES;\n  document.head.appendChild(styleSheet);\n}"
  },
  {
    "path": "src/components/shell/view/Shell.tsx",
    "content": "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport '@xterm/xterm/css/xterm.css';\nimport type { Project, ProjectSession } from '../../../types/app';\nimport {\n  PROMPT_BUFFER_SCAN_LINES,\n  PROMPT_DEBOUNCE_MS,\n  PROMPT_MAX_OPTIONS,\n  PROMPT_MIN_OPTIONS,\n  PROMPT_OPTION_SCAN_LINES,\n  SHELL_RESTART_DELAY_MS,\n} from '../constants/constants';\nimport { useShellRuntime } from '../hooks/useShellRuntime';\nimport { sendSocketMessage } from '../utils/socket';\nimport { getSessionDisplayName } from '../utils/auth';\nimport ShellConnectionOverlay from './subcomponents/ShellConnectionOverlay';\nimport ShellEmptyState from './subcomponents/ShellEmptyState';\nimport ShellHeader from './subcomponents/ShellHeader';\nimport ShellMinimalView from './subcomponents/ShellMinimalView';\nimport TerminalShortcutsPanel from './subcomponents/TerminalShortcutsPanel';\n\ntype CliPromptOption = { number: string; label: string };\n\ntype ShellProps = {\n  selectedProject?: Project | null;\n  selectedSession?: ProjectSession | null;\n  initialCommand?: string | null;\n  isPlainShell?: boolean;\n  onProcessComplete?: ((exitCode: number) => void) | null;\n  minimal?: boolean;\n  autoConnect?: boolean;\n  isActive?: boolean;\n};\n\nexport default function Shell({\n  selectedProject = null,\n  selectedSession = null,\n  initialCommand = null,\n  isPlainShell = false,\n  onProcessComplete = null,\n  minimal = false,\n  autoConnect = false,\n  isActive = true,\n}: ShellProps) {\n  const { t } = useTranslation('chat');\n  const [isRestarting, setIsRestarting] = useState(false);\n  const [cliPromptOptions, setCliPromptOptions] = useState<CliPromptOption[] | null>(null);\n  const promptCheckTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const onOutputRef = useRef<(() => void) | null>(null);\n\n  const {\n    terminalContainerRef,\n    terminalRef,\n    wsRef,\n    isConnected,\n    isInitialized,\n    isConnecting,\n    authUrl,\n    authUrlVersion,\n    connectToShell,\n    disconnectFromShell,\n    openAuthUrlInBrowser,\n    copyAuthUrlToClipboard,\n  } = useShellRuntime({\n    selectedProject,\n    selectedSession,\n    initialCommand,\n    isPlainShell,\n    minimal,\n    autoConnect,\n    isRestarting,\n    onProcessComplete,\n    onOutputRef,\n  });\n\n  // Check xterm.js buffer for CLI prompt patterns (❯ N. label)\n  const checkBufferForPrompt = useCallback(() => {\n    const term = terminalRef.current;\n    if (!term) return;\n    const buf = term.buffer.active;\n    const lastContentRow = buf.baseY + buf.cursorY;\n    const scanEnd = Math.min(buf.baseY + buf.length - 1, lastContentRow + 10);\n    const scanStart = Math.max(0, lastContentRow - PROMPT_BUFFER_SCAN_LINES);\n    const lines: string[] = [];\n    for (let i = scanStart; i <= scanEnd; i++) {\n      const line = buf.getLine(i);\n      if (line) lines.push(line.translateToString().trimEnd());\n    }\n\n    let footerIdx = -1;\n    for (let i = lines.length - 1; i >= 0; i--) {\n      if (/esc to cancel/i.test(lines[i]) || /enter to select/i.test(lines[i])) {\n        footerIdx = i;\n        break;\n      }\n    }\n\n    if (footerIdx === -1) {\n      setCliPromptOptions(null);\n      return;\n    }\n\n    // Scan upward from footer collecting numbered options.\n    // Non-matching lines are allowed (multi-line labels, blank separators)\n    // because CLI prompts may wrap options across multiple terminal rows.\n    const optMap = new Map<string, string>();\n    const optScanStart = Math.max(0, footerIdx - PROMPT_OPTION_SCAN_LINES);\n    for (let i = footerIdx - 1; i >= optScanStart; i--) {\n      const match = lines[i].match(/^\\s*[❯›>]?\\s*(\\d+)\\.\\s+(.+)/);\n      if (match) {\n        const num = match[1];\n        const label = match[2].trim();\n        if (parseInt(num, 10) <= PROMPT_MAX_OPTIONS && label.length > 0 && !optMap.has(num)) {\n          optMap.set(num, label);\n        }\n      }\n    }\n\n    const valid: CliPromptOption[] = [];\n    for (let i = 1; i <= optMap.size; i++) {\n      if (optMap.has(String(i))) valid.push({ number: String(i), label: optMap.get(String(i))! });\n      else break;\n    }\n\n    setCliPromptOptions(valid.length >= PROMPT_MIN_OPTIONS ? valid : null);\n  }, [terminalRef]);\n\n  // Schedule prompt check after terminal output (debounced)\n  const schedulePromptCheck = useCallback(() => {\n    if (promptCheckTimer.current) clearTimeout(promptCheckTimer.current);\n    promptCheckTimer.current = setTimeout(checkBufferForPrompt, PROMPT_DEBOUNCE_MS);\n  }, [checkBufferForPrompt]);\n\n  // Wire up the onOutput callback\n  useEffect(() => {\n    onOutputRef.current = schedulePromptCheck;\n  }, [schedulePromptCheck]);\n\n  // Cleanup prompt check timer on unmount\n  useEffect(() => {\n    return () => {\n      if (promptCheckTimer.current) clearTimeout(promptCheckTimer.current);\n    };\n  }, []);\n\n  // Clear stale prompt options and cancel pending timer on disconnect\n  useEffect(() => {\n    if (!isConnected) {\n      if (promptCheckTimer.current) {\n        clearTimeout(promptCheckTimer.current);\n        promptCheckTimer.current = null;\n      }\n      setCliPromptOptions(null);\n    }\n  }, [isConnected]);\n\n  useEffect(() => {\n    if (!isActive || !isInitialized || !isConnected) {\n      return;\n    }\n\n    const focusTerminal = () => {\n      terminalRef.current?.focus();\n    };\n\n    const animationFrameId = window.requestAnimationFrame(focusTerminal);\n    const timeoutId = window.setTimeout(focusTerminal, 0);\n\n    return () => {\n      window.cancelAnimationFrame(animationFrameId);\n      window.clearTimeout(timeoutId);\n    };\n  }, [isActive, isConnected, isInitialized, terminalRef]);\n\n  const sendInput = useCallback(\n    (data: string) => {\n      sendSocketMessage(wsRef.current, { type: 'input', data });\n    },\n    [wsRef],\n  );\n\n  const sessionDisplayName = useMemo(() => getSessionDisplayName(selectedSession), [selectedSession]);\n  const sessionDisplayNameShort = useMemo(\n    () => (sessionDisplayName ? sessionDisplayName.slice(0, 30) : null),\n    [sessionDisplayName],\n  );\n  const sessionDisplayNameLong = useMemo(\n    () => (sessionDisplayName ? sessionDisplayName.slice(0, 50) : null),\n    [sessionDisplayName],\n  );\n\n  const handleRestartShell = useCallback(() => {\n    setIsRestarting(true);\n    window.setTimeout(() => {\n      setIsRestarting(false);\n    }, SHELL_RESTART_DELAY_MS);\n  }, []);\n\n  if (!selectedProject) {\n    return (\n      <ShellEmptyState\n        title={t('shell.selectProject.title')}\n        description={t('shell.selectProject.description')}\n      />\n    );\n  }\n\n  if (minimal) {\n    return (\n      <ShellMinimalView\n        terminalContainerRef={terminalContainerRef}\n        authUrl={authUrl}\n        authUrlVersion={authUrlVersion}\n        initialCommand={initialCommand}\n        isConnected={isConnected}\n        openAuthUrlInBrowser={openAuthUrlInBrowser}\n        copyAuthUrlToClipboard={copyAuthUrlToClipboard}\n      />\n    );\n  }\n\n  const readyDescription = isPlainShell\n    ? t('shell.runCommand', {\n        command: initialCommand || t('shell.defaultCommand'),\n        projectName: selectedProject.displayName,\n      })\n    : selectedSession\n      ? t('shell.resumeSession', { displayName: sessionDisplayNameLong })\n      : t('shell.startSession');\n\n  const connectingDescription = isPlainShell\n    ? t('shell.runCommand', {\n        command: initialCommand || t('shell.defaultCommand'),\n        projectName: selectedProject.displayName,\n      })\n    : t('shell.startCli', { projectName: selectedProject.displayName });\n\n  const overlayMode = !isInitialized ? 'loading' : isConnecting ? 'connecting' : !isConnected ? 'connect' : null;\n  const overlayDescription = overlayMode === 'connecting' ? connectingDescription : readyDescription;\n\n  return (\n    <div className=\"flex h-full w-full flex-col bg-gray-900\">\n      <ShellHeader\n        isConnected={isConnected}\n        isInitialized={isInitialized}\n        isRestarting={isRestarting}\n        hasSession={Boolean(selectedSession)}\n        sessionDisplayNameShort={sessionDisplayNameShort}\n        onDisconnect={disconnectFromShell}\n        onRestart={handleRestartShell}\n        statusNewSessionText={t('shell.status.newSession')}\n        statusInitializingText={t('shell.status.initializing')}\n        statusRestartingText={t('shell.status.restarting')}\n        disconnectLabel={t('shell.actions.disconnect')}\n        disconnectTitle={t('shell.actions.disconnectTitle')}\n        restartLabel={t('shell.actions.restart')}\n        restartTitle={t('shell.actions.restartTitle')}\n        disableRestart={isRestarting || isConnected}\n      />\n\n      <div className=\"relative flex-1 overflow-hidden p-2\">\n        <div\n          ref={terminalContainerRef}\n          className=\"h-full w-full focus:outline-none\"\n          style={{ outline: 'none' }}\n        />\n\n        {overlayMode && (\n          <ShellConnectionOverlay\n            mode={overlayMode}\n            description={overlayDescription}\n            loadingLabel={t('shell.loading')}\n            connectLabel={t('shell.actions.connect')}\n            connectTitle={t('shell.actions.connectTitle')}\n            connectingLabel={t('shell.connecting')}\n            onConnect={connectToShell}\n          />\n        )}\n\n        {cliPromptOptions && isConnected && (\n          <div\n            className=\"absolute inset-x-0 bottom-0 z-10 border-t border-gray-700/80 bg-gray-800/95 px-3 py-2 backdrop-blur-sm\"\n            onMouseDown={(e) => e.preventDefault()}\n          >\n            <div className=\"flex flex-wrap items-center gap-2\">\n              {cliPromptOptions.map((opt) => (\n                <button\n                  type=\"button\"\n                  key={opt.number}\n                  onClick={() => {\n                    sendInput(opt.number);\n                    setCliPromptOptions(null);\n                  }}\n                  className=\"max-w-36 truncate rounded bg-blue-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-blue-700\"\n                  title={`${opt.number}. ${opt.label}`}\n                >\n                  {opt.number}. {opt.label}\n                </button>\n              ))}\n              <button\n                type=\"button\"\n                onClick={() => {\n                  sendInput('\\x1b');\n                  setCliPromptOptions(null);\n                }}\n                className=\"rounded bg-gray-700 px-3 py-1.5 text-xs font-medium text-gray-200 transition-colors hover:bg-gray-600\"\n              >\n                Esc\n              </button>\n            </div>\n          </div>\n        )}\n      </div>\n\n      <TerminalShortcutsPanel\n        wsRef={wsRef}\n        terminalRef={terminalRef}\n        isConnected={isConnected}\n      />\n\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/shell/view/subcomponents/ShellConnectionOverlay.tsx",
    "content": "type ShellConnectionOverlayProps = {\n  mode: 'loading' | 'connect' | 'connecting';\n  description: string;\n  loadingLabel: string;\n  connectLabel: string;\n  connectTitle: string;\n  connectingLabel: string;\n  onConnect: () => void;\n};\n\nexport default function ShellConnectionOverlay({\n  mode,\n  description,\n  loadingLabel,\n  connectLabel,\n  connectTitle,\n  connectingLabel,\n  onConnect,\n}: ShellConnectionOverlayProps) {\n  if (mode === 'loading') {\n    return (\n      <div className=\"absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90\">\n        <div className=\"text-white\">{loadingLabel}</div>\n      </div>\n    );\n  }\n\n  if (mode === 'connect') {\n    return (\n      <div className=\"absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4\">\n        <div className=\"w-full max-w-sm text-center\">\n          <button\n            onClick={onConnect}\n            className=\"flex w-full items-center justify-center space-x-2 rounded-lg bg-green-600 px-6 py-3 text-base font-medium text-white transition-colors hover:bg-green-700 sm:w-auto\"\n            title={connectTitle}\n          >\n            <svg className=\"h-5 w-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n              <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M13 10V3L4 14h7v7l9-11h-7z\" />\n            </svg>\n            <span>{connectLabel}</span>\n          </button>\n          <p className=\"mt-3 px-2 text-sm text-gray-400\">{description}</p>\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4\">\n      <div className=\"w-full max-w-sm text-center\">\n        <div className=\"flex items-center justify-center space-x-3 text-yellow-400\">\n          <div className=\"h-6 w-6 animate-spin rounded-full border-2 border-yellow-400 border-t-transparent\"></div>\n          <span className=\"text-base font-medium\">{connectingLabel}</span>\n        </div>\n        <p className=\"mt-3 px-2 text-sm text-gray-400\">{description}</p>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/shell/view/subcomponents/ShellEmptyState.tsx",
    "content": "type ShellEmptyStateProps = {\n  title: string;\n  description: string;\n};\n\nexport default function ShellEmptyState({ title, description }: ShellEmptyStateProps) {\n  return (\n    <div className=\"flex h-full items-center justify-center\">\n      <div className=\"text-center text-gray-500 dark:text-gray-400\">\n        <div className=\"mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 dark:bg-gray-800\">\n          <svg className=\"h-8 w-8 text-gray-400\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n            <path\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              strokeWidth={2}\n              d=\"M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2z\"\n            />\n          </svg>\n        </div>\n        <h3 className=\"mb-2 text-lg font-semibold\">{title}</h3>\n        <p>{description}</p>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/shell/view/subcomponents/ShellHeader.tsx",
    "content": "type ShellHeaderProps = {\n  isConnected: boolean;\n  isInitialized: boolean;\n  isRestarting: boolean;\n  hasSession: boolean;\n  sessionDisplayNameShort: string | null;\n  onDisconnect: () => void;\n  onRestart: () => void;\n  statusNewSessionText: string;\n  statusInitializingText: string;\n  statusRestartingText: string;\n  disconnectLabel: string;\n  disconnectTitle: string;\n  restartLabel: string;\n  restartTitle: string;\n  disableRestart: boolean;\n};\n\nexport default function ShellHeader({\n  isConnected,\n  isInitialized,\n  isRestarting,\n  hasSession,\n  sessionDisplayNameShort,\n  onDisconnect,\n  onRestart,\n  statusNewSessionText,\n  statusInitializingText,\n  statusRestartingText,\n  disconnectLabel,\n  disconnectTitle,\n  restartLabel,\n  restartTitle,\n  disableRestart,\n}: ShellHeaderProps) {\n  return (\n    <div className=\"flex-shrink-0 border-b border-gray-700 bg-gray-800 px-4 py-2\">\n      <div className=\"flex items-center justify-between\">\n        <div className=\"flex items-center space-x-2\">\n          <div className={`h-2 w-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`} />\n\n          {hasSession && sessionDisplayNameShort && (\n            <span className=\"text-xs text-blue-300\">({sessionDisplayNameShort}...)</span>\n          )}\n\n          {!hasSession && <span className=\"text-xs text-gray-400\">{statusNewSessionText}</span>}\n\n          {!isInitialized && <span className=\"text-xs text-yellow-400\">{statusInitializingText}</span>}\n\n          {isRestarting && <span className=\"text-xs text-blue-400\">{statusRestartingText}</span>}\n        </div>\n\n        <div className=\"flex items-center space-x-3\">\n          {isConnected && (\n            <button\n              onClick={onDisconnect}\n              className=\"flex items-center space-x-1 rounded bg-red-600 px-3 py-1 text-xs text-white hover:bg-red-700\"\n              title={disconnectTitle}\n            >\n              <svg className=\"h-3 w-3\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M6 18L18 6M6 6l12 12\" />\n              </svg>\n              <span>{disconnectLabel}</span>\n            </button>\n          )}\n\n          <button\n            onClick={onRestart}\n            disabled={disableRestart}\n            className=\"flex items-center space-x-1 text-xs text-gray-400 hover:text-white disabled:cursor-not-allowed disabled:opacity-50\"\n            title={restartTitle}\n          >\n            <svg className=\"h-3 w-3\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n              <path\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                strokeWidth={2}\n                d=\"M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15\"\n              />\n            </svg>\n            <span>{restartLabel}</span>\n          </button>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/shell/view/subcomponents/ShellMinimalView.tsx",
    "content": "import { useEffect, useMemo, useState } from 'react';\nimport type { RefObject } from 'react';\nimport type { AuthCopyStatus } from '../../types/types';\nimport { resolveAuthUrlForDisplay } from '../../utils/auth';\n\ntype ShellMinimalViewProps = {\n  terminalContainerRef: RefObject<HTMLDivElement>;\n  authUrl: string;\n  authUrlVersion: number;\n  initialCommand: string | null | undefined;\n  isConnected: boolean;\n  openAuthUrlInBrowser: (url: string) => boolean;\n  copyAuthUrlToClipboard: (url: string) => Promise<boolean>;\n};\n\nexport default function ShellMinimalView({\n  terminalContainerRef,\n  authUrl,\n  authUrlVersion,\n  initialCommand,\n  isConnected,\n  openAuthUrlInBrowser,\n  copyAuthUrlToClipboard,\n}: ShellMinimalViewProps) {\n  const [authUrlCopyStatus, setAuthUrlCopyStatus] = useState<AuthCopyStatus>('idle');\n  const [isAuthPanelHidden, setIsAuthPanelHidden] = useState(false);\n\n  const displayAuthUrl = useMemo(\n    () => resolveAuthUrlForDisplay(initialCommand, authUrl),\n    [authUrl, initialCommand],\n  );\n\n  // Keep auth panel UI state local to minimal mode and reset it when connection/url changes.\n  useEffect(() => {\n    setAuthUrlCopyStatus('idle');\n    setIsAuthPanelHidden(false);\n  }, [authUrlVersion, displayAuthUrl, isConnected]);\n\n  const hasAuthUrl = Boolean(displayAuthUrl);\n  const showMobileAuthPanel = hasAuthUrl && !isAuthPanelHidden;\n  const showMobileAuthPanelToggle = hasAuthUrl && isAuthPanelHidden;\n\n  return (\n    <div className=\"relative h-full w-full bg-gray-900\">\n      <div\n        ref={terminalContainerRef}\n        className=\"h-full w-full focus:outline-none\"\n        style={{ outline: 'none' }}\n      />\n\n      {showMobileAuthPanel && (\n        <div className=\"absolute inset-x-0 bottom-14 z-20 border-t border-gray-700/80 bg-gray-900/95 p-3 backdrop-blur-sm md:hidden\">\n          <div className=\"flex flex-col gap-2\">\n            <div className=\"flex items-center justify-between gap-2\">\n              <p className=\"text-xs text-gray-300\">Open or copy the login URL:</p>\n              <button\n                type=\"button\"\n                onClick={() => setIsAuthPanelHidden(true)}\n                className=\"rounded bg-gray-700 px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-gray-100 hover:bg-gray-600\"\n              >\n                Hide\n              </button>\n            </div>\n\n            <input\n              type=\"text\"\n              value={displayAuthUrl}\n              readOnly\n              onClick={(event) => event.currentTarget.select()}\n              className=\"w-full rounded border border-gray-600 bg-gray-800 px-2 py-1 text-xs text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500\"\n              aria-label=\"Authentication URL\"\n            />\n\n            <div className=\"flex items-center gap-2\">\n              <button\n                type=\"button\"\n                onClick={() => {\n                  openAuthUrlInBrowser(displayAuthUrl);\n                }}\n                className=\"flex-1 rounded bg-blue-600 px-3 py-2 text-xs font-medium text-white hover:bg-blue-700\"\n              >\n                Open URL\n              </button>\n\n              <button\n                type=\"button\"\n                onClick={async () => {\n                  const copied = await copyAuthUrlToClipboard(displayAuthUrl);\n                  setAuthUrlCopyStatus(copied ? 'copied' : 'failed');\n                }}\n                className=\"flex-1 rounded bg-gray-700 px-3 py-2 text-xs font-medium text-white hover:bg-gray-600\"\n              >\n                {authUrlCopyStatus === 'copied' ? 'Copied' : 'Copy URL'}\n              </button>\n            </div>\n          </div>\n        </div>\n      )}\n\n      {showMobileAuthPanelToggle && (\n        <div className=\"absolute bottom-14 right-3 z-20 md:hidden\">\n          <button\n            type=\"button\"\n            onClick={() => setIsAuthPanelHidden(false)}\n            className=\"rounded bg-gray-800/95 px-3 py-2 text-xs font-medium text-gray-100 shadow-lg backdrop-blur-sm hover:bg-gray-700\"\n          >\n            Show login URL\n          </button>\n        </div>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/shell/view/subcomponents/TerminalShortcutsPanel.tsx",
    "content": "import { type MutableRefObject, useState, useCallback, useEffect, useRef } from 'react';\nimport {\n  ChevronLeft,\n  ChevronRight,\n  Keyboard,\n  ArrowDownToLine,\n} from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport type { Terminal } from '@xterm/xterm';\nimport { sendSocketMessage } from '../../utils/socket';\n\nconst SHORTCUTS = [\n  { id: 'escape', labelKey: 'escape', sequence: '\\x1b', hint: 'Esc' },\n  { id: 'tab', labelKey: 'tab', sequence: '\\t', hint: 'Tab' },\n  { id: 'shift-tab', labelKey: 'shiftTab', sequence: '\\x1b[Z', hint: '\\u21e7Tab' },\n  { id: 'arrow-up', labelKey: 'arrowUp', sequence: '\\x1b[A', hint: '\\u2191' },\n  { id: 'arrow-down', labelKey: 'arrowDown', sequence: '\\x1b[B', hint: '\\u2193' },\n] as const;\n\ntype TerminalShortcutsPanelProps = {\n  wsRef: MutableRefObject<WebSocket | null>;\n  terminalRef: MutableRefObject<Terminal | null>;\n  isConnected: boolean;\n};\n\nconst preventFocusSteal = (e: React.PointerEvent) => e.preventDefault();\n\nexport default function TerminalShortcutsPanel({\n  wsRef,\n  terminalRef,\n  isConnected,\n}: TerminalShortcutsPanelProps) {\n  const { t } = useTranslation('settings');\n  const [isOpen, setIsOpen] = useState(false);\n  const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  useEffect(() => {\n    return () => {\n      if (closeTimeoutRef.current) {\n        clearTimeout(closeTimeoutRef.current);\n      }\n    };\n  }, []);\n\n  const handleToggle = useCallback(() => {\n    setIsOpen((prev) => !prev);\n  }, []);\n\n  const handleShortcutAction = useCallback((action: () => void) => {\n    action();\n    if (document.activeElement instanceof HTMLElement) {\n      document.activeElement.blur();\n    }\n    if (closeTimeoutRef.current) {\n      clearTimeout(closeTimeoutRef.current);\n    }\n    closeTimeoutRef.current = setTimeout(() => setIsOpen(false), 50);\n  }, []);\n\n  const sendInput = useCallback(\n    (data: string) => {\n      sendSocketMessage(wsRef.current, { type: 'input', data });\n    },\n    [wsRef],\n  );\n\n  const scrollToBottom = useCallback(() => {\n    terminalRef.current?.scrollToBottom();\n  }, [terminalRef]);\n\n  return (\n    <>\n      {/* Pull Tab */}\n      <button\n        type=\"button\"\n        onPointerDown={preventFocusSteal}\n        onClick={handleToggle}\n        className={`fixed ${\n          isOpen ? 'right-64' : 'right-0'\n        } z-50 cursor-pointer rounded-l-md border border-gray-200 bg-white p-2 shadow-lg transition-all duration-150 ease-out hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-700`}\n        style={{ top: '50%', transform: 'translateY(-50%)' }}\n        aria-label={\n          isOpen\n            ? t('terminalShortcuts.handle.closePanel')\n            : t('terminalShortcuts.handle.openPanel')\n        }\n      >\n        {isOpen ? (\n          <ChevronRight className=\"h-5 w-5 text-gray-600 dark:text-gray-400\" />\n        ) : (\n          <ChevronLeft className=\"h-5 w-5 text-gray-600 dark:text-gray-400\" />\n        )}\n      </button>\n\n      {/* Panel */}\n      <div\n        className={`fixed right-0 top-0 z-40 h-full w-64 transform border-l border-border bg-background shadow-xl transition-transform duration-150 ease-out ${\n          isOpen ? 'translate-x-0' : 'translate-x-full'\n        }`}\n      >\n        <div className=\"flex h-full flex-col\">\n          {/* Header */}\n          <div className=\"border-b border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900\">\n            <h3 className=\"flex items-center gap-2 text-lg font-semibold text-gray-900 dark:text-white\">\n              <Keyboard className=\"h-5 w-5 text-gray-600 dark:text-gray-400\" />\n              {t('terminalShortcuts.title')}\n            </h3>\n          </div>\n\n          {/* Content — conditionally rendered so buttons remount with clean CSS states */}\n          {isOpen && (\n            <div className=\"flex-1 space-y-6 overflow-y-auto overflow-x-hidden bg-background p-4\">\n              {/* Shortcut Keys */}\n              <div className=\"space-y-2\">\n                <h4 className=\"mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400\">\n                  {t('terminalShortcuts.sectionKeys')}\n                </h4>\n                {SHORTCUTS.map((shortcut) => (\n                  <button\n                    type=\"button\"\n                    key={shortcut.id}\n                    onPointerDown={preventFocusSteal}\n                    onClick={() => handleShortcutAction(() => sendInput(shortcut.sequence))}\n                    disabled={!isConnected}\n                    className=\"flex w-full items-center justify-between rounded-lg border border-transparent bg-gray-50 p-3 transition-colors hover:border-gray-300 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-gray-800 dark:hover:border-gray-600 dark:hover:bg-gray-700\"\n                  >\n                    <span className=\"text-sm text-gray-900 dark:text-white\">\n                      {t(`terminalShortcuts.${shortcut.labelKey}`)}\n                    </span>\n                    <kbd className=\"rounded border border-gray-300 bg-gray-200 px-2 py-0.5 font-mono text-xs text-gray-600 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300\">\n                      {shortcut.hint}\n                    </kbd>\n                  </button>\n                ))}\n              </div>\n\n              {/* Navigation */}\n              <div className=\"space-y-2\">\n                <h4 className=\"mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400\">\n                  {t('terminalShortcuts.sectionNavigation')}\n                </h4>\n                <button\n                  type=\"button\"\n                  onPointerDown={preventFocusSteal}\n                  onClick={() => handleShortcutAction(scrollToBottom)}\n                  disabled={!isConnected}\n                  className=\"flex w-full items-center justify-between rounded-lg border border-transparent bg-gray-50 p-3 transition-colors hover:border-gray-300 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-gray-800 dark:hover:border-gray-600 dark:hover:bg-gray-700\"\n                >\n                  <span className=\"text-sm text-gray-900 dark:text-white\">\n                    {t('terminalShortcuts.scrollDown')}\n                  </span>\n                  <ArrowDownToLine className=\"h-4 w-4 text-gray-600 dark:text-gray-400\" />\n                </button>\n              </div>\n            </div>\n          )}\n        </div>\n      </div>\n\n      {/* Backdrop */}\n      {isOpen && (\n        <div\n          className=\"fixed inset-0 z-30 bg-background/80 backdrop-blur-sm transition-opacity duration-150 ease-out\"\n          onPointerDown={preventFocusSteal}\n          onClick={handleToggle}\n        />\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "src/components/sidebar/hooks/useSidebarController.ts",
    "content": "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport type React from 'react';\nimport type { TFunction } from 'i18next';\nimport { api } from '../../../utils/api';\nimport type { Project, ProjectSession, SessionProvider } from '../../../types/app';\nimport type {\n  AdditionalSessionsByProject,\n  DeleteProjectConfirmation,\n  LoadingSessionsByProject,\n  ProjectSortOrder,\n  SessionDeleteConfirmation,\n  SessionWithProvider,\n} from '../types/types';\nimport {\n  filterProjects,\n  getAllSessions,\n  loadStarredProjects,\n  persistStarredProjects,\n  readProjectSortOrder,\n  sortProjects,\n} from '../utils/utils';\n\ntype SnippetHighlight = {\n  start: number;\n  end: number;\n};\n\ntype ConversationMatch = {\n  role: string;\n  snippet: string;\n  highlights: SnippetHighlight[];\n  timestamp: string | null;\n  provider?: string;\n  messageUuid?: string | null;\n};\n\ntype ConversationSession = {\n  sessionId: string;\n  sessionSummary: string;\n  provider?: string;\n  matches: ConversationMatch[];\n};\n\ntype ConversationProjectResult = {\n  projectName: string;\n  projectDisplayName: string;\n  sessions: ConversationSession[];\n};\n\nexport type ConversationSearchResults = {\n  results: ConversationProjectResult[];\n  totalMatches: number;\n  query: string;\n};\n\nexport type SearchProgress = {\n  scannedProjects: number;\n  totalProjects: number;\n};\n\ntype UseSidebarControllerArgs = {\n  projects: Project[];\n  selectedProject: Project | null;\n  selectedSession: ProjectSession | null;\n  isLoading: boolean;\n  isMobile: boolean;\n  t: TFunction;\n  onRefresh: () => Promise<void> | void;\n  onProjectSelect: (project: Project) => void;\n  onSessionSelect: (session: ProjectSession) => void;\n  onSessionDelete?: (sessionId: string) => void;\n  onProjectDelete?: (projectName: string) => void;\n  setCurrentProject: (project: Project) => void;\n  setSidebarVisible: (visible: boolean) => void;\n  sidebarVisible: boolean;\n};\n\nexport function useSidebarController({\n  projects,\n  selectedProject,\n  selectedSession,\n  isLoading,\n  isMobile,\n  t,\n  onRefresh,\n  onProjectSelect,\n  onSessionSelect,\n  onSessionDelete,\n  onProjectDelete,\n  setCurrentProject,\n  setSidebarVisible,\n  sidebarVisible,\n}: UseSidebarControllerArgs) {\n  const [expandedProjects, setExpandedProjects] = useState<Set<string>>(new Set());\n  const [editingProject, setEditingProject] = useState<string | null>(null);\n  const [showNewProject, setShowNewProject] = useState(false);\n  const [editingName, setEditingName] = useState('');\n  const [loadingSessions, setLoadingSessions] = useState<LoadingSessionsByProject>({});\n  const [additionalSessions, setAdditionalSessions] = useState<AdditionalSessionsByProject>({});\n  const [initialSessionsLoaded, setInitialSessionsLoaded] = useState<Set<string>>(new Set());\n  const [currentTime, setCurrentTime] = useState(new Date());\n  const [projectSortOrder, setProjectSortOrder] = useState<ProjectSortOrder>('name');\n  const [isRefreshing, setIsRefreshing] = useState(false);\n  const [projectHasMoreOverrides, setProjectHasMoreOverrides] = useState<Record<string, boolean>>({});\n  const [editingSession, setEditingSession] = useState<string | null>(null);\n  const [editingSessionName, setEditingSessionName] = useState('');\n  const [searchFilter, setSearchFilter] = useState('');\n  const [deletingProjects, setDeletingProjects] = useState<Set<string>>(new Set());\n  const [deleteConfirmation, setDeleteConfirmation] = useState<DeleteProjectConfirmation | null>(null);\n  const [sessionDeleteConfirmation, setSessionDeleteConfirmation] = useState<SessionDeleteConfirmation | null>(null);\n  const [showVersionModal, setShowVersionModal] = useState(false);\n  const [starredProjects, setStarredProjects] = useState<Set<string>>(() => loadStarredProjects());\n  const [searchMode, setSearchMode] = useState<'projects' | 'conversations'>('projects');\n  const [conversationResults, setConversationResults] = useState<ConversationSearchResults | null>(null);\n  const [isSearching, setIsSearching] = useState(false);\n  const [searchProgress, setSearchProgress] = useState<SearchProgress | null>(null);\n  const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const searchSeqRef = useRef(0);\n  const eventSourceRef = useRef<EventSource | null>(null);\n\n  const isSidebarCollapsed = !isMobile && !sidebarVisible;\n\n  useEffect(() => {\n    const timer = setInterval(() => {\n      setCurrentTime(new Date());\n    }, 60000);\n\n    return () => clearInterval(timer);\n  }, []);\n\n  useEffect(() => {\n    setAdditionalSessions({});\n    setInitialSessionsLoaded(new Set());\n    setProjectHasMoreOverrides({});\n  }, [projects]);\n\n  useEffect(() => {\n    if (selectedProject) {\n      setExpandedProjects((prev) => {\n        if (prev.has(selectedProject.name)) {\n          return prev;\n        }\n        const next = new Set(prev);\n        next.add(selectedProject.name);\n        return next;\n      });\n    }\n  }, [selectedSession, selectedProject]);\n\n  useEffect(() => {\n    if (projects.length > 0 && !isLoading) {\n      const loadedProjects = new Set<string>();\n      projects.forEach((project) => {\n        if (project.sessions && project.sessions.length >= 0) {\n          loadedProjects.add(project.name);\n        }\n      });\n      setInitialSessionsLoaded(loadedProjects);\n    }\n  }, [projects, isLoading]);\n\n  useEffect(() => {\n    const loadSortOrder = () => {\n      setProjectSortOrder(readProjectSortOrder());\n    };\n\n    loadSortOrder();\n\n    const handleStorageChange = (event: StorageEvent) => {\n      if (event.key === 'claude-settings') {\n        loadSortOrder();\n      }\n    };\n\n    window.addEventListener('storage', handleStorageChange);\n\n    const interval = setInterval(() => {\n      if (document.hasFocus()) {\n        loadSortOrder();\n      }\n    }, 1000);\n\n    return () => {\n      window.removeEventListener('storage', handleStorageChange);\n      clearInterval(interval);\n    };\n  }, []);\n\n  // Debounced conversation search with SSE streaming\n  useEffect(() => {\n    if (searchTimeoutRef.current) {\n      clearTimeout(searchTimeoutRef.current);\n    }\n    if (eventSourceRef.current) {\n      eventSourceRef.current.close();\n      eventSourceRef.current = null;\n    }\n\n    const query = searchFilter.trim();\n    if (searchMode !== 'conversations' || query.length < 2) {\n      searchSeqRef.current += 1;\n      setConversationResults(null);\n      setSearchProgress(null);\n      setIsSearching(false);\n      return;\n    }\n\n    setIsSearching(true);\n    const seq = ++searchSeqRef.current;\n\n    searchTimeoutRef.current = setTimeout(() => {\n      if (seq !== searchSeqRef.current) return;\n\n      const url = api.searchConversationsUrl(query);\n      const es = new EventSource(url);\n      eventSourceRef.current = es;\n\n      const accumulated: ConversationProjectResult[] = [];\n      let totalMatches = 0;\n\n      es.addEventListener('result', (evt) => {\n        if (seq !== searchSeqRef.current) { es.close(); return; }\n        try {\n          const data = JSON.parse(evt.data) as {\n            projectResult: ConversationProjectResult;\n            totalMatches: number;\n            scannedProjects: number;\n            totalProjects: number;\n          };\n          accumulated.push(data.projectResult);\n          totalMatches = data.totalMatches;\n          setConversationResults({ results: [...accumulated], totalMatches, query });\n          setSearchProgress({ scannedProjects: data.scannedProjects, totalProjects: data.totalProjects });\n        } catch {\n          // Ignore malformed SSE data\n        }\n      });\n\n      es.addEventListener('progress', (evt) => {\n        if (seq !== searchSeqRef.current) { es.close(); return; }\n        try {\n          const data = JSON.parse(evt.data) as { totalMatches: number; scannedProjects: number; totalProjects: number };\n          totalMatches = data.totalMatches;\n          setSearchProgress({ scannedProjects: data.scannedProjects, totalProjects: data.totalProjects });\n        } catch {\n          // Ignore malformed SSE data\n        }\n      });\n\n      es.addEventListener('done', () => {\n        if (seq !== searchSeqRef.current) { es.close(); return; }\n        es.close();\n        eventSourceRef.current = null;\n        setIsSearching(false);\n        setSearchProgress(null);\n        if (accumulated.length === 0) {\n          setConversationResults({ results: [], totalMatches: 0, query });\n        }\n      });\n\n      es.addEventListener('error', () => {\n        if (seq !== searchSeqRef.current) { es.close(); return; }\n        es.close();\n        eventSourceRef.current = null;\n        setIsSearching(false);\n        setSearchProgress(null);\n        if (accumulated.length === 0) {\n          setConversationResults({ results: [], totalMatches: 0, query });\n        }\n      });\n    }, 400);\n\n    return () => {\n      if (searchTimeoutRef.current) {\n        clearTimeout(searchTimeoutRef.current);\n      }\n      if (eventSourceRef.current) {\n        eventSourceRef.current.close();\n        eventSourceRef.current = null;\n      }\n    };\n  }, [searchFilter, searchMode]);\n\n  const handleTouchClick = useCallback(\n    (callback: () => void) =>\n      (event: React.TouchEvent<HTMLElement>) => {\n        const target = event.target as HTMLElement;\n        if (target.closest('.overflow-y-auto') || target.closest('[data-scroll-container]')) {\n          return;\n        }\n\n        event.preventDefault();\n        event.stopPropagation();\n        callback();\n      },\n    [],\n  );\n\n  const toggleProject = useCallback((projectName: string) => {\n    setExpandedProjects((prev) => {\n      const next = new Set<string>();\n      if (!prev.has(projectName)) {\n        next.add(projectName);\n      }\n      return next;\n    });\n  }, []);\n\n  const handleSessionClick = useCallback(\n    (session: SessionWithProvider, projectName: string) => {\n      onSessionSelect({ ...session, __projectName: projectName });\n    },\n    [onSessionSelect],\n  );\n\n  const toggleStarProject = useCallback((projectName: string) => {\n    setStarredProjects((prev) => {\n      const next = new Set(prev);\n      if (next.has(projectName)) {\n        next.delete(projectName);\n      } else {\n        next.add(projectName);\n      }\n\n      persistStarredProjects(next);\n      return next;\n    });\n  }, []);\n\n  const isProjectStarred = useCallback(\n    (projectName: string) => starredProjects.has(projectName),\n    [starredProjects],\n  );\n\n  const getProjectSessions = useCallback(\n    (project: Project) => getAllSessions(project, additionalSessions),\n    [additionalSessions],\n  );\n\n  const projectsWithSessionMeta = useMemo(\n    () =>\n      projects.map((project) => {\n        const hasMoreOverride = projectHasMoreOverrides[project.name];\n        if (hasMoreOverride === undefined) {\n          return project;\n        }\n\n        return {\n          ...project,\n          sessionMeta: { ...project.sessionMeta, hasMore: hasMoreOverride },\n        };\n      }),\n    [projectHasMoreOverrides, projects],\n  );\n\n  const sortedProjects = useMemo(\n    () => sortProjects(projectsWithSessionMeta, projectSortOrder, starredProjects, additionalSessions),\n    [additionalSessions, projectSortOrder, projectsWithSessionMeta, starredProjects],\n  );\n\n  const filteredProjects = useMemo(\n    () => filterProjects(sortedProjects, searchFilter),\n    [searchFilter, sortedProjects],\n  );\n\n  const startEditing = useCallback((project: Project) => {\n    setEditingProject(project.name);\n    setEditingName(project.displayName);\n  }, []);\n\n  const cancelEditing = useCallback(() => {\n    setEditingProject(null);\n    setEditingName('');\n  }, []);\n\n  const saveProjectName = useCallback(\n    async (projectName: string) => {\n      try {\n        const response = await api.renameProject(projectName, editingName);\n        if (response.ok) {\n          if (window.refreshProjects) {\n            await window.refreshProjects();\n          } else {\n            window.location.reload();\n          }\n        } else {\n          console.error('Failed to rename project');\n        }\n      } catch (error) {\n        console.error('Error renaming project:', error);\n      } finally {\n        setEditingProject(null);\n        setEditingName('');\n      }\n    },\n    [editingName],\n  );\n\n  const showDeleteSessionConfirmation = useCallback(\n    (\n      projectName: string,\n      sessionId: string,\n      sessionTitle: string,\n      provider: SessionDeleteConfirmation['provider'] = 'claude',\n    ) => {\n      setSessionDeleteConfirmation({ projectName, sessionId, sessionTitle, provider });\n    },\n    [],\n  );\n\n  const confirmDeleteSession = useCallback(async () => {\n    if (!sessionDeleteConfirmation) {\n      return;\n    }\n\n    const { projectName, sessionId, provider } = sessionDeleteConfirmation;\n    setSessionDeleteConfirmation(null);\n\n    try {\n      let response;\n      if (provider === 'codex') {\n        response = await api.deleteCodexSession(sessionId);\n      } else if (provider === 'gemini') {\n        response = await api.deleteGeminiSession(sessionId);\n      } else {\n        response = await api.deleteSession(projectName, sessionId);\n      }\n\n      if (response.ok) {\n        onSessionDelete?.(sessionId);\n      } else {\n        const errorText = await response.text();\n        console.error('[Sidebar] Failed to delete session:', {\n          status: response.status,\n          error: errorText,\n        });\n        alert(t('messages.deleteSessionFailed'));\n      }\n    } catch (error) {\n      console.error('[Sidebar] Error deleting session:', error);\n      alert(t('messages.deleteSessionError'));\n    }\n  }, [onSessionDelete, sessionDeleteConfirmation, t]);\n\n  const requestProjectDelete = useCallback(\n    (project: Project) => {\n      setDeleteConfirmation({\n        project,\n        sessionCount: getProjectSessions(project).length,\n      });\n    },\n    [getProjectSessions],\n  );\n\n  const confirmDeleteProject = useCallback(async () => {\n    if (!deleteConfirmation) {\n      return;\n    }\n\n    const { project, sessionCount } = deleteConfirmation;\n    const isEmpty = sessionCount === 0;\n\n    setDeleteConfirmation(null);\n    setDeletingProjects((prev) => new Set([...prev, project.name]));\n\n    try {\n      const response = await api.deleteProject(project.name, !isEmpty);\n\n      if (response.ok) {\n        onProjectDelete?.(project.name);\n      } else {\n        const error = (await response.json()) as { error?: string };\n        alert(error.error || t('messages.deleteProjectFailed'));\n      }\n    } catch (error) {\n      console.error('Error deleting project:', error);\n      alert(t('messages.deleteProjectError'));\n    } finally {\n      setDeletingProjects((prev) => {\n        const next = new Set(prev);\n        next.delete(project.name);\n        return next;\n      });\n    }\n  }, [deleteConfirmation, onProjectDelete, t]);\n\n  const loadMoreSessions = useCallback(\n    async (project: Project) => {\n      const hasMoreOverride = projectHasMoreOverrides[project.name];\n      const canLoadMore =\n        hasMoreOverride !== undefined ? hasMoreOverride : project.sessionMeta?.hasMore === true;\n      if (!canLoadMore || loadingSessions[project.name]) {\n        return;\n      }\n\n      setLoadingSessions((prev) => ({ ...prev, [project.name]: true }));\n\n      try {\n        const currentSessionCount =\n          (project.sessions?.length || 0) + (additionalSessions[project.name]?.length || 0);\n        const response = await api.sessions(project.name, 5, currentSessionCount);\n\n        if (!response.ok) {\n          return;\n        }\n\n        const result = (await response.json()) as {\n          sessions?: ProjectSession[];\n          hasMore?: boolean;\n        };\n\n        setAdditionalSessions((prev) => ({\n          ...prev,\n          [project.name]: [...(prev[project.name] || []), ...(result.sessions || [])],\n        }));\n\n        if (result.hasMore === false) {\n          // Keep hasMore state in local hook state instead of mutating the project prop object.\n          setProjectHasMoreOverrides((prev) => ({ ...prev, [project.name]: false }));\n        }\n      } catch (error) {\n        console.error('Error loading more sessions:', error);\n      } finally {\n        setLoadingSessions((prev) => ({ ...prev, [project.name]: false }));\n      }\n    },\n    [additionalSessions, loadingSessions, projectHasMoreOverrides],\n  );\n\n  const handleProjectSelect = useCallback(\n    (project: Project) => {\n      onProjectSelect(project);\n      setCurrentProject(project);\n    },\n    [onProjectSelect, setCurrentProject],\n  );\n\n  const refreshProjects = useCallback(async () => {\n    setIsRefreshing(true);\n    try {\n      await onRefresh();\n    } finally {\n      setIsRefreshing(false);\n    }\n  }, [onRefresh]);\n\n  const updateSessionSummary = useCallback(\n    async (_projectName: string, sessionId: string, summary: string, provider: SessionProvider) => {\n      const trimmed = summary.trim();\n      if (!trimmed) {\n        setEditingSession(null);\n        setEditingSessionName('');\n        return;\n      }\n      try {\n        const response = await api.renameSession(sessionId, trimmed, provider);\n        if (response.ok) {\n          await onRefresh();\n        } else {\n          console.error('[Sidebar] Failed to rename session:', response.status);\n          alert(t('messages.renameSessionFailed'));\n        }\n      } catch (error) {\n        console.error('[Sidebar] Error renaming session:', error);\n        alert(t('messages.renameSessionError'));\n      } finally {\n        setEditingSession(null);\n        setEditingSessionName('');\n      }\n    },\n    [onRefresh, t],\n  );\n\n  const collapseSidebar = useCallback(() => {\n    setSidebarVisible(false);\n  }, [setSidebarVisible]);\n\n  const expandSidebar = useCallback(() => {\n    setSidebarVisible(true);\n  }, [setSidebarVisible]);\n\n  return {\n    isSidebarCollapsed,\n    expandedProjects,\n    editingProject,\n    showNewProject,\n    editingName,\n    loadingSessions,\n    additionalSessions,\n    initialSessionsLoaded,\n    currentTime,\n    projectSortOrder,\n    isRefreshing,\n    editingSession,\n    editingSessionName,\n    searchFilter,\n    deletingProjects,\n    deleteConfirmation,\n    sessionDeleteConfirmation,\n    showVersionModal,\n    starredProjects,\n    filteredProjects,\n    toggleProject,\n    handleSessionClick,\n    toggleStarProject,\n    isProjectStarred,\n    getProjectSessions,\n    startEditing,\n    cancelEditing,\n    saveProjectName,\n    showDeleteSessionConfirmation,\n    confirmDeleteSession,\n    requestProjectDelete,\n    confirmDeleteProject,\n    loadMoreSessions,\n    handleProjectSelect,\n    refreshProjects,\n    updateSessionSummary,\n    collapseSidebar,\n    expandSidebar,\n    setShowNewProject,\n    setEditingName,\n    setEditingSession,\n    setEditingSessionName,\n    searchMode,\n    setSearchMode,\n    conversationResults,\n    isSearching,\n    searchProgress,\n    clearConversationResults: useCallback(() => {\n      searchSeqRef.current += 1;\n      if (eventSourceRef.current) {\n        eventSourceRef.current.close();\n        eventSourceRef.current = null;\n      }\n      setIsSearching(false);\n      setSearchProgress(null);\n      setConversationResults(null);\n    }, []),\n    setSearchFilter,\n    setDeleteConfirmation,\n    setSessionDeleteConfirmation,\n    setShowVersionModal,\n  };\n}\n"
  },
  {
    "path": "src/components/sidebar/types/types.ts",
    "content": "import type { LoadingProgress, Project, ProjectSession, SessionProvider } from '../../../types/app';\n\nexport type ProjectSortOrder = 'name' | 'date';\n\nexport type SessionWithProvider = ProjectSession & {\n  __provider: SessionProvider;\n};\n\nexport type AdditionalSessionsByProject = Record<string, ProjectSession[]>;\nexport type LoadingSessionsByProject = Record<string, boolean>;\n\nexport type DeleteProjectConfirmation = {\n  project: Project;\n  sessionCount: number;\n};\n\nexport type SessionDeleteConfirmation = {\n  projectName: string;\n  sessionId: string;\n  sessionTitle: string;\n  provider: SessionProvider;\n};\n\nexport type SidebarProps = {\n  projects: Project[];\n  selectedProject: Project | null;\n  selectedSession: ProjectSession | null;\n  onProjectSelect: (project: Project) => void;\n  onSessionSelect: (session: ProjectSession) => void;\n  onNewSession: (project: Project) => void;\n  onSessionDelete?: (sessionId: string) => void;\n  onProjectDelete?: (projectName: string) => void;\n  isLoading: boolean;\n  loadingProgress: LoadingProgress | null;\n  onRefresh: () => Promise<void> | void;\n  onShowSettings: () => void;\n  showSettings: boolean;\n  settingsInitialTab: string;\n  onCloseSettings: () => void;\n  isMobile: boolean;\n};\n\nexport type SessionViewModel = {\n  isCursorSession: boolean;\n  isCodexSession: boolean;\n  isGeminiSession: boolean;\n  isActive: boolean;\n  sessionName: string;\n  sessionTime: string;\n  messageCount: number;\n};\n\nexport type MCPServerStatus = {\n  hasMCPServer?: boolean;\n  isConfigured?: boolean;\n} | null;\n\nexport type SettingsProject = Pick<Project, 'name' | 'displayName' | 'fullPath' | 'path'>;\n"
  },
  {
    "path": "src/components/sidebar/utils/utils.ts",
    "content": "import type { TFunction } from 'i18next';\nimport type { Project } from '../../../types/app';\nimport type {\n  AdditionalSessionsByProject,\n  ProjectSortOrder,\n  SettingsProject,\n  SessionViewModel,\n  SessionWithProvider,\n} from '../types/types';\n\nexport const readProjectSortOrder = (): ProjectSortOrder => {\n  try {\n    const rawSettings = localStorage.getItem('claude-settings');\n    if (!rawSettings) {\n      return 'name';\n    }\n\n    const settings = JSON.parse(rawSettings) as { projectSortOrder?: ProjectSortOrder };\n    return settings.projectSortOrder === 'date' ? 'date' : 'name';\n  } catch {\n    return 'name';\n  }\n};\n\nexport const loadStarredProjects = (): Set<string> => {\n  try {\n    const saved = localStorage.getItem('starredProjects');\n    return saved ? new Set<string>(JSON.parse(saved)) : new Set<string>();\n  } catch {\n    return new Set<string>();\n  }\n};\n\nexport const persistStarredProjects = (starredProjects: Set<string>) => {\n  try {\n    localStorage.setItem('starredProjects', JSON.stringify([...starredProjects]));\n  } catch {\n    // Keep UI responsive even if storage fails.\n  }\n};\n\nexport const getSessionDate = (session: SessionWithProvider): Date => {\n  if (session.__provider === 'cursor') {\n    return new Date(session.createdAt || 0);\n  }\n\n  if (session.__provider === 'codex') {\n    return new Date(session.createdAt || session.lastActivity || 0);\n  }\n\n  return new Date(session.lastActivity || session.createdAt || 0);\n};\n\nexport const getSessionName = (session: SessionWithProvider, t: TFunction): string => {\n  if (session.__provider === 'cursor') {\n    return session.summary || session.name || t('projects.untitledSession');\n  }\n\n  if (session.__provider === 'codex') {\n    return session.summary || session.name || t('projects.codexSession');\n  }\n\n  if (session.__provider === 'gemini') {\n    return session.summary || session.name || t('projects.newSession');\n  }\n\n  return session.summary || t('projects.newSession');\n};\n\nexport const getSessionTime = (session: SessionWithProvider): string => {\n  if (session.__provider === 'cursor') {\n    return String(session.createdAt || '');\n  }\n\n  if (session.__provider === 'codex') {\n    return String(session.createdAt || session.lastActivity || '');\n  }\n\n  return String(session.lastActivity || session.createdAt || '');\n};\n\nexport const createSessionViewModel = (\n  session: SessionWithProvider,\n  currentTime: Date,\n  t: TFunction,\n): SessionViewModel => {\n  const sessionDate = getSessionDate(session);\n  const diffInMinutes = Math.floor((currentTime.getTime() - sessionDate.getTime()) / (1000 * 60));\n\n  return {\n    isCursorSession: session.__provider === 'cursor',\n    isCodexSession: session.__provider === 'codex',\n    isGeminiSession: session.__provider === 'gemini',\n    isActive: diffInMinutes < 10,\n    sessionName: getSessionName(session, t),\n    sessionTime: getSessionTime(session),\n    messageCount: Number(session.messageCount || 0),\n  };\n};\n\nexport const getAllSessions = (\n  project: Project,\n  additionalSessions: AdditionalSessionsByProject,\n): SessionWithProvider[] => {\n  const claudeSessions = [\n    ...(project.sessions || []),\n    ...(additionalSessions[project.name] || []),\n  ].map((session) => ({ ...session, __provider: 'claude' as const }));\n\n  const cursorSessions = (project.cursorSessions || []).map((session) => ({\n    ...session,\n    __provider: 'cursor' as const,\n  }));\n\n  const codexSessions = (project.codexSessions || []).map((session) => ({\n    ...session,\n    __provider: 'codex' as const,\n  }));\n\n  const geminiSessions = (project.geminiSessions || []).map((session) => ({\n    ...session,\n    __provider: 'gemini' as const,\n  }));\n\n  return [...claudeSessions, ...cursorSessions, ...codexSessions, ...geminiSessions].sort(\n    (a, b) => getSessionDate(b).getTime() - getSessionDate(a).getTime(),\n  );\n};\n\nexport const getProjectLastActivity = (\n  project: Project,\n  additionalSessions: AdditionalSessionsByProject,\n): Date => {\n  const sessions = getAllSessions(project, additionalSessions);\n  if (sessions.length === 0) {\n    return new Date(0);\n  }\n\n  return sessions.reduce((latest, session) => {\n    const sessionDate = getSessionDate(session);\n    return sessionDate > latest ? sessionDate : latest;\n  }, new Date(0));\n};\n\nexport const sortProjects = (\n  projects: Project[],\n  projectSortOrder: ProjectSortOrder,\n  starredProjects: Set<string>,\n  additionalSessions: AdditionalSessionsByProject,\n): Project[] => {\n  const byName = [...projects];\n\n  byName.sort((projectA, projectB) => {\n    const aStarred = starredProjects.has(projectA.name);\n    const bStarred = starredProjects.has(projectB.name);\n\n    if (aStarred && !bStarred) {\n      return -1;\n    }\n\n    if (!aStarred && bStarred) {\n      return 1;\n    }\n\n    if (projectSortOrder === 'date') {\n      return (\n        getProjectLastActivity(projectB, additionalSessions).getTime() -\n        getProjectLastActivity(projectA, additionalSessions).getTime()\n      );\n    }\n\n    return (projectA.displayName || projectA.name).localeCompare(projectB.displayName || projectB.name);\n  });\n\n  return byName;\n};\n\nexport const filterProjects = (projects: Project[], searchFilter: string): Project[] => {\n  const normalizedSearch = searchFilter.trim().toLowerCase();\n  if (!normalizedSearch) {\n    return projects;\n  }\n\n  return projects.filter((project) => {\n    const displayName = (project.displayName || project.name).toLowerCase();\n    const projectName = project.name.toLowerCase();\n    return displayName.includes(normalizedSearch) || projectName.includes(normalizedSearch);\n  });\n};\n\nexport const getTaskIndicatorStatus = (\n  project: Project,\n  mcpServerStatus: { hasMCPServer?: boolean; isConfigured?: boolean } | null,\n) => {\n  const projectConfigured = Boolean(project.taskmaster?.hasTaskmaster);\n  const mcpConfigured = Boolean(mcpServerStatus?.hasMCPServer && mcpServerStatus?.isConfigured);\n\n  if (projectConfigured && mcpConfigured) {\n    return 'fully-configured';\n  }\n\n  if (projectConfigured) {\n    return 'taskmaster-only';\n  }\n\n  if (mcpConfigured) {\n    return 'mcp-only';\n  }\n\n  return 'not-configured';\n};\n\nexport const normalizeProjectForSettings = (project: Project): SettingsProject => {\n  const fallbackPath =\n    typeof project.fullPath === 'string' && project.fullPath.length > 0\n      ? project.fullPath\n      : typeof project.path === 'string'\n        ? project.path\n        : '';\n\n  return {\n    name: project.name,\n    displayName:\n      typeof project.displayName === 'string' && project.displayName.trim().length > 0\n        ? project.displayName\n        : project.name,\n    fullPath: fallbackPath,\n    path:\n      typeof project.path === 'string' && project.path.length > 0\n        ? project.path\n        : fallbackPath,\n  };\n};\n"
  },
  {
    "path": "src/components/sidebar/view/Sidebar.tsx",
    "content": "import { useEffect } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useDeviceSettings } from '../../../hooks/useDeviceSettings';\nimport { useVersionCheck } from '../../../hooks/useVersionCheck';\nimport { useUiPreferences } from '../../../hooks/useUiPreferences';\nimport { useSidebarController } from '../hooks/useSidebarController';\nimport { useTaskMaster } from '../../../contexts/TaskMasterContext';\nimport { useTasksSettings } from '../../../contexts/TasksSettingsContext';\nimport type { Project, SessionProvider } from '../../../types/app';\nimport type { MCPServerStatus, SidebarProps } from '../types/types';\nimport SidebarCollapsed from './subcomponents/SidebarCollapsed';\nimport SidebarContent from './subcomponents/SidebarContent';\nimport SidebarModals from './subcomponents/SidebarModals';\nimport type { SidebarProjectListProps } from './subcomponents/SidebarProjectList';\n\ntype TaskMasterSidebarContext = {\n  setCurrentProject: (project: Project) => void;\n  mcpServerStatus: MCPServerStatus;\n};\n\nfunction Sidebar({\n  projects,\n  selectedProject,\n  selectedSession,\n  onProjectSelect,\n  onSessionSelect,\n  onNewSession,\n  onSessionDelete,\n  onProjectDelete,\n  isLoading,\n  loadingProgress,\n  onRefresh,\n  onShowSettings,\n  showSettings,\n  settingsInitialTab,\n  onCloseSettings,\n  isMobile,\n}: SidebarProps) {\n  const { t } = useTranslation(['sidebar', 'common']);\n  const { isPWA } = useDeviceSettings({ trackMobile: false });\n  const { updateAvailable, latestVersion, currentVersion, releaseInfo, installMode } = useVersionCheck(\n    'siteboon',\n    'claudecodeui',\n  );\n  const { preferences, setPreference } = useUiPreferences();\n  const { sidebarVisible } = preferences;\n  const { setCurrentProject, mcpServerStatus } = useTaskMaster() as TaskMasterSidebarContext;\n  const { tasksEnabled } = useTasksSettings();\n\n  const {\n    isSidebarCollapsed,\n    expandedProjects,\n    editingProject,\n    showNewProject,\n    editingName,\n    loadingSessions,\n    initialSessionsLoaded,\n    currentTime,\n    isRefreshing,\n    editingSession,\n    editingSessionName,\n    searchFilter,\n    searchMode,\n    setSearchMode,\n    conversationResults,\n    isSearching,\n    searchProgress,\n    clearConversationResults,\n    deletingProjects,\n    deleteConfirmation,\n    sessionDeleteConfirmation,\n    showVersionModal,\n    filteredProjects,\n    toggleProject,\n    handleSessionClick,\n    toggleStarProject,\n    isProjectStarred,\n    getProjectSessions,\n    startEditing,\n    cancelEditing,\n    saveProjectName,\n    showDeleteSessionConfirmation,\n    confirmDeleteSession,\n    requestProjectDelete,\n    confirmDeleteProject,\n    loadMoreSessions,\n    handleProjectSelect,\n    refreshProjects,\n    updateSessionSummary,\n    collapseSidebar: handleCollapseSidebar,\n    expandSidebar: handleExpandSidebar,\n    setShowNewProject,\n    setEditingName,\n    setEditingSession,\n    setEditingSessionName,\n    setSearchFilter,\n    setDeleteConfirmation,\n    setSessionDeleteConfirmation,\n    setShowVersionModal,\n  } = useSidebarController({\n    projects,\n    selectedProject,\n    selectedSession,\n    isLoading,\n    isMobile,\n    t,\n    onRefresh,\n    onProjectSelect,\n    onSessionSelect,\n    onSessionDelete,\n    onProjectDelete,\n    setCurrentProject,\n    setSidebarVisible: (visible) => setPreference('sidebarVisible', visible),\n    sidebarVisible,\n  });\n\n  useEffect(() => {\n    if (typeof document === 'undefined') {\n      return;\n    }\n\n    document.documentElement.classList.toggle('pwa-mode', isPWA);\n    document.body.classList.toggle('pwa-mode', isPWA);\n  }, [isPWA]);\n\n  const handleProjectCreated = () => {\n    if (window.refreshProjects) {\n      void window.refreshProjects();\n      return;\n    }\n\n    window.location.reload();\n  };\n\n  const projectListProps: SidebarProjectListProps = {\n    projects,\n    filteredProjects,\n    selectedProject,\n    selectedSession,\n    isLoading,\n    loadingProgress,\n    expandedProjects,\n    editingProject,\n    editingName,\n    loadingSessions,\n    initialSessionsLoaded,\n    currentTime,\n    editingSession,\n    editingSessionName,\n    deletingProjects,\n    tasksEnabled,\n    mcpServerStatus,\n    getProjectSessions,\n    isProjectStarred,\n    onEditingNameChange: setEditingName,\n    onToggleProject: toggleProject,\n    onProjectSelect: handleProjectSelect,\n    onToggleStarProject: toggleStarProject,\n    onStartEditingProject: startEditing,\n    onCancelEditingProject: cancelEditing,\n    onSaveProjectName: (projectName) => {\n      void saveProjectName(projectName);\n    },\n    onDeleteProject: requestProjectDelete,\n    onSessionSelect: handleSessionClick,\n    onDeleteSession: showDeleteSessionConfirmation,\n    onLoadMoreSessions: (project) => {\n      void loadMoreSessions(project);\n    },\n    onNewSession,\n    onEditingSessionNameChange: setEditingSessionName,\n    onStartEditingSession: (sessionId, initialName) => {\n      setEditingSession(sessionId);\n      setEditingSessionName(initialName);\n    },\n    onCancelEditingSession: () => {\n      setEditingSession(null);\n      setEditingSessionName('');\n    },\n    onSaveEditingSession: (projectName: string, sessionId: string, summary: string, provider: SessionProvider) => {\n      void updateSessionSummary(projectName, sessionId, summary, provider);\n    },\n    t,\n  };\n\n  return (\n    <>\n      <SidebarModals\n        projects={projects}\n        showSettings={showSettings}\n        settingsInitialTab={settingsInitialTab}\n        onCloseSettings={onCloseSettings}\n        showNewProject={showNewProject}\n        onCloseNewProject={() => setShowNewProject(false)}\n        onProjectCreated={handleProjectCreated}\n        deleteConfirmation={deleteConfirmation}\n        onCancelDeleteProject={() => setDeleteConfirmation(null)}\n        onConfirmDeleteProject={confirmDeleteProject}\n        sessionDeleteConfirmation={sessionDeleteConfirmation}\n        onCancelDeleteSession={() => setSessionDeleteConfirmation(null)}\n        onConfirmDeleteSession={confirmDeleteSession}\n        showVersionModal={showVersionModal}\n        onCloseVersionModal={() => setShowVersionModal(false)}\n        releaseInfo={releaseInfo}\n        currentVersion={currentVersion}\n        latestVersion={latestVersion}\n        installMode={installMode}\n        t={t}\n      />\n\n      {isSidebarCollapsed ? (\n        <SidebarCollapsed\n          onExpand={handleExpandSidebar}\n          onShowSettings={onShowSettings}\n          updateAvailable={updateAvailable}\n          onShowVersionModal={() => setShowVersionModal(true)}\n          t={t}\n        />\n      ) : (\n        <>\n          <SidebarContent\n            isPWA={isPWA}\n            isMobile={isMobile}\n            isLoading={isLoading}\n            projects={projects}\n            searchFilter={searchFilter}\n            onSearchFilterChange={setSearchFilter}\n            onClearSearchFilter={() => setSearchFilter('')}\n            searchMode={searchMode}\n            onSearchModeChange={(mode: 'projects' | 'conversations') => {\n              setSearchMode(mode);\n              if (mode === 'projects') clearConversationResults();\n            }}\n            conversationResults={conversationResults}\n            isSearching={isSearching}\n            searchProgress={searchProgress}\n            onConversationResultClick={(projectName: string, sessionId: string, provider: string, messageTimestamp?: string | null, messageSnippet?: string | null) => {\n              const resolvedProvider = (provider || 'claude') as SessionProvider;\n              const project = projects.find(p => p.name === projectName);\n              const searchTarget = { __searchTargetTimestamp: messageTimestamp || null, __searchTargetSnippet: messageSnippet || null };\n              const sessionObj = {\n                id: sessionId,\n                __provider: resolvedProvider,\n                __projectName: projectName,\n                ...searchTarget,\n              };\n              if (project) {\n                handleProjectSelect(project);\n                const sessions = getProjectSessions(project);\n                const existing = sessions.find(s => s.id === sessionId);\n                if (existing) {\n                  handleSessionClick({ ...existing, ...searchTarget }, projectName);\n                } else {\n                  handleSessionClick(sessionObj, projectName);\n                }\n              } else {\n                handleSessionClick(sessionObj, projectName);\n              }\n            }}\n            onRefresh={() => {\n              void refreshProjects();\n            }}\n            isRefreshing={isRefreshing}\n            onCreateProject={() => setShowNewProject(true)}\n            onCollapseSidebar={handleCollapseSidebar}\n            updateAvailable={updateAvailable}\n            releaseInfo={releaseInfo}\n            latestVersion={latestVersion}\n            onShowVersionModal={() => setShowVersionModal(true)}\n            onShowSettings={onShowSettings}\n            projectListProps={projectListProps}\n            t={t}\n          />\n        </>\n      )}\n\n    </>\n  );\n}\n\nexport default Sidebar;\n"
  },
  {
    "path": "src/components/sidebar/view/subcomponents/SidebarCollapsed.tsx",
    "content": "import { Settings, Sparkles, PanelLeftOpen } from 'lucide-react';\nimport type { TFunction } from 'i18next';\n\nconst DISCORD_INVITE_URL = 'https://discord.gg/buxwujPNRE';\n\nfunction DiscordIcon({ className }: { className?: string }) {\n  return (\n    <svg className={className} fill=\"currentColor\" viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n      <path d=\"M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z\" />\n    </svg>\n  );\n}\n\ntype SidebarCollapsedProps = {\n  onExpand: () => void;\n  onShowSettings: () => void;\n  updateAvailable: boolean;\n  onShowVersionModal: () => void;\n  t: TFunction;\n};\n\nexport default function SidebarCollapsed({\n  onExpand,\n  onShowSettings,\n  updateAvailable,\n  onShowVersionModal,\n  t,\n}: SidebarCollapsedProps) {\n  return (\n    <div className=\"flex h-full w-12 flex-col items-center gap-1 bg-background/80 py-3 backdrop-blur-sm\">\n      {/* Expand button with brand logo */}\n      <button\n        onClick={onExpand}\n        className=\"group flex h-8 w-8 items-center justify-center rounded-lg transition-colors hover:bg-accent/80\"\n        aria-label={t('common:versionUpdate.ariaLabels.showSidebar')}\n        title={t('common:versionUpdate.ariaLabels.showSidebar')}\n      >\n        <PanelLeftOpen className=\"h-4 w-4 text-muted-foreground transition-colors group-hover:text-foreground\" />\n      </button>\n\n      <div className=\"nav-divider my-1 w-6\" />\n\n      {/* Settings */}\n      <button\n        onClick={onShowSettings}\n        className=\"group flex h-8 w-8 items-center justify-center rounded-lg transition-colors hover:bg-accent/80\"\n        aria-label={t('actions.settings')}\n        title={t('actions.settings')}\n      >\n        <Settings className=\"h-4 w-4 text-muted-foreground transition-colors group-hover:text-foreground\" />\n      </button>\n\n      {/* Discord */}\n      <a\n        href={DISCORD_INVITE_URL}\n        target=\"_blank\"\n        rel=\"noopener noreferrer\"\n        className=\"group flex h-8 w-8 items-center justify-center rounded-lg transition-colors hover:bg-accent/80\"\n        aria-label={t('actions.joinCommunity')}\n        title={t('actions.joinCommunity')}\n      >\n        <DiscordIcon className=\"h-4 w-4 text-muted-foreground transition-colors group-hover:text-foreground\" />\n      </a>\n\n      {/* Update indicator */}\n      {updateAvailable && (\n        <button\n          onClick={onShowVersionModal}\n          className=\"relative flex h-8 w-8 items-center justify-center rounded-lg transition-colors hover:bg-accent/80\"\n          aria-label={t('common:versionUpdate.ariaLabels.updateAvailable')}\n          title={t('common:versionUpdate.ariaLabels.updateAvailable')}\n        >\n          <Sparkles className=\"h-4 w-4 text-blue-500\" />\n          <span className=\"absolute right-1.5 top-1.5 h-1.5 w-1.5 animate-pulse rounded-full bg-blue-500\" />\n        </button>\n      )}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/sidebar/view/subcomponents/SidebarContent.tsx",
    "content": "import { type ReactNode } from 'react';\nimport { Folder, MessageSquare, Search } from 'lucide-react';\nimport type { TFunction } from 'i18next';\nimport { ScrollArea } from '../../../../shared/view/ui';\nimport type { Project } from '../../../../types/app';\nimport type { ReleaseInfo } from '../../../../types/sharedTypes';\nimport type { ConversationSearchResults, SearchProgress } from '../../hooks/useSidebarController';\nimport SidebarFooter from './SidebarFooter';\nimport SidebarHeader from './SidebarHeader';\nimport SidebarProjectList, { type SidebarProjectListProps } from './SidebarProjectList';\n\ntype SearchMode = 'projects' | 'conversations';\n\nfunction HighlightedSnippet({ snippet, highlights }: { snippet: string; highlights: { start: number; end: number }[] }) {\n  const parts: ReactNode[] = [];\n  let cursor = 0;\n  for (const h of highlights) {\n    if (h.start > cursor) {\n      parts.push(snippet.slice(cursor, h.start));\n    }\n    parts.push(\n      <mark key={h.start} className=\"rounded-sm bg-yellow-200 px-0.5 text-foreground dark:bg-yellow-800\">\n        {snippet.slice(h.start, h.end)}\n      </mark>\n    );\n    cursor = h.end;\n  }\n  if (cursor < snippet.length) {\n    parts.push(snippet.slice(cursor));\n  }\n  return (\n    <span className=\"text-xs leading-relaxed text-muted-foreground\">\n      {parts}\n    </span>\n  );\n}\n\ntype SidebarContentProps = {\n  isPWA: boolean;\n  isMobile: boolean;\n  isLoading: boolean;\n  projects: Project[];\n  searchFilter: string;\n  onSearchFilterChange: (value: string) => void;\n  onClearSearchFilter: () => void;\n  searchMode: SearchMode;\n  onSearchModeChange: (mode: SearchMode) => void;\n  conversationResults: ConversationSearchResults | null;\n  isSearching: boolean;\n  searchProgress: SearchProgress | null;\n  onConversationResultClick: (projectName: string, sessionId: string, provider: string, messageTimestamp?: string | null, messageSnippet?: string | null) => void;\n  onRefresh: () => void;\n  isRefreshing: boolean;\n  onCreateProject: () => void;\n  onCollapseSidebar: () => void;\n  updateAvailable: boolean;\n  releaseInfo: ReleaseInfo | null;\n  latestVersion: string | null;\n  onShowVersionModal: () => void;\n  onShowSettings: () => void;\n  projectListProps: SidebarProjectListProps;\n  t: TFunction;\n};\n\nexport default function SidebarContent({\n  isPWA,\n  isMobile,\n  isLoading,\n  projects,\n  searchFilter,\n  onSearchFilterChange,\n  onClearSearchFilter,\n  searchMode,\n  onSearchModeChange,\n  conversationResults,\n  isSearching,\n  searchProgress,\n  onConversationResultClick,\n  onRefresh,\n  isRefreshing,\n  onCreateProject,\n  onCollapseSidebar,\n  updateAvailable,\n  releaseInfo,\n  latestVersion,\n  onShowVersionModal,\n  onShowSettings,\n  projectListProps,\n  t,\n}: SidebarContentProps) {\n  const showConversationSearch = searchMode === 'conversations' && searchFilter.trim().length >= 2;\n  const hasPartialResults = conversationResults && conversationResults.results.length > 0;\n\n  return (\n    <div\n      className=\"flex h-full flex-col bg-background/80 backdrop-blur-sm md:w-72 md:select-none\"\n      style={{}}\n    >\n      <SidebarHeader\n        isPWA={isPWA}\n        isMobile={isMobile}\n        isLoading={isLoading}\n        projectsCount={projects.length}\n        searchFilter={searchFilter}\n        onSearchFilterChange={onSearchFilterChange}\n        onClearSearchFilter={onClearSearchFilter}\n        searchMode={searchMode}\n        onSearchModeChange={onSearchModeChange}\n        onRefresh={onRefresh}\n        isRefreshing={isRefreshing}\n        onCreateProject={onCreateProject}\n        onCollapseSidebar={onCollapseSidebar}\n        t={t}\n      />\n\n      <ScrollArea className=\"flex-1 overflow-y-auto overscroll-contain md:px-1.5 md:py-2\">\n        {showConversationSearch ? (\n          isSearching && !hasPartialResults ? (\n            <div className=\"px-4 py-12 text-center md:py-8\">\n              <div className=\"mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-muted md:mb-3\">\n                <div className=\"h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent\" />\n              </div>\n              <p className=\"text-sm text-muted-foreground\">{t('search.searching')}</p>\n              {searchProgress && (\n                <p className=\"mt-1 text-xs text-muted-foreground/60\">\n                  {t('search.projectsScanned', { count: searchProgress.scannedProjects })}/{searchProgress.totalProjects}\n                </p>\n              )}\n            </div>\n          ) : !isSearching && conversationResults && conversationResults.results.length === 0 ? (\n            <div className=\"px-4 py-12 text-center md:py-8\">\n              <div className=\"mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-muted md:mb-3\">\n                <Search className=\"h-6 w-6 text-muted-foreground\" />\n              </div>\n              <h3 className=\"mb-2 text-base font-medium text-foreground md:mb-1\">{t('search.noResults')}</h3>\n              <p className=\"text-sm text-muted-foreground\">{t('search.tryDifferentQuery')}</p>\n            </div>\n          ) : hasPartialResults ? (\n            <div className=\"space-y-3 px-2\">\n              <div className=\"flex items-center justify-between px-1\">\n                <p className=\"text-xs text-muted-foreground\">\n                  {t('search.matches', { count: conversationResults.totalMatches })}\n                </p>\n                {isSearching && searchProgress && (\n                  <div className=\"flex items-center gap-1.5\">\n                    <div className=\"h-3 w-3 animate-spin rounded-full border-[1.5px] border-muted-foreground/40 border-t-primary\" />\n                    <p className=\"text-[10px] text-muted-foreground/60\">\n                      {searchProgress.scannedProjects}/{searchProgress.totalProjects}\n                    </p>\n                  </div>\n                )}\n              </div>\n              {isSearching && searchProgress && (\n                <div className=\"mx-1 h-0.5 overflow-hidden rounded-full bg-muted\">\n                  <div\n                    className=\"h-full rounded-full bg-primary/60 transition-all duration-300\"\n                    style={{ width: `${Math.round((searchProgress.scannedProjects / searchProgress.totalProjects) * 100)}%` }}\n                  />\n                </div>\n              )}\n              {conversationResults.results.map((projectResult) => (\n                <div key={projectResult.projectName} className=\"space-y-1\">\n                  <div className=\"flex items-center gap-1.5 px-1 py-1\">\n                    <Folder className=\"h-3 w-3 flex-shrink-0 text-muted-foreground\" />\n                    <span className=\"truncate text-xs font-medium text-foreground\">\n                      {projectResult.projectDisplayName}\n                    </span>\n                  </div>\n                  {projectResult.sessions.map((session) => (\n                    <button\n                      key={`${projectResult.projectName}-${session.sessionId}`}\n                      className=\"w-full rounded-md px-2 py-2 text-left transition-colors hover:bg-accent/50\"\n                      onClick={() => onConversationResultClick(\n                        projectResult.projectName,\n                        session.sessionId,\n                        session.provider || session.matches[0]?.provider || 'claude',\n                        session.matches[0]?.timestamp,\n                        session.matches[0]?.snippet\n                      )}\n                    >\n                      <div className=\"mb-1 flex items-center gap-1.5\">\n                        <MessageSquare className=\"h-3 w-3 flex-shrink-0 text-primary\" />\n                        <span className=\"truncate text-xs font-medium text-foreground\">\n                          {session.sessionSummary}\n                        </span>\n                        {session.provider && session.provider !== 'claude' && (\n                          <span className=\"flex-shrink-0 rounded bg-muted px-1 py-0.5 text-[9px] uppercase text-muted-foreground\">\n                            {session.provider}\n                          </span>\n                        )}\n                      </div>\n                      <div className=\"space-y-1 pl-4\">\n                        {session.matches.map((match, idx) => (\n                          <div key={idx} className=\"flex items-start gap-1\">\n                            <span className=\"mt-0.5 flex-shrink-0 text-[10px] font-medium uppercase text-muted-foreground/60\">\n                              {match.role === 'user' ? 'U' : 'A'}\n                            </span>\n                            <HighlightedSnippet\n                              snippet={match.snippet}\n                              highlights={match.highlights}\n                            />\n                          </div>\n                        ))}\n                      </div>\n                    </button>\n                  ))}\n                </div>\n              ))}\n            </div>\n          ) : null\n        ) : (\n          <SidebarProjectList {...projectListProps} />\n        )}\n      </ScrollArea>\n\n      <SidebarFooter\n        updateAvailable={updateAvailable}\n        releaseInfo={releaseInfo}\n        latestVersion={latestVersion}\n        onShowVersionModal={onShowVersionModal}\n        onShowSettings={onShowSettings}\n        t={t}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/sidebar/view/subcomponents/SidebarFooter.tsx",
    "content": "import { Settings, ArrowUpCircle } from 'lucide-react';\nimport type { TFunction } from 'i18next';\nimport type { ReleaseInfo } from '../../../../types/sharedTypes';\n\nconst DISCORD_INVITE_URL = 'https://discord.gg/buxwujPNRE';\n\nfunction DiscordIcon({ className }: { className?: string }) {\n  return (\n    <svg className={className} fill=\"currentColor\" viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n      <path d=\"M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z\" />\n    </svg>\n  );\n}\n\ntype SidebarFooterProps = {\n  updateAvailable: boolean;\n  releaseInfo: ReleaseInfo | null;\n  latestVersion: string | null;\n  onShowVersionModal: () => void;\n  onShowSettings: () => void;\n  t: TFunction;\n};\n\nexport default function SidebarFooter({\n  updateAvailable,\n  releaseInfo,\n  latestVersion,\n  onShowVersionModal,\n  onShowSettings,\n  t,\n}: SidebarFooterProps) {\n  return (\n    <div className=\"flex-shrink-0\" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0)' }}>\n      {/* Update banner */}\n      {updateAvailable && (\n        <>\n          <div className=\"nav-divider\" />\n          {/* Desktop update */}\n          <div className=\"hidden px-2 py-1.5 md:block\">\n            <button\n              className=\"group flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left transition-colors hover:bg-blue-50/80 dark:hover:bg-blue-900/15\"\n              onClick={onShowVersionModal}\n            >\n              <div className=\"relative flex-shrink-0\">\n                <ArrowUpCircle className=\"h-4 w-4 text-blue-500 dark:text-blue-400\" />\n                <span className=\"absolute -right-0.5 -top-0.5 h-1.5 w-1.5 animate-pulse rounded-full bg-blue-500\" />\n              </div>\n              <div className=\"min-w-0 flex-1\">\n                <span className=\"block truncate text-sm font-medium text-blue-600 dark:text-blue-300\">\n                  {releaseInfo?.title || `v${latestVersion}`}\n                </span>\n                <span className=\"text-[10px] text-blue-500/70 dark:text-blue-400/60\">\n                  {t('version.updateAvailable')}\n                </span>\n              </div>\n            </button>\n          </div>\n\n          {/* Mobile update */}\n          <div className=\"px-3 py-2 md:hidden\">\n            <button\n              className=\"flex h-11 w-full items-center gap-3 rounded-xl border border-blue-200/60 bg-blue-50/80 px-3.5 transition-all active:scale-[0.98] dark:border-blue-700/40 dark:bg-blue-900/15\"\n              onClick={onShowVersionModal}\n            >\n              <div className=\"relative flex-shrink-0\">\n                <ArrowUpCircle className=\"w-4.5 h-4.5 text-blue-500 dark:text-blue-400\" />\n                <span className=\"absolute -right-0.5 -top-0.5 h-1.5 w-1.5 animate-pulse rounded-full bg-blue-500\" />\n              </div>\n              <div className=\"min-w-0 flex-1 text-left\">\n                <span className=\"block truncate text-sm font-medium text-blue-600 dark:text-blue-300\">\n                  {releaseInfo?.title || `v${latestVersion}`}\n                </span>\n                <span className=\"text-xs text-blue-500/70 dark:text-blue-400/60\">\n                  {t('version.updateAvailable')}\n                </span>\n              </div>\n            </button>\n          </div>\n        </>\n      )}\n\n      {/* Discord + Settings */}\n      <div className=\"nav-divider\" />\n\n      {/* Desktop Discord */}\n      <div className=\"hidden px-2 pt-1.5 md:block\">\n        <a\n          href={DISCORD_INVITE_URL}\n          target=\"_blank\"\n          rel=\"noopener noreferrer\"\n          className=\"flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground\"\n        >\n          <DiscordIcon className=\"h-3.5 w-3.5\" />\n          <span className=\"text-sm\">{t('actions.joinCommunity')}</span>\n        </a>\n      </div>\n\n      {/* Desktop settings */}\n      <div className=\"hidden px-2 py-1.5 md:block\">\n        <button\n          className=\"flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground\"\n          onClick={onShowSettings}\n        >\n          <Settings className=\"h-3.5 w-3.5\" />\n          <span className=\"text-sm\">{t('actions.settings')}</span>\n        </button>\n      </div>\n\n      {/* Mobile Discord */}\n      <div className=\"px-3 pt-3 md:hidden\">\n        <a\n          href={DISCORD_INVITE_URL}\n          target=\"_blank\"\n          rel=\"noopener noreferrer\"\n          className=\"flex h-12 w-full items-center gap-3.5 rounded-xl bg-muted/40 px-4 transition-all hover:bg-muted/60 active:scale-[0.98]\"\n        >\n          <div className=\"flex h-8 w-8 items-center justify-center rounded-xl bg-background/80\">\n            <DiscordIcon className=\"w-4.5 h-4.5 text-muted-foreground\" />\n          </div>\n          <span className=\"text-base font-medium text-foreground\">{t('actions.joinCommunity')}</span>\n        </a>\n      </div>\n\n      {/* Mobile settings */}\n      <div className=\"px-3 pb-20 pt-2 md:hidden\">\n        <button\n          className=\"flex h-12 w-full items-center gap-3.5 rounded-xl bg-muted/40 px-4 transition-all hover:bg-muted/60 active:scale-[0.98]\"\n          onClick={onShowSettings}\n        >\n          <div className=\"flex h-8 w-8 items-center justify-center rounded-xl bg-background/80\">\n            <Settings className=\"w-4.5 h-4.5 text-muted-foreground\" />\n          </div>\n          <span className=\"text-base font-medium text-foreground\">{t('actions.settings')}</span>\n        </button>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/sidebar/view/subcomponents/SidebarHeader.tsx",
    "content": "import { Folder, FolderPlus, MessageSquare, Plus, RefreshCw, Search, X, PanelLeftClose } from 'lucide-react';\nimport type { TFunction } from 'i18next';\nimport { Button, Input } from '../../../../shared/view/ui';\nimport { IS_PLATFORM } from '../../../../constants/config';\nimport { cn } from '../../../../lib/utils';\n\ntype SearchMode = 'projects' | 'conversations';\n\ntype SidebarHeaderProps = {\n  isPWA: boolean;\n  isMobile: boolean;\n  isLoading: boolean;\n  projectsCount: number;\n  searchFilter: string;\n  onSearchFilterChange: (value: string) => void;\n  onClearSearchFilter: () => void;\n  searchMode: SearchMode;\n  onSearchModeChange: (mode: SearchMode) => void;\n  onRefresh: () => void;\n  isRefreshing: boolean;\n  onCreateProject: () => void;\n  onCollapseSidebar: () => void;\n  t: TFunction;\n};\n\nexport default function SidebarHeader({\n  isPWA,\n  isMobile,\n  isLoading,\n  projectsCount,\n  searchFilter,\n  onSearchFilterChange,\n  onClearSearchFilter,\n  searchMode,\n  onSearchModeChange,\n  onRefresh,\n  isRefreshing,\n  onCreateProject,\n  onCollapseSidebar,\n  t,\n}: SidebarHeaderProps) {\n  const LogoBlock = () => (\n    <div className=\"flex min-w-0 items-center gap-2.5\">\n      <div className=\"flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-lg bg-primary/90 shadow-sm\">\n        <svg className=\"h-3.5 w-3.5 text-primary-foreground\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth={2.2} strokeLinecap=\"round\" strokeLinejoin=\"round\">\n          <path d=\"M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z\" />\n        </svg>\n      </div>\n      <h1 className=\"truncate text-sm font-semibold tracking-tight text-foreground\">{t('app.title')}</h1>\n    </div>\n  );\n\n  return (\n    <div className=\"flex-shrink-0\">\n      {/* Desktop header */}\n      <div\n        className=\"hidden px-3 pb-2 pt-3 md:block\"\n        style={{}}\n      >\n        <div className=\"flex items-center justify-between gap-2\">\n          {IS_PLATFORM ? (\n            <a\n              href=\"https://cloudcli.ai/dashboard\"\n              className=\"flex min-w-0 items-center gap-2.5 transition-opacity hover:opacity-80\"\n              title={t('tooltips.viewEnvironments')}\n            >\n              <LogoBlock />\n            </a>\n          ) : (\n            <LogoBlock />\n          )}\n\n          <div className=\"flex flex-shrink-0 items-center gap-0.5\">\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              className=\"h-7 w-7 rounded-lg p-0 text-muted-foreground hover:bg-accent/80 hover:text-foreground\"\n              onClick={onRefresh}\n              disabled={isRefreshing}\n              title={t('tooltips.refresh')}\n            >\n              <RefreshCw\n                className={`h-3.5 w-3.5 ${\n                  isRefreshing ? 'animate-spin' : ''\n                }`}\n              />\n            </Button>\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              className=\"h-7 w-7 rounded-lg p-0 text-muted-foreground hover:bg-accent/80 hover:text-foreground\"\n              onClick={onCreateProject}\n              title={t('tooltips.createProject')}\n            >\n              <Plus className=\"h-3.5 w-3.5\" />\n            </Button>\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              className=\"h-7 w-7 rounded-lg p-0 text-muted-foreground hover:bg-accent/80 hover:text-foreground\"\n              onClick={onCollapseSidebar}\n              title={t('tooltips.hideSidebar')}\n            >\n              <PanelLeftClose className=\"h-3.5 w-3.5\" />\n            </Button>\n          </div>\n        </div>\n\n        {/* Search bar */}\n        {projectsCount > 0 && !isLoading && (\n          <div className=\"mt-2.5 space-y-2\">\n            {/* Search mode toggle */}\n            <div className=\"flex rounded-lg bg-muted/50 p-0.5\">\n              <button\n                onClick={() => onSearchModeChange('projects')}\n                aria-pressed={searchMode === 'projects'}\n                className={cn(\n                  \"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all\",\n                  searchMode === 'projects'\n                    ? \"bg-background shadow-sm text-foreground\"\n                    : \"text-muted-foreground hover:text-foreground\"\n                )}\n              >\n                <Folder className=\"h-3 w-3\" />\n                {t('search.modeProjects')}\n              </button>\n              <button\n                onClick={() => onSearchModeChange('conversations')}\n                aria-pressed={searchMode === 'conversations'}\n                className={cn(\n                  \"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all\",\n                  searchMode === 'conversations'\n                    ? \"bg-background shadow-sm text-foreground\"\n                    : \"text-muted-foreground hover:text-foreground\"\n                )}\n              >\n                <MessageSquare className=\"h-3 w-3\" />\n                {t('search.modeConversations')}\n              </button>\n            </div>\n            <div className=\"relative\">\n              <Search className=\"pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/50\" />\n              <Input\n                type=\"text\"\n                placeholder={searchMode === 'conversations' ? t('search.conversationsPlaceholder') : t('projects.searchPlaceholder')}\n                value={searchFilter}\n                onChange={(event) => onSearchFilterChange(event.target.value)}\n                className=\"nav-search-input h-9 rounded-xl border-0 pl-9 pr-8 text-sm transition-all duration-200 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0\"\n              />\n              {searchFilter && (\n                <button\n                  onClick={onClearSearchFilter}\n                  aria-label={t('tooltips.clearSearch')}\n                  className=\"absolute right-2.5 top-1/2 -translate-y-1/2 rounded-md p-0.5 hover:bg-accent\"\n                >\n                  <X className=\"h-3 w-3 text-muted-foreground\" />\n                </button>\n              )}\n            </div>\n          </div>\n        )}\n      </div>\n\n      {/* Desktop divider */}\n      <div className=\"nav-divider hidden md:block\" />\n\n      {/* Mobile header */}\n      <div\n        className=\"p-3 pb-2 md:hidden\"\n        style={isPWA && isMobile ? { paddingTop: '16px' } : {}}\n      >\n        <div className=\"flex items-center justify-between\">\n          {IS_PLATFORM ? (\n            <a\n              href=\"https://cloudcli.ai/dashboard\"\n              className=\"flex min-w-0 items-center gap-2.5 transition-opacity active:opacity-70\"\n              title={t('tooltips.viewEnvironments')}\n            >\n              <LogoBlock />\n            </a>\n          ) : (\n            <LogoBlock />\n          )}\n\n          <div className=\"flex flex-shrink-0 gap-1.5\">\n            <button\n              className=\"flex h-8 w-8 items-center justify-center rounded-lg bg-muted/50 transition-all active:scale-95\"\n              onClick={onRefresh}\n              disabled={isRefreshing}\n            >\n              <RefreshCw className={`h-4 w-4 text-muted-foreground ${isRefreshing ? 'animate-spin' : ''}`} />\n            </button>\n            <button\n              className=\"flex h-8 w-8 items-center justify-center rounded-lg bg-primary/90 text-primary-foreground transition-all active:scale-95\"\n              onClick={onCreateProject}\n            >\n              <FolderPlus className=\"h-4 w-4\" />\n            </button>\n          </div>\n        </div>\n\n        {/* Mobile search */}\n        {projectsCount > 0 && !isLoading && (\n          <div className=\"mt-2.5 space-y-2\">\n            <div className=\"flex rounded-lg bg-muted/50 p-0.5\">\n              <button\n                onClick={() => onSearchModeChange('projects')}\n                aria-pressed={searchMode === 'projects'}\n                className={cn(\n                  \"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all\",\n                  searchMode === 'projects'\n                    ? \"bg-background shadow-sm text-foreground\"\n                    : \"text-muted-foreground hover:text-foreground\"\n                )}\n              >\n                <Folder className=\"h-3 w-3\" />\n                {t('search.modeProjects')}\n              </button>\n              <button\n                onClick={() => onSearchModeChange('conversations')}\n                aria-pressed={searchMode === 'conversations'}\n                className={cn(\n                  \"flex-1 flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-all\",\n                  searchMode === 'conversations'\n                    ? \"bg-background shadow-sm text-foreground\"\n                    : \"text-muted-foreground hover:text-foreground\"\n                )}\n              >\n                <MessageSquare className=\"h-3 w-3\" />\n                {t('search.modeConversations')}\n              </button>\n            </div>\n            <div className=\"relative\">\n              <Search className=\"pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/50\" />\n              <Input\n                type=\"text\"\n                placeholder={searchMode === 'conversations' ? t('search.conversationsPlaceholder') : t('projects.searchPlaceholder')}\n                value={searchFilter}\n                onChange={(event) => onSearchFilterChange(event.target.value)}\n                className=\"nav-search-input h-10 rounded-xl border-0 pl-10 pr-9 text-sm transition-all duration-200 placeholder:text-muted-foreground/40 focus-visible:ring-0 focus-visible:ring-offset-0\"\n              />\n              {searchFilter && (\n                <button\n                  onClick={onClearSearchFilter}\n                  aria-label={t('tooltips.clearSearch')}\n                  className=\"absolute right-2.5 top-1/2 -translate-y-1/2 rounded-md p-1 hover:bg-accent\"\n                >\n                  <X className=\"h-3.5 w-3.5 text-muted-foreground\" />\n                </button>\n              )}\n            </div>\n          </div>\n        )}\n      </div>\n\n      {/* Mobile divider */}\n      <div className=\"nav-divider md:hidden\" />\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/sidebar/view/subcomponents/SidebarModals.tsx",
    "content": "import { useMemo } from 'react';\nimport ReactDOM from 'react-dom';\nimport { AlertTriangle, Trash2 } from 'lucide-react';\nimport type { TFunction } from 'i18next';\nimport { Button } from '../../../../shared/view/ui';\nimport Settings from '../../../settings/view/Settings';\nimport VersionUpgradeModal from '../../../version-upgrade/view';\nimport type { Project } from '../../../../types/app';\nimport type { ReleaseInfo } from '../../../../types/sharedTypes';\nimport type { InstallMode } from '../../../../hooks/useVersionCheck';\nimport { normalizeProjectForSettings } from '../../utils/utils';\nimport type { DeleteProjectConfirmation, SessionDeleteConfirmation, SettingsProject } from '../../types/types';\nimport ProjectCreationWizard from '../../../project-creation-wizard';\n\ntype SidebarModalsProps = {\n  projects: Project[];\n  showSettings: boolean;\n  settingsInitialTab: string;\n  onCloseSettings: () => void;\n  showNewProject: boolean;\n  onCloseNewProject: () => void;\n  onProjectCreated: () => void;\n  deleteConfirmation: DeleteProjectConfirmation | null;\n  onCancelDeleteProject: () => void;\n  onConfirmDeleteProject: () => void;\n  sessionDeleteConfirmation: SessionDeleteConfirmation | null;\n  onCancelDeleteSession: () => void;\n  onConfirmDeleteSession: () => void;\n  showVersionModal: boolean;\n  onCloseVersionModal: () => void;\n  releaseInfo: ReleaseInfo | null;\n  currentVersion: string;\n  latestVersion: string | null;\n  installMode: InstallMode;\n  t: TFunction;\n};\n\ntype TypedSettingsProps = {\n  isOpen: boolean;\n  onClose: () => void;\n  projects: SettingsProject[];\n  initialTab: string;\n};\n\nconst SettingsComponent = Settings as (props: TypedSettingsProps) => JSX.Element;\n\nfunction TypedSettings(props: TypedSettingsProps) {\n  return <SettingsComponent {...props} />;\n}\n\nexport default function SidebarModals({\n  projects,\n  showSettings,\n  settingsInitialTab,\n  onCloseSettings,\n  showNewProject,\n  onCloseNewProject,\n  onProjectCreated,\n  deleteConfirmation,\n  onCancelDeleteProject,\n  onConfirmDeleteProject,\n  sessionDeleteConfirmation,\n  onCancelDeleteSession,\n  onConfirmDeleteSession,\n  showVersionModal,\n  onCloseVersionModal,\n  releaseInfo,\n  currentVersion,\n  latestVersion,\n  installMode,\n  t,\n}: SidebarModalsProps) {\n  // Settings expects project identity/path fields to be present for dropdown labels and local-scope MCP config.\n  const settingsProjects = useMemo(\n    () => projects.map(normalizeProjectForSettings),\n    [projects],\n  );\n\n  return (\n    <>\n      {showNewProject &&\n        ReactDOM.createPortal(\n          <ProjectCreationWizard\n            onClose={onCloseNewProject}\n            onProjectCreated={onProjectCreated}\n          />,\n          document.body,\n        )}\n\n      {showSettings &&\n        ReactDOM.createPortal(\n          <TypedSettings\n            isOpen={showSettings}\n            onClose={onCloseSettings}\n            projects={settingsProjects}\n            initialTab={settingsInitialTab}\n          />,\n          document.body,\n        )}\n\n      {deleteConfirmation &&\n        ReactDOM.createPortal(\n          <div className=\"fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm\">\n            <div className=\"w-full max-w-md overflow-hidden rounded-xl border border-border bg-card shadow-2xl\">\n              <div className=\"p-6\">\n                <div className=\"flex items-start gap-4\">\n                  <div className=\"flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30\">\n                    <AlertTriangle className=\"h-6 w-6 text-red-600 dark:text-red-400\" />\n                  </div>\n                  <div className=\"min-w-0 flex-1\">\n                    <h3 className=\"mb-2 text-lg font-semibold text-foreground\">\n                      {t('deleteConfirmation.deleteProject')}\n                    </h3>\n                    <p className=\"mb-1 text-sm text-muted-foreground\">\n                      {t('deleteConfirmation.confirmDelete')}{' '}\n                      <span className=\"font-medium text-foreground\">\n                        {deleteConfirmation.project.displayName || deleteConfirmation.project.name}\n                      </span>\n                      ?\n                    </p>\n                    {deleteConfirmation.sessionCount > 0 && (\n                      <div className=\"mt-3 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-800 dark:bg-red-900/20\">\n                        <p className=\"text-sm font-medium text-red-700 dark:text-red-300\">\n                          {t('deleteConfirmation.sessionCount', { count: deleteConfirmation.sessionCount })}\n                        </p>\n                        <p className=\"mt-1 text-xs text-red-600 dark:text-red-400\">\n                          {t('deleteConfirmation.allConversationsDeleted')}\n                        </p>\n                      </div>\n                    )}\n                    <p className=\"mt-3 text-xs text-muted-foreground\">\n                      {t('deleteConfirmation.cannotUndo')}\n                    </p>\n                  </div>\n                </div>\n              </div>\n              <div className=\"flex gap-3 border-t border-border bg-muted/30 p-4\">\n                <Button variant=\"outline\" className=\"flex-1\" onClick={onCancelDeleteProject}>\n                  {t('actions.cancel')}\n                </Button>\n                <Button\n                  variant=\"destructive\"\n                  className=\"flex-1 bg-red-600 text-white hover:bg-red-700\"\n                  onClick={onConfirmDeleteProject}\n                >\n                  <Trash2 className=\"mr-2 h-4 w-4\" />\n                  {t('actions.delete')}\n                </Button>\n              </div>\n            </div>\n          </div>,\n          document.body,\n        )}\n\n      {sessionDeleteConfirmation &&\n        ReactDOM.createPortal(\n          <div className=\"fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm\">\n            <div className=\"w-full max-w-md overflow-hidden rounded-xl border border-border bg-card shadow-2xl\">\n              <div className=\"p-6\">\n                <div className=\"flex items-start gap-4\">\n                  <div className=\"flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30\">\n                    <AlertTriangle className=\"h-6 w-6 text-red-600 dark:text-red-400\" />\n                  </div>\n                  <div className=\"min-w-0 flex-1\">\n                    <h3 className=\"mb-2 text-lg font-semibold text-foreground\">\n                      {t('deleteConfirmation.deleteSession')}\n                    </h3>\n                    <p className=\"mb-1 text-sm text-muted-foreground\">\n                      {t('deleteConfirmation.confirmDelete')}{' '}\n                      <span className=\"font-medium text-foreground\">\n                        {sessionDeleteConfirmation.sessionTitle || t('sessions.unnamed')}\n                      </span>\n                      ?\n                    </p>\n                    <p className=\"mt-3 text-xs text-muted-foreground\">\n                      {t('deleteConfirmation.cannotUndo')}\n                    </p>\n                  </div>\n                </div>\n              </div>\n              <div className=\"flex gap-3 border-t border-border bg-muted/30 p-4\">\n                <Button variant=\"outline\" className=\"flex-1\" onClick={onCancelDeleteSession}>\n                  {t('actions.cancel')}\n                </Button>\n                <Button\n                  variant=\"destructive\"\n                  className=\"flex-1 bg-red-600 text-white hover:bg-red-700\"\n                  onClick={onConfirmDeleteSession}\n                >\n                  <Trash2 className=\"mr-2 h-4 w-4\" />\n                  {t('actions.delete')}\n                </Button>\n              </div>\n            </div>\n          </div>,\n          document.body,\n        )}\n\n      <VersionUpgradeModal\n        isOpen={showVersionModal}\n        onClose={onCloseVersionModal}\n        releaseInfo={releaseInfo}\n        currentVersion={currentVersion}\n        latestVersion={latestVersion}\n        installMode={installMode}\n      />\n    </>\n  );\n}\n"
  },
  {
    "path": "src/components/sidebar/view/subcomponents/SidebarProjectItem.tsx",
    "content": "import { Check, ChevronDown, ChevronRight, Edit3, Folder, FolderOpen, Star, Trash2, X } from 'lucide-react';\nimport type { TFunction } from 'i18next';\nimport { Button } from '../../../../shared/view/ui';\nimport { cn } from '../../../../lib/utils';\nimport type { Project, ProjectSession, SessionProvider } from '../../../../types/app';\nimport type { MCPServerStatus, SessionWithProvider } from '../../types/types';\nimport { getTaskIndicatorStatus } from '../../utils/utils';\nimport TaskIndicator from './TaskIndicator';\nimport SidebarProjectSessions from './SidebarProjectSessions';\n\ntype SidebarProjectItemProps = {\n  project: Project;\n  selectedProject: Project | null;\n  selectedSession: ProjectSession | null;\n  isExpanded: boolean;\n  isDeleting: boolean;\n  isStarred: boolean;\n  editingProject: string | null;\n  editingName: string;\n  sessions: SessionWithProvider[];\n  initialSessionsLoaded: boolean;\n  isLoadingSessions: boolean;\n  currentTime: Date;\n  editingSession: string | null;\n  editingSessionName: string;\n  tasksEnabled: boolean;\n  mcpServerStatus: MCPServerStatus;\n  onEditingNameChange: (name: string) => void;\n  onToggleProject: (projectName: string) => void;\n  onProjectSelect: (project: Project) => void;\n  onToggleStarProject: (projectName: string) => void;\n  onStartEditingProject: (project: Project) => void;\n  onCancelEditingProject: () => void;\n  onSaveProjectName: (projectName: string) => void;\n  onDeleteProject: (project: Project) => void;\n  onSessionSelect: (session: SessionWithProvider, projectName: string) => void;\n  onDeleteSession: (\n    projectName: string,\n    sessionId: string,\n    sessionTitle: string,\n    provider: SessionProvider,\n  ) => void;\n  onLoadMoreSessions: (project: Project) => void;\n  onNewSession: (project: Project) => void;\n  onEditingSessionNameChange: (value: string) => void;\n  onStartEditingSession: (sessionId: string, initialName: string) => void;\n  onCancelEditingSession: () => void;\n  onSaveEditingSession: (projectName: string, sessionId: string, summary: string, provider: SessionProvider) => void;\n  t: TFunction;\n};\n\nconst getSessionCountDisplay = (sessions: SessionWithProvider[], hasMoreSessions: boolean): string => {\n  const sessionCount = sessions.length;\n  if (hasMoreSessions && sessionCount >= 5) {\n    return `${sessionCount}+`;\n  }\n\n  return `${sessionCount}`;\n};\n\nexport default function SidebarProjectItem({\n  project,\n  selectedProject,\n  selectedSession,\n  isExpanded,\n  isDeleting,\n  isStarred,\n  editingProject,\n  editingName,\n  sessions,\n  initialSessionsLoaded,\n  isLoadingSessions,\n  currentTime,\n  editingSession,\n  editingSessionName,\n  tasksEnabled,\n  mcpServerStatus,\n  onEditingNameChange,\n  onToggleProject,\n  onProjectSelect,\n  onToggleStarProject,\n  onStartEditingProject,\n  onCancelEditingProject,\n  onSaveProjectName,\n  onDeleteProject,\n  onSessionSelect,\n  onDeleteSession,\n  onLoadMoreSessions,\n  onNewSession,\n  onEditingSessionNameChange,\n  onStartEditingSession,\n  onCancelEditingSession,\n  onSaveEditingSession,\n  t,\n}: SidebarProjectItemProps) {\n  const isSelected = selectedProject?.name === project.name;\n  const isEditing = editingProject === project.name;\n  const hasMoreSessions = project.sessionMeta?.hasMore === true;\n  const sessionCountDisplay = getSessionCountDisplay(sessions, hasMoreSessions);\n  const sessionCountLabel = `${sessionCountDisplay} session${sessions.length === 1 ? '' : 's'}`;\n  const taskStatus = getTaskIndicatorStatus(project, mcpServerStatus);\n\n  const toggleProject = () => onToggleProject(project.name);\n  const toggleStarProject = () => onToggleStarProject(project.name);\n\n  const saveProjectName = () => {\n    onSaveProjectName(project.name);\n  };\n\n  const selectAndToggleProject = () => {\n    if (selectedProject?.name !== project.name) {\n      onProjectSelect(project);\n    }\n\n    toggleProject();\n  };\n\n  return (\n    <div className={cn('md:space-y-1', isDeleting && 'opacity-50 pointer-events-none')}>\n      <div className=\"md:group group\">\n        <div className=\"md:hidden\">\n          <div\n            className={cn(\n              'p-3 mx-3 my-1 rounded-lg bg-card border border-border/50 active:scale-[0.98] transition-all duration-150',\n              isSelected && 'bg-primary/5 border-primary/20',\n              isStarred &&\n                !isSelected &&\n                'bg-yellow-50/50 dark:bg-yellow-900/5 border-yellow-200/30 dark:border-yellow-800/30',\n            )}\n            onClick={toggleProject}\n          >\n            <div className=\"flex items-center justify-between\">\n              <div className=\"flex min-w-0 flex-1 items-center gap-3\">\n                <div\n                  className={cn(\n                    'w-8 h-8 rounded-lg flex items-center justify-center transition-colors',\n                    isExpanded ? 'bg-primary/10' : 'bg-muted',\n                  )}\n                >\n                  {isExpanded ? (\n                    <FolderOpen className=\"h-4 w-4 text-primary\" />\n                  ) : (\n                    <Folder className=\"h-4 w-4 text-muted-foreground\" />\n                  )}\n                </div>\n\n                <div className=\"min-w-0 flex-1\">\n                  {isEditing ? (\n                    <input\n                      type=\"text\"\n                      value={editingName}\n                      onChange={(event) => onEditingNameChange(event.target.value)}\n                      className=\"w-full rounded-lg border-2 border-primary/40 bg-background px-3 py-2 text-sm text-foreground shadow-sm transition-all duration-200 focus:border-primary focus:shadow-md focus:outline-none\"\n                      placeholder={t('projects.projectNamePlaceholder')}\n                      autoFocus\n                      autoComplete=\"off\"\n                      onClick={(event) => event.stopPropagation()}\n                      onKeyDown={(event) => {\n                        if (event.key === 'Enter') {\n                          saveProjectName();\n                        }\n\n                        if (event.key === 'Escape') {\n                          onCancelEditingProject();\n                        }\n                      }}\n                      style={{\n                        fontSize: '16px',\n                        WebkitAppearance: 'none',\n                        borderRadius: '8px',\n                      }}\n                    />\n                  ) : (\n                    <>\n                      <div className=\"flex min-w-0 flex-1 items-center justify-between\">\n                        <h3 className=\"truncate text-sm font-medium text-foreground\">{project.displayName}</h3>\n                        {tasksEnabled && (\n                          <TaskIndicator\n                            status={taskStatus}\n                            size=\"xs\"\n                            className=\"ml-2 hidden flex-shrink-0 md:inline-flex\"\n                          />\n                        )}\n                      </div>\n                      <p className=\"text-xs text-muted-foreground\">{sessionCountLabel}</p>\n                    </>\n                  )}\n                </div>\n              </div>\n\n              <div className=\"flex items-center gap-1\">\n                {isEditing ? (\n                  <>\n                    <button\n                      className=\"flex h-8 w-8 items-center justify-center rounded-lg bg-green-500 shadow-sm transition-all duration-150 active:scale-90 active:shadow-none dark:bg-green-600\"\n                      onClick={(event) => {\n                        event.stopPropagation();\n                        saveProjectName();\n                      }}\n                    >\n                      <Check className=\"h-4 w-4 text-white\" />\n                    </button>\n                    <button\n                      className=\"flex h-8 w-8 items-center justify-center rounded-lg bg-gray-500 shadow-sm transition-all duration-150 active:scale-90 active:shadow-none dark:bg-gray-600\"\n                      onClick={(event) => {\n                        event.stopPropagation();\n                        onCancelEditingProject();\n                      }}\n                    >\n                      <X className=\"h-4 w-4 text-white\" />\n                    </button>\n                  </>\n                ) : (\n                  <>\n                    <button\n                      className={cn(\n                        'w-8 h-8 rounded-lg flex items-center justify-center active:scale-90 transition-all duration-150 border',\n                        isStarred\n                          ? 'bg-yellow-500/10 dark:bg-yellow-900/30 border-yellow-200 dark:border-yellow-800'\n                          : 'bg-gray-500/10 dark:bg-gray-900/30 border-gray-200 dark:border-gray-800',\n                      )}\n                      onClick={(event) => {\n                        event.stopPropagation();\n                        toggleStarProject();\n                      }}\n                      title={isStarred ? t('tooltips.removeFromFavorites') : t('tooltips.addToFavorites')}\n                    >\n                      <Star\n                        className={cn(\n                          'w-4 h-4 transition-colors',\n                          isStarred\n                            ? 'text-yellow-600 dark:text-yellow-400 fill-current'\n                            : 'text-gray-600 dark:text-gray-400',\n                        )}\n                      />\n                    </button>\n\n                    <button\n                      className=\"flex h-8 w-8 items-center justify-center rounded-lg border border-red-200 bg-red-500/10 active:scale-90 dark:border-red-800 dark:bg-red-900/30\"\n                      onClick={(event) => {\n                        event.stopPropagation();\n                        onDeleteProject(project);\n                      }}\n                    >\n                      <Trash2 className=\"h-4 w-4 text-red-600 dark:text-red-400\" />\n                    </button>\n\n                    <button\n                      className=\"flex h-8 w-8 items-center justify-center rounded-lg border border-primary/20 bg-primary/10 active:scale-90 dark:border-primary/30 dark:bg-primary/20\"\n                      onClick={(event) => {\n                        event.stopPropagation();\n                        onStartEditingProject(project);\n                      }}\n                    >\n                      <Edit3 className=\"h-4 w-4 text-primary\" />\n                    </button>\n\n                    <div className=\"flex h-6 w-6 items-center justify-center rounded-md bg-muted/30\">\n                      {isExpanded ? (\n                        <ChevronDown className=\"h-3 w-3 text-muted-foreground\" />\n                      ) : (\n                        <ChevronRight className=\"h-3 w-3 text-muted-foreground\" />\n                      )}\n                    </div>\n                  </>\n                )}\n              </div>\n            </div>\n          </div>\n        </div>\n\n        <Button\n          variant=\"ghost\"\n          className={cn(\n            'hidden md:flex w-full justify-between p-2 h-auto font-normal hover:bg-accent/50',\n            isSelected && 'bg-accent text-accent-foreground',\n            isStarred &&\n              !isSelected &&\n              'bg-yellow-50/50 dark:bg-yellow-900/10 hover:bg-yellow-100/50 dark:hover:bg-yellow-900/20',\n          )}\n          onClick={selectAndToggleProject}\n        >\n          <div className=\"flex min-w-0 flex-1 items-center gap-3\">\n            {isExpanded ? (\n              <FolderOpen className=\"h-4 w-4 flex-shrink-0 text-primary\" />\n            ) : (\n              <Folder className=\"h-4 w-4 flex-shrink-0 text-muted-foreground\" />\n            )}\n            <div className=\"min-w-0 flex-1 text-left\">\n              {isEditing ? (\n                <div className=\"space-y-1\">\n                  <input\n                    type=\"text\"\n                    value={editingName}\n                    onChange={(event) => onEditingNameChange(event.target.value)}\n                    className=\"w-full rounded border border-border bg-background px-2 py-1 text-sm text-foreground focus:ring-2 focus:ring-primary/20\"\n                    placeholder={t('projects.projectNamePlaceholder')}\n                    autoFocus\n                    onKeyDown={(event) => {\n                      if (event.key === 'Enter') {\n                        saveProjectName();\n                      }\n                      if (event.key === 'Escape') {\n                        onCancelEditingProject();\n                      }\n                    }}\n                  />\n                  <div className=\"truncate text-xs text-muted-foreground\" title={project.fullPath}>\n                    {project.fullPath}\n                  </div>\n                </div>\n              ) : (\n                <div>\n                  <div className=\"truncate text-sm font-semibold text-foreground\" title={project.displayName}>\n                    {project.displayName}\n                  </div>\n                  <div className=\"text-xs text-muted-foreground\">\n                    {sessionCountDisplay}\n                    {project.fullPath !== project.displayName && (\n                      <span className=\"ml-1 opacity-60\" title={project.fullPath}>\n                        {' - '}\n                        {project.fullPath.length > 25 ? `...${project.fullPath.slice(-22)}` : project.fullPath}\n                      </span>\n                    )}\n                  </div>\n                </div>\n              )}\n            </div>\n          </div>\n\n          <div className=\"flex flex-shrink-0 items-center gap-1\">\n            {isEditing ? (\n              <>\n                <div\n                  className=\"flex h-6 w-6 cursor-pointer items-center justify-center rounded text-green-600 transition-colors hover:bg-green-50 hover:text-green-700 dark:hover:bg-green-900/20\"\n                  onClick={(event) => {\n                    event.stopPropagation();\n                    saveProjectName();\n                  }}\n                >\n                  <Check className=\"h-3 w-3\" />\n                </div>\n                <div\n                  className=\"flex h-6 w-6 cursor-pointer items-center justify-center rounded text-gray-500 transition-colors hover:bg-gray-50 hover:text-gray-700 dark:hover:bg-gray-800\"\n                  onClick={(event) => {\n                    event.stopPropagation();\n                    onCancelEditingProject();\n                  }}\n                >\n                  <X className=\"h-3 w-3\" />\n                </div>\n              </>\n            ) : (\n              <>\n                <div\n                  className={cn(\n                    'w-6 h-6 opacity-0 group-hover:opacity-100 transition-all duration-200 flex items-center justify-center rounded cursor-pointer touch:opacity-100',\n                    isStarred ? 'hover:bg-yellow-50 dark:hover:bg-yellow-900/20 opacity-100' : 'hover:bg-accent',\n                  )}\n                  onClick={(event) => {\n                    event.stopPropagation();\n                    toggleStarProject();\n                  }}\n                  title={isStarred ? t('tooltips.removeFromFavorites') : t('tooltips.addToFavorites')}\n                >\n                  <Star\n                    className={cn(\n                      'w-3 h-3 transition-colors',\n                      isStarred\n                        ? 'text-yellow-600 dark:text-yellow-400 fill-current'\n                        : 'text-muted-foreground',\n                    )}\n                  />\n                </div>\n                <div\n                  className=\"touch:opacity-100 flex h-6 w-6 cursor-pointer items-center justify-center rounded opacity-0 transition-all duration-200 hover:bg-accent group-hover:opacity-100\"\n                  onClick={(event) => {\n                    event.stopPropagation();\n                    onStartEditingProject(project);\n                  }}\n                  title={t('tooltips.renameProject')}\n                >\n                  <Edit3 className=\"h-3 w-3\" />\n                </div>\n                <div\n                  className=\"touch:opacity-100 flex h-6 w-6 cursor-pointer items-center justify-center rounded opacity-0 transition-all duration-200 hover:bg-red-50 group-hover:opacity-100 dark:hover:bg-red-900/20\"\n                  onClick={(event) => {\n                    event.stopPropagation();\n                    onDeleteProject(project);\n                  }}\n                  title={t('tooltips.deleteProject')}\n                >\n                  <Trash2 className=\"h-3 w-3 text-red-600 dark:text-red-400\" />\n                </div>\n                {isExpanded ? (\n                  <ChevronDown className=\"h-4 w-4 text-muted-foreground transition-colors group-hover:text-foreground\" />\n                ) : (\n                  <ChevronRight className=\"h-4 w-4 text-muted-foreground transition-colors group-hover:text-foreground\" />\n                )}\n              </>\n            )}\n          </div>\n        </Button>\n      </div>\n\n      <SidebarProjectSessions\n        project={project}\n        isExpanded={isExpanded}\n        sessions={sessions}\n        selectedSession={selectedSession}\n        initialSessionsLoaded={initialSessionsLoaded}\n        isLoadingSessions={isLoadingSessions}\n        currentTime={currentTime}\n        editingSession={editingSession}\n        editingSessionName={editingSessionName}\n        onEditingSessionNameChange={onEditingSessionNameChange}\n        onStartEditingSession={onStartEditingSession}\n        onCancelEditingSession={onCancelEditingSession}\n        onSaveEditingSession={onSaveEditingSession}\n        onProjectSelect={onProjectSelect}\n        onSessionSelect={onSessionSelect}\n        onDeleteSession={onDeleteSession}\n        onLoadMoreSessions={onLoadMoreSessions}\n        onNewSession={onNewSession}\n        t={t}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/sidebar/view/subcomponents/SidebarProjectList.tsx",
    "content": "import { useEffect } from 'react';\nimport type { TFunction } from 'i18next';\nimport type { LoadingProgress, Project, ProjectSession, SessionProvider } from '../../../../types/app';\nimport type {\n  LoadingSessionsByProject,\n  MCPServerStatus,\n  SessionWithProvider,\n} from '../../types/types';\nimport SidebarProjectItem from './SidebarProjectItem';\nimport SidebarProjectsState from './SidebarProjectsState';\n\nexport type SidebarProjectListProps = {\n  projects: Project[];\n  filteredProjects: Project[];\n  selectedProject: Project | null;\n  selectedSession: ProjectSession | null;\n  isLoading: boolean;\n  loadingProgress: LoadingProgress | null;\n  expandedProjects: Set<string>;\n  editingProject: string | null;\n  editingName: string;\n  loadingSessions: LoadingSessionsByProject;\n  initialSessionsLoaded: Set<string>;\n  currentTime: Date;\n  editingSession: string | null;\n  editingSessionName: string;\n  deletingProjects: Set<string>;\n  tasksEnabled: boolean;\n  mcpServerStatus: MCPServerStatus;\n  getProjectSessions: (project: Project) => SessionWithProvider[];\n  isProjectStarred: (projectName: string) => boolean;\n  onEditingNameChange: (value: string) => void;\n  onToggleProject: (projectName: string) => void;\n  onProjectSelect: (project: Project) => void;\n  onToggleStarProject: (projectName: string) => void;\n  onStartEditingProject: (project: Project) => void;\n  onCancelEditingProject: () => void;\n  onSaveProjectName: (projectName: string) => void;\n  onDeleteProject: (project: Project) => void;\n  onSessionSelect: (session: SessionWithProvider, projectName: string) => void;\n  onDeleteSession: (\n    projectName: string,\n    sessionId: string,\n    sessionTitle: string,\n    provider: SessionProvider,\n  ) => void;\n  onLoadMoreSessions: (project: Project) => void;\n  onNewSession: (project: Project) => void;\n  onEditingSessionNameChange: (value: string) => void;\n  onStartEditingSession: (sessionId: string, initialName: string) => void;\n  onCancelEditingSession: () => void;\n  onSaveEditingSession: (projectName: string, sessionId: string, summary: string, provider: SessionProvider) => void;\n  t: TFunction;\n};\n\nexport default function SidebarProjectList({\n  projects,\n  filteredProjects,\n  selectedProject,\n  selectedSession,\n  isLoading,\n  loadingProgress,\n  expandedProjects,\n  editingProject,\n  editingName,\n  loadingSessions,\n  initialSessionsLoaded,\n  currentTime,\n  editingSession,\n  editingSessionName,\n  deletingProjects,\n  tasksEnabled,\n  mcpServerStatus,\n  getProjectSessions,\n  isProjectStarred,\n  onEditingNameChange,\n  onToggleProject,\n  onProjectSelect,\n  onToggleStarProject,\n  onStartEditingProject,\n  onCancelEditingProject,\n  onSaveProjectName,\n  onDeleteProject,\n  onSessionSelect,\n  onDeleteSession,\n  onLoadMoreSessions,\n  onNewSession,\n  onEditingSessionNameChange,\n  onStartEditingSession,\n  onCancelEditingSession,\n  onSaveEditingSession,\n  t,\n}: SidebarProjectListProps) {\n  const state = (\n    <SidebarProjectsState\n      isLoading={isLoading}\n      loadingProgress={loadingProgress}\n      projectsCount={projects.length}\n      filteredProjectsCount={filteredProjects.length}\n      t={t}\n    />\n  );\n\n  useEffect(() => {\n    let baseTitle = 'CloudCLI UI';\n    const displayName = selectedProject?.displayName?.trim();\n    if (displayName) {\n      baseTitle = `${displayName} - ${baseTitle}`;\n    }\n    document.title = baseTitle;\n  }, [selectedProject]);\n\n  const showProjects = !isLoading && projects.length > 0 && filteredProjects.length > 0;\n\n  return (\n    <div className=\"pb-safe-area-inset-bottom md:space-y-1\">\n      {!showProjects\n        ? state\n        : filteredProjects.map((project) => (\n            <SidebarProjectItem\n              key={project.name}\n              project={project}\n              selectedProject={selectedProject}\n              selectedSession={selectedSession}\n              isExpanded={expandedProjects.has(project.name)}\n              isDeleting={deletingProjects.has(project.name)}\n              isStarred={isProjectStarred(project.name)}\n              editingProject={editingProject}\n              editingName={editingName}\n              sessions={getProjectSessions(project)}\n              initialSessionsLoaded={initialSessionsLoaded.has(project.name)}\n              isLoadingSessions={Boolean(loadingSessions[project.name])}\n              currentTime={currentTime}\n              editingSession={editingSession}\n              editingSessionName={editingSessionName}\n              tasksEnabled={tasksEnabled}\n              mcpServerStatus={mcpServerStatus}\n              onEditingNameChange={onEditingNameChange}\n              onToggleProject={onToggleProject}\n              onProjectSelect={onProjectSelect}\n              onToggleStarProject={onToggleStarProject}\n              onStartEditingProject={onStartEditingProject}\n              onCancelEditingProject={onCancelEditingProject}\n              onSaveProjectName={onSaveProjectName}\n              onDeleteProject={onDeleteProject}\n              onSessionSelect={onSessionSelect}\n              onDeleteSession={onDeleteSession}\n              onLoadMoreSessions={onLoadMoreSessions}\n              onNewSession={onNewSession}\n              onEditingSessionNameChange={onEditingSessionNameChange}\n              onStartEditingSession={onStartEditingSession}\n              onCancelEditingSession={onCancelEditingSession}\n              onSaveEditingSession={onSaveEditingSession}\n              t={t}\n            />\n          ))}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/sidebar/view/subcomponents/SidebarProjectSessions.tsx",
    "content": "import { ChevronDown, Plus } from 'lucide-react';\nimport type { TFunction } from 'i18next';\nimport { Button } from '../../../../shared/view/ui';\nimport type { Project, ProjectSession, SessionProvider } from '../../../../types/app';\nimport type { SessionWithProvider } from '../../types/types';\nimport SidebarSessionItem from './SidebarSessionItem';\n\ntype SidebarProjectSessionsProps = {\n  project: Project;\n  isExpanded: boolean;\n  sessions: SessionWithProvider[];\n  selectedSession: ProjectSession | null;\n  initialSessionsLoaded: boolean;\n  isLoadingSessions: boolean;\n  currentTime: Date;\n  editingSession: string | null;\n  editingSessionName: string;\n  onEditingSessionNameChange: (value: string) => void;\n  onStartEditingSession: (sessionId: string, initialName: string) => void;\n  onCancelEditingSession: () => void;\n  onSaveEditingSession: (projectName: string, sessionId: string, summary: string, provider: SessionProvider) => void;\n  onProjectSelect: (project: Project) => void;\n  onSessionSelect: (session: SessionWithProvider, projectName: string) => void;\n  onDeleteSession: (\n    projectName: string,\n    sessionId: string,\n    sessionTitle: string,\n    provider: SessionProvider,\n  ) => void;\n  onLoadMoreSessions: (project: Project) => void;\n  onNewSession: (project: Project) => void;\n  t: TFunction;\n};\n\nfunction SessionListSkeleton() {\n  return (\n    <>\n      {Array.from({ length: 3 }).map((_, index) => (\n        <div key={index} className=\"rounded-md p-2\">\n          <div className=\"flex items-start gap-2\">\n            <div className=\"mt-0.5 h-3 w-3 animate-pulse rounded-full bg-muted\" />\n            <div className=\"flex-1 space-y-1\">\n              <div className=\"h-3 animate-pulse rounded bg-muted\" style={{ width: `${60 + index * 15}%` }} />\n              <div className=\"h-2 w-1/2 animate-pulse rounded bg-muted\" />\n            </div>\n          </div>\n        </div>\n      ))}\n    </>\n  );\n}\n\nexport default function SidebarProjectSessions({\n  project,\n  isExpanded,\n  sessions,\n  selectedSession,\n  initialSessionsLoaded,\n  isLoadingSessions,\n  currentTime,\n  editingSession,\n  editingSessionName,\n  onEditingSessionNameChange,\n  onStartEditingSession,\n  onCancelEditingSession,\n  onSaveEditingSession,\n  onProjectSelect,\n  onSessionSelect,\n  onDeleteSession,\n  onLoadMoreSessions,\n  onNewSession,\n  t,\n}: SidebarProjectSessionsProps) {\n  if (!isExpanded) {\n    return null;\n  }\n\n  const hasSessions = sessions.length > 0;\n  const hasMoreSessions = project.sessionMeta?.hasMore === true;\n\n  return (\n    <div className=\"ml-3 space-y-1 border-l border-border pl-3\">\n      {!initialSessionsLoaded ? (\n        <SessionListSkeleton />\n      ) : !hasSessions && !isLoadingSessions ? (\n        <div className=\"px-3 py-2 text-left\">\n          <p className=\"text-xs text-muted-foreground\">{t('sessions.noSessions')}</p>\n        </div>\n      ) : (\n        sessions.map((session) => (\n          <SidebarSessionItem\n            key={session.id}\n            project={project}\n            session={session}\n            selectedSession={selectedSession}\n            currentTime={currentTime}\n            editingSession={editingSession}\n            editingSessionName={editingSessionName}\n            onEditingSessionNameChange={onEditingSessionNameChange}\n            onStartEditingSession={onStartEditingSession}\n            onCancelEditingSession={onCancelEditingSession}\n            onSaveEditingSession={onSaveEditingSession}\n            onProjectSelect={onProjectSelect}\n            onSessionSelect={onSessionSelect}\n            onDeleteSession={onDeleteSession}\n            t={t}\n          />\n        ))\n      )}\n\n      {hasSessions && hasMoreSessions && (\n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          className=\"mt-2 w-full justify-center gap-2 text-muted-foreground\"\n          onClick={() => onLoadMoreSessions(project)}\n          disabled={isLoadingSessions}\n        >\n          {isLoadingSessions ? (\n            <>\n              <div className=\"h-3 w-3 animate-spin rounded-full border border-muted-foreground border-t-transparent\" />\n              {t('sessions.loading')}\n            </>\n          ) : (\n            <>\n              <ChevronDown className=\"h-3 w-3\" />\n              {t('sessions.showMore')}\n            </>\n          )}\n        </Button>\n      )}\n\n      <div className=\"px-3 pb-2 md:hidden\">\n        <button\n          className=\"flex h-8 w-full items-center justify-center gap-2 rounded-md bg-primary text-xs font-medium text-primary-foreground transition-all duration-150 hover:bg-primary/90 active:scale-[0.98]\"\n          onClick={() => {\n            onProjectSelect(project);\n            onNewSession(project);\n          }}\n        >\n          <Plus className=\"h-3 w-3\" />\n          {t('sessions.newSession')}\n        </button>\n      </div>\n\n      <Button\n        variant=\"default\"\n        size=\"sm\"\n        className=\"mt-1 hidden h-8 w-full justify-start gap-2 bg-primary text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90 md:flex\"\n        onClick={() => onNewSession(project)}\n      >\n        <Plus className=\"h-3 w-3\" />\n        {t('sessions.newSession')}\n      </Button>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/sidebar/view/subcomponents/SidebarProjectsState.tsx",
    "content": "import { Folder, Search } from 'lucide-react';\nimport type { TFunction } from 'i18next';\nimport type { LoadingProgress } from '../../../../types/app';\n\ntype SidebarProjectsStateProps = {\n  isLoading: boolean;\n  loadingProgress: LoadingProgress | null;\n  projectsCount: number;\n  filteredProjectsCount: number;\n  t: TFunction;\n};\n\nexport default function SidebarProjectsState({\n  isLoading,\n  loadingProgress,\n  projectsCount,\n  filteredProjectsCount,\n  t,\n}: SidebarProjectsStateProps) {\n  if (isLoading) {\n    return (\n      <div className=\"px-4 py-12 text-center md:py-8\">\n        <div className=\"mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-muted md:mb-3\">\n          <div className=\"h-6 w-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent\" />\n        </div>\n        <h3 className=\"mb-2 text-base font-medium text-foreground md:mb-1\">{t('projects.loadingProjects')}</h3>\n        {loadingProgress && loadingProgress.total > 0 ? (\n          <div className=\"space-y-2\">\n            <div className=\"h-2 w-full overflow-hidden rounded-full bg-muted\">\n              <div\n                className=\"h-full bg-primary transition-all duration-300 ease-out\"\n                style={{ width: `${(loadingProgress.current / loadingProgress.total) * 100}%` }}\n              />\n            </div>\n            <p className=\"text-sm text-muted-foreground\">\n              {loadingProgress.current}/{loadingProgress.total} {t('projects.projects')}\n            </p>\n            {loadingProgress.currentProject && (\n              <p\n                className=\"mx-auto max-w-[200px] truncate text-xs text-muted-foreground/70\"\n                title={loadingProgress.currentProject}\n              >\n                {loadingProgress.currentProject.split('-').slice(-2).join('/')}\n              </p>\n            )}\n          </div>\n        ) : (\n          <p className=\"text-sm text-muted-foreground\">{t('projects.fetchingProjects')}</p>\n        )}\n      </div>\n    );\n  }\n\n  if (projectsCount === 0) {\n    return (\n      <div className=\"px-4 py-12 text-center md:py-8\">\n        <div className=\"mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-muted md:mb-3\">\n          <Folder className=\"h-6 w-6 text-muted-foreground\" />\n        </div>\n        <h3 className=\"mb-2 text-base font-medium text-foreground md:mb-1\">{t('projects.noProjects')}</h3>\n        <p className=\"text-sm text-muted-foreground\">{t('projects.runClaudeCli')}</p>\n      </div>\n    );\n  }\n\n  if (filteredProjectsCount === 0) {\n    return (\n      <div className=\"px-4 py-12 text-center md:py-8\">\n        <div className=\"mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-lg bg-muted md:mb-3\">\n          <Search className=\"h-6 w-6 text-muted-foreground\" />\n        </div>\n        <h3 className=\"mb-2 text-base font-medium text-foreground md:mb-1\">{t('projects.noMatchingProjects')}</h3>\n        <p className=\"text-sm text-muted-foreground\">{t('projects.tryDifferentSearch')}</p>\n      </div>\n    );\n  }\n\n  return null;\n}\n"
  },
  {
    "path": "src/components/sidebar/view/subcomponents/SidebarSessionItem.tsx",
    "content": "import { Check, Clock, Edit2, Trash2, X } from 'lucide-react';\nimport type { TFunction } from 'i18next';\nimport { Badge, Button } from '../../../../shared/view/ui';\nimport { cn } from '../../../../lib/utils';\nimport { formatTimeAgo } from '../../../../utils/dateUtils';\nimport type { Project, ProjectSession, SessionProvider } from '../../../../types/app';\nimport type { SessionWithProvider } from '../../types/types';\nimport { createSessionViewModel } from '../../utils/utils';\nimport SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';\n\ntype SidebarSessionItemProps = {\n  project: Project;\n  session: SessionWithProvider;\n  selectedSession: ProjectSession | null;\n  currentTime: Date;\n  editingSession: string | null;\n  editingSessionName: string;\n  onEditingSessionNameChange: (value: string) => void;\n  onStartEditingSession: (sessionId: string, initialName: string) => void;\n  onCancelEditingSession: () => void;\n  onSaveEditingSession: (projectName: string, sessionId: string, summary: string, provider: SessionProvider) => void;\n  onProjectSelect: (project: Project) => void;\n  onSessionSelect: (session: SessionWithProvider, projectName: string) => void;\n  onDeleteSession: (\n    projectName: string,\n    sessionId: string,\n    sessionTitle: string,\n    provider: SessionProvider,\n  ) => void;\n  t: TFunction;\n};\n\nexport default function SidebarSessionItem({\n  project,\n  session,\n  selectedSession,\n  currentTime,\n  editingSession,\n  editingSessionName,\n  onEditingSessionNameChange,\n  onStartEditingSession,\n  onCancelEditingSession,\n  onSaveEditingSession,\n  onProjectSelect,\n  onSessionSelect,\n  onDeleteSession,\n  t,\n}: SidebarSessionItemProps) {\n  const sessionView = createSessionViewModel(session, currentTime, t);\n  const isSelected = selectedSession?.id === session.id;\n\n  const selectMobileSession = () => {\n    onProjectSelect(project);\n    onSessionSelect(session, project.name);\n  };\n\n  const saveEditedSession = () => {\n    onSaveEditingSession(project.name, session.id, editingSessionName, session.__provider);\n  };\n\n  const requestDeleteSession = () => {\n    onDeleteSession(project.name, session.id, sessionView.sessionName, session.__provider);\n  };\n\n  return (\n    <div className=\"group relative\">\n      {sessionView.isActive && (\n        <div className=\"absolute left-0 top-1/2 -translate-x-1 -translate-y-1/2 transform\">\n          <div className=\"h-2 w-2 animate-pulse rounded-full bg-green-500\" />\n        </div>\n      )}\n\n      <div className=\"md:hidden\">\n        <div\n          className={cn(\n            'p-2 mx-3 my-0.5 rounded-md bg-card border active:scale-[0.98] transition-all duration-150 relative',\n            isSelected ? 'bg-primary/5 border-primary/20' : '',\n            !isSelected && sessionView.isActive\n              ? 'border-green-500/30 bg-green-50/5 dark:bg-green-900/5'\n              : 'border-border/30',\n          )}\n          onClick={selectMobileSession}\n        >\n          <div className=\"flex items-center gap-2\">\n            <div\n              className={cn(\n                'w-5 h-5 rounded-md flex items-center justify-center flex-shrink-0',\n                isSelected ? 'bg-primary/10' : 'bg-muted/50',\n              )}\n            >\n              <SessionProviderLogo provider={session.__provider} className=\"h-3 w-3\" />\n            </div>\n\n            <div className=\"min-w-0 flex-1\">\n              <div className=\"truncate text-xs font-medium text-foreground\">{sessionView.sessionName}</div>\n              <div className=\"mt-0.5 flex items-center gap-1\">\n                <Clock className=\"h-2.5 w-2.5 text-muted-foreground\" />\n                <span className=\"text-xs text-muted-foreground\">\n                  {formatTimeAgo(sessionView.sessionTime, currentTime, t)}\n                </span>\n                {sessionView.messageCount > 0 && (\n                  <Badge variant=\"secondary\" className=\"ml-auto px-1 py-0 text-xs\">\n                    {sessionView.messageCount}\n                  </Badge>\n                )}\n                <span className=\"ml-1 opacity-70\">\n                  <SessionProviderLogo provider={session.__provider} className=\"h-3 w-3\" />\n                </span>\n              </div>\n            </div>\n\n            {!sessionView.isCursorSession && (\n              <button\n                className=\"ml-1 flex h-5 w-5 items-center justify-center rounded-md bg-red-50 opacity-70 transition-transform active:scale-95 dark:bg-red-900/20\"\n                onClick={(event) => {\n                  event.stopPropagation();\n                  requestDeleteSession();\n                }}\n              >\n                <Trash2 className=\"h-2.5 w-2.5 text-red-600 dark:text-red-400\" />\n              </button>\n            )}\n          </div>\n        </div>\n      </div>\n\n      <div className=\"hidden md:block\">\n        <Button\n          variant=\"ghost\"\n          className={cn(\n            'w-full justify-start p-2 h-auto font-normal text-left hover:bg-accent/50 transition-colors duration-200',\n            isSelected && 'bg-accent text-accent-foreground',\n          )}\n          onClick={() => onSessionSelect(session, project.name)}\n        >\n          <div className=\"flex w-full min-w-0 items-start gap-2\">\n            <SessionProviderLogo provider={session.__provider} className=\"mt-0.5 h-3 w-3 flex-shrink-0\" />\n            <div className=\"min-w-0 flex-1\">\n              <div className=\"truncate text-xs font-medium text-foreground\">{sessionView.sessionName}</div>\n              <div className=\"mt-0.5 flex items-center gap-1\">\n                <Clock className=\"h-2.5 w-2.5 text-muted-foreground\" />\n                <span className=\"text-xs text-muted-foreground\">\n                  {formatTimeAgo(sessionView.sessionTime, currentTime, t)}\n                </span>\n                {sessionView.messageCount > 0 && (\n                  <Badge\n                    variant=\"secondary\"\n                    className=\"ml-auto px-1 py-0 text-xs transition-opacity group-hover:opacity-0\"\n                  >\n                    {sessionView.messageCount}\n                  </Badge>\n                )}\n                <span className=\"ml-1 opacity-70 transition-opacity group-hover:opacity-0\">\n                  <SessionProviderLogo provider={session.__provider} className=\"h-3 w-3\" />\n                </span>\n              </div>\n            </div>\n          </div>\n        </Button>\n\n        <div className=\"absolute right-2 top-1/2 flex -translate-y-1/2 transform items-center gap-1 opacity-0 transition-all duration-200 group-hover:opacity-100\">\n            {editingSession === session.id ? (\n              <>\n                <input\n                  type=\"text\"\n                  value={editingSessionName}\n                  onChange={(event) => onEditingSessionNameChange(event.target.value)}\n                  onKeyDown={(event) => {\n                    event.stopPropagation();\n                    if (event.key === 'Enter') {\n                      saveEditedSession();\n                    } else if (event.key === 'Escape') {\n                      onCancelEditingSession();\n                    }\n                  }}\n                  onClick={(event) => event.stopPropagation()}\n                  className=\"w-32 rounded border border-border bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-primary\"\n                  autoFocus\n                />\n                <button\n                  className=\"flex h-6 w-6 items-center justify-center rounded bg-green-50 hover:bg-green-100 dark:bg-green-900/20 dark:hover:bg-green-900/40\"\n                  onClick={(event) => {\n                    event.stopPropagation();\n                    saveEditedSession();\n                  }}\n                  title={t('tooltips.save')}\n                >\n                  <Check className=\"h-3 w-3 text-green-600 dark:text-green-400\" />\n                </button>\n                <button\n                  className=\"flex h-6 w-6 items-center justify-center rounded bg-gray-50 hover:bg-gray-100 dark:bg-gray-900/20 dark:hover:bg-gray-900/40\"\n                  onClick={(event) => {\n                    event.stopPropagation();\n                    onCancelEditingSession();\n                  }}\n                  title={t('tooltips.cancel')}\n                >\n                  <X className=\"h-3 w-3 text-gray-600 dark:text-gray-400\" />\n                </button>\n              </>\n            ) : (\n              <>\n                <button\n                  className=\"flex h-6 w-6 items-center justify-center rounded bg-gray-50 hover:bg-gray-100 dark:bg-gray-900/20 dark:hover:bg-gray-900/40\"\n                  onClick={(event) => {\n                    event.stopPropagation();\n                    onStartEditingSession(session.id, sessionView.sessionName);\n                  }}\n                  title={t('tooltips.editSessionName')}\n                >\n                  <Edit2 className=\"h-3 w-3 text-gray-600 dark:text-gray-400\" />\n                </button>\n                {!sessionView.isCursorSession && (\n                  <button\n                    className=\"flex h-6 w-6 items-center justify-center rounded bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/40\"\n                    onClick={(event) => {\n                      event.stopPropagation();\n                      requestDeleteSession();\n                    }}\n                    title={t('tooltips.deleteSession')}\n                  >\n                    <Trash2 className=\"h-3 w-3 text-red-600 dark:text-red-400\" />\n                  </button>\n                )}\n              </>\n            )}\n          </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/sidebar/view/subcomponents/TaskIndicator.tsx",
    "content": "import { AlertCircle, CheckCircle, Settings, X } from 'lucide-react';\nimport type { LucideIcon } from 'lucide-react';\nimport { cn } from '../../../../lib/utils';\n\ntype TaskIndicatorStatus =\n  | 'fully-configured'\n  | 'taskmaster-only'\n  | 'mcp-only'\n  | 'not-configured'\n  | 'error';\n\ntype TaskIndicatorSize = 'xs' | 'sm' | 'md' | 'lg';\n\ntype TaskIndicatorProps = {\n  status?: TaskIndicatorStatus;\n  size?: TaskIndicatorSize;\n  className?: string;\n  showLabel?: boolean;\n};\n\ntype IndicatorConfig = {\n  icon: LucideIcon;\n  colorClassName: string;\n  backgroundClassName: string;\n  label: string;\n  title: string;\n};\n\nconst sizeClassNames: Record<TaskIndicatorSize, string> = {\n  xs: 'w-3 h-3',\n  sm: 'w-4 h-4',\n  md: 'w-5 h-5',\n  lg: 'w-6 h-6',\n};\n\nconst paddingClassNames: Record<TaskIndicatorSize, string> = {\n  xs: 'p-0.5',\n  sm: 'p-1',\n  md: 'p-1.5',\n  lg: 'p-2',\n};\n\nconst getIndicatorConfig = (status: TaskIndicatorStatus): IndicatorConfig => {\n  // Keep color and label mapping centralized so status display remains consistent in sidebar UIs.\n  if (status === 'fully-configured') {\n    return {\n      icon: CheckCircle,\n      colorClassName: 'text-green-500 dark:text-green-400',\n      backgroundClassName: 'bg-green-50 dark:bg-green-950',\n      label: 'TaskMaster Ready',\n      title: 'TaskMaster fully configured with MCP server',\n    };\n  }\n\n  if (status === 'taskmaster-only') {\n    return {\n      icon: Settings,\n      colorClassName: 'text-blue-500 dark:text-blue-400',\n      backgroundClassName: 'bg-blue-50 dark:bg-blue-950',\n      label: 'TaskMaster Init',\n      title: 'TaskMaster initialized, MCP server needs setup',\n    };\n  }\n\n  if (status === 'mcp-only') {\n    return {\n      icon: AlertCircle,\n      colorClassName: 'text-amber-500 dark:text-amber-400',\n      backgroundClassName: 'bg-amber-50 dark:bg-amber-950',\n      label: 'MCP Ready',\n      title: 'MCP server configured, TaskMaster needs initialization',\n    };\n  }\n\n  return {\n    icon: X,\n    colorClassName: 'text-gray-400 dark:text-gray-500',\n    backgroundClassName: 'bg-gray-50 dark:bg-gray-900',\n    label: 'No TaskMaster',\n    title: 'TaskMaster not configured',\n  };\n};\n\nexport default function TaskIndicator({\n  status = 'not-configured',\n  size = 'sm',\n  className = '',\n  showLabel = false,\n}: TaskIndicatorProps) {\n  const indicatorConfig = getIndicatorConfig(status);\n  const Icon = indicatorConfig.icon;\n\n  if (showLabel) {\n    return (\n      <div\n        className={cn(\n          'inline-flex items-center gap-1.5 text-xs rounded-md px-2 py-1 transition-colors',\n          indicatorConfig.backgroundClassName,\n          indicatorConfig.colorClassName,\n          className,\n        )}\n        title={indicatorConfig.title}\n      >\n        <Icon className={sizeClassNames[size]} />\n        <span className=\"font-medium\">{indicatorConfig.label}</span>\n      </div>\n    );\n  }\n\n  return (\n    <div\n      className={cn(\n        'inline-flex items-center justify-center rounded-full transition-colors',\n        indicatorConfig.backgroundClassName,\n        paddingClassNames[size],\n        className,\n      )}\n      title={indicatorConfig.title}\n    >\n      <Icon className={cn(sizeClassNames[size], indicatorConfig.colorClassName)} />\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/standalone-shell/view/StandaloneShell.tsx",
    "content": "import { useCallback, useState } from 'react';\nimport type { Project, ProjectSession } from '../../../types/app';\nimport Shell from '../../shell/view/Shell';\nimport StandaloneShellEmptyState from './subcomponents/StandaloneShellEmptyState';\nimport StandaloneShellHeader from './subcomponents/StandaloneShellHeader';\n\ntype StandaloneShellProps = {\n  project?: Project | null;\n  session?: ProjectSession | null;\n  command?: string | null;\n  isPlainShell?: boolean | null;\n  isActive?: boolean;\n  autoConnect?: boolean;\n  onComplete?: ((exitCode: number) => void) | null;\n  onClose?: (() => void) | null;\n  title?: string | null;\n  className?: string;\n  showHeader?: boolean;\n  compact?: boolean;\n  minimal?: boolean;\n};\n\nexport default function StandaloneShell({\n  project = null,\n  session = null,\n  command = null,\n  isPlainShell = null,\n  isActive = true,\n  autoConnect = true,\n  onComplete = null,\n  onClose = null,\n  title = null,\n  className = '',\n  showHeader = true,\n  compact = false,\n  minimal = false,\n}: StandaloneShellProps) {\n  const [isCompleted, setIsCompleted] = useState(false);\n\n  // Keep `compact` in the public API for compatibility with existing callers.\n  void compact;\n\n  const shouldUsePlainShell = isPlainShell !== null ? isPlainShell : command !== null;\n\n  const handleProcessComplete = useCallback(\n    (exitCode: number) => {\n      setIsCompleted(true);\n      onComplete?.(exitCode);\n    },\n    [onComplete],\n  );\n\n  if (!project) {\n    return <StandaloneShellEmptyState className={className} />;\n  }\n\n  return (\n    <div className={`flex h-full w-full flex-col ${className}`}>\n      {!minimal && showHeader && title && (\n        <StandaloneShellHeader title={title} isCompleted={isCompleted} onClose={onClose} />\n      )}\n\n      <div className=\"min-h-0 w-full flex-1\">\n        <Shell\n          selectedProject={project}\n          selectedSession={session}\n          initialCommand={command}\n          isPlainShell={shouldUsePlainShell}\n          isActive={isActive}\n          onProcessComplete={handleProcessComplete}\n          minimal={minimal}\n          autoConnect={minimal ? true : autoConnect}\n        />\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/standalone-shell/view/subcomponents/StandaloneShellEmptyState.tsx",
    "content": "type StandaloneShellEmptyStateProps = {\n  className: string;\n};\n\nexport default function StandaloneShellEmptyState({ className }: StandaloneShellEmptyStateProps) {\n  return (\n    <div className={`flex h-full items-center justify-center ${className}`}>\n      <div className=\"text-center text-gray-500 dark:text-gray-400\">\n        <div className=\"mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 dark:bg-gray-800\">\n          <svg className=\"h-8 w-8 text-gray-400\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n            <path\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              strokeWidth={2}\n              d=\"M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 002 2z\"\n            />\n          </svg>\n        </div>\n        <h3 className=\"mb-2 text-lg font-semibold\">No Project Selected</h3>\n        <p>A project is required to open a shell</p>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/standalone-shell/view/subcomponents/StandaloneShellHeader.tsx",
    "content": "type StandaloneShellHeaderProps = {\n  title: string;\n  isCompleted: boolean;\n  onClose?: (() => void) | null;\n};\n\nexport default function StandaloneShellHeader({\n  title,\n  isCompleted,\n  onClose = null,\n}: StandaloneShellHeaderProps) {\n  return (\n    <div className=\"flex-shrink-0 border-b border-gray-700 bg-gray-800 px-4 py-2\">\n      <div className=\"flex items-center justify-between\">\n        <div className=\"flex items-center space-x-2\">\n          <h3 className=\"text-sm font-medium text-gray-200\">{title}</h3>\n          {isCompleted && <span className=\"text-xs text-green-400\">(Completed)</span>}\n        </div>\n\n        {onClose && (\n          <button onClick={onClose} className=\"text-gray-400 hover:text-white\" title=\"Close\">\n            <svg className=\"h-4 w-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n              <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M6 18L18 6M6 6l12 12\" />\n            </svg>\n          </button>\n        )}\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/task-master/context/TaskMasterContext.tsx",
    "content": "import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';\nimport { api } from '../../../utils/api';\nimport { useAuth } from '../../auth/context/AuthContext';\nimport { useWebSocket } from '../../../contexts/WebSocketContext';\nimport type {\n  TaskMasterContextError,\n  TaskMasterContextValue,\n  TaskMasterMcpStatus,\n  TaskMasterProject,\n  TaskMasterProjectInfo,\n  TaskMasterProjectInput,\n  TaskMasterTask,\n  TaskMasterWebSocketMessage,\n} from '../types';\n\nconst TaskMasterContext = createContext<TaskMasterContextValue | null>(null);\n\nfunction createTaskMasterError(context: string, error: unknown): TaskMasterContextError {\n  const message = error instanceof Error ? error.message : `Failed to ${context}`;\n  return {\n    message,\n    context,\n    timestamp: new Date().toISOString(),\n  };\n}\n\nfunction enrichProject(project: TaskMasterProject): TaskMasterProject {\n  return {\n    ...project,\n    taskMasterConfigured: project.taskmaster?.hasTaskmaster ?? false,\n    taskMasterStatus: project.taskmaster?.status ?? 'not-configured',\n    taskCount: Number(project.taskmaster?.metadata?.taskCount ?? 0),\n    completedCount: Number(project.taskmaster?.metadata?.completed ?? 0),\n  };\n}\n\nfunction getNextTask(tasks: TaskMasterTask[]): TaskMasterTask | null {\n  return tasks.find((task) => task.status === 'pending' || task.status === 'in-progress') ?? null;\n}\n\nfunction isTaskMasterMessage(\n  message: TaskMasterWebSocketMessage | null,\n): message is TaskMasterWebSocketMessage & { type: string } {\n  if (!message?.type) {\n    return false;\n  }\n\n  return message.type.startsWith('taskmaster-');\n}\n\nexport function useTaskMaster() {\n  const context = useContext(TaskMasterContext);\n  if (!context) {\n    throw new Error('useTaskMaster must be used within a TaskMasterProvider');\n  }\n  return context;\n}\n\nexport function TaskMasterProvider({ children }: { children: React.ReactNode }) {\n  const { latestMessage } = useWebSocket();\n  const { user, token, isLoading: isAuthLoading } = useAuth();\n\n  const [projects, setProjects] = useState<TaskMasterProject[]>([]);\n  const [currentProject, setCurrentProjectState] = useState<TaskMasterProject | null>(null);\n  const [projectTaskMaster, setProjectTaskMaster] = useState<TaskMasterProjectInfo | null>(null);\n  const [mcpServerStatus, setMcpServerStatus] = useState<TaskMasterMcpStatus>(null);\n\n  const [tasks, setTasks] = useState<TaskMasterTask[]>([]);\n  const [nextTask, setNextTask] = useState<TaskMasterTask | null>(null);\n\n  const [isLoading, setIsLoading] = useState(false);\n  const [isLoadingTasks, setIsLoadingTasks] = useState(false);\n  const [isLoadingMCP, setIsLoadingMCP] = useState(false);\n  const [error, setError] = useState<TaskMasterContextError | null>(null);\n\n  const currentProjectNameRef = useRef<string | null>(null);\n\n  useEffect(() => {\n    currentProjectNameRef.current = currentProject?.name ?? null;\n  }, [currentProject?.name]);\n\n  const clearError = useCallback(() => {\n    setError(null);\n  }, []);\n\n  const handleError = useCallback((context: string, caughtError: unknown) => {\n    console.error(`TaskMaster ${context} error:`, caughtError);\n    setError(createTaskMasterError(context, caughtError));\n  }, []);\n\n  const setCurrentProject = useCallback((project: TaskMasterProjectInput) => {\n    const normalizedProject = project ? enrichProject(project as TaskMasterProject) : null;\n    setCurrentProjectState(normalizedProject);\n    setProjectTaskMaster(normalizedProject?.taskmaster ?? null);\n\n    // Project-scoped task data is reset immediately to avoid stale task rendering.\n    setTasks([]);\n    setNextTask(null);\n  }, []);\n\n  const refreshProjects = useCallback(async () => {\n    if (!user || !token) {\n      setProjects([]);\n      setCurrentProjectState(null);\n      setProjectTaskMaster(null);\n      setTasks([]);\n      setNextTask(null);\n      return;\n    }\n\n    try {\n      setIsLoading(true);\n      clearError();\n\n      const response = await api.get('/projects');\n      if (!response.ok) {\n        throw new Error(`Failed to fetch projects: ${response.status}`);\n      }\n\n      const data = (await response.json()) as unknown;\n      const loadedProjects = Array.isArray(data) ? (data as TaskMasterProject[]) : [];\n      const enrichedProjects = loadedProjects.map((project) => enrichProject(project));\n\n      setProjects(enrichedProjects);\n\n      const currentProjectName = currentProjectNameRef.current;\n      if (!currentProjectName) {\n        return;\n      }\n\n      const matchingProject = enrichedProjects.find((project) => project.name === currentProjectName) ?? null;\n      setCurrentProjectState(matchingProject);\n      setProjectTaskMaster(matchingProject?.taskmaster ?? null);\n    } catch (caughtError) {\n      handleError('load projects', caughtError);\n    } finally {\n      setIsLoading(false);\n    }\n  }, [clearError, handleError, token, user]);\n\n  const refreshTasks = useCallback(async () => {\n    const projectName = currentProject?.name;\n\n    if (!projectName || !user || !token) {\n      setTasks([]);\n      setNextTask(null);\n      return;\n    }\n\n    try {\n      setIsLoadingTasks(true);\n      clearError();\n\n      const response = await api.get(`/taskmaster/tasks/${encodeURIComponent(projectName)}`);\n      if (!response.ok) {\n        const errorPayload = (await response.json()) as { message?: string };\n        throw new Error(errorPayload.message ?? 'Failed to load tasks');\n      }\n\n      const data = (await response.json()) as { tasks?: TaskMasterTask[] };\n      const loadedTasks = Array.isArray(data.tasks) ? data.tasks : [];\n\n      setTasks(loadedTasks);\n      setNextTask(getNextTask(loadedTasks));\n    } catch (caughtError) {\n      handleError('load tasks', caughtError);\n      setTasks([]);\n      setNextTask(null);\n    } finally {\n      setIsLoadingTasks(false);\n    }\n  }, [clearError, currentProject?.name, handleError, token, user]);\n\n  const refreshMCPStatus = useCallback(async () => {\n    if (!user || !token) {\n      setMcpServerStatus(null);\n      return;\n    }\n\n    try {\n      setIsLoadingMCP(true);\n      clearError();\n\n      const response = await api.get('/mcp-utils/taskmaster-server');\n      if (!response.ok) {\n        throw new Error(`Failed to load MCP status: ${response.status}`);\n      }\n\n      const status = (await response.json()) as TaskMasterMcpStatus;\n      setMcpServerStatus(status);\n    } catch (caughtError) {\n      handleError('check MCP server status', caughtError);\n      setMcpServerStatus(null);\n    } finally {\n      setIsLoadingMCP(false);\n    }\n  }, [clearError, handleError, token, user]);\n\n  useEffect(() => {\n    if (!isAuthLoading && user && token) {\n      void refreshProjects();\n      void refreshMCPStatus();\n    }\n  }, [isAuthLoading, refreshMCPStatus, refreshProjects, token, user]);\n\n  useEffect(() => {\n    if (currentProject?.name && user && token) {\n      void refreshTasks();\n    }\n  }, [currentProject?.name, refreshTasks, token, user]);\n\n  useEffect(() => {\n    const message = latestMessage as TaskMasterWebSocketMessage | null;\n    if (!isTaskMasterMessage(message)) {\n      return;\n    }\n\n    if (message.type === 'taskmaster-project-updated' && message.projectName) {\n      void refreshProjects();\n      return;\n    }\n\n    if (message.type === 'taskmaster-tasks-updated' && message.projectName === currentProject?.name) {\n      void refreshTasks();\n      return;\n    }\n\n    if (message.type === 'taskmaster-mcp-status-changed') {\n      void refreshMCPStatus();\n    }\n  }, [currentProject?.name, latestMessage, refreshMCPStatus, refreshProjects, refreshTasks]);\n\n  const contextValue = useMemo<TaskMasterContextValue>(\n    () => ({\n      projects,\n      currentProject,\n      projectTaskMaster,\n      mcpServerStatus,\n      tasks,\n      nextTask,\n      isLoading,\n      isLoadingTasks,\n      isLoadingMCP,\n      error,\n      refreshProjects,\n      setCurrentProject,\n      refreshTasks,\n      refreshMCPStatus,\n      clearError,\n    }),\n    [\n      clearError,\n      currentProject,\n      error,\n      isLoading,\n      isLoadingMCP,\n      isLoadingTasks,\n      mcpServerStatus,\n      nextTask,\n      projectTaskMaster,\n      projects,\n      refreshMCPStatus,\n      refreshProjects,\n      refreshTasks,\n      setCurrentProject,\n      tasks,\n    ],\n  );\n\n  return <TaskMasterContext.Provider value={contextValue}>{children}</TaskMasterContext.Provider>;\n}\n\nexport default TaskMasterContext;\n"
  },
  {
    "path": "src/components/task-master/hooks/useProjectPrdFiles.ts",
    "content": "import { useCallback, useEffect, useState } from 'react';\nimport { api } from '../../../utils/api';\nimport type { PrdFile } from '../types';\n\ntype UseProjectPrdFilesOptions = {\n  projectName?: string;\n};\n\ntype PrdResponse = {\n  prdFiles?: PrdFile[];\n  prds?: PrdFile[];\n};\n\nfunction normalizePrdResponse(responseData: PrdResponse): PrdFile[] {\n  if (Array.isArray(responseData.prdFiles)) {\n    return responseData.prdFiles;\n  }\n\n  if (Array.isArray(responseData.prds)) {\n    return responseData.prds;\n  }\n\n  return [];\n}\n\nexport function useProjectPrdFiles({ projectName }: UseProjectPrdFilesOptions) {\n  const [prdFiles, setPrdFiles] = useState<PrdFile[]>([]);\n  const [isLoadingPrdFiles, setIsLoadingPrdFiles] = useState(false);\n\n  const refreshPrdFiles = useCallback(async () => {\n    if (!projectName) {\n      setPrdFiles([]);\n      return;\n    }\n\n    try {\n      setIsLoadingPrdFiles(true);\n      const response = await api.get(`/taskmaster/prd/${encodeURIComponent(projectName)}`);\n\n      if (!response.ok) {\n        setPrdFiles([]);\n        return;\n      }\n\n      const data = (await response.json()) as PrdResponse;\n      setPrdFiles(normalizePrdResponse(data));\n    } catch (error) {\n      console.error('Failed to load PRD files:', error);\n      setPrdFiles([]);\n    } finally {\n      setIsLoadingPrdFiles(false);\n    }\n  }, [projectName]);\n\n  useEffect(() => {\n    void refreshPrdFiles();\n  }, [refreshPrdFiles]);\n\n  return {\n    prdFiles,\n    isLoadingPrdFiles,\n    refreshPrdFiles,\n  };\n}\n"
  },
  {
    "path": "src/components/task-master/hooks/useTaskBoardState.ts",
    "content": "import { useMemo, useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport type { TaskBoardSortField, TaskBoardSortOrder, TaskBoardView, TaskKanbanColumn, TaskMasterTask } from '../types';\nimport { buildKanbanColumns } from '../utils/taskKanban';\nimport { sortTasks, toggleSortOrder } from '../utils/taskSorting';\n\ntype UseTaskBoardStateOptions = {\n  tasks: TaskMasterTask[];\n  defaultView?: TaskBoardView;\n};\n\nfunction matchesSearch(task: TaskMasterTask, searchTerm: string): boolean {\n  if (!searchTerm) {\n    return true;\n  }\n\n  const normalizedSearch = searchTerm.toLowerCase();\n  const description = typeof task.description === 'string' ? task.description : '';\n\n  return (\n    task.title.toLowerCase().includes(normalizedSearch)\n    || description.toLowerCase().includes(normalizedSearch)\n    || String(task.id).toLowerCase().includes(normalizedSearch)\n  );\n}\n\nexport function useTaskBoardState({ tasks, defaultView = 'kanban' }: UseTaskBoardStateOptions) {\n  const { t } = useTranslation('tasks');\n\n  const [searchTerm, setSearchTerm] = useState('');\n  const [statusFilter, setStatusFilter] = useState('all');\n  const [priorityFilter, setPriorityFilter] = useState('all');\n  const [sortField, setSortField] = useState<TaskBoardSortField>('id');\n  const [sortOrder, setSortOrder] = useState<TaskBoardSortOrder>('asc');\n  const [viewMode, setViewMode] = useState<TaskBoardView>(defaultView);\n  const [showFilters, setShowFilters] = useState(false);\n\n  const statuses = useMemo(() => {\n    return [...new Set(tasks.map((task) => task.status).filter(Boolean))] as string[];\n  }, [tasks]);\n\n  const priorities = useMemo(() => {\n    return [...new Set(tasks.map((task) => task.priority).filter(Boolean))] as string[];\n  }, [tasks]);\n\n  const filteredTasks = useMemo(() => {\n    const filtered = tasks.filter((task) => {\n      const status = task.status ?? 'pending';\n      const priority = task.priority ?? 'medium';\n\n      const matchesStatus = statusFilter === 'all' || status === statusFilter;\n      const matchesPriority = priorityFilter === 'all' || priority === priorityFilter;\n\n      return matchesSearch(task, searchTerm) && matchesStatus && matchesPriority;\n    });\n\n    return sortTasks(filtered, sortField, sortOrder);\n  }, [tasks, searchTerm, statusFilter, priorityFilter, sortField, sortOrder]);\n\n  const kanbanColumns = useMemo<TaskKanbanColumn[]>(() => {\n    return buildKanbanColumns(filteredTasks, t);\n  }, [filteredTasks, t]);\n\n  const handleSortChange = (nextSortField: TaskBoardSortField) => {\n    setSortOrder((currentOrder) => toggleSortOrder(sortField, currentOrder, nextSortField));\n    setSortField(nextSortField);\n  };\n\n  const clearFilters = () => {\n    setSearchTerm('');\n    setStatusFilter('all');\n    setPriorityFilter('all');\n  };\n\n  return {\n    searchTerm,\n    setSearchTerm,\n    statusFilter,\n    setStatusFilter,\n    priorityFilter,\n    setPriorityFilter,\n    sortField,\n    setSortField,\n    sortOrder,\n    setSortOrder,\n    viewMode,\n    setViewMode,\n    showFilters,\n    setShowFilters,\n    statuses,\n    priorities,\n    filteredTasks,\n    kanbanColumns,\n    handleSortChange,\n    clearFilters,\n  };\n}\n"
  },
  {
    "path": "src/components/task-master/index.ts",
    "content": "export { default as TaskMasterPanel } from './view/TaskMasterPanel';\nexport { default as NextTaskBanner } from './view/NextTaskBanner';\n\nexport { TaskMasterProvider, useTaskMaster } from './context/TaskMasterContext';"
  },
  {
    "path": "src/components/task-master/types.ts",
    "content": "import type { Project } from '../../types/app';\n\nexport type TaskId = string | number;\n\nexport type TaskStatus =\n  | 'pending'\n  | 'in-progress'\n  | 'done'\n  | 'review'\n  | 'blocked'\n  | 'deferred'\n  | 'cancelled'\n  | string;\n\nexport type TaskPriority = 'high' | 'medium' | 'low' | string;\n\nexport type TaskMasterTask = {\n  id: TaskId;\n  title: string;\n  description?: string;\n  status?: TaskStatus;\n  priority?: TaskPriority;\n  details?: string;\n  testStrategy?: string;\n  parentId?: TaskId;\n  dependencies?: TaskId[];\n  subtasks?: TaskMasterTask[];\n  createdAt?: string;\n  updatedAt?: string;\n  [key: string]: unknown;\n};\n\nexport type TaskReference = {\n  id: TaskId;\n  title?: string;\n  [key: string]: unknown;\n};\n\nexport type TaskSelection = TaskMasterTask | TaskReference;\n\nexport type PrdFile = {\n  name: string;\n  content?: string;\n  isExisting?: boolean;\n  modified?: string;\n  created?: string;\n  path?: string;\n  size?: number;\n  [key: string]: unknown;\n};\n\nexport type TaskMasterProjectInfo = {\n  hasTaskmaster?: boolean;\n  status?: string;\n  metadata?: Record<string, unknown>;\n  [key: string]: unknown;\n};\n\nexport type TaskMasterProject = Project & {\n  taskMasterConfigured?: boolean;\n  taskMasterStatus?: string;\n  taskCount?: number;\n  completedCount?: number;\n  taskmaster?: TaskMasterProjectInfo;\n};\n\nexport type TaskMasterProjectInput = TaskMasterProject | Project | null;\n\nexport type TaskMasterContextError = {\n  message: string;\n  context: string;\n  timestamp: string;\n};\n\nexport type TaskMasterMcpStatus = {\n  hasMCPServer?: boolean;\n  isConfigured?: boolean;\n  hasApiKeys?: boolean;\n  scope?: string;\n  config?: {\n    command?: string;\n    args?: string[];\n    url?: string;\n    envVars?: string[];\n    type?: string;\n  };\n  reason?: string;\n  [key: string]: unknown;\n} | null;\n\nexport type TaskMasterWebSocketMessage = {\n  type?: string;\n  projectName?: string;\n  [key: string]: unknown;\n};\n\nexport type TaskMasterContextValue = {\n  projects: TaskMasterProject[];\n  currentProject: TaskMasterProject | null;\n  projectTaskMaster: TaskMasterProjectInfo | null;\n  mcpServerStatus: TaskMasterMcpStatus;\n  tasks: TaskMasterTask[];\n  nextTask: TaskMasterTask | null;\n  isLoading: boolean;\n  isLoadingTasks: boolean;\n  isLoadingMCP: boolean;\n  error: TaskMasterContextError | null;\n  refreshProjects: () => Promise<void>;\n  setCurrentProject: (project: TaskMasterProjectInput) => void;\n  refreshTasks: () => Promise<void>;\n  refreshMCPStatus: () => Promise<void>;\n  clearError: () => void;\n};\n\nexport type TaskBoardView = 'kanban' | 'list' | 'grid';\n\nexport type TaskBoardSortField = 'id' | 'title' | 'status' | 'priority' | 'updated';\n\nexport type TaskBoardSortOrder = 'asc' | 'desc';\n\nexport type TaskKanbanColumn = {\n  id: string;\n  title: string;\n  status: string;\n  color: string;\n  headerColor: string;\n  tasks: TaskMasterTask[];\n};\n"
  },
  {
    "path": "src/components/task-master/utils/taskKanban.ts",
    "content": "import type { TFunction } from 'i18next';\nimport type { TaskKanbanColumn, TaskMasterTask } from '../types';\n\nconst KANBAN_COLUMN_CONFIG = [\n  {\n    id: 'pending',\n    titleKey: 'kanban.pending',\n    status: 'pending',\n    color: 'bg-slate-50 dark:bg-slate-900/50 border-slate-200 dark:border-slate-700',\n    headerColor: 'bg-slate-100 dark:bg-slate-800 text-slate-800 dark:text-slate-200',\n  },\n  {\n    id: 'in-progress',\n    titleKey: 'kanban.inProgress',\n    status: 'in-progress',\n    color: 'bg-blue-50 dark:bg-blue-900/50 border-blue-200 dark:border-blue-700',\n    headerColor: 'bg-blue-100 dark:bg-blue-800 text-blue-800 dark:text-blue-200',\n  },\n  {\n    id: 'done',\n    titleKey: 'kanban.done',\n    status: 'done',\n    color: 'bg-emerald-50 dark:bg-emerald-900/50 border-emerald-200 dark:border-emerald-700',\n    headerColor: 'bg-emerald-100 dark:bg-emerald-800 text-emerald-800 dark:text-emerald-200',\n  },\n  {\n    id: 'blocked',\n    titleKey: 'kanban.blocked',\n    status: 'blocked',\n    color: 'bg-red-50 dark:bg-red-900/50 border-red-200 dark:border-red-700',\n    headerColor: 'bg-red-100 dark:bg-red-800 text-red-800 dark:text-red-200',\n  },\n  {\n    id: 'deferred',\n    titleKey: 'kanban.deferred',\n    status: 'deferred',\n    color: 'bg-amber-50 dark:bg-amber-900/50 border-amber-200 dark:border-amber-700',\n    headerColor: 'bg-amber-100 dark:bg-amber-800 text-amber-800 dark:text-amber-200',\n  },\n  {\n    id: 'cancelled',\n    titleKey: 'kanban.cancelled',\n    status: 'cancelled',\n    color: 'bg-gray-50 dark:bg-gray-900/50 border-gray-200 dark:border-gray-700',\n    headerColor: 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200',\n  },\n] as const;\n\nconst CORE_WORKFLOW_STATUSES = new Set(['pending', 'in-progress', 'done']);\n\nexport function buildKanbanColumns(tasks: TaskMasterTask[], t: TFunction<'tasks'>): TaskKanbanColumn[] {\n  const tasksByStatus = tasks.reduce<Record<string, TaskMasterTask[]>>((accumulator, task) => {\n    const status = task.status ?? 'pending';\n    if (!accumulator[status]) {\n      accumulator[status] = [];\n    }\n    accumulator[status].push(task);\n    return accumulator;\n  }, {});\n\n  return KANBAN_COLUMN_CONFIG.filter((column) => {\n    const hasTasks = (tasksByStatus[column.status] ?? []).length > 0;\n    return hasTasks || CORE_WORKFLOW_STATUSES.has(column.status);\n  }).map((column) => ({\n    id: column.id,\n    title: t(column.titleKey),\n    status: column.status,\n    color: column.color,\n    headerColor: column.headerColor,\n    tasks: tasksByStatus[column.status] ?? [],\n  }));\n}\n"
  },
  {
    "path": "src/components/task-master/utils/taskSorting.ts",
    "content": "import type { TaskBoardSortField, TaskBoardSortOrder, TaskMasterTask } from '../types';\n\nconst STATUS_ORDER: Record<string, number> = {\n  pending: 1,\n  'in-progress': 2,\n  review: 3,\n  done: 4,\n  blocked: 5,\n  deferred: 6,\n  cancelled: 7,\n};\n\nconst PRIORITY_ORDER: Record<string, number> = {\n  low: 1,\n  medium: 2,\n  high: 3,\n};\n\nfunction toComparableIdParts(taskId: string | number): number[] {\n  return String(taskId)\n    .split('.')\n    .map((part) => Number.parseInt(part, 10))\n    .map((part) => (Number.isNaN(part) ? 0 : part));\n}\n\nfunction compareTaskIds(leftId: string | number, rightId: string | number): number {\n  const leftParts = toComparableIdParts(leftId);\n  const rightParts = toComparableIdParts(rightId);\n  const maxDepth = Math.max(leftParts.length, rightParts.length);\n\n  for (let index = 0; index < maxDepth; index += 1) {\n    const left = leftParts[index] ?? 0;\n    const right = rightParts[index] ?? 0;\n    if (left !== right) {\n      return left - right;\n    }\n  }\n\n  return 0;\n}\n\nfunction getSortValue(task: TaskMasterTask, field: TaskBoardSortField): number | string {\n  if (field === 'title') {\n    return task.title.toLowerCase();\n  }\n\n  if (field === 'status') {\n    return STATUS_ORDER[task.status ?? 'pending'] ?? 999;\n  }\n\n  if (field === 'priority') {\n    return PRIORITY_ORDER[task.priority ?? 'medium'] ?? 0;\n  }\n\n  if (field === 'updated') {\n    const timestamp = task.updatedAt ?? task.createdAt ?? '';\n    return new Date(timestamp).getTime() || 0;\n  }\n\n  return 0;\n}\n\nexport function sortTasks(\n  tasks: TaskMasterTask[],\n  field: TaskBoardSortField,\n  order: TaskBoardSortOrder,\n): TaskMasterTask[] {\n  const sortedTasks = [...tasks];\n\n  sortedTasks.sort((leftTask, rightTask) => {\n    const direction = order === 'asc' ? 1 : -1;\n\n    if (field === 'id') {\n      return compareTaskIds(leftTask.id, rightTask.id) * direction;\n    }\n\n    const leftValue = getSortValue(leftTask, field);\n    const rightValue = getSortValue(rightTask, field);\n\n    if (typeof leftValue === 'string' && typeof rightValue === 'string') {\n      return leftValue.localeCompare(rightValue) * direction;\n    }\n\n    return (Number(leftValue) - Number(rightValue)) * direction;\n  });\n\n  return sortedTasks;\n}\n\nexport function toggleSortOrder(\n  currentField: TaskBoardSortField,\n  currentOrder: TaskBoardSortOrder,\n  nextField: TaskBoardSortField,\n): TaskBoardSortOrder {\n  if (currentField !== nextField) {\n    return 'asc';\n  }\n\n  return currentOrder === 'asc' ? 'desc' : 'asc';\n}\n"
  },
  {
    "path": "src/components/task-master/view/NextTaskBanner.tsx",
    "content": "import { useState } from 'react';\nimport {\n  CheckCircle,\n  Circle,\n  Eye,\n  Flag,\n  List,\n  Play,\n  Settings,\n  Target,\n  Terminal,\n  Zap,\n} from 'lucide-react';\nimport { cn } from '../../../lib/utils';\nimport { useTaskMaster } from '../context/TaskMasterContext';\nimport TaskDetailModal from './TaskDetailModal';\nimport TaskMasterSetupModal from './modals/TaskMasterSetupModal';\n\ntype NextTaskBannerProps = {\n  onShowAllTasks?: (() => void) | null;\n  onStartTask?: (() => void) | null;\n  className?: string;\n};\n\nfunction PriorityIndicator({ priority }: { priority?: string }) {\n  if (priority === 'high') {\n    return (\n      <div className=\"flex h-4 w-4 items-center justify-center rounded bg-red-100 dark:bg-red-900/50\" title=\"High Priority\">\n        <Zap className=\"h-2.5 w-2.5 text-red-600 dark:text-red-400\" />\n      </div>\n    );\n  }\n\n  if (priority === 'medium') {\n    return (\n      <div className=\"flex h-4 w-4 items-center justify-center rounded bg-amber-100 dark:bg-amber-900/50\" title=\"Medium Priority\">\n        <Flag className=\"h-2.5 w-2.5 text-amber-600 dark:text-amber-400\" />\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"flex h-4 w-4 items-center justify-center rounded bg-gray-100 dark:bg-gray-800\" title=\"Low Priority\">\n      <Circle className=\"h-2.5 w-2.5 text-gray-400 dark:text-gray-500\" />\n    </div>\n  );\n}\n\nexport default function NextTaskBanner({ onShowAllTasks = null, onStartTask = null, className = '' }: NextTaskBannerProps) {\n  const {\n    nextTask,\n    tasks,\n    currentProject,\n    isLoadingTasks,\n    projectTaskMaster,\n    refreshTasks,\n    refreshProjects,\n    setCurrentProject,\n  } = useTaskMaster();\n\n  const [showTaskDetail, setShowTaskDetail] = useState(false);\n  const [showSetupModal, setShowSetupModal] = useState(false);\n  const [showSetupDetails, setShowSetupDetails] = useState(false);\n\n  if (!currentProject || isLoadingTasks) {\n    return null;\n  }\n\n  const hasTasks = Array.isArray(tasks) && tasks.length > 0;\n  const hasTaskMaster = Boolean(projectTaskMaster?.hasTaskmaster || currentProject.taskmaster?.hasTaskmaster);\n\n  const handleSetupRefresh = () => {\n    void refreshProjects();\n    setCurrentProject(currentProject);\n    void refreshTasks();\n  };\n\n  if (!hasTasks && !hasTaskMaster) {\n    return (\n      <>\n        <div className={cn('bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg p-3 mb-4', className)}>\n          <div className=\"flex items-center justify-between gap-3\">\n            <div className=\"flex min-w-0 items-center gap-2\">\n              <List className=\"h-4 w-4 text-blue-600 dark:text-blue-400\" />\n              <p className=\"text-sm font-medium text-gray-900 dark:text-white\">TaskMaster AI is not configured</p>\n            </div>\n\n            <button\n              onClick={() => setShowSetupModal(true)}\n              className=\"flex items-center gap-1 rounded bg-blue-600 px-2 py-1 text-xs text-white transition-colors hover:bg-blue-700\"\n            >\n              <Terminal className=\"h-3 w-3\" />\n              Initialize\n            </button>\n          </div>\n\n          <button\n            onClick={() => setShowSetupDetails((current) => !current)}\n            className=\"mt-2 flex items-center gap-1 text-xs text-blue-700 hover:underline dark:text-blue-300\"\n          >\n            <Settings className=\"h-3 w-3\" />\n            {showSetupDetails ? 'Hide details' : 'What is TaskMaster?'}\n          </button>\n\n          {showSetupDetails && (\n            <div className=\"mt-3 space-y-1 text-xs text-blue-900 dark:text-blue-100\">\n              <p>- AI-powered task management with dependencies and subtasks.</p>\n              <p>- PRD-driven task generation for faster project bootstrapping.</p>\n              <p>- Kanban and list views for day-to-day execution.</p>\n            </div>\n          )}\n        </div>\n\n        <TaskMasterSetupModal\n          isOpen={showSetupModal}\n          project={currentProject}\n          onClose={() => setShowSetupModal(false)}\n          onAfterClose={handleSetupRefresh}\n        />\n      </>\n    );\n  }\n\n  if (nextTask) {\n    return (\n      <>\n        <div className={cn('bg-slate-50 dark:bg-slate-900/30 border border-slate-200 dark:border-slate-700 rounded-lg p-3 mb-4', className)}>\n          <div className=\"flex items-center justify-between gap-3\">\n            <div className=\"min-w-0 flex-1\">\n              <div className=\"mb-1 flex items-center gap-2\">\n                <div className=\"flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/50\">\n                  <Target className=\"h-3 w-3 text-blue-600 dark:text-blue-400\" />\n                </div>\n                <span className=\"text-xs font-medium text-slate-600 dark:text-slate-400\">Task {nextTask.id}</span>\n                <PriorityIndicator priority={nextTask.priority} />\n              </div>\n              <p className=\"line-clamp-1 text-sm font-medium text-slate-900 dark:text-slate-100\">{nextTask.title}</p>\n            </div>\n\n            <div className=\"flex flex-shrink-0 items-center gap-1\">\n              <button\n                onClick={() => onStartTask?.()}\n                className=\"flex items-center gap-1 rounded-md bg-blue-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-700\"\n              >\n                <Play className=\"h-3 w-3\" />\n                Start Task\n              </button>\n\n              <button\n                onClick={() => setShowTaskDetail(true)}\n                className=\"rounded-md border border-slate-300 px-2 py-1.5 text-xs text-slate-600 hover:bg-slate-100 dark:border-slate-600 dark:text-slate-300 dark:hover:bg-slate-800\"\n                title=\"View task details\"\n              >\n                <Eye className=\"h-3 w-3\" />\n              </button>\n\n              {onShowAllTasks && (\n                <button\n                  onClick={onShowAllTasks}\n                  className=\"rounded-md border border-slate-300 px-2 py-1.5 text-xs text-slate-600 hover:bg-slate-100 dark:border-slate-600 dark:text-slate-300 dark:hover:bg-slate-800\"\n                  title=\"View all tasks\"\n                >\n                  <List className=\"h-3 w-3\" />\n                </button>\n              )}\n            </div>\n          </div>\n        </div>\n\n        <TaskDetailModal\n          task={nextTask}\n          isOpen={showTaskDetail}\n          onClose={() => setShowTaskDetail(false)}\n          onStatusChange={() => {\n            void refreshTasks();\n          }}\n        />\n      </>\n    );\n  }\n\n  if (hasTasks) {\n    const completedTasks = tasks.filter((task) => task.status === 'done').length;\n\n    return (\n      <div className={cn('bg-purple-50 dark:bg-purple-950 border border-purple-200 dark:border-purple-800 rounded-lg p-3 mb-4', className)}>\n        <div className=\"flex items-center justify-between\">\n          <div className=\"flex items-center gap-2\">\n            <CheckCircle className=\"h-4 w-4 text-purple-600 dark:text-purple-400\" />\n            <span className=\"text-sm font-medium text-gray-900 dark:text-white\">\n              {completedTasks === tasks.length ? 'All tasks complete' : 'No pending tasks'}\n            </span>\n          </div>\n          <div className=\"flex items-center gap-2\">\n            <span className=\"text-xs text-gray-600 dark:text-gray-400\">\n              {completedTasks}/{tasks.length}\n            </span>\n            {onShowAllTasks && (\n              <button\n                onClick={onShowAllTasks}\n                className=\"rounded bg-purple-600 px-2 py-1 text-xs text-white transition-colors hover:bg-purple-700\"\n              >\n                Review\n              </button>\n            )}\n          </div>\n        </div>\n      </div>\n    );\n  }\n\n  return null;\n}\n"
  },
  {
    "path": "src/components/task-master/view/TaskBoard.tsx",
    "content": "import { useState } from 'react';\nimport { cn } from '../../../lib/utils';\nimport { api } from '../../../utils/api';\nimport { useTaskMaster } from '../context/TaskMasterContext';\nimport { useTaskBoardState } from '../hooks/useTaskBoardState';\nimport type { PrdFile, TaskBoardView, TaskMasterProject, TaskMasterTask, TaskSelection } from '../types';\nimport TaskBoardContent from './TaskBoardContent';\nimport TaskBoardToolbar from './TaskBoardToolbar';\nimport TaskEmptyState from './TaskEmptyState';\nimport CreateTaskModal from './modals/CreateTaskModal';\nimport TaskHelpModal from './modals/TaskHelpModal';\nimport TaskMasterSetupModal from './modals/TaskMasterSetupModal';\n\ntype TaskBoardProps = {\n  tasks?: TaskMasterTask[];\n  onTaskClick?: ((task: TaskSelection) => void) | null;\n  className?: string;\n  showParentTasks?: boolean;\n  defaultView?: TaskBoardView;\n  currentProject?: TaskMasterProject | null;\n  onTaskCreated?: (() => void) | null;\n  onShowPRDEditor?: ((file?: PrdFile) => void) | null;\n  existingPRDs?: PrdFile[];\n  onRefreshPRDs?: ((showNotification?: boolean) => void) | null;\n};\n\nexport default function TaskBoard({\n  tasks = [],\n  onTaskClick = null,\n  className = '',\n  showParentTasks = false,\n  defaultView = 'kanban',\n  currentProject = null,\n  onTaskCreated = null,\n  onShowPRDEditor = null,\n  existingPRDs = [],\n  onRefreshPRDs = null,\n}: TaskBoardProps) {\n  const { projectTaskMaster, refreshProjects, refreshTasks, setCurrentProject } = useTaskMaster();\n\n  const [showCreateModal, setShowCreateModal] = useState(false);\n  const [showHelpModal, setShowHelpModal] = useState(false);\n  const [showSetupModal, setShowSetupModal] = useState(false);\n\n  const {\n    searchTerm,\n    setSearchTerm,\n    statusFilter,\n    setStatusFilter,\n    priorityFilter,\n    setPriorityFilter,\n    sortField,\n    setSortField,\n    sortOrder,\n    setSortOrder,\n    viewMode,\n    setViewMode,\n    showFilters,\n    setShowFilters,\n    statuses,\n    priorities,\n    filteredTasks,\n    kanbanColumns,\n    handleSortChange,\n    clearFilters,\n  } = useTaskBoardState({ tasks, defaultView });\n\n  const hasTaskMasterDirectory = Boolean(\n    currentProject?.taskMasterConfigured\n      || currentProject?.taskmaster?.hasTaskmaster\n      || projectTaskMaster?.hasTaskmaster,\n  );\n\n  const loadPrdAndOpenEditor = async (prd: PrdFile) => {\n    if (!currentProject?.name) {\n      return;\n    }\n\n    try {\n      const response = await api.get(\n        `/taskmaster/prd/${encodeURIComponent(currentProject.name)}/${encodeURIComponent(prd.name)}`,\n      );\n\n      if (!response.ok) {\n        throw new Error(`Failed to load PRD ${prd.name}`);\n      }\n\n      const data = (await response.json()) as { content?: string };\n      onShowPRDEditor?.({\n        name: prd.name,\n        content: data.content ?? '',\n        isExisting: true,\n      });\n    } catch (error) {\n      console.error('Failed to open PRD in editor:', error);\n    }\n  };\n\n  const refreshAfterSetup = () => {\n    void refreshProjects();\n    if (currentProject) {\n      setCurrentProject(currentProject);\n    }\n    void refreshTasks();\n    onRefreshPRDs?.(false);\n  };\n\n  if (tasks.length === 0) {\n    return (\n      <>\n        <TaskEmptyState\n          className={className}\n          hasTaskMasterDirectory={hasTaskMasterDirectory}\n          existingPrds={existingPRDs}\n          onOpenSetupModal={() => setShowSetupModal(true)}\n          onCreatePrd={() => onShowPRDEditor?.()}\n          onOpenPrd={(prd) => {\n            void loadPrdAndOpenEditor(prd);\n          }}\n        />\n\n        <TaskMasterSetupModal\n          isOpen={showSetupModal}\n          project={currentProject}\n          onClose={() => setShowSetupModal(false)}\n          onAfterClose={refreshAfterSetup}\n        />\n      </>\n    );\n  }\n\n  return (\n    <div className={cn('space-y-4', className)}>\n      <TaskBoardToolbar\n        hasProject={Boolean(currentProject)}\n        hasTaskMasterConfigured={hasTaskMasterDirectory}\n        totalTaskCount={tasks.length}\n        filteredTaskCount={filteredTasks.length}\n        searchTerm={searchTerm}\n        onSearchTermChange={setSearchTerm}\n        viewMode={viewMode}\n        onViewModeChange={setViewMode}\n        showFilters={showFilters}\n        onToggleFilters={() => setShowFilters((current) => !current)}\n        statusFilter={statusFilter}\n        onStatusFilterChange={setStatusFilter}\n        priorityFilter={priorityFilter}\n        onPriorityFilterChange={setPriorityFilter}\n        sortField={sortField}\n        sortOrder={sortOrder}\n        onSortChange={handleSortChange}\n        onSortConfigChange={(field, order) => {\n          setSortField(field);\n          setSortOrder(order);\n        }}\n        statuses={statuses}\n        priorities={priorities}\n        onClearFilters={clearFilters}\n        existingPrds={existingPRDs}\n        onCreatePrd={() => onShowPRDEditor?.()}\n        onOpenPrd={(prd) => {\n          void loadPrdAndOpenEditor(prd);\n        }}\n        onOpenHelp={() => setShowHelpModal(true)}\n        onOpenCreateTask={() => setShowCreateModal(true)}\n      />\n\n      <TaskBoardContent\n        viewMode={viewMode}\n        filteredTaskCount={filteredTasks.length}\n        kanbanColumns={kanbanColumns}\n        filteredTasks={filteredTasks}\n        showParentTasks={showParentTasks}\n        onTaskClick={(task) => onTaskClick?.(task)}\n      />\n\n      <CreateTaskModal\n        isOpen={showCreateModal}\n        onClose={() => {\n          setShowCreateModal(false);\n          onTaskCreated?.();\n        }}\n      />\n\n      <TaskHelpModal\n        isOpen={showHelpModal}\n        onClose={() => setShowHelpModal(false)}\n        onCreatePrd={() => onShowPRDEditor?.()}\n      />\n\n      <TaskMasterSetupModal\n        isOpen={showSetupModal}\n        project={currentProject}\n        onClose={() => setShowSetupModal(false)}\n        onAfterClose={refreshAfterSetup}\n      />\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/task-master/view/TaskBoardContent.tsx",
    "content": "import { Search } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { cn } from '../../../lib/utils';\nimport type { TaskBoardView, TaskKanbanColumn, TaskMasterTask, TaskSelection } from '../types';\nimport TaskCard from './TaskCard';\n\ntype TaskBoardContentProps = {\n  viewMode: TaskBoardView;\n  filteredTaskCount: number;\n  kanbanColumns: TaskKanbanColumn[];\n  filteredTasks: TaskMasterTask[];\n  showParentTasks: boolean;\n  onTaskClick: (task: TaskSelection) => void;\n};\n\nfunction KanbanColumns({\n  columns,\n  showParentTasks,\n  onTaskClick,\n}: {\n  columns: TaskKanbanColumn[];\n  showParentTasks: boolean;\n  onTaskClick: (task: TaskSelection) => void;\n}) {\n  const { t } = useTranslation('tasks');\n\n  return (\n    <div\n      className={cn(\n        'grid gap-6',\n        columns.length === 1 && 'grid-cols-1 max-w-md mx-auto',\n        columns.length === 2 && 'grid-cols-1 md:grid-cols-2',\n        columns.length === 3 && 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',\n        columns.length === 4 && 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',\n        columns.length === 5 && 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5',\n        columns.length >= 6 && 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6',\n      )}\n    >\n      {columns.map((column) => (\n        <div key={column.id} className={cn('rounded-xl border shadow-sm transition-shadow hover:shadow-md', column.color)}>\n          <div className={cn('px-4 py-3 rounded-t-xl border-b', column.headerColor)}>\n            <div className=\"flex items-center justify-between\">\n              <h3 className=\"text-sm font-semibold\">{column.title}</h3>\n              <span className=\"rounded-full bg-white/60 px-2 py-1 text-xs font-medium dark:bg-black/20\">\n                {column.tasks.length}\n              </span>\n            </div>\n          </div>\n\n          <div className=\"max-h-[calc(100vh-300px)] min-h-[200px] space-y-3 overflow-y-auto p-3\">\n            {column.tasks.length === 0 ? (\n              <div className=\"py-8 text-center text-gray-400 dark:text-gray-500\">\n                <div className=\"mx-auto mb-2 flex h-8 w-8 items-center justify-center rounded-full bg-gray-200 dark:bg-gray-700\">\n                  <div className=\"h-3 w-3 rounded-full bg-gray-300 dark:bg-gray-600\" />\n                </div>\n                <div className=\"text-xs font-medium text-gray-500 dark:text-gray-400\">{t('kanban.noTasksYet')}</div>\n                <div className=\"mt-1 text-xs text-gray-400 dark:text-gray-500\">\n                  {column.status === 'pending'\n                    ? t('kanban.tasksWillAppear')\n                    : column.status === 'in-progress'\n                      ? t('kanban.moveTasksHere')\n                      : column.status === 'done'\n                        ? t('kanban.completedTasksHere')\n                        : t('kanban.statusTasksHere')}\n                </div>\n              </div>\n            ) : (\n              column.tasks.map((task) => (\n                <TaskCard\n                  key={String(task.id)}\n                  task={task}\n                  onClick={() => onTaskClick(task)}\n                  showParent={showParentTasks}\n                  className=\"w-full shadow-sm hover:shadow-md\"\n                />\n              ))\n            )}\n          </div>\n        </div>\n      ))}\n    </div>\n  );\n}\n\nexport default function TaskBoardContent({\n  viewMode,\n  filteredTaskCount,\n  kanbanColumns,\n  filteredTasks,\n  showParentTasks,\n  onTaskClick,\n}: TaskBoardContentProps) {\n  const { t } = useTranslation('tasks');\n\n  if (filteredTaskCount === 0) {\n    return (\n      <div className=\"py-12 text-center\">\n        <div className=\"text-gray-500 dark:text-gray-400\">\n          <Search className=\"mx-auto mb-4 h-12 w-12 opacity-50\" />\n          <h3 className=\"mb-2 text-lg font-medium\">{t('noMatchingTasks.title')}</h3>\n          <p className=\"text-sm\">{t('noMatchingTasks.description')}</p>\n        </div>\n      </div>\n    );\n  }\n\n  if (viewMode === 'kanban') {\n    return <KanbanColumns columns={kanbanColumns} showParentTasks={showParentTasks} onTaskClick={onTaskClick} />;\n  }\n\n  return (\n    <div className={cn('gap-4', viewMode === 'grid' ? 'grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3' : 'space-y-4')}>\n      {filteredTasks.map((task) => (\n        <TaskCard\n          key={String(task.id)}\n          task={task}\n          onClick={() => onTaskClick(task)}\n          showParent={showParentTasks}\n          className={viewMode === 'grid' ? 'h-full' : ''}\n        />\n      ))}\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/task-master/view/TaskBoardToolbar.tsx",
    "content": "import { useEffect, useRef, useState } from 'react';\nimport {\n  ChevronDown,\n  Columns,\n  FileText,\n  Filter,\n  Grid,\n  HelpCircle,\n  List,\n  Plus,\n  Search,\n} from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { cn } from '../../../lib/utils';\nimport type { PrdFile, TaskBoardSortField, TaskBoardSortOrder, TaskBoardView } from '../types';\nimport TaskFiltersPanel from './shared/TaskFiltersPanel';\nimport TaskQuickSortBar from './shared/TaskQuickSortBar';\n\ntype TaskBoardToolbarProps = {\n  hasProject: boolean;\n  hasTaskMasterConfigured: boolean;\n  totalTaskCount: number;\n  filteredTaskCount: number;\n  searchTerm: string;\n  onSearchTermChange: (value: string) => void;\n  viewMode: TaskBoardView;\n  onViewModeChange: (viewMode: TaskBoardView) => void;\n  showFilters: boolean;\n  onToggleFilters: () => void;\n  statusFilter: string;\n  onStatusFilterChange: (status: string) => void;\n  priorityFilter: string;\n  onPriorityFilterChange: (priority: string) => void;\n  sortField: TaskBoardSortField;\n  sortOrder: TaskBoardSortOrder;\n  onSortChange: (field: TaskBoardSortField) => void;\n  onSortConfigChange: (field: TaskBoardSortField, order: TaskBoardSortOrder) => void;\n  statuses: string[];\n  priorities: string[];\n  onClearFilters: () => void;\n  existingPrds: PrdFile[];\n  onCreatePrd: () => void;\n  onOpenPrd: (prd: PrdFile) => void;\n  onOpenHelp: () => void;\n  onOpenCreateTask: () => void;\n};\n\nexport default function TaskBoardToolbar({\n  hasProject,\n  hasTaskMasterConfigured,\n  totalTaskCount,\n  filteredTaskCount,\n  searchTerm,\n  onSearchTermChange,\n  viewMode,\n  onViewModeChange,\n  showFilters,\n  onToggleFilters,\n  statusFilter,\n  onStatusFilterChange,\n  priorityFilter,\n  onPriorityFilterChange,\n  sortField,\n  sortOrder,\n  onSortChange,\n  onSortConfigChange,\n  statuses,\n  priorities,\n  onClearFilters,\n  existingPrds,\n  onCreatePrd,\n  onOpenPrd,\n  onOpenHelp,\n  onOpenCreateTask,\n}: TaskBoardToolbarProps) {\n  const { t } = useTranslation('tasks');\n  const [isPrdDropdownOpen, setIsPrdDropdownOpen] = useState(false);\n  const dropdownRef = useRef<HTMLDivElement | null>(null);\n\n  useEffect(() => {\n    const handleMouseDown = (event: MouseEvent) => {\n      if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {\n        setIsPrdDropdownOpen(false);\n      }\n    };\n\n    document.addEventListener('mousedown', handleMouseDown);\n    return () => document.removeEventListener('mousedown', handleMouseDown);\n  }, []);\n\n  return (\n    <>\n      <div className=\"flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between\">\n        <div className=\"relative max-w-md flex-1\">\n          <Search className=\"absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400\" />\n          <input\n            type=\"text\"\n            value={searchTerm}\n            onChange={(event) => onSearchTermChange(event.target.value)}\n            placeholder={t('search.placeholder')}\n            className=\"w-full rounded-lg border border-gray-300 bg-white py-2 pl-10 pr-4 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-white\"\n          />\n        </div>\n\n        <div className=\"flex flex-wrap items-center gap-2\">\n          <div className=\"flex rounded-lg bg-gray-100 p-1 dark:bg-gray-800\">\n            <button\n              onClick={() => onViewModeChange('kanban')}\n              className={cn(\n                'p-2 rounded-md',\n                viewMode === 'kanban'\n                  ? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'\n                  : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300',\n              )}\n              title={t('views.kanban')}\n            >\n              <Columns className=\"h-4 w-4\" />\n            </button>\n\n            <button\n              onClick={() => onViewModeChange('list')}\n              className={cn(\n                'p-2 rounded-md',\n                viewMode === 'list'\n                  ? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'\n                  : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300',\n              )}\n              title={t('views.list')}\n            >\n              <List className=\"h-4 w-4\" />\n            </button>\n\n            <button\n              onClick={() => onViewModeChange('grid')}\n              className={cn(\n                'p-2 rounded-md',\n                viewMode === 'grid'\n                  ? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'\n                  : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300',\n              )}\n              title={t('views.grid')}\n            >\n              <Grid className=\"h-4 w-4\" />\n            </button>\n          </div>\n\n          <button\n            onClick={onToggleFilters}\n            className={cn(\n              'flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors',\n              showFilters\n                ? 'bg-blue-50 dark:bg-blue-900 border-blue-200 dark:border-blue-700 text-blue-700 dark:text-blue-300'\n                : 'bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700',\n            )}\n          >\n            <Filter className=\"h-4 w-4\" />\n            <span className=\"hidden sm:inline\">{t('filters.button')}</span>\n            <ChevronDown className={cn('w-4 h-4 transition-transform', showFilters && 'rotate-180')} />\n          </button>\n\n          {hasProject && (\n            <>\n              <button\n                onClick={onOpenHelp}\n                className=\"rounded-lg border border-gray-300 p-2 text-gray-600 hover:bg-gray-100 hover:text-blue-600 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-blue-400\"\n                title={t('buttons.help')}\n              >\n                <HelpCircle className=\"h-4 w-4\" />\n              </button>\n\n              <div ref={dropdownRef} className=\"relative\">\n                {existingPrds.length > 0 ? (\n                  <>\n                    <button\n                      onClick={() => setIsPrdDropdownOpen((current) => !current)}\n                      className=\"flex items-center gap-2 rounded-lg bg-purple-600 px-3 py-2 font-medium text-white hover:bg-purple-700\"\n                      title={t('buttons.prdsAvailable', { count: existingPrds.length })}\n                    >\n                      <FileText className=\"h-4 w-4\" />\n                      <span className=\"hidden sm:inline\">{t('buttons.prds')}</span>\n                      <span className=\"min-w-5 rounded-full bg-purple-500 px-1.5 py-0.5 text-center text-xs\">\n                        {existingPrds.length}\n                      </span>\n                      <ChevronDown className={cn('w-3 h-3 transition-transform hidden sm:block', isPrdDropdownOpen && 'rotate-180')} />\n                    </button>\n\n                    {isPrdDropdownOpen && (\n                      <div className=\"absolute right-0 top-full z-30 mt-2 w-56 rounded-lg border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800\">\n                        <div className=\"p-2\">\n                          <button\n                            onClick={() => {\n                              onCreatePrd();\n                              setIsPrdDropdownOpen(false);\n                            }}\n                            className=\"flex w-full items-center gap-2 rounded px-3 py-2 text-left text-sm font-medium text-purple-700 hover:bg-purple-50 dark:text-purple-300 dark:hover:bg-purple-900/30\"\n                          >\n                            <Plus className=\"h-4 w-4\" />\n                            {t('buttons.createNewPRD')}\n                          </button>\n\n                          <div className=\"my-1 border-t border-gray-200 dark:border-gray-700\" />\n\n                          {existingPrds.map((prd) => (\n                            <button\n                              key={prd.name}\n                              onClick={() => {\n                                onOpenPrd(prd);\n                                setIsPrdDropdownOpen(false);\n                              }}\n                              className=\"flex w-full items-center gap-2 rounded px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700\"\n                            >\n                              <FileText className=\"h-4 w-4\" />\n                              <span className=\"truncate\">{prd.name}</span>\n                            </button>\n                          ))}\n                        </div>\n                      </div>\n                    )}\n                  </>\n                ) : (\n                  <button\n                    onClick={onCreatePrd}\n                    className=\"flex items-center gap-2 rounded-lg bg-purple-600 px-3 py-2 font-medium text-white hover:bg-purple-700\"\n                    title={t('buttons.addPRD')}\n                  >\n                    <FileText className=\"h-4 w-4\" />\n                    <span className=\"hidden sm:inline\">{t('buttons.addPRD')}</span>\n                  </button>\n                )}\n              </div>\n\n              {(hasTaskMasterConfigured || totalTaskCount > 0) && (\n                <button\n                  onClick={onOpenCreateTask}\n                  className=\"flex items-center gap-2 rounded-lg bg-blue-600 px-3 py-2 font-medium text-white hover:bg-blue-700\"\n                  title={t('buttons.addTask')}\n                >\n                  <Plus className=\"h-4 w-4\" />\n                  <span className=\"hidden sm:inline\">{t('buttons.addTask')}</span>\n                </button>\n              )}\n            </>\n          )}\n        </div>\n      </div>\n\n      <TaskFiltersPanel\n        showFilters={showFilters}\n        statusFilter={statusFilter}\n        onStatusFilterChange={onStatusFilterChange}\n        priorityFilter={priorityFilter}\n        onPriorityFilterChange={onPriorityFilterChange}\n        sortField={sortField}\n        sortOrder={sortOrder}\n        onSortConfigChange={onSortConfigChange}\n        statuses={statuses}\n        priorities={priorities}\n        filteredTaskCount={filteredTaskCount}\n        totalTaskCount={totalTaskCount}\n        onClearFilters={onClearFilters}\n      />\n\n      <TaskQuickSortBar sortField={sortField} sortOrder={sortOrder} onSortChange={onSortChange} />\n    </>\n  );\n}\n"
  },
  {
    "path": "src/components/task-master/view/TaskCard.tsx",
    "content": "import { memo } from 'react';\nimport {\n  AlertCircle,\n  ArrowRight,\n  CheckCircle,\n  ChevronUp,\n  Circle,\n  Clock,\n  Minus,\n  Pause,\n  X,\n} from 'lucide-react';\nimport { cn } from '../../../lib/utils';\nimport { Tooltip } from '../../../shared/view/ui';\nimport type { TaskMasterTask } from '../types';\n\ntype TaskCardProps = {\n  task: TaskMasterTask;\n  onClick?: (() => void) | null;\n  showParent?: boolean;\n  className?: string;\n};\n\ntype TaskStatusStyle = {\n  icon: typeof Circle;\n  statusText: string;\n  iconColor: string;\n  textColor: string;\n};\n\nfunction getStatusStyle(status?: string): TaskStatusStyle {\n  if (status === 'done') {\n    return {\n      icon: CheckCircle,\n      statusText: 'Done',\n      iconColor: 'text-green-600 dark:text-green-400',\n      textColor: 'text-green-900 dark:text-green-100',\n    };\n  }\n\n  if (status === 'in-progress') {\n    return {\n      icon: Clock,\n      statusText: 'In Progress',\n      iconColor: 'text-blue-600 dark:text-blue-400',\n      textColor: 'text-blue-900 dark:text-blue-100',\n    };\n  }\n\n  if (status === 'review') {\n    return {\n      icon: AlertCircle,\n      statusText: 'Review',\n      iconColor: 'text-amber-600 dark:text-amber-400',\n      textColor: 'text-amber-900 dark:text-amber-100',\n    };\n  }\n\n  if (status === 'deferred') {\n    return {\n      icon: Pause,\n      statusText: 'Deferred',\n      iconColor: 'text-gray-500 dark:text-gray-400',\n      textColor: 'text-gray-700 dark:text-gray-300',\n    };\n  }\n\n  if (status === 'cancelled') {\n    return {\n      icon: X,\n      statusText: 'Cancelled',\n      iconColor: 'text-red-600 dark:text-red-400',\n      textColor: 'text-red-900 dark:text-red-100',\n    };\n  }\n\n  return {\n    icon: Circle,\n    statusText: 'Pending',\n    iconColor: 'text-slate-500 dark:text-slate-400',\n    textColor: 'text-slate-900 dark:text-slate-100',\n  };\n}\n\nfunction renderPriorityIcon(priority?: string) {\n  if (priority === 'high') {\n    return (\n      <Tooltip content=\"High priority\">\n        <div className=\"flex h-4 w-4 items-center justify-center rounded bg-red-100 dark:bg-red-900/30\">\n          <ChevronUp className=\"h-2.5 w-2.5 text-red-600 dark:text-red-400\" />\n        </div>\n      </Tooltip>\n    );\n  }\n\n  if (priority === 'medium') {\n    return (\n      <Tooltip content=\"Medium priority\">\n        <div className=\"flex h-4 w-4 items-center justify-center rounded bg-amber-100 dark:bg-amber-900/30\">\n          <Minus className=\"h-2.5 w-2.5 text-amber-600 dark:text-amber-400\" />\n        </div>\n      </Tooltip>\n    );\n  }\n\n  if (priority === 'low') {\n    return (\n      <Tooltip content=\"Low priority\">\n        <div className=\"flex h-4 w-4 items-center justify-center rounded bg-blue-100 dark:bg-blue-900/30\">\n          <Circle className=\"h-1.5 w-1.5 fill-current text-blue-600 dark:text-blue-400\" />\n        </div>\n      </Tooltip>\n    );\n  }\n\n  return (\n    <Tooltip content=\"No priority set\">\n      <div className=\"flex h-4 w-4 items-center justify-center rounded bg-gray-100 dark:bg-gray-800\">\n        <Circle className=\"h-1.5 w-1.5 text-gray-400 dark:text-gray-500\" />\n      </div>\n    </Tooltip>\n  );\n}\n\nfunction getSubtaskProgress(task: TaskMasterTask): { completed: number; total: number; percentage: number } {\n  const subtasks = task.subtasks ?? [];\n  const total = subtasks.length;\n  const completed = subtasks.filter((subtask) => subtask.status === 'done').length;\n  const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;\n\n  return { completed, total, percentage };\n}\n\nfunction TaskCard({ task, onClick = null, showParent = false, className = '' }: TaskCardProps) {\n  const statusStyle = getStatusStyle(task.status);\n  const progress = getSubtaskProgress(task);\n\n  return (\n    <div\n      className={cn(\n        'bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-3 space-y-3',\n        'hover:shadow-md hover:border-blue-300 dark:hover:border-blue-600 transition-all duration-200',\n        onClick ? 'cursor-pointer hover:-translate-y-0.5' : 'cursor-default',\n        className,\n      )}\n      onClick={onClick ?? undefined}\n    >\n      <div className=\"flex items-start justify-between gap-2\">\n        <div className=\"min-w-0 flex-1\">\n          <div className=\"mb-1 flex items-center gap-2\">\n            <Tooltip content={`Task ID: ${task.id}`}>\n              <span className=\"rounded bg-gray-100 px-2 py-0.5 font-mono text-xs text-gray-500 dark:bg-gray-700 dark:text-gray-400\">\n                {task.id}\n              </span>\n            </Tooltip>\n          </div>\n\n          <h3 className=\"line-clamp-2 text-sm font-medium leading-tight text-gray-900 dark:text-white\">\n            {task.title}\n          </h3>\n\n          {showParent && task.parentId && (\n            <span className=\"text-xs font-medium text-gray-500 dark:text-gray-400\">Task {task.parentId}</span>\n          )}\n        </div>\n\n        <div className=\"flex-shrink-0\">{renderPriorityIcon(task.priority)}</div>\n      </div>\n\n      <div className=\"flex items-center justify-between\">\n        <div className=\"flex items-center\">\n          {Array.isArray(task.dependencies) && task.dependencies.length > 0 && (\n            <Tooltip content={`Depends on: ${task.dependencies.map((dependency) => `Task ${dependency}`).join(', ')}`}>\n              <div className=\"flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400\">\n                <ArrowRight className=\"h-3 w-3\" />\n                <span>Depends on: {task.dependencies.join(', ')}</span>\n              </div>\n            </Tooltip>\n          )}\n        </div>\n\n        <Tooltip content={`Status: ${statusStyle.statusText}`}>\n          <div className=\"flex items-center gap-1\">\n            <div className={cn('w-2 h-2 rounded-full', statusStyle.iconColor.replace('text-', 'bg-'))} />\n            <span className={cn('text-xs font-medium', statusStyle.textColor)}>{statusStyle.statusText}</span>\n          </div>\n        </Tooltip>\n      </div>\n\n      {progress.total > 0 && (\n        <div className=\"ml-3\">\n          <div className=\"mb-1 flex items-center gap-2\">\n            <span className=\"text-xs text-gray-500 dark:text-gray-400\">Progress:</span>\n            <div className=\"h-1.5 flex-1 rounded-full bg-gray-200 dark:bg-gray-700\" title={`${progress.completed} of ${progress.total} subtasks completed`}>\n              <div\n                className={cn('h-full rounded-full transition-all duration-300', task.status === 'done' ? 'bg-green-500' : 'bg-blue-500')}\n                style={{ width: `${progress.percentage}%` }}\n              />\n            </div>\n            <span className=\"text-xs text-gray-500 dark:text-gray-400\">\n              {progress.completed}/{progress.total}\n            </span>\n          </div>\n        </div>\n      )}\n    </div>\n  );\n}\n\nexport default memo(TaskCard);\n"
  },
  {
    "path": "src/components/task-master/view/TaskDetailModal.tsx",
    "content": "import { useEffect, useMemo, useState } from 'react';\nimport {\n  AlertCircle,\n  ArrowRight,\n  CheckCircle,\n  ChevronDown,\n  ChevronRight,\n  Circle,\n  Clock,\n  Copy,\n  Edit,\n  Pause,\n  Save,\n  X,\n} from 'lucide-react';\nimport { cn } from '../../../lib/utils';\nimport { copyTextToClipboard } from '../../../utils/clipboard';\nimport { api } from '../../../utils/api';\nimport { useTaskMaster } from '../context/TaskMasterContext';\nimport type { TaskId, TaskMasterTask, TaskReference } from '../types';\n\ntype TaskDetailModalProps = {\n  task: TaskMasterTask | null;\n  isOpen?: boolean;\n  className?: string;\n  onClose: () => void;\n  onEdit?: ((task: TaskMasterTask) => void) | null;\n  onStatusChange?: ((taskId: TaskId, status: string) => void) | null;\n  onTaskClick?: ((task: TaskReference) => void) | null;\n};\n\nconst STATUS_OPTIONS = [\n  { value: 'pending', label: 'Pending' },\n  { value: 'in-progress', label: 'In Progress' },\n  { value: 'review', label: 'Review' },\n  { value: 'done', label: 'Done' },\n  { value: 'deferred', label: 'Deferred' },\n  { value: 'cancelled', label: 'Cancelled' },\n];\n\nfunction getStatusIcon(status?: string) {\n  if (status === 'done') return CheckCircle;\n  if (status === 'in-progress') return Clock;\n  if (status === 'review') return AlertCircle;\n  if (status === 'deferred') return Pause;\n  if (status === 'cancelled') return X;\n  return Circle;\n}\n\nfunction getPriorityBadgeClass(priority?: string): string {\n  if (priority === 'high') return 'text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950';\n  if (priority === 'medium') return 'text-yellow-600 dark:text-yellow-400 bg-yellow-50 dark:bg-yellow-950';\n  if (priority === 'low') return 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-950';\n  return 'text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800';\n}\n\nexport default function TaskDetailModal({\n  task,\n  isOpen = true,\n  className = '',\n  onClose,\n  onEdit = null,\n  onStatusChange = null,\n  onTaskClick = null,\n}: TaskDetailModalProps) {\n  const { currentProject, refreshTasks } = useTaskMaster();\n\n  const [isEditMode, setIsEditMode] = useState(false);\n  const [isSaving, setIsSaving] = useState(false);\n  const [showDetails, setShowDetails] = useState(false);\n  const [showTestStrategy, setShowTestStrategy] = useState(false);\n  const [editableTask, setEditableTask] = useState<TaskMasterTask | null>(task);\n\n  useEffect(() => {\n    setEditableTask(task);\n    setIsEditMode(false);\n  }, [task]);\n\n  const StatusIcon = useMemo(() => getStatusIcon(task?.status), [task?.status]);\n\n  if (!isOpen || !task || !editableTask) {\n    return null;\n  }\n\n  const handleSaveChanges = async () => {\n    if (!currentProject?.name) {\n      return;\n    }\n\n    const updates: Record<string, string> = {};\n\n    if (editableTask.title !== task.title) {\n      updates.title = editableTask.title;\n    }\n\n    if (editableTask.description !== task.description) {\n      updates.description = editableTask.description ?? '';\n    }\n\n    if (editableTask.details !== task.details) {\n      updates.details = editableTask.details ?? '';\n    }\n\n    if (Object.keys(updates).length === 0) {\n      setIsEditMode(false);\n      return;\n    }\n\n    setIsSaving(true);\n    try {\n      const response = await api.taskmaster.updateTask(currentProject.name, task.id, updates);\n      if (!response.ok) {\n        const errorPayload = (await response.json()) as { message?: string };\n        throw new Error(errorPayload.message ?? 'Failed to update task');\n      }\n\n      setIsEditMode(false);\n      await refreshTasks();\n      onEdit?.(editableTask);\n    } catch (error) {\n      console.error('Failed to save task changes:', error);\n      alert(error instanceof Error ? error.message : 'Failed to update task');\n    } finally {\n      setIsSaving(false);\n    }\n  };\n\n  const handleStatusSelect = async (nextStatus: string) => {\n    if (!currentProject?.name || nextStatus === task.status) {\n      return;\n    }\n\n    try {\n      const response = await api.taskmaster.updateTask(currentProject.name, task.id, { status: nextStatus });\n      if (!response.ok) {\n        const errorPayload = (await response.json()) as { message?: string };\n        throw new Error(errorPayload.message ?? 'Failed to update task status');\n      }\n\n      await refreshTasks();\n      onStatusChange?.(task.id, nextStatus);\n    } catch (error) {\n      console.error('Failed to update task status:', error);\n      alert(error instanceof Error ? error.message : 'Failed to update task status');\n    }\n  };\n\n  return (\n    <div className=\"fixed inset-0 z-[100] flex items-center justify-center bg-black/50 md:p-4\">\n      <div\n        className={cn(\n          'w-full md:max-w-4xl h-full md:h-[90vh] bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 md:rounded-lg shadow-xl flex flex-col',\n          className,\n        )}\n      >\n        <div className=\"flex items-center justify-between border-b border-gray-200 p-4 dark:border-gray-700 md:p-6\">\n          <div className=\"flex min-w-0 flex-1 items-center gap-3\">\n            <StatusIcon className=\"h-6 w-6 text-blue-600 dark:text-blue-400\" />\n            <div className=\"min-w-0 flex-1\">\n              <button\n                onClick={() => copyTextToClipboard(String(task.id))}\n                className=\"mb-2 inline-flex items-center gap-1 rounded bg-gray-100 px-2 py-1 text-xs text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700\"\n                title=\"Copy task ID\"\n              >\n                <span>Task {task.id}</span>\n                <Copy className=\"h-3 w-3\" />\n              </button>\n\n              {isEditMode ? (\n                <input\n                  type=\"text\"\n                  value={editableTask.title}\n                  onChange={(event) => setEditableTask({ ...editableTask, title: event.target.value })}\n                  className=\"w-full border-b-2 border-blue-500 bg-transparent text-lg font-semibold text-gray-900 focus:outline-none dark:text-white\"\n                />\n              ) : (\n                <h1 className=\"line-clamp-2 text-lg font-semibold text-gray-900 dark:text-white md:text-xl\">{task.title}</h1>\n              )}\n            </div>\n          </div>\n\n          <div className=\"flex items-center gap-2\">\n            {isEditMode ? (\n              <>\n                <button\n                  onClick={handleSaveChanges}\n                  disabled={isSaving}\n                  className=\"rounded-md p-2 text-green-600 hover:bg-green-50 disabled:opacity-50 dark:hover:bg-green-950\"\n                  title=\"Save\"\n                >\n                  <Save className={cn('w-5 h-5', isSaving && 'animate-spin')} />\n                </button>\n                <button\n                  onClick={() => {\n                    setEditableTask(task);\n                    setIsEditMode(false);\n                  }}\n                  disabled={isSaving}\n                  className=\"rounded-md p-2 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800\"\n                  title=\"Cancel editing\"\n                >\n                  <X className=\"h-5 w-5\" />\n                </button>\n              </>\n            ) : (\n              <button\n                onClick={() => setIsEditMode(true)}\n                className=\"rounded-md p-2 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800\"\n                title=\"Edit task\"\n              >\n                <Edit className=\"h-5 w-5\" />\n              </button>\n            )}\n            <button onClick={onClose} className=\"rounded-md p-2 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800\" title=\"Close\">\n              <X className=\"h-5 w-5\" />\n            </button>\n          </div>\n        </div>\n\n        <div className=\"flex-1 space-y-6 overflow-y-auto p-4 md:p-6\">\n          <div className=\"grid grid-cols-1 gap-4 md:grid-cols-3\">\n            <div className=\"space-y-2\">\n              <label className=\"text-sm font-medium text-gray-700 dark:text-gray-300\">Status</label>\n              <select\n                value={task.status ?? 'pending'}\n                onChange={(event) => {\n                  void handleStatusSelect(event.target.value);\n                }}\n                className=\"w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-white\"\n              >\n                {STATUS_OPTIONS.map((option) => (\n                  <option key={option.value} value={option.value}>\n                    {option.label}\n                  </option>\n                ))}\n              </select>\n            </div>\n\n            <div className=\"space-y-2\">\n              <label className=\"text-sm font-medium text-gray-700 dark:text-gray-300\">Priority</label>\n              <div className={cn('px-3 py-2 rounded-md text-sm font-medium capitalize', getPriorityBadgeClass(task.priority))}>\n                {task.priority ?? 'Not set'}\n              </div>\n            </div>\n\n            <div className=\"space-y-2\">\n              <label className=\"text-sm font-medium text-gray-700 dark:text-gray-300\">Dependencies</label>\n              {Array.isArray(task.dependencies) && task.dependencies.length > 0 ? (\n                <div className=\"flex flex-wrap gap-1\">\n                  {task.dependencies.map((dependency) => (\n                    <button\n                      key={String(dependency)}\n                      onClick={() => onTaskClick?.({ id: dependency })}\n                      className=\"rounded bg-blue-100 px-2 py-1 text-sm text-blue-700 hover:bg-blue-200 dark:bg-blue-900 dark:text-blue-300 dark:hover:bg-blue-800\"\n                    >\n                      <ArrowRight className=\"mr-1 inline h-3 w-3\" />\n                      {dependency}\n                    </button>\n                  ))}\n                </div>\n              ) : (\n                <span className=\"text-sm text-gray-500 dark:text-gray-400\">No dependencies</span>\n              )}\n            </div>\n          </div>\n\n          <div className=\"space-y-2\">\n            <label className=\"text-sm font-medium text-gray-700 dark:text-gray-300\">Description</label>\n            {isEditMode ? (\n              <textarea\n                rows={4}\n                value={editableTask.description ?? ''}\n                onChange={(event) => setEditableTask({ ...editableTask, description: event.target.value })}\n                className=\"w-full rounded-md border border-gray-300 bg-white px-3 py-2 dark:border-gray-600 dark:bg-gray-800\"\n              />\n            ) : (\n              <p className=\"whitespace-pre-wrap text-gray-700 dark:text-gray-300\">{task.description || 'No description provided'}</p>\n            )}\n          </div>\n\n          {task.details && (\n            <div className=\"rounded-lg border border-gray-200 dark:border-gray-700\">\n              <button\n                onClick={() => setShowDetails((current) => !current)}\n                className=\"flex w-full items-center justify-between p-4 text-left hover:bg-gray-50 dark:hover:bg-gray-800\"\n              >\n                <span className=\"text-sm font-medium text-gray-700 dark:text-gray-300\">Implementation Details</span>\n                {showDetails ? <ChevronDown className=\"h-4 w-4\" /> : <ChevronRight className=\"h-4 w-4\" />}\n              </button>\n              {showDetails && (\n                <div className=\"border-t border-gray-200 p-4 dark:border-gray-700\">\n                  <p className=\"whitespace-pre-wrap text-gray-700 dark:text-gray-300\">{task.details}</p>\n                </div>\n              )}\n            </div>\n          )}\n\n          {task.testStrategy && (\n            <div className=\"rounded-lg border border-gray-200 dark:border-gray-700\">\n              <button\n                onClick={() => setShowTestStrategy((current) => !current)}\n                className=\"flex w-full items-center justify-between p-4 text-left hover:bg-gray-50 dark:hover:bg-gray-800\"\n              >\n                <span className=\"text-sm font-medium text-gray-700 dark:text-gray-300\">Test Strategy</span>\n                {showTestStrategy ? <ChevronDown className=\"h-4 w-4\" /> : <ChevronRight className=\"h-4 w-4\" />}\n              </button>\n              {showTestStrategy && (\n                <div className=\"border-t border-gray-200 bg-blue-50 p-4 dark:border-gray-700 dark:bg-blue-950/30\">\n                  <p className=\"whitespace-pre-wrap text-gray-700 dark:text-gray-300\">{task.testStrategy}</p>\n                </div>\n              )}\n            </div>\n          )}\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/task-master/view/TaskEmptyState.tsx",
    "content": "import { FileText, Settings, Terminal } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { cn } from '../../../lib/utils';\nimport type { PrdFile } from '../types';\n\ntype TaskEmptyStateProps = {\n  className?: string;\n  hasTaskMasterDirectory: boolean;\n  existingPrds: PrdFile[];\n  onOpenSetupModal: () => void;\n  onCreatePrd: () => void;\n  onOpenPrd: (prd: PrdFile) => void;\n};\n\nexport default function TaskEmptyState({\n  className = '',\n  hasTaskMasterDirectory,\n  existingPrds,\n  onOpenSetupModal,\n  onCreatePrd,\n  onOpenPrd,\n}: TaskEmptyStateProps) {\n  const { t } = useTranslation('tasks');\n\n  if (!hasTaskMasterDirectory) {\n    return (\n      <div className={cn('text-center py-12', className)}>\n        <div className=\"mx-auto max-w-md\">\n          <div className=\"mb-4 text-blue-600 dark:text-blue-400\">\n            <Settings className=\"mx-auto mb-4 h-12 w-12\" />\n          </div>\n\n          <h3 className=\"mb-2 text-lg font-semibold text-gray-900 dark:text-white\">{t('notConfigured.title')}</h3>\n          <p className=\"mb-6 text-sm text-gray-600 dark:text-gray-400\">{t('notConfigured.description')}</p>\n\n          <div className=\"mb-6 rounded-lg bg-blue-50 p-4 text-left dark:bg-blue-950\">\n            <h4 className=\"mb-3 text-sm font-medium text-blue-900 dark:text-blue-100\">{t('notConfigured.whatIsTitle')}</h4>\n            <div className=\"space-y-1 text-xs text-blue-800 dark:text-blue-200\">\n              <p>- {t('notConfigured.features.aiPowered')}</p>\n              <p>- {t('notConfigured.features.prdTemplates')}</p>\n              <p>- {t('notConfigured.features.dependencyTracking')}</p>\n              <p>- {t('notConfigured.features.progressVisualization')}</p>\n              <p>- {t('notConfigured.features.cliIntegration')}</p>\n            </div>\n          </div>\n\n          <button\n            onClick={onOpenSetupModal}\n            className=\"mx-auto flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700\"\n          >\n            <Terminal className=\"h-4 w-4\" />\n            {t('notConfigured.initializeButton')}\n          </button>\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div className={cn('text-center py-12', className)}>\n      <div className=\"mx-auto max-w-4xl\">\n        <div className=\"mb-6 rounded-xl border border-blue-200 bg-gradient-to-r from-blue-50 to-indigo-50 p-6 text-left dark:border-blue-800 dark:from-blue-950/50 dark:to-indigo-950/50\">\n          <div className=\"mb-4 flex items-center gap-3\">\n            <div className=\"flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/50\">\n              <FileText className=\"h-5 w-5 text-blue-600 dark:text-blue-400\" />\n            </div>\n            <div>\n              <h2 className=\"text-xl font-semibold text-gray-900 dark:text-white\">{t('gettingStarted.title')}</h2>\n              <p className=\"text-sm text-gray-600 dark:text-gray-400\">{t('gettingStarted.subtitle')}</p>\n            </div>\n          </div>\n\n          <div className=\"mb-4 space-y-3\">\n            <div className=\"rounded-lg border border-blue-100 bg-white p-3 dark:border-blue-800/50 dark:bg-gray-800/60\">\n              <h4 className=\"mb-1 font-medium text-gray-900 dark:text-white\">1. {t('gettingStarted.steps.createPRD.title')}</h4>\n              <p className=\"mb-3 text-sm text-gray-600 dark:text-gray-400\">{t('gettingStarted.steps.createPRD.description')}</p>\n\n              <button\n                onClick={onCreatePrd}\n                className=\"inline-flex items-center gap-2 rounded bg-purple-100 px-2 py-1 text-xs text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-300 dark:hover:bg-purple-900/50\"\n              >\n                <FileText className=\"h-3 w-3\" />\n                {t('gettingStarted.steps.createPRD.addButton')}\n              </button>\n\n              {existingPrds.length > 0 && (\n                <div className=\"mt-3 border-t border-gray-200 pt-3 dark:border-gray-700\">\n                  <p className=\"mb-2 text-xs text-gray-500 dark:text-gray-400\">{t('gettingStarted.steps.createPRD.existingPRDs')}</p>\n                  <div className=\"flex flex-wrap gap-2\">\n                    {existingPrds.map((prd) => (\n                      <button\n                        key={prd.name}\n                        onClick={() => onOpenPrd(prd)}\n                        className=\"inline-flex items-center gap-1 rounded bg-gray-100 px-2 py-1 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600\"\n                      >\n                        <FileText className=\"h-3 w-3\" />\n                        {prd.name}\n                      </button>\n                    ))}\n                  </div>\n                </div>\n              )}\n            </div>\n\n            <div className=\"rounded-lg border border-blue-100 bg-white p-3 dark:border-blue-800/50 dark:bg-gray-800/60\">\n              <h4 className=\"mb-1 font-medium text-gray-900 dark:text-white\">2. {t('gettingStarted.steps.generateTasks.title')}</h4>\n              <p className=\"text-sm text-gray-600 dark:text-gray-400\">{t('gettingStarted.steps.generateTasks.description')}</p>\n            </div>\n\n            <div className=\"rounded-lg border border-blue-100 bg-white p-3 dark:border-blue-800/50 dark:bg-gray-800/60\">\n              <h4 className=\"mb-1 font-medium text-gray-900 dark:text-white\">3. {t('gettingStarted.steps.analyzeTasks.title')}</h4>\n              <p className=\"text-sm text-gray-600 dark:text-gray-400\">{t('gettingStarted.steps.analyzeTasks.description')}</p>\n            </div>\n\n            <div className=\"rounded-lg border border-blue-100 bg-white p-3 dark:border-blue-800/50 dark:bg-gray-800/60\">\n              <h4 className=\"mb-1 font-medium text-gray-900 dark:text-white\">4. {t('gettingStarted.steps.startBuilding.title')}</h4>\n              <p className=\"text-sm text-gray-600 dark:text-gray-400\">{t('gettingStarted.steps.startBuilding.description')}</p>\n            </div>\n          </div>\n\n          <button\n            onClick={onCreatePrd}\n            className=\"inline-flex items-center gap-2 rounded-lg bg-purple-600 px-4 py-2 font-medium text-white hover:bg-purple-700\"\n          >\n            <FileText className=\"h-4 w-4\" />\n            {t('buttons.addPRD')}\n          </button>\n        </div>\n\n        <p className=\"text-sm text-gray-500 dark:text-gray-400\">{t('gettingStarted.tip')}</p>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/task-master/view/TaskMasterPanel.tsx",
    "content": "import { useCallback, useEffect, useRef, useState } from 'react';\nimport PRDEditor from '../../prd-editor';\nimport { useTaskMaster } from '../context/TaskMasterContext';\nimport { useProjectPrdFiles } from '../hooks/useProjectPrdFiles';\nimport type { PrdFile, TaskMasterTask, TaskSelection } from '../types';\nimport TaskBoard from './TaskBoard';\nimport TaskDetailModal from './TaskDetailModal';\n\ntype TaskMasterPanelProps = {\n  isVisible: boolean;\n};\n\nconst PRD_SAVE_MESSAGE = 'PRD saved successfully!';\n\nexport default function TaskMasterPanel({ isVisible }: TaskMasterPanelProps) {\n  const { tasks, currentProject, refreshTasks } = useTaskMaster();\n\n  const [selectedTask, setSelectedTask] = useState<TaskMasterTask | null>(null);\n  const [isTaskDetailOpen, setIsTaskDetailOpen] = useState(false);\n\n  const [isPrdEditorOpen, setIsPrdEditorOpen] = useState(false);\n  const [selectedPrd, setSelectedPrd] = useState<PrdFile | null>(null);\n\n  const [prdNotification, setPrdNotification] = useState<string | null>(null);\n  const notificationTimeoutRef = useRef<number | null>(null);\n\n  const { prdFiles, refreshPrdFiles } = useProjectPrdFiles({ projectName: currentProject?.name });\n\n  const showPrdNotification = useCallback((message: string) => {\n    if (notificationTimeoutRef.current) {\n      window.clearTimeout(notificationTimeoutRef.current);\n    }\n\n    setPrdNotification(message);\n\n    notificationTimeoutRef.current = window.setTimeout(() => {\n      setPrdNotification(null);\n      notificationTimeoutRef.current = null;\n    }, 3000);\n  }, []);\n\n  const refreshPrdData = useCallback(\n    async (showNotification = false) => {\n      await refreshPrdFiles();\n      if (showNotification) {\n        showPrdNotification(PRD_SAVE_MESSAGE);\n      }\n    },\n    [refreshPrdFiles, showPrdNotification],\n  );\n\n  useEffect(() => {\n    return () => {\n      if (notificationTimeoutRef.current) {\n        window.clearTimeout(notificationTimeoutRef.current);\n      }\n    };\n  }, []);\n\n  const handleTaskClick = useCallback(\n    (taskSelection: TaskSelection) => {\n      const selectedId = String(taskSelection.id);\n\n      if (!taskSelection.title) {\n        const fullTask = tasks.find((task) => String(task.id) === selectedId) ?? null;\n        if (fullTask) {\n          setSelectedTask(fullTask);\n          setIsTaskDetailOpen(true);\n        }\n        return;\n      }\n\n      setSelectedTask(taskSelection as TaskMasterTask);\n      setIsTaskDetailOpen(true);\n    },\n    [tasks],\n  );\n\n  return (\n    <>\n      <div className={`h-full ${isVisible ? 'block' : 'hidden'}`}>\n        <div className=\"flex h-full flex-col overflow-hidden\">\n          <TaskBoard\n            tasks={tasks}\n            onTaskClick={handleTaskClick}\n            showParentTasks\n            className=\"flex-1 overflow-y-auto p-4\"\n            currentProject={currentProject}\n            onTaskCreated={refreshTasks}\n            onShowPRDEditor={(prd) => {\n              setSelectedPrd(prd ?? null);\n              setIsPrdEditorOpen(true);\n            }}\n            existingPRDs={prdFiles}\n            onRefreshPRDs={(showNotification = false) => {\n              void refreshPrdData(showNotification);\n            }}\n          />\n        </div>\n      </div>\n\n      <TaskDetailModal\n        task={selectedTask}\n        isOpen={isTaskDetailOpen}\n        onClose={() => {\n          setIsTaskDetailOpen(false);\n          setSelectedTask(null);\n        }}\n        onStatusChange={() => {\n          void refreshTasks();\n        }}\n        onTaskClick={handleTaskClick}\n      />\n\n      {isPrdEditorOpen && (\n        <PRDEditor\n          project={currentProject}\n          projectPath={currentProject?.fullPath || currentProject?.path}\n          onClose={() => {\n            setIsPrdEditorOpen(false);\n            setSelectedPrd(null);\n          }}\n          isNewFile={!selectedPrd?.isExisting}\n          file={{\n            name: selectedPrd?.name || 'prd.txt',\n            content: selectedPrd?.content || '',\n            isExisting: selectedPrd?.isExisting,\n          }}\n          onSave={async () => {\n            setIsPrdEditorOpen(false);\n            setSelectedPrd(null);\n            await refreshPrdData(true);\n            await refreshTasks();\n          }}\n        />\n      )}\n\n      {prdNotification && (\n        <div className=\"animate-in slide-in-from-bottom-2 fixed bottom-4 right-4 z-50 duration-300\">\n          <div className=\"flex items-center gap-3 rounded-lg bg-green-600 px-4 py-3 text-white shadow-lg\">\n            <svg className=\"h-5 w-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n              <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M5 13l4 4L19 7\" />\n            </svg>\n            <span className=\"font-medium\">{prdNotification}</span>\n          </div>\n        </div>\n      )}\n    </>\n  );\n}\n"
  },
  {
    "path": "src/components/task-master/view/modals/CreateTaskModal.tsx",
    "content": "import { Sparkles, X } from 'lucide-react';\n\ntype CreateTaskModalProps = {\n  isOpen: boolean;\n  onClose: () => void;\n};\n\nexport default function CreateTaskModal({ isOpen, onClose }: CreateTaskModalProps) {\n  if (!isOpen) {\n    return null;\n  }\n\n  return (\n    <div className=\"fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm\">\n      <div className=\"w-full max-w-md rounded-lg border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800\">\n        <div className=\"flex items-center justify-between border-b border-gray-200 p-6 dark:border-gray-700\">\n          <div className=\"flex items-center gap-3\">\n            <div className=\"flex h-8 w-8 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/50\">\n              <Sparkles className=\"h-4 w-4 text-blue-600 dark:text-blue-400\" />\n            </div>\n            <h3 className=\"text-lg font-semibold text-gray-900 dark:text-white\">Create AI-Generated Task</h3>\n          </div>\n          <button\n            onClick={onClose}\n            className=\"rounded-md p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300\"\n          >\n            <X className=\"h-5 w-5\" />\n          </button>\n        </div>\n\n        <div className=\"space-y-6 p-6\">\n          <div className=\"rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-900/20\">\n            <div className=\"flex items-start gap-3\">\n              <div className=\"mt-0.5 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/50\">\n                <Sparkles className=\"h-4 w-4 text-blue-600 dark:text-blue-400\" />\n              </div>\n              <div>\n                <h4 className=\"mb-2 font-semibold text-blue-900 dark:text-blue-100\">Pro tip: ask Claude Code directly</h4>\n                <p className=\"mb-3 text-sm text-blue-800 dark:text-blue-200\">\n                  Ask for a task in chat with context and requirements. TaskMaster can generate implementation-ready tasks.\n                </p>\n                <div className=\"rounded border border-blue-200 bg-white p-3 dark:border-blue-700 dark:bg-gray-800\">\n                  <p className=\"mb-1 text-xs font-medium text-gray-600 dark:text-gray-400\">Example:</p>\n                  <p className=\"font-mono text-sm text-gray-900 dark:text-white\">\n                    Please add a task for profile image uploads and include best-practice research.\n                  </p>\n                </div>\n              </div>\n            </div>\n          </div>\n\n          <div className=\"border-t border-gray-200 pt-4 text-center dark:border-gray-700\">\n            <a\n              href=\"https://github.com/eyaltoledano/claude-task-master/blob/main/docs/examples.md\"\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"inline-block text-sm font-medium text-blue-600 underline hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300\"\n            >\n              View TaskMaster documentation\n            </a>\n          </div>\n\n          <button\n            onClick={onClose}\n            className=\"w-full rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600\"\n          >\n            Got it\n          </button>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/task-master/view/modals/TaskHelpModal.tsx",
    "content": "import { ExternalLink, FileText, X } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\n\ntype TaskHelpModalProps = {\n  isOpen: boolean;\n  onClose: () => void;\n  onCreatePrd: () => void;\n};\n\ntype HelpStep = {\n  index: number;\n  title: string;\n  description: string;\n  accent: string;\n};\n\nexport default function TaskHelpModal({ isOpen, onClose, onCreatePrd }: TaskHelpModalProps) {\n  const { t } = useTranslation('tasks');\n\n  if (!isOpen) {\n    return null;\n  }\n\n  const steps: HelpStep[] = [\n    {\n      index: 1,\n      title: t('gettingStarted.steps.createPRD.title'),\n      description: t('gettingStarted.steps.createPRD.description'),\n      accent: 'border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950/40',\n    },\n    {\n      index: 2,\n      title: t('gettingStarted.steps.generateTasks.title'),\n      description: t('gettingStarted.steps.generateTasks.description'),\n      accent: 'border-emerald-200 dark:border-emerald-800 bg-emerald-50 dark:bg-emerald-950/40',\n    },\n    {\n      index: 3,\n      title: t('gettingStarted.steps.analyzeTasks.title'),\n      description: t('gettingStarted.steps.analyzeTasks.description'),\n      accent: 'border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-950/40',\n    },\n    {\n      index: 4,\n      title: t('gettingStarted.steps.startBuilding.title'),\n      description: t('gettingStarted.steps.startBuilding.description'),\n      accent: 'border-purple-200 dark:border-purple-800 bg-purple-50 dark:bg-purple-950/40',\n    },\n  ];\n\n  return (\n    <div className=\"fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm\">\n      <div className=\"max-h-[90vh] w-full max-w-4xl overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-900\">\n        <div className=\"flex items-center justify-between border-b border-gray-200 p-6 dark:border-gray-700\">\n          <div className=\"flex items-center gap-3\">\n            <div className=\"flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/50\">\n              <FileText className=\"h-5 w-5 text-blue-600 dark:text-blue-400\" />\n            </div>\n            <div>\n              <h2 className=\"text-xl font-semibold text-gray-900 dark:text-white\">{t('helpGuide.title')}</h2>\n              <p className=\"text-sm text-gray-600 dark:text-gray-400\">{t('helpGuide.subtitle')}</p>\n            </div>\n          </div>\n\n          <button\n            onClick={onClose}\n            className=\"rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300\"\n            title=\"Close\"\n          >\n            <X className=\"h-5 w-5\" />\n          </button>\n        </div>\n\n        <div className=\"max-h-[calc(90vh-120px)] space-y-4 overflow-y-auto p-6\">\n          {steps.map((step) => (\n            <div key={step.index} className={`rounded-lg border p-4 ${step.accent}`}>\n              <div className=\"flex gap-4\">\n                <div className=\"flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-sm font-semibold text-white\">\n                  {step.index}\n                </div>\n                <div>\n                  <h4 className=\"mb-2 font-medium text-gray-900 dark:text-white\">{step.title}</h4>\n                  <p className=\"text-sm text-gray-700 dark:text-gray-300\">{step.description}</p>\n\n                  {step.index === 1 && (\n                    <button\n                      onClick={() => {\n                        onCreatePrd();\n                        onClose();\n                      }}\n                      className=\"mt-3 inline-flex items-center gap-2 rounded-lg bg-purple-100 px-3 py-1.5 text-sm text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-300 dark:hover:bg-purple-900/50\"\n                    >\n                      <FileText className=\"h-4 w-4\" />\n                      {t('buttons.addPRD')}\n                    </button>\n                  )}\n                </div>\n              </div>\n            </div>\n          ))}\n\n          <div className=\"rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50\">\n            <h4 className=\"mb-2 font-medium text-gray-900 dark:text-white\">{t('helpGuide.proTips.title')}</h4>\n            <ul className=\"space-y-2 text-sm text-gray-600 dark:text-gray-400\">\n              <li>{t('helpGuide.proTips.search')}</li>\n              <li>{t('helpGuide.proTips.views')}</li>\n              <li>{t('helpGuide.proTips.filters')}</li>\n              <li>{t('helpGuide.proTips.details')}</li>\n            </ul>\n          </div>\n\n          <div className=\"rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-950/40\">\n            <h4 className=\"mb-2 font-medium text-blue-900 dark:text-blue-100\">{t('helpGuide.learnMore.title')}</h4>\n            <p className=\"mb-3 text-sm text-blue-800 dark:text-blue-200\">{t('helpGuide.learnMore.description')}</p>\n            <a\n              href=\"https://github.com/eyaltoledano/claude-task-master\"\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"inline-flex items-center gap-2 rounded-lg bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-700\"\n            >\n              {t('helpGuide.learnMore.githubButton')}\n              <ExternalLink className=\"h-4 w-4\" />\n            </a>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/task-master/view/modals/TaskMasterSetupModal.tsx",
    "content": "import { useState } from 'react';\nimport { Plus, Terminal } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { cn } from '../../../../lib/utils';\nimport Shell from '../../../shell/view/Shell';\nimport type { TaskMasterProject } from '../../types';\n\ntype TaskMasterSetupModalProps = {\n  isOpen: boolean;\n  project: TaskMasterProject | null;\n  onClose: () => void;\n  onAfterClose?: (() => void) | null;\n};\n\nexport default function TaskMasterSetupModal({ isOpen, project, onClose, onAfterClose = null }: TaskMasterSetupModalProps) {\n  const { t } = useTranslation('tasks');\n  const [isTaskMasterComplete, setIsTaskMasterComplete] = useState(false);\n\n  if (!isOpen || !project) {\n    return null;\n  }\n\n  const closeModal = () => {\n    onClose();\n    setIsTaskMasterComplete(false);\n\n    // Delay refresh slightly so the CLI has time to flush writes to disk.\n    window.setTimeout(() => {\n      onAfterClose?.();\n    }, 800);\n  };\n\n  return (\n    <div className=\"fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4 pt-16 backdrop-blur-sm\">\n      <div className=\"flex h-[600px] w-full max-w-4xl flex-col rounded-lg border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-900\">\n        <div className=\"flex items-center justify-between border-b border-gray-200 p-4 dark:border-gray-700\">\n          <div className=\"flex items-center gap-3\">\n            <div className=\"flex h-8 w-8 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/50\">\n              <Terminal className=\"h-4 w-4 text-blue-600 dark:text-blue-400\" />\n            </div>\n            <div>\n              <h2 className=\"text-lg font-semibold text-gray-900 dark:text-white\">{t('setupModal.title')}</h2>\n              <p className=\"text-sm text-gray-500 dark:text-gray-400\">{t('setupModal.subtitle', { projectName: project.displayName })}</p>\n            </div>\n          </div>\n\n          <button\n            onClick={closeModal}\n            className=\"rounded-md p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300\"\n            title=\"Close\"\n          >\n            <Plus className=\"h-5 w-5 rotate-45\" />\n          </button>\n        </div>\n\n        <div className=\"flex-1 p-4\">\n          <div className=\"h-full overflow-hidden rounded-lg bg-black\">\n            <Shell\n              selectedProject={project}\n              selectedSession={null}\n              initialCommand=\"npx task-master init\"\n              isPlainShell\n              isActive\n              onProcessComplete={(exitCode) => {\n                if (exitCode === 0) {\n                  setIsTaskMasterComplete(true);\n                }\n              }}\n            />\n          </div>\n        </div>\n\n        <div className=\"border-t border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50\">\n          <div className=\"flex items-center justify-between\">\n            <div className=\"text-sm text-gray-600 dark:text-gray-400\">\n              {isTaskMasterComplete ? (\n                <span className=\"flex items-center gap-2 text-green-600 dark:text-green-400\">\n                  <span className=\"h-2 w-2 rounded-full bg-green-500\" />\n                  {t('setupModal.completed')}\n                </span>\n              ) : (\n                t('setupModal.willStart')\n              )}\n            </div>\n\n            <button\n              onClick={closeModal}\n              className={cn(\n                'px-4 py-2 text-sm font-medium rounded-md transition-colors',\n                isTaskMasterComplete\n                  ? 'bg-green-600 hover:bg-green-700 text-white'\n                  : 'text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600',\n              )}\n            >\n              {isTaskMasterComplete ? t('setupModal.closeContinueButton') : t('setupModal.closeButton')}\n            </button>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/task-master/view/shared/TaskFiltersPanel.tsx",
    "content": "import { useTranslation } from 'react-i18next';\nimport type { TaskBoardSortField, TaskBoardSortOrder } from '../../types';\n\ntype TaskFiltersPanelProps = {\n  showFilters: boolean;\n  statusFilter: string;\n  onStatusFilterChange: (status: string) => void;\n  priorityFilter: string;\n  onPriorityFilterChange: (priority: string) => void;\n  sortField: TaskBoardSortField;\n  sortOrder: TaskBoardSortOrder;\n  onSortConfigChange: (field: TaskBoardSortField, order: TaskBoardSortOrder) => void;\n  statuses: string[];\n  priorities: string[];\n  filteredTaskCount: number;\n  totalTaskCount: number;\n  onClearFilters: () => void;\n};\n\nexport default function TaskFiltersPanel({\n  showFilters,\n  statusFilter,\n  onStatusFilterChange,\n  priorityFilter,\n  onPriorityFilterChange,\n  sortField,\n  sortOrder,\n  onSortConfigChange,\n  statuses,\n  priorities,\n  filteredTaskCount,\n  totalTaskCount,\n  onClearFilters,\n}: TaskFiltersPanelProps) {\n  const { t } = useTranslation('tasks');\n\n  if (!showFilters) {\n    return null;\n  }\n\n  return (\n    <div className=\"space-y-4 rounded-lg bg-gray-50 p-4 dark:bg-gray-800\">\n      <div className=\"grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3\">\n        <div>\n          <label className=\"mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300\">{t('filters.status')}</label>\n          <select\n            value={statusFilter}\n            onChange={(event) => onStatusFilterChange(event.target.value)}\n            className=\"w-full rounded-md border border-gray-300 bg-white px-3 py-2 dark:border-gray-600 dark:bg-gray-800\"\n          >\n            <option value=\"all\">{t('filters.allStatuses')}</option>\n            {statuses.map((status) => (\n              <option key={status} value={status}>\n                {t(`statuses.${status}`, status)}\n              </option>\n            ))}\n          </select>\n        </div>\n\n        <div>\n          <label className=\"mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300\">{t('filters.priority')}</label>\n          <select\n            value={priorityFilter}\n            onChange={(event) => onPriorityFilterChange(event.target.value)}\n            className=\"w-full rounded-md border border-gray-300 bg-white px-3 py-2 dark:border-gray-600 dark:bg-gray-800\"\n          >\n            <option value=\"all\">{t('filters.allPriorities')}</option>\n            {priorities.map((priority) => (\n              <option key={priority} value={priority}>\n                {t(`priorities.${priority}`, priority)}\n              </option>\n            ))}\n          </select>\n        </div>\n\n        <div>\n          <label className=\"mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300\">{t('filters.sortBy')}</label>\n          <select\n            value={`${sortField}-${sortOrder}`}\n            onChange={(event) => {\n              const [field, order] = event.target.value.split('-') as [TaskBoardSortField, TaskBoardSortOrder];\n              onSortConfigChange(field, order);\n            }}\n            className=\"w-full rounded-md border border-gray-300 bg-white px-3 py-2 dark:border-gray-600 dark:bg-gray-800\"\n          >\n            <option value=\"id-asc\">{t('sort.idAsc')}</option>\n            <option value=\"id-desc\">{t('sort.idDesc')}</option>\n            <option value=\"title-asc\">{t('sort.titleAsc')}</option>\n            <option value=\"title-desc\">{t('sort.titleDesc')}</option>\n            <option value=\"status-asc\">{t('sort.statusAsc')}</option>\n            <option value=\"status-desc\">{t('sort.statusDesc')}</option>\n            <option value=\"priority-asc\">{t('sort.priorityAsc')}</option>\n            <option value=\"priority-desc\">{t('sort.priorityDesc')}</option>\n          </select>\n        </div>\n      </div>\n\n      <div className=\"flex items-center justify-between\">\n        <div className=\"text-sm text-gray-600 dark:text-gray-400\">\n          {t('filters.showing', { filtered: filteredTaskCount, total: totalTaskCount })}\n        </div>\n        <button onClick={onClearFilters} className=\"text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300\">\n          {t('filters.clearFilters')}\n        </button>\n      </div>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/task-master/view/shared/TaskQuickSortBar.tsx",
    "content": "import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react';\nimport { useTranslation } from 'react-i18next';\nimport { cn } from '../../../../lib/utils';\nimport type { TaskBoardSortField, TaskBoardSortOrder } from '../../types';\n\ntype TaskQuickSortBarProps = {\n  sortField: TaskBoardSortField;\n  sortOrder: TaskBoardSortOrder;\n  onSortChange: (field: TaskBoardSortField) => void;\n};\n\nfunction getSortIcon(field: TaskBoardSortField, currentField: TaskBoardSortField, currentOrder: TaskBoardSortOrder) {\n  if (field !== currentField) {\n    return <ArrowUpDown className=\"h-4 w-4\" />;\n  }\n\n  return currentOrder === 'asc' ? <ArrowUp className=\"h-4 w-4\" /> : <ArrowDown className=\"h-4 w-4\" />;\n}\n\nexport default function TaskQuickSortBar({ sortField, sortOrder, onSortChange }: TaskQuickSortBarProps) {\n  const { t } = useTranslation('tasks');\n\n  return (\n    <div className=\"flex flex-wrap gap-2\">\n      <button\n        onClick={() => onSortChange('id')}\n        className={cn(\n          'flex items-center gap-1 px-3 py-1.5 rounded-md text-sm',\n          sortField === 'id'\n            ? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300'\n            : 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700',\n        )}\n      >\n        {t('sort.id')} {getSortIcon('id', sortField, sortOrder)}\n      </button>\n\n      <button\n        onClick={() => onSortChange('status')}\n        className={cn(\n          'flex items-center gap-1 px-3 py-1.5 rounded-md text-sm',\n          sortField === 'status'\n            ? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300'\n            : 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700',\n        )}\n      >\n        {t('sort.status')} {getSortIcon('status', sortField, sortOrder)}\n      </button>\n\n      <button\n        onClick={() => onSortChange('priority')}\n        className={cn(\n          'flex items-center gap-1 px-3 py-1.5 rounded-md text-sm',\n          sortField === 'priority'\n            ? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300'\n            : 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700',\n        )}\n      >\n        {t('sort.priority')} {getSortIcon('priority', sortField, sortOrder)}\n      </button>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/components/version-upgrade/view/VersionUpgradeModal.tsx",
    "content": "import { useCallback, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { authenticatedFetch } from \"../../../utils/api\";\nimport { ReleaseInfo } from \"../../../types/sharedTypes\";\nimport { copyTextToClipboard } from \"../../../utils/clipboard\";\nimport type { InstallMode } from \"../../../hooks/useVersionCheck\";\n\ninterface VersionUpgradeModalProps {\n    isOpen: boolean;\n    onClose: () => void;\n    releaseInfo: ReleaseInfo | null;\n    currentVersion: string;\n    latestVersion: string | null;\n    installMode: InstallMode;\n}\n\nexport function VersionUpgradeModal({\n    isOpen,\n    onClose,\n    releaseInfo,\n    currentVersion,\n    latestVersion,\n    installMode\n}: VersionUpgradeModalProps) {\n    const { t } = useTranslation('common');\n    const upgradeCommand = installMode === 'npm'\n        ? t('versionUpdate.npmUpgradeCommand')\n        : 'git checkout main && git pull && npm install';\n    const [isUpdating, setIsUpdating] = useState(false);\n    const [updateOutput, setUpdateOutput] = useState('');\n    const [updateError, setUpdateError] = useState('');\n\n    const handleUpdateNow = useCallback(async () => {\n        setIsUpdating(true);\n        setUpdateOutput('Starting update...\\n');\n        setUpdateError('');\n\n        try {\n            // Call the backend API to run the update command\n            const response = await authenticatedFetch('/api/system/update', {\n                method: 'POST',\n            });\n\n            const data = await response.json();\n\n            if (response.ok) {\n                setUpdateOutput(prev => prev + data.output + '\\n');\n                setUpdateOutput(prev => prev + '\\n✅ Update completed successfully!\\n');\n                setUpdateOutput(prev => prev + 'Please restart the server to apply changes.\\n');\n            } else {\n                setUpdateError(data.error || 'Update failed');\n                setUpdateOutput(prev => prev + '\\n❌ Update failed: ' + (data.error || 'Unknown error') + '\\n');\n            }\n        } catch (error: any) {\n            setUpdateError(error.message);\n            setUpdateOutput(prev => prev + '\\n❌ Update failed: ' + error.message + '\\n');\n        } finally {\n            setIsUpdating(false);\n        }\n    }, []);\n\n    if (!isOpen) return null;\n\n    return (\n        <div className=\"fixed inset-0 z-50 flex items-center justify-center\">\n            {/* Backdrop */}\n            <button\n                className=\"fixed inset-0 bg-black/50 backdrop-blur-sm\"\n                onClick={onClose}\n                aria-label={t('versionUpdate.ariaLabels.closeModal')}\n            />\n\n            {/* Modal */}\n            <div className=\"relative mx-4 max-h-[90vh] w-full max-w-2xl space-y-4 overflow-y-auto rounded-lg border border-gray-200 bg-white p-6 shadow-xl dark:border-gray-700 dark:bg-gray-800\">\n                {/* Header */}\n                <div className=\"flex items-center justify-between\">\n                    <div className=\"flex items-center gap-3\">\n                        <div className=\"flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/30\">\n                            <svg className=\"h-5 w-5 text-blue-600 dark:text-blue-400\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10\" />\n                            </svg>\n                        </div>\n                        <div>\n                            <h2 className=\"text-lg font-semibold text-gray-900 dark:text-white\">{t('versionUpdate.title')}</h2>\n                            <p className=\"text-sm text-gray-500 dark:text-gray-400\">\n                                {releaseInfo?.title || t('versionUpdate.newVersionReady')}\n                            </p>\n                        </div>\n                    </div>\n                    <button\n                        onClick={onClose}\n                        className=\"rounded-md p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300\"\n                    >\n                        <svg className=\"h-5 w-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M6 18L18 6M6 6l12 12\" />\n                        </svg>\n                    </button>\n                </div>\n\n                {/* Version Info */}\n                <div className=\"space-y-3\">\n                    <div className=\"flex items-center justify-between rounded-lg bg-gray-50 p-3 dark:bg-gray-700/50\">\n                        <span className=\"text-sm font-medium text-gray-700 dark:text-gray-300\">{t('versionUpdate.currentVersion')}</span>\n                        <span className=\"font-mono text-sm text-gray-900 dark:text-white\">{currentVersion}</span>\n                    </div>\n                    <div className=\"flex items-center justify-between rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-700 dark:bg-blue-900/20\">\n                        <span className=\"text-sm font-medium text-blue-700 dark:text-blue-300\">{t('versionUpdate.latestVersion')}</span>\n                        <span className=\"font-mono text-sm text-blue-900 dark:text-blue-100\">{latestVersion}</span>\n                    </div>\n                </div>\n\n                {/* Changelog */}\n                {releaseInfo?.body && (\n                    <div className=\"space-y-3\">\n                        <div className=\"flex items-center justify-between\">\n                            <h3 className=\"text-sm font-medium text-gray-900 dark:text-white\">{t('versionUpdate.whatsNew')}</h3>\n                            {releaseInfo?.htmlUrl && (\n                                <a\n                                    href={releaseInfo.htmlUrl}\n                                    target=\"_blank\"\n                                    rel=\"noopener noreferrer\"\n                                    className=\"flex items-center gap-1 text-xs text-blue-600 hover:text-blue-700 hover:underline dark:text-blue-400 dark:hover:text-blue-300\"\n                                >\n                                    {t('versionUpdate.viewFullRelease')}\n                                    <svg className=\"h-3 w-3\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                        <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14\" />\n                                    </svg>\n                                </a>\n                            )}\n                        </div>\n                        <div className=\"max-h-64 overflow-y-auto rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-600 dark:bg-gray-700/50\">\n                            <div className=\"prose prose-sm max-w-none whitespace-pre-wrap text-sm text-gray-700 dark:prose-invert dark:text-gray-300\">\n                                {cleanChangelog(releaseInfo.body)}\n                            </div>\n                        </div>\n                    </div>\n                )}\n\n                {/* Update Output */}\n                {(updateOutput || updateError) && (\n                    <div className=\"space-y-2\">\n                        <h3 className=\"text-sm font-medium text-gray-900 dark:text-white\">{t('versionUpdate.updateProgress')}</h3>\n                        <div className=\"max-h-48 overflow-y-auto rounded-lg border border-gray-700 bg-gray-900 p-4 dark:bg-gray-950\">\n                            <pre className=\"whitespace-pre-wrap font-mono text-xs text-green-400\">{updateOutput}</pre>\n                        </div>\n                        {updateError && (\n                            <div className=\"rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-900/40 dark:bg-red-900/20 dark:text-red-200\">\n                                {updateError}\n                            </div>\n                        )}\n                    </div>\n                )}\n\n                {/* Upgrade Instructions */}\n                {!isUpdating && !updateOutput && (\n                    <div className=\"space-y-3\">\n                        <h3 className=\"text-sm font-medium text-gray-900 dark:text-white\">{t('versionUpdate.manualUpgrade')}</h3>\n                        <div className=\"rounded-lg border bg-gray-100 p-3 dark:bg-gray-800\">\n                            <code className=\"font-mono text-sm text-gray-800 dark:text-gray-200\">\n                                {upgradeCommand}\n                            </code>\n                        </div>\n                        <p className=\"text-xs text-gray-600 dark:text-gray-400\">\n                            {t('versionUpdate.manualUpgradeHint')}\n                        </p>\n                    </div>\n                )}\n\n                {/* Actions */}\n                <div className=\"flex gap-2 pt-2\">\n                    <button\n                        onClick={onClose}\n                        className=\"flex-1 rounded-md bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600\"\n                    >\n                        {updateOutput ? t('versionUpdate.buttons.close') : t('versionUpdate.buttons.later')}\n                    </button>\n                    {!updateOutput && (\n                        <>\n                            <button\n                                onClick={() => copyTextToClipboard(upgradeCommand)}\n                                className=\"flex-1 rounded-md bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600\"\n                            >\n                                {t('versionUpdate.buttons.copyCommand')}\n                            </button>\n                            <button\n                                onClick={handleUpdateNow}\n                                disabled={isUpdating}\n                                className=\"flex flex-1 items-center justify-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-blue-400\"\n                            >\n                                {isUpdating ? (\n                                    <>\n                                        <div className=\"h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent\" />\n                                        {t('versionUpdate.buttons.updating')}\n                                    </>\n                                ) : (\n                                    t('versionUpdate.buttons.updateNow')\n                                )}\n                            </button>\n                        </>\n                    )}\n                </div>\n            </div>\n        </div>\n    );\n};\n\n// Clean up changelog by removing GitHub-specific metadata\nconst cleanChangelog = (body: string) => {\n    if (!body) return '';\n\n    return body\n        // Remove full commit hashes (40 character hex strings)\n        .replace(/\\b[0-9a-f]{40}\\b/gi, '')\n        // Remove short commit hashes (7-10 character hex strings at start of line or after dash/space)\n        .replace(/(?:^|\\s|-)([0-9a-f]{7,10})\\b/gi, '')\n        // Remove \"Full Changelog\" links\n        .replace(/\\*\\*Full Changelog\\*\\*:.*$/gim, '')\n        // Remove compare links (e.g., https://github.com/.../compare/v1.0.0...v1.0.1)\n        .replace(/https?:\\/\\/github\\.com\\/[^\\/]+\\/[^\\/]+\\/compare\\/[^\\s)]+/gi, '')\n        // Clean up multiple consecutive empty lines\n        .replace(/\\n\\s*\\n\\s*\\n/g, '\\n\\n')\n        // Trim whitespace\n        .trim();\n};\n"
  },
  {
    "path": "src/components/version-upgrade/view/index.ts",
    "content": "export { VersionUpgradeModal as default } from \"./VersionUpgradeModal\";"
  },
  {
    "path": "src/constants/config.ts",
    "content": "/**\n * Environment Flag: Is Platform\n * Indicates if the app is running in Platform mode (hosted) or OSS mode (self-hosted)\n */\nexport const IS_PLATFORM = import.meta.env.VITE_IS_PLATFORM === 'true';"
  },
  {
    "path": "src/contexts/AuthContext.jsx",
    "content": "export { AuthProvider, useAuth } from '../components/auth/context/AuthContext';\n"
  },
  {
    "path": "src/contexts/PluginsContext.tsx",
    "content": "import { createContext, useCallback, useContext, useEffect, useState } from 'react';\nimport type { ReactNode } from 'react';\nimport { authenticatedFetch } from '../utils/api';\n\nexport type Plugin = {\n  name: string;\n  displayName: string;\n  version: string;\n  description: string;\n  author: string;\n  icon: string;\n  type: 'react' | 'module';\n  slot: 'tab';\n  entry: string;\n  server: string | null;\n  permissions: string[];\n  enabled: boolean;\n  serverRunning: boolean;\n  dirName: string;\n  repoUrl: string | null;\n};\n\ntype PluginsContextValue = {\n  plugins: Plugin[];\n  loading: boolean;\n  pluginsError: string | null;\n  refreshPlugins: () => Promise<void>;\n  installPlugin: (url: string) => Promise<{ success: boolean; error?: string }>;\n  uninstallPlugin: (name: string) => Promise<{ success: boolean; error?: string }>;\n  updatePlugin: (name: string) => Promise<{ success: boolean; error?: string }>;\n  togglePlugin: (name: string, enabled: boolean) => Promise<{ success: boolean; error: string | null }>;\n};\n\nconst PluginsContext = createContext<PluginsContextValue | null>(null);\n\nexport function usePlugins() {\n  const context = useContext(PluginsContext);\n  if (!context) {\n    throw new Error('usePlugins must be used within a PluginsProvider');\n  }\n  return context;\n}\n\nexport function PluginsProvider({ children }: { children: ReactNode }) {\n  const [plugins, setPlugins] = useState<Plugin[]>([]);\n  const [loading, setLoading] = useState(true);\n  const [pluginsError, setPluginsError] = useState<string | null>(null);\n\n  const refreshPlugins = useCallback(async () => {\n    try {\n      const res = await authenticatedFetch('/api/plugins');\n      if (res.ok) {\n        const data = await res.json();\n        setPlugins(data.plugins || []);\n        setPluginsError(null);\n      } else {\n        let errorMessage = `Failed to fetch plugins (${res.status})`;\n        try {\n          const data = await res.json();\n          errorMessage = data.details || data.error || errorMessage;\n        } catch {\n          errorMessage = res.statusText || errorMessage;\n        }\n        setPluginsError(errorMessage);\n      }\n    } catch (err) {\n      const message = err instanceof Error ? err.message : 'Failed to fetch plugins';\n      setPluginsError(message);\n      console.error('[Plugins] Failed to fetch plugins:', err);\n    } finally {\n      setLoading(false);\n    }\n  }, []);\n\n  useEffect(() => {\n    void refreshPlugins();\n  }, [refreshPlugins]);\n\n  const installPlugin = useCallback(async (url: string) => {\n    try {\n      const res = await authenticatedFetch('/api/plugins/install', {\n        method: 'POST',\n        body: JSON.stringify({ url }),\n      });\n      const data = await res.json();\n      if (res.ok) {\n        await refreshPlugins();\n        return { success: true };\n      }\n      return { success: false, error: data.details || data.error || 'Install failed' };\n    } catch (err) {\n      return { success: false, error: err instanceof Error ? err.message : 'Install failed' };\n    }\n  }, [refreshPlugins]);\n\n  const uninstallPlugin = useCallback(async (name: string) => {\n    try {\n      const res = await authenticatedFetch(`/api/plugins/${encodeURIComponent(name)}`, {\n        method: 'DELETE',\n      });\n      const data = await res.json();\n      if (res.ok) {\n        await refreshPlugins();\n        return { success: true };\n      }\n      return { success: false, error: data.details || data.error || 'Uninstall failed' };\n    } catch (err) {\n      return { success: false, error: err instanceof Error ? err.message : 'Uninstall failed' };\n    }\n  }, [refreshPlugins]);\n\n  const updatePlugin = useCallback(async (name: string) => {\n    try {\n      const res = await authenticatedFetch(`/api/plugins/${encodeURIComponent(name)}/update`, {\n        method: 'POST',\n      });\n      const data = await res.json();\n      if (res.ok) {\n        await refreshPlugins();\n        return { success: true };\n      }\n      return { success: false, error: data.details || data.error || 'Update failed' };\n    } catch (err) {\n      return { success: false, error: err instanceof Error ? err.message : 'Update failed' };\n    }\n  }, [refreshPlugins]);\n\n  const togglePlugin = useCallback(async (name: string, enabled: boolean): Promise<{ success: boolean; error: string | null }> => {\n    try {\n      const res = await authenticatedFetch(`/api/plugins/${encodeURIComponent(name)}/enable`, {\n        method: 'PUT',\n        body: JSON.stringify({ enabled }),\n      });\n      if (!res.ok) {\n        let errorMessage = `Toggle failed (${res.status})`;\n        try {\n          const data = await res.json();\n          errorMessage = data.details || data.error || errorMessage;\n        } catch {\n          // response body wasn't JSON, use status text\n          errorMessage = res.statusText || errorMessage;\n        }\n        return { success: false, error: errorMessage };\n      }\n      await refreshPlugins();\n      return { success: true, error: null };\n    } catch (err) {\n      return { success: false, error: err instanceof Error ? err.message : 'Toggle failed' };\n    }\n  }, [refreshPlugins]);\n\n  return (\n    <PluginsContext.Provider value={{ plugins, loading, pluginsError, refreshPlugins, installPlugin, uninstallPlugin, updatePlugin, togglePlugin }}>\n      {children}\n    </PluginsContext.Provider>\n  );\n}\n"
  },
  {
    "path": "src/contexts/TaskMasterContext.ts",
    "content": "export {\n  TaskMasterProvider,\n  useTaskMaster,\n} from '../components/task-master/context/TaskMasterContext';\n\nexport { default } from '../components/task-master/context/TaskMasterContext';\n"
  },
  {
    "path": "src/contexts/TasksSettingsContext.jsx",
    "content": "import React, { createContext, useContext, useState, useEffect } from 'react';\nimport { api } from '../utils/api';\n\nconst TasksSettingsContext = createContext({\n  tasksEnabled: true,\n  setTasksEnabled: () => {},\n  toggleTasksEnabled: () => {},\n  isTaskMasterInstalled: null,\n  isTaskMasterReady: null,\n  installationStatus: null,\n  isCheckingInstallation: true\n});\n\nexport const useTasksSettings = () => {\n  const context = useContext(TasksSettingsContext);\n  if (!context) {\n    throw new Error('useTasksSettings must be used within a TasksSettingsProvider');\n  }\n  return context;\n};\n\nexport const TasksSettingsProvider = ({ children }) => {\n  const [tasksEnabled, setTasksEnabled] = useState(() => {\n    // Load from localStorage on initialization\n    const saved = localStorage.getItem('tasks-enabled');\n    return saved !== null ? JSON.parse(saved) : true; // Default to true\n  });\n  \n  const [isTaskMasterInstalled, setIsTaskMasterInstalled] = useState(null);\n  const [isTaskMasterReady, setIsTaskMasterReady] = useState(null);\n  const [installationStatus, setInstallationStatus] = useState(null);\n  const [isCheckingInstallation, setIsCheckingInstallation] = useState(true);\n\n  // Save to localStorage whenever tasksEnabled changes\n  useEffect(() => {\n    localStorage.setItem('tasks-enabled', JSON.stringify(tasksEnabled));\n  }, [tasksEnabled]);\n\n  // Check TaskMaster installation status asynchronously on component mount\n  useEffect(() => {\n    const checkInstallation = async () => {\n      try {\n        const response = await api.get('/taskmaster/installation-status');\n        if (response.ok) {\n          const data = await response.json();\n          setInstallationStatus(data);\n          setIsTaskMasterInstalled(data.installation?.isInstalled || false);\n          setIsTaskMasterReady(data.isReady || false);\n          \n          // If TaskMaster is not installed and user hasn't explicitly enabled tasks,\n          // disable tasks automatically\n          const userEnabledTasks = localStorage.getItem('tasks-enabled');\n          if (!data.installation?.isInstalled && !userEnabledTasks) {\n            setTasksEnabled(false);\n          }\n        } else {\n          console.error('Failed to check TaskMaster installation status');\n          setIsTaskMasterInstalled(false);\n          setIsTaskMasterReady(false);\n        }\n      } catch (error) {\n        console.error('Error checking TaskMaster installation:', error);\n        setIsTaskMasterInstalled(false);\n        setIsTaskMasterReady(false);\n      } finally {\n        setIsCheckingInstallation(false);\n      }\n    };\n\n    // Run check asynchronously without blocking initial render\n    setTimeout(checkInstallation, 0);\n  }, []);\n\n  const toggleTasksEnabled = () => {\n    setTasksEnabled(prev => !prev);\n  };\n\n  const contextValue = {\n    tasksEnabled,\n    setTasksEnabled,\n    toggleTasksEnabled,\n    isTaskMasterInstalled,\n    isTaskMasterReady,\n    installationStatus,\n    isCheckingInstallation\n  };\n\n  return (\n    <TasksSettingsContext.Provider value={contextValue}>\n      {children}\n    </TasksSettingsContext.Provider>\n  );\n};\n\nexport default TasksSettingsContext;"
  },
  {
    "path": "src/contexts/ThemeContext.jsx",
    "content": "import React, { createContext, useContext, useState, useEffect } from 'react';\n\nconst ThemeContext = createContext();\n\nexport const useTheme = () => {\n  const context = useContext(ThemeContext);\n  if (!context) {\n    throw new Error('useTheme must be used within a ThemeProvider');\n  }\n  return context;\n};\n\nexport const ThemeProvider = ({ children }) => {\n  // Check for saved theme preference or default to system preference\n  const [isDarkMode, setIsDarkMode] = useState(() => {\n    // Check localStorage first\n    const savedTheme = localStorage.getItem('theme');\n    if (savedTheme) {\n      return savedTheme === 'dark';\n    }\n    \n    // Check system preference\n    if (window.matchMedia) {\n      return window.matchMedia('(prefers-color-scheme: dark)').matches;\n    }\n    \n    return false;\n  });\n\n  // Update document class and localStorage when theme changes\n  useEffect(() => {\n    if (isDarkMode) {\n      document.documentElement.classList.add('dark');\n      localStorage.setItem('theme', 'dark');\n      \n      // Update iOS status bar style and theme color for dark mode\n      const statusBarMeta = document.querySelector('meta[name=\"apple-mobile-web-app-status-bar-style\"]');\n      if (statusBarMeta) {\n        statusBarMeta.setAttribute('content', 'black-translucent');\n      }\n      \n      const themeColorMeta = document.querySelector('meta[name=\"theme-color\"]');\n      if (themeColorMeta) {\n        themeColorMeta.setAttribute('content', '#0c1117'); // Dark background color (hsl(222.2 84% 4.9%))\n      }\n    } else {\n      document.documentElement.classList.remove('dark');\n      localStorage.setItem('theme', 'light');\n      \n      // Update iOS status bar style and theme color for light mode\n      const statusBarMeta = document.querySelector('meta[name=\"apple-mobile-web-app-status-bar-style\"]');\n      if (statusBarMeta) {\n        statusBarMeta.setAttribute('content', 'default');\n      }\n      \n      const themeColorMeta = document.querySelector('meta[name=\"theme-color\"]');\n      if (themeColorMeta) {\n        themeColorMeta.setAttribute('content', '#ffffff'); // Light background color\n      }\n    }\n  }, [isDarkMode]);\n\n  // Listen for system theme changes\n  useEffect(() => {\n    if (!window.matchMedia) return;\n\n    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');\n    const handleChange = (e) => {\n      // Only update if user hasn't manually set a preference\n      const savedTheme = localStorage.getItem('theme');\n      if (!savedTheme) {\n        setIsDarkMode(e.matches);\n      }\n    };\n\n    mediaQuery.addEventListener('change', handleChange);\n    return () => mediaQuery.removeEventListener('change', handleChange);\n  }, []);\n\n  const toggleDarkMode = () => {\n    setIsDarkMode(prev => !prev);\n  };\n\n  const value = {\n    isDarkMode,\n    toggleDarkMode,\n  };\n\n  return (\n    <ThemeContext.Provider value={value}>\n      {children}\n    </ThemeContext.Provider>\n  );\n};"
  },
  {
    "path": "src/contexts/WebSocketContext.tsx",
    "content": "import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';\nimport { useAuth } from '../components/auth/context/AuthContext';\nimport { IS_PLATFORM } from '../constants/config';\n\ntype WebSocketContextType = {\n  ws: WebSocket | null;\n  sendMessage: (message: any) => void;\n  latestMessage: any | null;\n  isConnected: boolean;\n};\n\nconst WebSocketContext = createContext<WebSocketContextType | null>(null);\n\nexport const useWebSocket = () => {\n  const context = useContext(WebSocketContext);\n  if (!context) {\n    throw new Error('useWebSocket must be used within a WebSocketProvider');\n  }\n  return context;\n};\n\nconst buildWebSocketUrl = (token: string | null) => {\n  const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';\n  if (IS_PLATFORM) return `${protocol}//${window.location.host}/ws`; // Platform mode: Use same domain as the page (goes through proxy)\n  if (!token) return null;\n  return `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`; // OSS mode: Use same host:port that served the page\n};\n\nconst useWebSocketProviderState = (): WebSocketContextType => {\n  const wsRef = useRef<WebSocket | null>(null);\n  const unmountedRef = useRef(false); // Track if component is unmounted\n  const hasConnectedRef = useRef(false); // Track if we've ever connected (to detect reconnects)\n  const [latestMessage, setLatestMessage] = useState<any>(null);\n  const [isConnected, setIsConnected] = useState(false);\n  const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);\n  const { token } = useAuth();\n\n  useEffect(() => {\n    connect();\n    \n    return () => {\n      unmountedRef.current = true;\n      if (reconnectTimeoutRef.current) {\n        clearTimeout(reconnectTimeoutRef.current);\n      }\n      if (wsRef.current) {\n        wsRef.current.close();\n      }\n    };\n  }, [token]); // everytime token changes, we reconnect\n\n  const connect = useCallback(() => {\n    if (unmountedRef.current) return; // Prevent connection if unmounted\n    try {\n      // Construct WebSocket URL\n      const wsUrl = buildWebSocketUrl(token);\n\n      if (!wsUrl) return console.warn('No authentication token found for WebSocket connection');\n      \n      const websocket = new WebSocket(wsUrl);\n\n      websocket.onopen = () => {\n        setIsConnected(true);\n        wsRef.current = websocket;\n        if (hasConnectedRef.current) {\n          // This is a reconnect — signal so components can catch up on missed messages\n          setLatestMessage({ type: 'websocket-reconnected', timestamp: Date.now() });\n        }\n        hasConnectedRef.current = true;\n      };\n\n      websocket.onmessage = (event) => {\n        try {\n          const data = JSON.parse(event.data);\n          setLatestMessage(data);\n        } catch (error) {\n          console.error('Error parsing WebSocket message:', error);\n        }\n      };\n\n      websocket.onclose = () => {\n        setIsConnected(false);\n        wsRef.current = null;\n        \n        // Attempt to reconnect after 3 seconds\n        reconnectTimeoutRef.current = setTimeout(() => {\n          if (unmountedRef.current) return; // Prevent reconnection if unmounted\n          connect();\n        }, 3000);\n      };\n\n      websocket.onerror = (error) => {\n        console.error('WebSocket error:', error);\n      };\n\n    } catch (error) {\n      console.error('Error creating WebSocket connection:', error);\n    }\n  }, [token]); // everytime token changes, we reconnect\n\n  const sendMessage = useCallback((message: any) => {\n    const socket = wsRef.current;\n    if (socket && socket.readyState === WebSocket.OPEN) {\n      socket.send(JSON.stringify(message));\n    } else {\n      console.warn('WebSocket not connected');\n    }\n  }, []);\n\n  const value: WebSocketContextType = useMemo(() =>\n  ({\n    ws: wsRef.current,\n    sendMessage,\n    latestMessage,\n    isConnected\n  }), [sendMessage, latestMessage, isConnected]);\n\n  return value;\n};\n\nexport const WebSocketProvider = ({ children }: { children: React.ReactNode }) => {\n  const webSocketData = useWebSocketProviderState();\n  \n  return (\n    <WebSocketContext.Provider value={webSocketData}>\n      {children}\n    </WebSocketContext.Provider>\n  );\n};\n\nexport default WebSocketContext;\n"
  },
  {
    "path": "src/hooks/useDeviceSettings.ts",
    "content": "import { useEffect, useState } from 'react';\n\ntype UseDeviceSettingsOptions = {\n  mobileBreakpoint?: number;\n  trackMobile?: boolean;\n  trackPWA?: boolean;\n};\n\nconst getIsMobile = (mobileBreakpoint: number): boolean => {\n  if (typeof window === 'undefined') {\n    return false;\n  }\n\n  return window.innerWidth < mobileBreakpoint;\n};\n\nconst getIsPWA = (): boolean => {\n  if (typeof window === 'undefined') {\n    return false;\n  }\n\n  const navigatorWithStandalone = window.navigator as Navigator & { standalone?: boolean };\n\n  return (\n    window.matchMedia('(display-mode: standalone)').matches ||\n    Boolean(navigatorWithStandalone.standalone) ||\n    document.referrer.includes('android-app://')\n  );\n};\n\nexport function useDeviceSettings(options: UseDeviceSettingsOptions = {}) {\n  const {\n    mobileBreakpoint = 768,\n    trackMobile = true,\n    trackPWA = true\n  } = options;\n\n  const [isMobile, setIsMobile] = useState<boolean>(() => (\n    trackMobile ? getIsMobile(mobileBreakpoint) : false\n  ));\n  const [isPWA, setIsPWA] = useState<boolean>(() => (\n    trackPWA ? getIsPWA() : false\n  ));\n\n  useEffect(() => {\n    if (!trackMobile || typeof window === 'undefined') {\n      return;\n    }\n\n    const checkMobile = () => {\n      setIsMobile(getIsMobile(mobileBreakpoint));\n    };\n\n    checkMobile();\n    window.addEventListener('resize', checkMobile);\n\n    return () => {\n      window.removeEventListener('resize', checkMobile);\n    };\n  }, [mobileBreakpoint, trackMobile]);\n\n  useEffect(() => {\n    if (!trackPWA || typeof window === 'undefined') {\n      return;\n    }\n\n    const mediaQuery = window.matchMedia('(display-mode: standalone)');\n    const checkPWA = () => {\n      setIsPWA(getIsPWA());\n    };\n\n    checkPWA();\n\n    if (typeof mediaQuery.addEventListener === 'function') {\n      mediaQuery.addEventListener('change', checkPWA);\n      return () => {\n        mediaQuery.removeEventListener('change', checkPWA);\n      };\n    }\n\n    mediaQuery.addListener(checkPWA);\n    return () => {\n      mediaQuery.removeListener(checkPWA);\n    };\n  }, [trackPWA]);\n\n  return { isMobile, isPWA };\n}\n"
  },
  {
    "path": "src/hooks/useLocalStorage.jsx",
    "content": "import { useState } from 'react';\n\n/**\n * Custom hook to persist state in localStorage.\n *\n * @param {string} key The key to use for localStorage.\n * @param {any} initialValue The initial value to use if nothing is in localStorage.\n * @returns {[any, Function]} A tuple containing the stored value and a setter function.\n */\nfunction useLocalStorage(key, initialValue) {\n  const [storedValue, setStoredValue] = useState(() => {\n    if (typeof window === 'undefined') {\n      return initialValue;\n    }\n    try {\n      const item = window.localStorage.getItem(key);\n      return item ? JSON.parse(item) : initialValue;\n    } catch (error) {\n      console.log(error);\n      return initialValue;\n    }\n  });\n\n  const setValue = (value) => {\n    if (typeof window === 'undefined') {\n      return;\n    }\n    try {\n      const valueToStore =\n        value instanceof Function ? value(storedValue) : value;\n      window.localStorage.setItem(key, JSON.stringify(valueToStore));\n      setStoredValue(valueToStore);\n    } catch (error) {\n      console.log(error);\n    }\n  };\n\n  return [storedValue, setValue];\n}\n\nexport default useLocalStorage;\n"
  },
  {
    "path": "src/hooks/useProjectsState.ts",
    "content": "import { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport type { NavigateFunction } from 'react-router-dom';\nimport { api } from '../utils/api';\nimport type {\n  AppSocketMessage,\n  AppTab,\n  LoadingProgress,\n  Project,\n  ProjectSession,\n  ProjectsUpdatedMessage,\n} from '../types/app';\n\ntype UseProjectsStateArgs = {\n  sessionId?: string;\n  navigate: NavigateFunction;\n  latestMessage: AppSocketMessage | null;\n  isMobile: boolean;\n  activeSessions: Set<string>;\n};\n\ntype FetchProjectsOptions = {\n  showLoadingState?: boolean;\n};\n\nconst serialize = (value: unknown) => JSON.stringify(value ?? null);\n\nconst projectsHaveChanges = (\n  prevProjects: Project[],\n  nextProjects: Project[],\n  includeExternalSessions: boolean,\n): boolean => {\n  if (prevProjects.length !== nextProjects.length) {\n    return true;\n  }\n\n  return nextProjects.some((nextProject, index) => {\n    const prevProject = prevProjects[index];\n    if (!prevProject) {\n      return true;\n    }\n\n    const baseChanged =\n      nextProject.name !== prevProject.name ||\n      nextProject.displayName !== prevProject.displayName ||\n      nextProject.fullPath !== prevProject.fullPath ||\n      serialize(nextProject.sessionMeta) !== serialize(prevProject.sessionMeta) ||\n      serialize(nextProject.sessions) !== serialize(prevProject.sessions) ||\n      serialize(nextProject.taskmaster) !== serialize(prevProject.taskmaster);\n\n    if (baseChanged) {\n      return true;\n    }\n\n    if (!includeExternalSessions) {\n      return false;\n    }\n\n    return (\n      serialize(nextProject.cursorSessions) !== serialize(prevProject.cursorSessions) ||\n      serialize(nextProject.codexSessions) !== serialize(prevProject.codexSessions) ||\n      serialize(nextProject.geminiSessions) !== serialize(prevProject.geminiSessions)\n    );\n  });\n};\n\nconst getProjectSessions = (project: Project): ProjectSession[] => {\n  return [\n    ...(project.sessions ?? []),\n    ...(project.codexSessions ?? []),\n    ...(project.cursorSessions ?? []),\n    ...(project.geminiSessions ?? []),\n  ];\n};\n\nconst isUpdateAdditive = (\n  currentProjects: Project[],\n  updatedProjects: Project[],\n  selectedProject: Project | null,\n  selectedSession: ProjectSession | null,\n): boolean => {\n  if (!selectedProject || !selectedSession) {\n    return true;\n  }\n\n  const currentSelectedProject = currentProjects.find((project) => project.name === selectedProject.name);\n  const updatedSelectedProject = updatedProjects.find((project) => project.name === selectedProject.name);\n\n  if (!currentSelectedProject || !updatedSelectedProject) {\n    return false;\n  }\n\n  const currentSelectedSession = getProjectSessions(currentSelectedProject).find(\n    (session) => session.id === selectedSession.id,\n  );\n  const updatedSelectedSession = getProjectSessions(updatedSelectedProject).find(\n    (session) => session.id === selectedSession.id,\n  );\n\n  if (!currentSelectedSession || !updatedSelectedSession) {\n    return false;\n  }\n\n  return (\n    currentSelectedSession.id === updatedSelectedSession.id &&\n    currentSelectedSession.title === updatedSelectedSession.title &&\n    currentSelectedSession.created_at === updatedSelectedSession.created_at &&\n    currentSelectedSession.updated_at === updatedSelectedSession.updated_at\n  );\n};\n\nconst VALID_TABS: Set<string> = new Set(['chat', 'files', 'shell', 'git', 'tasks', 'preview']);\n\nconst isValidTab = (tab: string): tab is AppTab => {\n  return VALID_TABS.has(tab) || tab.startsWith('plugin:');\n};\n\nconst readPersistedTab = (): AppTab => {\n  try {\n    const stored = localStorage.getItem('activeTab');\n    if (stored && isValidTab(stored)) {\n      return stored as AppTab;\n    }\n  } catch {\n    // localStorage unavailable\n  }\n  return 'chat';\n};\n\nexport function useProjectsState({\n  sessionId,\n  navigate,\n  latestMessage,\n  isMobile,\n  activeSessions,\n}: UseProjectsStateArgs) {\n  const [projects, setProjects] = useState<Project[]>([]);\n  const [selectedProject, setSelectedProject] = useState<Project | null>(null);\n  const [selectedSession, setSelectedSession] = useState<ProjectSession | null>(null);\n  const [activeTab, setActiveTab] = useState<AppTab>(readPersistedTab);\n\n  useEffect(() => {\n    try {\n      localStorage.setItem('activeTab', activeTab);\n    } catch {\n      // Silently ignore storage errors\n    }\n  }, [activeTab]);\n\n  const [sidebarOpen, setSidebarOpen] = useState(false);\n  const [isLoadingProjects, setIsLoadingProjects] = useState(true);\n  const [loadingProgress, setLoadingProgress] = useState<LoadingProgress | null>(null);\n  const [isInputFocused, setIsInputFocused] = useState(false);\n  const [showSettings, setShowSettings] = useState(false);\n  const [settingsInitialTab, setSettingsInitialTab] = useState('agents');\n  const [externalMessageUpdate, setExternalMessageUpdate] = useState(0);\n\n  const loadingProgressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  const fetchProjects = useCallback(async ({ showLoadingState = true }: FetchProjectsOptions = {}) => {\n    try {\n      if (showLoadingState) {\n        setIsLoadingProjects(true);\n      }\n      const response = await api.projects();\n      const projectData = (await response.json()) as Project[];\n\n      setProjects((prevProjects) => {\n        if (prevProjects.length === 0) {\n          return projectData;\n        }\n\n        return projectsHaveChanges(prevProjects, projectData, true)\n          ? projectData\n          : prevProjects;\n      });\n    } catch (error) {\n      console.error('Error fetching projects:', error);\n    } finally {\n      if (showLoadingState) {\n        setIsLoadingProjects(false);\n      }\n    }\n  }, []);\n\n  const refreshProjectsSilently = useCallback(async () => {\n    // Keep chat view stable while still syncing sidebar/session metadata in background.\n    await fetchProjects({ showLoadingState: false });\n  }, [fetchProjects]);\n\n  const openSettings = useCallback((tab = 'tools') => {\n    setSettingsInitialTab(tab);\n    setShowSettings(true);\n  }, []);\n\n  useEffect(() => {\n    void fetchProjects();\n  }, [fetchProjects]);\n\n  // Auto-select the project when there is only one, so the user lands on the new session page\n  useEffect(() => {\n    if (!isLoadingProjects && projects.length === 1 && !selectedProject && !sessionId) {\n      setSelectedProject(projects[0]);\n    }\n  }, [isLoadingProjects, projects, selectedProject, sessionId]);\n\n  useEffect(() => {\n    if (!latestMessage) {\n      return;\n    }\n\n    if (latestMessage.type === 'loading_progress') {\n      if (loadingProgressTimeoutRef.current) {\n        clearTimeout(loadingProgressTimeoutRef.current);\n        loadingProgressTimeoutRef.current = null;\n      }\n\n      setLoadingProgress(latestMessage as LoadingProgress);\n\n      if (latestMessage.phase === 'complete') {\n        loadingProgressTimeoutRef.current = setTimeout(() => {\n          setLoadingProgress(null);\n          loadingProgressTimeoutRef.current = null;\n        }, 500);\n      }\n\n      return;\n    }\n\n    if (latestMessage.type !== 'projects_updated') {\n      return;\n    }\n\n    const projectsMessage = latestMessage as ProjectsUpdatedMessage;\n\n    if (projectsMessage.changedFile && selectedSession && selectedProject) {\n      const normalized = projectsMessage.changedFile.replace(/\\\\/g, '/');\n      const changedFileParts = normalized.split('/');\n\n      if (changedFileParts.length >= 2) {\n        const filename = changedFileParts[changedFileParts.length - 1];\n        const changedSessionId = filename.replace('.jsonl', '');\n\n        if (changedSessionId === selectedSession.id) {\n          const isSessionActive = activeSessions.has(selectedSession.id);\n\n          if (!isSessionActive) {\n            setExternalMessageUpdate((prev) => prev + 1);\n          }\n        }\n      }\n    }\n\n    const hasActiveSession =\n      (selectedSession && activeSessions.has(selectedSession.id)) ||\n      (activeSessions.size > 0 && Array.from(activeSessions).some((id) => id.startsWith('new-session-')));\n\n    const updatedProjects = projectsMessage.projects;\n\n    if (\n      hasActiveSession &&\n      !isUpdateAdditive(projects, updatedProjects, selectedProject, selectedSession)\n    ) {\n      return;\n    }\n\n    setProjects(updatedProjects);\n\n    if (!selectedProject) {\n      return;\n    }\n\n    const updatedSelectedProject = updatedProjects.find(\n      (project) => project.name === selectedProject.name,\n    );\n\n    if (!updatedSelectedProject) {\n      return;\n    }\n\n    if (serialize(updatedSelectedProject) !== serialize(selectedProject)) {\n      setSelectedProject(updatedSelectedProject);\n    }\n\n    if (!selectedSession) {\n      return;\n    }\n\n    const updatedSelectedSession = getProjectSessions(updatedSelectedProject).find(\n      (session) => session.id === selectedSession.id,\n    );\n\n    if (!updatedSelectedSession) {\n      setSelectedSession(null);\n    }\n  }, [latestMessage, selectedProject, selectedSession, activeSessions, projects]);\n\n  useEffect(() => {\n    return () => {\n      if (loadingProgressTimeoutRef.current) {\n        clearTimeout(loadingProgressTimeoutRef.current);\n        loadingProgressTimeoutRef.current = null;\n      }\n    };\n  }, []);\n\n  useEffect(() => {\n    if (!sessionId || projects.length === 0) {\n      return;\n    }\n\n    for (const project of projects) {\n      const claudeSession = project.sessions?.find((session) => session.id === sessionId);\n      if (claudeSession) {\n        const shouldUpdateProject = selectedProject?.name !== project.name;\n        const shouldUpdateSession =\n          selectedSession?.id !== sessionId || selectedSession.__provider !== 'claude';\n\n        if (shouldUpdateProject) {\n          setSelectedProject(project);\n        }\n        if (shouldUpdateSession) {\n          setSelectedSession({ ...claudeSession, __provider: 'claude' });\n        }\n        return;\n      }\n\n      const cursorSession = project.cursorSessions?.find((session) => session.id === sessionId);\n      if (cursorSession) {\n        const shouldUpdateProject = selectedProject?.name !== project.name;\n        const shouldUpdateSession =\n          selectedSession?.id !== sessionId || selectedSession.__provider !== 'cursor';\n\n        if (shouldUpdateProject) {\n          setSelectedProject(project);\n        }\n        if (shouldUpdateSession) {\n          setSelectedSession({ ...cursorSession, __provider: 'cursor' });\n        }\n        return;\n      }\n\n      const codexSession = project.codexSessions?.find((session) => session.id === sessionId);\n      if (codexSession) {\n        const shouldUpdateProject = selectedProject?.name !== project.name;\n        const shouldUpdateSession =\n          selectedSession?.id !== sessionId || selectedSession.__provider !== 'codex';\n\n        if (shouldUpdateProject) {\n          setSelectedProject(project);\n        }\n        if (shouldUpdateSession) {\n          setSelectedSession({ ...codexSession, __provider: 'codex' });\n        }\n        return;\n      }\n\n      const geminiSession = project.geminiSessions?.find((session) => session.id === sessionId);\n      if (geminiSession) {\n        const shouldUpdateProject = selectedProject?.name !== project.name;\n        const shouldUpdateSession =\n          selectedSession?.id !== sessionId || selectedSession.__provider !== 'gemini';\n\n        if (shouldUpdateProject) {\n          setSelectedProject(project);\n        }\n        if (shouldUpdateSession) {\n          setSelectedSession({ ...geminiSession, __provider: 'gemini' });\n        }\n        return;\n      }\n    }\n  }, [sessionId, projects, selectedProject?.name, selectedSession?.id, selectedSession?.__provider]);\n\n  const handleProjectSelect = useCallback(\n    (project: Project) => {\n      setSelectedProject(project);\n      setSelectedSession(null);\n      navigate('/');\n\n      if (isMobile) {\n        setSidebarOpen(false);\n      }\n    },\n    [isMobile, navigate],\n  );\n\n  const handleSessionSelect = useCallback(\n    (session: ProjectSession) => {\n      setSelectedSession(session);\n\n      if (activeTab === 'tasks' || activeTab === 'preview') {\n        setActiveTab('chat');\n      }\n\n      const provider = localStorage.getItem('selected-provider') || 'claude';\n      if (provider === 'cursor') {\n        sessionStorage.setItem('cursorSessionId', session.id);\n      }\n\n      if (isMobile) {\n        const sessionProjectName = session.__projectName;\n        const currentProjectName = selectedProject?.name;\n\n        if (sessionProjectName !== currentProjectName) {\n          setSidebarOpen(false);\n        }\n      }\n\n      navigate(`/session/${session.id}`);\n    },\n    [activeTab, isMobile, navigate, selectedProject?.name],\n  );\n\n  const handleNewSession = useCallback(\n    (project: Project) => {\n      setSelectedProject(project);\n      setSelectedSession(null);\n      setActiveTab('chat');\n      navigate('/');\n\n      if (isMobile) {\n        setSidebarOpen(false);\n      }\n    },\n    [isMobile, navigate],\n  );\n\n  const handleSessionDelete = useCallback(\n    (sessionIdToDelete: string) => {\n      if (selectedSession?.id === sessionIdToDelete) {\n        setSelectedSession(null);\n        navigate('/');\n      }\n\n      setProjects((prevProjects) =>\n        prevProjects.map((project) => ({\n          ...project,\n          sessions: project.sessions?.filter((session) => session.id !== sessionIdToDelete) ?? [],\n          sessionMeta: {\n            ...project.sessionMeta,\n            total: Math.max(0, (project.sessionMeta?.total as number | undefined ?? 0) - 1),\n          },\n        })),\n      );\n    },\n    [navigate, selectedSession?.id],\n  );\n\n  const handleSidebarRefresh = useCallback(async () => {\n    try {\n      const response = await api.projects();\n      const freshProjects = (await response.json()) as Project[];\n\n      setProjects((prevProjects) =>\n        projectsHaveChanges(prevProjects, freshProjects, true) ? freshProjects : prevProjects,\n      );\n\n      if (!selectedProject) {\n        return;\n      }\n\n      const refreshedProject = freshProjects.find((project) => project.name === selectedProject.name);\n      if (!refreshedProject) {\n        return;\n      }\n\n      if (serialize(refreshedProject) !== serialize(selectedProject)) {\n        setSelectedProject(refreshedProject);\n      }\n\n      if (!selectedSession) {\n        return;\n      }\n\n      const refreshedSession = getProjectSessions(refreshedProject).find(\n        (session) => session.id === selectedSession.id,\n      );\n\n      if (refreshedSession) {\n        // Keep provider metadata stable when refreshed payload doesn't include __provider.\n        const normalizedRefreshedSession =\n          refreshedSession.__provider || !selectedSession.__provider\n            ? refreshedSession\n            : { ...refreshedSession, __provider: selectedSession.__provider };\n\n        if (serialize(normalizedRefreshedSession) !== serialize(selectedSession)) {\n          setSelectedSession(normalizedRefreshedSession);\n        }\n      }\n    } catch (error) {\n      console.error('Error refreshing sidebar:', error);\n    }\n  }, [selectedProject, selectedSession]);\n\n  const handleProjectDelete = useCallback(\n    (projectName: string) => {\n      if (selectedProject?.name === projectName) {\n        setSelectedProject(null);\n        setSelectedSession(null);\n        navigate('/');\n      }\n\n      setProjects((prevProjects) => prevProjects.filter((project) => project.name !== projectName));\n    },\n    [navigate, selectedProject?.name],\n  );\n\n  const sidebarSharedProps = useMemo(\n    () => ({\n      projects,\n      selectedProject,\n      selectedSession,\n      onProjectSelect: handleProjectSelect,\n      onSessionSelect: handleSessionSelect,\n      onNewSession: handleNewSession,\n      onSessionDelete: handleSessionDelete,\n      onProjectDelete: handleProjectDelete,\n      isLoading: isLoadingProjects,\n      loadingProgress,\n      onRefresh: handleSidebarRefresh,\n      onShowSettings: () => setShowSettings(true),\n      showSettings,\n      settingsInitialTab,\n      onCloseSettings: () => setShowSettings(false),\n      isMobile,\n    }),\n    [\n      handleNewSession,\n      handleProjectDelete,\n      handleProjectSelect,\n      handleSessionDelete,\n      handleSessionSelect,\n      handleSidebarRefresh,\n      isLoadingProjects,\n      isMobile,\n      loadingProgress,\n      projects,\n      settingsInitialTab,\n      selectedProject,\n      selectedSession,\n      showSettings,\n    ],\n  );\n\n  return {\n    projects,\n    selectedProject,\n    selectedSession,\n    activeTab,\n    sidebarOpen,\n    isLoadingProjects,\n    loadingProgress,\n    isInputFocused,\n    showSettings,\n    settingsInitialTab,\n    externalMessageUpdate,\n    setActiveTab,\n    setSidebarOpen,\n    setIsInputFocused,\n    setShowSettings,\n    openSettings,\n    fetchProjects,\n    refreshProjectsSilently,\n    sidebarSharedProps,\n    handleProjectSelect,\n    handleSessionSelect,\n    handleNewSession,\n    handleSessionDelete,\n    handleProjectDelete,\n    handleSidebarRefresh,\n  };\n}\n"
  },
  {
    "path": "src/hooks/useSessionProtection.ts",
    "content": "import { useCallback, useState } from 'react';\n\nexport function useSessionProtection() {\n  const [activeSessions, setActiveSessions] = useState<Set<string>>(new Set());\n  const [processingSessions, setProcessingSessions] = useState<Set<string>>(new Set());\n\n  const markSessionAsActive = useCallback((sessionId?: string | null) => {\n    if (!sessionId) {\n      return;\n    }\n\n    setActiveSessions((prev) => new Set([...prev, sessionId]));\n  }, []);\n\n  const markSessionAsInactive = useCallback((sessionId?: string | null) => {\n    if (!sessionId) {\n      return;\n    }\n\n    setActiveSessions((prev) => {\n      const next = new Set(prev);\n      next.delete(sessionId);\n      return next;\n    });\n  }, []);\n\n  const markSessionAsProcessing = useCallback((sessionId?: string | null) => {\n    if (!sessionId) {\n      return;\n    }\n\n    setProcessingSessions((prev) => new Set([...prev, sessionId]));\n  }, []);\n\n  const markSessionAsNotProcessing = useCallback((sessionId?: string | null) => {\n    if (!sessionId) {\n      return;\n    }\n\n    setProcessingSessions((prev) => {\n      const next = new Set(prev);\n      next.delete(sessionId);\n      return next;\n    });\n  }, []);\n\n  const replaceTemporarySession = useCallback((realSessionId?: string | null) => {\n    if (!realSessionId) {\n      return;\n    }\n\n    setActiveSessions((prev) => {\n      const next = new Set<string>();\n      for (const sessionId of prev) {\n        if (!sessionId.startsWith('new-session-')) {\n          next.add(sessionId);\n        }\n      }\n      next.add(realSessionId);\n      return next;\n    });\n  }, []);\n\n  return {\n    activeSessions,\n    processingSessions,\n    markSessionAsActive,\n    markSessionAsInactive,\n    markSessionAsProcessing,\n    markSessionAsNotProcessing,\n    replaceTemporarySession,\n  };\n}\n"
  },
  {
    "path": "src/hooks/useUiPreferences.ts",
    "content": "import { useEffect, useReducer, useRef } from 'react';\n\ntype UiPreferences = {\n  autoExpandTools: boolean;\n  showRawParameters: boolean;\n  showThinking: boolean;\n  autoScrollToBottom: boolean;\n  sendByCtrlEnter: boolean;\n  sidebarVisible: boolean;\n};\n\ntype UiPreferenceKey = keyof UiPreferences;\n\ntype SetPreferenceAction = {\n  type: 'set';\n  key: UiPreferenceKey;\n  value: unknown;\n};\n\ntype SetManyPreferencesAction = {\n  type: 'set_many';\n  value?: Partial<Record<UiPreferenceKey, unknown>>;\n};\n\ntype ResetPreferencesAction = {\n  type: 'reset';\n  value?: Partial<UiPreferences>;\n};\n\ntype UiPreferencesAction =\n  | SetPreferenceAction\n  | SetManyPreferencesAction\n  | ResetPreferencesAction;\n\nconst DEFAULTS: UiPreferences = {\n  autoExpandTools: false,\n  showRawParameters: false,\n  showThinking: true,\n  autoScrollToBottom: true,\n  sendByCtrlEnter: false,\n  sidebarVisible: true,\n};\n\nconst PREFERENCE_KEYS = Object.keys(DEFAULTS) as UiPreferenceKey[];\nconst VALID_KEYS = new Set<UiPreferenceKey>(PREFERENCE_KEYS); // prevents unknown keys from being written\nconst SYNC_EVENT = 'ui-preferences:sync';\n\ntype SyncEventDetail = {\n  storageKey: string;\n  sourceId: string;\n  value: Partial<Record<UiPreferenceKey, unknown>>;\n};\n\nconst parseBoolean = (value: unknown, fallback: boolean): boolean => {\n  if (typeof value === 'boolean') {\n    return value;\n  }\n\n  if (typeof value === 'string') {\n    if (value === 'true') return true;\n    if (value === 'false') return false;\n  }\n\n  return fallback;\n};\n\nconst readLegacyPreference = (key: UiPreferenceKey, fallback: boolean): boolean => {\n  try {\n    const raw = localStorage.getItem(key);\n    if (raw === null) return fallback;\n\n    // Supports values written by both JSON.stringify and plain strings.\n    const parsed = JSON.parse(raw);\n    return parseBoolean(parsed, fallback);\n  } catch {\n    return fallback;\n  }\n};\n\nconst readInitialPreferences = (storageKey: string): UiPreferences => {\n  if (typeof window === 'undefined') {\n    return DEFAULTS;\n  }\n\n  try {\n    const raw = localStorage.getItem(storageKey);\n\n    if (raw) {\n      const parsed = JSON.parse(raw);\n      if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {\n        const parsedRecord = parsed as Record<string, unknown>;\n\n        return PREFERENCE_KEYS.reduce((acc, key) => {\n          acc[key] = parseBoolean(parsedRecord[key], DEFAULTS[key]);\n          return acc;\n        }, { ...DEFAULTS });\n      }\n    }\n  } catch {\n    // Fall back to legacy keys when unified key is missing or invalid.\n  }\n\n  return PREFERENCE_KEYS.reduce((acc, key) => {\n    acc[key] = readLegacyPreference(key, DEFAULTS[key]);\n    return acc;\n  }, { ...DEFAULTS });\n};\n\nfunction reducer(state: UiPreferences, action: UiPreferencesAction): UiPreferences {\n  switch (action.type) {\n    case 'set': {\n      const { key, value } = action;\n      if (!VALID_KEYS.has(key)) {\n        return state;\n      }\n\n      const nextValue = parseBoolean(value, state[key]);\n      if (state[key] === nextValue) {\n        return state;\n      }\n\n      return { ...state, [key]: nextValue };\n    }\n    case 'set_many': {\n      const updates = action.value || {};\n      let changed = false;\n      const nextState = { ...state };\n\n      for (const key of PREFERENCE_KEYS) {\n        if (!(key in updates)) continue;\n\n        const value = updates[key];\n        const nextValue = parseBoolean(value, state[key]);\n        if (nextState[key] !== nextValue) {\n          nextState[key] = nextValue;\n          changed = true;\n        }\n      }\n\n      return changed ? nextState : state;\n    }\n    case 'reset':\n      return { ...DEFAULTS, ...(action.value || {}) };\n    default:\n      return state;\n  }\n}\n\nexport function useUiPreferences(storageKey = 'uiPreferences') {\n  const instanceIdRef = useRef(`ui-preferences-${Math.random().toString(36).slice(2)}`);\n  const [state, dispatch] = useReducer(\n    reducer,\n    storageKey,\n    readInitialPreferences\n  );\n\n  useEffect(() => {\n    if (typeof window === 'undefined') {\n      return;\n    }\n\n    localStorage.setItem(storageKey, JSON.stringify(state));\n\n    window.dispatchEvent(\n      new CustomEvent<SyncEventDetail>(SYNC_EVENT, {\n        detail: {\n          storageKey,\n          sourceId: instanceIdRef.current,\n          value: state,\n        },\n      })\n    );\n  }, [state, storageKey]);\n\n  useEffect(() => {\n    if (typeof window === 'undefined') {\n      return;\n    }\n\n    const applyExternalUpdate = (value: unknown) => {\n      if (!value || typeof value !== 'object' || Array.isArray(value)) {\n        return;\n      }\n      dispatch({ type: 'set_many', value: value as Partial<Record<UiPreferenceKey, unknown>> });\n    };\n\n    const handleStorageChange = (event: StorageEvent) => {\n      if (event.key !== storageKey || event.newValue === null) {\n        return;\n      }\n\n      try {\n        const parsed = JSON.parse(event.newValue);\n        applyExternalUpdate(parsed);\n      } catch {\n        // Ignore malformed storage updates.\n      }\n    };\n\n    const handleSyncEvent = (event: Event) => {\n      const syncEvent = event as CustomEvent<SyncEventDetail>;\n      const detail = syncEvent.detail;\n      if (!detail || detail.storageKey !== storageKey || detail.sourceId === instanceIdRef.current) {\n        return;\n      }\n\n      applyExternalUpdate(detail.value);\n    };\n\n    window.addEventListener('storage', handleStorageChange);\n    window.addEventListener(SYNC_EVENT, handleSyncEvent as EventListener);\n\n    return () => {\n      window.removeEventListener('storage', handleStorageChange);\n      window.removeEventListener(SYNC_EVENT, handleSyncEvent as EventListener);\n    };\n  }, [storageKey]);\n\n  const setPreference = (key: UiPreferenceKey, value: unknown) => {\n    dispatch({ type: 'set', key, value });\n  };\n\n  const setPreferences = (value: Partial<Record<UiPreferenceKey, unknown>>) => {\n    dispatch({ type: 'set_many', value });\n  };\n\n  const resetPreferences = (value?: Partial<UiPreferences>) => {\n    dispatch({ type: 'reset', value });\n  };\n\n  return {\n    preferences: state,\n    setPreference,\n    setPreferences,\n    resetPreferences,\n    dispatch,\n  };\n}\n"
  },
  {
    "path": "src/hooks/useVersionCheck.ts",
    "content": "import { useState, useEffect } from 'react';\nimport { version } from '../../package.json';\nimport { ReleaseInfo } from '../types/sharedTypes';\n\n/**\n * Compare two semantic version strings\n * Works only with numeric versions separated by dots (e.g. \"1.2.3\")\n * @param {string} v1 \n * @param {string} v2\n * @returns positive if v1 > v2, negative if v1 < v2, 0 if equal\n */\nconst compareVersions = (v1: string, v2: string) => {\n  const parts1 = v1.split('.').map(Number);\n  const parts2 = v2.split('.').map(Number);\n  \n  for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {\n    const p1 = parts1[i] || 0;\n    const p2 = parts2[i] || 0;\n    if (p1 !== p2) return p1 - p2;\n  }\n  return 0;\n};\n\nexport type InstallMode = 'git' | 'npm';\n\nexport const useVersionCheck = (owner: string, repo: string) => {\n  const [updateAvailable, setUpdateAvailable] = useState(false);\n  const [latestVersion, setLatestVersion] = useState<string | null>(null);\n  const [releaseInfo, setReleaseInfo] = useState<ReleaseInfo | null>(null);\n  const [installMode, setInstallMode] = useState<InstallMode>('git');\n\n  useEffect(() => {\n    const fetchInstallMode = async () => {\n      try {\n        const response = await fetch('/health');\n        const data = await response.json();\n        if (data.installMode === 'npm' || data.installMode === 'git') {\n          setInstallMode(data.installMode);\n        }\n      } catch {\n        // Default to git on error\n      }\n    };\n    fetchInstallMode();\n  }, []);\n\n  useEffect(() => {\n    const checkVersion = async () => {\n      try {\n        const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/releases/latest`);\n        const data = await response.json();\n\n        // Handle the case where there might not be any releases\n        if (data.tag_name) {\n          const latest = data.tag_name.replace(/^v/, '');\n          setLatestVersion(latest);\n          // Only show update if latest version is actually newer\n          setUpdateAvailable(compareVersions(latest, version) > 0);\n\n          // Store release information\n          setReleaseInfo({\n            title: data.name || data.tag_name,\n            body: data.body || '',\n            htmlUrl: data.html_url || `https://github.com/${owner}/${repo}/releases/latest`,\n            publishedAt: data.published_at\n          });\n        } else {\n          // No releases found, don't show update notification\n          setUpdateAvailable(false);\n          setLatestVersion(null);\n          setReleaseInfo(null);\n        }\n      } catch (error) {\n        console.error('Version check failed:', error);\n        // On error, don't show update notification\n        setUpdateAvailable(false);\n        setLatestVersion(null);\n        setReleaseInfo(null);\n      }\n    };\n\n    checkVersion();\n    const interval = setInterval(checkVersion, 5 * 60 * 1000); // Check every 5 minutes\n    return () => clearInterval(interval);\n  }, [owner, repo]);\n\n  return { updateAvailable, latestVersion, currentVersion: version, releaseInfo, installMode };\n}; "
  },
  {
    "path": "src/hooks/useWebPush.ts",
    "content": "import { useCallback, useEffect, useState } from 'react';\nimport { authenticatedFetch } from '../utils/api';\n\ntype WebPushState = {\n  permission: NotificationPermission | 'unsupported';\n  isSubscribed: boolean;\n  isLoading: boolean;\n  subscribe: () => Promise<void>;\n  unsubscribe: () => Promise<void>;\n};\n\nfunction urlBase64ToUint8Array(base64String: string): Uint8Array {\n  const padding = '='.repeat((4 - (base64String.length % 4)) % 4);\n  const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');\n  const rawData = window.atob(base64);\n  const outputArray = new Uint8Array(rawData.length);\n  for (let i = 0; i < rawData.length; ++i) {\n    outputArray[i] = rawData.charCodeAt(i);\n  }\n  return outputArray;\n}\n\nexport function useWebPush(): WebPushState {\n  const [permission, setPermission] = useState<NotificationPermission | 'unsupported'>(() => {\n    if (typeof window === 'undefined' || !('Notification' in window) || !('serviceWorker' in navigator)) {\n      return 'unsupported';\n    }\n    return Notification.permission;\n  });\n  const [isSubscribed, setIsSubscribed] = useState(false);\n  const [isLoading, setIsLoading] = useState(false);\n\n  // Check existing subscription on mount\n  useEffect(() => {\n    if (permission === 'unsupported') return;\n\n    navigator.serviceWorker.ready.then((registration) => {\n      registration.pushManager.getSubscription().then((sub) => {\n        setIsSubscribed(sub !== null);\n      });\n    }).catch(() => {\n      // SW not ready yet\n    });\n  }, [permission]);\n\n  const subscribe = useCallback(async () => {\n    if (permission === 'unsupported') return;\n    setIsLoading(true);\n\n    try {\n      const perm = await Notification.requestPermission();\n      setPermission(perm);\n      if (perm !== 'granted') return;\n\n      const keyRes = await authenticatedFetch('/api/settings/push/vapid-public-key');\n      const { publicKey } = await keyRes.json();\n\n      const registration = await navigator.serviceWorker.ready;\n      const subscription = await registration.pushManager.subscribe({\n        userVisibleOnly: true,\n        applicationServerKey: urlBase64ToUint8Array(publicKey).buffer as ArrayBuffer,\n      });\n\n      const subJson = subscription.toJSON();\n      await authenticatedFetch('/api/settings/push/subscribe', {\n        method: 'POST',\n        body: JSON.stringify({\n          endpoint: subJson.endpoint,\n          keys: subJson.keys,\n        }),\n      });\n\n      setIsSubscribed(true);\n    } catch (err) {\n      console.error('Push subscribe failed:', err);\n    } finally {\n      setIsLoading(false);\n    }\n  }, [permission]);\n\n  const unsubscribe = useCallback(async () => {\n    setIsLoading(true);\n    try {\n      const registration = await navigator.serviceWorker.ready;\n      const subscription = await registration.pushManager.getSubscription();\n      if (subscription) {\n        const endpoint = subscription.endpoint;\n        await subscription.unsubscribe();\n        await authenticatedFetch('/api/settings/push/unsubscribe', {\n          method: 'POST',\n          body: JSON.stringify({ endpoint }),\n        });\n      }\n      setIsSubscribed(false);\n    } catch (err) {\n      console.error('Push unsubscribe failed:', err);\n    } finally {\n      setIsLoading(false);\n    }\n  }, []);\n\n  return { permission, isSubscribed, isLoading, subscribe, unsubscribe };\n}\n"
  },
  {
    "path": "src/i18n/config.js",
    "content": "/**\n * i18n Configuration\n *\n * Configures i18next for internationalization support.\n * Features:\n * - Lazy-loading of translation namespaces\n * - Language detection from localStorage\n * - Fallback to English for missing translations\n * - Development mode warnings for missing keys\n */\n\nimport i18n from 'i18next';\nimport { initReactI18next } from 'react-i18next';\n// eslint-disable-next-line import-x/order\nimport LanguageDetector from 'i18next-browser-languagedetector';\n\n// Import translation resources\nimport enCommon from './locales/en/common.json';\nimport enSettings from './locales/en/settings.json';\nimport enAuth from './locales/en/auth.json';\nimport enSidebar from './locales/en/sidebar.json';\nimport enChat from './locales/en/chat.json';\nimport enCodeEditor from './locales/en/codeEditor.json';\n// eslint-disable-next-line import-x/order\nimport enTasks from './locales/en/tasks.json';\n\nimport koCommon from './locales/ko/common.json';\nimport koSettings from './locales/ko/settings.json';\nimport koAuth from './locales/ko/auth.json';\nimport koSidebar from './locales/ko/sidebar.json';\nimport koChat from './locales/ko/chat.json';\n// eslint-disable-next-line import-x/order\nimport koCodeEditor from './locales/ko/codeEditor.json';\n\nimport zhCommon from './locales/zh-CN/common.json';\nimport zhSettings from './locales/zh-CN/settings.json';\nimport zhAuth from './locales/zh-CN/auth.json';\nimport zhSidebar from './locales/zh-CN/sidebar.json';\nimport zhChat from './locales/zh-CN/chat.json';\n// eslint-disable-next-line import-x/order\nimport zhCodeEditor from './locales/zh-CN/codeEditor.json';\n\nimport jaCommon from './locales/ja/common.json';\nimport jaSettings from './locales/ja/settings.json';\nimport jaAuth from './locales/ja/auth.json';\nimport jaSidebar from './locales/ja/sidebar.json';\nimport jaChat from './locales/ja/chat.json';\nimport jaCodeEditor from './locales/ja/codeEditor.json';\n// eslint-disable-next-line import-x/order\nimport jaTasks from './locales/ja/tasks.json';\n\nimport ruCommon from './locales/ru/common.json';\nimport ruSettings from './locales/ru/settings.json';\nimport ruAuth from './locales/ru/auth.json';\nimport ruSidebar from './locales/ru/sidebar.json';\nimport ruChat from './locales/ru/chat.json';\nimport ruCodeEditor from './locales/ru/codeEditor.json';\n// eslint-disable-next-line import-x/order\nimport ruTasks from './locales/ru/tasks.json';\n\nimport deCommon from './locales/de/common.json';\nimport deSettings from './locales/de/settings.json';\nimport deAuth from './locales/de/auth.json';\nimport deSidebar from './locales/de/sidebar.json';\nimport deChat from './locales/de/chat.json';\nimport deCodeEditor from './locales/de/codeEditor.json';\n// eslint-disable-next-line import-x/order\nimport deTasks from './locales/de/tasks.json';\n\n// Import supported languages configuration\nimport { languages } from './languages.js';\n\n// Get saved language preference from localStorage\nconst getSavedLanguage = () => {\n  try {\n    const saved = localStorage.getItem('userLanguage');\n    // Validate that the saved language is supported\n    if (saved && languages.some(lang => lang.value === saved)) {\n      return saved;\n    }\n    return 'en';\n  } catch {\n    return 'en';\n  }\n};\n\n// Initialize i18next\ni18n\n  .use(LanguageDetector) // Detect user language\n  .use(initReactI18next) // Pass i18n instance to react-i18next\n  .init({\n    // Resources containing all translations\n    resources: {\n      en: {\n        common: enCommon,\n        settings: enSettings,\n        auth: enAuth,\n        sidebar: enSidebar,\n        chat: enChat,\n        codeEditor: enCodeEditor,\n        tasks: enTasks,\n      },\n      ko: {\n        common: koCommon,\n        settings: koSettings,\n        auth: koAuth,\n        sidebar: koSidebar,\n        chat: koChat,\n        codeEditor: koCodeEditor,\n      },\n      'zh-CN': {\n        common: zhCommon,\n        settings: zhSettings,\n        auth: zhAuth,\n        sidebar: zhSidebar,\n        chat: zhChat,\n        codeEditor: zhCodeEditor,\n      },\n      ja: {\n        common: jaCommon,\n        settings: jaSettings,\n        auth: jaAuth,\n        sidebar: jaSidebar,\n        chat: jaChat,\n        codeEditor: jaCodeEditor,\n        tasks: jaTasks,\n      },\n      ru: {\n        common: ruCommon,\n        settings: ruSettings,\n        auth: ruAuth,\n        sidebar: ruSidebar,\n        chat: ruChat,\n        codeEditor: ruCodeEditor,\n        tasks: ruTasks,\n      },\n      de: {\n        common: deCommon,\n        settings: deSettings,\n        auth: deAuth,\n        sidebar: deSidebar,\n        chat: deChat,\n        codeEditor: deCodeEditor,\n        tasks: deTasks,\n      },\n    },\n\n    // Default language\n    lng: getSavedLanguage(),\n\n    // Fallback language when a translation is missing\n    fallbackLng: 'en',\n\n    // Enable debug mode in development (logs missing keys to console)\n    debug: import.meta.env.DEV,\n\n    // Namespaces - load only what's needed\n    ns: ['common', 'settings', 'auth', 'sidebar', 'chat', 'codeEditor', 'tasks'],\n    defaultNS: 'common',\n\n    // Key separator for nested keys (default: '.')\n    keySeparator: '.',\n\n    // Namespace separator (default: ':')\n    nsSeparator: ':',\n\n    // Save missing translations (disabled - requires manual review)\n    saveMissing: false,\n\n    // Interpolation settings\n    interpolation: {\n      escapeValue: false, // React already escapes values\n    },\n\n    // React-specific settings\n    react: {\n      useSuspense: true, // Use Suspense for lazy-loading\n      bindI18n: 'languageChanged', // Re-render on language change\n      bindI18nStore: false, // Don't re-render on resource changes\n    },\n\n    // Detection options\n    detection: {\n      // Order of language detection (local storage first)\n      order: ['localStorage'],\n\n      // Keys to look for in localStorage\n      lookupLocalStorage: 'userLanguage',\n\n      // Cache user language\n      caches: ['localStorage'],\n    },\n  });\n\n// Save language preference when it changes\ni18n.on('languageChanged', (lng) => {\n  try {\n    localStorage.setItem('userLanguage', lng);\n  } catch (error) {\n    console.error('Failed to save language preference:', error);\n  }\n});\n\nexport default i18n;\n"
  },
  {
    "path": "src/i18n/languages.js",
    "content": "/**\n * Supported Languages Configuration\n *\n * This file contains the list of supported languages for the application.\n * Each language includes:\n * - value: Language code (e.g., 'en', 'zh-CN')\n * - label: Display name in English\n * - nativeName: Native language name for display\n */\n\nexport const languages = [\n  {\n    value: 'en',\n    label: 'English',\n    nativeName: 'English',\n  },\n  {\n    value: 'ko',\n    label: 'Korean',\n    nativeName: '한국어',\n  },\n  {\n    value: 'zh-CN',\n    label: 'Simplified Chinese',\n    nativeName: '简体中文',\n  },\n  {\n    value: 'ja',\n    label: 'Japanese',\n    nativeName: '日本語',\n  },\n  {\n    value: 'ru',\n    label: 'Russian',\n    nativeName: 'Русский',\n  },\n  {\n    value: 'de',\n    label: 'German',\n    nativeName: 'Deutsch',\n  },\n];\n\n/**\n * Get language object by value\n * @param {string} value - Language code\n * @returns {Object|undefined} Language object or undefined if not found\n */\nexport const getLanguage = (value) => {\n  return languages.find(lang => lang.value === value);\n};\n\n/**\n * Get all language values\n * @returns {string[]} Array of language codes\n */\nexport const getLanguageValues = () => {\n  return languages.map(lang => lang.value);\n};\n\n/**\n * Check if a language is supported\n * @param {string} value - Language code to check\n * @returns {boolean} True if language is supported\n */\nexport const isLanguageSupported = (value) => {\n  return languages.some(lang => lang.value === value);\n};\n"
  },
  {
    "path": "src/i18n/locales/de/auth.json",
    "content": "{\n  \"login\": {\n    \"title\": \"Willkommen zurück\",\n    \"description\": \"Meld dich bei deinem Claude Code UI-Konto an\",\n    \"username\": \"Benutzername\",\n    \"password\": \"Passwort\",\n    \"submit\": \"Anmelden\",\n    \"loading\": \"Wird angemeldet...\",\n    \"errors\": {\n      \"invalidCredentials\": \"Ungültiger Benutzername oder Passwort\",\n      \"requiredFields\": \"Bitte alle Felder ausfüllen\",\n      \"networkError\": \"Netzwerkfehler. Bitte erneut versuchen.\"\n    },\n    \"placeholders\": {\n      \"username\": \"Benutzernamen eingeben\",\n      \"password\": \"Passwort eingeben\"\n    }\n  },\n  \"register\": {\n    \"title\": \"Konto erstellen\",\n    \"username\": \"Benutzername\",\n    \"password\": \"Passwort\",\n    \"confirmPassword\": \"Passwort bestätigen\",\n    \"submit\": \"Konto erstellen\",\n    \"loading\": \"Konto wird erstellt...\",\n    \"errors\": {\n      \"passwordMismatch\": \"Passwörter stimmen nicht überein\",\n      \"usernameTaken\": \"Benutzername ist bereits vergeben\",\n      \"weakPassword\": \"Passwort ist zu schwach\"\n    }\n  },\n  \"logout\": {\n    \"title\": \"Abmelden\",\n    \"confirm\": \"Möchtest du dich wirklich abmelden?\",\n    \"button\": \"Abmelden\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/de/chat.json",
    "content": "{\n  \"codeBlock\": {\n    \"copy\": \"Kopieren\",\n    \"copied\": \"Kopiert\",\n    \"copyCode\": \"Code kopieren\"\n  },\n  \"copyMessage\": {\n    \"copy\": \"Nachricht kopieren\",\n    \"copied\": \"Nachricht kopiert\",\n    \"selectFormat\": \"Kopierformat auswählen\",\n    \"copyAsMarkdown\": \"Als Markdown kopieren\",\n    \"copyAsText\": \"Als Text kopieren\"\n  },\n  \"messageTypes\": {\n    \"user\": \"Benutzer:in\",\n    \"error\": \"Fehler\",\n    \"tool\": \"Werkzeug\",\n    \"claude\": \"Claude\",\n    \"cursor\": \"Cursor\",\n    \"codex\": \"Codex\",\n    \"gemini\": \"Gemini\"\n  },\n  \"tools\": {\n    \"settings\": \"Werkzeugeinstellungen\",\n    \"error\": \"Werkzeugfehler\",\n    \"result\": \"Werkzeugergebnis\",\n    \"viewParams\": \"Eingabeparameter anzeigen\",\n    \"viewRawParams\": \"Rohe Parameter anzeigen\",\n    \"viewDiff\": \"Bearbeitungs-Diff anzeigen für\",\n    \"creatingFile\": \"Neue Datei wird erstellt:\",\n    \"updatingTodo\": \"Aufgabenliste wird aktualisiert\",\n    \"read\": \"Gelesen\",\n    \"readFile\": \"Datei lesen\",\n    \"updateTodo\": \"Aufgabenliste aktualisieren\",\n    \"readTodo\": \"Aufgabenliste lesen\",\n    \"searchResults\": \"Ergebnisse\"\n  },\n  \"search\": {\n    \"found\": \"{{count}} {{type}} gefunden\",\n    \"file\": \"Datei\",\n    \"files\": \"Dateien\",\n    \"pattern\": \"Muster:\",\n    \"in\": \"in:\"\n  },\n  \"fileOperations\": {\n    \"updated\": \"Datei erfolgreich aktualisiert\",\n    \"created\": \"Datei erfolgreich erstellt\",\n    \"written\": \"Datei erfolgreich geschrieben\",\n    \"diff\": \"Diff\",\n    \"newFile\": \"Neue Datei\",\n    \"viewContent\": \"Dateiinhalt anzeigen\",\n    \"viewFullOutput\": \"Vollständige Ausgabe anzeigen ({{count}} Zeichen)\",\n    \"contentDisplayed\": \"Der Dateiinhalt wird in der Diff-Ansicht oben angezeigt\"\n  },\n  \"interactive\": {\n    \"title\": \"Interaktive Eingabeaufforderung\",\n    \"waiting\": \"Warte auf deine Antwort in der CLI\",\n    \"instruction\": \"Bitte wähl eine Option in deinem Terminal, in dem Claude läuft.\",\n    \"selectedOption\": \"✓ Claude hat Option {{number}} ausgewählt\",\n    \"instructionDetail\": \"In der CLI würdest du diese Option interaktiv mit den Pfeiltasten oder durch Eingabe der Nummer auswählen.\"\n  },\n  \"thinking\": {\n    \"title\": \"Denkt nach...\",\n    \"emoji\": \"💭 Denkt nach...\"\n  },\n  \"json\": {\n    \"response\": \"JSON-Antwort\"\n  },\n  \"permissions\": {\n    \"grant\": \"Berechtigung für {{tool}} erteilen\",\n    \"added\": \"Berechtigung hinzugefügt\",\n    \"addTo\": \"Fügt {{entry}} zu erlaubten Werkzeugen hinzu.\",\n    \"retry\": \"Berechtigung gespeichert. Wiederhole die Anfrage, um das Werkzeug zu verwenden.\",\n    \"error\": \"Berechtigungen konnten nicht aktualisiert werden. Bitte erneut versuchen.\",\n    \"openSettings\": \"Einstellungen öffnen\"\n  },\n  \"todo\": {\n    \"updated\": \"Aufgabenliste wurde erfolgreich aktualisiert\",\n    \"current\": \"Aktuelle Aufgabenliste\"\n  },\n  \"plan\": {\n    \"viewPlan\": \"📋 Implementierungsplan anzeigen\",\n    \"title\": \"Implementierungsplan\"\n  },\n  \"usageLimit\": {\n    \"resetAt\": \"Claude-Nutzungslimit erreicht. Dein Limit wird um **{{time}} {{timezone}}** zurückgesetzt - {{date}}\"\n  },\n  \"codex\": {\n    \"permissionMode\": \"Berechtigungsmodus\",\n    \"modes\": {\n      \"default\": \"Standardmodus\",\n      \"acceptEdits\": \"Bearbeitungen akzeptieren\",\n      \"bypassPermissions\": \"Berechtigungen umgehen\",\n      \"plan\": \"Planungsmodus\"\n    },\n    \"descriptions\": {\n      \"default\": \"Nur vertrauenswürdige Befehle (ls, cat, grep, git status usw.) werden automatisch ausgeführt. Andere Befehle werden übersprungen. Kann in den Arbeitsbereich schreiben.\",\n      \"acceptEdits\": \"Alle Befehle werden automatisch innerhalb des Arbeitsbereichs ausgeführt. Vollautomatischer Modus mit isolierter Ausführung.\",\n      \"bypassPermissions\": \"Vollständiger Systemzugriff ohne Einschränkungen. Alle Befehle werden automatisch mit vollem Festplatten- und Netzwerkzugriff ausgeführt. Mit Vorsicht verwenden.\",\n      \"plan\": \"Planungsmodus – keine Befehle werden ausgeführt\"\n    },\n    \"technicalDetails\": \"Technische Details\"\n  },\n  \"gemini\": {\n    \"permissionMode\": \"Gemini-Berechtigungsmodus\",\n    \"description\": \"Steuert, wie Gemini CLI Vorgangsgenehmigungen handhabt.\",\n    \"modes\": {\n      \"default\": {\n        \"title\": \"Standard (Genehmigung erforderlich)\",\n        \"description\": \"Gemini fordert vor der Ausführung von Befehlen, dem Schreiben von Dateien und dem Abrufen von Web-Ressourcen eine Genehmigung an.\"\n      },\n      \"autoEdit\": {\n        \"title\": \"Auto-Bearbeitung (Dateigenehmigungen überspringen)\",\n        \"description\": \"Gemini genehmigt automatisch Dateibearbeitungen und Web-Abrufe, fragt aber weiterhin bei Shell-Befehlen nach.\"\n      },\n      \"yolo\": {\n        \"title\": \"YOLO (Alle Berechtigungen umgehen)\",\n        \"description\": \"Gemini führt alle Vorgänge ohne Rückfrage aus. Vorsicht ist geboten.\"\n      }\n    }\n  },\n  \"input\": {\n    \"placeholder\": \"/ für Befehle, @ für Dateien eingeben oder {{provider}} etwas fragen...\",\n    \"placeholderDefault\": \"Nachricht eingeben...\",\n    \"disabled\": \"Eingabe deaktiviert\",\n    \"attachFiles\": \"Dateien anhängen\",\n    \"attachImages\": \"Bilder anhängen\",\n    \"send\": \"Senden\",\n    \"stop\": \"Stoppen\",\n    \"hintText\": {\n      \"ctrlEnter\": \"Strg+Enter zum Senden • Shift+Enter für neue Zeile • Tab zum Moduswechsel • / für Slash-Befehle\",\n      \"enter\": \"Enter zum Senden • Shift+Enter für neue Zeile • Tab zum Moduswechsel • / für Slash-Befehle\"\n    },\n    \"clickToChangeMode\": \"Klicken, um den Berechtigungsmodus zu ändern (oder Tab in der Eingabe drücken)\",\n    \"showAllCommands\": \"Alle Befehle anzeigen\",\n    \"clearInput\": \"Eingabe leeren\",\n    \"scrollToBottom\": \"Nach unten scrollen\"\n  },\n  \"thinkingMode\": {\n    \"selector\": {\n      \"title\": \"Denkmodus\",\n      \"description\": \"Erweitertes Denken gibt Claude mehr Zeit, Alternativen zu evaluieren\",\n      \"active\": \"Aktiv\",\n      \"tip\": \"Höhere Denkmodi brauchen mehr Zeit, liefern aber eine gründlichere Analyse\"\n    },\n    \"modes\": {\n      \"none\": {\n        \"name\": \"Standard\",\n        \"description\": \"Reguläre Claude-Antwort\",\n        \"prefix\": \"\"\n      },\n      \"think\": {\n        \"name\": \"Denken\",\n        \"description\": \"Grundlegendes erweitertes Denken\",\n        \"prefix\": \"think\"\n      },\n      \"thinkHard\": {\n        \"name\": \"Intensiv denken\",\n        \"description\": \"Gründlichere Auswertung\",\n        \"prefix\": \"think hard\"\n      },\n      \"thinkHarder\": {\n        \"name\": \"Sehr intensiv denken\",\n        \"description\": \"Tiefgehende Analyse mit Alternativen\",\n        \"prefix\": \"think harder\"\n      },\n      \"ultrathink\": {\n        \"name\": \"Ultradenken\",\n        \"description\": \"Maximales Denkbudget\",\n        \"prefix\": \"ultrathink\"\n      }\n    },\n    \"buttonTitle\": \"Denkmodus: {{mode}}\"\n  },\n  \"providerSelection\": {\n    \"title\": \"KI-Assistent wählen\",\n    \"description\": \"Anbieter auswählen, um eine neue Unterhaltung zu starten\",\n    \"selectModel\": \"Modell auswählen\",\n    \"providerInfo\": {\n      \"anthropic\": \"von Anthropic\",\n      \"openai\": \"von OpenAI\",\n      \"cursorEditor\": \"KI-Code-Editor\",\n      \"google\": \"von Google\"\n    },\n    \"readyPrompt\": {\n      \"claude\": \"Bereit, Claude mit {{model}} zu verwenden. Gib unten deine Nachricht ein.\",\n      \"cursor\": \"Bereit, Cursor mit {{model}} zu verwenden. Gib unten deine Nachricht ein.\",\n      \"codex\": \"Bereit, Codex mit {{model}} zu verwenden. Gib unten deine Nachricht ein.\",\n      \"gemini\": \"Bereit, Gemini mit {{model}} zu verwenden. Gib unten deine Nachricht ein.\",\n      \"default\": \"Wähl oben einen Anbieter, um zu beginnen\"\n    }\n  },\n  \"session\": {\n    \"continue\": {\n      \"title\": \"Unterhaltung fortsetzen\",\n      \"description\": \"Stell Fragen zu deinem Code, fordere Änderungen an oder hol Hilfe bei Entwicklungsaufgaben\"\n    },\n    \"loading\": {\n      \"olderMessages\": \"Ältere Nachrichten werden geladen...\",\n      \"sessionMessages\": \"Sitzungsnachrichten werden geladen...\"\n    },\n    \"messages\": {\n      \"showingOf\": \"{{shown}} von {{total}} Nachrichten werden angezeigt\",\n      \"scrollToLoad\": \"Nach oben scrollen, um mehr zu laden\",\n      \"showingLast\": \"Letzte {{count}} Nachrichten werden angezeigt ({{total}} gesamt)\",\n      \"loadEarlier\": \"Frühere Nachrichten laden\",\n      \"loadAll\": \"Alle Nachrichten laden\",\n      \"loadingAll\": \"Alle Nachrichten werden geladen...\",\n      \"allLoaded\": \"Alle Nachrichten geladen\",\n      \"perfWarning\": \"Alle Nachrichten geladen – Scrollen kann langsamer sein. Klick auf 'Nach unten scrollen', um die Leistung wiederherzustellen.\"\n    }\n  },\n  \"shell\": {\n    \"selectProject\": {\n      \"title\": \"Projekt auswählen\",\n      \"description\": \"Wähl ein Projekt, um ein interaktives Terminal in diesem Verzeichnis zu öffnen\"\n    },\n    \"status\": {\n      \"newSession\": \"Neue Sitzung\",\n      \"initializing\": \"Wird initialisiert...\",\n      \"restarting\": \"Wird neu gestartet...\"\n    },\n    \"actions\": {\n      \"disconnect\": \"Trennen\",\n      \"disconnectTitle\": \"Vom Terminal trennen\",\n      \"restart\": \"Neu starten\",\n      \"restartTitle\": \"Terminal neu starten (zuerst trennen)\",\n      \"connect\": \"Im Terminal fortfahren\",\n      \"connectTitle\": \"Mit Terminal verbinden\"\n    },\n    \"loading\": \"Terminal wird geladen...\",\n    \"connecting\": \"Verbindung zum Terminal wird hergestellt...\",\n    \"startSession\": \"Neue Claude-Sitzung starten\",\n    \"resumeSession\": \"Sitzung fortsetzen: {{displayName}}...\",\n    \"runCommand\": \"{{command}} in {{projectName}} ausführen\",\n    \"startCli\": \"Claude CLI wird in {{projectName}} gestartet\",\n    \"defaultCommand\": \"Befehl\"\n  },\n  \"claudeStatus\": {\n    \"actions\": {\n      \"thinking\": \"Denkt nach\",\n      \"processing\": \"Verarbeitet\",\n      \"analyzing\": \"Analysiert\",\n      \"working\": \"Arbeitet\",\n      \"computing\": \"Berechnet\",\n      \"reasoning\": \"Schlussfolgert\"\n    },\n    \"state\": {\n      \"live\": \"Live\",\n      \"paused\": \"Pausiert\"\n    },\n    \"elapsed\": {\n      \"seconds\": \"{{count}}s\",\n      \"minutesSeconds\": \"{{minutes}}m {{seconds}}s\",\n      \"label\": \"{{time}} vergangen\",\n      \"startingNow\": \"Startet jetzt\"\n    },\n    \"controls\": {\n      \"stopGeneration\": \"Generierung stoppen\",\n      \"pressEscToStop\": \"Jederzeit Esc drücken, um zu stoppen\"\n    },\n    \"providers\": {\n      \"assistant\": \"Assistent\"\n    }\n  },\n  \"projectSelection\": {\n    \"startChatWithProvider\": \"Wähl ein Projekt, um mit {{provider}} zu chatten\"\n  },\n  \"tasks\": {\n    \"nextTaskPrompt\": \"Nächste Aufgabe starten\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/de/codeEditor.json",
    "content": "{\n  \"toolbar\": {\n    \"changes\": \"Änderungen\",\n    \"previousChange\": \"Vorherige Änderung\",\n    \"nextChange\": \"Nächste Änderung\",\n    \"hideDiff\": \"Diff-Hervorhebung ausblenden\",\n    \"showDiff\": \"Diff-Hervorhebung anzeigen\",\n    \"settings\": \"Editor-Einstellungen\",\n    \"collapse\": \"Editor einklappen\",\n    \"expand\": \"Editor auf volle Breite erweitern\"\n  },\n  \"loading\": \"{{fileName}} wird geladen...\",\n  \"header\": {\n    \"showingChanges\": \"Änderungen werden angezeigt\"\n  },\n  \"actions\": {\n    \"download\": \"Datei herunterladen\",\n    \"save\": \"Speichern\",\n    \"saving\": \"Wird gespeichert...\",\n    \"saved\": \"Gespeichert!\",\n    \"exitFullscreen\": \"Vollbild beenden\",\n    \"fullscreen\": \"Vollbild\",\n    \"close\": \"Schließen\",\n    \"previewMarkdown\": \"Markdown-Vorschau\",\n    \"editMarkdown\": \"Markdown bearbeiten\"\n  },\n  \"footer\": {\n    \"lines\": \"Zeilen:\",\n    \"characters\": \"Zeichen:\",\n    \"shortcuts\": \"Strg+S zum Speichern • Esc zum Schließen\"\n  },\n  \"binaryFile\": {\n    \"title\": \"Binärdatei\",\n    \"message\": \"Die Datei \\\"{{fileName}}\\\" kann im Texteditor nicht angezeigt werden, da es sich um eine Binärdatei handelt.\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/de/common.json",
    "content": "{\n  \"buttons\": {\n    \"save\": \"Speichern\",\n    \"cancel\": \"Abbrechen\",\n    \"delete\": \"Löschen\",\n    \"create\": \"Erstellen\",\n    \"edit\": \"Bearbeiten\",\n    \"close\": \"Schließen\",\n    \"confirm\": \"Bestätigen\",\n    \"submit\": \"Absenden\",\n    \"retry\": \"Erneut versuchen\",\n    \"refresh\": \"Aktualisieren\",\n    \"search\": \"Suchen\",\n    \"clear\": \"Leeren\",\n    \"copy\": \"Kopieren\",\n    \"download\": \"Herunterladen\",\n    \"upload\": \"Hochladen\",\n    \"browse\": \"Durchsuchen\"\n  },\n  \"tabs\": {\n    \"chat\": \"Chat\",\n    \"shell\": \"Terminal\",\n    \"files\": \"Dateien\",\n    \"git\": \"Quellcodeverwaltung\",\n    \"tasks\": \"Aufgaben\"\n  },\n  \"status\": {\n    \"loading\": \"Lädt...\",\n    \"success\": \"Erfolgreich\",\n    \"error\": \"Fehler\",\n    \"failed\": \"Fehlgeschlagen\",\n    \"pending\": \"Ausstehend\",\n    \"completed\": \"Abgeschlossen\",\n    \"inProgress\": \"In Bearbeitung\"\n  },\n  \"messages\": {\n    \"savedSuccessfully\": \"Erfolgreich gespeichert\",\n    \"deletedSuccessfully\": \"Erfolgreich gelöscht\",\n    \"updatedSuccessfully\": \"Erfolgreich aktualisiert\",\n    \"operationFailed\": \"Vorgang fehlgeschlagen\",\n    \"networkError\": \"Netzwerkfehler. Bitte überprüf deine Verbindung.\",\n    \"unauthorized\": \"Nicht autorisiert. Bitte meld dich an.\",\n    \"notFound\": \"Nicht gefunden\",\n    \"invalidInput\": \"Ungültige Eingabe\",\n    \"requiredField\": \"Dieses Feld ist erforderlich\",\n    \"unknownError\": \"Ein unbekannter Fehler ist aufgetreten\"\n  },\n  \"navigation\": {\n    \"settings\": \"Einstellungen\",\n    \"home\": \"Startseite\",\n    \"back\": \"Zurück\",\n    \"next\": \"Weiter\",\n    \"previous\": \"Zurück\",\n    \"logout\": \"Abmelden\"\n  },\n  \"common\": {\n    \"language\": \"Sprache\",\n    \"theme\": \"Design\",\n    \"darkMode\": \"Darkmode\",\n    \"lightMode\": \"Hellmodus\",\n    \"name\": \"Name\",\n    \"description\": \"Beschreibung\",\n    \"enabled\": \"Aktiviert\",\n    \"disabled\": \"Deaktiviert\",\n    \"optional\": \"Optional\",\n    \"version\": \"Version\",\n    \"select\": \"Auswählen\",\n    \"selectAll\": \"Alle auswählen\",\n    \"deselectAll\": \"Alle abwählen\"\n  },\n  \"time\": {\n    \"justNow\": \"Gerade eben\",\n    \"minutesAgo\": \"vor {{count}} Min.\",\n    \"hoursAgo\": \"vor {{count}} Std.\",\n    \"daysAgo\": \"vor {{count}} Tagen\",\n    \"yesterday\": \"Gestern\"\n  },\n  \"fileOperations\": {\n    \"newFile\": \"Neue Datei\",\n    \"newFolder\": \"Neuer Ordner\",\n    \"rename\": \"Umbenennen\",\n    \"move\": \"Verschieben\",\n    \"copyPath\": \"Pfad kopieren\",\n    \"openInEditor\": \"Im Editor öffnen\"\n  },\n  \"mainContent\": {\n    \"loading\": \"Claude Code UI wird geladen\",\n    \"settingUpWorkspace\": \"Arbeitsbereich wird eingerichtet...\",\n    \"chooseProject\": \"Projekt auswählen\",\n    \"selectProjectDescription\": \"Wähl ein Projekt aus der Seitenleiste, um mit Claude zu programmieren. Jedes Projekt enthält deine Chat-Sitzungen und den Dateiverlauf.\",\n    \"tip\": \"Tipp\",\n    \"createProjectMobile\": \"Tipp oben auf die Menüschaltfläche, um auf Projekte zuzugreifen\",\n    \"createProjectDesktop\": \"Erstell ein neues Projekt, indem du auf das Ordnersymbol in der Seitenleiste klickst\",\n    \"newSession\": \"Neue Sitzung\",\n    \"untitledSession\": \"Unbenannte Sitzung\",\n    \"projectFiles\": \"Projektdateien\"\n  },\n  \"fileTree\": {\n    \"loading\": \"Dateien werden geladen...\",\n    \"files\": \"Dateien\",\n    \"simpleView\": \"Einfache Ansicht\",\n    \"compactView\": \"Kompakte Ansicht\",\n    \"detailedView\": \"Detailansicht\",\n    \"searchPlaceholder\": \"Dateien und Ordner durchsuchen...\",\n    \"clearSearch\": \"Suche leeren\",\n    \"name\": \"Name\",\n    \"size\": \"Größe\",\n    \"modified\": \"Geändert\",\n    \"permissions\": \"Berechtigungen\",\n    \"noFilesFound\": \"Keine Dateien gefunden\",\n    \"checkProjectPath\": \"Überprüf, ob der Projektpfad zugänglich ist\",\n    \"noMatchesFound\": \"Keine Treffer gefunden\",\n    \"tryDifferentSearch\": \"Versuch einen anderen Suchbegriff oder leere die Suche\",\n    \"justNow\": \"gerade eben\",\n    \"minAgo\": \"vor {{count}} Min.\",\n    \"hoursAgo\": \"vor {{count}} Std.\",\n    \"daysAgo\": \"vor {{count}} Tagen\",\n    \"newFile\": \"Neue Datei (Cmd+N)\",\n    \"newFolder\": \"Neuer Ordner (Cmd+Shift+N)\",\n    \"refresh\": \"Aktualisieren\",\n    \"collapseAll\": \"Alle einklappen\",\n    \"context\": {\n      \"rename\": \"Umbenennen\",\n      \"delete\": \"Löschen\",\n      \"copyPath\": \"Pfad kopieren\",\n      \"download\": \"Herunterladen\",\n      \"newFile\": \"Neue Datei\",\n      \"newFolder\": \"Neuer Ordner\",\n      \"refresh\": \"Aktualisieren\",\n      \"menuLabel\": \"Datei-Kontextmenü\",\n      \"loading\": \"Lädt...\"\n    }\n  },\n  \"projectWizard\": {\n    \"title\": \"Neues Projekt erstellen\",\n    \"steps\": {\n      \"type\": \"Typ\",\n      \"configure\": \"Konfigurieren\",\n      \"confirm\": \"Bestätigen\"\n    },\n    \"step1\": {\n      \"question\": \"Hast du bereits einen Arbeitsbereich, oder möchtest du einen neuen erstellen?\",\n      \"existing\": {\n        \"title\": \"Vorhandener Arbeitsbereich\",\n        \"description\": \"Ich habe bereits einen Arbeitsbereich auf meinem Server und möchte ihn nur zur Projektliste hinzufügen\"\n      },\n      \"new\": {\n        \"title\": \"Neuer Arbeitsbereich\",\n        \"description\": \"Einen neuen Arbeitsbereich erstellen, optional aus einem GitHub-Repository klonen\"\n      }\n    },\n    \"step2\": {\n      \"existingPath\": \"Arbeitsbereichspfad\",\n      \"newPath\": \"Arbeitsbereichspfad\",\n      \"existingPlaceholder\": \"/Pfad/zum/vorhandenen/Arbeitsbereich\",\n      \"newPlaceholder\": \"/Pfad/zum/neuen/Arbeitsbereich\",\n      \"existingHelp\": \"Vollständiger Pfad zu deinem vorhandenen Arbeitsbereichsverzeichnis\",\n      \"newHelp\": \"Vollständiger Pfad zu deinem Arbeitsbereichsverzeichnis\",\n      \"githubUrl\": \"GitHub-URL (Optional)\",\n      \"githubPlaceholder\": \"https://github.com/benutzername/repository\",\n      \"githubHelp\": \"Optional: GitHub-URL angeben, um ein Repository zu klonen\",\n      \"githubAuth\": \"GitHub-Authentifizierung (Optional)\",\n      \"githubAuthHelp\": \"Nur für private Repositories erforderlich. Öffentliche Repos können ohne Authentifizierung geklont werden.\",\n      \"loadingTokens\": \"Gespeicherte Token werden geladen...\",\n      \"storedToken\": \"Gespeicherter Token\",\n      \"newToken\": \"Neuer Token\",\n      \"nonePublic\": \"Keiner (Öffentlich)\",\n      \"selectToken\": \"Token auswählen\",\n      \"selectTokenPlaceholder\": \"-- Token auswählen --\",\n      \"tokenPlaceholder\": \"ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\n      \"tokenHelp\": \"Dieser Token wird nur für diesen Vorgang verwendet\",\n      \"publicRepoInfo\": \"Öffentliche Repositories benötigen keine Authentifizierung. Du kannst das Token beim Klonen eines öffentlichen Repos weglassen.\",\n      \"noTokensHelp\": \"Keine gespeicherten Token verfügbar. Du kannst Token unter Einstellungen → API-Schlüssel für einfachere Wiederverwendung hinzufügen.\",\n      \"optionalTokenPublic\": \"GitHub-Token (Optional für öffentliche Repos)\",\n      \"tokenPublicPlaceholder\": \"ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (leer lassen für öffentliche Repos)\"\n    },\n    \"step3\": {\n      \"reviewConfig\": \"Konfiguration überprüfen\",\n      \"workspaceType\": \"Arbeitsbereichstyp:\",\n      \"existingWorkspace\": \"Vorhandener Arbeitsbereich\",\n      \"newWorkspace\": \"Neuer Arbeitsbereich\",\n      \"path\": \"Pfad:\",\n      \"cloneFrom\": \"Klonen von:\",\n      \"authentication\": \"Authentifizierung:\",\n      \"usingStoredToken\": \"Gespeicherter Token wird verwendet:\",\n      \"usingProvidedToken\": \"Angegebener Token wird verwendet\",\n      \"noAuthentication\": \"Keine Authentifizierung\",\n      \"sshKey\": \"SSH-Schlüssel\",\n      \"existingInfo\": \"Der Arbeitsbereich wird zur Projektliste hinzugefügt und steht für Claude/Cursor-Sitzungen zur Verfügung.\",\n      \"newWithClone\": \"Das Repository wird aus diesem Ordner geklont.\",\n      \"newEmpty\": \"Der Arbeitsbereich wird zur Projektliste hinzugefügt und steht für Claude/Cursor-Sitzungen zur Verfügung.\",\n      \"cloningRepository\": \"Repository wird geklont...\"\n    },\n    \"buttons\": {\n      \"cancel\": \"Abbrechen\",\n      \"back\": \"Zurück\",\n      \"next\": \"Weiter\",\n      \"createProject\": \"Projekt erstellen\",\n      \"creating\": \"Wird erstellt...\",\n      \"cloning\": \"Wird geklont...\"\n    },\n    \"errors\": {\n      \"selectType\": \"Bitte wähl aus, ob du einen vorhandenen Arbeitsbereich hast oder einen neuen erstellen möchtest\",\n      \"providePath\": \"Bitte gib einen Arbeitsbereichspfad an\",\n      \"failedToCreate\": \"Arbeitsbereich konnte nicht erstellt werden\",\n      \"failedToCreateFolder\": \"Ordner konnte nicht erstellt werden\"\n    }\n  },\n  \"versionUpdate\": {\n    \"title\": \"Update verfügbar\",\n    \"newVersionReady\": \"Eine neue Version ist verfügbar\",\n    \"currentVersion\": \"Aktuelle Version\",\n    \"latestVersion\": \"Neueste Version\",\n    \"whatsNew\": \"Neuigkeiten:\",\n    \"viewFullRelease\": \"Vollständige Version anzeigen\",\n    \"updateProgress\": \"Update-Fortschritt:\",\n    \"manualUpgrade\": \"Manuelles Upgrade:\",\n    \"npmUpgradeCommand\": \"npm install -g @siteboon/claude-code-ui@latest\",\n    \"manualUpgradeHint\": \"Oder klick auf \\\"Jetzt aktualisieren\\\", um das Update automatisch durchzuführen.\",\n    \"updateCompleted\": \"Update erfolgreich abgeschlossen!\",\n    \"restartServer\": \"Bitte starte den Server neu, um die Änderungen anzuwenden.\",\n    \"updateFailed\": \"Update fehlgeschlagen\",\n    \"buttons\": {\n      \"close\": \"Schließen\",\n      \"later\": \"Später\",\n      \"copyCommand\": \"Befehl kopieren\",\n      \"updateNow\": \"Jetzt aktualisieren\",\n      \"updating\": \"Wird aktualisiert...\"\n    },\n    \"ariaLabels\": {\n      \"closeModal\": \"Versions-Update-Modal schließen\",\n      \"showSidebar\": \"Seitenleiste anzeigen\",\n      \"settings\": \"Einstellungen\",\n      \"updateAvailable\": \"Update verfügbar\",\n      \"closeSidebar\": \"Seitenleiste schließen\"\n    }\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/de/settings.json",
    "content": "{\n  \"title\": \"Einstellungen\",\n  \"tabs\": {\n    \"account\": \"Konto\",\n    \"permissions\": \"Berechtigungen\",\n    \"mcpServers\": \"MCP-Server\",\n    \"appearance\": \"Darstellung\"\n  },\n  \"account\": {\n    \"title\": \"Konto\",\n    \"language\": \"Sprache\",\n    \"languageLabel\": \"Anzeigesprache\",\n    \"languageDescription\": \"Wähl deine bevorzugte Sprache für die Oberfläche\",\n    \"username\": \"Benutzername\",\n    \"email\": \"E-Mail\",\n    \"profile\": \"Profil\",\n    \"changePassword\": \"Passwort ändern\"\n  },\n  \"mcp\": {\n    \"title\": \"MCP-Server\",\n    \"addServer\": \"Server hinzufügen\",\n    \"editServer\": \"Server bearbeiten\",\n    \"deleteServer\": \"Server löschen\",\n    \"serverName\": \"Servername\",\n    \"serverType\": \"Servertyp\",\n    \"config\": \"Konfiguration\",\n    \"testConnection\": \"Verbindung testen\",\n    \"status\": \"Status\",\n    \"connected\": \"Verbunden\",\n    \"disconnected\": \"Getrennt\",\n    \"scope\": {\n      \"label\": \"Geltungsbereich\",\n      \"user\": \"Benutzer:in\",\n      \"project\": \"Projekt\"\n    }\n  },\n  \"appearance\": {\n    \"title\": \"Darstellung\",\n    \"theme\": \"Design\",\n    \"codeEditor\": \"Code-Editor\",\n    \"editorTheme\": \"Editor-Design\",\n    \"wordWrap\": \"Zeilenumbruch\",\n    \"showMinimap\": \"Minimap anzeigen\",\n    \"lineNumbers\": \"Zeilennummern\",\n    \"fontSize\": \"Schriftgröße\"\n  },\n  \"actions\": {\n    \"saveChanges\": \"Änderungen speichern\",\n    \"resetToDefaults\": \"Auf Standardwerte zurücksetzen\",\n    \"cancelChanges\": \"Änderungen abbrechen\"\n  },\n  \"quickSettings\": {\n    \"title\": \"Schnelleinstellungen\",\n    \"sections\": {\n      \"appearance\": \"Darstellung\",\n      \"toolDisplay\": \"Werkzeuganzeige\",\n      \"viewOptions\": \"Anzeigeoptionen\",\n      \"inputSettings\": \"Eingabeeinstellungen\",\n      \"whisperDictation\": \"Whisper-Diktat\"\n    },\n    \"darkMode\": \"Darkmode\",\n    \"autoExpandTools\": \"Werkzeuge automatisch erweitern\",\n    \"showRawParameters\": \"Rohe Parameter anzeigen\",\n    \"showThinking\": \"Denken anzeigen\",\n    \"autoScrollToBottom\": \"Automatisch nach unten scrollen\",\n    \"sendByCtrlEnter\": \"Mit Strg+Enter senden\",\n    \"sendByCtrlEnterDescription\": \"Wenn aktiviert, sendet Strg+Enter die Nachricht anstelle von Enter. Dies ist nützlich für IME-Benutzer:innen, um versehentliches Senden zu vermeiden.\",\n    \"dragHandle\": {\n      \"dragging\": \"Handle wird gezogen\",\n      \"closePanel\": \"Einstellungspanel schließen\",\n      \"openPanel\": \"Einstellungspanel öffnen\",\n      \"draggingStatus\": \"Wird gezogen...\",\n      \"toggleAndMove\": \"Klicken zum Umschalten, ziehen zum Verschieben\"\n    },\n    \"whisper\": {\n      \"modes\": {\n        \"default\": \"Standardmodus\",\n        \"defaultDescription\": \"Direkte Transkription deiner Sprache\",\n        \"prompt\": \"Prompt-Verbesserung\",\n        \"promptDescription\": \"Rohe Ideen in klare, detaillierte KI-Prompts umwandeln\",\n        \"vibe\": \"Vibe-Modus\",\n        \"vibeDescription\": \"Ideen als klare Agentenanweisungen mit Details formatieren\"\n      }\n    }\n  },\n  \"terminalShortcuts\": {\n    \"title\": \"Terminal-Tastenkürzel\",\n    \"sectionKeys\": \"Tasten\",\n    \"sectionNavigation\": \"Navigation\",\n    \"escape\": \"Escape\",\n    \"tab\": \"Tab\",\n    \"shiftTab\": \"Shift+Tab\",\n    \"arrowUp\": \"Pfeil oben\",\n    \"arrowDown\": \"Pfeil unten\",\n    \"scrollDown\": \"Nach unten scrollen\",\n    \"handle\": {\n      \"closePanel\": \"Tastenkürzel-Panel schließen\",\n      \"openPanel\": \"Tastenkürzel-Panel öffnen\"\n    }\n  },\n  \"mainTabs\": {\n    \"label\": \"Einstellungen\",\n    \"agents\": \"Agenten\",\n    \"appearance\": \"Darstellung\",\n    \"git\": \"Git\",\n    \"apiTokens\": \"API & Token\",\n    \"tasks\": \"Aufgaben\",\n    \"plugins\": \"Plugins\"\n  },\n  \"appearanceSettings\": {\n    \"darkMode\": {\n      \"label\": \"Darkmode\",\n      \"description\": \"Zwischen hellem und dunklem Design wechseln\"\n    },\n    \"projectSorting\": {\n      \"label\": \"Projektsortierung\",\n      \"description\": \"Wie Projekte in der Seitenleiste angeordnet werden\",\n      \"alphabetical\": \"Alphabetisch\",\n      \"recentActivity\": \"Letzte Aktivität\"\n    },\n    \"codeEditor\": {\n      \"title\": \"Code-Editor\",\n      \"theme\": {\n        \"label\": \"Editor-Design\",\n        \"description\": \"Standarddesign für den Code-Editor\"\n      },\n      \"wordWrap\": {\n        \"label\": \"Zeilenumbruch\",\n        \"description\": \"Zeilenumbruch standardmäßig im Editor aktivieren\"\n      },\n      \"showMinimap\": {\n        \"label\": \"Minimap anzeigen\",\n        \"description\": \"Minimap zur einfacheren Navigation in der Diff-Ansicht anzeigen\"\n      },\n      \"lineNumbers\": {\n        \"label\": \"Zeilennummern anzeigen\",\n        \"description\": \"Zeilennummern im Editor anzeigen\"\n      },\n      \"fontSize\": {\n        \"label\": \"Schriftgröße\",\n        \"description\": \"Editor-Schriftgröße in Pixeln\"\n      }\n    }\n  },\n  \"mcpForm\": {\n    \"title\": {\n      \"add\": \"MCP-Server hinzufügen\",\n      \"edit\": \"MCP-Server bearbeiten\"\n    },\n    \"importMode\": {\n      \"form\": \"Formulareingabe\",\n      \"json\": \"JSON-Import\"\n    },\n    \"scope\": {\n      \"label\": \"Geltungsbereich\",\n      \"userGlobal\": \"Benutzer:in (Global)\",\n      \"projectLocal\": \"Projekt (Lokal)\",\n      \"userDescription\": \"Benutzerbereich: Auf allen Projekten deines Computers verfügbar\",\n      \"projectDescription\": \"Lokaler Bereich: Nur im ausgewählten Projekt verfügbar\",\n      \"cannotChange\": \"Der Geltungsbereich kann beim Bearbeiten eines vorhandenen Servers nicht geändert werden\"\n    },\n    \"fields\": {\n      \"serverName\": \"Servername\",\n      \"transportType\": \"Transporttyp\",\n      \"command\": \"Befehl\",\n      \"arguments\": \"Argumente (eines pro Zeile)\",\n      \"jsonConfig\": \"JSON-Konfiguration\",\n      \"url\": \"URL\",\n      \"envVars\": \"Umgebungsvariablen (SCHLÜSSEL=Wert, eine pro Zeile)\",\n      \"headers\": \"Header (SCHLÜSSEL=Wert, eine pro Zeile)\",\n      \"selectProject\": \"Projekt auswählen...\"\n    },\n    \"placeholders\": {\n      \"serverName\": \"mein-server\"\n    },\n    \"validation\": {\n      \"missingType\": \"Pflichtfeld fehlt: type\",\n      \"stdioRequiresCommand\": \"stdio-Typ erfordert ein Befehlsfeld\",\n      \"httpRequiresUrl\": \"{{type}}-Typ erfordert ein URL-Feld\",\n      \"invalidJson\": \"Ungültiges JSON-Format\",\n      \"jsonHelp\": \"Füge deine MCP-Server-Konfiguration im JSON-Format ein. Beispielformate:\",\n      \"jsonExampleStdio\": \"• stdio: {\\\"type\\\":\\\"stdio\\\",\\\"command\\\":\\\"npx\\\",\\\"args\\\":[\\\"@upstash/context7-mcp\\\"]}\",\n      \"jsonExampleHttp\": \"• http/sse: {\\\"type\\\":\\\"http\\\",\\\"url\\\":\\\"https://api.example.com/mcp\\\"}\"\n    },\n    \"configDetails\": \"Konfigurationsdetails (aus {{configFile}})\",\n    \"projectPath\": \"Pfad: {{path}}\",\n    \"actions\": {\n      \"cancel\": \"Abbrechen\",\n      \"saving\": \"Wird gespeichert...\",\n      \"addServer\": \"Server hinzufügen\",\n      \"updateServer\": \"Server aktualisieren\"\n    }\n  },\n  \"saveStatus\": {\n    \"success\": \"Einstellungen erfolgreich gespeichert!\",\n    \"error\": \"Einstellungen konnten nicht gespeichert werden\",\n    \"saving\": \"Wird gespeichert...\"\n  },\n  \"footerActions\": {\n    \"save\": \"Einstellungen speichern\",\n    \"cancel\": \"Abbrechen\"\n  },\n  \"git\": {\n    \"title\": \"Git-Konfiguration\",\n    \"description\": \"Konfiguriere deine Git-Identität für Commits. Diese Einstellungen werden global über git config --global angewendet\",\n    \"name\": {\n      \"label\": \"Git-Name\",\n      \"help\": \"Dein Name für Git-Commits\"\n    },\n    \"email\": {\n      \"label\": \"Git-E-Mail\",\n      \"help\": \"Deine E-Mail-Adresse für Git-Commits\"\n    },\n    \"actions\": {\n      \"save\": \"Konfiguration speichern\",\n      \"saving\": \"Wird gespeichert...\"\n    },\n    \"status\": {\n      \"success\": \"Erfolgreich gespeichert\"\n    }\n  },\n  \"apiKeys\": {\n    \"title\": \"API-Schlüssel\",\n    \"description\": \"Generiere API-Schlüssel, um von anderen Anwendungen auf die externe API zuzugreifen.\",\n    \"newKey\": {\n      \"alertTitle\": \"⚠️ API-Schlüssel speichern\",\n      \"alertMessage\": \"Dies ist das einzige Mal, dass du diesen Schlüssel siehst. Speichere ihn sicher.\",\n      \"iveSavedIt\": \"Ich habe ihn gespeichert\"\n    },\n    \"form\": {\n      \"placeholder\": \"API-Schlüsselname (z. B. Produktionsserver)\",\n      \"createButton\": \"Erstellen\",\n      \"cancelButton\": \"Abbrechen\"\n    },\n    \"newButton\": \"Neuer API-Schlüssel\",\n    \"empty\": \"Noch keine API-Schlüssel erstellt.\",\n    \"list\": {\n      \"created\": \"Erstellt:\",\n      \"lastUsed\": \"Zuletzt verwendet:\"\n    },\n    \"confirmDelete\": \"Möchtest du diesen API-Schlüssel wirklich löschen?\",\n    \"status\": {\n      \"active\": \"Aktiv\",\n      \"inactive\": \"Inaktiv\"\n    },\n    \"github\": {\n      \"title\": \"GitHub-Token\",\n      \"description\": \"Füge GitHub Personal Access Tokens hinzu, um private Repositories über die externe API zu klonen.\",\n      \"descriptionAlt\": \"Füge GitHub Personal Access Tokens hinzu, um private Repositories zu klonen. Du kannst Token auch direkt in API-Anfragen übergeben, ohne sie zu speichern.\",\n      \"addButton\": \"Token hinzufügen\",\n      \"form\": {\n        \"namePlaceholder\": \"Token-Name (z. B. Persönliche Repos)\",\n        \"tokenPlaceholder\": \"GitHub Personal Access Token (ghp_...)\",\n        \"descriptionPlaceholder\": \"Beschreibung (optional)\",\n        \"addButton\": \"Token hinzufügen\",\n        \"cancelButton\": \"Abbrechen\",\n        \"howToCreate\": \"Wie man einen GitHub Personal Access Token erstellt →\"\n      },\n      \"empty\": \"Noch keine GitHub-Token hinzugefügt.\",\n      \"added\": \"Hinzugefügt:\",\n      \"confirmDelete\": \"Möchtest du diesen GitHub-Token wirklich löschen?\"\n    },\n    \"apiDocsLink\": \"API-Dokumentation\",\n    \"documentation\": {\n      \"title\": \"Externe API-Dokumentation\",\n      \"description\": \"Erfahre, wie du die externe API nutzen kannst, um Claude/Cursor-Sitzungen aus deinen Anwendungen heraus zu starten.\",\n      \"viewLink\": \"API-Dokumentation anzeigen →\"\n    },\n    \"loading\": \"Lädt...\",\n    \"version\": {\n      \"updateAvailable\": \"Update verfügbar: v{{version}}\"\n    }\n  },\n  \"tasks\": {\n    \"checking\": \"TaskMaster-Installation wird überprüft...\",\n    \"notInstalled\": {\n      \"title\": \"TaskMaster AI CLI nicht installiert\",\n      \"description\": \"TaskMaster CLI ist erforderlich, um Aufgabenverwaltungsfunktionen zu nutzen. Installiere es, um loszulegen:\",\n      \"installCommand\": \"npm install -g task-master-ai\",\n      \"viewOnGitHub\": \"Auf GitHub anzeigen\",\n      \"afterInstallation\": \"Nach der Installation:\",\n      \"steps\": {\n        \"restart\": \"Diese Anwendung neu starten\",\n        \"autoAvailable\": \"TaskMaster-Funktionen werden automatisch verfügbar\",\n        \"initCommand\": \"task-master init in deinem Projektverzeichnis verwenden\"\n      }\n    },\n    \"settings\": {\n      \"enableLabel\": \"TaskMaster-Integration aktivieren\",\n      \"enableDescription\": \"TaskMaster-Aufgaben, Banner und Seitenleisten-Indikatoren in der gesamten Oberfläche anzeigen\"\n    }\n  },\n  \"agents\": {\n    \"authStatus\": {\n      \"checking\": \"Wird überprüft...\",\n      \"connected\": \"Verbunden\",\n      \"notConnected\": \"Nicht verbunden\",\n      \"disconnected\": \"Getrennt\",\n      \"checkingAuth\": \"Authentifizierungsstatus wird überprüft...\",\n      \"loggedInAs\": \"Angemeldet als {{email}}\",\n      \"authenticatedUser\": \"authentifizierte:r Benutzer:in\"\n    },\n    \"account\": {\n      \"claude\": {\n        \"description\": \"Anthropic Claude KI-Assistent\"\n      },\n      \"cursor\": {\n        \"description\": \"Cursor KI-gestützter Code-Editor\"\n      },\n      \"codex\": {\n        \"description\": \"OpenAI Codex KI-Assistent\"\n      },\n      \"gemini\": {\n        \"description\": \"Google Gemini KI-Assistent\"\n      }\n    },\n    \"connectionStatus\": \"Verbindungsstatus\",\n    \"login\": {\n      \"title\": \"Anmelden\",\n      \"reAuthenticate\": \"Erneut authentifizieren\",\n      \"description\": \"Meld dich bei deinem {{agent}}-Konto an, um KI-Funktionen zu aktivieren\",\n      \"reAuthDescription\": \"Mit einem anderen Konto anmelden oder Anmeldedaten aktualisieren\",\n      \"button\": \"Anmelden\",\n      \"reLoginButton\": \"Erneut anmelden\"\n    },\n    \"error\": \"Fehler: {{error}}\"\n  },\n  \"permissions\": {\n    \"title\": \"Berechtigungseinstellungen\",\n    \"skipPermissions\": {\n      \"label\": \"Berechtigungsaufforderungen überspringen (mit Vorsicht verwenden)\",\n      \"claudeDescription\": \"Entspricht dem Flag --dangerously-skip-permissions\",\n      \"cursorDescription\": \"Entspricht dem Flag -f in der Cursor CLI\"\n    },\n    \"allowedTools\": {\n      \"title\": \"Erlaubte Werkzeuge\",\n      \"description\": \"Werkzeuge, die automatisch ohne Berechtigungsaufforderung erlaubt werden\",\n      \"placeholder\": \"z. B. \\\"Bash(git log:*)\\\" oder \\\"Write\\\"\",\n      \"quickAdd\": \"Häufige Werkzeuge schnell hinzufügen:\",\n      \"empty\": \"Keine erlaubten Werkzeuge konfiguriert\"\n    },\n    \"blockedTools\": {\n      \"title\": \"Gesperrte Werkzeuge\",\n      \"description\": \"Werkzeuge, die automatisch ohne Berechtigungsaufforderung gesperrt werden\",\n      \"placeholder\": \"z. B. \\\"Bash(rm:*)\\\"\",\n      \"empty\": \"Keine gesperrten Werkzeuge konfiguriert\"\n    },\n    \"allowedCommands\": {\n      \"title\": \"Erlaubte Shell-Befehle\",\n      \"description\": \"Shell-Befehle, die automatisch ohne Aufforderung erlaubt werden\",\n      \"placeholder\": \"z. B. \\\"Shell(ls)\\\" oder \\\"Shell(git status)\\\"\",\n      \"quickAdd\": \"Häufige Befehle schnell hinzufügen:\",\n      \"empty\": \"Keine erlaubten Befehle konfiguriert\"\n    },\n    \"blockedCommands\": {\n      \"title\": \"Gesperrte Shell-Befehle\",\n      \"description\": \"Shell-Befehle, die automatisch gesperrt werden\",\n      \"placeholder\": \"z. B. \\\"Shell(rm -rf)\\\" oder \\\"Shell(sudo)\\\"\",\n      \"empty\": \"Keine gesperrten Befehle konfiguriert\"\n    },\n    \"toolExamples\": {\n      \"title\": \"Werkzeugmuster-Beispiele:\",\n      \"bashGitLog\": \"- Alle git log-Befehle erlauben\",\n      \"bashGitDiff\": \"- Alle git diff-Befehle erlauben\",\n      \"write\": \"- Alle Write-Werkzeugnutzungen erlauben\",\n      \"bashRm\": \"- Alle rm-Befehle sperren (gefährlich)\"\n    },\n    \"shellExamples\": {\n      \"title\": \"Shell-Befehl-Beispiele:\",\n      \"ls\": \"- ls-Befehl erlauben\",\n      \"gitStatus\": \"- git status erlauben\",\n      \"npmInstall\": \"- npm install erlauben\",\n      \"rmRf\": \"- Rekursives Löschen sperren\"\n    },\n    \"codex\": {\n      \"permissionMode\": \"Berechtigungsmodus\",\n      \"description\": \"Steuert, wie Codex Dateiänderungen und Befehlsausführung handhabt\",\n      \"modes\": {\n        \"default\": {\n          \"title\": \"Standard\",\n          \"description\": \"Nur vertrauenswürdige Befehle (ls, cat, grep, git status usw.) werden automatisch ausgeführt. Andere Befehle werden übersprungen. Kann in den Arbeitsbereich schreiben.\"\n        },\n        \"acceptEdits\": {\n          \"title\": \"Bearbeitungen akzeptieren\",\n          \"description\": \"Alle Befehle werden automatisch innerhalb des Arbeitsbereichs ausgeführt. Vollautomatischer Modus mit isolierter Ausführung.\"\n        },\n        \"bypassPermissions\": {\n          \"title\": \"Berechtigungen umgehen\",\n          \"description\": \"Vollständiger Systemzugriff ohne Einschränkungen. Alle Befehle werden automatisch mit vollem Festplatten- und Netzwerkzugriff ausgeführt. Mit Vorsicht verwenden.\"\n        }\n      },\n      \"technicalDetails\": \"Technische Details\",\n      \"technicalInfo\": {\n        \"default\": \"sandboxMode=workspace-write, approvalPolicy=untrusted. Vertrauenswürdige Befehle: cat, cd, grep, head, ls, pwd, tail, git status/log/diff/show, find (ohne -exec) usw.\",\n        \"acceptEdits\": \"sandboxMode=workspace-write, approvalPolicy=never. Alle Befehle werden automatisch innerhalb des Projektverzeichnisses ausgeführt.\",\n        \"bypassPermissions\": \"sandboxMode=danger-full-access, approvalPolicy=never. Vollständiger Systemzugriff, nur in vertrauenswürdigen Umgebungen verwenden.\",\n        \"overrideNote\": \"Du kannst dies pro Sitzung über die Modusschaltfläche in der Chat-Oberfläche überschreiben.\"\n      }\n    },\n    \"actions\": {\n      \"add\": \"Hinzufügen\"\n    }\n  },\n  \"mcpServers\": {\n    \"title\": \"MCP-Server\",\n    \"description\": {\n      \"claude\": \"Model Context Protocol-Server stellen Claude zusätzliche Werkzeuge und Datenquellen zur Verfügung\",\n      \"cursor\": \"Model Context Protocol-Server stellen Cursor zusätzliche Werkzeuge und Datenquellen zur Verfügung\",\n      \"codex\": \"Model Context Protocol-Server stellen Codex zusätzliche Werkzeuge und Datenquellen zur Verfügung\"\n    },\n    \"addButton\": \"MCP-Server hinzufügen\",\n    \"empty\": \"Keine MCP-Server konfiguriert\",\n    \"serverType\": \"Typ\",\n    \"scope\": {\n      \"local\": \"lokal\",\n      \"user\": \"benutzer\"\n    },\n    \"config\": {\n      \"command\": \"Befehl\",\n      \"url\": \"URL\",\n      \"args\": \"Argumente\",\n      \"environment\": \"Umgebung\"\n    },\n    \"tools\": {\n      \"title\": \"Werkzeuge\",\n      \"count\": \"({{count}}):\",\n      \"more\": \"+{{count}} weitere\"\n    },\n    \"actions\": {\n      \"edit\": \"Server bearbeiten\",\n      \"delete\": \"Server löschen\"\n    },\n    \"help\": {\n      \"title\": \"Über Codex MCP\",\n      \"description\": \"Codex unterstützt stdio-basierte MCP-Server. Du kannst Server hinzufügen, die die Fähigkeiten von Codex mit zusätzlichen Werkzeugen und Ressourcen erweitern.\"\n    }\n  },\n  \"pluginSettings\": {\n    \"title\": \"Plugins\",\n    \"description\": \"Erweitere die Oberfläche mit eigenen Plugins. Installiere sie von Git oder lege einen Ordner in ~/.claude-code-ui/plugins/ ab.\",\n    \"installPlaceholder\": \"https://github.com/benutzer/mein-plugin\",\n    \"installButton\": \"Installieren\",\n    \"installing\": \"Wird installiert…\",\n    \"securityWarning\": \"Installiere nur Plugins, deren Quellcode du geprüft hast oder die von Autoren stammen, denen du vertraust.\",\n    \"scanningPlugins\": \"Plugins werden durchsucht…\",\n    \"noPluginsInstalled\": \"Keine Plugins installiert\",\n    \"pullLatest\": \"Neueste Version von Git laden\",\n    \"noGitRemote\": \"Kein Git-Remote — Update nicht verfügbar\",\n    \"uninstallPlugin\": \"Plugin deinstallieren\",\n    \"confirmUninstall\": \"Zum Bestätigen erneut klicken\",\n    \"confirmUninstallMessage\": \"{{name}} entfernen? Das kann nicht rückgängig gemacht werden.\",\n    \"cancel\": \"Abbrechen\",\n    \"remove\": \"Entfernen\",\n    \"updateFailed\": \"Update fehlgeschlagen\",\n    \"installFailed\": \"Installation fehlgeschlagen\",\n    \"uninstallFailed\": \"Deinstallation fehlgeschlagen\",\n    \"toggleFailed\": \"Umschalten fehlgeschlagen\",\n    \"buildYourOwn\": \"Eigenes Plugin erstellen\",\n    \"starter\": \"Starter\",\n    \"docs\": \"Dokumentation\",\n    \"starterPlugin\": {\n      \"name\": \"Projektstatistiken\",\n      \"badge\": \"starter\",\n      \"description\": \"Dateianzahl, Codezeilen, Dateityp-Aufschlüsselung und aktuelle Aktivitäten für dein Projekt.\",\n      \"install\": \"Installieren\"\n    },\n    \"morePlugins\": \"Mehr\",\n    \"enable\": \"Aktivieren\",\n    \"disable\": \"Deaktivieren\",\n    \"installAriaLabel\": \"Git-Repository-URL des Plugins\",\n    \"tab\": \"Tab\",\n    \"runningStatus\": \"läuft\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/de/sidebar.json",
    "content": "{\n  \"projects\": {\n    \"title\": \"Projekte\",\n    \"newProject\": \"Neues Projekt\",\n    \"deleteProject\": \"Projekt löschen\",\n    \"renameProject\": \"Projekt umbenennen\",\n    \"noProjects\": \"Keine Projekte gefunden\",\n    \"loadingProjects\": \"Projekte werden geladen...\",\n    \"searchPlaceholder\": \"Projekte durchsuchen...\",\n    \"projectNamePlaceholder\": \"Projektname\",\n    \"starred\": \"Favoriten\",\n    \"all\": \"Alle\",\n    \"untitledSession\": \"Unbenannte Sitzung\",\n    \"newSession\": \"Neue Sitzung\",\n    \"codexSession\": \"Codex-Sitzung\",\n    \"fetchingProjects\": \"Deine Claude-Projekte und -Sitzungen werden abgerufen\",\n    \"projects\": \"Projekte\",\n    \"noMatchingProjects\": \"Keine passenden Projekte\",\n    \"tryDifferentSearch\": \"Versuch, den Suchbegriff anzupassen\",\n    \"runClaudeCli\": \"Führ Claude CLI in einem Projektverzeichnis aus, um zu beginnen\"\n  },\n  \"app\": {\n    \"title\": \"Claude Code UI\",\n    \"subtitle\": \"KI-Programmierassistent-Oberfläche\"\n  },\n  \"sessions\": {\n    \"title\": \"Sitzungen\",\n    \"newSession\": \"Neue Sitzung\",\n    \"deleteSession\": \"Sitzung löschen\",\n    \"renameSession\": \"Sitzung umbenennen\",\n    \"noSessions\": \"Noch keine Sitzungen\",\n    \"loadingSessions\": \"Sitzungen werden geladen...\",\n    \"unnamed\": \"Unbenannt\",\n    \"loading\": \"Lädt...\",\n    \"showMore\": \"Weitere Sitzungen anzeigen\"\n  },\n  \"tooltips\": {\n    \"viewEnvironments\": \"Umgebungen anzeigen\",\n    \"hideSidebar\": \"Seitenleiste ausblenden\",\n    \"createProject\": \"Neues Projekt erstellen\",\n    \"refresh\": \"Projekte und Sitzungen aktualisieren (Strg+R)\",\n    \"renameProject\": \"Projekt umbenennen (F2)\",\n    \"deleteProject\": \"Leeres Projekt löschen (Entf)\",\n    \"addToFavorites\": \"Zu Favoriten hinzufügen\",\n    \"removeFromFavorites\": \"Aus Favoriten entfernen\",\n    \"editSessionName\": \"Sitzungsname manuell bearbeiten\",\n    \"deleteSession\": \"Diese Sitzung dauerhaft löschen\",\n    \"save\": \"Speichern\",\n    \"cancel\": \"Abbrechen\",\n    \"clearSearch\": \"Suche leeren\"\n  },\n  \"navigation\": {\n    \"chat\": \"Chat\",\n    \"files\": \"Dateien\",\n    \"git\": \"Git\",\n    \"terminal\": \"Terminal\",\n    \"tasks\": \"Aufgaben\"\n  },\n  \"actions\": {\n    \"refresh\": \"Aktualisieren\",\n    \"settings\": \"Einstellungen\",\n    \"collapseAll\": \"Alle einklappen\",\n    \"expandAll\": \"Alle ausklappen\",\n    \"cancel\": \"Abbrechen\",\n    \"save\": \"Speichern\",\n    \"delete\": \"Löschen\",\n    \"rename\": \"Umbenennen\",\n    \"joinCommunity\": \"Community beitreten\"\n  },\n  \"status\": {\n    \"active\": \"Aktiv\",\n    \"inactive\": \"Inaktiv\",\n    \"thinking\": \"Denkt nach...\",\n    \"error\": \"Fehler\",\n    \"aborted\": \"Abgebrochen\",\n    \"unknown\": \"Unbekannt\"\n  },\n  \"time\": {\n    \"justNow\": \"Gerade eben\",\n    \"oneMinuteAgo\": \"vor 1 Min.\",\n    \"minutesAgo\": \"vor {{count}} Min.\",\n    \"oneHourAgo\": \"vor 1 Std.\",\n    \"hoursAgo\": \"vor {{count}} Std.\",\n    \"oneDayAgo\": \"vor 1 Tag\",\n    \"daysAgo\": \"vor {{count}} Tagen\"\n  },\n  \"messages\": {\n    \"deleteConfirm\": \"Möchtest du das wirklich löschen?\",\n    \"renameSuccess\": \"Erfolgreich umbenannt\",\n    \"deleteSuccess\": \"Erfolgreich gelöscht\",\n    \"errorOccurred\": \"Ein Fehler ist aufgetreten\",\n    \"deleteSessionConfirm\": \"Möchtest du diese Sitzung wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.\",\n    \"deleteProjectConfirm\": \"Möchtest du dieses leere Projekt wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.\",\n    \"enterProjectPath\": \"Bitte gib einen Projektpfad ein\",\n    \"deleteSessionFailed\": \"Sitzung konnte nicht gelöscht werden. Bitte erneut versuchen.\",\n    \"deleteSessionError\": \"Fehler beim Löschen der Sitzung. Bitte erneut versuchen.\",\n    \"renameSessionFailed\": \"Sitzung konnte nicht umbenannt werden. Bitte erneut versuchen.\",\n    \"renameSessionError\": \"Fehler beim Umbenennen der Sitzung. Bitte erneut versuchen.\",\n    \"deleteProjectFailed\": \"Projekt konnte nicht gelöscht werden. Bitte erneut versuchen.\",\n    \"deleteProjectError\": \"Fehler beim Löschen des Projekts. Bitte erneut versuchen.\",\n    \"createProjectFailed\": \"Projekt konnte nicht erstellt werden. Bitte erneut versuchen.\",\n    \"createProjectError\": \"Fehler beim Erstellen des Projekts. Bitte erneut versuchen.\"\n  },\n  \"version\": {\n    \"updateAvailable\": \"Update verfügbar\"\n  },\n  \"search\": {\n    \"modeProjects\": \"Projekte\",\n    \"modeConversations\": \"Unterhaltungen\",\n    \"conversationsPlaceholder\": \"In Unterhaltungen suchen...\",\n    \"searching\": \"Sucht...\",\n    \"noResults\": \"Keine Ergebnisse gefunden\",\n    \"tryDifferentQuery\": \"Versuch eine andere Suchanfrage\",\n    \"matches_one\": \"{{count}} Treffer\",\n    \"matches_other\": \"{{count}} Treffer\",\n    \"projectsScanned_one\": \"{{count}} Projekt durchsucht\",\n    \"projectsScanned_other\": \"{{count}} Projekte durchsucht\"\n  },\n  \"deleteConfirmation\": {\n    \"deleteProject\": \"Projekt löschen\",\n    \"deleteSession\": \"Sitzung löschen\",\n    \"confirmDelete\": \"Möchtest du wirklich löschen\",\n    \"sessionCount_one\": \"Dieses Projekt enthält {{count}} Unterhaltung.\",\n    \"sessionCount_other\": \"Dieses Projekt enthält {{count}} Unterhaltungen.\",\n    \"allConversationsDeleted\": \"Alle Unterhaltungen werden dauerhaft gelöscht.\",\n    \"cannotUndo\": \"Diese Aktion kann nicht rückgängig gemacht werden.\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/de/tasks.json",
    "content": "{\n  \"notConfigured\": {\n    \"title\": \"TaskMaster AI ist nicht konfiguriert\",\n    \"description\": \"TaskMaster hilft dabei, komplexe Projekte mit KI-Unterstützung in überschaubare Aufgaben aufzuteilen\",\n    \"whatIsTitle\": \"🎯 Was ist TaskMaster?\",\n    \"features\": {\n      \"aiPowered\": \"KI-gestütztes Aufgabenmanagement: Komplexe Projekte in handhabbare Unteraufgaben aufteilen\",\n      \"prdTemplates\": \"PRD-Vorlagen: Aufgaben aus Produktanforderungsdokumenten generieren\",\n      \"dependencyTracking\": \"Abhängigkeitsverfolgung: Aufgabenbeziehungen und Ausführungsreihenfolge verstehen\",\n      \"progressVisualization\": \"Fortschrittsvisualisierung: Kanban-Boards und detaillierte Aufgabenanalysen\",\n      \"cliIntegration\": \"CLI-Integration: Taskmaster-Befehle für erweiterte Workflows verwenden\"\n    },\n    \"initializeButton\": \"TaskMaster AI initialisieren\"\n  },\n  \"gettingStarted\": {\n    \"title\": \"Erste Schritte mit TaskMaster\",\n    \"subtitle\": \"TaskMaster ist initialisiert! Hier ist, was du als Nächstes tun kannst:\",\n    \"steps\": {\n      \"createPRD\": {\n        \"title\": \"Produktanforderungsdokument (PRD) erstellen\",\n        \"description\": \"Besprich deine Projektidee und erstelle ein PRD, das beschreibt, was du bauen möchtest.\",\n        \"addButton\": \"PRD hinzufügen\",\n        \"existingPRDs\": \"Vorhandene PRDs:\"\n      },\n      \"generateTasks\": {\n        \"title\": \"Aufgaben aus PRD generieren\",\n        \"description\": \"Sobald du ein PRD hast, bitte deinen KI-Assistenten, es zu analysieren. TaskMaster wird es automatisch in überschaubare Aufgaben mit Implementierungsdetails aufteilen.\"\n      },\n      \"analyzeTasks\": {\n        \"title\": \"Aufgaben analysieren und erweitern\",\n        \"description\": \"Bitte deinen KI-Assistenten, die Aufgabenkomplexität zu analysieren und sie in detaillierte Unteraufgaben für eine einfachere Implementierung zu erweitern.\"\n      },\n      \"startBuilding\": {\n        \"title\": \"Mit dem Bauen beginnen\",\n        \"description\": \"Bitte deinen KI-Assistenten, mit der Bearbeitung von Aufgaben zu beginnen, deren Status zu aktualisieren und neue Aufgaben hinzuzufügen, wenn dein Projekt sich weiterentwickelt.\"\n      }\n    },\n    \"tip\": \"💡 Tipp: Beginne mit einem PRD, um das Beste aus TaskMasters KI-gestützter Aufgabengenerierung herauszuholen\"\n  },\n  \"setupModal\": {\n    \"title\": \"TaskMaster-Einrichtung\",\n    \"subtitle\": \"Interaktives CLI für {{projectName}}\",\n    \"willStart\": \"Die TaskMaster-Initialisierung startet automatisch\",\n    \"completed\": \"TaskMaster-Einrichtung abgeschlossen! Du kannst dieses Fenster jetzt schließen.\",\n    \"closeButton\": \"Schließen\",\n    \"closeContinueButton\": \"Schließen & Fortfahren\"\n  },\n  \"helpGuide\": {\n    \"title\": \"Erste Schritte mit TaskMaster\",\n    \"subtitle\": \"Dein Leitfaden für produktives Aufgabenmanagement\",\n    \"examples\": {\n      \"parsePRD\": \"💬 Beispiel:\\n\\\"Ich habe gerade ein neues Projekt mit Claude Task Master initialisiert. Ich habe ein PRD unter .taskmaster/docs/prd.txt. Kannst du mir helfen, es zu analysieren und die ersten Aufgaben einzurichten?\\\"\",\n      \"expandTask\": \"💬 Beispiel:\\n\\\"Aufgabe 5 scheint komplex. Kannst du sie in Unteraufgaben aufteilen?\\\"\",\n      \"addTask\": \"💬 Beispiel:\\n\\\"Bitte füge eine neue Aufgabe hinzu, um Benutzerprofilbild-Uploads mit Cloudinary zu implementieren, und recherchiere den besten Ansatz.\\\"\"\n    },\n    \"moreExamples\": \"Weitere Beispiele und Verwendungsmuster anzeigen →\",\n    \"proTips\": {\n      \"title\": \"💡 Profi-Tipps\",\n      \"search\": \"Verwende die Suchleiste, um bestimmte Aufgaben schnell zu finden\",\n      \"views\": \"Wechsle mit den Ansichts-Umschaltern zwischen Kanban-, Listen- und Rasteransicht\",\n      \"filters\": \"Verwende Filter, um dich auf bestimmte Aufgabenstatus oder Prioritäten zu konzentrieren\",\n      \"details\": \"Klicke auf eine Aufgabe, um detaillierte Informationen anzuzeigen und Unteraufgaben zu verwalten\"\n    },\n    \"learnMore\": {\n      \"title\": \"📚 Mehr erfahren\",\n      \"description\": \"TaskMaster AI ist ein fortschrittliches Aufgabenmanagementsystem für Entwickler:innen. Dokumentation, Beispiele und Möglichkeiten zur Mitarbeit am Projekt.\",\n      \"githubButton\": \"Auf GitHub ansehen\"\n    }\n  },\n  \"search\": {\n    \"placeholder\": \"Aufgaben suchen...\"\n  },\n  \"filters\": {\n    \"button\": \"Filter\",\n    \"status\": \"Status\",\n    \"priority\": \"Priorität\",\n    \"sortBy\": \"Sortieren nach\",\n    \"allStatuses\": \"Alle Status\",\n    \"allPriorities\": \"Alle Prioritäten\",\n    \"showing\": \"{{filtered}} von {{total}} Aufgaben werden angezeigt\",\n    \"clearFilters\": \"Filter zurücksetzen\"\n  },\n  \"sort\": {\n    \"id\": \"ID\",\n    \"status\": \"Status\",\n    \"priority\": \"Priorität\",\n    \"idAsc\": \"ID (aufsteigend)\",\n    \"idDesc\": \"ID (absteigend)\",\n    \"titleAsc\": \"Titel (A-Z)\",\n    \"titleDesc\": \"Titel (Z-A)\",\n    \"statusAsc\": \"Status (Ausstehend zuerst)\",\n    \"statusDesc\": \"Status (Erledigt zuerst)\",\n    \"priorityAsc\": \"Priorität (Hoch zuerst)\",\n    \"priorityDesc\": \"Priorität (Niedrig zuerst)\"\n  },\n  \"views\": {\n    \"kanban\": \"Kanban-Ansicht\",\n    \"list\": \"Listenansicht\",\n    \"grid\": \"Rasteransicht\"\n  },\n  \"kanban\": {\n    \"pending\": \"📋 Ausstehend\",\n    \"inProgress\": \"🚀 In Bearbeitung\",\n    \"done\": \"✅ Erledigt\",\n    \"blocked\": \"🚫 Blockiert\",\n    \"deferred\": \"⏳ Zurückgestellt\",\n    \"cancelled\": \"❌ Abgebrochen\",\n    \"noTasksYet\": \"Noch keine Aufgaben\",\n    \"tasksWillAppear\": \"Aufgaben werden hier angezeigt\",\n    \"moveTasksHere\": \"Aufgaben hierher verschieben, wenn sie gestartet werden\",\n    \"completedTasksHere\": \"Abgeschlossene Aufgaben erscheinen hier\",\n    \"statusTasksHere\": \"Aufgaben mit diesem Status werden hier angezeigt\"\n  },\n  \"buttons\": {\n    \"help\": \"TaskMaster-Leitfaden für Erste Schritte\",\n    \"prds\": \"PRDs\",\n    \"addPRD\": \"PRD hinzufügen\",\n    \"addTask\": \"Aufgabe hinzufügen\",\n    \"createNewPRD\": \"Neues PRD erstellen\",\n    \"prdsAvailable\": \"{{count}} PRD(s) verfügbar\"\n  },\n  \"prd\": {\n    \"modified\": \"Geändert: {{date}}\"\n  },\n  \"statuses\": {\n    \"pending\": \"Ausstehend\",\n    \"in-progress\": \"In Bearbeitung\",\n    \"done\": \"Erledigt\",\n    \"blocked\": \"Blockiert\",\n    \"deferred\": \"Zurückgestellt\",\n    \"cancelled\": \"Abgebrochen\"\n  },\n  \"priorities\": {\n    \"high\": \"Hoch\",\n    \"medium\": \"Mittel\",\n    \"low\": \"Niedrig\"\n  },\n  \"noMatchingTasks\": {\n    \"title\": \"Keine Aufgaben entsprechen deinen Filtern\",\n    \"description\": \"Versuche, deine Such- oder Filterkriterien anzupassen.\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/en/auth.json",
    "content": "{\n  \"login\": {\n    \"title\": \"Welcome Back\",\n    \"description\": \"Sign in to your Claude Code UI account\",\n    \"username\": \"Username\",\n    \"password\": \"Password\",\n    \"submit\": \"Sign In\",\n    \"loading\": \"Signing in...\",\n    \"errors\": {\n      \"invalidCredentials\": \"Invalid username or password\",\n      \"requiredFields\": \"Please fill in all fields\",\n      \"networkError\": \"Network error. Please try again.\"\n    },\n    \"placeholders\": {\n      \"username\": \"Enter your username\",\n      \"password\": \"Enter your password\"\n    }\n  },\n  \"register\": {\n    \"title\": \"Create Account\",\n    \"username\": \"Username\",\n    \"password\": \"Password\",\n    \"confirmPassword\": \"Confirm Password\",\n    \"submit\": \"Create Account\",\n    \"loading\": \"Creating account...\",\n    \"errors\": {\n      \"passwordMismatch\": \"Passwords do not match\",\n      \"usernameTaken\": \"Username is already taken\",\n      \"weakPassword\": \"Password is too weak\"\n    }\n  },\n  \"logout\": {\n    \"title\": \"Sign Out\",\n    \"confirm\": \"Are you sure you want to sign out?\",\n    \"button\": \"Sign Out\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/en/chat.json",
    "content": "{\n  \"codeBlock\": {\n    \"copy\": \"Copy\",\n    \"copied\": \"Copied\",\n    \"copyCode\": \"Copy code\"\n  },\n  \"copyMessage\": {\n    \"copy\": \"Copy message\",\n    \"copied\": \"Message copied\",\n    \"selectFormat\": \"Select copy format\",\n    \"copyAsMarkdown\": \"Copy as markdown\",\n    \"copyAsText\": \"Copy as text\"\n  },\n  \"messageTypes\": {\n    \"user\": \"U\",\n    \"error\": \"Error\",\n    \"tool\": \"Tool\",\n    \"claude\": \"Claude\",\n    \"cursor\": \"Cursor\",\n    \"codex\": \"Codex\",\n    \"gemini\": \"Gemini\"\n  },\n  \"tools\": {\n    \"settings\": \"Tool Settings\",\n    \"error\": \"Tool Error\",\n    \"result\": \"Tool Result\",\n    \"viewParams\": \"View input parameters\",\n    \"viewRawParams\": \"View raw parameters\",\n    \"viewDiff\": \"View edit diff for\",\n    \"creatingFile\": \"Creating new file:\",\n    \"updatingTodo\": \"Updating Todo List\",\n    \"read\": \"Read\",\n    \"readFile\": \"Read file\",\n    \"updateTodo\": \"Update todo list\",\n    \"readTodo\": \"Read todo list\",\n    \"searchResults\": \"results\"\n  },\n  \"search\": {\n    \"found\": \"Found {{count}} {{type}}\",\n    \"file\": \"file\",\n    \"files\": \"files\",\n    \"pattern\": \"pattern:\",\n    \"in\": \"in:\"\n  },\n  \"fileOperations\": {\n    \"updated\": \"File updated successfully\",\n    \"created\": \"File created successfully\",\n    \"written\": \"File written successfully\",\n    \"diff\": \"Diff\",\n    \"newFile\": \"New File\",\n    \"viewContent\": \"View file content\",\n    \"viewFullOutput\": \"View full output ({{count}} chars)\",\n    \"contentDisplayed\": \"The file content is displayed in the diff view above\"\n  },\n  \"interactive\": {\n    \"title\": \"Interactive Prompt\",\n    \"waiting\": \"Waiting for your response in the CLI\",\n    \"instruction\": \"Please select an option in your terminal where Claude is running.\",\n    \"selectedOption\": \"✓ Claude selected option {{number}}\",\n    \"instructionDetail\": \"In the CLI, you would select this option interactively using arrow keys or by typing the number.\"\n  },\n  \"thinking\": {\n    \"title\": \"Thinking...\",\n    \"emoji\": \"💭 Thinking...\"\n  },\n  \"json\": {\n    \"response\": \"JSON Response\"\n  },\n  \"permissions\": {\n    \"grant\": \"Grant permission for {{tool}}\",\n    \"added\": \"Permission added\",\n    \"addTo\": \"Adds {{entry}} to Allowed Tools.\",\n    \"retry\": \"Permission saved. Retry the request to use the tool.\",\n    \"error\": \"Unable to update permissions. Please try again.\",\n    \"openSettings\": \"Open settings\"\n  },\n  \"todo\": {\n    \"updated\": \"Todo list has been updated successfully\",\n    \"current\": \"Current Todo List\"\n  },\n  \"plan\": {\n    \"viewPlan\": \"📋 View implementation plan\",\n    \"title\": \"Implementation Plan\"\n  },\n  \"usageLimit\": {\n    \"resetAt\": \"Claude usage limit reached. Your limit will reset at **{{time}} {{timezone}}** - {{date}}\"\n  },\n  \"codex\": {\n    \"permissionMode\": \"Permission Mode\",\n    \"modes\": {\n      \"default\": \"Default Mode\",\n      \"acceptEdits\": \"Accept Edits\",\n      \"bypassPermissions\": \"Bypass Permissions\",\n      \"plan\": \"Plan Mode\"\n    },\n    \"descriptions\": {\n      \"default\": \"Only trusted commands (ls, cat, grep, git status, etc.) run automatically. Other commands are skipped. Can write to workspace.\",\n      \"acceptEdits\": \"All commands run automatically within the workspace. Full auto mode with sandboxed execution.\",\n      \"bypassPermissions\": \"Full system access with no restrictions. All commands run automatically with full disk and network access. Use with caution.\",\n      \"plan\": \"Planning mode - no commands are executed\"\n    },\n    \"technicalDetails\": \"Technical details\"\n  },\n  \"gemini\": {\n    \"permissionMode\": \"Gemini Permission Mode\",\n    \"description\": \"Control how Gemini CLI handles operation approvals.\",\n    \"modes\": {\n      \"default\": {\n        \"title\": \"Standard (Ask for Approval)\",\n        \"description\": \"Gemini will prompt for approval before executing commands, writing files, and fetching web resources.\"\n      },\n      \"autoEdit\": {\n        \"title\": \"Auto Edit (Skip File Approvals)\",\n        \"description\": \"Gemini will automatically approve file edits and web fetches, but will still prompt for shell commands.\"\n      },\n      \"yolo\": {\n        \"title\": \"YOLO (Bypass All Permissions)\",\n        \"description\": \"Gemini will execute all operations without asking for approval. Exercise caution.\"\n      }\n    }\n  },\n  \"input\": {\n    \"placeholder\": \"Type / for commands, @ for files, or ask {{provider}} anything...\",\n    \"placeholderDefault\": \"Type your message...\",\n    \"disabled\": \"Input disabled\",\n    \"attachFiles\": \"Attach files\",\n    \"attachImages\": \"Attach images\",\n    \"send\": \"Send\",\n    \"stop\": \"Stop\",\n    \"hintText\": {\n      \"ctrlEnter\": \"Ctrl+Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands\",\n      \"enter\": \"Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands\"\n    },\n    \"clickToChangeMode\": \"Click to change permission mode (or press Tab in input)\",\n    \"showAllCommands\": \"Show all commands\",\n    \"clearInput\": \"Clear input\",\n    \"scrollToBottom\": \"Scroll to bottom\"\n  },\n  \"thinkingMode\": {\n    \"selector\": {\n      \"title\": \"Thinking Mode\",\n      \"description\": \"Extended thinking gives Claude more time to evaluate alternatives\",\n      \"active\": \"Active\",\n      \"tip\": \"Higher thinking modes take more time but provide more thorough analysis\"\n    },\n    \"modes\": {\n      \"none\": {\n        \"name\": \"Standard\",\n        \"description\": \"Regular Claude response\",\n        \"prefix\": \"\"\n      },\n      \"think\": {\n        \"name\": \"Think\",\n        \"description\": \"Basic extended thinking\",\n        \"prefix\": \"think\"\n      },\n      \"thinkHard\": {\n        \"name\": \"Think Hard\",\n        \"description\": \"More thorough evaluation\",\n        \"prefix\": \"think hard\"\n      },\n      \"thinkHarder\": {\n        \"name\": \"Think Harder\",\n        \"description\": \"Deep analysis with alternatives\",\n        \"prefix\": \"think harder\"\n      },\n      \"ultrathink\": {\n        \"name\": \"Ultrathink\",\n        \"description\": \"Maximum thinking budget\",\n        \"prefix\": \"ultrathink\"\n      }\n    },\n    \"buttonTitle\": \"Thinking mode: {{mode}}\"\n  },\n  \"providerSelection\": {\n    \"title\": \"Choose Your AI Assistant\",\n    \"description\": \"Select a provider to start a new conversation\",\n    \"selectModel\": \"Select Model\",\n    \"providerInfo\": {\n      \"anthropic\": \"by Anthropic\",\n      \"openai\": \"by OpenAI\",\n      \"cursorEditor\": \"AI Code Editor\",\n      \"google\": \"by Google\"\n    },\n    \"readyPrompt\": {\n      \"claude\": \"Ready to use Claude with {{model}}. Start typing your message below.\",\n      \"cursor\": \"Ready to use Cursor with {{model}}. Start typing your message below.\",\n      \"codex\": \"Ready to use Codex with {{model}}. Start typing your message below.\",\n      \"gemini\": \"Ready to use Gemini with {{model}}. Start typing your message below.\",\n      \"default\": \"Select a provider above to begin\"\n    }\n  },\n  \"session\": {\n    \"continue\": {\n      \"title\": \"Continue your conversation\",\n      \"description\": \"Ask questions about your code, request changes, or get help with development tasks\"\n    },\n    \"loading\": {\n      \"olderMessages\": \"Loading older messages...\",\n      \"sessionMessages\": \"Loading session messages...\"\n    },\n    \"messages\": {\n      \"showingOf\": \"Showing {{shown}} of {{total}} messages\",\n      \"scrollToLoad\": \"Scroll up to load more\",\n      \"showingLast\": \"Showing last {{count}} messages ({{total}} total)\",\n      \"loadEarlier\": \"Load earlier messages\",\n      \"loadAll\": \"Load all messages\",\n      \"loadingAll\": \"Loading all messages...\",\n      \"allLoaded\": \"All messages loaded\",\n      \"perfWarning\": \"All messages loaded — scrolling may be slower. Click \\\"Scroll to bottom\\\" to restore performance.\"\n    }\n  },\n  \"shell\": {\n    \"selectProject\": {\n      \"title\": \"Select a Project\",\n      \"description\": \"Choose a project to open an interactive shell in that directory\"\n    },\n    \"status\": {\n      \"newSession\": \"New Session\",\n      \"initializing\": \"Initializing...\",\n      \"restarting\": \"Restarting...\"\n    },\n    \"actions\": {\n      \"disconnect\": \"Disconnect\",\n      \"disconnectTitle\": \"Disconnect from shell\",\n      \"restart\": \"Restart\",\n      \"restartTitle\": \"Restart Shell (disconnect first)\",\n      \"connect\": \"Continue in Shell\",\n      \"connectTitle\": \"Connect to shell\"\n    },\n    \"loading\": \"Loading terminal...\",\n    \"connecting\": \"Connecting to shell...\",\n    \"startSession\": \"Start a new Claude session\",\n    \"resumeSession\": \"Resume session: {{displayName}}...\",\n    \"runCommand\": \"Run {{command}} in {{projectName}}\",\n    \"startCli\": \"Starting Claude CLI in {{projectName}}\",\n    \"defaultCommand\": \"command\"\n  },\n  \"claudeStatus\": {\n    \"actions\": {\n      \"thinking\": \"Thinking\",\n      \"processing\": \"Processing\",\n      \"analyzing\": \"Analyzing\",\n      \"working\": \"Working\",\n      \"computing\": \"Computing\",\n      \"reasoning\": \"Reasoning\"\n    },\n    \"state\": {\n      \"live\": \"Live\",\n      \"paused\": \"Paused\"\n    },\n    \"elapsed\": {\n      \"seconds\": \"{{count}}s\",\n      \"minutesSeconds\": \"{{minutes}}m {{seconds}}s\",\n      \"label\": \"{{time}} elapsed\",\n      \"startingNow\": \"Starting now\"\n    },\n    \"controls\": {\n      \"stopGeneration\": \"Stop Generation\",\n      \"pressEscToStop\": \"Press Esc anytime to stop\"\n    },\n    \"providers\": {\n      \"assistant\": \"Assistant\"\n    }\n  },\n  \"projectSelection\": {\n    \"startChatWithProvider\": \"Select a project to start chatting with {{provider}}\"\n  },\n  \"tasks\": {\n    \"nextTaskPrompt\": \"Start the next task\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/en/codeEditor.json",
    "content": "{\n  \"toolbar\": {\n    \"changes\": \"changes\",\n    \"previousChange\": \"Previous change\",\n    \"nextChange\": \"Next change\",\n    \"hideDiff\": \"Hide diff highlighting\",\n    \"showDiff\": \"Show diff highlighting\",\n    \"settings\": \"Editor Settings\",\n    \"collapse\": \"Collapse editor\",\n    \"expand\": \"Expand editor to full width\"\n  },\n  \"loading\": \"Loading {{fileName}}...\",\n  \"header\": {\n    \"showingChanges\": \"Showing changes\"\n  },\n  \"actions\": {\n    \"download\": \"Download file\",\n    \"save\": \"Save\",\n    \"saving\": \"Saving...\",\n    \"saved\": \"Saved!\",\n    \"exitFullscreen\": \"Exit fullscreen\",\n    \"fullscreen\": \"Fullscreen\",\n    \"close\": \"Close\",\n    \"previewMarkdown\": \"Preview markdown\",\n    \"editMarkdown\": \"Edit markdown\"\n  },\n  \"footer\": {\n    \"lines\": \"Lines:\",\n    \"characters\": \"Characters:\",\n    \"shortcuts\": \"Press Ctrl+S to save • Esc to close\"\n  },\n  \"binaryFile\": {\n    \"title\": \"Binary File\",\n    \"message\": \"The file \\\"{{fileName}}\\\" cannot be displayed in the text editor because it is a binary file.\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/en/common.json",
    "content": "{\n  \"buttons\": {\n    \"save\": \"Save\",\n    \"cancel\": \"Cancel\",\n    \"delete\": \"Delete\",\n    \"create\": \"Create\",\n    \"edit\": \"Edit\",\n    \"close\": \"Close\",\n    \"confirm\": \"Confirm\",\n    \"submit\": \"Submit\",\n    \"retry\": \"Retry\",\n    \"refresh\": \"Refresh\",\n    \"search\": \"Search\",\n    \"clear\": \"Clear\",\n    \"copy\": \"Copy\",\n    \"download\": \"Download\",\n    \"upload\": \"Upload\",\n    \"browse\": \"Browse\"\n  },\n  \"tabs\": {\n    \"chat\": \"Chat\",\n    \"shell\": \"Shell\",\n    \"files\": \"Files\",\n    \"git\": \"Source Control\",\n    \"tasks\": \"Tasks\"\n  },\n  \"status\": {\n    \"loading\": \"Loading...\",\n    \"success\": \"Success\",\n    \"error\": \"Error\",\n    \"failed\": \"Failed\",\n    \"pending\": \"Pending\",\n    \"completed\": \"Completed\",\n    \"inProgress\": \"In Progress\"\n  },\n  \"messages\": {\n    \"savedSuccessfully\": \"Saved successfully\",\n    \"deletedSuccessfully\": \"Deleted successfully\",\n    \"updatedSuccessfully\": \"Updated successfully\",\n    \"operationFailed\": \"Operation failed\",\n    \"networkError\": \"Network error. Please check your connection.\",\n    \"unauthorized\": \"Unauthorized. Please log in.\",\n    \"notFound\": \"Not found\",\n    \"invalidInput\": \"Invalid input\",\n    \"requiredField\": \"This field is required\",\n    \"unknownError\": \"An unknown error occurred\"\n  },\n  \"navigation\": {\n    \"settings\": \"Settings\",\n    \"home\": \"Home\",\n    \"back\": \"Back\",\n    \"next\": \"Next\",\n    \"previous\": \"Previous\",\n    \"logout\": \"Logout\"\n  },\n  \"common\": {\n    \"language\": \"Language\",\n    \"theme\": \"Theme\",\n    \"darkMode\": \"Dark Mode\",\n    \"lightMode\": \"Light Mode\",\n    \"name\": \"Name\",\n    \"description\": \"Description\",\n    \"enabled\": \"Enabled\",\n    \"disabled\": \"Disabled\",\n    \"optional\": \"Optional\",\n    \"version\": \"Version\",\n    \"select\": \"Select\",\n    \"selectAll\": \"Select All\",\n    \"deselectAll\": \"Deselect All\"\n  },\n  \"time\": {\n    \"justNow\": \"Just now\",\n    \"minutesAgo\": \"{{count}} mins ago\",\n    \"hoursAgo\": \"{{count}} hours ago\",\n    \"daysAgo\": \"{{count}} days ago\",\n    \"yesterday\": \"Yesterday\"\n  },\n  \"fileOperations\": {\n    \"newFile\": \"New File\",\n    \"newFolder\": \"New Folder\",\n    \"rename\": \"Rename\",\n    \"move\": \"Move\",\n    \"copyPath\": \"Copy Path\",\n    \"openInEditor\": \"Open in Editor\"\n  },\n  \"mainContent\": {\n    \"loading\": \"Loading Claude Code UI\",\n    \"settingUpWorkspace\": \"Setting up your workspace...\",\n    \"chooseProject\": \"Choose Your Project\",\n    \"selectProjectDescription\": \"Select a project from the sidebar to start coding with Claude. Each project contains your chat sessions and file history.\",\n    \"tip\": \"Tip\",\n    \"createProjectMobile\": \"Tap the menu button above to access projects\",\n    \"createProjectDesktop\": \"Create a new project by clicking the folder icon in the sidebar\",\n    \"newSession\": \"New Session\",\n    \"untitledSession\": \"Untitled Session\",\n    \"projectFiles\": \"Project Files\"\n  },\n  \"fileTree\": {\n    \"loading\": \"Loading files...\",\n    \"files\": \"Files\",\n    \"simpleView\": \"Simple view\",\n    \"compactView\": \"Compact view\",\n    \"detailedView\": \"Detailed view\",\n    \"searchPlaceholder\": \"Search files and folders...\",\n    \"clearSearch\": \"Clear search\",\n    \"name\": \"Name\",\n    \"size\": \"Size\",\n    \"modified\": \"Modified\",\n    \"permissions\": \"Permissions\",\n    \"noFilesFound\": \"No files found\",\n    \"checkProjectPath\": \"Check if the project path is accessible\",\n    \"noMatchesFound\": \"No matches found\",\n    \"tryDifferentSearch\": \"Try a different search term or clear the search\",\n    \"justNow\": \"just now\",\n    \"minAgo\": \"{{count}} min ago\",\n    \"hoursAgo\": \"{{count}} hours ago\",\n    \"daysAgo\": \"{{count}} days ago\",\n    \"newFile\": \"New File (Cmd+N)\",\n    \"newFolder\": \"New Folder (Cmd+Shift+N)\",\n    \"refresh\": \"Refresh\",\n    \"collapseAll\": \"Collapse All\",\n    \"context\": {\n      \"rename\": \"Rename\",\n      \"delete\": \"Delete\",\n      \"copyPath\": \"Copy Path\",\n      \"download\": \"Download\",\n      \"newFile\": \"New File\",\n      \"newFolder\": \"New Folder\",\n      \"refresh\": \"Refresh\",\n      \"menuLabel\": \"File context menu\",\n      \"loading\": \"Loading...\"\n    }\n  },\n  \"projectWizard\": {\n    \"title\": \"Create New Project\",\n    \"steps\": {\n      \"type\": \"Type\",\n      \"configure\": \"Configure\",\n      \"confirm\": \"Confirm\"\n    },\n    \"step1\": {\n      \"question\": \"Do you already have a workspace, or would you like to create a new one?\",\n      \"existing\": {\n        \"title\": \"Existing Workspace\",\n        \"description\": \"I already have a workspace on my server and just need to add it to the project list\"\n      },\n      \"new\": {\n        \"title\": \"New Workspace\",\n        \"description\": \"Create a new workspace, optionally clone from a GitHub repository\"\n      }\n    },\n    \"step2\": {\n      \"existingPath\": \"Workspace Path\",\n      \"newPath\": \"Workspace Path\",\n      \"existingPlaceholder\": \"/path/to/existing/workspace\",\n      \"newPlaceholder\": \"/path/to/new/workspace\",\n      \"existingHelp\": \"Full path to your existing workspace directory\",\n      \"newHelp\": \"Full path to your workspace directory\",\n      \"githubUrl\": \"GitHub URL (Optional)\",\n      \"githubPlaceholder\": \"https://github.com/username/repository\",\n      \"githubHelp\": \"Optional: provide a GitHub URL to clone a repository\",\n      \"githubAuth\": \"GitHub Authentication (Optional)\",\n      \"githubAuthHelp\": \"Only required for private repositories. Public repos can be cloned without authentication.\",\n      \"loadingTokens\": \"Loading stored tokens...\",\n      \"storedToken\": \"Stored Token\",\n      \"newToken\": \"New Token\",\n      \"nonePublic\": \"None (Public)\",\n      \"selectToken\": \"Select Token\",\n      \"selectTokenPlaceholder\": \"-- Select a token --\",\n      \"tokenPlaceholder\": \"ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\n      \"tokenHelp\": \"This token will be used only for this operation\",\n      \"publicRepoInfo\": \"Public repositories don't require authentication. You can skip providing a token if cloning a public repo.\",\n      \"noTokensHelp\": \"No stored tokens available. You can add tokens in Settings → API Keys for easier reuse.\",\n      \"optionalTokenPublic\": \"GitHub Token (Optional for Public Repos)\",\n      \"tokenPublicPlaceholder\": \"ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (leave empty for public repos)\"\n    },\n    \"step3\": {\n      \"reviewConfig\": \"Review Your Configuration\",\n      \"workspaceType\": \"Workspace Type:\",\n      \"existingWorkspace\": \"Existing Workspace\",\n      \"newWorkspace\": \"New Workspace\",\n      \"path\": \"Path:\",\n      \"cloneFrom\": \"Clone From:\",\n      \"authentication\": \"Authentication:\",\n      \"usingStoredToken\": \"Using stored token:\",\n      \"usingProvidedToken\": \"Using provided token\",\n      \"noAuthentication\": \"No authentication\",\n      \"sshKey\": \"SSH Key\",\n      \"existingInfo\": \"The workspace will be added to your project list and will be available for Claude/Cursor sessions.\",\n      \"newWithClone\": \"The repository will be cloned from this folder.\",\n      \"newEmpty\": \"The workspace will be added to your project list and will be available for Claude/Cursor sessions.\",\n      \"cloningRepository\": \"Cloning repository...\"\n    },\n    \"buttons\": {\n      \"cancel\": \"Cancel\",\n      \"back\": \"Back\",\n      \"next\": \"Next\",\n      \"createProject\": \"Create Project\",\n      \"creating\": \"Creating...\",\n      \"cloning\": \"Cloning...\"\n    },\n    \"errors\": {\n      \"selectType\": \"Please select whether you have an existing workspace or want to create a new one\",\n      \"providePath\": \"Please provide a workspace path\",\n      \"failedToCreate\": \"Failed to create workspace\",\n      \"failedToCreateFolder\": \"Failed to create folder\"\n    }\n  },\n  \"notifications\": {\n    \"genericTool\": \"a tool\",\n    \"codes\": {\n      \"generic\": {\n        \"info\": {\n          \"title\": \"Notification\"\n        }\n      },\n      \"permission\": {\n        \"required\": {\n          \"title\": \"Action Required\",\n          \"body\": \"{{toolName}} is waiting for your decision.\"\n        }\n      },\n      \"run\": {\n        \"stopped\": {\n          \"title\": \"Run Stopped\",\n          \"body\": \"Reason: {{reason}}\"\n        },\n        \"failed\": {\n          \"title\": \"Run Failed\"\n        }\n      },\n      \"agent\": {\n        \"notification\": {\n          \"title\": \"Agent Notification\"\n        }\n      }\n    }\n  },\n  \"versionUpdate\": {\n    \"title\": \"Update Available\",\n    \"newVersionReady\": \"A new version is ready\",\n    \"currentVersion\": \"Current Version\",\n    \"latestVersion\": \"Latest Version\",\n    \"whatsNew\": \"What's New:\",\n    \"viewFullRelease\": \"View full release\",\n    \"updateProgress\": \"Update Progress:\",\n    \"manualUpgrade\": \"Manual upgrade:\",\n    \"npmUpgradeCommand\": \"npm install -g @siteboon/claude-code-ui@latest\",\n    \"manualUpgradeHint\": \"Or click \\\"Update Now\\\" to run the update automatically.\",\n    \"updateCompleted\": \"Update completed successfully!\",\n    \"restartServer\": \"Please restart the server to apply changes.\",\n    \"updateFailed\": \"Update failed\",\n    \"buttons\": {\n      \"close\": \"Close\",\n      \"later\": \"Later\",\n      \"copyCommand\": \"Copy Command\",\n      \"updateNow\": \"Update Now\",\n      \"updating\": \"Updating...\"\n    },\n    \"ariaLabels\": {\n      \"closeModal\": \"Close version upgrade modal\",\n      \"showSidebar\": \"Show sidebar\",\n      \"settings\": \"Settings\",\n      \"updateAvailable\": \"Update available\",\n      \"closeSidebar\": \"Close sidebar\"\n    }\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/en/settings.json",
    "content": "{\n  \"title\": \"Settings\",\n  \"tabs\": {\n    \"account\": \"Account\",\n    \"permissions\": \"Permissions\",\n    \"mcpServers\": \"MCP Servers\",\n    \"appearance\": \"Appearance\"\n  },\n  \"account\": {\n    \"title\": \"Account\",\n    \"language\": \"Language\",\n    \"languageLabel\": \"Display Language\",\n    \"languageDescription\": \"Choose your preferred language for the interface\",\n    \"username\": \"Username\",\n    \"email\": \"Email\",\n    \"profile\": \"Profile\",\n    \"changePassword\": \"Change Password\"\n  },\n  \"mcp\": {\n    \"title\": \"MCP Servers\",\n    \"addServer\": \"Add Server\",\n    \"editServer\": \"Edit Server\",\n    \"deleteServer\": \"Delete Server\",\n    \"serverName\": \"Server Name\",\n    \"serverType\": \"Server Type\",\n    \"config\": \"Configuration\",\n    \"testConnection\": \"Test Connection\",\n    \"status\": \"Status\",\n    \"connected\": \"Connected\",\n    \"disconnected\": \"Disconnected\",\n    \"scope\": {\n      \"label\": \"Scope\",\n      \"user\": \"User\",\n      \"project\": \"Project\"\n    }\n  },\n  \"appearance\": {\n    \"title\": \"Appearance\",\n    \"theme\": \"Theme\",\n    \"codeEditor\": \"Code Editor\",\n    \"editorTheme\": \"Editor Theme\",\n    \"wordWrap\": \"Word Wrap\",\n    \"showMinimap\": \"Show Minimap\",\n    \"lineNumbers\": \"Line Numbers\",\n    \"fontSize\": \"Font Size\"\n  },\n  \"actions\": {\n    \"saveChanges\": \"Save Changes\",\n    \"resetToDefaults\": \"Reset to Defaults\",\n    \"cancelChanges\": \"Cancel Changes\"\n  },\n  \"quickSettings\": {\n    \"title\": \"Quick Settings\",\n    \"sections\": {\n      \"appearance\": \"Appearance\",\n      \"toolDisplay\": \"Tool Display\",\n      \"viewOptions\": \"View Options\",\n      \"inputSettings\": \"Input Settings\",\n      \"whisperDictation\": \"Whisper Dictation\"\n    },\n    \"darkMode\": \"Dark Mode\",\n    \"autoExpandTools\": \"Auto-expand tools\",\n    \"showRawParameters\": \"Show raw parameters\",\n    \"showThinking\": \"Show thinking\",\n    \"autoScrollToBottom\": \"Auto-scroll to bottom\",\n    \"sendByCtrlEnter\": \"Send by Ctrl+Enter\",\n    \"sendByCtrlEnterDescription\": \"When enabled, pressing Ctrl+Enter will send the message instead of just Enter. This is useful for IME users to avoid accidental sends.\",\n    \"dragHandle\": {\n      \"dragging\": \"Dragging handle\",\n      \"closePanel\": \"Close settings panel\",\n      \"openPanel\": \"Open settings panel\",\n      \"draggingStatus\": \"Dragging...\",\n      \"toggleAndMove\": \"Click to toggle, drag to move\"\n    },\n    \"whisper\": {\n      \"modes\": {\n        \"default\": \"Default Mode\",\n        \"defaultDescription\": \"Direct transcription of your speech\",\n        \"prompt\": \"Prompt Enhancement\",\n        \"promptDescription\": \"Transform rough ideas into clear, detailed AI prompts\",\n        \"vibe\": \"Vibe Mode\",\n        \"vibeDescription\": \"Format ideas as clear agent instructions with details\"\n      }\n    }\n  },\n  \"terminalShortcuts\": {\n    \"title\": \"Terminal Shortcuts\",\n    \"sectionKeys\": \"Keys\",\n    \"sectionNavigation\": \"Navigation\",\n    \"escape\": \"Escape\",\n    \"tab\": \"Tab\",\n    \"shiftTab\": \"Shift+Tab\",\n    \"arrowUp\": \"Arrow Up\",\n    \"arrowDown\": \"Arrow Down\",\n    \"scrollDown\": \"Scroll Down\",\n    \"handle\": {\n      \"closePanel\": \"Close shortcuts panel\",\n      \"openPanel\": \"Open shortcuts panel\"\n    }\n  },\n  \"mainTabs\": {\n    \"label\": \"Settings\",\n    \"agents\": \"Agents\",\n    \"appearance\": \"Appearance\",\n    \"git\": \"Git\",\n    \"apiTokens\": \"API & Tokens\",\n    \"tasks\": \"Tasks\",\n    \"notifications\": \"Notifications\",\n    \"plugins\": \"Plugins\"\n\n  },\n  \"notifications\": {\n    \"title\": \"Notifications\",\n    \"description\": \"Control which notification events you receive.\",\n    \"webPush\": {\n      \"title\": \"Web Push Notifications\",\n      \"enable\": \"Enable Push Notifications\",\n      \"disable\": \"Disable Push Notifications\",\n      \"enabled\": \"Push notifications are enabled\",\n      \"loading\": \"Updating...\",\n      \"unsupported\": \"Push notifications are not supported in this browser.\",\n      \"denied\": \"Push notifications are blocked. Please allow them in your browser settings.\"\n    },\n    \"events\": {\n      \"title\": \"Event Types\",\n      \"actionRequired\": \"Action required\",\n      \"stop\": \"Run stopped\",\n      \"error\": \"Run failed\"\n    }\n  },\n  \"appearanceSettings\": {\n    \"darkMode\": {\n      \"label\": \"Dark Mode\",\n      \"description\": \"Toggle between light and dark themes\"\n    },\n    \"projectSorting\": {\n      \"label\": \"Project Sorting\",\n      \"description\": \"How projects are ordered in the sidebar\",\n      \"alphabetical\": \"Alphabetical\",\n      \"recentActivity\": \"Recent Activity\"\n    },\n    \"codeEditor\": {\n      \"title\": \"Code Editor\",\n      \"theme\": {\n        \"label\": \"Editor Theme\",\n        \"description\": \"Default theme for the code editor\"\n      },\n      \"wordWrap\": {\n        \"label\": \"Word Wrap\",\n        \"description\": \"Enable word wrapping by default in the editor\"\n      },\n      \"showMinimap\": {\n        \"label\": \"Show Minimap\",\n        \"description\": \"Display a minimap for easier navigation in diff view\"\n      },\n      \"lineNumbers\": {\n        \"label\": \"Show Line Numbers\",\n        \"description\": \"Display line numbers in the editor\"\n      },\n      \"fontSize\": {\n        \"label\": \"Font Size\",\n        \"description\": \"Editor font size in pixels\"\n      }\n    }\n  },\n  \"mcpForm\": {\n    \"title\": {\n      \"add\": \"Add MCP Server\",\n      \"edit\": \"Edit MCP Server\"\n    },\n    \"importMode\": {\n      \"form\": \"Form Input\",\n      \"json\": \"JSON Import\"\n    },\n    \"scope\": {\n      \"label\": \"Scope\",\n      \"userGlobal\": \"User (Global)\",\n      \"projectLocal\": \"Project (Local)\",\n      \"userDescription\": \"User scope: Available across all projects on your machine\",\n      \"projectDescription\": \"Local scope: Only available in the selected project\",\n      \"cannotChange\": \"Scope cannot be changed when editing an existing server\"\n    },\n    \"fields\": {\n      \"serverName\": \"Server Name\",\n      \"transportType\": \"Transport Type\",\n      \"command\": \"Command\",\n      \"arguments\": \"Arguments (one per line)\",\n      \"jsonConfig\": \"JSON Configuration\",\n      \"url\": \"URL\",\n      \"envVars\": \"Environment Variables (KEY=value, one per line)\",\n      \"headers\": \"Headers (KEY=value, one per line)\",\n      \"selectProject\": \"Select a project...\"\n    },\n    \"placeholders\": {\n      \"serverName\": \"my-server\"\n    },\n    \"validation\": {\n      \"missingType\": \"Missing required field: type\",\n      \"stdioRequiresCommand\": \"stdio type requires a command field\",\n      \"httpRequiresUrl\": \"{{type}} type requires a url field\",\n      \"invalidJson\": \"Invalid JSON format\",\n      \"jsonHelp\": \"Paste your MCP server configuration in JSON format. Example formats:\",\n      \"jsonExampleStdio\": \"• stdio: {\\\"type\\\":\\\"stdio\\\",\\\"command\\\":\\\"npx\\\",\\\"args\\\":[\\\"@upstash/context7-mcp\\\"]}\",\n      \"jsonExampleHttp\": \"• http/sse: {\\\"type\\\":\\\"http\\\",\\\"url\\\":\\\"https://api.example.com/mcp\\\"}\"\n    },\n    \"configDetails\": \"Configuration Details (from {{configFile}})\",\n    \"projectPath\": \"Path: {{path}}\",\n    \"actions\": {\n      \"cancel\": \"Cancel\",\n      \"saving\": \"Saving...\",\n      \"addServer\": \"Add Server\",\n      \"updateServer\": \"Update Server\"\n    }\n  },\n  \"saveStatus\": {\n    \"success\": \"Settings saved successfully!\",\n    \"error\": \"Failed to save settings\",\n    \"saving\": \"Saving...\"\n  },\n  \"footerActions\": {\n    \"save\": \"Save Settings\",\n    \"cancel\": \"Cancel\"\n  },\n  \"git\": {\n    \"title\": \"Git Configuration\",\n    \"description\": \"Configure your git identity for commits. These settings will be applied globally via git config --global\",\n    \"name\": {\n      \"label\": \"Git Name\",\n      \"help\": \"Your name for git commits\"\n    },\n    \"email\": {\n      \"label\": \"Git Email\",\n      \"help\": \"Your email for git commits\"\n    },\n    \"actions\": {\n      \"save\": \"Save Configuration\",\n      \"saving\": \"Saving...\"\n    },\n    \"status\": {\n      \"success\": \"Saved successfully\"\n    }\n  },\n  \"apiKeys\": {\n    \"title\": \"API Keys\",\n    \"description\": \"Generate API keys to access the external API from other applications.\",\n    \"newKey\": {\n      \"alertTitle\": \"⚠️ Save Your API Key\",\n      \"alertMessage\": \"This is the only time you'll see this key. Store it securely.\",\n      \"iveSavedIt\": \"I've saved it\"\n    },\n    \"form\": {\n      \"placeholder\": \"API Key Name (e.g., Production Server)\",\n      \"createButton\": \"Create\",\n      \"cancelButton\": \"Cancel\"\n    },\n    \"newButton\": \"New API Key\",\n    \"empty\": \"No API keys created yet.\",\n    \"list\": {\n      \"created\": \"Created:\",\n      \"lastUsed\": \"Last used:\"\n    },\n    \"confirmDelete\": \"Are you sure you want to delete this API key?\",\n    \"status\": {\n      \"active\": \"Active\",\n      \"inactive\": \"Inactive\"\n    },\n    \"github\": {\n      \"title\": \"GitHub Tokens\",\n      \"description\": \"Add GitHub Personal Access Tokens to clone private repositories via the external API.\",\n      \"descriptionAlt\": \"Add GitHub Personal Access Tokens to clone private repositories. You can also pass tokens directly in API requests without storing them.\",\n      \"addButton\": \"Add Token\",\n      \"form\": {\n        \"namePlaceholder\": \"Token Name (e.g., Personal Repos)\",\n        \"tokenPlaceholder\": \"GitHub Personal Access Token (ghp_...)\",\n        \"descriptionPlaceholder\": \"Description (optional)\",\n        \"addButton\": \"Add Token\",\n        \"cancelButton\": \"Cancel\",\n        \"howToCreate\": \"How to create a GitHub Personal Access Token →\"\n      },\n      \"empty\": \"No GitHub tokens added yet.\",\n      \"added\": \"Added:\",\n      \"confirmDelete\": \"Are you sure you want to delete this GitHub token?\"\n    },\n    \"apiDocsLink\": \"API Documentation\",\n    \"documentation\": {\n      \"title\": \"External API Documentation\",\n      \"description\": \"Learn how to use the external API to trigger Claude/Cursor sessions from your applications.\",\n      \"viewLink\": \"View API Documentation →\"\n    },\n    \"loading\": \"Loading...\",\n    \"version\": {\n      \"updateAvailable\": \"Update available: v{{version}}\"\n    }\n  },\n  \"tasks\": {\n    \"checking\": \"Checking TaskMaster installation...\",\n    \"notInstalled\": {\n      \"title\": \"TaskMaster AI CLI Not Installed\",\n      \"description\": \"TaskMaster CLI is required to use task management features. Install it to get started:\",\n      \"installCommand\": \"npm install -g task-master-ai\",\n      \"viewOnGitHub\": \"View on GitHub\",\n      \"afterInstallation\": \"After installation:\",\n      \"steps\": {\n        \"restart\": \"Restart this application\",\n        \"autoAvailable\": \"TaskMaster features will automatically become available\",\n        \"initCommand\": \"Use task-master init in your project directory\"\n      }\n    },\n    \"settings\": {\n      \"enableLabel\": \"Enable TaskMaster Integration\",\n      \"enableDescription\": \"Show TaskMaster tasks, banners, and sidebar indicators across the interface\"\n    }\n  },\n  \"agents\": {\n    \"authStatus\": {\n      \"checking\": \"Checking...\",\n      \"connected\": \"Connected\",\n      \"notConnected\": \"Not connected\",\n      \"disconnected\": \"Disconnected\",\n      \"checkingAuth\": \"Checking authentication status...\",\n      \"loggedInAs\": \"Logged in as {{email}}\",\n      \"authenticatedUser\": \"authenticated user\"\n    },\n    \"account\": {\n      \"claude\": {\n        \"description\": \"Anthropic Claude AI assistant\"\n      },\n      \"cursor\": {\n        \"description\": \"Cursor AI-powered code editor\"\n      },\n      \"codex\": {\n        \"description\": \"OpenAI Codex AI assistant\"\n      },\n      \"gemini\": {\n        \"description\": \"Google Gemini AI assistant\"\n      }\n    },\n    \"connectionStatus\": \"Connection Status\",\n    \"login\": {\n      \"title\": \"Login\",\n      \"reAuthenticate\": \"Re-authenticate\",\n      \"description\": \"Sign in to your {{agent}} account to enable AI features\",\n      \"reAuthDescription\": \"Sign in with a different account or refresh credentials\",\n      \"button\": \"Login\",\n      \"reLoginButton\": \"Re-login\"\n    },\n    \"error\": \"Error: {{error}}\"\n  },\n  \"permissions\": {\n    \"title\": \"Permission Settings\",\n    \"skipPermissions\": {\n      \"label\": \"Skip permission prompts (use with caution)\",\n      \"claudeDescription\": \"Equivalent to --dangerously-skip-permissions flag\",\n      \"cursorDescription\": \"Equivalent to -f flag in Cursor CLI\"\n    },\n    \"allowedTools\": {\n      \"title\": \"Allowed Tools\",\n      \"description\": \"Tools that are automatically allowed without prompting for permission\",\n      \"placeholder\": \"e.g., \\\"Bash(git log:*)\\\" or \\\"Write\\\"\",\n      \"quickAdd\": \"Quick add common tools:\",\n      \"empty\": \"No allowed tools configured\"\n    },\n    \"blockedTools\": {\n      \"title\": \"Blocked Tools\",\n      \"description\": \"Tools that are automatically blocked without prompting for permission\",\n      \"placeholder\": \"e.g., \\\"Bash(rm:*)\\\"\",\n      \"empty\": \"No blocked tools configured\"\n    },\n    \"allowedCommands\": {\n      \"title\": \"Allowed Shell Commands\",\n      \"description\": \"Shell commands that are automatically allowed without prompting\",\n      \"placeholder\": \"e.g., \\\"Shell(ls)\\\" or \\\"Shell(git status)\\\"\",\n      \"quickAdd\": \"Quick add common commands:\",\n      \"empty\": \"No allowed commands configured\"\n    },\n    \"blockedCommands\": {\n      \"title\": \"Blocked Shell Commands\",\n      \"description\": \"Shell commands that are automatically blocked\",\n      \"placeholder\": \"e.g., \\\"Shell(rm -rf)\\\" or \\\"Shell(sudo)\\\"\",\n      \"empty\": \"No blocked commands configured\"\n    },\n    \"toolExamples\": {\n      \"title\": \"Tool Pattern Examples:\",\n      \"bashGitLog\": \"- Allow all git log commands\",\n      \"bashGitDiff\": \"- Allow all git diff commands\",\n      \"write\": \"- Allow all Write tool usage\",\n      \"bashRm\": \"- Block all rm commands (dangerous)\"\n    },\n    \"shellExamples\": {\n      \"title\": \"Shell Command Examples:\",\n      \"ls\": \"- Allow ls command\",\n      \"gitStatus\": \"- Allow git status\",\n      \"npmInstall\": \"- Allow npm install\",\n      \"rmRf\": \"- Block recursive delete\"\n    },\n    \"codex\": {\n      \"permissionMode\": \"Permission Mode\",\n      \"description\": \"Controls how Codex handles file modifications and command execution\",\n      \"modes\": {\n        \"default\": {\n          \"title\": \"Default\",\n          \"description\": \"Only trusted commands (ls, cat, grep, git status, etc.) run automatically. Other commands are skipped. Can write to workspace.\"\n        },\n        \"acceptEdits\": {\n          \"title\": \"Accept Edits\",\n          \"description\": \"All commands run automatically within the workspace. Full auto mode with sandboxed execution.\"\n        },\n        \"bypassPermissions\": {\n          \"title\": \"Bypass Permissions\",\n          \"description\": \"Full system access with no restrictions. All commands run automatically with full disk and network access. Use with caution.\"\n        }\n      },\n      \"technicalDetails\": \"Technical details\",\n      \"technicalInfo\": {\n        \"default\": \"sandboxMode=workspace-write, approvalPolicy=untrusted. Trusted commands: cat, cd, grep, head, ls, pwd, tail, git status/log/diff/show, find (without -exec), etc.\",\n        \"acceptEdits\": \"sandboxMode=workspace-write, approvalPolicy=never. All commands auto-execute within project directory.\",\n        \"bypassPermissions\": \"sandboxMode=danger-full-access, approvalPolicy=never. Full system access, use only in trusted environments.\",\n        \"overrideNote\": \"You can override this per-session using the mode button in the chat interface.\"\n      }\n    },\n    \"actions\": {\n      \"add\": \"Add\"\n    }\n  },\n  \"mcpServers\": {\n    \"title\": \"MCP Servers\",\n    \"description\": {\n      \"claude\": \"Model Context Protocol servers provide additional tools and data sources to Claude\",\n      \"cursor\": \"Model Context Protocol servers provide additional tools and data sources to Cursor\",\n      \"codex\": \"Model Context Protocol servers provide additional tools and data sources to Codex\"\n    },\n    \"addButton\": \"Add MCP Server\",\n    \"empty\": \"No MCP servers configured\",\n    \"serverType\": \"Type\",\n    \"scope\": {\n      \"local\": \"local\",\n      \"user\": \"user\"\n    },\n    \"config\": {\n      \"command\": \"Command\",\n      \"url\": \"URL\",\n      \"args\": \"Args\",\n      \"environment\": \"Environment\"\n    },\n    \"tools\": {\n      \"title\": \"Tools\",\n      \"count\": \"({{count}}):\",\n      \"more\": \"+{{count}} more\"\n    },\n    \"actions\": {\n      \"edit\": \"Edit server\",\n      \"delete\": \"Delete server\"\n    },\n    \"help\": {\n      \"title\": \"About Codex MCP\",\n      \"description\": \"Codex supports stdio-based MCP servers. You can add servers that extend Codex's capabilities with additional tools and resources.\"\n    }\n  },\n  \"pluginSettings\": {\n    \"title\": \"Plugins\",\n    \"description\": \"Extend the interface with custom plugins. Install from git or drop a folder in ~/.claude-code-ui/plugins/\",\n    \"installPlaceholder\": \"https://github.com/user/my-plugin\",\n    \"installButton\": \"Install\",\n    \"installing\": \"Installing…\",\n    \"securityWarning\": \"Only install plugins whose source code you have reviewed or from authors you trust.\",\n    \"scanningPlugins\": \"Scanning plugins…\",\n    \"noPluginsInstalled\": \"No plugins installed\",\n    \"pullLatest\": \"Pull latest from git\",\n    \"noGitRemote\": \"No git remote — update not available\",\n    \"uninstallPlugin\": \"Uninstall plugin\",\n    \"confirmUninstall\": \"Click again to confirm\",\n    \"confirmUninstallMessage\": \"Remove {{name}}? This cannot be undone.\",\n    \"cancel\": \"Cancel\",\n    \"remove\": \"Remove\",\n    \"updateFailed\": \"Update failed\",\n    \"installFailed\": \"Installation failed\",\n    \"uninstallFailed\": \"Uninstall failed\",\n    \"toggleFailed\": \"Toggle failed\",\n    \"buildYourOwn\": \"Build your own plugin\",\n    \"starter\": \"Starter\",\n    \"docs\": \"Docs\",\n    \"starterPlugin\": {\n      \"name\": \"Project Stats\",\n      \"badge\": \"starter\",\n      \"description\": \"File counts, lines of code, file-type breakdown, and recent activity for your project.\",\n      \"install\": \"Install\"\n    },\n    \"morePlugins\": \"More\",\n    \"enable\": \"Enable\",\n    \"disable\": \"Disable\",\n    \"installAriaLabel\": \"Plugin git repository URL\",\n    \"tab\": \"tab\",\n    \"runningStatus\": \"running\"\n  }\n}"
  },
  {
    "path": "src/i18n/locales/en/sidebar.json",
    "content": "{\n  \"projects\": {\n    \"title\": \"Projects\",\n    \"newProject\": \"New Project\",\n    \"deleteProject\": \"Delete Project\",\n    \"renameProject\": \"Rename Project\",\n    \"noProjects\": \"No projects found\",\n    \"loadingProjects\": \"Loading projects...\",\n    \"searchPlaceholder\": \"Search projects...\",\n    \"projectNamePlaceholder\": \"Project name\",\n    \"starred\": \"Starred\",\n    \"all\": \"All\",\n    \"untitledSession\": \"Untitled Session\",\n    \"newSession\": \"New Session\",\n    \"codexSession\": \"Codex Session\",\n    \"fetchingProjects\": \"Fetching your Claude projects and sessions\",\n    \"projects\": \"projects\",\n    \"noMatchingProjects\": \"No matching projects\",\n    \"tryDifferentSearch\": \"Try adjusting your search term\",\n    \"runClaudeCli\": \"Run Claude CLI in a project directory to get started\"\n  },\n  \"app\": {\n    \"title\": \"Claude Code UI\",\n    \"subtitle\": \"AI coding assistant interface\"\n  },\n  \"sessions\": {\n    \"title\": \"Sessions\",\n    \"newSession\": \"New Session\",\n    \"deleteSession\": \"Delete Session\",\n    \"renameSession\": \"Rename Session\",\n    \"noSessions\": \"No sessions yet\",\n    \"loadingSessions\": \"Loading sessions...\",\n    \"unnamed\": \"Unnamed\",\n    \"loading\": \"Loading...\",\n    \"showMore\": \"Show more sessions\"\n  },\n  \"tooltips\": {\n    \"viewEnvironments\": \"View Environments\",\n    \"hideSidebar\": \"Hide sidebar\",\n    \"createProject\": \"Create new project\",\n    \"refresh\": \"Refresh projects and sessions (Ctrl+R)\",\n    \"renameProject\": \"Rename project (F2)\",\n    \"deleteProject\": \"Delete empty project (Delete)\",\n    \"addToFavorites\": \"Add to favorites\",\n    \"removeFromFavorites\": \"Remove from favorites\",\n    \"editSessionName\": \"Manually edit session name\",\n    \"deleteSession\": \"Delete this session permanently\",\n    \"save\": \"Save\",\n    \"cancel\": \"Cancel\",\n    \"clearSearch\": \"Clear search\"\n  },\n  \"navigation\": {\n    \"chat\": \"Chat\",\n    \"files\": \"Files\",\n    \"git\": \"Git\",\n    \"terminal\": \"Terminal\",\n    \"tasks\": \"Tasks\"\n  },\n  \"actions\": {\n    \"refresh\": \"Refresh\",\n    \"settings\": \"Settings\",\n    \"collapseAll\": \"Collapse All\",\n    \"expandAll\": \"Expand All\",\n    \"cancel\": \"Cancel\",\n    \"save\": \"Save\",\n    \"delete\": \"Delete\",\n    \"rename\": \"Rename\",\n    \"joinCommunity\": \"Join Community\"\n  },\n  \"status\": {\n    \"active\": \"Active\",\n    \"inactive\": \"Inactive\",\n    \"thinking\": \"Thinking...\",\n    \"error\": \"Error\",\n    \"aborted\": \"Aborted\",\n    \"unknown\": \"Unknown\"\n  },\n  \"time\": {\n    \"justNow\": \"Just now\",\n    \"oneMinuteAgo\": \"1 min ago\",\n    \"minutesAgo\": \"{{count}} mins ago\",\n    \"oneHourAgo\": \"1 hour ago\",\n    \"hoursAgo\": \"{{count}} hours ago\",\n    \"oneDayAgo\": \"1 day ago\",\n    \"daysAgo\": \"{{count}} days ago\"\n  },\n  \"messages\": {\n    \"deleteConfirm\": \"Are you sure you want to delete this?\",\n    \"renameSuccess\": \"Renamed successfully\",\n    \"deleteSuccess\": \"Deleted successfully\",\n    \"errorOccurred\": \"An error occurred\",\n    \"deleteSessionConfirm\": \"Are you sure you want to delete this session? This action cannot be undone.\",\n    \"deleteProjectConfirm\": \"Are you sure you want to delete this empty project? This action cannot be undone.\",\n    \"enterProjectPath\": \"Please enter a project path\",\n    \"deleteSessionFailed\": \"Failed to delete session. Please try again.\",\n    \"deleteSessionError\": \"Error deleting session. Please try again.\",\n    \"renameSessionFailed\": \"Failed to rename session. Please try again.\",\n    \"renameSessionError\": \"Error renaming session. Please try again.\",\n    \"deleteProjectFailed\": \"Failed to delete project. Please try again.\",\n    \"deleteProjectError\": \"Error deleting project. Please try again.\",\n    \"createProjectFailed\": \"Failed to create project. Please try again.\",\n    \"createProjectError\": \"Error creating project. Please try again.\"\n  },\n  \"version\": {\n    \"updateAvailable\": \"Update available\"\n  },\n  \"search\": {\n    \"modeProjects\": \"Projects\",\n    \"modeConversations\": \"Conversations\",\n    \"conversationsPlaceholder\": \"Search in conversations...\",\n    \"searching\": \"Searching...\",\n    \"noResults\": \"No results found\",\n    \"tryDifferentQuery\": \"Try a different search query\",\n    \"matches_one\": \"{{count}} match\",\n    \"matches_other\": \"{{count}} matches\",\n    \"projectsScanned_one\": \"{{count}} project scanned\",\n    \"projectsScanned_other\": \"{{count}} projects scanned\"\n  },\n  \"deleteConfirmation\": {\n    \"deleteProject\": \"Delete Project\",\n    \"deleteSession\": \"Delete Session\",\n    \"confirmDelete\": \"Are you sure you want to delete\",\n    \"sessionCount_one\": \"This project contains {{count}} conversation.\",\n    \"sessionCount_other\": \"This project contains {{count}} conversations.\",\n    \"allConversationsDeleted\": \"All conversations will be permanently deleted.\",\n    \"cannotUndo\": \"This action cannot be undone.\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/en/tasks.json",
    "content": "{\n  \"notConfigured\": {\n    \"title\": \"TaskMaster AI is not configured\",\n    \"description\": \"TaskMaster helps break down complex projects into manageable tasks with AI-powered assistance\",\n    \"whatIsTitle\": \"🎯 What is TaskMaster?\",\n    \"features\": {\n      \"aiPowered\": \"AI-Powered Task Management: Break complex projects into manageable subtasks\",\n      \"prdTemplates\": \"PRD Templates: Generate tasks from Product Requirements Documents\",\n      \"dependencyTracking\": \"Dependency Tracking: Understand task relationships and execution order\",\n      \"progressVisualization\": \"Progress Visualization: Kanban boards and detailed task analytics\",\n      \"cliIntegration\": \"CLI Integration: Use taskmaster commands for advanced workflows\"\n    },\n    \"initializeButton\": \"Initialize TaskMaster AI\"\n  },\n  \"gettingStarted\": {\n    \"title\": \"Getting Started with TaskMaster\",\n    \"subtitle\": \"TaskMaster is initialized! Here's what to do next:\",\n    \"steps\": {\n      \"createPRD\": {\n        \"title\": \"Create a Product Requirements Document (PRD)\",\n        \"description\": \"Discuss your project idea and create a PRD that describes what you want to build.\",\n        \"addButton\": \"Add PRD\",\n        \"existingPRDs\": \"Existing PRDs:\"\n      },\n      \"generateTasks\": {\n        \"title\": \"Generate Tasks from PRD\",\n        \"description\": \"Once you have a PRD, ask your AI assistant to parse it and TaskMaster will automatically break it down into manageable tasks with implementation details.\"\n      },\n      \"analyzeTasks\": {\n        \"title\": \"Analyze & Expand Tasks\",\n        \"description\": \"Ask your AI assistant to analyze task complexity and expand them into detailed subtasks for easier implementation.\"\n      },\n      \"startBuilding\": {\n        \"title\": \"Start Building\",\n        \"description\": \"Ask your AI assistant to begin working on tasks, update their status, and add new tasks as your project evolves.\"\n      }\n    },\n    \"tip\": \"💡 Tip: Start with a PRD to get the most out of TaskMaster's AI-powered task generation\"\n  },\n  \"setupModal\": {\n    \"title\": \"TaskMaster Setup\",\n    \"subtitle\": \"Interactive CLI for {{projectName}}\",\n    \"willStart\": \"TaskMaster initialization will start automatically\",\n    \"completed\": \"TaskMaster setup completed! You can now close this window.\",\n    \"closeButton\": \"Close\",\n    \"closeContinueButton\": \"Close & Continue\"\n  },\n  \"helpGuide\": {\n    \"title\": \"Getting Started with TaskMaster\",\n    \"subtitle\": \"Your guide to productive task management\",\n    \"examples\": {\n      \"parsePRD\": \"💬 Example:\\n\\\"I've just initialized a new project with Claude Task Master. I have a PRD at .taskmaster/docs/prd.txt. Can you help me parse it and set up the initial tasks?\\\"\",\n      \"expandTask\": \"💬 Example:\\n\\\"Task 5 seems complex. Can you break it down into subtasks?\\\"\",\n      \"addTask\": \"💬 Example:\\n\\\"Please add a new task to implement user profile image uploads using Cloudinary, research the best approach.\\\"\"\n    },\n    \"moreExamples\": \"View more examples and usage patterns →\",\n    \"proTips\": {\n      \"title\": \"💡 Pro Tips\",\n      \"search\": \"Use the search bar to quickly find specific tasks\",\n      \"views\": \"Switch between Kanban, List, and Grid views using the view toggles\",\n      \"filters\": \"Use filters to focus on specific task statuses or priorities\",\n      \"details\": \"Click on any task to view detailed information and manage subtasks\"\n    },\n    \"learnMore\": {\n      \"title\": \"📚 Learn More\",\n      \"description\": \"TaskMaster AI is an advanced task management system built for developers. Get documentation, examples, and contribute to the project.\",\n      \"githubButton\": \"View on GitHub\"\n    }\n  },\n  \"search\": {\n    \"placeholder\": \"Search tasks...\"\n  },\n  \"filters\": {\n    \"button\": \"Filters\",\n    \"status\": \"Status\",\n    \"priority\": \"Priority\",\n    \"sortBy\": \"Sort By\",\n    \"allStatuses\": \"All Statuses\",\n    \"allPriorities\": \"All Priorities\",\n    \"showing\": \"Showing {{filtered}} of {{total}} tasks\",\n    \"clearFilters\": \"Clear Filters\"\n  },\n  \"sort\": {\n    \"id\": \"ID\",\n    \"status\": \"Status\",\n    \"priority\": \"Priority\",\n    \"idAsc\": \"ID (Ascending)\",\n    \"idDesc\": \"ID (Descending)\",\n    \"titleAsc\": \"Title (A-Z)\",\n    \"titleDesc\": \"Title (Z-A)\",\n    \"statusAsc\": \"Status (Pending First)\",\n    \"statusDesc\": \"Status (Done First)\",\n    \"priorityAsc\": \"Priority (High First)\",\n    \"priorityDesc\": \"Priority (Low First)\"\n  },\n  \"views\": {\n    \"kanban\": \"Kanban view\",\n    \"list\": \"List view\",\n    \"grid\": \"Grid view\"\n  },\n  \"kanban\": {\n    \"pending\": \"📋 To Do\",\n    \"inProgress\": \"🚀 In Progress\",\n    \"done\": \"✅ Done\",\n    \"blocked\": \"🚫 Blocked\",\n    \"deferred\": \"⏳ Deferred\",\n    \"cancelled\": \"❌ Cancelled\",\n    \"noTasksYet\": \"No tasks yet\",\n    \"tasksWillAppear\": \"Tasks will appear here\",\n    \"moveTasksHere\": \"Move tasks here when started\",\n    \"completedTasksHere\": \"Completed tasks appear here\",\n    \"statusTasksHere\": \"Tasks with this status will appear here\"\n  },\n  \"buttons\": {\n    \"help\": \"TaskMaster Getting Started Guide\",\n    \"prds\": \"PRDs\",\n    \"addPRD\": \"Add PRD\",\n    \"addTask\": \"Add Task\",\n    \"createNewPRD\": \"Create New PRD\",\n    \"prdsAvailable\": \"{{count}} PRD(s) available\"\n  },\n  \"prd\": {\n    \"modified\": \"Modified: {{date}}\"\n  },\n  \"statuses\": {\n    \"pending\": \"Pending\",\n    \"in-progress\": \"In Progress\",\n    \"done\": \"Done\",\n    \"blocked\": \"Blocked\",\n    \"deferred\": \"Deferred\",\n    \"cancelled\": \"Cancelled\"\n  },\n  \"priorities\": {\n    \"high\": \"High\",\n    \"medium\": \"Medium\",\n    \"low\": \"Low\"\n  },\n  \"noMatchingTasks\": {\n    \"title\": \"No tasks match your filters\",\n    \"description\": \"Try adjusting your search or filter criteria.\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/ja/auth.json",
    "content": "{\n  \"login\": {\n    \"title\": \"おかえりなさい\",\n    \"description\": \"Claude Code UIアカウントにサインイン\",\n    \"username\": \"ユーザー名\",\n    \"password\": \"パスワード\",\n    \"submit\": \"サインイン\",\n    \"loading\": \"サインイン中...\",\n    \"errors\": {\n      \"invalidCredentials\": \"ユーザー名またはパスワードが正しくありません\",\n      \"requiredFields\": \"すべての項目を入力してください\",\n      \"networkError\": \"ネットワークエラー。もう一度お試しください。\"\n    },\n    \"placeholders\": {\n      \"username\": \"ユーザー名を入力\",\n      \"password\": \"パスワードを入力\"\n    }\n  },\n  \"register\": {\n    \"title\": \"アカウント作成\",\n    \"username\": \"ユーザー名\",\n    \"password\": \"パスワード\",\n    \"confirmPassword\": \"パスワードの確認\",\n    \"submit\": \"アカウントを作成\",\n    \"loading\": \"アカウントを作成中...\",\n    \"errors\": {\n      \"passwordMismatch\": \"パスワードが一致しません\",\n      \"usernameTaken\": \"このユーザー名は既に使用されています\",\n      \"weakPassword\": \"パスワードが弱すぎます\"\n    }\n  },\n  \"logout\": {\n    \"title\": \"サインアウト\",\n    \"confirm\": \"サインアウトしてもよろしいですか？\",\n    \"button\": \"サインアウト\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/ja/chat.json",
    "content": "{\n  \"codeBlock\": {\n    \"copy\": \"コピー\",\n    \"copied\": \"コピーしました\",\n    \"copyCode\": \"コードをコピー\"\n  },\n  \"copyMessage\": {\n    \"copy\": \"メッセージをコピー\",\n    \"copied\": \"メッセージをコピーしました\",\n    \"selectFormat\": \"コピー形式を選択\",\n    \"copyAsMarkdown\": \"Markdownとしてコピー\",\n    \"copyAsText\": \"テキストとしてコピー\"\n  },\n  \"messageTypes\": {\n    \"user\": \"U\",\n    \"error\": \"エラー\",\n    \"tool\": \"ツール\",\n    \"claude\": \"Claude\",\n    \"cursor\": \"Cursor\",\n    \"codex\": \"Codex\"\n  },\n  \"tools\": {\n    \"settings\": \"ツール設定\",\n    \"error\": \"ツールエラー\",\n    \"result\": \"ツール結果\",\n    \"viewParams\": \"入力パラメータを表示\",\n    \"viewRawParams\": \"生パラメータを表示\",\n    \"viewDiff\": \"編集差分を表示:\",\n    \"creatingFile\": \"新規ファイルを作成:\",\n    \"updatingTodo\": \"Todoリストを更新中\",\n    \"read\": \"読み取り\",\n    \"readFile\": \"ファイルを読み取り\",\n    \"updateTodo\": \"Todoリストを更新\",\n    \"readTodo\": \"Todoリストを読み取り\",\n    \"searchResults\": \"件の結果\"\n  },\n  \"search\": {\n    \"found\": \"{{count}}件の{{type}}が見つかりました\",\n    \"file\": \"ファイル\",\n    \"files\": \"ファイル\",\n    \"pattern\": \"パターン:\",\n    \"in\": \"場所:\"\n  },\n  \"fileOperations\": {\n    \"updated\": \"ファイルを更新しました\",\n    \"created\": \"ファイルを作成しました\",\n    \"written\": \"ファイルを書き込みました\",\n    \"diff\": \"差分\",\n    \"newFile\": \"新規ファイル\",\n    \"viewContent\": \"ファイルの内容を表示\",\n    \"viewFullOutput\": \"全出力を表示（{{count}}文字）\",\n    \"contentDisplayed\": \"ファイルの内容は上の差分ビューに表示されています\"\n  },\n  \"interactive\": {\n    \"title\": \"インタラクティブプロンプト\",\n    \"waiting\": \"CLIでの応答を待っています\",\n    \"instruction\": \"Claudeが実行されているターミナルでオプションを選択してください。\",\n    \"selectedOption\": \"✓ Claudeがオプション{{number}}を選択しました\",\n    \"instructionDetail\": \"CLIでは、矢印キーまたは番号を入力してオプションを選択します。\"\n  },\n  \"thinking\": {\n    \"title\": \"思考中...\",\n    \"emoji\": \"💭 思考中...\"\n  },\n  \"json\": {\n    \"response\": \"JSONレスポンス\"\n  },\n  \"permissions\": {\n    \"grant\": \"{{tool}}に権限を付与\",\n    \"added\": \"権限を追加しました\",\n    \"addTo\": \"{{entry}}を許可されたツールに追加します。\",\n    \"retry\": \"権限を保存しました。ツールを使用するにはリクエストを再試行してください。\",\n    \"error\": \"権限を更新できませんでした。もう一度お試しください。\",\n    \"openSettings\": \"設定を開く\"\n  },\n  \"todo\": {\n    \"updated\": \"Todoリストを更新しました\",\n    \"current\": \"現在のTodoリスト\"\n  },\n  \"plan\": {\n    \"viewPlan\": \"📋 実装プランを表示\",\n    \"title\": \"実装プラン\"\n  },\n  \"usageLimit\": {\n    \"resetAt\": \"Claudeの使用制限に達しました。制限は**{{time}} {{timezone}}** - {{date}}にリセットされます\"\n  },\n  \"codex\": {\n    \"permissionMode\": \"権限モード\",\n    \"modes\": {\n      \"default\": \"デフォルトモード\",\n      \"acceptEdits\": \"編集を許可\",\n      \"bypassPermissions\": \"権限をバイパス\",\n      \"plan\": \"プランモード\"\n    },\n    \"descriptions\": {\n      \"default\": \"信頼されたコマンド（ls、cat、grep、git statusなど）のみ自動実行。その他のコマンドはスキップ。ワークスペースへの書き込みは可能。\",\n      \"acceptEdits\": \"ワークスペース内ですべてのコマンドを自動実行。サンドボックス環境での完全自動モード。\",\n      \"bypassPermissions\": \"制限なしの完全なシステムアクセス。すべてのコマンドがディスクとネットワークへの完全なアクセスで自動実行されます。注意して使用してください。\",\n      \"plan\": \"プランニングモード - コマンドは実行されません\"\n    },\n    \"technicalDetails\": \"技術的な詳細\"\n  },\n  \"input\": {\n    \"placeholder\": \"/ でコマンド、@ でファイル指定、または {{provider}} に何でも聞いてください...\",\n    \"placeholderDefault\": \"メッセージを入力...\",\n    \"disabled\": \"入力無効\",\n    \"attachFiles\": \"ファイルを添付\",\n    \"attachImages\": \"画像を添付\",\n    \"send\": \"送信\",\n    \"stop\": \"停止\",\n    \"hintText\": {\n      \"ctrlEnter\": \"Ctrl+Enterで送信 • Shift+Enterで改行 • Tabでモード切替 • / でスラッシュコマンド\",\n      \"enter\": \"Enterで送信 • Shift+Enterで改行 • Tabでモード切替 • / でスラッシュコマンド\"\n    },\n    \"clickToChangeMode\": \"クリックで権限モードを変更（または入力欄でTab）\",\n    \"showAllCommands\": \"すべてのコマンドを表示\"\n  },\n  \"thinkingMode\": {\n    \"selector\": {\n      \"title\": \"思考モード\",\n      \"description\": \"拡張思考によりClaudeがより多くの選択肢を検討できます\",\n      \"active\": \"有効\",\n      \"tip\": \"高い思考モードは時間がかかりますが、より深い分析が得られます\"\n    },\n    \"modes\": {\n      \"none\": {\n        \"name\": \"標準\",\n        \"description\": \"通常のClaudeの応答\",\n        \"prefix\": \"\"\n      },\n      \"think\": {\n        \"name\": \"Think\",\n        \"description\": \"基本的な拡張思考\",\n        \"prefix\": \"think\"\n      },\n      \"thinkHard\": {\n        \"name\": \"Think Hard\",\n        \"description\": \"より深い検討\",\n        \"prefix\": \"think hard\"\n      },\n      \"thinkHarder\": {\n        \"name\": \"Think Harder\",\n        \"description\": \"代替案を含む深い分析\",\n        \"prefix\": \"think harder\"\n      },\n      \"ultrathink\": {\n        \"name\": \"Ultrathink\",\n        \"description\": \"最大限の思考予算\",\n        \"prefix\": \"ultrathink\"\n      }\n    },\n    \"buttonTitle\": \"思考モード: {{mode}}\"\n  },\n  \"providerSelection\": {\n    \"title\": \"AIアシスタントを選択\",\n    \"description\": \"新しい会話を始めるプロバイダーを選択してください\",\n    \"selectModel\": \"モデルを選択\",\n    \"providerInfo\": {\n      \"anthropic\": \"by Anthropic\",\n      \"openai\": \"by OpenAI\",\n      \"cursorEditor\": \"AIコードエディタ\"\n    },\n    \"readyPrompt\": {\n      \"claude\": \"{{model}}でClaudeを使用する準備ができました。下にメッセージを入力してください。\",\n      \"cursor\": \"{{model}}でCursorを使用する準備ができました。下にメッセージを入力してください。\",\n      \"codex\": \"{{model}}でCodexを使用する準備ができました。下にメッセージを入力してください。\",\n      \"default\": \"上からプロバイダーを選択して開始してください\"\n    }\n  },\n  \"session\": {\n    \"continue\": {\n      \"title\": \"会話を続ける\",\n      \"description\": \"コードについて質問したり、変更をリクエストしたり、開発タスクのサポートを受けられます\"\n    },\n    \"loading\": {\n      \"olderMessages\": \"過去のメッセージを読み込んでいます...\",\n      \"sessionMessages\": \"セッションメッセージを読み込んでいます...\"\n    },\n    \"messages\": {\n      \"showingOf\": \"{{total}}件中{{shown}}件を表示\",\n      \"scrollToLoad\": \"上にスクロールしてさらに読み込む\",\n      \"showingLast\": \"最新{{count}}件を表示（全{{total}}件）\",\n      \"loadEarlier\": \"過去のメッセージを読み込む\"\n    }\n  },\n  \"shell\": {\n    \"selectProject\": {\n      \"title\": \"プロジェクトを選択\",\n      \"description\": \"プロジェクトを選択してそのディレクトリでシェルを開きます\"\n    },\n    \"status\": {\n      \"newSession\": \"新しいセッション\",\n      \"initializing\": \"初期化中...\",\n      \"restarting\": \"再起動中...\"\n    },\n    \"actions\": {\n      \"disconnect\": \"切断\",\n      \"disconnectTitle\": \"シェルから切断\",\n      \"restart\": \"再起動\",\n      \"restartTitle\": \"シェルを再起動（先に切断してください）\",\n      \"connect\": \"シェルで続行\",\n      \"connectTitle\": \"シェルに接続\"\n    },\n    \"loading\": \"ターミナルを読み込んでいます...\",\n    \"connecting\": \"シェルに接続しています...\",\n    \"startSession\": \"新しいClaudeセッションを開始\",\n    \"resumeSession\": \"セッションを再開: {{displayName}}...\",\n    \"runCommand\": \"{{projectName}}で{{command}}を実行\",\n    \"startCli\": \"{{projectName}}でClaude CLIを起動しています\",\n    \"defaultCommand\": \"コマンド\"\n  },\n  \"claudeStatus\": {\n    \"actions\": {\n      \"thinking\": \"Thinking\",\n      \"processing\": \"Processing\",\n      \"analyzing\": \"Analyzing\",\n      \"working\": \"Working\",\n      \"computing\": \"Computing\",\n      \"reasoning\": \"Reasoning\"\n    },\n    \"state\": {\n      \"live\": \"Live\",\n      \"paused\": \"Paused\"\n    },\n    \"elapsed\": {\n      \"seconds\": \"{{count}}s\",\n      \"minutesSeconds\": \"{{minutes}}m {{seconds}}s\",\n      \"label\": \"{{time}} elapsed\",\n      \"startingNow\": \"Starting now\"\n    },\n    \"controls\": {\n      \"stopGeneration\": \"Stop Generation\",\n      \"pressEscToStop\": \"Press Esc anytime to stop\"\n    },\n    \"providers\": {\n      \"assistant\": \"Assistant\"\n    }\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/ja/codeEditor.json",
    "content": "{\n  \"toolbar\": {\n    \"changes\": \"件の変更\",\n    \"previousChange\": \"前の変更\",\n    \"nextChange\": \"次の変更\",\n    \"hideDiff\": \"差分ハイライトを非表示\",\n    \"showDiff\": \"差分ハイライトを表示\",\n    \"settings\": \"エディタ設定\",\n    \"collapse\": \"エディタを折りたたむ\",\n    \"expand\": \"エディタを全幅に展開\"\n  },\n  \"loading\": \"{{fileName}}を読み込んでいます...\",\n  \"header\": {\n    \"showingChanges\": \"変更を表示中\"\n  },\n  \"actions\": {\n    \"download\": \"ファイルをダウンロード\",\n    \"save\": \"保存\",\n    \"saving\": \"保存中...\",\n    \"saved\": \"保存しました！\",\n    \"exitFullscreen\": \"全画面を終了\",\n    \"fullscreen\": \"全画面\",\n    \"close\": \"閉じる\"\n  },\n  \"footer\": {\n    \"lines\": \"行数:\",\n    \"characters\": \"文字数:\",\n    \"shortcuts\": \"Ctrl+Sで保存 • Escで閉じる\"\n  },\n  \"binaryFile\": {\n    \"title\": \"バイナリファイル\",\n    \"message\": \"ファイル \\\"{{fileName}}\\\" はバイナリファイルのため、テキストエディタで表示できません。\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/ja/common.json",
    "content": "{\n  \"buttons\": {\n    \"save\": \"保存\",\n    \"cancel\": \"キャンセル\",\n    \"delete\": \"削除\",\n    \"create\": \"作成\",\n    \"edit\": \"編集\",\n    \"close\": \"閉じる\",\n    \"confirm\": \"確認\",\n    \"submit\": \"送信\",\n    \"retry\": \"再試行\",\n    \"refresh\": \"更新\",\n    \"search\": \"検索\",\n    \"clear\": \"クリア\",\n    \"copy\": \"コピー\",\n    \"download\": \"ダウンロード\",\n    \"upload\": \"アップロード\",\n    \"browse\": \"参照\"\n  },\n  \"tabs\": {\n    \"chat\": \"チャット\",\n    \"shell\": \"シェル\",\n    \"files\": \"ファイル\",\n    \"git\": \"ソース管理\",\n    \"tasks\": \"タスク\"\n  },\n  \"status\": {\n    \"loading\": \"読み込み中...\",\n    \"success\": \"成功\",\n    \"error\": \"エラー\",\n    \"failed\": \"失敗\",\n    \"pending\": \"保留中\",\n    \"completed\": \"完了\",\n    \"inProgress\": \"進行中\"\n  },\n  \"messages\": {\n    \"savedSuccessfully\": \"保存しました\",\n    \"deletedSuccessfully\": \"削除しました\",\n    \"updatedSuccessfully\": \"更新しました\",\n    \"operationFailed\": \"操作に失敗しました\",\n    \"networkError\": \"ネットワークエラー。接続を確認してください。\",\n    \"unauthorized\": \"認証されていません。ログインしてください。\",\n    \"notFound\": \"見つかりません\",\n    \"invalidInput\": \"入力が無効です\",\n    \"requiredField\": \"この項目は必須です\",\n    \"unknownError\": \"不明なエラーが発生しました\"\n  },\n  \"navigation\": {\n    \"settings\": \"設定\",\n    \"home\": \"ホーム\",\n    \"back\": \"戻る\",\n    \"next\": \"次へ\",\n    \"previous\": \"前へ\",\n    \"logout\": \"ログアウト\"\n  },\n  \"common\": {\n    \"language\": \"言語\",\n    \"theme\": \"テーマ\",\n    \"darkMode\": \"ダークモード\",\n    \"lightMode\": \"ライトモード\",\n    \"name\": \"名前\",\n    \"description\": \"説明\",\n    \"enabled\": \"有効\",\n    \"disabled\": \"無効\",\n    \"optional\": \"任意\",\n    \"version\": \"バージョン\",\n    \"select\": \"選択\",\n    \"selectAll\": \"すべて選択\",\n    \"deselectAll\": \"すべて解除\"\n  },\n  \"time\": {\n    \"justNow\": \"たった今\",\n    \"minutesAgo\": \"{{count}}分前\",\n    \"hoursAgo\": \"{{count}}時間前\",\n    \"daysAgo\": \"{{count}}日前\",\n    \"yesterday\": \"昨日\"\n  },\n  \"fileOperations\": {\n    \"newFile\": \"新規ファイル\",\n    \"newFolder\": \"新規フォルダ\",\n    \"rename\": \"名前の変更\",\n    \"move\": \"移動\",\n    \"copyPath\": \"パスをコピー\",\n    \"openInEditor\": \"エディタで開く\"\n  },\n  \"mainContent\": {\n    \"loading\": \"Claude Code UI を読み込んでいます\",\n    \"settingUpWorkspace\": \"ワークスペースを準備しています...\",\n    \"chooseProject\": \"プロジェクトを選択\",\n    \"selectProjectDescription\": \"サイドバーからプロジェクトを選択して、Claudeとコーディングを始めましょう。各プロジェクトにはチャットセッションとファイル履歴が含まれています。\",\n    \"tip\": \"ヒント\",\n    \"createProjectMobile\": \"上部のメニューボタンからプロジェクトにアクセスできます\",\n    \"createProjectDesktop\": \"サイドバーのフォルダアイコンをクリックして新しいプロジェクトを作成できます\",\n    \"newSession\": \"新しいセッション\",\n    \"untitledSession\": \"無題のセッション\",\n    \"projectFiles\": \"プロジェクトファイル\"\n  },\n  \"fileTree\": {\n    \"loading\": \"ファイルを読み込んでいます...\",\n    \"files\": \"ファイル\",\n    \"simpleView\": \"シンプル表示\",\n    \"compactView\": \"コンパクト表示\",\n    \"detailedView\": \"詳細表示\",\n    \"searchPlaceholder\": \"ファイルやフォルダを検索...\",\n    \"clearSearch\": \"検索をクリア\",\n    \"name\": \"名前\",\n    \"size\": \"サイズ\",\n    \"modified\": \"更新日時\",\n    \"permissions\": \"権限\",\n    \"noFilesFound\": \"ファイルが見つかりません\",\n    \"checkProjectPath\": \"プロジェクトのパスがアクセス可能か確認してください\",\n    \"noMatchesFound\": \"一致するものが見つかりません\",\n    \"tryDifferentSearch\": \"別の検索語を試すか、検索をクリアしてください\",\n    \"justNow\": \"たった今\",\n    \"minAgo\": \"{{count}}分前\",\n    \"hoursAgo\": \"{{count}}時間前\",\n    \"daysAgo\": \"{{count}}日前\",\n    \"newFile\": \"新規ファイル (Cmd+N)\",\n    \"newFolder\": \"新規フォルダ (Cmd+Shift+N)\",\n    \"refresh\": \"更新\",\n    \"collapseAll\": \"すべて折りたたむ\",\n    \"context\": {\n      \"rename\": \"名前を変更\",\n      \"delete\": \"削除\",\n      \"copyPath\": \"パスをコピー\",\n      \"download\": \"ダウンロード\",\n      \"newFile\": \"新しいファイル\",\n      \"newFolder\": \"新しいフォルダ\",\n      \"refresh\": \"更新\",\n      \"menuLabel\": \"ファイルのコンテキストメニュー\",\n      \"loading\": \"読み込み中...\"\n    }\n  },\n  \"projectWizard\": {\n    \"title\": \"新規プロジェクトを作成\",\n    \"steps\": {\n      \"type\": \"種類\",\n      \"configure\": \"設定\",\n      \"confirm\": \"確認\"\n    },\n    \"step1\": {\n      \"question\": \"既存のワークスペースがありますか？それとも新しく作成しますか？\",\n      \"existing\": {\n        \"title\": \"既存のワークスペース\",\n        \"description\": \"サーバー上に既存のワークスペースがあり、プロジェクト一覧に追加したい\"\n      },\n      \"new\": {\n        \"title\": \"新しいワークスペース\",\n        \"description\": \"新しいワークスペースを作成し、必要に応じてGitHubリポジトリからクローンする\"\n      }\n    },\n    \"step2\": {\n      \"existingPath\": \"ワークスペースのパス\",\n      \"newPath\": \"ワークスペースのパス\",\n      \"existingPlaceholder\": \"/path/to/existing/workspace\",\n      \"newPlaceholder\": \"/path/to/new/workspace\",\n      \"existingHelp\": \"既存のワークスペースディレクトリのフルパス\",\n      \"newHelp\": \"ワークスペースディレクトリのフルパス\",\n      \"githubUrl\": \"GitHub URL（任意）\",\n      \"githubPlaceholder\": \"https://github.com/username/repository\",\n      \"githubHelp\": \"任意: リポジトリをクローンするためのGitHub URLを入力してください\",\n      \"githubAuth\": \"GitHub認証（任意）\",\n      \"githubAuthHelp\": \"プライベートリポジトリの場合のみ必要です。パブリックリポジトリは認証なしでクローンできます。\",\n      \"loadingTokens\": \"保存済みトークンを読み込んでいます...\",\n      \"storedToken\": \"保存済みトークン\",\n      \"newToken\": \"新しいトークン\",\n      \"nonePublic\": \"なし（パブリック）\",\n      \"selectToken\": \"トークンを選択\",\n      \"selectTokenPlaceholder\": \"-- トークンを選択 --\",\n      \"tokenPlaceholder\": \"ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\n      \"tokenHelp\": \"このトークンはこの操作にのみ使用されます\",\n      \"publicRepoInfo\": \"パブリックリポジトリには認証は不要です。パブリックリポジトリをクローンする場合、トークンは省略できます。\",\n      \"noTokensHelp\": \"保存済みトークンがありません。設定 → APIキーでトークンを追加すると再利用が簡単になります。\",\n      \"optionalTokenPublic\": \"GitHubトークン（パブリックリポジトリの場合は任意）\",\n      \"tokenPublicPlaceholder\": \"ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx（パブリックリポジトリの場合は空欄可）\"\n    },\n    \"step3\": {\n      \"reviewConfig\": \"設定の確認\",\n      \"workspaceType\": \"ワークスペースの種類:\",\n      \"existingWorkspace\": \"既存のワークスペース\",\n      \"newWorkspace\": \"新しいワークスペース\",\n      \"path\": \"パス:\",\n      \"cloneFrom\": \"クローン元:\",\n      \"authentication\": \"認証:\",\n      \"usingStoredToken\": \"保存済みトークンを使用:\",\n      \"usingProvidedToken\": \"入力されたトークンを使用\",\n      \"noAuthentication\": \"認証なし\",\n      \"sshKey\": \"SSHキー\",\n      \"existingInfo\": \"ワークスペースがプロジェクト一覧に追加され、Claude/Cursorセッションで使用できるようになります。\",\n      \"newWithClone\": \"このフォルダからリポジトリがクローンされます。\",\n      \"newEmpty\": \"ワークスペースがプロジェクト一覧に追加され、Claude/Cursorセッションで使用できるようになります。\",\n      \"cloningRepository\": \"リポジトリをクローンしています...\"\n    },\n    \"buttons\": {\n      \"cancel\": \"キャンセル\",\n      \"back\": \"戻る\",\n      \"next\": \"次へ\",\n      \"createProject\": \"プロジェクトを作成\",\n      \"creating\": \"作成中...\",\n      \"cloning\": \"クローン中...\"\n    },\n    \"errors\": {\n      \"selectType\": \"既存のワークスペースか新規作成かを選択してください\",\n      \"providePath\": \"ワークスペースのパスを入力してください\",\n      \"failedToCreate\": \"ワークスペースの作成に失敗しました\",\n      \"failedToCreateFolder\": \"フォルダの作成に失敗しました\"\n    }\n  },\n  \"notifications\": {\n    \"genericTool\": \"ツール\",\n    \"codes\": {\n      \"generic\": {\n        \"info\": {\n          \"title\": \"通知\"\n        }\n      },\n      \"permission\": {\n        \"required\": {\n          \"title\": \"対応が必要です\",\n          \"body\": \"{{toolName}} があなたの判断を待っています。\"\n        }\n      },\n      \"run\": {\n        \"stopped\": {\n          \"title\": \"実行が停止しました\",\n          \"body\": \"理由: {{reason}}\"\n        },\n        \"failed\": {\n          \"title\": \"実行に失敗しました\"\n        }\n      },\n      \"agent\": {\n        \"notification\": {\n          \"title\": \"エージェント通知\"\n        }\n      }\n    }\n  },\n  \"versionUpdate\": {\n    \"title\": \"アップデートのお知らせ\",\n    \"newVersionReady\": \"新しいバージョンが利用可能です\",\n    \"currentVersion\": \"現在のバージョン\",\n    \"latestVersion\": \"最新バージョン\",\n    \"whatsNew\": \"変更点:\",\n    \"viewFullRelease\": \"リリース全文を見る\",\n    \"updateProgress\": \"アップデートの進捗:\",\n    \"manualUpgrade\": \"手動アップグレード:\",\n    \"npmUpgradeCommand\": \"npm install -g @siteboon/claude-code-ui@latest\",\n    \"manualUpgradeHint\": \"または「今すぐ更新」をクリックして自動的にアップデートを実行できます。\",\n    \"updateCompleted\": \"アップデートが完了しました！\",\n    \"restartServer\": \"変更を適用するにはサーバーを再起動してください。\",\n    \"updateFailed\": \"アップデートに失敗しました\",\n    \"buttons\": {\n      \"close\": \"閉じる\",\n      \"later\": \"後で\",\n      \"copyCommand\": \"コマンドをコピー\",\n      \"updateNow\": \"今すぐ更新\",\n      \"updating\": \"更新中...\"\n    },\n    \"ariaLabels\": {\n      \"closeModal\": \"バージョンアップグレードモーダルを閉じる\",\n      \"showSidebar\": \"サイドバーを表示\",\n      \"settings\": \"設定\",\n      \"updateAvailable\": \"アップデートあり\",\n      \"closeSidebar\": \"サイドバーを閉じる\"\n    }\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/ja/settings.json",
    "content": "{\n  \"title\": \"設定\",\n  \"tabs\": {\n    \"account\": \"アカウント\",\n    \"permissions\": \"権限\",\n    \"mcpServers\": \"MCPサーバー\",\n    \"appearance\": \"外観\"\n  },\n  \"account\": {\n    \"title\": \"アカウント\",\n    \"language\": \"言語\",\n    \"languageLabel\": \"表示言語\",\n    \"languageDescription\": \"インターフェースの表示言語を選択してください\",\n    \"username\": \"ユーザー名\",\n    \"email\": \"メールアドレス\",\n    \"profile\": \"プロフィール\",\n    \"changePassword\": \"パスワードを変更\"\n  },\n  \"mcp\": {\n    \"title\": \"MCPサーバー\",\n    \"addServer\": \"サーバーを追加\",\n    \"editServer\": \"サーバーを編集\",\n    \"deleteServer\": \"サーバーを削除\",\n    \"serverName\": \"サーバー名\",\n    \"serverType\": \"サーバーの種類\",\n    \"config\": \"設定\",\n    \"testConnection\": \"接続テスト\",\n    \"status\": \"状態\",\n    \"connected\": \"接続済み\",\n    \"disconnected\": \"未接続\",\n    \"scope\": {\n      \"label\": \"スコープ\",\n      \"user\": \"ユーザー\",\n      \"project\": \"プロジェクト\"\n    }\n  },\n  \"appearance\": {\n    \"title\": \"外観\",\n    \"theme\": \"テーマ\",\n    \"codeEditor\": \"コードエディタ\",\n    \"editorTheme\": \"エディタのテーマ\",\n    \"wordWrap\": \"折り返し\",\n    \"showMinimap\": \"ミニマップを表示\",\n    \"lineNumbers\": \"行番号\",\n    \"fontSize\": \"フォントサイズ\"\n  },\n  \"actions\": {\n    \"saveChanges\": \"変更を保存\",\n    \"resetToDefaults\": \"デフォルトに戻す\",\n    \"cancelChanges\": \"変更をキャンセル\"\n  },\n  \"quickSettings\": {\n    \"title\": \"クイック設定\",\n    \"sections\": {\n      \"appearance\": \"外観\",\n      \"toolDisplay\": \"ツール表示\",\n      \"viewOptions\": \"表示オプション\",\n      \"inputSettings\": \"入力設定\",\n      \"whisperDictation\": \"Whisper音声入力\"\n    },\n    \"darkMode\": \"ダークモード\",\n    \"autoExpandTools\": \"ツールを自動展開\",\n    \"showRawParameters\": \"生パラメータを表示\",\n    \"showThinking\": \"思考を表示\",\n    \"autoScrollToBottom\": \"自動スクロール\",\n    \"sendByCtrlEnter\": \"Ctrl+Enterで送信\",\n    \"sendByCtrlEnterDescription\": \"有効にすると、Enterではなく Ctrl+Enter でメッセージを送信します。IMEユーザーの誤送信防止に便利です。\",\n    \"dragHandle\": {\n      \"dragging\": \"ドラッグ中\",\n      \"closePanel\": \"設定パネルを閉じる\",\n      \"openPanel\": \"設定パネルを開く\",\n      \"draggingStatus\": \"ドラッグ中...\",\n      \"toggleAndMove\": \"クリックで切替、ドラッグで移動\"\n    },\n    \"whisper\": {\n      \"modes\": {\n        \"default\": \"標準モード\",\n        \"defaultDescription\": \"音声をそのまま文字起こしします\",\n        \"prompt\": \"プロンプト強化\",\n        \"promptDescription\": \"ラフなアイデアを明確で詳細なAIプロンプトに変換します\",\n        \"vibe\": \"バイブモード\",\n        \"vibeDescription\": \"アイデアを明確なエージェント指示に整形します\"\n      }\n    }\n  },\n  \"terminalShortcuts\": {\n    \"title\": \"ターミナルショートカット\",\n    \"sectionKeys\": \"キー\",\n    \"sectionNavigation\": \"ナビゲーション\",\n    \"escape\": \"Escape\",\n    \"tab\": \"Tab\",\n    \"shiftTab\": \"Shift+Tab\",\n    \"arrowUp\": \"上矢印\",\n    \"arrowDown\": \"下矢印\",\n    \"scrollDown\": \"下にスクロール\",\n    \"handle\": {\n      \"closePanel\": \"ショートカットパネルを閉じる\",\n      \"openPanel\": \"ショートカットパネルを開く\"\n    }\n  },\n  \"mainTabs\": {\n    \"label\": \"設定\",\n    \"agents\": \"エージェント\",\n    \"appearance\": \"外観\",\n    \"git\": \"Git\",\n    \"apiTokens\": \"API & トークン\",\n    \"tasks\": \"タスク\",\n    \"notifications\": \"通知\",\n    \"plugins\": \"プラグイン\"\n\n  },\n  \"notifications\": {\n    \"title\": \"通知\",\n    \"description\": \"受信する通知イベントを設定します。\",\n    \"webPush\": {\n      \"title\": \"Webプッシュ通知\",\n      \"enable\": \"プッシュ通知を有効にする\",\n      \"disable\": \"プッシュ通知を無効にする\",\n      \"enabled\": \"プッシュ通知は有効です\",\n      \"loading\": \"更新中...\",\n      \"unsupported\": \"このブラウザではプッシュ通知がサポートされていません。\",\n      \"denied\": \"プッシュ通知がブロックされています。ブラウザの設定で許可してください。\"\n    },\n    \"events\": {\n      \"title\": \"イベント種別\",\n      \"actionRequired\": \"対応が必要\",\n      \"stop\": \"実行停止\",\n      \"error\": \"実行失敗\"\n    }\n  },\n  \"appearanceSettings\": {\n    \"darkMode\": {\n      \"label\": \"ダークモード\",\n      \"description\": \"ライトテーマとダークテーマを切り替えます\"\n    },\n    \"projectSorting\": {\n      \"label\": \"プロジェクトの並び順\",\n      \"description\": \"サイドバーでのプロジェクトの並び順を設定します\",\n      \"alphabetical\": \"アルファベット順\",\n      \"recentActivity\": \"最近のアクティビティ順\"\n    },\n    \"codeEditor\": {\n      \"title\": \"コードエディタ\",\n      \"theme\": {\n        \"label\": \"エディタのテーマ\",\n        \"description\": \"コードエディタのデフォルトテーマ\"\n      },\n      \"wordWrap\": {\n        \"label\": \"折り返し\",\n        \"description\": \"エディタでデフォルトで折り返しを有効にします\"\n      },\n      \"showMinimap\": {\n        \"label\": \"ミニマップを表示\",\n        \"description\": \"差分ビューでナビゲーション用のミニマップを表示します\"\n      },\n      \"lineNumbers\": {\n        \"label\": \"行番号を表示\",\n        \"description\": \"エディタに行番号を表示します\"\n      },\n      \"fontSize\": {\n        \"label\": \"フォントサイズ\",\n        \"description\": \"エディタのフォントサイズ（ピクセル）\"\n      }\n    }\n  },\n  \"mcpForm\": {\n    \"title\": {\n      \"add\": \"MCPサーバーを追加\",\n      \"edit\": \"MCPサーバーを編集\"\n    },\n    \"importMode\": {\n      \"form\": \"フォーム入力\",\n      \"json\": \"JSONインポート\"\n    },\n    \"scope\": {\n      \"label\": \"スコープ\",\n      \"userGlobal\": \"ユーザー（グローバル）\",\n      \"projectLocal\": \"プロジェクト（ローカル）\",\n      \"userDescription\": \"ユーザースコープ: すべてのプロジェクトで利用可能\",\n      \"projectDescription\": \"ローカルスコープ: 選択したプロジェクトでのみ利用可能\",\n      \"cannotChange\": \"既存のサーバーを編集する場合、スコープは変更できません\"\n    },\n    \"fields\": {\n      \"serverName\": \"サーバー名\",\n      \"transportType\": \"トランスポートの種類\",\n      \"command\": \"コマンド\",\n      \"arguments\": \"引数（1行に1つ）\",\n      \"jsonConfig\": \"JSON設定\",\n      \"url\": \"URL\",\n      \"envVars\": \"環境変数（KEY=value、1行に1つ）\",\n      \"headers\": \"ヘッダー（KEY=value、1行に1つ）\",\n      \"selectProject\": \"プロジェクトを選択...\"\n    },\n    \"placeholders\": {\n      \"serverName\": \"my-server\"\n    },\n    \"validation\": {\n      \"missingType\": \"必須フィールドがありません: type\",\n      \"stdioRequiresCommand\": \"stdioタイプにはcommandフィールドが必要です\",\n      \"httpRequiresUrl\": \"{{type}}タイプにはurlフィールドが必要です\",\n      \"invalidJson\": \"無効なJSON形式です\",\n      \"jsonHelp\": \"MCPサーバー設定をJSON形式で貼り付けてください。例:\",\n      \"jsonExampleStdio\": \"• stdio: {\\\"type\\\":\\\"stdio\\\",\\\"command\\\":\\\"npx\\\",\\\"args\\\":[\\\"@upstash/context7-mcp\\\"]}\",\n      \"jsonExampleHttp\": \"• http/sse: {\\\"type\\\":\\\"http\\\",\\\"url\\\":\\\"https://api.example.com/mcp\\\"}\"\n    },\n    \"configDetails\": \"設定の詳細（{{configFile}}より）\",\n    \"projectPath\": \"パス: {{path}}\",\n    \"actions\": {\n      \"cancel\": \"キャンセル\",\n      \"saving\": \"保存中...\",\n      \"addServer\": \"サーバーを追加\",\n      \"updateServer\": \"サーバーを更新\"\n    }\n  },\n  \"saveStatus\": {\n    \"success\": \"設定を保存しました！\",\n    \"error\": \"設定の保存に失敗しました\",\n    \"saving\": \"保存中...\"\n  },\n  \"footerActions\": {\n    \"save\": \"設定を保存\",\n    \"cancel\": \"キャンセル\"\n  },\n  \"git\": {\n    \"title\": \"Git設定\",\n    \"description\": \"コミット用のGit IDを設定します。この設定は git config --global で適用されます\",\n    \"name\": {\n      \"label\": \"Git名前\",\n      \"help\": \"コミットに使用する名前\"\n    },\n    \"email\": {\n      \"label\": \"Gitメールアドレス\",\n      \"help\": \"コミットに使用するメールアドレス\"\n    },\n    \"actions\": {\n      \"save\": \"設定を保存\",\n      \"saving\": \"保存中...\"\n    },\n    \"status\": {\n      \"success\": \"保存しました\"\n    }\n  },\n  \"apiKeys\": {\n    \"title\": \"APIキー\",\n    \"description\": \"外部APIにアクセスするためのAPIキーを生成します。\",\n    \"newKey\": {\n      \"alertTitle\": \"⚠️ APIキーを保存してください\",\n      \"alertMessage\": \"このキーが表示されるのは今回限りです。安全な場所に保管してください。\",\n      \"iveSavedIt\": \"保存しました\"\n    },\n    \"form\": {\n      \"placeholder\": \"APIキーの名前（例: 本番サーバー）\",\n      \"createButton\": \"作成\",\n      \"cancelButton\": \"キャンセル\"\n    },\n    \"newButton\": \"新しいAPIキー\",\n    \"empty\": \"APIキーはまだ作成されていません。\",\n    \"list\": {\n      \"created\": \"作成日:\",\n      \"lastUsed\": \"最終使用日:\"\n    },\n    \"confirmDelete\": \"このAPIキーを削除してもよろしいですか？\",\n    \"status\": {\n      \"active\": \"有効\",\n      \"inactive\": \"無効\"\n    },\n    \"github\": {\n      \"title\": \"GitHubトークン\",\n      \"description\": \"外部APIからプライベートリポジトリをクローンするためのGitHubパーソナルアクセストークンを追加します。\",\n      \"descriptionAlt\": \"プライベートリポジトリをクローンするためのGitHubパーソナルアクセストークンを追加します。保存せずにAPIリクエストで直接トークンを渡すこともできます。\",\n      \"addButton\": \"トークンを追加\",\n      \"form\": {\n        \"namePlaceholder\": \"トークンの名前（例: 個人リポジトリ）\",\n        \"tokenPlaceholder\": \"GitHubパーソナルアクセストークン（ghp_...）\",\n        \"descriptionPlaceholder\": \"説明（任意）\",\n        \"addButton\": \"トークンを追加\",\n        \"cancelButton\": \"キャンセル\",\n        \"howToCreate\": \"GitHubパーソナルアクセストークンの作成方法 →\"\n      },\n      \"empty\": \"GitHubトークンはまだ追加されていません。\",\n      \"added\": \"追加日:\",\n      \"confirmDelete\": \"このGitHubトークンを削除してもよろしいですか？\"\n    },\n    \"apiDocsLink\": \"APIドキュメント\",\n    \"documentation\": {\n      \"title\": \"外部APIドキュメント\",\n      \"description\": \"外部APIを使用してアプリケーションからClaude/Cursorセッションを起動する方法を学びます。\",\n      \"viewLink\": \"APIドキュメントを見る →\"\n    },\n    \"loading\": \"読み込み中...\",\n    \"version\": {\n      \"updateAvailable\": \"アップデートあり: v{{version}}\"\n    }\n  },\n  \"tasks\": {\n    \"checking\": \"TaskMasterのインストールを確認しています...\",\n    \"notInstalled\": {\n      \"title\": \"TaskMaster AI CLIがインストールされていません\",\n      \"description\": \"タスク管理機能を使用するにはTaskMaster CLIが必要です。以下のコマンドでインストールしてください:\",\n      \"installCommand\": \"npm install -g task-master-ai\",\n      \"viewOnGitHub\": \"GitHubで見る\",\n      \"afterInstallation\": \"インストール後:\",\n      \"steps\": {\n        \"restart\": \"このアプリケーションを再起動してください\",\n        \"autoAvailable\": \"TaskMaster機能が自動的に利用可能になります\",\n        \"initCommand\": \"プロジェクトディレクトリで task-master init を実行してください\"\n      }\n    },\n    \"settings\": {\n      \"enableLabel\": \"TaskMaster統合を有効にする\",\n      \"enableDescription\": \"インターフェース全体でTaskMasterのタスク、バナー、サイドバーインジケータを表示します\"\n    }\n  },\n  \"agents\": {\n    \"authStatus\": {\n      \"checking\": \"確認中...\",\n      \"connected\": \"接続済み\",\n      \"notConnected\": \"未接続\",\n      \"disconnected\": \"切断\",\n      \"checkingAuth\": \"認証状態を確認しています...\",\n      \"loggedInAs\": \"{{email}}でログイン中\",\n      \"authenticatedUser\": \"認証済みユーザー\"\n    },\n    \"account\": {\n      \"claude\": {\n        \"description\": \"Anthropic Claude AIアシスタント\"\n      },\n      \"cursor\": {\n        \"description\": \"Cursor AI搭載コードエディタ\"\n      },\n      \"codex\": {\n        \"description\": \"OpenAI Codex AIアシスタント\"\n      },\n      \"gemini\": {\n        \"description\": \"Google Gemini AIアシスタント\"\n      }\n    },\n    \"connectionStatus\": \"接続状態\",\n    \"login\": {\n      \"title\": \"ログイン\",\n      \"reAuthenticate\": \"再認証\",\n      \"description\": \"{{agent}}アカウントにサインインしてAI機能を有効にします\",\n      \"reAuthDescription\": \"別のアカウントでサインインするか、認証情報を更新します\",\n      \"button\": \"ログイン\",\n      \"reLoginButton\": \"再ログイン\"\n    },\n    \"error\": \"エラー: {{error}}\"\n  },\n  \"permissions\": {\n    \"title\": \"権限設定\",\n    \"skipPermissions\": {\n      \"label\": \"権限プロンプトをスキップ（注意して使用）\",\n      \"claudeDescription\": \"--dangerously-skip-permissions フラグに相当\",\n      \"cursorDescription\": \"Cursor CLIの -f フラグに相当\"\n    },\n    \"allowedTools\": {\n      \"title\": \"許可されたツール\",\n      \"description\": \"権限の確認なしに自動的に許可されるツール\",\n      \"placeholder\": \"例: \\\"Bash(git log:*)\\\" または \\\"Write\\\"\",\n      \"quickAdd\": \"よく使うツールを追加:\",\n      \"empty\": \"許可されたツールはありません\"\n    },\n    \"blockedTools\": {\n      \"title\": \"ブロックされたツール\",\n      \"description\": \"権限の確認なしに自動的にブロックされるツール\",\n      \"placeholder\": \"例: \\\"Bash(rm:*)\\\"\",\n      \"empty\": \"ブロックされたツールはありません\"\n    },\n    \"allowedCommands\": {\n      \"title\": \"許可されたシェルコマンド\",\n      \"description\": \"権限の確認なしに自動的に許可されるシェルコマンド\",\n      \"placeholder\": \"例: \\\"Shell(ls)\\\" または \\\"Shell(git status)\\\"\",\n      \"quickAdd\": \"よく使うコマンドを追加:\",\n      \"empty\": \"許可されたコマンドはありません\"\n    },\n    \"blockedCommands\": {\n      \"title\": \"ブロックされたシェルコマンド\",\n      \"description\": \"自動的にブロックされるシェルコマンド\",\n      \"placeholder\": \"例: \\\"Shell(rm -rf)\\\" または \\\"Shell(sudo)\\\"\",\n      \"empty\": \"ブロックされたコマンドはありません\"\n    },\n    \"toolExamples\": {\n      \"title\": \"ツールパターンの例:\",\n      \"bashGitLog\": \"- すべてのgit logコマンドを許可\",\n      \"bashGitDiff\": \"- すべてのgit diffコマンドを許可\",\n      \"write\": \"- すべてのWriteツールの使用を許可\",\n      \"bashRm\": \"- すべてのrmコマンドをブロック（危険）\"\n    },\n    \"shellExamples\": {\n      \"title\": \"シェルコマンドの例:\",\n      \"ls\": \"- lsコマンドを許可\",\n      \"gitStatus\": \"- git statusを許可\",\n      \"npmInstall\": \"- npm installを許可\",\n      \"rmRf\": \"- 再帰的削除をブロック\"\n    },\n    \"codex\": {\n      \"permissionMode\": \"権限モード\",\n      \"description\": \"Codexがファイルの変更やコマンドの実行を処理する方法を制御します\",\n      \"modes\": {\n        \"default\": {\n          \"title\": \"デフォルト\",\n          \"description\": \"信頼されたコマンド（ls、cat、grep、git statusなど）のみ自動実行。その他のコマンドはスキップ。ワークスペースへの書き込みは可能。\"\n        },\n        \"acceptEdits\": {\n          \"title\": \"編集を許可\",\n          \"description\": \"ワークスペース内ですべてのコマンドを自動実行。サンドボックス環境での完全自動モード。\"\n        },\n        \"bypassPermissions\": {\n          \"title\": \"権限をバイパス\",\n          \"description\": \"制限なしの完全なシステムアクセス。すべてのコマンドがディスクとネットワークへの完全なアクセスで自動実行されます。注意して使用してください。\"\n        }\n      },\n      \"technicalDetails\": \"技術的な詳細\",\n      \"technicalInfo\": {\n        \"default\": \"sandboxMode=workspace-write, approvalPolicy=untrusted。信頼されたコマンド: cat, cd, grep, head, ls, pwd, tail, git status/log/diff/show, find（-execなし）など。\",\n        \"acceptEdits\": \"sandboxMode=workspace-write, approvalPolicy=never。すべてのコマンドがプロジェクトディレクトリ内で自動実行。\",\n        \"bypassPermissions\": \"sandboxMode=danger-full-access, approvalPolicy=never。完全なシステムアクセス。信頼された環境でのみ使用してください。\",\n        \"overrideNote\": \"チャットインターフェースのモードボタンを使用してセッションごとに上書きできます。\"\n      }\n    },\n    \"actions\": {\n      \"add\": \"追加\"\n    }\n  },\n  \"mcpServers\": {\n    \"title\": \"MCPサーバー\",\n    \"description\": {\n      \"claude\": \"Model Context Protocolサーバーは、Claudeに追加のツールやデータソースを提供します\",\n      \"cursor\": \"Model Context Protocolサーバーは、Cursorに追加のツールやデータソースを提供します\",\n      \"codex\": \"Model Context Protocolサーバーは、Codexに追加のツールやデータソースを提供します\"\n    },\n    \"addButton\": \"MCPサーバーを追加\",\n    \"empty\": \"MCPサーバーは設定されていません\",\n    \"serverType\": \"種類\",\n    \"scope\": {\n      \"local\": \"ローカル\",\n      \"user\": \"ユーザー\"\n    },\n    \"config\": {\n      \"command\": \"コマンド\",\n      \"url\": \"URL\",\n      \"args\": \"引数\",\n      \"environment\": \"環境変数\"\n    },\n    \"tools\": {\n      \"title\": \"ツール\",\n      \"count\": \"（{{count}}）:\",\n      \"more\": \"他{{count}}件\"\n    },\n    \"actions\": {\n      \"edit\": \"サーバーを編集\",\n      \"delete\": \"サーバーを削除\"\n    },\n    \"help\": {\n      \"title\": \"Codex MCPについて\",\n      \"description\": \"Codexはstdioベースのツールサーバーをサポートしています。追加のツールやリソースでCodexの機能を拡張するサーバーを追加できます。\"\n    }\n  },\n  \"pluginSettings\": {\n    \"title\": \"プラグイン\",\n    \"description\": \"カスタムプラグインでインターフェースを拡張します。gitからインストールするか、~/.claude-code-ui/plugins/ にフォルダを配置してください。\",\n    \"installPlaceholder\": \"https://github.com/user/my-plugin\",\n    \"installButton\": \"インストール\",\n    \"installing\": \"インストール中…\",\n    \"securityWarning\": \"信頼できる作成者のプラグイン、またはソースコードを確認済みのプラグインのみをインストールしてください。\",\n    \"scanningPlugins\": \"プラグインをスキャン中…\",\n    \"noPluginsInstalled\": \"プラグインがインストールされていません\",\n    \"pullLatest\": \"gitから最新を取得\",\n    \"noGitRemote\": \"リモートgitリポジトリがありません — アップデート不可\",\n    \"uninstallPlugin\": \"プラグインを削除\",\n    \"confirmUninstall\": \"クリックして確定\",\n    \"confirmUninstallMessage\": \"{{name}} を削除しますか？この操作は取り消せません。\",\n    \"cancel\": \"キャンセル\",\n    \"remove\": \"削除\",\n    \"updateFailed\": \"アップデートに失敗しました\",\n    \"installFailed\": \"インストールに失敗しました\",\n    \"uninstallFailed\": \"削除に失敗しました\",\n    \"toggleFailed\": \"切り替えに失敗しました\",\n    \"buildYourOwn\": \"プラグインを自作する\",\n    \"starter\": \"スターター\",\n    \"docs\": \"ドキュメント\",\n    \"starterPlugin\": {\n      \"name\": \"プロジェクト統計\",\n      \"badge\": \"スターター\",\n      \"description\": \"プロジェクトのファイル数、コード行数、ファイルタイプの内訳、最近のアクティビティを表示します。\",\n      \"install\": \"インストール\"\n    },\n    \"morePlugins\": \"詳細\",\n    \"enable\": \"有効にする\",\n    \"disable\": \"無効にする\",\n    \"installAriaLabel\": \"プラグインのgitリポジトリURL\",\n    \"tab\": \"タブ\",\n    \"runningStatus\": \"実行中\"\n  }\n}"
  },
  {
    "path": "src/i18n/locales/ja/sidebar.json",
    "content": "{\n  \"projects\": {\n    \"title\": \"プロジェクト\",\n    \"newProject\": \"新規プロジェクト\",\n    \"deleteProject\": \"プロジェクトを削除\",\n    \"renameProject\": \"プロジェクト名を変更\",\n    \"noProjects\": \"プロジェクトが見つかりません\",\n    \"loadingProjects\": \"プロジェクトを読み込んでいます...\",\n    \"searchPlaceholder\": \"プロジェクトを検索...\",\n    \"projectNamePlaceholder\": \"プロジェクト名\",\n    \"starred\": \"お気に入り\",\n    \"all\": \"すべて\",\n    \"untitledSession\": \"無題のセッション\",\n    \"newSession\": \"新しいセッション\",\n    \"codexSession\": \"Codexセッション\",\n    \"fetchingProjects\": \"Claudeのプロジェクトとセッションを取得しています\",\n    \"projects\": \"プロジェクト\",\n    \"noMatchingProjects\": \"一致するプロジェクトがありません\",\n    \"tryDifferentSearch\": \"検索語を変えてお試しください\",\n    \"runClaudeCli\": \"プロジェクトディレクトリでClaude CLIを実行して始めましょう\"\n  },\n  \"app\": {\n    \"title\": \"Claude Code UI\",\n    \"subtitle\": \"AIコーディングアシスタント\"\n  },\n  \"sessions\": {\n    \"title\": \"セッション\",\n    \"newSession\": \"新しいセッション\",\n    \"deleteSession\": \"セッションを削除\",\n    \"renameSession\": \"セッション名を変更\",\n    \"noSessions\": \"セッションはまだありません\",\n    \"loadingSessions\": \"セッションを読み込んでいます...\",\n    \"unnamed\": \"名称未設定\",\n    \"loading\": \"読み込み中...\",\n    \"showMore\": \"さらにセッションを表示\"\n  },\n  \"tooltips\": {\n    \"viewEnvironments\": \"環境を表示\",\n    \"hideSidebar\": \"サイドバーを隠す\",\n    \"createProject\": \"新しいプロジェクトを作成\",\n    \"refresh\": \"プロジェクトとセッションを更新 (Ctrl+R)\",\n    \"renameProject\": \"プロジェクト名を変更 (F2)\",\n    \"deleteProject\": \"空のプロジェクトを削除 (Delete)\",\n    \"addToFavorites\": \"お気に入りに追加\",\n    \"removeFromFavorites\": \"お気に入りから削除\",\n    \"editSessionName\": \"セッション名を手動で編集\",\n    \"deleteSession\": \"このセッションを完全に削除\",\n    \"save\": \"保存\",\n    \"cancel\": \"キャンセル\"\n  },\n  \"navigation\": {\n    \"chat\": \"チャット\",\n    \"files\": \"ファイル\",\n    \"git\": \"Git\",\n    \"terminal\": \"ターミナル\",\n    \"tasks\": \"タスク\"\n  },\n  \"actions\": {\n    \"refresh\": \"更新\",\n    \"settings\": \"設定\",\n    \"collapseAll\": \"すべて折りたたむ\",\n    \"expandAll\": \"すべて展開\",\n    \"cancel\": \"キャンセル\",\n    \"save\": \"保存\",\n    \"delete\": \"削除\",\n    \"rename\": \"名前の変更\",\n    \"joinCommunity\": \"コミュニティに参加\"\n  },\n  \"status\": {\n    \"active\": \"アクティブ\",\n    \"inactive\": \"非アクティブ\",\n    \"thinking\": \"思考中...\",\n    \"error\": \"エラー\",\n    \"aborted\": \"中断\",\n    \"unknown\": \"不明\"\n  },\n  \"time\": {\n    \"justNow\": \"たった今\",\n    \"oneMinuteAgo\": \"1分前\",\n    \"minutesAgo\": \"{{count}}分前\",\n    \"oneHourAgo\": \"1時間前\",\n    \"hoursAgo\": \"{{count}}時間前\",\n    \"oneDayAgo\": \"1日前\",\n    \"daysAgo\": \"{{count}}日前\"\n  },\n  \"messages\": {\n    \"deleteConfirm\": \"本当に削除しますか？\",\n    \"renameSuccess\": \"名前を変更しました\",\n    \"deleteSuccess\": \"削除しました\",\n    \"errorOccurred\": \"エラーが発生しました\",\n    \"deleteSessionConfirm\": \"このセッションを削除してもよろしいですか？この操作は取り消せません。\",\n    \"deleteProjectConfirm\": \"この空のプロジェクトを削除してもよろしいですか？この操作は取り消せません。\",\n    \"enterProjectPath\": \"プロジェクトのパスを入力してください\",\n    \"deleteSessionFailed\": \"セッションの削除に失敗しました。もう一度お試しください。\",\n    \"deleteSessionError\": \"セッションの削除でエラーが発生しました。もう一度お試しください。\",\n    \"renameSessionFailed\": \"セッション名の変更に失敗しました。もう一度お試しください。\",\n    \"renameSessionError\": \"セッション名の変更でエラーが発生しました。もう一度お試しください。\",\n    \"deleteProjectFailed\": \"プロジェクトの削除に失敗しました。もう一度お試しください。\",\n    \"deleteProjectError\": \"プロジェクトの削除でエラーが発生しました。もう一度お試しください。\",\n    \"createProjectFailed\": \"プロジェクトの作成に失敗しました。もう一度お試しください。\",\n    \"createProjectError\": \"プロジェクトの作成でエラーが発生しました。もう一度お試しください。\"\n  },\n  \"version\": {\n    \"updateAvailable\": \"アップデートあり\"\n  },\n  \"deleteConfirmation\": {\n    \"deleteProject\": \"プロジェクトを削除\",\n    \"deleteSession\": \"セッションを削除\",\n    \"confirmDelete\": \"本当に削除しますか？\",\n    \"sessionCount\": \"このプロジェクトには{{count}}件の会話があります。\",\n    \"allConversationsDeleted\": \"すべての会話が完全に削除されます。\",\n    \"cannotUndo\": \"この操作は取り消せません。\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/ja/tasks.json",
    "content": "{\n  \"notConfigured\": {\n    \"title\": \"TaskMaster AIが設定されていません\",\n    \"description\": \"TaskMasterは、AIを活用した支援により、複雑なプロジェクトを管理しやすいタスクに分解するのに役立ちます\",\n    \"whatIsTitle\": \"🎯 TaskMasterとは？\",\n    \"features\": {\n      \"aiPowered\": \"AIを活用したタスク管理：複雑なプロジェクトを管理しやすいサブタスクに分解\",\n      \"prdTemplates\": \"PRDテンプレート：Product Requirements Documentからタスクを生成\",\n      \"dependencyTracking\": \"依存関係の追跡：タスクの関係性と実行順序を理解\",\n      \"progressVisualization\": \"進捗の可視化：カンバンボードと詳細なタスク分析\",\n      \"cliIntegration\": \"CLI統合：高度なワークフローのためにtaskmasterコマンドを使用\"\n    },\n    \"initializeButton\": \"TaskMaster AIを初期化\"\n  },\n  \"gettingStarted\": {\n    \"title\": \"TaskMasterを始める\",\n    \"subtitle\": \"TaskMasterが初期化されました！次にすることは:\",\n    \"steps\": {\n      \"createPRD\": {\n        \"title\": \"Product Requirements Document (PRD) を作成\",\n        \"description\": \"プロジェクトのアイデアについて話し合い、構築したい内容を説明するPRDを作成します。\",\n        \"addButton\": \"PRDを追加\",\n        \"existingPRDs\": \"既存のPRD:\"\n      },\n      \"generateTasks\": {\n        \"title\": \"PRDからタスクを生成\",\n        \"description\": \"PRDができたら、AIアシスタントに解析を依頼してください。TaskMasterが自動的に実装の詳細を含む管理しやすいタスクに分解します。\"\n      },\n      \"analyzeTasks\": {\n        \"title\": \"タスクの分析と展開\",\n        \"description\": \"AIアシスタントにタスクの複雑さを分析してもらい、より簡単に実装できる詳細なサブタスクに展開します。\"\n      },\n      \"startBuilding\": {\n        \"title\": \"開発を始める\",\n        \"description\": \"AIアシスタントにタスクの作業を開始してもらい、ステータスを更新し、プロジェクトの進行に応じて新しいタスクを追加します。\"\n      }\n    },\n    \"tip\": \"💡 ヒント：TaskMasterのAIを活用したタスク生成を最大限に活用するには、PRDから始めましょう\"\n  },\n  \"setupModal\": {\n    \"title\": \"TaskMasterのセットアップ\",\n    \"subtitle\": \"{{projectName}}のインタラクティブCLI\",\n    \"willStart\": \"TaskMasterの初期化が自動的に開始されます\",\n    \"completed\": \"TaskMasterのセットアップが完了しました！このウィンドウを閉じることができます。\",\n    \"closeButton\": \"閉じる\",\n    \"closeContinueButton\": \"閉じて続ける\"\n  },\n  \"helpGuide\": {\n    \"title\": \"TaskMasterを始める\",\n    \"subtitle\": \"生産的なタスク管理のガイド\",\n    \"examples\": {\n      \"parsePRD\": \"💬 例：\\n「Claude Task Masterで新しいプロジェクトを初期化しました。.taskmaster/docs/prd.txtにPRDがあります。解析して初期タスクを設定するのを手伝ってもらえますか？」\",\n      \"expandTask\": \"💬 例：\\n「タスク5は複雑そうです。サブタスクに分解してもらえますか？」\",\n      \"addTask\": \"💬 例：\\n「Cloudinaryを使用してユーザープロフィール画像のアップロードを実装する新しいタスクを追加してください。最適なアプローチを調査してください。」\"\n    },\n    \"moreExamples\": \"さらなる例と使用パターンを見る →\",\n    \"proTips\": {\n      \"title\": \"💡 プロのヒント\",\n      \"search\": \"検索バーを使用して特定のタスクをすばやく見つける\",\n      \"views\": \"ビュー切替を使用してカンバン、リスト、グリッドビューを切り替える\",\n      \"filters\": \"フィルターを使用して特定のタスクステータスや優先度に焦点を当てる\",\n      \"details\": \"任意のタスクをクリックして詳細情報を表示し、サブタスクを管理する\"\n    },\n    \"learnMore\": {\n      \"title\": \"📚 詳細を見る\",\n      \"description\": \"TaskMaster AIは開発者向けに構築された高度なタスク管理システムです。ドキュメント、例を入手し、プロジェクトに貢献できます。\",\n      \"githubButton\": \"GitHubで見る\"\n    }\n  },\n  \"search\": {\n    \"placeholder\": \"タスクを検索...\"\n  },\n  \"filters\": {\n    \"button\": \"フィルター\",\n    \"status\": \"ステータス\",\n    \"priority\": \"優先度\",\n    \"sortBy\": \"並び替え\",\n    \"allStatuses\": \"すべてのステータス\",\n    \"allPriorities\": \"すべての優先度\",\n    \"showing\": \"{{filtered}}件のタスクを表示中（全{{total}}件）\",\n    \"clearFilters\": \"フィルターをクリア\"\n  },\n  \"sort\": {\n    \"id\": \"ID\",\n    \"status\": \"ステータス\",\n    \"priority\": \"優先度\",\n    \"idAsc\": \"ID（昇順）\",\n    \"idDesc\": \"ID（降順）\",\n    \"titleAsc\": \"タイトル（A-Z）\",\n    \"titleDesc\": \"タイトル（Z-A）\",\n    \"statusAsc\": \"ステータス（保留中が先）\",\n    \"statusDesc\": \"ステータス（完了が先）\",\n    \"priorityAsc\": \"優先度（高が先）\",\n    \"priorityDesc\": \"優先度（低が先）\"\n  },\n  \"views\": {\n    \"kanban\": \"カンバンビュー\",\n    \"list\": \"リストビュー\",\n    \"grid\": \"グリッドビュー\"\n  },\n  \"kanban\": {\n    \"pending\": \"📋 やること\",\n    \"inProgress\": \"🚀 進行中\",\n    \"done\": \"✅ 完了\",\n    \"blocked\": \"🚫 ブロック中\",\n    \"deferred\": \"⏳ 延期\",\n    \"cancelled\": \"❌ キャンセル\",\n    \"noTasksYet\": \"まだタスクはありません\",\n    \"tasksWillAppear\": \"タスクはここに表示されます\",\n    \"moveTasksHere\": \"開始したらタスクをここに移動\",\n    \"completedTasksHere\": \"完了したタスクはここに表示されます\",\n    \"statusTasksHere\": \"このステータスのタスクはここに表示されます\"\n  },\n  \"buttons\": {\n    \"help\": \"TaskMaster入門ガイド\",\n    \"prds\": \"PRD\",\n    \"addPRD\": \"PRDを追加\",\n    \"addTask\": \"タスクを追加\",\n    \"createNewPRD\": \"新しいPRDを作成\",\n    \"prdsAvailable\": \"{{count}}件のPRDがあります\"\n  },\n  \"prd\": {\n    \"modified\": \"更新日: {{date}}\"\n  },\n  \"statuses\": {\n    \"pending\": \"保留中\",\n    \"in-progress\": \"進行中\",\n    \"done\": \"完了\",\n    \"blocked\": \"ブロック中\",\n    \"deferred\": \"延期\",\n    \"cancelled\": \"キャンセル\"\n  },\n  \"priorities\": {\n    \"high\": \"高\",\n    \"medium\": \"中\",\n    \"low\": \"低\"\n  },\n  \"noMatchingTasks\": {\n    \"title\": \"フィルターに一致するタスクがありません\",\n    \"description\": \"検索条件またはフィルター基準を調整してみてください。\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/ko/auth.json",
    "content": "{\n  \"login\": {\n    \"title\": \"다시 오신 것을 환영합니다\",\n    \"description\": \"Claude Code UI 계정에 로그인하세요\",\n    \"username\": \"사용자명\",\n    \"password\": \"비밀번호\",\n    \"submit\": \"로그인\",\n    \"loading\": \"로그인 중...\",\n    \"errors\": {\n      \"invalidCredentials\": \"사용자명 또는 비밀번호가 잘못되었습니다\",\n      \"requiredFields\": \"모든 항목을 입력해주세요\",\n      \"networkError\": \"네트워크 오류. 다시 시도해주세요.\"\n    },\n    \"placeholders\": {\n      \"username\": \"사용자명을 입력하세요\",\n      \"password\": \"비밀번호를 입력하세요\"\n    }\n  },\n  \"register\": {\n    \"title\": \"계정 생성\",\n    \"username\": \"사용자명\",\n    \"password\": \"비밀번호\",\n    \"confirmPassword\": \"비밀번호 확인\",\n    \"submit\": \"계정 생성\",\n    \"loading\": \"계정 생성 중...\",\n    \"errors\": {\n      \"passwordMismatch\": \"비밀번호가 일치하지 않습니다\",\n      \"usernameTaken\": \"이미 사용 중인 사용자명입니다\",\n      \"weakPassword\": \"비밀번호가 너무 약합니다\"\n    }\n  },\n  \"logout\": {\n    \"title\": \"로그아웃\",\n    \"confirm\": \"정말 로그아웃하시겠습니까?\",\n    \"button\": \"로그아웃\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/ko/chat.json",
    "content": "{\n  \"codeBlock\": {\n    \"copy\": \"복사\",\n    \"copied\": \"복사됨\",\n    \"copyCode\": \"코드 복사\"\n  },\n  \"copyMessage\": {\n    \"copy\": \"메시지 복사\",\n    \"copied\": \"메시지 복사됨\",\n    \"selectFormat\": \"복사 형식 선택\",\n    \"copyAsMarkdown\": \"마크다운으로 복사\",\n    \"copyAsText\": \"텍스트로 복사\"\n  },\n  \"messageTypes\": {\n    \"user\": \"U\",\n    \"error\": \"오류\",\n    \"tool\": \"도구\",\n    \"claude\": \"Claude\",\n    \"cursor\": \"Cursor\",\n    \"codex\": \"Codex\",\n    \"gemini\": \"Gemini\"\n  },\n  \"tools\": {\n    \"settings\": \"도구 설정\",\n    \"error\": \"도구 오류\",\n    \"result\": \"도구 결과\",\n    \"viewParams\": \"입력 파라미터 보기\",\n    \"viewRawParams\": \"Raw 파라미터 보기\",\n    \"viewDiff\": \"편집 Diff 보기:\",\n    \"creatingFile\": \"새 파일 생성:\",\n    \"updatingTodo\": \"Todo 리스트 업데이트\",\n    \"read\": \"읽기\",\n    \"readFile\": \"파일 읽기\",\n    \"updateTodo\": \"Todo 리스트 업데이트\",\n    \"readTodo\": \"Todo 리스트 읽기\",\n    \"searchResults\": \"결과\"\n  },\n  \"search\": {\n    \"found\": \"{{count}}개의 {{type}} 발견\",\n    \"file\": \"파일\",\n    \"files\": \"파일\",\n    \"pattern\": \"패턴:\",\n    \"in\": \"위치:\"\n  },\n  \"fileOperations\": {\n    \"updated\": \"파일이 업데이트되었습니다\",\n    \"created\": \"파일이 생성되었습니다\",\n    \"written\": \"파일이 작성되었습니다\",\n    \"diff\": \"Diff\",\n    \"newFile\": \"새 파일\",\n    \"viewContent\": \"파일 내용 보기\",\n    \"viewFullOutput\": \"전체 출력 보기 ({{count}}자)\",\n    \"contentDisplayed\": \"파일 내용이 위의 Diff 보기에 표시됩니다\"\n  },\n  \"interactive\": {\n    \"title\": \"대화형 프롬프트\",\n    \"waiting\": \"CLI에서 응답을 기다리는 중\",\n    \"instruction\": \"Claude가 실행 중인 터미널에서 옵션을 선택해주세요.\",\n    \"selectedOption\": \"✓ Claude가 옵션 {{number}}을(를) 선택했습니다\",\n    \"instructionDetail\": \"CLI에서 화살표 키 또는 숫자를 입력하여 이 옵션을 대화형으로 선택합니다.\"\n  },\n  \"thinking\": {\n    \"title\": \"생각 중...\",\n    \"emoji\": \"💭 생각 중...\"\n  },\n  \"json\": {\n    \"response\": \"JSON 응답\"\n  },\n  \"permissions\": {\n    \"grant\": \"{{tool}}에 대한 권한 부여\",\n    \"added\": \"권한이 추가되었습니다\",\n    \"addTo\": \"{{entry}}을(를) 허용된 도구에 추가합니다.\",\n    \"retry\": \"권한이 저장되었습니다. 도구를 사용하려면 요청을 재시도하세요.\",\n    \"error\": \"권한을 업데이트할 수 없습니다. 다시 시도해주세요.\",\n    \"openSettings\": \"설정 열기\"\n  },\n  \"todo\": {\n    \"updated\": \"Todo 리스트가 업데이트되었습니다\",\n    \"current\": \"현재 Todo 리스트\"\n  },\n  \"plan\": {\n    \"viewPlan\": \"📋 구현 계획 보기\",\n    \"title\": \"구현 계획\"\n  },\n  \"usageLimit\": {\n    \"resetAt\": \"Claude 사용량 한도에 도달했습니다. 한도는 **{{time}} {{timezone}}** - {{date}}에 초기화됩니다\"\n  },\n  \"codex\": {\n    \"permissionMode\": \"권한 모드\",\n    \"modes\": {\n      \"default\": \"기본 모드\",\n      \"acceptEdits\": \"편집 허용\",\n      \"bypassPermissions\": \"권한 우회\",\n      \"plan\": \"Plan 모드\"\n    },\n    \"descriptions\": {\n      \"default\": \"신뢰할 수 있는 명령어(ls, cat, grep, git status 등)만 자동 실행됩니다. 다른 명령어는 건너뜁니다. 워크스페이스에 쓰기 가능.\",\n      \"acceptEdits\": \"워크스페이스 내에서 모든 명령어가 자동 실행됩니다. 샌드박스 내 완전 자동 모드.\",\n      \"bypassPermissions\": \"제한 없는 전체 시스템 접근. 모든 명령어가 전체 디스크 및 네트워크 접근 권한으로 자동 실행됩니다. 주의해서 사용하세요.\",\n      \"plan\": \"계획 모드 - 명령어가 실행되지 않습니다\"\n    },\n    \"technicalDetails\": \"기술 상세\"\n  },\n  \"input\": {\n    \"placeholder\": \"/를 입력하여 명령어, @를 입력하여 파일, 또는 {{provider}}에게 무엇이든 물어보세요...\",\n    \"placeholderDefault\": \"메시지를 입력하세요...\",\n    \"disabled\": \"입력 비활성화\",\n    \"attachFiles\": \"파일 첨부\",\n    \"attachImages\": \"이미지 첨부\",\n    \"send\": \"전송\",\n    \"stop\": \"중지\",\n    \"hintText\": {\n      \"ctrlEnter\": \"Ctrl+Enter로 전송 • Shift+Enter로 줄바꿈 • Tab으로 모드 변경 • /로 슬래시 명령어\",\n      \"enter\": \"Enter로 전송 • Shift+Enter로 줄바꿈 • Tab으로 모드 변경 • /로 슬래시 명령어\"\n    },\n    \"clickToChangeMode\": \"클릭하여 권한 모드 변경 (또는 입력창에서 Tab)\",\n    \"showAllCommands\": \"모든 명령어 보기\",\n    \"clearInput\": \"입력 지우기\",\n    \"scrollToBottom\": \"맨 아래로 스크롤\"\n  },\n  \"thinkingMode\": {\n    \"selector\": {\n      \"title\": \"Thinking 모드\",\n      \"description\": \"확장된 thinking은 Claude에게 대안을 평가할 시간을 더 줍니다\",\n      \"active\": \"활성\",\n      \"tip\": \"높은 thinking 모드는 시간이 더 걸리지만 더 철저한 분석을 제공합니다\"\n    },\n    \"modes\": {\n      \"none\": {\n        \"name\": \"Standard\",\n        \"description\": \"일반 Claude 응답\",\n        \"prefix\": \"\"\n      },\n      \"think\": {\n        \"name\": \"Think\",\n        \"description\": \"기본 확장 thinking\",\n        \"prefix\": \"think\"\n      },\n      \"thinkHard\": {\n        \"name\": \"Think Hard\",\n        \"description\": \"더 철저한 평가\",\n        \"prefix\": \"think hard\"\n      },\n      \"thinkHarder\": {\n        \"name\": \"Think Harder\",\n        \"description\": \"대안을 포함한 심층 분석\",\n        \"prefix\": \"think harder\"\n      },\n      \"ultrathink\": {\n        \"name\": \"Ultrathink\",\n        \"description\": \"최대 thinking 예산\",\n        \"prefix\": \"ultrathink\"\n      }\n    },\n    \"buttonTitle\": \"Thinking 모드: {{mode}}\"\n  },\n  \"providerSelection\": {\n    \"title\": \"AI 어시스턴트 선택\",\n    \"description\": \"새 대화를 시작할 프로바이더를 선택하세요\",\n    \"selectModel\": \"모델 선택\",\n    \"providerInfo\": {\n      \"anthropic\": \"Anthropic 제공\",\n      \"openai\": \"OpenAI 제공\",\n      \"cursorEditor\": \"AI 코드 에디터\",\n      \"google\": \"Google 제공\"\n    },\n    \"readyPrompt\": {\n      \"claude\": \"{{model}} 모델로 Claude를 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요.\",\n      \"cursor\": \"{{model}} 모델로 Cursor를 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요.\",\n      \"codex\": \"{{model}} 모델로 Codex를 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요.\",\n      \"gemini\": \"{{model}} 모델로 Gemini를 사용할 준비가 되었습니다. 아래에 메시지를 입력하세요.\",\n      \"default\": \"시작하려면 위에서 제공자를 선택하세요\"\n    }\n  },\n  \"session\": {\n    \"continue\": {\n      \"title\": \"대화 계속하기\",\n      \"description\": \"코드에 대해 질문하거나, 변경을 요청하거나, 개발 작업에 도움을 받으세요\"\n    },\n    \"loading\": {\n      \"olderMessages\": \"이전 메시지 로딩 중...\",\n      \"sessionMessages\": \"세션 메시지 로딩 중...\"\n    },\n    \"messages\": {\n      \"showingOf\": \"{{total}}개 중 {{shown}}개 표시\",\n      \"scrollToLoad\": \"위로 스크롤하여 더 로드\",\n      \"showingLast\": \"마지막 {{count}}개 메시지 표시 (총 {{total}}개)\",\n      \"loadEarlier\": \"이전 메시지 로드\",\n      \"loadAll\": \"모든 메시지 로드\",\n      \"loadingAll\": \"모든 메시지 로딩 중...\",\n      \"allLoaded\": \"모든 메시지 로드 완료\",\n      \"perfWarning\": \"모든 메시지가 로드됨 - 스크롤이 느려질 수 있습니다. \\\"맨 아래로 스크롤\\\"을 클릭하면 성능이 복구됩니다.\"\n    }\n  },\n  \"shell\": {\n    \"selectProject\": {\n      \"title\": \"프로젝트 선택\",\n      \"description\": \"해당 디렉토리에서 대화형 Shell을 열 프로젝트를 선택하세요\"\n    },\n    \"status\": {\n      \"newSession\": \"새 세션\",\n      \"initializing\": \"초기화 중...\",\n      \"restarting\": \"재시작 중...\"\n    },\n    \"actions\": {\n      \"disconnect\": \"연결 끊기\",\n      \"disconnectTitle\": \"Shell 연결 끊기\",\n      \"restart\": \"재시작\",\n      \"restartTitle\": \"Shell 재시작 (먼저 연결 끊기)\",\n      \"connect\": \"Shell에서 계속\",\n      \"connectTitle\": \"Shell에 연결\"\n    },\n    \"loading\": \"터미널 로딩 중...\",\n    \"connecting\": \"Shell에 연결 중...\",\n    \"startSession\": \"새 Claude 세션 시작\",\n    \"resumeSession\": \"세션 재개: {{displayName}}...\",\n    \"runCommand\": \"{{projectName}}에서 {{command}} 실행\",\n    \"startCli\": \"{{projectName}}에서 Claude CLI 시작\",\n    \"defaultCommand\": \"명령어\"\n  },\n  \"claudeStatus\": {\n    \"actions\": {\n      \"thinking\": \"Thinking\",\n      \"processing\": \"Processing\",\n      \"analyzing\": \"Analyzing\",\n      \"working\": \"Working\",\n      \"computing\": \"Computing\",\n      \"reasoning\": \"Reasoning\"\n    },\n    \"state\": {\n      \"live\": \"Live\",\n      \"paused\": \"Paused\"\n    },\n    \"elapsed\": {\n      \"seconds\": \"{{count}}s\",\n      \"minutesSeconds\": \"{{minutes}}m {{seconds}}s\",\n      \"label\": \"{{time}} elapsed\",\n      \"startingNow\": \"Starting now\"\n    },\n    \"controls\": {\n      \"stopGeneration\": \"Stop Generation\",\n      \"pressEscToStop\": \"Press Esc anytime to stop\"\n    },\n    \"providers\": {\n      \"assistant\": \"Assistant\"\n    }\n  },\n  \"projectSelection\": {\n    \"startChatWithProvider\": \"{{provider}}와 채팅을 시작하려면 프로젝트를 선택하세요\"\n  },\n  \"tasks\": {\n    \"nextTaskPrompt\": \"다음 작업 시작\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/ko/codeEditor.json",
    "content": "{\n  \"toolbar\": {\n    \"changes\": \"변경사항\",\n    \"previousChange\": \"이전 변경\",\n    \"nextChange\": \"다음 변경\",\n    \"hideDiff\": \"Diff 하이라이트 숨기기\",\n    \"showDiff\": \"Diff 하이라이트 표시\",\n    \"settings\": \"에디터 설정\",\n    \"collapse\": \"에디터 접기\",\n    \"expand\": \"에디터 전체 너비로 펼치기\"\n  },\n  \"loading\": \"{{fileName}} 로딩 중...\",\n  \"header\": {\n    \"showingChanges\": \"변경사항 표시\"\n  },\n  \"actions\": {\n    \"download\": \"파일 다운로드\",\n    \"save\": \"저장\",\n    \"saving\": \"저장 중...\",\n    \"saved\": \"저장됨!\",\n    \"exitFullscreen\": \"전체화면 종료\",\n    \"fullscreen\": \"전체화면\",\n    \"close\": \"닫기\"\n  },\n  \"footer\": {\n    \"lines\": \"줄:\",\n    \"characters\": \"문자:\",\n    \"shortcuts\": \"Ctrl+S로 저장 • Esc로 닫기\"\n  },\n  \"binaryFile\": {\n    \"title\": \"바이너리 파일\",\n    \"message\": \"파일 \\\"{{fileName}}\\\"은(는) 바이너리 파일이므로 텍스트 편집기에서 표시할 수 없습니다.\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/ko/common.json",
    "content": "{\n  \"buttons\": {\n    \"save\": \"저장\",\n    \"cancel\": \"취소\",\n    \"delete\": \"삭제\",\n    \"create\": \"생성\",\n    \"edit\": \"편집\",\n    \"close\": \"닫기\",\n    \"confirm\": \"확인\",\n    \"submit\": \"제출\",\n    \"retry\": \"재시도\",\n    \"refresh\": \"새로고침\",\n    \"search\": \"검색\",\n    \"clear\": \"지우기\",\n    \"copy\": \"복사\",\n    \"download\": \"다운로드\",\n    \"upload\": \"업로드\",\n    \"browse\": \"찾아보기\"\n  },\n  \"tabs\": {\n    \"chat\": \"채팅\",\n    \"shell\": \"Shell\",\n    \"files\": \"파일\",\n    \"git\": \"소스 관리\",\n    \"tasks\": \"작업\"\n  },\n  \"status\": {\n    \"loading\": \"로딩 중...\",\n    \"success\": \"성공\",\n    \"error\": \"오류\",\n    \"failed\": \"실패\",\n    \"pending\": \"대기 중\",\n    \"completed\": \"완료\",\n    \"inProgress\": \"진행 중\"\n  },\n  \"messages\": {\n    \"savedSuccessfully\": \"저장되었습니다\",\n    \"deletedSuccessfully\": \"삭제되었습니다\",\n    \"updatedSuccessfully\": \"업데이트되었습니다\",\n    \"operationFailed\": \"작업 실패\",\n    \"networkError\": \"네트워크 오류. 연결을 확인해주세요.\",\n    \"unauthorized\": \"인증되지 않았습니다. 로그인해주세요.\",\n    \"notFound\": \"찾을 수 없음\",\n    \"invalidInput\": \"잘못된 입력\",\n    \"requiredField\": \"필수 항목입니다\",\n    \"unknownError\": \"알 수 없는 오류가 발생했습니다\"\n  },\n  \"navigation\": {\n    \"settings\": \"설정\",\n    \"home\": \"홈\",\n    \"back\": \"뒤로\",\n    \"next\": \"다음\",\n    \"previous\": \"이전\",\n    \"logout\": \"로그아웃\"\n  },\n  \"common\": {\n    \"language\": \"언어\",\n    \"theme\": \"테마\",\n    \"darkMode\": \"다크 모드\",\n    \"lightMode\": \"라이트 모드\",\n    \"name\": \"이름\",\n    \"description\": \"설명\",\n    \"enabled\": \"활성화\",\n    \"disabled\": \"비활성화\",\n    \"optional\": \"선택사항\",\n    \"version\": \"버전\",\n    \"select\": \"선택\",\n    \"selectAll\": \"전체 선택\",\n    \"deselectAll\": \"전체 해제\"\n  },\n  \"time\": {\n    \"justNow\": \"방금 전\",\n    \"minutesAgo\": \"{{count}}분 전\",\n    \"hoursAgo\": \"{{count}}시간 전\",\n    \"daysAgo\": \"{{count}}일 전\",\n    \"yesterday\": \"어제\"\n  },\n  \"fileOperations\": {\n    \"newFile\": \"새 파일\",\n    \"newFolder\": \"새 폴더\",\n    \"rename\": \"이름 변경\",\n    \"move\": \"이동\",\n    \"copyPath\": \"경로 복사\",\n    \"openInEditor\": \"에디터에서 열기\"\n  },\n  \"mainContent\": {\n    \"loading\": \"Claude Code UI 로딩 중\",\n    \"settingUpWorkspace\": \"워크스페이스 설정 중...\",\n    \"chooseProject\": \"프로젝트 선택\",\n    \"selectProjectDescription\": \"사이드바에서 프로젝트를 선택하여 Claude와 코딩을 시작하세요. 각 프로젝트에는 채팅 세션과 파일 히스토리가 포함됩니다.\",\n    \"tip\": \"팁\",\n    \"createProjectMobile\": \"위의 메뉴 버튼을 눌러 프로젝트에 접근하세요\",\n    \"createProjectDesktop\": \"사이드바의 폴더 아이콘을 클릭하여 새 프로젝트를 생성하세요\",\n    \"newSession\": \"새 세션\",\n    \"untitledSession\": \"제목 없는 세션\",\n    \"projectFiles\": \"프로젝트 파일\"\n  },\n  \"fileTree\": {\n    \"loading\": \"파일 로딩 중...\",\n    \"files\": \"파일\",\n    \"simpleView\": \"간단히 보기\",\n    \"compactView\": \"컴팩트 보기\",\n    \"detailedView\": \"상세히 보기\",\n    \"searchPlaceholder\": \"파일 및 폴더 검색...\",\n    \"clearSearch\": \"검색 지우기\",\n    \"name\": \"이름\",\n    \"size\": \"크기\",\n    \"modified\": \"수정일\",\n    \"permissions\": \"권한\",\n    \"noFilesFound\": \"파일을 찾을 수 없음\",\n    \"checkProjectPath\": \"프로젝트 경로가 접근 가능한지 확인하세요\",\n    \"noMatchesFound\": \"일치하는 항목 없음\",\n    \"tryDifferentSearch\": \"다른 검색어를 시도하거나 검색을 지우세요\",\n    \"justNow\": \"방금 전\",\n    \"minAgo\": \"{{count}}분 전\",\n    \"hoursAgo\": \"{{count}}시간 전\",\n    \"daysAgo\": \"{{count}}일 전\",\n    \"newFile\": \"새 파일 (Cmd+N)\",\n    \"newFolder\": \"새 폴더 (Cmd+Shift+N)\",\n    \"refresh\": \"새로고침\",\n    \"collapseAll\": \"모두 접기\",\n    \"context\": {\n      \"rename\": \"이름 변경\",\n      \"delete\": \"삭제\",\n      \"copyPath\": \"경로 복사\",\n      \"download\": \"다운로드\",\n      \"newFile\": \"새 파일\",\n      \"newFolder\": \"새 폴더\",\n      \"refresh\": \"새로 고침\",\n      \"menuLabel\": \"파일 컨텍스트 메뉴\",\n      \"loading\": \"로딩 중...\"\n    }\n  },\n  \"projectWizard\": {\n    \"title\": \"새 프로젝트 생성\",\n    \"steps\": {\n      \"type\": \"유형\",\n      \"configure\": \"설정\",\n      \"confirm\": \"확인\"\n    },\n    \"step1\": {\n      \"question\": \"이미 워크스페이스가 있으신가요, 아니면 새로 생성하시겠습니까?\",\n      \"existing\": {\n        \"title\": \"기존 워크스페이스\",\n        \"description\": \"서버에 이미 워크스페이스가 있고 프로젝트 목록에 추가만 하면 됩니다\"\n      },\n      \"new\": {\n        \"title\": \"새 워크스페이스\",\n        \"description\": \"새 워크스페이스를 생성하고, 선택적으로 GitHub 저장소에서 clone합니다\"\n      }\n    },\n    \"step2\": {\n      \"existingPath\": \"워크스페이스 경로\",\n      \"newPath\": \"워크스페이스 경로\",\n      \"existingPlaceholder\": \"/path/to/existing/workspace\",\n      \"newPlaceholder\": \"/path/to/new/workspace\",\n      \"existingHelp\": \"기존 워크스페이스 디렉토리의 전체 경로\",\n      \"newHelp\": \"워크스페이스 디렉토리의 전체 경로\",\n      \"githubUrl\": \"GitHub URL (선택사항)\",\n      \"githubPlaceholder\": \"https://github.com/username/repository\",\n      \"githubHelp\": \"선택사항: 저장소를 clone하려면 GitHub URL을 입력하세요\",\n      \"githubAuth\": \"GitHub 인증 (선택사항)\",\n      \"githubAuthHelp\": \"비공개 저장소에만 필요합니다. 공개 저장소는 인증 없이 clone할 수 있습니다.\",\n      \"loadingTokens\": \"저장된 토큰 로딩 중...\",\n      \"storedToken\": \"저장된 토큰\",\n      \"newToken\": \"새 토큰\",\n      \"nonePublic\": \"없음 (공개)\",\n      \"selectToken\": \"토큰 선택\",\n      \"selectTokenPlaceholder\": \"-- 토큰 선택 --\",\n      \"tokenPlaceholder\": \"ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\n      \"tokenHelp\": \"이 토큰은 이 작업에만 사용됩니다\",\n      \"publicRepoInfo\": \"공개 저장소는 인증이 필요하지 않습니다. 공개 저장소를 clone하는 경우 토큰을 생략할 수 있습니다.\",\n      \"noTokensHelp\": \"저장된 토큰이 없습니다. 설정 → API Keys에서 토큰을 추가하면 재사용이 편리합니다.\",\n      \"optionalTokenPublic\": \"GitHub 토큰 (공개 저장소는 선택사항)\",\n      \"tokenPublicPlaceholder\": \"ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (공개 저장소는 비워두세요)\"\n    },\n    \"step3\": {\n      \"reviewConfig\": \"설정 검토\",\n      \"workspaceType\": \"워크스페이스 유형:\",\n      \"existingWorkspace\": \"기존 워크스페이스\",\n      \"newWorkspace\": \"새 워크스페이스\",\n      \"path\": \"경로:\",\n      \"cloneFrom\": \"Clone 소스:\",\n      \"authentication\": \"인증:\",\n      \"usingStoredToken\": \"저장된 토큰 사용:\",\n      \"usingProvidedToken\": \"제공된 토큰 사용\",\n      \"noAuthentication\": \"인증 없음\",\n      \"sshKey\": \"SSH 키\",\n      \"existingInfo\": \"워크스페이스가 프로젝트 목록에 추가되며 Claude/Cursor 세션에서 사용할 수 있습니다.\",\n      \"newWithClone\": \"이 폴더에 저장소가 clone됩니다.\",\n      \"newEmpty\": \"워크스페이스가 프로젝트 목록에 추가되며 Claude/Cursor 세션에서 사용할 수 있습니다.\",\n      \"cloningRepository\": \"저장소 clone 중...\"\n    },\n    \"buttons\": {\n      \"cancel\": \"취소\",\n      \"back\": \"뒤로\",\n      \"next\": \"다음\",\n      \"createProject\": \"프로젝트 생성\",\n      \"creating\": \"생성 중...\",\n      \"cloning\": \"Clone 중...\"\n    },\n    \"errors\": {\n      \"selectType\": \"기존 워크스페이스를 사용할지 새로 생성할지 선택해주세요\",\n      \"providePath\": \"워크스페이스 경로를 입력해주세요\",\n      \"failedToCreate\": \"워크스페이스 생성 실패\",\n      \"failedToCreateFolder\": \"폴더 생성 실패\"\n    }\n  },\n  \"notifications\": {\n    \"genericTool\": \"도구\",\n    \"codes\": {\n      \"generic\": {\n        \"info\": {\n          \"title\": \"알림\"\n        }\n      },\n      \"permission\": {\n        \"required\": {\n          \"title\": \"작업 필요\",\n          \"body\": \"{{toolName}} 에 대한 결정을 기다리고 있습니다.\"\n        }\n      },\n      \"run\": {\n        \"stopped\": {\n          \"title\": \"실행이 중지되었습니다\",\n          \"body\": \"사유: {{reason}}\"\n        },\n        \"failed\": {\n          \"title\": \"실행 실패\"\n        }\n      },\n      \"agent\": {\n        \"notification\": {\n          \"title\": \"에이전트 알림\"\n        }\n      }\n    }\n  },\n  \"versionUpdate\": {\n    \"title\": \"업데이트 가능\",\n    \"newVersionReady\": \"새 버전이 준비되었습니다\",\n    \"currentVersion\": \"현재 버전\",\n    \"latestVersion\": \"최신 버전\",\n    \"whatsNew\": \"새로운 기능:\",\n    \"viewFullRelease\": \"전체 릴리스 보기\",\n    \"updateProgress\": \"업데이트 진행 상황:\",\n    \"manualUpgrade\": \"수동 업그레이드:\",\n    \"npmUpgradeCommand\": \"npm install -g @siteboon/claude-code-ui@latest\",\n    \"manualUpgradeHint\": \"또는 \\\"지금 업데이트\\\"를 클릭하여 자동으로 업데이트합니다.\",\n    \"updateCompleted\": \"업데이트가 완료되었습니다!\",\n    \"restartServer\": \"변경사항을 적용하려면 서버를 재시작하세요.\",\n    \"updateFailed\": \"업데이트 실패\",\n    \"buttons\": {\n      \"close\": \"닫기\",\n      \"later\": \"나중에\",\n      \"copyCommand\": \"명령어 복사\",\n      \"updateNow\": \"지금 업데이트\",\n      \"updating\": \"업데이트 중...\"\n    },\n    \"ariaLabels\": {\n      \"closeModal\": \"버전 업그레이드 모달 닫기\",\n      \"showSidebar\": \"사이드바 표시\",\n      \"settings\": \"설정\",\n      \"updateAvailable\": \"업데이트 가능\",\n      \"closeSidebar\": \"사이드바 닫기\"\n    }\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/ko/settings.json",
    "content": "{\n  \"title\": \"설정\",\n  \"tabs\": {\n    \"account\": \"계정\",\n    \"permissions\": \"권한\",\n    \"mcpServers\": \"MCP 서버\",\n    \"appearance\": \"외관\"\n  },\n  \"account\": {\n    \"title\": \"계정\",\n    \"language\": \"언어\",\n    \"languageLabel\": \"표시 언어\",\n    \"languageDescription\": \"인터페이스에 사용할 언어를 선택하세요\",\n    \"username\": \"사용자명\",\n    \"email\": \"이메일\",\n    \"profile\": \"프로필\",\n    \"changePassword\": \"비밀번호 변경\"\n  },\n  \"mcp\": {\n    \"title\": \"MCP 서버\",\n    \"addServer\": \"서버 추가\",\n    \"editServer\": \"서버 편집\",\n    \"deleteServer\": \"서버 삭제\",\n    \"serverName\": \"서버 이름\",\n    \"serverType\": \"서버 유형\",\n    \"config\": \"설정\",\n    \"testConnection\": \"연결 테스트\",\n    \"status\": \"상태\",\n    \"connected\": \"연결됨\",\n    \"disconnected\": \"연결 끊김\",\n    \"scope\": {\n      \"label\": \"범위\",\n      \"user\": \"사용자\",\n      \"project\": \"프로젝트\"\n    }\n  },\n  \"appearance\": {\n    \"title\": \"외관\",\n    \"theme\": \"테마\",\n    \"codeEditor\": \"코드 에디터\",\n    \"editorTheme\": \"에디터 테마\",\n    \"wordWrap\": \"자동 줄바꿈\",\n    \"showMinimap\": \"미니맵 표시\",\n    \"lineNumbers\": \"줄 번호\",\n    \"fontSize\": \"글꼴 크기\"\n  },\n  \"actions\": {\n    \"saveChanges\": \"변경사항 저장\",\n    \"resetToDefaults\": \"기본값으로 초기화\",\n    \"cancelChanges\": \"변경 취소\"\n  },\n  \"quickSettings\": {\n    \"title\": \"빠른 설정\",\n    \"sections\": {\n      \"appearance\": \"외관\",\n      \"toolDisplay\": \"도구 표시\",\n      \"viewOptions\": \"보기 옵션\",\n      \"inputSettings\": \"입력 설정\",\n      \"whisperDictation\": \"Whisper 음성 인식\"\n    },\n    \"darkMode\": \"다크 모드\",\n    \"autoExpandTools\": \"도구 자동 펼치기\",\n    \"showRawParameters\": \"Raw 파라미터 표시\",\n    \"showThinking\": \"생각 과정 표시\",\n    \"autoScrollToBottom\": \"자동 스크롤\",\n    \"sendByCtrlEnter\": \"Ctrl+Enter로 전송\",\n    \"sendByCtrlEnterDescription\": \"활성화하면 Enter 대신 Ctrl+Enter로 메시지를 전송합니다. IME 사용자가 실수로 전송하는 것을 방지하는 데 유용합니다.\",\n    \"dragHandle\": {\n      \"dragging\": \"드래그 핸들\",\n      \"closePanel\": \"설정 패널 닫기\",\n      \"openPanel\": \"설정 패널 열기\",\n      \"draggingStatus\": \"드래그 중...\",\n      \"toggleAndMove\": \"클릭하여 토글, 드래그하여 이동\"\n    },\n    \"whisper\": {\n      \"modes\": {\n        \"default\": \"기본 모드\",\n        \"defaultDescription\": \"음성을 그대로 텍스트로 변환\",\n        \"prompt\": \"프롬프트 향상\",\n        \"promptDescription\": \"거친 아이디어를 명확하고 상세한 AI 프롬프트로 변환\",\n        \"vibe\": \"Vibe 모드\",\n        \"vibeDescription\": \"아이디어를 상세한 에이전트 지침 형식으로 변환\"\n      }\n    }\n  },\n  \"terminalShortcuts\": {\n    \"title\": \"터미널 단축키\",\n    \"sectionKeys\": \"키\",\n    \"sectionNavigation\": \"탐색\",\n    \"escape\": \"Escape\",\n    \"tab\": \"Tab\",\n    \"shiftTab\": \"Shift+Tab\",\n    \"arrowUp\": \"위쪽 화살표\",\n    \"arrowDown\": \"아래쪽 화살표\",\n    \"scrollDown\": \"아래로 스크롤\",\n    \"handle\": {\n      \"closePanel\": \"단축키 패널 닫기\",\n      \"openPanel\": \"단축키 패널 열기\"\n    }\n  },\n  \"mainTabs\": {\n    \"label\": \"설정\",\n    \"agents\": \"에이전트\",\n    \"appearance\": \"외관\",\n    \"git\": \"Git\",\n    \"apiTokens\": \"API & 토큰\",\n    \"tasks\": \"작업\",\n    \"notifications\": \"알림\",\n    \"plugins\": \"플러그인\"\n\n  },\n  \"notifications\": {\n    \"title\": \"알림\",\n    \"description\": \"수신할 알림 이벤트를 설정합니다.\",\n    \"webPush\": {\n      \"title\": \"웹 푸시 알림\",\n      \"enable\": \"푸시 알림 활성화\",\n      \"disable\": \"푸시 알림 비활성화\",\n      \"enabled\": \"푸시 알림이 활성화되었습니다\",\n      \"loading\": \"업데이트 중...\",\n      \"unsupported\": \"이 브라우저에서는 푸시 알림이 지원되지 않습니다.\",\n      \"denied\": \"푸시 알림이 차단되었습니다. 브라우저 설정에서 허용해 주세요.\"\n    },\n    \"events\": {\n      \"title\": \"이벤트 유형\",\n      \"actionRequired\": \"작업 필요\",\n      \"stop\": \"실행 중지\",\n      \"error\": \"실행 실패\"\n    }\n  },\n  \"appearanceSettings\": {\n    \"darkMode\": {\n      \"label\": \"다크 모드\",\n      \"description\": \"라이트/다크 테마 전환\"\n    },\n    \"projectSorting\": {\n      \"label\": \"프로젝트 정렬\",\n      \"description\": \"사이드바에서 프로젝트 정렬 방식\",\n      \"alphabetical\": \"알파벳순\",\n      \"recentActivity\": \"최근 활동순\"\n    },\n    \"codeEditor\": {\n      \"title\": \"코드 에디터\",\n      \"theme\": {\n        \"label\": \"에디터 테마\",\n        \"description\": \"코드 에디터의 기본 테마\"\n      },\n      \"wordWrap\": {\n        \"label\": \"자동 줄바꿈\",\n        \"description\": \"에디터에서 기본적으로 자동 줄바꿈 활성화\"\n      },\n      \"showMinimap\": {\n        \"label\": \"미니맵 표시\",\n        \"description\": \"Diff 보기에서 쉬운 탐색을 위한 미니맵 표시\"\n      },\n      \"lineNumbers\": {\n        \"label\": \"줄 번호 표시\",\n        \"description\": \"에디터에 줄 번호 표시\"\n      },\n      \"fontSize\": {\n        \"label\": \"글꼴 크기\",\n        \"description\": \"에디터 글꼴 크기 (픽셀)\"\n      }\n    }\n  },\n  \"mcpForm\": {\n    \"title\": {\n      \"add\": \"MCP 서버 추가\",\n      \"edit\": \"MCP 서버 편집\"\n    },\n    \"importMode\": {\n      \"form\": \"폼 입력\",\n      \"json\": \"JSON 가져오기\"\n    },\n    \"scope\": {\n      \"label\": \"범위\",\n      \"userGlobal\": \"사용자 (전역)\",\n      \"projectLocal\": \"프로젝트 (로컬)\",\n      \"userDescription\": \"사용자 범위: 모든 프로젝트에서 사용 가능\",\n      \"projectDescription\": \"로컬 범위: 선택한 프로젝트에서만 사용 가능\",\n      \"cannotChange\": \"기존 서버를 편집할 때는 범위를 변경할 수 없습니다\"\n    },\n    \"fields\": {\n      \"serverName\": \"서버 이름\",\n      \"transportType\": \"전송 유형\",\n      \"command\": \"명령어\",\n      \"arguments\": \"인수 (한 줄에 하나씩)\",\n      \"jsonConfig\": \"JSON 설정\",\n      \"url\": \"URL\",\n      \"envVars\": \"환경 변수 (KEY=value, 한 줄에 하나씩)\",\n      \"headers\": \"헤더 (KEY=value, 한 줄에 하나씩)\",\n      \"selectProject\": \"프로젝트 선택...\"\n    },\n    \"placeholders\": {\n      \"serverName\": \"my-server\"\n    },\n    \"validation\": {\n      \"missingType\": \"필수 항목 누락: type\",\n      \"stdioRequiresCommand\": \"stdio 유형은 command 필드가 필요합니다\",\n      \"httpRequiresUrl\": \"{{type}} 유형은 url 필드가 필요합니다\",\n      \"invalidJson\": \"잘못된 JSON 형식\",\n      \"jsonHelp\": \"MCP 서버 설정을 JSON 형식으로 붙여넣으세요. 예시:\",\n      \"jsonExampleStdio\": \"• stdio: {\\\"type\\\":\\\"stdio\\\",\\\"command\\\":\\\"npx\\\",\\\"args\\\":[\\\"@upstash/context7-mcp\\\"]}\",\n      \"jsonExampleHttp\": \"• http/sse: {\\\"type\\\":\\\"http\\\",\\\"url\\\":\\\"https://api.example.com/mcp\\\"}\"\n    },\n    \"configDetails\": \"설정 상세 ({{configFile}}에서)\",\n    \"projectPath\": \"경로: {{path}}\",\n    \"actions\": {\n      \"cancel\": \"취소\",\n      \"saving\": \"저장 중...\",\n      \"addServer\": \"서버 추가\",\n      \"updateServer\": \"서버 업데이트\"\n    }\n  },\n  \"saveStatus\": {\n    \"success\": \"설정이 저장되었습니다!\",\n    \"error\": \"설정 저장 실패\",\n    \"saving\": \"저장 중...\"\n  },\n  \"footerActions\": {\n    \"save\": \"설정 저장\",\n    \"cancel\": \"취소\"\n  },\n  \"git\": {\n    \"title\": \"Git 설정\",\n    \"description\": \"커밋을 위한 Git 정보를 설정합니다. 이 설정은 git config --global로 전역 적용됩니다\",\n    \"name\": {\n      \"label\": \"Git 이름\",\n      \"help\": \"Git 커밋에 사용될 이름\"\n    },\n    \"email\": {\n      \"label\": \"Git 이메일\",\n      \"help\": \"Git 커밋에 사용될 이메일\"\n    },\n    \"actions\": {\n      \"save\": \"설정 저장\",\n      \"saving\": \"저장 중...\"\n    },\n    \"status\": {\n      \"success\": \"저장 완료\"\n    }\n  },\n  \"apiKeys\": {\n    \"title\": \"API 키\",\n    \"description\": \"다른 애플리케이션에서 외부 API에 접근하기 위한 API 키를 생성합니다.\",\n    \"newKey\": {\n      \"alertTitle\": \"⚠️ API 키를 저장하세요\",\n      \"alertMessage\": \"이 키는 지금만 볼 수 있습니다. 안전하게 보관하세요.\",\n      \"iveSavedIt\": \"저장했습니다\"\n    },\n    \"form\": {\n      \"placeholder\": \"API 키 이름 (예: Production Server)\",\n      \"createButton\": \"생성\",\n      \"cancelButton\": \"취소\"\n    },\n    \"newButton\": \"새 API 키\",\n    \"empty\": \"생성된 API 키가 없습니다.\",\n    \"list\": {\n      \"created\": \"생성일:\",\n      \"lastUsed\": \"마지막 사용:\"\n    },\n    \"confirmDelete\": \"이 API 키를 삭제하시겠습니까?\",\n    \"status\": {\n      \"active\": \"활성\",\n      \"inactive\": \"비활성\"\n    },\n    \"github\": {\n      \"title\": \"GitHub 토큰\",\n      \"description\": \"외부 API를 통해 비공개 저장소를 clone하기 위한 GitHub Personal Access Token을 추가합니다.\",\n      \"descriptionAlt\": \"비공개 저장소를 clone하기 위한 GitHub Personal Access Token을 추가합니다. 저장하지 않고 API 요청에 직접 토큰을 전달할 수도 있습니다.\",\n      \"addButton\": \"토큰 추가\",\n      \"form\": {\n        \"namePlaceholder\": \"토큰 이름 (예: Personal Repos)\",\n        \"tokenPlaceholder\": \"GitHub Personal Access Token (ghp_...)\",\n        \"descriptionPlaceholder\": \"설명 (선택사항)\",\n        \"addButton\": \"토큰 추가\",\n        \"cancelButton\": \"취소\",\n        \"howToCreate\": \"GitHub Personal Access Token 생성 방법 →\"\n      },\n      \"empty\": \"추가된 GitHub 토큰이 없습니다.\",\n      \"added\": \"추가일:\",\n      \"confirmDelete\": \"이 GitHub 토큰을 삭제하시겠습니까?\"\n    },\n    \"apiDocsLink\": \"API 문서\",\n    \"documentation\": {\n      \"title\": \"외부 API 문서\",\n      \"description\": \"외부 API를 사용하여 애플리케이션에서 Claude/Cursor 세션을 트리거하는 방법을 알아보세요.\",\n      \"viewLink\": \"API 문서 보기 →\"\n    },\n    \"loading\": \"로딩 중...\",\n    \"version\": {\n      \"updateAvailable\": \"업데이트 가능: v{{version}}\"\n    }\n  },\n  \"tasks\": {\n    \"checking\": \"TaskMaster 설치 확인 중...\",\n    \"notInstalled\": {\n      \"title\": \"TaskMaster AI CLI가 설치되지 않았습니다\",\n      \"description\": \"작업 관리 기능을 사용하려면 TaskMaster CLI가 필요합니다. 시작하려면 설치하세요:\",\n      \"installCommand\": \"npm install -g task-master-ai\",\n      \"viewOnGitHub\": \"GitHub에서 보기\",\n      \"afterInstallation\": \"설치 후:\",\n      \"steps\": {\n        \"restart\": \"이 애플리케이션을 재시작하세요\",\n        \"autoAvailable\": \"TaskMaster 기능이 자동으로 활성화됩니다\",\n        \"initCommand\": \"프로젝트 디렉토리에서 task-master init을 사용하세요\"\n      }\n    },\n    \"settings\": {\n      \"enableLabel\": \"TaskMaster 통합 활성화\",\n      \"enableDescription\": \"인터페이스 전체에 TaskMaster 작업, 배너 및 사이드바 표시\"\n    }\n  },\n  \"agents\": {\n    \"authStatus\": {\n      \"checking\": \"확인 중...\",\n      \"connected\": \"연결됨\",\n      \"notConnected\": \"연결되지 않음\",\n      \"disconnected\": \"연결 끊김\",\n      \"checkingAuth\": \"인증 상태 확인 중...\",\n      \"loggedInAs\": \"{{email}}(으)로 로그인됨\",\n      \"authenticatedUser\": \"인증된 사용자\"\n    },\n    \"account\": {\n      \"claude\": {\n        \"description\": \"Anthropic Claude AI 어시스턴트\"\n      },\n      \"cursor\": {\n        \"description\": \"Cursor AI 기반 코드 에디터\"\n      },\n      \"codex\": {\n        \"description\": \"OpenAI Codex AI 어시스턴트\"\n      },\n      \"gemini\": {\n        \"description\": \"Google Gemini AI 어시스턴트\"\n      }\n    },\n    \"connectionStatus\": \"연결 상태\",\n    \"login\": {\n      \"title\": \"로그인\",\n      \"reAuthenticate\": \"재인증\",\n      \"description\": \"AI 기능을 활성화하려면 {{agent}} 계정에 로그인하세요\",\n      \"reAuthDescription\": \"다른 계정으로 로그인하거나 자격 증명을 새로고침하세요\",\n      \"button\": \"로그인\",\n      \"reLoginButton\": \"재로그인\"\n    },\n    \"error\": \"오류: {{error}}\"\n  },\n  \"permissions\": {\n    \"title\": \"권한 설정\",\n    \"skipPermissions\": {\n      \"label\": \"권한 확인 건너뛰기 (주의해서 사용)\",\n      \"claudeDescription\": \"--dangerously-skip-permissions 플래그와 동일\",\n      \"cursorDescription\": \"Cursor CLI의 -f 플래그와 동일\"\n    },\n    \"allowedTools\": {\n      \"title\": \"허용된 도구\",\n      \"description\": \"권한 확인 없이 자동으로 허용되는 도구\",\n      \"placeholder\": \"예: \\\"Bash(git log:*)\\\" 또는 \\\"Write\\\"\",\n      \"quickAdd\": \"자주 쓰는 도구 빠른 추가:\",\n      \"empty\": \"설정된 허용 도구 없음\"\n    },\n    \"blockedTools\": {\n      \"title\": \"차단된 도구\",\n      \"description\": \"권한 확인 없이 자동으로 차단되는 도구\",\n      \"placeholder\": \"예: \\\"Bash(rm:*)\\\"\",\n      \"empty\": \"설정된 차단 도구 없음\"\n    },\n    \"allowedCommands\": {\n      \"title\": \"허용된 Shell 명령어\",\n      \"description\": \"권한 확인 없이 자동으로 허용되는 Shell 명령어\",\n      \"placeholder\": \"예: \\\"Shell(ls)\\\" 또는 \\\"Shell(git status)\\\"\",\n      \"quickAdd\": \"자주 쓰는 명령어 빠른 추가:\",\n      \"empty\": \"설정된 허용 명령어 없음\"\n    },\n    \"blockedCommands\": {\n      \"title\": \"차단된 Shell 명령어\",\n      \"description\": \"자동으로 차단되는 Shell 명령어\",\n      \"placeholder\": \"예: \\\"Shell(rm -rf)\\\" 또는 \\\"Shell(sudo)\\\"\",\n      \"empty\": \"설정된 차단 명령어 없음\"\n    },\n    \"toolExamples\": {\n      \"title\": \"도구 패턴 예시:\",\n      \"bashGitLog\": \"- 모든 git log 명령어 허용\",\n      \"bashGitDiff\": \"- 모든 git diff 명령어 허용\",\n      \"write\": \"- 모든 Write 도구 사용 허용\",\n      \"bashRm\": \"- 모든 rm 명령어 차단 (위험)\"\n    },\n    \"shellExamples\": {\n      \"title\": \"Shell 명령어 예시:\",\n      \"ls\": \"- ls 명령어 허용\",\n      \"gitStatus\": \"- git status 허용\",\n      \"npmInstall\": \"- npm install 허용\",\n      \"rmRf\": \"- 재귀 삭제 차단\"\n    },\n    \"codex\": {\n      \"permissionMode\": \"권한 모드\",\n      \"description\": \"Codex가 파일 수정 및 명령어 실행을 처리하는 방식을 제어합니다\",\n      \"modes\": {\n        \"default\": {\n          \"title\": \"기본\",\n          \"description\": \"신뢰할 수 있는 명령어(ls, cat, grep, git status 등)만 자동 실행됩니다. 다른 명령어는 건너뜁니다. 워크스페이스에 쓰기 가능.\"\n        },\n        \"acceptEdits\": {\n          \"title\": \"편집 허용\",\n          \"description\": \"워크스페이스 내에서 모든 명령어가 자동 실행됩니다. 샌드박스 내 완전 자동 모드.\"\n        },\n        \"bypassPermissions\": {\n          \"title\": \"권한 우회\",\n          \"description\": \"제한 없는 전체 시스템 접근. 모든 명령어가 전체 디스크 및 네트워크 접근 권한으로 자동 실행됩니다. 주의해서 사용하세요.\"\n        }\n      },\n      \"technicalDetails\": \"기술 상세\",\n      \"technicalInfo\": {\n        \"default\": \"sandboxMode=workspace-write, approvalPolicy=untrusted. 신뢰할 수 있는 명령어: cat, cd, grep, head, ls, pwd, tail, git status/log/diff/show, find(-exec 제외) 등.\",\n        \"acceptEdits\": \"sandboxMode=workspace-write, approvalPolicy=never. 프로젝트 디렉토리 내에서 모든 명령어 자동 실행.\",\n        \"bypassPermissions\": \"sandboxMode=danger-full-access, approvalPolicy=never. 전체 시스템 접근, 신뢰할 수 있는 환경에서만 사용하세요.\",\n        \"overrideNote\": \"채팅 인터페이스의 모드 버튼을 사용하여 세션별로 재정의할 수 있습니다.\"\n      }\n    },\n    \"actions\": {\n      \"add\": \"추가\"\n    }\n  },\n  \"mcpServers\": {\n    \"title\": \"MCP 서버\",\n    \"description\": {\n      \"claude\": \"Model Context Protocol 서버는 Claude에 추가 도구와 데이터 소스를 제공합니다\",\n      \"cursor\": \"Model Context Protocol 서버는 Cursor에 추가 도구와 데이터 소스를 제공합니다\",\n      \"codex\": \"Model Context Protocol 서버는 Codex에 추가 도구와 데이터 소스를 제공합니다\"\n    },\n    \"addButton\": \"MCP 서버 추가\",\n    \"empty\": \"설정된 MCP 서버 없음\",\n    \"serverType\": \"유형\",\n    \"scope\": {\n      \"local\": \"로컬\",\n      \"user\": \"사용자\"\n    },\n    \"config\": {\n      \"command\": \"명령어\",\n      \"url\": \"URL\",\n      \"args\": \"인수\",\n      \"environment\": \"환경\"\n    },\n    \"tools\": {\n      \"title\": \"도구\",\n      \"count\": \"({{count}}):\",\n      \"more\": \"+{{count}}개 더\"\n    },\n    \"actions\": {\n      \"edit\": \"서버 편집\",\n      \"delete\": \"서버 삭제\"\n    },\n    \"help\": {\n      \"title\": \"Codex MCP 정보\",\n      \"description\": \"Codex는 stdio 기반 MCP 서버를 지원합니다. 추가 도구와 리소스로 Codex의 기능을 확장하는 서버를 추가할 수 있습니다.\"\n    }\n  },\n  \"pluginSettings\": {\n    \"title\": \"플러그인\",\n    \"description\": \"커스텀 플러그인으로 인터페이스를 확장하세요. git에서 설치하거나 ~/.claude-code-ui/plugins/ 폴더에 직접 추가할 수 있습니다.\",\n    \"installPlaceholder\": \"https://github.com/user/my-plugin\",\n    \"installButton\": \"설치\",\n    \"installing\": \"설치 중…\",\n    \"securityWarning\": \"소스 코드를 검토했거나 신뢰할 수 있는 작성자의 플러그인만 설치하세요.\",\n    \"scanningPlugins\": \"플러그인 스캔 중…\",\n    \"noPluginsInstalled\": \"설치된 플러그인이 없습니다\",\n    \"pullLatest\": \"git에서 최신 버전 가져오기\",\n    \"noGitRemote\": \"git 리모트가 없음 — 업데이트 불가\",\n    \"uninstallPlugin\": \"플러그인 삭제\",\n    \"confirmUninstall\": \"다시 클릭하여 확인\",\n    \"confirmUninstallMessage\": \"{{name}} 플러그인을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.\",\n    \"cancel\": \"취소\",\n    \"remove\": \"삭제\",\n    \"updateFailed\": \"업데이트 실패\",\n    \"installFailed\": \"설치 실패\",\n    \"uninstallFailed\": \"삭제 실패\",\n    \"toggleFailed\": \"토글 실패\",\n    \"buildYourOwn\": \"나만의 플러그인 만들기\",\n    \"starter\": \"스타터\",\n    \"docs\": \"문서\",\n    \"starterPlugin\": {\n      \"name\": \"프로젝트 통계\",\n      \"badge\": \"스타터\",\n      \"description\": \"프로젝트의 파일 수, 코드 라인 수, 파일 유형별 분석 및 최근 활동을 확인합니다.\",\n      \"install\": \"설치\"\n    },\n    \"morePlugins\": \"더 보기\",\n    \"enable\": \"활성화\",\n    \"disable\": \"비활성화\",\n    \"installAriaLabel\": \"플러그인 git 저장소 URL\",\n    \"tab\": \"탭\",\n    \"runningStatus\": \"실행 중\"\n  }\n}"
  },
  {
    "path": "src/i18n/locales/ko/sidebar.json",
    "content": "{\n  \"projects\": {\n    \"title\": \"프로젝트\",\n    \"newProject\": \"새 프로젝트\",\n    \"deleteProject\": \"프로젝트 삭제\",\n    \"renameProject\": \"프로젝트 이름 변경\",\n    \"noProjects\": \"프로젝트가 없습니다\",\n    \"loadingProjects\": \"프로젝트 로딩 중...\",\n    \"searchPlaceholder\": \"프로젝트 검색...\",\n    \"projectNamePlaceholder\": \"프로젝트 이름\",\n    \"starred\": \"즐겨찾기\",\n    \"all\": \"전체\",\n    \"untitledSession\": \"제목 없는 세션\",\n    \"newSession\": \"새 세션\",\n    \"codexSession\": \"Codex 세션\",\n    \"fetchingProjects\": \"Claude 프로젝트와 세션을 가져오는 중\",\n    \"projects\": \"프로젝트\",\n    \"noMatchingProjects\": \"일치하는 프로젝트 없음\",\n    \"tryDifferentSearch\": \"검색어를 변경해보세요\",\n    \"runClaudeCli\": \"프로젝트 디렉토리에서 Claude CLI를 실행하여 시작하세요\"\n  },\n  \"app\": {\n    \"title\": \"Claude Code UI\",\n    \"subtitle\": \"AI 코딩 어시스턴트 UI\"\n  },\n  \"sessions\": {\n    \"title\": \"세션\",\n    \"newSession\": \"새 세션\",\n    \"deleteSession\": \"세션 삭제\",\n    \"renameSession\": \"세션 이름 변경\",\n    \"noSessions\": \"세션이 없습니다\",\n    \"loadingSessions\": \"세션 로딩 중...\",\n    \"unnamed\": \"이름 없음\",\n    \"loading\": \"로딩 중...\",\n    \"showMore\": \"더 많은 세션 보기\"\n  },\n  \"tooltips\": {\n    \"viewEnvironments\": \"환경 보기\",\n    \"hideSidebar\": \"사이드바 숨기기\",\n    \"createProject\": \"새 프로젝트 생성\",\n    \"refresh\": \"프로젝트 및 세션 새로고침 (Ctrl+R)\",\n    \"renameProject\": \"프로젝트 이름 변경 (F2)\",\n    \"deleteProject\": \"빈 프로젝트 삭제 (Delete)\",\n    \"addToFavorites\": \"즐겨찾기에 추가\",\n    \"removeFromFavorites\": \"즐겨찾기에서 제거\",\n    \"editSessionName\": \"세션 이름 직접 편집\",\n    \"deleteSession\": \"이 세션 영구 삭제\",\n    \"save\": \"저장\",\n    \"cancel\": \"취소\"\n  },\n  \"navigation\": {\n    \"chat\": \"채팅\",\n    \"files\": \"파일\",\n    \"git\": \"Git\",\n    \"terminal\": \"터미널\",\n    \"tasks\": \"작업\"\n  },\n  \"actions\": {\n    \"refresh\": \"새로고침\",\n    \"settings\": \"설정\",\n    \"collapseAll\": \"모두 접기\",\n    \"expandAll\": \"모두 펼치기\",\n    \"cancel\": \"취소\",\n    \"save\": \"저장\",\n    \"delete\": \"삭제\",\n    \"rename\": \"이름 변경\",\n    \"joinCommunity\": \"커뮤니티 참여\"\n  },\n  \"status\": {\n    \"active\": \"활성\",\n    \"inactive\": \"비활성\",\n    \"thinking\": \"생각 중...\",\n    \"error\": \"오류\",\n    \"aborted\": \"중단됨\",\n    \"unknown\": \"알 수 없음\"\n  },\n  \"time\": {\n    \"justNow\": \"방금 전\",\n    \"oneMinuteAgo\": \"1분 전\",\n    \"minutesAgo\": \"{{count}}분 전\",\n    \"oneHourAgo\": \"1시간 전\",\n    \"hoursAgo\": \"{{count}}시간 전\",\n    \"oneDayAgo\": \"1일 전\",\n    \"daysAgo\": \"{{count}}일 전\"\n  },\n  \"messages\": {\n    \"deleteConfirm\": \"정말 삭제하시겠습니까?\",\n    \"renameSuccess\": \"이름이 변경되었습니다\",\n    \"deleteSuccess\": \"삭제되었습니다\",\n    \"errorOccurred\": \"오류가 발생했습니다\",\n    \"deleteSessionConfirm\": \"이 세션을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.\",\n    \"deleteProjectConfirm\": \"이 빈 프로젝트를 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.\",\n    \"enterProjectPath\": \"프로젝트 경로를 입력해주세요\",\n    \"deleteSessionFailed\": \"세션 삭제 실패. 다시 시도해주세요.\",\n    \"deleteSessionError\": \"세션 삭제 오류. 다시 시도해주세요.\",\n    \"renameSessionFailed\": \"세션 이름 변경 실패. 다시 시도해주세요.\",\n    \"renameSessionError\": \"세션 이름 변경 오류. 다시 시도해주세요.\",\n    \"deleteProjectFailed\": \"프로젝트 삭제 실패. 다시 시도해주세요.\",\n    \"deleteProjectError\": \"프로젝트 삭제 오류. 다시 시도해주세요.\",\n    \"createProjectFailed\": \"프로젝트 생성 실패. 다시 시도해주세요.\",\n    \"createProjectError\": \"프로젝트 생성 오류. 다시 시도해주세요.\"\n  },\n  \"version\": {\n    \"updateAvailable\": \"업데이트 가능\"\n  },\n  \"deleteConfirmation\": {\n    \"deleteProject\": \"프로젝트 삭제\",\n    \"deleteSession\": \"세션 삭제\",\n    \"confirmDelete\": \"정말 삭제하시겠습니까\",\n    \"sessionCount_one\": \"이 프로젝트에는 {{count}}개의 대화가 있습니다.\",\n    \"sessionCount_other\": \"이 프로젝트에는 {{count}}개의 대화가 있습니다.\",\n    \"allConversationsDeleted\": \"모든 대화가 영구적으로 삭제됩니다.\",\n    \"cannotUndo\": \"이 작업은 취소할 수 없습니다.\"\n  }\n}"
  },
  {
    "path": "src/i18n/locales/ru/auth.json",
    "content": "{\n  \"login\": {\n    \"title\": \"Добро пожаловать\",\n    \"description\": \"Войдите в свой аккаунт Claude Code UI\",\n    \"username\": \"Имя пользователя\",\n    \"password\": \"Пароль\",\n    \"submit\": \"Войти\",\n    \"loading\": \"Вход...\",\n    \"errors\": {\n      \"invalidCredentials\": \"Неверное имя пользователя или пароль\",\n      \"requiredFields\": \"Пожалуйста, заполните все поля\",\n      \"networkError\": \"Ошибка сети. Попробуйте снова.\"\n    },\n    \"placeholders\": {\n      \"username\": \"Введите имя пользователя\",\n      \"password\": \"Введите пароль\"\n    }\n  },\n  \"register\": {\n    \"title\": \"Создать аккаунт\",\n    \"username\": \"Имя пользователя\",\n    \"password\": \"Пароль\",\n    \"confirmPassword\": \"Подтвердите пароль\",\n    \"submit\": \"Создать аккаунт\",\n    \"loading\": \"Создание аккаунта...\",\n    \"errors\": {\n      \"passwordMismatch\": \"Пароли не совпадают\",\n      \"usernameTaken\": \"Имя пользователя уже занято\",\n      \"weakPassword\": \"Пароль слишком слабый\"\n    }\n  },\n  \"logout\": {\n    \"title\": \"Выйти\",\n    \"confirm\": \"Вы уверены, что хотите выйти?\",\n    \"button\": \"Выйти\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/ru/chat.json",
    "content": "{\n  \"codeBlock\": {\n    \"copy\": \"Копировать\",\n    \"copied\": \"Скопировано\",\n    \"copyCode\": \"Копировать код\"\n  },\n  \"copyMessage\": {\n    \"copy\": \"Копировать сообщение\",\n    \"copied\": \"Сообщение скопировано\",\n    \"selectFormat\": \"Выбрать формат копирования\",\n    \"copyAsMarkdown\": \"Копировать как Markdown\",\n    \"copyAsText\": \"Копировать как текст\"\n  },\n  \"messageTypes\": {\n    \"user\": \"П\",\n    \"error\": \"Ошибка\",\n    \"tool\": \"Инструмент\",\n    \"claude\": \"Claude\",\n    \"cursor\": \"Cursor\",\n    \"codex\": \"Codex\",\n    \"gemini\": \"Gemini\"\n  },\n  \"tools\": {\n    \"settings\": \"Настройки инструмента\",\n    \"error\": \"Ошибка инструмента\",\n    \"result\": \"Результат инструмента\",\n    \"viewParams\": \"Просмотр входных параметров\",\n    \"viewRawParams\": \"Просмотр сырых параметров\",\n    \"viewDiff\": \"Просмотр различий редактирования для\",\n    \"creatingFile\": \"Создание нового файла:\",\n    \"updatingTodo\": \"Обновление списка задач\",\n    \"read\": \"Чтение\",\n    \"readFile\": \"Чтение файла\",\n    \"updateTodo\": \"Обновить список задач\",\n    \"readTodo\": \"Прочитать список задач\",\n    \"searchResults\": \"результаты\"\n  },\n  \"search\": {\n    \"found\": \"Найдено {{count}} {{type}}\",\n    \"file\": \"файл\",\n    \"files\": \"файлов\",\n    \"pattern\": \"шаблон:\",\n    \"in\": \"в:\"\n  },\n  \"fileOperations\": {\n    \"updated\": \"Файл успешно обновлен\",\n    \"created\": \"Файл успешно создан\",\n    \"written\": \"Файл успешно записан\",\n    \"diff\": \"Различия\",\n    \"newFile\": \"Новый файл\",\n    \"viewContent\": \"Просмотр содержимого файла\",\n    \"viewFullOutput\": \"Просмотр полного вывода ({{count}} символов)\",\n    \"contentDisplayed\": \"Содержимое файла отображено в представлении различий выше\"\n  },\n  \"interactive\": {\n    \"title\": \"Интерактивный запрос\",\n    \"waiting\": \"Ожидание вашего ответа в CLI\",\n    \"instruction\": \"Пожалуйста, выберите опцию в терминале, где запущен Claude.\",\n    \"selectedOption\": \"✓ Claude выбрал опцию {{number}}\",\n    \"instructionDetail\": \"В CLI вы бы выбрали эту опцию интерактивно, используя клавиши со стрелками или введя номер.\"\n  },\n  \"thinking\": {\n    \"title\": \"Думаю...\",\n    \"emoji\": \"💭 Думаю...\"\n  },\n  \"json\": {\n    \"response\": \"JSON ответ\"\n  },\n  \"permissions\": {\n    \"grant\": \"Предоставить разрешение для {{tool}}\",\n    \"added\": \"Разрешение добавлено\",\n    \"addTo\": \"Добавляет {{entry}} в разрешенные инструменты.\",\n    \"retry\": \"Разрешение сохранено. Повторите запрос для использования инструмента.\",\n    \"error\": \"Не удалось обновить разрешения. Попробуйте снова.\",\n    \"openSettings\": \"Открыть настройки\"\n  },\n  \"todo\": {\n    \"updated\": \"Список задач успешно обновлен\",\n    \"current\": \"Текущий список задач\"\n  },\n  \"plan\": {\n    \"viewPlan\": \"📋 Просмотр плана реализации\",\n    \"title\": \"План реализации\"\n  },\n  \"usageLimit\": {\n    \"resetAt\": \"Достигнут лимит использования Claude. Ваш лимит будет сброшен в **{{time}} {{timezone}}** - {{date}}\"\n  },\n  \"codex\": {\n    \"permissionMode\": \"Режим разрешений\",\n    \"modes\": {\n      \"default\": \"Режим по умолчанию\",\n      \"acceptEdits\": \"Принимать правки\",\n      \"bypassPermissions\": \"Обход разрешений\",\n      \"plan\": \"Режим планирования\"\n    },\n    \"descriptions\": {\n      \"default\": \"Только доверенные команды (ls, cat, grep, git status и т.д.) выполняются автоматически. Другие команды пропускаются. Может записывать в рабочее пространство.\",\n      \"acceptEdits\": \"Все команды выполняются автоматически в рабочем пространстве. Полный автоматический режим с изолированным выполнением.\",\n      \"bypassPermissions\": \"Полный системный доступ без ограничений. Все команды выполняются автоматически с полным доступом к диску и сети. Используйте с осторожностью.\",\n      \"plan\": \"Режим планирования - команды не выполняются\"\n    },\n    \"technicalDetails\": \"Технические детали\"\n  },\n  \"gemini\": {\n    \"permissionMode\": \"Режим разрешений Gemini\",\n    \"description\": \"Управление тем, как Gemini CLI обрабатывает подтверждения операций.\",\n    \"modes\": {\n      \"default\": {\n        \"title\": \"Стандартный (запрашивать подтверждение)\",\n        \"description\": \"Gemini будет запрашивать подтверждение перед выполнением команд, записью файлов и получением веб-ресурсов.\"\n      },\n      \"autoEdit\": {\n        \"title\": \"Автоматическое редактирование (пропускать подтверждения файлов)\",\n        \"description\": \"Gemini будет автоматически подтверждать редактирование файлов и веб-запросы, но все еще будет запрашивать подтверждение для команд оболочки.\"\n      },\n      \"yolo\": {\n        \"title\": \"YOLO (обход всех разрешений)\",\n        \"description\": \"Gemini будет выполнять все операции без запроса подтверждения. Будьте осторожны.\"\n      }\n    }\n  },\n  \"input\": {\n    \"placeholder\": \"Введите / для команд, @ для файлов, или спросите {{provider}} что угодно...\",\n    \"placeholderDefault\": \"Введите ваше сообщение...\",\n    \"disabled\": \"Ввод отключен\",\n    \"attachFiles\": \"Прикрепить файлы\",\n    \"attachImages\": \"Прикрепить изображения\",\n    \"send\": \"Отправить\",\n    \"stop\": \"Остановить\",\n    \"hintText\": {\n      \"ctrlEnter\": \"Ctrl+Enter для отправки • Shift+Enter для новой строки • Tab для смены режима • / для команд\",\n      \"enter\": \"Enter для отправки • Shift+Enter для новой строки • Tab для смены режима • / для команд\"\n    },\n    \"clickToChangeMode\": \"Нажмите для смены режима разрешений (или нажмите Tab в поле ввода)\",\n    \"showAllCommands\": \"Показать все команды\",\n    \"clearInput\": \"Очистить ввод\",\n    \"scrollToBottom\": \"Прокрутить вниз\"\n  },\n  \"thinkingMode\": {\n    \"selector\": {\n      \"title\": \"Режим размышления\",\n      \"description\": \"Расширенное размышление дает Claude больше времени для оценки альтернатив\",\n      \"active\": \"Активен\",\n      \"tip\": \"Более высокие режимы размышления занимают больше времени, но обеспечивают более тщательный анализ\"\n    },\n    \"modes\": {\n      \"none\": {\n        \"name\": \"Стандартный\",\n        \"description\": \"Обычный ответ Claude\",\n        \"prefix\": \"\"\n      },\n      \"think\": {\n        \"name\": \"Думать\",\n        \"description\": \"Базовое расширенное размышление\",\n        \"prefix\": \"думать\"\n      },\n      \"thinkHard\": {\n        \"name\": \"Думать усердно\",\n        \"description\": \"Более тщательная оценка\",\n        \"prefix\": \"думать усердно\"\n      },\n      \"thinkHarder\": {\n        \"name\": \"Думать еще усерднее\",\n        \"description\": \"Глубокий анализ с альтернативами\",\n        \"prefix\": \"думать еще усерднее\"\n      },\n      \"ultrathink\": {\n        \"name\": \"Ультра-размышление\",\n        \"description\": \"Максимальный бюджет размышления\",\n        \"prefix\": \"ультра-размышление\"\n      }\n    },\n    \"buttonTitle\": \"Режим размышления: {{mode}}\"\n  },\n  \"providerSelection\": {\n    \"title\": \"Выберите вашего AI-ассистента\",\n    \"description\": \"Выберите провайдера для начала нового разговора\",\n    \"selectModel\": \"Выбрать модель\",\n    \"providerInfo\": {\n      \"anthropic\": \"от Anthropic\",\n      \"openai\": \"от OpenAI\",\n      \"cursorEditor\": \"AI редактор кода\",\n      \"google\": \"от Google\"\n    },\n    \"readyPrompt\": {\n      \"claude\": \"Готов использовать Claude с {{model}}. Начните вводить сообщение ниже.\",\n      \"cursor\": \"Готов использовать Cursor с {{model}}. Начните вводить сообщение ниже.\",\n      \"codex\": \"Готов использовать Codex с {{model}}. Начните вводить сообщение ниже.\",\n      \"gemini\": \"Готов использовать Gemini с {{model}}. Начните вводить сообщение ниже.\",\n      \"default\": \"Выберите провайдера выше для начала\"\n    }\n  },\n  \"session\": {\n    \"continue\": {\n      \"title\": \"Продолжить разговор\",\n      \"description\": \"Задавайте вопросы о вашем коде, запрашивайте изменения или получайте помощь с задачами разработки\"\n    },\n    \"loading\": {\n      \"olderMessages\": \"Загрузка старых сообщений...\",\n      \"sessionMessages\": \"Загрузка сообщений сеанса...\"\n    },\n    \"messages\": {\n      \"showingOf\": \"Показано {{shown}} из {{total}} сообщений\",\n      \"scrollToLoad\": \"Прокрутите вверх для загрузки еще\",\n      \"showingLast\": \"Показаны последние {{count}} сообщений (всего {{total}})\",\n      \"loadEarlier\": \"Загрузить более ранние сообщения\",\n      \"loadAll\": \"Загрузить все сообщения\",\n      \"loadingAll\": \"Загрузка всех сообщений...\",\n      \"allLoaded\": \"Все сообщения загружены\",\n      \"perfWarning\": \"Все сообщения загружены — прокрутка может быть медленнее. Нажмите \\\"Прокрутить вниз\\\" для восстановления производительности.\"\n    }\n  },\n  \"shell\": {\n    \"selectProject\": {\n      \"title\": \"Выберите проект\",\n      \"description\": \"Выберите проект для открытия интерактивной оболочки в этом каталоге\"\n    },\n    \"status\": {\n      \"newSession\": \"Новый сеанс\",\n      \"initializing\": \"Инициализация...\",\n      \"restarting\": \"Перезапуск...\"\n    },\n    \"actions\": {\n      \"disconnect\": \"Отключиться\",\n      \"disconnectTitle\": \"Отключиться от оболочки\",\n      \"restart\": \"Перезапустить\",\n      \"restartTitle\": \"Перезапустить оболочку (сначала отключитесь)\",\n      \"connect\": \"Продолжить в оболочке\",\n      \"connectTitle\": \"Подключиться к оболочке\"\n    },\n    \"loading\": \"Загрузка терминала...\",\n    \"connecting\": \"Подключение к оболочке...\",\n    \"startSession\": \"Начать новый сеанс Claude\",\n    \"resumeSession\": \"Возобновить сеанс: {{displayName}}...\",\n    \"runCommand\": \"Выполнить {{command}} в {{projectName}}\",\n    \"startCli\": \"Запуск Claude CLI в {{projectName}}\",\n    \"defaultCommand\": \"команда\"\n  },\n  \"claudeStatus\": {\n    \"actions\": {\n      \"thinking\": \"Думает\",\n      \"processing\": \"Обрабатывает\",\n      \"analyzing\": \"Анализирует\",\n      \"working\": \"Работает\",\n      \"computing\": \"Вычисляет\",\n      \"reasoning\": \"Рассуждает\"\n    },\n    \"state\": {\n      \"live\": \"В сети\",\n      \"paused\": \"Приостановлен\"\n    },\n    \"elapsed\": {\n      \"seconds\": \"{{count}}с\",\n      \"minutesSeconds\": \"{{minutes}}м {{seconds}}с\",\n      \"label\": \"Прошло {{time}}\",\n      \"startingNow\": \"Начинается сейчас\"\n    },\n    \"controls\": {\n      \"stopGeneration\": \"Остановить генерацию\",\n      \"pressEscToStop\": \"Нажмите Esc в любое время для остановки\"\n    },\n    \"providers\": {\n      \"assistant\": \"Ассистент\"\n    }\n  },\n  \"projectSelection\": {\n    \"startChatWithProvider\": \"Выберите проект для начала чата с {{provider}}\"\n  },\n  \"tasks\": {\n    \"nextTaskPrompt\": \"Начать следующую задачу\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/ru/codeEditor.json",
    "content": "{\n  \"toolbar\": {\n    \"changes\": \"изменения\",\n    \"previousChange\": \"Предыдущее изменение\",\n    \"nextChange\": \"Следующее изменение\",\n    \"hideDiff\": \"Скрыть подсветку различий\",\n    \"showDiff\": \"Показать подсветку различий\",\n    \"settings\": \"Настройки редактора\",\n    \"collapse\": \"Свернуть редактор\",\n    \"expand\": \"Развернуть редактор на всю ширину\"\n  },\n  \"loading\": \"Загрузка {{fileName}}...\",\n  \"header\": {\n    \"showingChanges\": \"Показаны изменения\"\n  },\n  \"actions\": {\n    \"download\": \"Скачать файл\",\n    \"save\": \"Сохранить\",\n    \"saving\": \"Сохранение...\",\n    \"saved\": \"Сохранено!\",\n    \"exitFullscreen\": \"Выйти из полноэкранного режима\",\n    \"fullscreen\": \"Полноэкранный режим\",\n    \"close\": \"Закрыть\",\n    \"previewMarkdown\": \"Предпросмотр markdown\",\n    \"editMarkdown\": \"Редактировать markdown\"\n  },\n  \"footer\": {\n    \"lines\": \"Строк:\",\n    \"characters\": \"Символов:\",\n    \"shortcuts\": \"Нажмите Ctrl+S для сохранения • Esc для закрытия\"\n  },\n  \"binaryFile\": {\n    \"title\": \"Бинарный файл\",\n    \"message\": \"Файл \\\"{{fileName}}\\\" не может быть отображен в текстовом редакторе, так как это бинарный файл.\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/ru/common.json",
    "content": "{\n  \"buttons\": {\n    \"save\": \"Сохранить\",\n    \"cancel\": \"Отмена\",\n    \"delete\": \"Удалить\",\n    \"create\": \"Создать\",\n    \"edit\": \"Редактировать\",\n    \"close\": \"Закрыть\",\n    \"confirm\": \"Подтвердить\",\n    \"submit\": \"Отправить\",\n    \"retry\": \"Повторить\",\n    \"refresh\": \"Обновить\",\n    \"search\": \"Поиск\",\n    \"clear\": \"Очистить\",\n    \"copy\": \"Копировать\",\n    \"download\": \"Скачать\",\n    \"upload\": \"Загрузить\",\n    \"browse\": \"Обзор\"\n  },\n  \"tabs\": {\n    \"chat\": \"Чат\",\n    \"shell\": \"Терминал\",\n    \"files\": \"Файлы\",\n    \"git\": \"Система контроля версий\",\n    \"tasks\": \"Задачи\"\n  },\n  \"status\": {\n    \"loading\": \"Загрузка...\",\n    \"success\": \"Успешно\",\n    \"error\": \"Ошибка\",\n    \"failed\": \"Не удалось\",\n    \"pending\": \"Ожидание\",\n    \"completed\": \"Завершено\",\n    \"inProgress\": \"В процессе\"\n  },\n  \"messages\": {\n    \"savedSuccessfully\": \"Успешно сохранено\",\n    \"deletedSuccessfully\": \"Успешно удалено\",\n    \"updatedSuccessfully\": \"Успешно обновлено\",\n    \"operationFailed\": \"Операция не удалась\",\n    \"networkError\": \"Ошибка сети. Проверьте подключение.\",\n    \"unauthorized\": \"Не авторизован. Пожалуйста, войдите.\",\n    \"notFound\": \"Не найдено\",\n    \"invalidInput\": \"Неверный ввод\",\n    \"requiredField\": \"Это поле обязательно\",\n    \"unknownError\": \"Произошла неизвестная ошибка\"\n  },\n  \"navigation\": {\n    \"settings\": \"Настройки\",\n    \"home\": \"Главная\",\n    \"back\": \"Назад\",\n    \"next\": \"Далее\",\n    \"previous\": \"Предыдущий\",\n    \"logout\": \"Выйти\"\n  },\n  \"common\": {\n    \"language\": \"Язык\",\n    \"theme\": \"Тема\",\n    \"darkMode\": \"Темная тема\",\n    \"lightMode\": \"Светлая тема\",\n    \"name\": \"Имя\",\n    \"description\": \"Описание\",\n    \"enabled\": \"Включено\",\n    \"disabled\": \"Отключено\",\n    \"optional\": \"Необязательно\",\n    \"version\": \"Версия\",\n    \"select\": \"Выбрать\",\n    \"selectAll\": \"Выбрать все\",\n    \"deselectAll\": \"Снять выделение\"\n  },\n  \"time\": {\n    \"justNow\": \"Только что\",\n    \"minutesAgo\": \"{{count}} мин. назад\",\n    \"hoursAgo\": \"{{count}} ч. назад\",\n    \"daysAgo\": \"{{count}} дн. назад\",\n    \"yesterday\": \"Вчера\"\n  },\n  \"fileOperations\": {\n    \"newFile\": \"Новый файл\",\n    \"newFolder\": \"Новая папка\",\n    \"rename\": \"Переименовать\",\n    \"move\": \"Переместить\",\n    \"copyPath\": \"Копировать путь\",\n    \"openInEditor\": \"Открыть в редакторе\"\n  },\n  \"mainContent\": {\n    \"loading\": \"Загрузка Claude Code UI\",\n    \"settingUpWorkspace\": \"Настройка рабочего пространства...\",\n    \"chooseProject\": \"Выберите проект\",\n    \"selectProjectDescription\": \"Выберите проект на боковой панели, чтобы начать работу с Claude. Каждый проект содержит ваши сеансы чата и историю файлов.\",\n    \"tip\": \"Совет\",\n    \"createProjectMobile\": \"Нажмите кнопку меню выше для доступа к проектам\",\n    \"createProjectDesktop\": \"Создайте новый проект, нажав на значок папки на боковой панели\",\n    \"newSession\": \"Новый сеанс\",\n    \"untitledSession\": \"Безымянный сеанс\",\n    \"projectFiles\": \"Файлы проекта\"\n  },\n  \"fileTree\": {\n    \"loading\": \"Загрузка файлов...\",\n    \"files\": \"Файлы\",\n    \"simpleView\": \"Простой вид\",\n    \"compactView\": \"Компактный вид\",\n    \"detailedView\": \"Подробный вид\",\n    \"searchPlaceholder\": \"Поиск файлов и папок...\",\n    \"clearSearch\": \"Очистить поиск\",\n    \"name\": \"Имя\",\n    \"size\": \"Размер\",\n    \"modified\": \"Изменено\",\n    \"permissions\": \"Права доступа\",\n    \"noFilesFound\": \"Файлы не найдены\",\n    \"checkProjectPath\": \"Проверьте доступность пути к проекту\",\n    \"noMatchesFound\": \"Совпадений не найдено\",\n    \"tryDifferentSearch\": \"Попробуйте другой поисковый запрос или очистите поиск\",\n    \"justNow\": \"только что\",\n    \"minAgo\": \"{{count}} мин. назад\",\n    \"hoursAgo\": \"{{count}} ч. назад\",\n    \"daysAgo\": \"{{count}} дн. назад\",\n    \"newFile\": \"Новый файл (Cmd+N)\",\n    \"newFolder\": \"Новая папка (Cmd+Shift+N)\",\n    \"refresh\": \"Обновить\",\n    \"collapseAll\": \"Свернуть все\",\n    \"context\": {\n      \"rename\": \"Переименовать\",\n      \"delete\": \"Удалить\",\n      \"copyPath\": \"Копировать путь\",\n      \"download\": \"Скачать\",\n      \"newFile\": \"Новый файл\",\n      \"newFolder\": \"Новая папка\",\n      \"refresh\": \"Обновить\",\n      \"menuLabel\": \"Контекстное меню файла\",\n      \"loading\": \"Загрузка...\"\n    }\n  },\n  \"projectWizard\": {\n    \"title\": \"Создать новый проект\",\n    \"steps\": {\n      \"type\": \"Тип\",\n      \"configure\": \"Настройка\",\n      \"confirm\": \"Подтверждение\"\n    },\n    \"step1\": {\n      \"question\": \"У вас уже есть рабочее пространство или вы хотите создать новое?\",\n      \"existing\": {\n        \"title\": \"Существующее рабочее пространство\",\n        \"description\": \"У меня уже есть рабочее пространство на сервере, нужно только добавить его в список проектов\"\n      },\n      \"new\": {\n        \"title\": \"Новое рабочее пространство\",\n        \"description\": \"Создать новое рабочее пространство, опционально клонировать из репозитория GitHub\"\n      }\n    },\n    \"step2\": {\n      \"existingPath\": \"Путь к рабочему пространству\",\n      \"newPath\": \"Путь к рабочему пространству\",\n      \"existingPlaceholder\": \"/путь/к/существующему/пространству\",\n      \"newPlaceholder\": \"/путь/к/новому/пространству\",\n      \"existingHelp\": \"Полный путь к каталогу вашего рабочего пространства\",\n      \"newHelp\": \"Полный путь к каталогу вашего рабочего пространства\",\n      \"githubUrl\": \"URL GitHub (необязательно)\",\n      \"githubPlaceholder\": \"https://github.com/username/repository\",\n      \"githubHelp\": \"Необязательно: укажите URL GitHub для клонирования репозитория\",\n      \"githubAuth\": \"Аутентификация GitHub (необязательно)\",\n      \"githubAuthHelp\": \"Требуется только для приватных репозиториев. Публичные репозитории можно клонировать без аутентификации.\",\n      \"loadingTokens\": \"Загрузка сохраненных токенов...\",\n      \"storedToken\": \"Сохраненный токен\",\n      \"newToken\": \"Новый токен\",\n      \"nonePublic\": \"Нет (публичный)\",\n      \"selectToken\": \"Выбрать токен\",\n      \"selectTokenPlaceholder\": \"-- Выберите токен --\",\n      \"tokenPlaceholder\": \"ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\n      \"tokenHelp\": \"Этот токен будет использован только для этой операции\",\n      \"publicRepoInfo\": \"Публичные репозитории не требуют аутентификации. Вы можете пропустить токен при клонировании публичного репозитория.\",\n      \"noTokensHelp\": \"Нет доступных сохраненных токенов. Вы можете добавить токены в Настройки → API ключи для удобного повторного использования.\",\n      \"optionalTokenPublic\": \"Токен GitHub (необязательно для публичных репозиториев)\",\n      \"tokenPublicPlaceholder\": \"ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (оставьте пустым для публичных репозиториев)\"\n    },\n    \"step3\": {\n      \"reviewConfig\": \"Проверьте вашу конфигурацию\",\n      \"workspaceType\": \"Тип рабочего пространства:\",\n      \"existingWorkspace\": \"Существующее рабочее пространство\",\n      \"newWorkspace\": \"Новое рабочее пространство\",\n      \"path\": \"Путь:\",\n      \"cloneFrom\": \"Клонировать из:\",\n      \"authentication\": \"Аутентификация:\",\n      \"usingStoredToken\": \"Использование сохраненного токена:\",\n      \"usingProvidedToken\": \"Использование предоставленного токена\",\n      \"noAuthentication\": \"Без аутентификации\",\n      \"sshKey\": \"SSH ключ\",\n      \"existingInfo\": \"Рабочее пространство будет добавлено в список проектов и будет доступно для сеансов Claude/Cursor.\",\n      \"newWithClone\": \"Репозиторий будет клонирован в эту папку.\",\n      \"newEmpty\": \"Рабочее пространство будет добавлено в список проектов и будет доступно для сеансов Claude/Cursor.\",\n      \"cloningRepository\": \"Клонирование репозитория...\"\n    },\n    \"buttons\": {\n      \"cancel\": \"Отмена\",\n      \"back\": \"Назад\",\n      \"next\": \"Далее\",\n      \"createProject\": \"Создать проект\",\n      \"creating\": \"Создание...\",\n      \"cloning\": \"Клонирование...\"\n    },\n    \"errors\": {\n      \"selectType\": \"Пожалуйста, выберите, есть ли у вас существующее рабочее пространство или вы хотите создать новое\",\n      \"providePath\": \"Пожалуйста, укажите путь к рабочему пространству\",\n      \"failedToCreate\": \"Не удалось создать рабочее пространство\",\n      \"failedToCreateFolder\": \"Не удалось создать папку\"\n    }\n  },\n  \"versionUpdate\": {\n    \"title\": \"Доступно обновление\",\n    \"newVersionReady\": \"Новая версия готова\",\n    \"currentVersion\": \"Текущая версия\",\n    \"latestVersion\": \"Последняя версия\",\n    \"whatsNew\": \"Что нового:\",\n    \"viewFullRelease\": \"Посмотреть полный релиз\",\n    \"updateProgress\": \"Прогресс обновления:\",\n    \"manualUpgrade\": \"Ручное обновление:\",\n    \"npmUpgradeCommand\": \"npm install -g @siteboon/claude-code-ui@latest\",\n    \"manualUpgradeHint\": \"Или нажмите \\\"Обновить сейчас\\\" для автоматического обновления.\",\n    \"updateCompleted\": \"Обновление успешно завершено!\",\n    \"restartServer\": \"Пожалуйста, перезапустите сервер для применения изменений.\",\n    \"updateFailed\": \"Обновление не удалось\",\n    \"buttons\": {\n      \"close\": \"Закрыть\",\n      \"later\": \"Позже\",\n      \"copyCommand\": \"Копировать команду\",\n      \"updateNow\": \"Обновить сейчас\",\n      \"updating\": \"Обновление...\"\n    },\n    \"ariaLabels\": {\n      \"closeModal\": \"Закрыть модальное окно обновления версии\",\n      \"showSidebar\": \"Показать боковую панель\",\n      \"settings\": \"Настройки\",\n      \"updateAvailable\": \"Доступно обновление\",\n      \"closeSidebar\": \"Закрыть боковую панель\"\n    }\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/ru/settings.json",
    "content": "{\n  \"title\": \"Настройки\",\n  \"tabs\": {\n    \"account\": \"Аккаунт\",\n    \"permissions\": \"Разрешения\",\n    \"mcpServers\": \"MCP серверы\",\n    \"appearance\": \"Внешний вид\"\n  },\n  \"account\": {\n    \"title\": \"Аккаунт\",\n    \"language\": \"Язык\",\n    \"languageLabel\": \"Язык интерфейса\",\n    \"languageDescription\": \"Выберите предпочитаемый язык для интерфейса\",\n    \"username\": \"Имя пользователя\",\n    \"email\": \"Email\",\n    \"profile\": \"Профиль\",\n    \"changePassword\": \"Изменить пароль\"\n  },\n  \"mcp\": {\n    \"title\": \"MCP серверы\",\n    \"addServer\": \"Добавить сервер\",\n    \"editServer\": \"Редактировать сервер\",\n    \"deleteServer\": \"Удалить сервер\",\n    \"serverName\": \"Имя сервера\",\n    \"serverType\": \"Тип сервера\",\n    \"config\": \"Конфигурация\",\n    \"testConnection\": \"Проверить подключение\",\n    \"status\": \"Статус\",\n    \"connected\": \"Подключен\",\n    \"disconnected\": \"Отключен\",\n    \"scope\": {\n      \"label\": \"Область\",\n      \"user\": \"Пользователь\",\n      \"project\": \"Проект\"\n    }\n  },\n  \"appearance\": {\n    \"title\": \"Внешний вид\",\n    \"theme\": \"Тема\",\n    \"codeEditor\": \"Редактор кода\",\n    \"editorTheme\": \"Тема редактора\",\n    \"wordWrap\": \"Перенос слов\",\n    \"showMinimap\": \"Показать миникарту\",\n    \"lineNumbers\": \"Номера строк\",\n    \"fontSize\": \"Размер шрифта\"\n  },\n  \"actions\": {\n    \"saveChanges\": \"Сохранить изменения\",\n    \"resetToDefaults\": \"Сбросить к значениям по умолчанию\",\n    \"cancelChanges\": \"Отменить изменения\"\n  },\n  \"quickSettings\": {\n    \"title\": \"Быстрые настройки\",\n    \"sections\": {\n      \"appearance\": \"Внешний вид\",\n      \"toolDisplay\": \"Отображение инструментов\",\n      \"viewOptions\": \"Параметры просмотра\",\n      \"inputSettings\": \"Настройки ввода\",\n      \"whisperDictation\": \"Диктовка Whisper\"\n    },\n    \"darkMode\": \"Темная тема\",\n    \"autoExpandTools\": \"Автоматически разворачивать инструменты\",\n    \"showRawParameters\": \"Показывать сырые параметры\",\n    \"showThinking\": \"Показывать размышления\",\n    \"autoScrollToBottom\": \"Автопрокрутка вниз\",\n    \"sendByCtrlEnter\": \"Отправка по Ctrl+Enter\",\n    \"sendByCtrlEnterDescription\": \"Когда включено, нажатие Ctrl+Enter будет отправлять сообщение вместо просто Enter. Это полезно для пользователей IME, чтобы избежать случайной отправки.\",\n    \"dragHandle\": {\n      \"dragging\": \"Перетаскивание ручки\",\n      \"closePanel\": \"Закрыть панель настроек\",\n      \"openPanel\": \"Открыть панель настроек\",\n      \"draggingStatus\": \"Перетаскивание...\",\n      \"toggleAndMove\": \"Нажмите для переключения, перетащите для перемещения\"\n    },\n    \"whisper\": {\n      \"modes\": {\n        \"default\": \"Режим по умолчанию\",\n        \"defaultDescription\": \"Прямая транскрипция вашей речи\",\n        \"prompt\": \"Улучшение запроса\",\n        \"promptDescription\": \"Преобразование грубых идей в четкие, детальные AI-запросы\",\n        \"vibe\": \"Режим Vibe\",\n        \"vibeDescription\": \"Форматирование идей как четких инструкций агента с деталями\"\n      }\n    }\n  },\n  \"terminalShortcuts\": {\n    \"title\": \"Горячие клавиши терминала\",\n    \"sectionKeys\": \"Клавиши\",\n    \"sectionNavigation\": \"Навигация\",\n    \"escape\": \"Escape\",\n    \"tab\": \"Tab\",\n    \"shiftTab\": \"Shift+Tab\",\n    \"arrowUp\": \"Стрелка вверх\",\n    \"arrowDown\": \"Стрелка вниз\",\n    \"scrollDown\": \"Прокрутка вниз\",\n    \"handle\": {\n      \"closePanel\": \"Закрыть панель горячих клавиш\",\n      \"openPanel\": \"Открыть панель горячих клавиш\"\n    }\n  },\n  \"mainTabs\": {\n    \"label\": \"Настройки\",\n    \"agents\": \"Агенты\",\n    \"appearance\": \"Внешний вид\",\n    \"git\": \"Git\",\n    \"apiTokens\": \"API и токены\",\n    \"tasks\": \"Задачи\",\n    \"plugins\": \"Плагины\"\n  },\n  \"appearanceSettings\": {\n    \"darkMode\": {\n      \"label\": \"Темная тема\",\n      \"description\": \"Переключение между светлой и темной темами\"\n    },\n    \"projectSorting\": {\n      \"label\": \"Сортировка проектов\",\n      \"description\": \"Как проекты упорядочены на боковой панели\",\n      \"alphabetical\": \"По алфавиту\",\n      \"recentActivity\": \"По недавней активности\"\n    },\n    \"codeEditor\": {\n      \"title\": \"Редактор кода\",\n      \"theme\": {\n        \"label\": \"Тема редактора\",\n        \"description\": \"Тема по умолчанию для редактора кода\"\n      },\n      \"wordWrap\": {\n        \"label\": \"Перенос слов\",\n        \"description\": \"Включить перенос слов по умолчанию в редакторе\"\n      },\n      \"showMinimap\": {\n        \"label\": \"Показать миникарту\",\n        \"description\": \"Отображать миникарту для упрощения навигации в представлении различий\"\n      },\n      \"lineNumbers\": {\n        \"label\": \"Показать номера строк\",\n        \"description\": \"Отображать номера строк в редакторе\"\n      },\n      \"fontSize\": {\n        \"label\": \"Размер шрифта\",\n        \"description\": \"Размер шрифта редактора в пикселях\"\n      }\n    }\n  },\n  \"mcpForm\": {\n    \"title\": {\n      \"add\": \"Добавить MCP сервер\",\n      \"edit\": \"Редактировать MCP сервер\"\n    },\n    \"importMode\": {\n      \"form\": \"Ввод формы\",\n      \"json\": \"Импорт JSON\"\n    },\n    \"scope\": {\n      \"label\": \"Область\",\n      \"userGlobal\": \"Пользователь (глобально)\",\n      \"projectLocal\": \"Проект (локально)\",\n      \"userDescription\": \"Область пользователя: доступно во всех проектах на вашей машине\",\n      \"projectDescription\": \"Локальная область: доступно только в выбранном проекте\",\n      \"cannotChange\": \"Область не может быть изменена при редактировании существующего сервера\"\n    },\n    \"fields\": {\n      \"serverName\": \"Имя сервера\",\n      \"transportType\": \"Тип транспорта\",\n      \"command\": \"Команда\",\n      \"arguments\": \"Аргументы (по одному на строку)\",\n      \"jsonConfig\": \"JSON конфигурация\",\n      \"url\": \"URL\",\n      \"envVars\": \"Переменные окружения (КЛЮЧ=значение, по одной на строку)\",\n      \"headers\": \"Заголовки (КЛЮЧ=значение, по одному на строку)\",\n      \"selectProject\": \"Выберите проект...\"\n    },\n    \"placeholders\": {\n      \"serverName\": \"мой-сервер\"\n    },\n    \"validation\": {\n      \"missingType\": \"Отсутствует обязательное поле: type\",\n      \"stdioRequiresCommand\": \"тип stdio требует поле command\",\n      \"httpRequiresUrl\": \"тип {{type}} требует поле url\",\n      \"invalidJson\": \"Неверный формат JSON\",\n      \"jsonHelp\": \"Вставьте конфигурацию вашего MCP сервера в формате JSON. Примеры форматов:\",\n      \"jsonExampleStdio\": \"• stdio: {\\\"type\\\":\\\"stdio\\\",\\\"command\\\":\\\"npx\\\",\\\"args\\\":[\\\"@upstash/context7-mcp\\\"]}\",\n      \"jsonExampleHttp\": \"• http/sse: {\\\"type\\\":\\\"http\\\",\\\"url\\\":\\\"https://api.example.com/mcp\\\"}\"\n    },\n    \"configDetails\": \"Детали конфигурации (из {{configFile}})\",\n    \"projectPath\": \"Путь: {{path}}\",\n    \"actions\": {\n      \"cancel\": \"Отмена\",\n      \"saving\": \"Сохранение...\",\n      \"addServer\": \"Добавить сервер\",\n      \"updateServer\": \"Обновить сервер\"\n    }\n  },\n  \"saveStatus\": {\n    \"success\": \"Настройки успешно сохранены!\",\n    \"error\": \"Не удалось сохранить настройки\",\n    \"saving\": \"Сохранение...\"\n  },\n  \"footerActions\": {\n    \"save\": \"Сохранить настройки\",\n    \"cancel\": \"Отмена\"\n  },\n  \"git\": {\n    \"title\": \"Конфигурация Git\",\n    \"description\": \"Настройте вашу git идентичность для коммитов. Эти настройки будут применены глобально через git config --global\",\n    \"name\": {\n      \"label\": \"Имя Git\",\n      \"help\": \"Ваше имя для git коммитов\"\n    },\n    \"email\": {\n      \"label\": \"Email Git\",\n      \"help\": \"Ваш email для git коммитов\"\n    },\n    \"actions\": {\n      \"save\": \"Сохранить конфигурацию\",\n      \"saving\": \"Сохранение...\"\n    },\n    \"status\": {\n      \"success\": \"Успешно сохранено\"\n    }\n  },\n  \"apiKeys\": {\n    \"title\": \"API ключи\",\n    \"description\": \"Генерируйте API ключи для доступа к внешнему API из других приложений.\",\n    \"newKey\": {\n      \"alertTitle\": \"⚠️ Сохраните ваш API ключ\",\n      \"alertMessage\": \"Это единственный раз, когда вы увидите этот ключ. Сохраните его в безопасном месте.\",\n      \"iveSavedIt\": \"Я сохранил его\"\n    },\n    \"form\": {\n      \"placeholder\": \"Имя API ключа (например, Продакшн сервер)\",\n      \"createButton\": \"Создать\",\n      \"cancelButton\": \"Отмена\"\n    },\n    \"newButton\": \"Новый API ключ\",\n    \"empty\": \"API ключи еще не созданы.\",\n    \"list\": {\n      \"created\": \"Создан:\",\n      \"lastUsed\": \"Последнее использование:\"\n    },\n    \"confirmDelete\": \"Вы уверены, что хотите удалить этот API ключ?\",\n    \"status\": {\n      \"active\": \"Активен\",\n      \"inactive\": \"Неактивен\"\n    },\n    \"github\": {\n      \"title\": \"GitHub токены\",\n      \"description\": \"Добавьте персональные токены доступа GitHub для клонирования приватных репозиториев через внешний API.\",\n      \"descriptionAlt\": \"Добавьте персональные токены доступа GitHub для клонирования приватных репозиториев. Вы также можете передавать токены напрямую в API запросах без их сохранения.\",\n      \"addButton\": \"Добавить токен\",\n      \"form\": {\n        \"namePlaceholder\": \"Имя токена (например, Личные репозитории)\",\n        \"tokenPlaceholder\": \"Персональный токен доступа GitHub (ghp_...)\",\n        \"descriptionPlaceholder\": \"Описание (необязательно)\",\n        \"addButton\": \"Добавить токен\",\n        \"cancelButton\": \"Отмена\",\n        \"howToCreate\": \"Как создать персональный токен доступа GitHub →\"\n      },\n      \"empty\": \"GitHub токены еще не добавлены.\",\n      \"added\": \"Добавлен:\",\n      \"confirmDelete\": \"Вы уверены, что хотите удалить этот GitHub токен?\"\n    },\n    \"apiDocsLink\": \"Документация API\",\n    \"documentation\": {\n      \"title\": \"Документация внешнего API\",\n      \"description\": \"Узнайте, как использовать внешний API для запуска сеансов Claude/Cursor из ваших приложений.\",\n      \"viewLink\": \"Просмотр документации API →\"\n    },\n    \"loading\": \"Загрузка...\",\n    \"version\": {\n      \"updateAvailable\": \"Доступно обновление: v{{version}}\"\n    }\n  },\n  \"tasks\": {\n    \"checking\": \"Проверка установки TaskMaster...\",\n    \"notInstalled\": {\n      \"title\": \"TaskMaster AI CLI не установлен\",\n      \"description\": \"TaskMaster CLI требуется для использования функций управления задачами. Установите его для начала работы:\",\n      \"installCommand\": \"npm install -g task-master-ai\",\n      \"viewOnGitHub\": \"Посмотреть на GitHub\",\n      \"afterInstallation\": \"После установки:\",\n      \"steps\": {\n        \"restart\": \"Перезапустите это приложение\",\n        \"autoAvailable\": \"Функции TaskMaster станут автоматически доступны\",\n        \"initCommand\": \"Используйте task-master init в каталоге вашего проекта\"\n      }\n    },\n    \"settings\": {\n      \"enableLabel\": \"Включить интеграцию TaskMaster\",\n      \"enableDescription\": \"Показывать задачи TaskMaster, баннеры и индикаторы боковой панели в интерфейсе\"\n    }\n  },\n  \"agents\": {\n    \"authStatus\": {\n      \"checking\": \"Проверка...\",\n      \"connected\": \"Подключен\",\n      \"notConnected\": \"Не подключен\",\n      \"disconnected\": \"Отключен\",\n      \"checkingAuth\": \"Проверка статуса аутентификации...\",\n      \"loggedInAs\": \"Вошли как {{email}}\",\n      \"authenticatedUser\": \"аутентифицированный пользователь\"\n    },\n    \"account\": {\n      \"claude\": {\n        \"description\": \"AI-ассистент Anthropic Claude\"\n      },\n      \"cursor\": {\n        \"description\": \"Редактор кода с AI Cursor\"\n      },\n      \"codex\": {\n        \"description\": \"AI-ассистент OpenAI Codex\"\n      },\n      \"gemini\": {\n        \"description\": \"AI-ассистент Google Gemini\"\n      }\n    },\n    \"connectionStatus\": \"Статус подключения\",\n    \"login\": {\n      \"title\": \"Вход\",\n      \"reAuthenticate\": \"Повторная аутентификация\",\n      \"description\": \"Войдите в ваш аккаунт {{agent}} для включения AI функций\",\n      \"reAuthDescription\": \"Войдите с другим аккаунтом или обновите учетные данные\",\n      \"button\": \"Войти\",\n      \"reLoginButton\": \"Войти снова\"\n    },\n    \"error\": \"Ошибка: {{error}}\"\n  },\n  \"permissions\": {\n    \"title\": \"Настройки разрешений\",\n    \"skipPermissions\": {\n      \"label\": \"Пропускать запросы разрешений (используйте с осторожностью)\",\n      \"claudeDescription\": \"Эквивалентно флагу --dangerously-skip-permissions\",\n      \"cursorDescription\": \"Эквивалентно флагу -f в Cursor CLI\"\n    },\n    \"allowedTools\": {\n      \"title\": \"Разрешенные инструменты\",\n      \"description\": \"Инструменты, которые автоматически разрешены без запроса разрешения\",\n      \"placeholder\": \"например, \\\"Bash(git log:*)\\\" или \\\"Write\\\"\",\n      \"quickAdd\": \"Быстро добавить общие инструменты:\",\n      \"empty\": \"Разрешенные инструменты не настроены\"\n    },\n    \"blockedTools\": {\n      \"title\": \"Заблокированные инструменты\",\n      \"description\": \"Инструменты, которые автоматически блокируются без запроса разрешения\",\n      \"placeholder\": \"например, \\\"Bash(rm:*)\\\"\",\n      \"empty\": \"Заблокированные инструменты не настроены\"\n    },\n    \"allowedCommands\": {\n      \"title\": \"Разрешенные команды оболочки\",\n      \"description\": \"Команды оболочки, которые автоматически разрешены без запроса\",\n      \"placeholder\": \"например, \\\"Shell(ls)\\\" или \\\"Shell(git status)\\\"\",\n      \"quickAdd\": \"Быстро добавить общие команды:\",\n      \"empty\": \"Разрешенные команды не настроены\"\n    },\n    \"blockedCommands\": {\n      \"title\": \"Заблокированные команды оболочки\",\n      \"description\": \"Команды оболочки, которые автоматически блокируются\",\n      \"placeholder\": \"например, \\\"Shell(rm -rf)\\\" или \\\"Shell(sudo)\\\"\",\n      \"empty\": \"Заблокированные команды не настроены\"\n    },\n    \"toolExamples\": {\n      \"title\": \"Примеры шаблонов инструментов:\",\n      \"bashGitLog\": \"- Разрешить все команды git log\",\n      \"bashGitDiff\": \"- Разрешить все команды git diff\",\n      \"write\": \"- Разрешить все использование инструмента Write\",\n      \"bashRm\": \"- Заблокировать все команды rm (опасно)\"\n    },\n    \"shellExamples\": {\n      \"title\": \"Примеры команд оболочки:\",\n      \"ls\": \"- Разрешить команду ls\",\n      \"gitStatus\": \"- Разрешить git status\",\n      \"npmInstall\": \"- Разрешить npm install\",\n      \"rmRf\": \"- Заблокировать рекурсивное удаление\"\n    },\n    \"codex\": {\n      \"permissionMode\": \"Режим разрешений\",\n      \"description\": \"Управляет тем, как Codex обрабатывает изменения файлов и выполнение команд\",\n      \"modes\": {\n        \"default\": {\n          \"title\": \"По умолчанию\",\n          \"description\": \"Только доверенные команды (ls, cat, grep, git status и т.д.) выполняются автоматически. Другие команды пропускаются. Может записывать в рабочее пространство.\"\n        },\n        \"acceptEdits\": {\n          \"title\": \"Принимать правки\",\n          \"description\": \"Все команды выполняются автоматически в рабочем пространстве. Полный автоматический режим с изолированным выполнением.\"\n        },\n        \"bypassPermissions\": {\n          \"title\": \"Обход разрешений\",\n          \"description\": \"Полный системный доступ без ограничений. Все команды выполняются автоматически с полным доступом к диску и сети. Используйте с осторожностью.\"\n        }\n      },\n      \"technicalDetails\": \"Технические детали\",\n      \"technicalInfo\": {\n        \"default\": \"sandboxMode=workspace-write, approvalPolicy=untrusted. Доверенные команды: cat, cd, grep, head, ls, pwd, tail, git status/log/diff/show, find (без -exec) и т.д.\",\n        \"acceptEdits\": \"sandboxMode=workspace-write, approvalPolicy=never. Все команды автоматически выполняются в каталоге проекта.\",\n        \"bypassPermissions\": \"sandboxMode=danger-full-access, approvalPolicy=never. Полный системный доступ, используйте только в доверенных средах.\",\n        \"overrideNote\": \"Вы можете переопределить это для каждого сеанса, используя кнопку режима в интерфейсе чата.\"\n      }\n    },\n    \"actions\": {\n      \"add\": \"Добавить\"\n    }\n  },\n  \"mcpServers\": {\n    \"title\": \"MCP серверы\",\n    \"description\": {\n      \"claude\": \"Серверы Model Context Protocol предоставляют дополнительные инструменты и источники данных для Claude\",\n      \"cursor\": \"Серверы Model Context Protocol предоставляют дополнительные инструменты и источники данных для Cursor\",\n      \"codex\": \"Серверы Model Context Protocol предоставляют дополнительные инструменты и источники данных для Codex\"\n    },\n    \"addButton\": \"Добавить MCP сервер\",\n    \"empty\": \"MCP серверы не настроены\",\n    \"serverType\": \"Тип\",\n    \"scope\": {\n      \"local\": \"локальный\",\n      \"user\": \"пользователь\"\n    },\n    \"config\": {\n      \"command\": \"Команда\",\n      \"url\": \"URL\",\n      \"args\": \"Аргументы\",\n      \"environment\": \"Окружение\"\n    },\n    \"tools\": {\n      \"title\": \"Инструменты\",\n      \"count\": \"({{count}}):\",\n      \"more\": \"+{{count}} еще\"\n    },\n    \"actions\": {\n      \"edit\": \"Редактировать сервер\",\n      \"delete\": \"Удалить сервер\"\n    },\n    \"help\": {\n      \"title\": \"О Codex MCP\",\n      \"description\": \"Codex поддерживает MCP серверы на основе stdio. Вы можете добавлять серверы, которые расширяют возможности Codex дополнительными инструментами и ресурсами.\"\n    }\n  },\n  \"pluginSettings\": {\n    \"title\": \"Плагины\",\n    \"description\": \"Расширяйте интерфейс с помощью кастомных плагинов. Установите из git или добавьте папку в ~/.claude-code-ui/plugins/\",\n    \"installPlaceholder\": \"https://github.com/user/my-plugin\",\n    \"installButton\": \"Установить\",\n    \"installing\": \"Установка…\",\n    \"securityWarning\": \"Устанавливайте только те плагины, исходный код которых вы проверили или от авторов, которым вы доверяете.\",\n    \"scanningPlugins\": \"Сканирование плагинов…\",\n    \"noPluginsInstalled\": \"Плагины не установлены\",\n    \"pullLatest\": \"Получить обновления из git\",\n    \"noGitRemote\": \"Нет удаленного git-репозитория — обновление недоступно\",\n    \"uninstallPlugin\": \"Удалить плагин\",\n    \"confirmUninstall\": \"Нажмите еще раз для подтверждения\",\n    \"confirmUninstallMessage\": \"Удалить {{name}}? Это действие нельзя отменить.\",\n    \"cancel\": \"Отмена\",\n    \"remove\": \"Удалить\",\n    \"updateFailed\": \"Ошибка обновления\",\n    \"installFailed\": \"Ошибка установки\",\n    \"uninstallFailed\": \"Ошибка удаления\",\n    \"toggleFailed\": \"Ошибка переключения\",\n    \"buildYourOwn\": \"Создайте свой плагин\",\n    \"starter\": \"Шаблон\",\n    \"docs\": \"Документация\",\n    \"starterPlugin\": {\n      \"name\": \"Статистика проекта\",\n      \"badge\": \"шаблон\",\n      \"description\": \"Количество файлов, строк кода, разбивка по типам файлов и недавняя активность в вашем проекте.\",\n      \"install\": \"Установить\"\n    },\n    \"morePlugins\": \"Ещё\",\n    \"enable\": \"Включить\",\n    \"disable\": \"Выключить\",\n    \"installAriaLabel\": \"URL git-репозитория плагина\",\n    \"tab\": \"вкладка\",\n    \"runningStatus\": \"запущен\"\n  }\n}"
  },
  {
    "path": "src/i18n/locales/ru/sidebar.json",
    "content": "{\n  \"projects\": {\n    \"title\": \"Проекты\",\n    \"newProject\": \"Новый проект\",\n    \"deleteProject\": \"Удалить проект\",\n    \"renameProject\": \"Переименовать проект\",\n    \"noProjects\": \"Проекты не найдены\",\n    \"loadingProjects\": \"Загрузка проектов...\",\n    \"searchPlaceholder\": \"Поиск проектов...\",\n    \"projectNamePlaceholder\": \"Имя проекта\",\n    \"starred\": \"Избранное\",\n    \"all\": \"Все\",\n    \"untitledSession\": \"Безымянный сеанс\",\n    \"newSession\": \"Новый сеанс\",\n    \"codexSession\": \"Сеанс Codex\",\n    \"fetchingProjects\": \"Получение ваших проектов и сеансов Claude\",\n    \"projects\": \"проекты\",\n    \"noMatchingProjects\": \"Нет подходящих проектов\",\n    \"tryDifferentSearch\": \"Попробуйте изменить поисковый запрос\",\n    \"runClaudeCli\": \"Запустите Claude CLI в каталоге проекта для начала работы\"\n  },\n  \"app\": {\n    \"title\": \"Claude Code UI\",\n    \"subtitle\": \"Интерфейс AI помощника для программирования\"\n  },\n  \"sessions\": {\n    \"title\": \"Сеансы\",\n    \"newSession\": \"Новый сеанс\",\n    \"deleteSession\": \"Удалить сеанс\",\n    \"renameSession\": \"Переименовать сеанс\",\n    \"noSessions\": \"Сеансов пока нет\",\n    \"loadingSessions\": \"Загрузка сеансов...\",\n    \"unnamed\": \"Без имени\",\n    \"loading\": \"Загрузка...\",\n    \"showMore\": \"Показать больше сеансов\"\n  },\n  \"tooltips\": {\n    \"viewEnvironments\": \"Просмотр окружений\",\n    \"hideSidebar\": \"Скрыть боковую панель\",\n    \"createProject\": \"Создать новый проект\",\n    \"refresh\": \"Обновить проекты и сеансы (Ctrl+R)\",\n    \"renameProject\": \"Переименовать проект (F2)\",\n    \"deleteProject\": \"Удалить пустой проект (Delete)\",\n    \"addToFavorites\": \"Добавить в избранное\",\n    \"removeFromFavorites\": \"Удалить из избранного\",\n    \"editSessionName\": \"Вручную редактировать имя сеанса\",\n    \"deleteSession\": \"Удалить этот сеанс навсегда\",\n    \"save\": \"Сохранить\",\n    \"cancel\": \"Отмена\",\n    \"clearSearch\": \"Очистить поиск\"\n  },\n  \"navigation\": {\n    \"chat\": \"Чат\",\n    \"files\": \"Файлы\",\n    \"git\": \"Git\",\n    \"terminal\": \"Терминал\",\n    \"tasks\": \"Задачи\"\n  },\n  \"actions\": {\n    \"refresh\": \"Обновить\",\n    \"settings\": \"Настройки\",\n    \"collapseAll\": \"Свернуть все\",\n    \"expandAll\": \"Развернуть все\",\n    \"cancel\": \"Отмена\",\n    \"save\": \"Сохранить\",\n    \"delete\": \"Удалить\",\n    \"rename\": \"Переименовать\",\n    \"joinCommunity\": \"Присоединиться к сообществу\"\n  },\n  \"status\": {\n    \"active\": \"Активен\",\n    \"inactive\": \"Неактивен\",\n    \"thinking\": \"Думает...\",\n    \"error\": \"Ошибка\",\n    \"aborted\": \"Прервано\",\n    \"unknown\": \"Неизвестно\"\n  },\n  \"time\": {\n    \"justNow\": \"Только что\",\n    \"oneMinuteAgo\": \"1 мин. назад\",\n    \"minutesAgo\": \"{{count}} мин. назад\",\n    \"oneHourAgo\": \"1 час назад\",\n    \"hoursAgo\": \"{{count}} ч. назад\",\n    \"oneDayAgo\": \"1 день назад\",\n    \"daysAgo\": \"{{count}} дн. назад\"\n  },\n  \"messages\": {\n    \"deleteConfirm\": \"Вы уверены, что хотите это удалить?\",\n    \"renameSuccess\": \"Успешно переименовано\",\n    \"deleteSuccess\": \"Успешно удалено\",\n    \"errorOccurred\": \"Произошла ошибка\",\n    \"deleteSessionConfirm\": \"Вы уверены, что хотите удалить этот сеанс? Это действие нельзя отменить.\",\n    \"deleteProjectConfirm\": \"Вы уверены, что хотите удалить этот пустой проект? Это действие нельзя отменить.\",\n    \"enterProjectPath\": \"Пожалуйста, введите путь к проекту\",\n    \"deleteSessionFailed\": \"Не удалось удалить сеанс. Попробуйте снова.\",\n    \"deleteSessionError\": \"Ошибка при удалении сеанса. Попробуйте снова.\",\n    \"renameSessionFailed\": \"Не удалось переименовать сеанс. Попробуйте снова.\",\n    \"renameSessionError\": \"Ошибка при переименовании сеанса. Попробуйте снова.\",\n    \"deleteProjectFailed\": \"Не удалось удалить проект. Попробуйте снова.\",\n    \"deleteProjectError\": \"Ошибка при удалении проекта. Попробуйте снова.\",\n    \"createProjectFailed\": \"Не удалось создать проект. Попробуйте снова.\",\n    \"createProjectError\": \"Ошибка при создании проекта. Попробуйте снова.\"\n  },\n  \"version\": {\n    \"updateAvailable\": \"Доступно обновление\"\n  },\n  \"search\": {\n    \"modeProjects\": \"Проекты\",\n    \"modeConversations\": \"Разговоры\",\n    \"conversationsPlaceholder\": \"Поиск в разговорах...\",\n    \"searching\": \"Поиск...\",\n    \"noResults\": \"Результаты не найдены\",\n    \"tryDifferentQuery\": \"Попробуйте другой поисковый запрос\",\n    \"matches_one\": \"{{count}} совпадение\",\n    \"matches_few\": \"{{count}} совпадения\",\n    \"matches_many\": \"{{count}} совпадений\",\n    \"matches_other\": \"{{count}} совпадений\",\n    \"projectsScanned_one\": \"{{count}} проект просканирован\",\n    \"projectsScanned_few\": \"{{count}} проекта просканировано\",\n    \"projectsScanned_many\": \"{{count}} проектов просканировано\",\n    \"projectsScanned_other\": \"{{count}} проектов просканировано\"\n  },\n  \"deleteConfirmation\": {\n    \"deleteProject\": \"Удалить проект\",\n    \"deleteSession\": \"Удалить сеанс\",\n    \"confirmDelete\": \"Вы уверены, что хотите удалить\",\n    \"sessionCount_one\": \"Этот проект содержит {{count}} разговор.\",\n    \"sessionCount_few\": \"Этот проект содержит {{count}} разговора.\",\n    \"sessionCount_many\": \"Этот проект содержит {{count}} разговоров.\",\n    \"sessionCount_other\": \"Этот проект содержит {{count}} разговоров.\",\n    \"allConversationsDeleted\": \"Все разговоры будут удалены навсегда.\",\n    \"cannotUndo\": \"Это действие нельзя отменить.\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/ru/tasks.json",
    "content": "{\n  \"notConfigured\": {\n    \"title\": \"TaskMaster AI не настроен\",\n    \"description\": \"TaskMaster помогает разбивать сложные проекты на управляемые задачи с помощью AI\",\n    \"whatIsTitle\": \"🎯 Что такое TaskMaster?\",\n    \"features\": {\n      \"aiPowered\": \"Управление задачами с AI: разбивайте сложные проекты на управляемые подзадачи\",\n      \"prdTemplates\": \"Шаблоны PRD: генерируйте задачи из документов требований к продукту\",\n      \"dependencyTracking\": \"Отслеживание зависимостей: понимайте связи задач и порядок выполнения\",\n      \"progressVisualization\": \"Визуализация прогресса: канбан-доски и детальная аналитика задач\",\n      \"cliIntegration\": \"Интеграция с CLI: используйте команды taskmaster для продвинутых рабочих процессов\"\n    },\n    \"initializeButton\": \"Инициализировать TaskMaster AI\"\n  },\n  \"gettingStarted\": {\n    \"title\": \"Начало работы с TaskMaster\",\n    \"subtitle\": \"TaskMaster инициализирован! Вот что делать дальше:\",\n    \"steps\": {\n      \"createPRD\": {\n        \"title\": \"Создайте документ требований к продукту (PRD)\",\n        \"description\": \"Обсудите идею вашего проекта и создайте PRD, описывающий то, что вы хотите построить.\",\n        \"addButton\": \"Добавить PRD\",\n        \"existingPRDs\": \"Существующие PRD:\"\n      },\n      \"generateTasks\": {\n        \"title\": \"Генерация задач из PRD\",\n        \"description\": \"Когда у вас есть PRD, попросите вашего AI-ассистента разобрать его, и TaskMaster автоматически разобьет его на управляемые задачи с деталями реализации.\"\n      },\n      \"analyzeTasks\": {\n        \"title\": \"Анализ и расширение задач\",\n        \"description\": \"Попросите вашего AI-ассистента проанализировать сложность задач и расширить их в детальные подзадачи для упрощения реализации.\"\n      },\n      \"startBuilding\": {\n        \"title\": \"Начните разработку\",\n        \"description\": \"Попросите вашего AI-ассистента начать работу над задачами, обновлять их статус и добавлять новые задачи по мере развития вашего проекта.\"\n      }\n    },\n    \"tip\": \"💡 Совет: начните с PRD, чтобы получить максимум от AI-генерации задач TaskMaster\"\n  },\n  \"setupModal\": {\n    \"title\": \"Настройка TaskMaster\",\n    \"subtitle\": \"Интерактивный CLI для {{projectName}}\",\n    \"willStart\": \"Инициализация TaskMaster начнется автоматически\",\n    \"completed\": \"Настройка TaskMaster завершена! Теперь вы можете закрыть это окно.\",\n    \"closeButton\": \"Закрыть\",\n    \"closeContinueButton\": \"Закрыть и продолжить\"\n  },\n  \"helpGuide\": {\n    \"title\": \"Начало работы с TaskMaster\",\n    \"subtitle\": \"Ваш гид по продуктивному управлению задачами\",\n    \"examples\": {\n      \"parsePRD\": \"💬 Пример:\\n\\\"Я только что инициализировал новый проект с Claude Task Master. У меня есть PRD в .taskmaster/docs/prd.txt. Можете помочь мне разобрать его и настроить начальные задачи?\\\"\",\n      \"expandTask\": \"💬 Пример:\\n\\\"Задача 5 кажется сложной. Можете разбить её на подзадачи?\\\"\",\n      \"addTask\": \"💬 Пример:\\n\\\"Пожалуйста, добавьте новую задачу для реализации загрузки изображений профиля пользователя с использованием Cloudinary, изучите лучший подход.\\\"\"\n    },\n    \"moreExamples\": \"Посмотреть больше примеров и шаблонов использования →\",\n    \"proTips\": {\n      \"title\": \"💡 Профессиональные советы\",\n      \"search\": \"Используйте строку поиска для быстрого поиска конкретных задач\",\n      \"views\": \"Переключайтесь между представлениями Канбан, Список и Сетка, используя переключатели представлений\",\n      \"filters\": \"Используйте фильтры для фокусировки на конкретных статусах или приоритетах задач\",\n      \"details\": \"Нажмите на любую задачу для просмотра детальной информации и управления подзадачами\"\n    },\n    \"learnMore\": {\n      \"title\": \"📚 Узнать больше\",\n      \"description\": \"TaskMaster AI - это продвинутая система управления задачами, созданная для разработчиков. Получите документацию, примеры и внесите вклад в проект.\",\n      \"githubButton\": \"Посмотреть на GitHub\"\n    }\n  },\n  \"search\": {\n    \"placeholder\": \"Поиск задач...\"\n  },\n  \"filters\": {\n    \"button\": \"Фильтры\",\n    \"status\": \"Статус\",\n    \"priority\": \"Приоритет\",\n    \"sortBy\": \"Сортировать по\",\n    \"allStatuses\": \"Все статусы\",\n    \"allPriorities\": \"Все приоритеты\",\n    \"showing\": \"Показано {{filtered}} из {{total}} задач\",\n    \"clearFilters\": \"Очистить фильтры\"\n  },\n  \"sort\": {\n    \"id\": \"ID\",\n    \"status\": \"Статус\",\n    \"priority\": \"Приоритет\",\n    \"idAsc\": \"ID (по возрастанию)\",\n    \"idDesc\": \"ID (по убыванию)\",\n    \"titleAsc\": \"Название (А-Я)\",\n    \"titleDesc\": \"Название (Я-А)\",\n    \"statusAsc\": \"Статус (сначала ожидающие)\",\n    \"statusDesc\": \"Статус (сначала выполненные)\",\n    \"priorityAsc\": \"Приоритет (сначала высокий)\",\n    \"priorityDesc\": \"Приоритет (сначала низкий)\"\n  },\n  \"views\": {\n    \"kanban\": \"Представление Канбан\",\n    \"list\": \"Представление списком\",\n    \"grid\": \"Представление сеткой\"\n  },\n  \"kanban\": {\n    \"pending\": \"📋 К выполнению\",\n    \"inProgress\": \"🚀 В процессе\",\n    \"done\": \"✅ Выполнено\",\n    \"blocked\": \"🚫 Заблокировано\",\n    \"deferred\": \"⏳ Отложено\",\n    \"cancelled\": \"❌ Отменено\",\n    \"noTasksYet\": \"Задач пока нет\",\n    \"tasksWillAppear\": \"Задачи появятся здесь\",\n    \"moveTasksHere\": \"Перемещайте задачи сюда при начале работы\",\n    \"completedTasksHere\": \"Завершенные задачи появляются здесь\",\n    \"statusTasksHere\": \"Задачи с этим статусом появятся здесь\"\n  },\n  \"buttons\": {\n    \"help\": \"Руководство по началу работы с TaskMaster\",\n    \"prds\": \"PRD\",\n    \"addPRD\": \"Добавить PRD\",\n    \"addTask\": \"Добавить задачу\",\n    \"createNewPRD\": \"Создать новый PRD\",\n    \"prdsAvailable\": \"Доступно {{count}} PRD\"\n  },\n  \"prd\": {\n    \"modified\": \"Изменено: {{date}}\"\n  },\n  \"statuses\": {\n    \"pending\": \"Ожидание\",\n    \"in-progress\": \"В процессе\",\n    \"done\": \"Выполнено\",\n    \"blocked\": \"Заблокировано\",\n    \"deferred\": \"Отложено\",\n    \"cancelled\": \"Отменено\"\n  },\n  \"priorities\": {\n    \"high\": \"Высокий\",\n    \"medium\": \"Средний\",\n    \"low\": \"Низкий\"\n  },\n  \"noMatchingTasks\": {\n    \"title\": \"Нет задач, соответствующих вашим фильтрам\",\n    \"description\": \"Попробуйте изменить критерии поиска или фильтрации.\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/zh-CN/auth.json",
    "content": "{\n  \"login\": {\n    \"title\": \"欢迎回来\",\n    \"description\": \"登录您的 Claude Code UI 账户\",\n    \"username\": \"用户名\",\n    \"password\": \"密码\",\n    \"submit\": \"登录\",\n    \"loading\": \"登录中...\",\n    \"errors\": {\n      \"invalidCredentials\": \"用户名或密码无效\",\n      \"requiredFields\": \"请填写所有字段\",\n      \"networkError\": \"网络错误，请重试。\"\n    },\n    \"placeholders\": {\n      \"username\": \"输入您的用户名\",\n      \"password\": \"输入您的密码\"\n    }\n  },\n  \"register\": {\n    \"title\": \"创建账户\",\n    \"username\": \"用户名\",\n    \"password\": \"密码\",\n    \"confirmPassword\": \"确认密码\",\n    \"submit\": \"创建账户\",\n    \"loading\": \"创建账户中...\",\n    \"errors\": {\n      \"passwordMismatch\": \"密码不匹配\",\n      \"usernameTaken\": \"用户名已被占用\",\n      \"weakPassword\": \"密码强度太弱\"\n    }\n  },\n  \"logout\": {\n    \"title\": \"退出登录\",\n    \"confirm\": \"确定要退出登录吗？\",\n    \"button\": \"退出登录\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/zh-CN/chat.json",
    "content": "{\n  \"codeBlock\": {\n    \"copy\": \"复制\",\n    \"copied\": \"已复制\",\n    \"copyCode\": \"复制代码\"\n  },\n  \"copyMessage\": {\n    \"copy\": \"复制消息\",\n    \"copied\": \"消息已复制\",\n    \"selectFormat\": \"选择复制格式\",\n    \"copyAsMarkdown\": \"复制为 Markdown\",\n    \"copyAsText\": \"复制为纯文本\"\n  },\n  \"messageTypes\": {\n    \"user\": \"U\",\n    \"error\": \"错误\",\n    \"tool\": \"工具\",\n    \"claude\": \"Claude\",\n    \"cursor\": \"Cursor\",\n    \"codex\": \"Codex\",\n    \"gemini\": \"Gemini\"\n  },\n  \"tools\": {\n    \"settings\": \"工具设置\",\n    \"error\": \"工具错误\",\n    \"result\": \"工具结果\",\n    \"viewParams\": \"查看输入参数\",\n    \"viewRawParams\": \"查看原始参数\",\n    \"viewDiff\": \"查看编辑差异\",\n    \"creatingFile\": \"创建新文件：\",\n    \"updatingTodo\": \"更新待办事项\",\n    \"read\": \"读取\",\n    \"readFile\": \"读取文件\",\n    \"updateTodo\": \"更新待办列表\",\n    \"readTodo\": \"读取待办列表\",\n    \"searchResults\": \"结果\"\n  },\n  \"search\": {\n    \"found\": \"找到 {{count}} 个{{type}}\",\n    \"file\": \"文件\",\n    \"files\": \"文件\",\n    \"pattern\": \"模式：\",\n    \"in\": \"在：\"\n  },\n  \"fileOperations\": {\n    \"updated\": \"文件更新成功\",\n    \"created\": \"文件创建成功\",\n    \"written\": \"文件写入成功\",\n    \"diff\": \"差异\",\n    \"newFile\": \"新文件\",\n    \"viewContent\": \"查看文件内容\",\n    \"viewFullOutput\": \"查看完整输出（{{count}} 个字符）\",\n    \"contentDisplayed\": \"文件内容显示在上面的差异视图中\"\n  },\n  \"interactive\": {\n    \"title\": \"交互式提示\",\n    \"waiting\": \"等待您在 CLI 中响应\",\n    \"instruction\": \"请在 Claude 运行的终端中选择一个选项。\",\n    \"selectedOption\": \"✓ Claude 选择了选项 {{number}}\",\n    \"instructionDetail\": \"在 CLI 中，您可以使用方向键或输入数字来交互式地选择此选项。\"\n  },\n  \"thinking\": {\n    \"title\": \"思考中...\",\n    \"emoji\": \"💭 思考中...\"\n  },\n  \"json\": {\n    \"response\": \"JSON 响应\"\n  },\n  \"permissions\": {\n    \"grant\": \"授予 {{tool}} 权限\",\n    \"added\": \"权限已添加\",\n    \"addTo\": \"将 {{entry}} 添加到允许的工具。\",\n    \"retry\": \"权限已保存。重试请求以使用该工具。\",\n    \"error\": \"无法更新权限。请重试。\",\n    \"openSettings\": \"打开设置\"\n  },\n  \"todo\": {\n    \"updated\": \"待办列表已成功更新\",\n    \"current\": \"当前待办列表\"\n  },\n  \"plan\": {\n    \"viewPlan\": \"📋 查看实施计划\",\n    \"title\": \"实施计划\"\n  },\n  \"usageLimit\": {\n    \"resetAt\": \"Claude 使用限制已达到。您的限制将在 **{{time}} {{timezone}}** - {{date}} 重置\"\n  },\n  \"codex\": {\n    \"permissionMode\": \"权限模式\",\n    \"modes\": {\n      \"default\": \"默认模式\",\n      \"acceptEdits\": \"编辑模式\",\n      \"bypassPermissions\": \"无限制模式\",\n      \"plan\": \"计划模式\"\n    },\n    \"descriptions\": {\n      \"default\": \"只有受信任的命令（ls、cat、grep、git status 等）自动运行。其他命令将被跳过。可以写入工作区。\",\n      \"acceptEdits\": \"工作区内的所有命令自动运行。完全自动模式，具有沙盒执行功能。\",\n      \"bypassPermissions\": \"完全的系统访问，无限制。所有命令自动运行，具有完整的磁盘和网络访问权限。请谨慎使用。\",\n      \"plan\": \"计划模式 - 不执行任何命令\"\n    },\n    \"technicalDetails\": \"技术细节\"\n  },\n  \"input\": {\n    \"placeholder\": \"输入 / 调用命令，@ 选择文件，或向 {{provider}} 提问...\",\n    \"placeholderDefault\": \"输入您的消息...\",\n    \"disabled\": \"输入已禁用\",\n    \"attachFiles\": \"附加文件\",\n    \"attachImages\": \"附加图片\",\n    \"send\": \"发送\",\n    \"stop\": \"停止\",\n    \"hintText\": {\n      \"ctrlEnter\": \"Ctrl+Enter 发送 • Shift+Enter 换行 • Tab 切换模式 • / 斜杠命令\",\n      \"enter\": \"Enter 发送 • Shift+Enter 换行 • Tab 切换模式 • / 斜杠命令\"\n    },\n    \"clickToChangeMode\": \"点击更改权限模式（或在输入框中按 Tab）\",\n    \"showAllCommands\": \"显示所有命令\",\n    \"clearInput\": \"清空输入\",\n    \"scrollToBottom\": \"滚动到底部\"\n  },\n  \"thinkingMode\": {\n    \"selector\": {\n      \"title\": \"思考模式\",\n      \"description\": \"扩展思考给 Claude 更多时间来评估替代方案\",\n      \"active\": \"激活\",\n      \"tip\": \"更高的思考模式需要更多时间，但提供更彻底的分析\"\n    },\n    \"modes\": {\n      \"none\": {\n        \"name\": \"标准\",\n        \"description\": \"常规 Claude 响应\",\n        \"prefix\": \"\"\n      },\n      \"think\": {\n        \"name\": \"思考\",\n        \"description\": \"基本扩展思考\",\n        \"prefix\": \"思考\"\n      },\n      \"thinkHard\": {\n        \"name\": \"深入思考\",\n        \"description\": \"更彻底的评估\",\n        \"prefix\": \"深入思考\"\n      },\n      \"thinkHarder\": {\n        \"name\": \"更深入思考\",\n        \"description\": \"考虑替代方案的深度分析\",\n        \"prefix\": \"更深入思考\"\n      },\n      \"ultrathink\": {\n        \"name\": \"超级思考\",\n        \"description\": \"最大思考预算\",\n        \"prefix\": \"超级思考\"\n      }\n    },\n    \"buttonTitle\": \"思考模式：{{mode}}\"\n  },\n  \"providerSelection\": {\n    \"title\": \"选择您的 AI 助手\",\n    \"description\": \"选择一个供应商以开始新对话\",\n    \"selectModel\": \"选择模型\",\n    \"providerInfo\": {\n      \"anthropic\": \"由 Anthropic 提供\",\n      \"openai\": \"由 OpenAI 提供\",\n      \"cursorEditor\": \"AI 代码编辑器\",\n      \"google\": \"由 Google 提供\"\n    },\n    \"readyPrompt\": {\n      \"claude\": \"准备好使用带有 {{model}} 的 Claude。请在下方开始输入您的消息。\",\n      \"cursor\": \"准备好使用带有 {{model}} 的 Cursor。请在下方开始输入您的消息。\",\n      \"codex\": \"准备好使用带有 {{model}} 的 Codex。请在下方开始输入您的消息。\",\n      \"gemini\": \"准备好使用带有 {{model}} 的 Gemini。请在下方开始输入您的消息。\",\n      \"default\": \"请在上方选择一个提供者以开始\"\n    }\n  },\n  \"session\": {\n    \"continue\": {\n      \"title\": \"继续您的对话\",\n      \"description\": \"询问有关代码的问题、请求更改或获取开发任务的帮助\"\n    },\n    \"loading\": {\n      \"olderMessages\": \"正在加载更早的消息...\",\n      \"sessionMessages\": \"正在加载会话消息...\"\n    },\n    \"messages\": {\n      \"showingOf\": \"显示 {{shown}} / {{total}} 条消息\",\n      \"scrollToLoad\": \"向上滚动以加载更多\",\n      \"showingLast\": \"显示最近 {{count}} 条消息（共 {{total}} 条）\",\n      \"loadEarlier\": \"加载更早的消息\",\n      \"loadAll\": \"加载全部消息\",\n      \"loadingAll\": \"正在加载全部消息...\",\n      \"allLoaded\": \"全部消息已加载\",\n      \"perfWarning\": \"已加载全部消息 - 滚动可能变慢。点击「滚动到底部」恢复性能。\"\n    }\n  },\n  \"shell\": {\n    \"selectProject\": {\n      \"title\": \"选择项目\",\n      \"description\": \"选择一个项目以在该目录中打开交互式 Shell\"\n    },\n    \"status\": {\n      \"newSession\": \"新会话\",\n      \"initializing\": \"初始化中...\",\n      \"restarting\": \"重启中...\"\n    },\n    \"actions\": {\n      \"disconnect\": \"断开连接\",\n      \"disconnectTitle\": \"断开 Shell 连接\",\n      \"restart\": \"重启\",\n      \"restartTitle\": \"重启 Shell（请先断开连接）\",\n      \"connect\": \"在 Shell 中继续\",\n      \"connectTitle\": \"连接到 Shell\"\n    },\n    \"loading\": \"正在加载终端...\",\n    \"connecting\": \"正在连接到 Shell...\",\n    \"startSession\": \"启动新的 Claude 会话\",\n    \"resumeSession\": \"恢复会话：{{displayName}}...\",\n    \"runCommand\": \"在 {{projectName}} 中运行 {{command}}\",\n    \"startCli\": \"在 {{projectName}} 中启动 Claude CLI\",\n    \"defaultCommand\": \"命令\"\n  },\n  \"claudeStatus\": {\n    \"actions\": {\n      \"thinking\": \"Thinking\",\n      \"processing\": \"Processing\",\n      \"analyzing\": \"Analyzing\",\n      \"working\": \"Working\",\n      \"computing\": \"Computing\",\n      \"reasoning\": \"Reasoning\"\n    },\n    \"state\": {\n      \"live\": \"Live\",\n      \"paused\": \"Paused\"\n    },\n    \"elapsed\": {\n      \"seconds\": \"{{count}}s\",\n      \"minutesSeconds\": \"{{minutes}}m {{seconds}}s\",\n      \"label\": \"{{time}} elapsed\",\n      \"startingNow\": \"Starting now\"\n    },\n    \"controls\": {\n      \"stopGeneration\": \"Stop Generation\",\n      \"pressEscToStop\": \"Press Esc anytime to stop\"\n    },\n    \"providers\": {\n      \"assistant\": \"Assistant\"\n    }\n  },\n  \"projectSelection\": {\n    \"startChatWithProvider\": \"选择一个项目以开始与 {{provider}} 聊天\"\n  },\n  \"tasks\": {\n    \"nextTaskPrompt\": \"开始下一个任务\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/zh-CN/codeEditor.json",
    "content": "{\n  \"toolbar\": {\n    \"changes\": \"个更改\",\n    \"previousChange\": \"上一个更改\",\n    \"nextChange\": \"下一个更改\",\n    \"hideDiff\": \"隐藏差异高亮\",\n    \"showDiff\": \"显示差异高亮\",\n    \"settings\": \"编辑器设置\",\n    \"collapse\": \"折叠编辑器\",\n    \"expand\": \"展开编辑器到全宽\"\n  },\n  \"loading\": \"正在加载 {{fileName}}...\",\n  \"header\": {\n    \"showingChanges\": \"显示更改\"\n  },\n  \"actions\": {\n    \"download\": \"下载文件\",\n    \"save\": \"保存\",\n    \"saving\": \"保存中...\",\n    \"saved\": \"已保存！\",\n    \"exitFullscreen\": \"退出全屏\",\n    \"fullscreen\": \"全屏\",\n    \"close\": \"关闭\"\n  },\n  \"footer\": {\n    \"lines\": \"行数：\",\n    \"characters\": \"字符数：\",\n    \"shortcuts\": \"按 Ctrl+S 保存 • Esc 关闭\"\n  },\n  \"binaryFile\": {\n    \"title\": \"二进制文件\",\n    \"message\": \"文件 \\\"{{fileName}}\\\" 无法在文本编辑器中显示，因为它是二进制文件。\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/zh-CN/common.json",
    "content": "{\n  \"buttons\": {\n    \"save\": \"保存\",\n    \"cancel\": \"取消\",\n    \"delete\": \"删除\",\n    \"create\": \"创建\",\n    \"edit\": \"编辑\",\n    \"close\": \"关闭\",\n    \"confirm\": \"确认\",\n    \"submit\": \"提交\",\n    \"retry\": \"重试\",\n    \"refresh\": \"刷新\",\n    \"search\": \"搜索\",\n    \"clear\": \"清除\",\n    \"copy\": \"复制\",\n    \"download\": \"下载\",\n    \"upload\": \"上传\",\n    \"browse\": \"浏览\"\n  },\n  \"tabs\": {\n    \"chat\": \"聊天\",\n    \"shell\": \"终端\",\n    \"files\": \"文件\",\n    \"git\": \"源代码管理\",\n    \"tasks\": \"任务\"\n  },\n  \"status\": {\n    \"loading\": \"加载中...\",\n    \"success\": \"成功\",\n    \"error\": \"错误\",\n    \"failed\": \"失败\",\n    \"pending\": \"待处理\",\n    \"completed\": \"已完成\",\n    \"inProgress\": \"进行中\"\n  },\n  \"messages\": {\n    \"savedSuccessfully\": \"保存成功\",\n    \"deletedSuccessfully\": \"删除成功\",\n    \"updatedSuccessfully\": \"更新成功\",\n    \"operationFailed\": \"操作失败\",\n    \"networkError\": \"网络错误，请检查您的连接。\",\n    \"unauthorized\": \"未授权，请登录。\",\n    \"notFound\": \"未找到\",\n    \"invalidInput\": \"输入无效\",\n    \"requiredField\": \"此字段为必填项\",\n    \"unknownError\": \"发生未知错误\"\n  },\n  \"navigation\": {\n    \"settings\": \"设置\",\n    \"home\": \"首页\",\n    \"back\": \"返回\",\n    \"next\": \"下一步\",\n    \"previous\": \"上一步\",\n    \"logout\": \"退出登录\"\n  },\n  \"common\": {\n    \"language\": \"语言\",\n    \"theme\": \"主题\",\n    \"darkMode\": \"深色模式\",\n    \"lightMode\": \"浅色模式\",\n    \"name\": \"名称\",\n    \"description\": \"描述\",\n    \"enabled\": \"已启用\",\n    \"disabled\": \"已禁用\",\n    \"optional\": \"可选\",\n    \"version\": \"版本\",\n    \"select\": \"选择\",\n    \"selectAll\": \"全选\",\n    \"deselectAll\": \"取消全选\"\n  },\n  \"time\": {\n    \"justNow\": \"刚刚\",\n    \"minutesAgo\": \"{{count}} 分钟前\",\n    \"hoursAgo\": \"{{count}} 小时前\",\n    \"daysAgo\": \"{{count}} 天前\",\n    \"yesterday\": \"昨天\"\n  },\n  \"fileOperations\": {\n    \"newFile\": \"新建文件\",\n    \"newFolder\": \"新建文件夹\",\n    \"rename\": \"重命名\",\n    \"move\": \"移动\",\n    \"copyPath\": \"复制路径\",\n    \"openInEditor\": \"在编辑器中打开\"\n  },\n  \"mainContent\": {\n    \"loading\": \"正在加载 Claude Code UI\",\n    \"settingUpWorkspace\": \"正在设置您的工作空间...\",\n    \"chooseProject\": \"选择您的项目\",\n    \"selectProjectDescription\": \"从侧边栏选择一个项目以开始使用 Claude 进行编程。每个项目包含您的聊天会话和文件历史。\",\n    \"tip\": \"提示\",\n    \"createProjectMobile\": \"点击上方的菜单按钮以访问项目\",\n    \"createProjectDesktop\": \"点击侧边栏中的文件夹图标以创建新项目\",\n    \"newSession\": \"新会话\",\n    \"untitledSession\": \"未命名会话\",\n    \"projectFiles\": \"项目文件\"\n  },\n  \"fileTree\": {\n    \"loading\": \"正在加载文件...\",\n    \"files\": \"文件\",\n    \"simpleView\": \"简单视图\",\n    \"compactView\": \"紧凑视图\",\n    \"detailedView\": \"详细视图\",\n    \"searchPlaceholder\": \"搜索文件和文件夹...\",\n    \"clearSearch\": \"清除搜索\",\n    \"name\": \"名称\",\n    \"size\": \"大小\",\n    \"modified\": \"修改时间\",\n    \"permissions\": \"权限\",\n    \"noFilesFound\": \"未找到文件\",\n    \"checkProjectPath\": \"检查项目路径是否可访问\",\n    \"noMatchesFound\": \"未找到匹配项\",\n    \"tryDifferentSearch\": \"尝试不同的搜索词或清除搜索\",\n    \"justNow\": \"刚刚\",\n    \"minAgo\": \"{{count}} 分钟前\",\n    \"hoursAgo\": \"{{count}} 小时前\",\n    \"daysAgo\": \"{{count}} 天前\",\n    \"newFile\": \"新建文件 (Cmd+N)\",\n    \"newFolder\": \"新建文件夹 (Cmd+Shift+N)\",\n    \"refresh\": \"刷新\",\n    \"collapseAll\": \"全部折叠\",\n    \"context\": {\n      \"rename\": \"重命名\",\n      \"delete\": \"删除\",\n      \"copyPath\": \"复制路径\",\n      \"download\": \"下载\",\n      \"newFile\": \"新建文件\",\n      \"newFolder\": \"新建文件夹\",\n      \"refresh\": \"刷新\",\n      \"menuLabel\": \"文件上下文菜单\",\n      \"loading\": \"加载中...\"\n    }\n  },\n  \"projectWizard\": {\n    \"title\": \"创建新项目\",\n    \"steps\": {\n      \"type\": \"类型\",\n      \"configure\": \"配置\",\n      \"confirm\": \"确认\"\n    },\n    \"step1\": {\n      \"question\": \"您已经有工作区，还是想创建一个新的工作区？\",\n      \"existing\": {\n        \"title\": \"现有工作区\",\n        \"description\": \"我的服务器上已经有工作区，只需要将其添加到项目列表中\"\n      },\n      \"new\": {\n        \"title\": \"新建工作区\",\n        \"description\": \"创建一个新工作区，可选择从 GitHub 仓库克隆\"\n      }\n    },\n    \"step2\": {\n      \"existingPath\": \"工作区路径\",\n      \"newPath\": \"工作区路径\",\n      \"existingPlaceholder\": \"/path/to/existing/workspace\",\n      \"newPlaceholder\": \"/path/to/new/workspace\",\n      \"existingHelp\": \"您现有工作区目录的完整路径\",\n      \"newHelp\": \"工作区目录的完整路径\",\n      \"githubUrl\": \"GitHub URL（可选）\",\n      \"githubPlaceholder\": \"https://github.com/username/repository\",\n      \"githubHelp\": \"可选：提供 GitHub URL 以克隆仓库\",\n      \"githubAuth\": \"GitHub 身份验证（可选）\",\n      \"githubAuthHelp\": \"仅私有仓库需要。公共仓库无需身份验证即可克隆。\",\n      \"loadingTokens\": \"正在加载已保存的令牌...\",\n      \"storedToken\": \"已保存的令牌\",\n      \"newToken\": \"新令牌\",\n      \"nonePublic\": \"无（公共）\",\n      \"selectToken\": \"选择令牌\",\n      \"selectTokenPlaceholder\": \"-- 选择令牌 --\",\n      \"tokenPlaceholder\": \"ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\n      \"tokenHelp\": \"此令牌仅用于此操作\",\n      \"publicRepoInfo\": \"公共仓库不需要身份验证。如果克隆公共仓库，可以跳过提供令牌。\",\n      \"noTokensHelp\": \"没有可用的已保存令牌。您可以在 设置 → API 密钥 中添加令牌以便重复使用。\",\n      \"optionalTokenPublic\": \"GitHub 令牌（公共仓库可选）\",\n      \"tokenPublicPlaceholder\": \"ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx（公共仓库可留空）\"\n    },\n    \"step3\": {\n      \"reviewConfig\": \"查看您的配置\",\n      \"workspaceType\": \"工作区类型：\",\n      \"existingWorkspace\": \"现有工作区\",\n      \"newWorkspace\": \"新建工作区\",\n      \"path\": \"路径：\",\n      \"cloneFrom\": \"克隆自：\",\n      \"authentication\": \"身份验证：\",\n      \"usingStoredToken\": \"使用已保存的令牌：\",\n      \"usingProvidedToken\": \"使用提供的令牌\",\n      \"noAuthentication\": \"无身份验证\",\n      \"sshKey\": \"SSH 密钥\",\n      \"existingInfo\": \"工作区将被添加到您的项目列表中，并可用于 Claude/Cursor 会话。\",\n      \"newWithClone\": \"仓库将从此文件夹克隆。\",\n      \"newEmpty\": \"工作区将被添加到您的项目列表中，并可用于 Claude/Cursor 会话。\",\n      \"cloningRepository\": \"正在克隆仓库...\"\n    },\n    \"buttons\": {\n      \"cancel\": \"取消\",\n      \"back\": \"返回\",\n      \"next\": \"下一步\",\n      \"createProject\": \"创建项目\",\n      \"creating\": \"创建中...\",\n      \"cloning\": \"正在克隆...\"\n    },\n    \"errors\": {\n      \"selectType\": \"请选择您已有现有工作区还是想创建新工作区\",\n      \"providePath\": \"请提供工作区路径\",\n      \"failedToCreate\": \"创建工作区失败\",\n      \"failedToCreateFolder\": \"创建文件夹失败\"\n    }\n  },\n  \"notifications\": {\n    \"genericTool\": \"工具\",\n    \"codes\": {\n      \"generic\": {\n        \"info\": {\n          \"title\": \"通知\"\n        }\n      },\n      \"permission\": {\n        \"required\": {\n          \"title\": \"需要处理\",\n          \"body\": \"{{toolName}} 正在等待你的决策。\"\n        }\n      },\n      \"run\": {\n        \"stopped\": {\n          \"title\": \"运行已停止\",\n          \"body\": \"原因：{{reason}}\"\n        },\n        \"failed\": {\n          \"title\": \"运行失败\"\n        }\n      },\n      \"agent\": {\n        \"notification\": {\n          \"title\": \"Agent 通知\"\n        }\n      }\n    }\n  },\n  \"versionUpdate\": {\n    \"title\": \"有可用更新\",\n    \"newVersionReady\": \"新版本已准备就绪\",\n    \"currentVersion\": \"当前版本\",\n    \"latestVersion\": \"最新版本\",\n    \"whatsNew\": \"新内容：\",\n    \"viewFullRelease\": \"查看完整发布\",\n    \"updateProgress\": \"更新进度：\",\n    \"manualUpgrade\": \"手动升级：\",\n    \"npmUpgradeCommand\": \"npm install -g @siteboon/claude-code-ui@latest\",\n    \"manualUpgradeHint\": \"或点击'立即更新'以自动运行更新。\",\n    \"updateCompleted\": \"更新成功完成！\",\n    \"restartServer\": \"请重启服务器以应用更改。\",\n    \"updateFailed\": \"更新失败\",\n    \"buttons\": {\n      \"close\": \"关闭\",\n      \"later\": \"稍后\",\n      \"copyCommand\": \"复制命令\",\n      \"updateNow\": \"立即更新\",\n      \"updating\": \"更新中...\"\n    },\n    \"ariaLabels\": {\n      \"closeModal\": \"关闭版本升级模态框\",\n      \"showSidebar\": \"显示侧边栏\",\n      \"settings\": \"设置\",\n      \"updateAvailable\": \"有可用更新\",\n      \"closeSidebar\": \"关闭侧边栏\"\n    }\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/zh-CN/settings.json",
    "content": "{\n  \"title\": \"设置\",\n  \"tabs\": {\n    \"account\": \"账户\",\n    \"permissions\": \"权限\",\n    \"mcpServers\": \"MCP 服务器\",\n    \"appearance\": \"外观\"\n  },\n  \"account\": {\n    \"title\": \"账户\",\n    \"language\": \"语言\",\n    \"languageLabel\": \"显示语言\",\n    \"languageDescription\": \"选择您偏好的界面语言\",\n    \"username\": \"用户名\",\n    \"email\": \"邮箱\",\n    \"profile\": \"个人资料\",\n    \"changePassword\": \"修改密码\"\n  },\n  \"mcp\": {\n    \"title\": \"MCP 服务器\",\n    \"addServer\": \"添加服务器\",\n    \"editServer\": \"编辑服务器\",\n    \"deleteServer\": \"删除服务器\",\n    \"serverName\": \"服务器名称\",\n    \"serverType\": \"服务器类型\",\n    \"config\": \"配置\",\n    \"testConnection\": \"测试连接\",\n    \"status\": \"状态\",\n    \"connected\": \"已连接\",\n    \"disconnected\": \"未连接\",\n    \"scope\": {\n      \"label\": \"范围\",\n      \"user\": \"用户\",\n      \"project\": \"项目\"\n    }\n  },\n  \"appearance\": {\n    \"title\": \"外观\",\n    \"theme\": \"主题\",\n    \"codeEditor\": \"代码编辑器\",\n    \"editorTheme\": \"编辑器主题\",\n    \"wordWrap\": \"自动换行\",\n    \"showMinimap\": \"显示缩略图\",\n    \"lineNumbers\": \"行号\",\n    \"fontSize\": \"字体大小\"\n  },\n  \"actions\": {\n    \"saveChanges\": \"保存更改\",\n    \"resetToDefaults\": \"重置为默认值\",\n    \"cancelChanges\": \"取消更改\"\n  },\n  \"quickSettings\": {\n    \"title\": \"快速设置\",\n    \"sections\": {\n      \"appearance\": \"外观\",\n      \"toolDisplay\": \"工具显示\",\n      \"viewOptions\": \"视图选项\",\n      \"inputSettings\": \"输入设置\",\n      \"whisperDictation\": \"Whisper 听写\"\n    },\n    \"darkMode\": \"深色模式\",\n    \"autoExpandTools\": \"自动展开工具\",\n    \"showRawParameters\": \"显示原始参数\",\n    \"showThinking\": \"显示思考过程\",\n    \"autoScrollToBottom\": \"自动滚动到底部\",\n    \"sendByCtrlEnter\": \"使用 Ctrl+Enter 发送\",\n    \"sendByCtrlEnterDescription\": \"启用后，按 Ctrl+Enter 发送消息，而不是仅按 Enter。这对于使用输入法的用户可以避免意外发送。\",\n    \"dragHandle\": {\n      \"dragging\": \"正在拖拽手柄\",\n      \"closePanel\": \"关闭设置面板\",\n      \"openPanel\": \"打开设置面板\",\n      \"draggingStatus\": \"正在拖拽...\",\n      \"toggleAndMove\": \"点击切换，拖拽移动\"\n    },\n    \"whisper\": {\n      \"modes\": {\n        \"default\": \"默认模式\",\n        \"defaultDescription\": \"直接转录您的语音\",\n        \"prompt\": \"提示词增强\",\n        \"promptDescription\": \"将粗略的想法转化为清晰、详细的 AI 提示词\",\n        \"vibe\": \"Vibe 模式\",\n        \"vibeDescription\": \"将想法格式化为带有详细说明的清晰智能体指令\"\n      }\n    }\n  },\n  \"terminalShortcuts\": {\n    \"title\": \"终端快捷键\",\n    \"sectionKeys\": \"按键\",\n    \"sectionNavigation\": \"导航\",\n    \"escape\": \"Escape\",\n    \"tab\": \"Tab\",\n    \"shiftTab\": \"Shift+Tab\",\n    \"arrowUp\": \"上箭头\",\n    \"arrowDown\": \"下箭头\",\n    \"scrollDown\": \"滚动到底部\",\n    \"handle\": {\n      \"closePanel\": \"关闭快捷键面板\",\n      \"openPanel\": \"打开快捷键面板\"\n    }\n  },\n  \"mainTabs\": {\n    \"label\": \"设置\",\n    \"agents\": \"智能体\",\n    \"appearance\": \"外观\",\n    \"git\": \"Git\",\n    \"apiTokens\": \"API 和令牌\",\n    \"tasks\": \"任务\",\n    \"notifications\": \"通知\",\n    \"plugins\": \"插件\"\n    \n  },\n  \"notifications\": {\n    \"title\": \"通知\",\n    \"description\": \"控制你希望接收的通知事件。\",\n    \"webPush\": {\n      \"title\": \"Web 推送通知\",\n      \"enable\": \"启用推送通知\",\n      \"disable\": \"关闭推送通知\",\n      \"enabled\": \"推送通知已启用\",\n      \"loading\": \"更新中...\",\n      \"unsupported\": \"此浏览器不支持推送通知。\",\n      \"denied\": \"推送通知已被阻止，请在浏览器设置中允许。\"\n    },\n    \"events\": {\n      \"title\": \"事件类型\",\n      \"actionRequired\": \"需要处理\",\n      \"stop\": \"运行已停止\",\n      \"error\": \"运行失败\"\n    }\n  },\n  \"appearanceSettings\": {\n    \"darkMode\": {\n      \"label\": \"深色模式\",\n      \"description\": \"切换浅色和深色主题\"\n    },\n    \"projectSorting\": {\n      \"label\": \"项目排序\",\n      \"description\": \"项目在侧边栏中的排列方式\",\n      \"alphabetical\": \"按字母顺序\",\n      \"recentActivity\": \"最近活动\"\n    },\n    \"codeEditor\": {\n      \"title\": \"代码编辑器\",\n      \"theme\": {\n        \"label\": \"编辑器主题\",\n        \"description\": \"代码编辑器的默认主题\"\n      },\n      \"wordWrap\": {\n        \"label\": \"自动换行\",\n        \"description\": \"在编辑器中默认启用自动换行\"\n      },\n      \"showMinimap\": {\n        \"label\": \"显示缩略图\",\n        \"description\": \"在差异视图中显示缩略图以便于导航\"\n      },\n      \"lineNumbers\": {\n        \"label\": \"显示行号\",\n        \"description\": \"在编辑器中显示行号\"\n      },\n      \"fontSize\": {\n        \"label\": \"字体大小\",\n        \"description\": \"编辑器字体大小（px）\"\n      }\n    }\n  },\n  \"mcpForm\": {\n    \"title\": {\n      \"add\": \"添加 MCP 服务器\",\n      \"edit\": \"编辑 MCP 服务器\"\n    },\n    \"importMode\": {\n      \"form\": \"表单输入\",\n      \"json\": \"JSON 导入\"\n    },\n    \"scope\": {\n      \"label\": \"范围\",\n      \"userGlobal\": \"用户（全局）\",\n      \"projectLocal\": \"项目（本地）\",\n      \"userDescription\": \"用户范围：在您机器上的所有项目中可用\",\n      \"projectDescription\": \"本地范围：仅在选定项目中可用\",\n      \"cannotChange\": \"编辑现有服务器时无法更改范围\"\n    },\n    \"fields\": {\n      \"serverName\": \"服务器名称\",\n      \"transportType\": \"传输类型\",\n      \"command\": \"命令\",\n      \"arguments\": \"参数（每行一个）\",\n      \"jsonConfig\": \"JSON 配置\",\n      \"url\": \"URL\",\n      \"envVars\": \"环境变量（KEY=值，每行一个）\",\n      \"headers\": \"请求头（KEY=值，每行一个）\",\n      \"selectProject\": \"选择项目...\"\n    },\n    \"placeholders\": {\n      \"serverName\": \"我的服务\"\n    },\n    \"validation\": {\n      \"missingType\": \"缺少必填字段：type\",\n      \"stdioRequiresCommand\": \"stdio 类型需要 command 字段\",\n      \"httpRequiresUrl\": \"{{type}} 类型需要 url 字段\",\n      \"invalidJson\": \"无效的 JSON 格式\",\n      \"jsonHelp\": \"粘贴您的 MCP 服务器配置（JSON 格式）。示例格式：\",\n      \"jsonExampleStdio\": \"• stdio: {\\\"type\\\":\\\"stdio\\\",\\\"command\\\":\\\"npx\\\",\\\"args\\\":[\\\"@upstash/context7-mcp\\\"]}\",\n      \"jsonExampleHttp\": \"• http/sse: {\\\"type\\\":\\\"http\\\",\\\"url\\\":\\\"https://api.example.com/mcp\\\"}\"\n    },\n    \"configDetails\": \"配置详细信息（来自 {{configFile}}）\",\n    \"projectPath\": \"路径：{{path}}\",\n    \"actions\": {\n      \"cancel\": \"取消\",\n      \"saving\": \"保存中...\",\n      \"addServer\": \"添加服务器\",\n      \"updateServer\": \"更新服务器\"\n    }\n  },\n  \"saveStatus\": {\n    \"success\": \"设置保存成功！\",\n    \"error\": \"保存设置失败\",\n    \"saving\": \"保存中...\"\n  },\n  \"footerActions\": {\n    \"save\": \"保存设置\",\n    \"cancel\": \"取消\"\n  },\n  \"git\": {\n    \"title\": \"Git 配置\",\n    \"description\": \"配置您的 git 提交身份。这些设置将通过 git config --global 全局应用\",\n    \"name\": {\n      \"label\": \"Git 名称\",\n      \"help\": \"您的 git 提交名称\"\n    },\n    \"email\": {\n      \"label\": \"Git 邮箱\",\n      \"help\": \"您的 git 提交邮箱\"\n    },\n    \"actions\": {\n      \"save\": \"保存配置\",\n      \"saving\": \"保存中...\"\n    },\n    \"status\": {\n      \"success\": \"保存成功\"\n    }\n  },\n  \"apiKeys\": {\n    \"title\": \"API 密钥\",\n    \"description\": \"生成 API 密钥以从其他应用访问外部 API。\",\n    \"newKey\": {\n      \"alertTitle\": \"⚠️ 保存您的 API 密钥\",\n      \"alertMessage\": \"这是您唯一一次看到此密钥。请妥善保存。\",\n      \"iveSavedIt\": \"我已保存\"\n    },\n    \"form\": {\n      \"placeholder\": \"API 密钥名称（例如：生产服务器）\",\n      \"createButton\": \"创建\",\n      \"cancelButton\": \"取消\"\n    },\n    \"newButton\": \"新建 API 密钥\",\n    \"empty\": \"尚未创建 API 密钥。\",\n    \"list\": {\n      \"created\": \"创建时间：\",\n      \"lastUsed\": \"最后使用：\"\n    },\n    \"confirmDelete\": \"确定要删除此 API 密钥吗？\",\n    \"status\": {\n      \"active\": \"激活\",\n      \"inactive\": \"未激活\"\n    },\n    \"github\": {\n      \"title\": \"GitHub 令牌\",\n      \"description\": \"添加 GitHub 个人访问令牌以通过外部 API 克隆私有仓库。\",\n      \"descriptionAlt\": \"添加 GitHub 个人访问令牌以克隆私有仓库。您也可以直接在 API 请求中传递令牌而无需存储。\",\n      \"addButton\": \"添加令牌\",\n      \"form\": {\n        \"namePlaceholder\": \"令牌名称（例如：个人仓库）\",\n        \"tokenPlaceholder\": \"GitHub 个人访问令牌（ghp_...）\",\n        \"descriptionPlaceholder\": \"描述（可选）\",\n        \"addButton\": \"添加令牌\",\n        \"cancelButton\": \"取消\",\n        \"howToCreate\": \"如何创建 GitHub 个人访问令牌 →\"\n      },\n      \"empty\": \"尚未添加 GitHub 令牌。\",\n      \"added\": \"添加时间：\",\n      \"confirmDelete\": \"确定要删除此 GitHub 令牌吗？\"\n    },\n    \"apiDocsLink\": \"API 文档\",\n    \"documentation\": {\n      \"title\": \"外部 API 文档\",\n      \"description\": \"了解如何使用外部 API 从您的应用程序触发 Claude/Cursor 会话。\",\n      \"viewLink\": \"查看 API 文档 →\"\n    },\n    \"loading\": \"加载中...\",\n    \"version\": {\n      \"updateAvailable\": \"有可用更新：v{{version}}\"\n    }\n  },\n  \"tasks\": {\n    \"checking\": \"正在检查 TaskMaster 安装...\",\n    \"notInstalled\": {\n      \"title\": \"未安装 TaskMaster AI CLI\",\n      \"description\": \"需要 TaskMaster CLI 才能使用任务管理功能。安装它以开始使用：\",\n      \"installCommand\": \"npm install -g task-master-ai\",\n      \"viewOnGitHub\": \"在 GitHub 上查看\",\n      \"afterInstallation\": \"安装后：\",\n      \"steps\": {\n        \"restart\": \"重启此应用程序\",\n        \"autoAvailable\": \"TaskMaster 功能将自动可用\",\n        \"initCommand\": \"在项目目录中使用 task-master init\"\n      }\n    },\n    \"settings\": {\n      \"enableLabel\": \"启用 TaskMaster 集成\",\n      \"enableDescription\": \"在整个界面中显示 TaskMaster 任务、横幅和侧边栏指示器\"\n    }\n  },\n  \"agents\": {\n    \"authStatus\": {\n      \"checking\": \"检查中...\",\n      \"connected\": \"已连接\",\n      \"notConnected\": \"未连接\",\n      \"disconnected\": \"已断开\",\n      \"checkingAuth\": \"正在检查认证状态...\",\n      \"loggedInAs\": \"登录为 {{email}}\",\n      \"authenticatedUser\": \"已认证用户\"\n    },\n    \"account\": {\n      \"claude\": {\n        \"description\": \"Anthropic Claude AI 助手\"\n      },\n      \"cursor\": {\n        \"description\": \"Cursor AI 驱动的代码编辑器\"\n      },\n      \"codex\": {\n        \"description\": \"OpenAI Codex AI 助手\"\n      },\n      \"gemini\": {\n        \"description\": \"Google Gemini AI 助手\"\n      }\n    },\n    \"connectionStatus\": \"连接状态\",\n    \"login\": {\n      \"title\": \"登录\",\n      \"reAuthenticate\": \"重新认证\",\n      \"description\": \"登录您的 {{agent}} 账户以启用 AI 功能\",\n      \"reAuthDescription\": \"使用其他账户登录或刷新凭据\",\n      \"button\": \"登录\",\n      \"reLoginButton\": \"重新登录\"\n    },\n    \"error\": \"错误：{{error}}\"\n  },\n  \"permissions\": {\n    \"title\": \"权限设置\",\n    \"skipPermissions\": {\n      \"label\": \"跳过权限提示（请谨慎使用）\",\n      \"claudeDescription\": \"等同于 --dangerously-skip-permissions 标志\",\n      \"cursorDescription\": \"等同于 Cursor CLI 中的 -f 标志\"\n    },\n    \"allowedTools\": {\n      \"title\": \"允许的工具\",\n      \"description\": \"无需权限提示即可自动使用的工具\",\n      \"placeholder\": \"例如：\\\"Bash(git log:*)\\\" 或 \\\"Write\\\"\",\n      \"quickAdd\": \"快速添加常用工具：\",\n      \"empty\": \"未配置允许的工具\"\n    },\n    \"blockedTools\": {\n      \"title\": \"禁用的工具\",\n      \"description\": \"无需权限提示即可自动禁用的工具\",\n      \"placeholder\": \"例如：\\\"Bash(rm:*)\\\"\",\n      \"empty\": \"未配置禁用的工具\"\n    },\n    \"allowedCommands\": {\n      \"title\": \"允许的 Shell 命令\",\n      \"description\": \"无需权限提示即可自动执行的 Shell 命令\",\n      \"placeholder\": \"例如：\\\"Shell(ls)\\\" 或 \\\"Shell(git status)\\\"\",\n      \"quickAdd\": \"快速添加常用命令：\",\n      \"empty\": \"未配置允许的命令\"\n    },\n    \"blockedCommands\": {\n      \"title\": \"阻止的 Shell 命令\",\n      \"description\": \"自动阻止的 Shell 命令\",\n      \"placeholder\": \"例如：\\\"Shell(rm -rf)\\\" 或 \\\"Shell(sudo)\\\"\",\n      \"empty\": \"未配置阻止的命令\"\n    },\n    \"toolExamples\": {\n      \"title\": \"工具模式示例：\",\n      \"bashGitLog\": \"- 允许所有 git log 命令\",\n      \"bashGitDiff\": \"- 允许所有 git diff 命令\",\n      \"write\": \"- 允许所有 Write 工具使用\",\n      \"bashRm\": \"- 阻止所有 rm 命令（危险）\"\n    },\n    \"shellExamples\": {\n      \"title\": \"Shell 命令示例：\",\n      \"ls\": \"- 允许 ls 命令\",\n      \"gitStatus\": \"- 允许 git status\",\n      \"npmInstall\": \"- 允许 npm install\",\n      \"rmRf\": \"- 阻止递归删除\"\n    },\n    \"codex\": {\n      \"permissionMode\": \"权限模式\",\n      \"description\": \"控制 Codex 如何处理文件修改和命令执行\",\n      \"modes\": {\n        \"default\": {\n          \"title\": \"默认\",\n          \"description\": \"只有受信任的命令（ls、cat、grep、git status 等）会自动运行。其他命令将被跳过。可以写入工作区。\"\n        },\n        \"acceptEdits\": {\n          \"title\": \"接受编辑\",\n          \"description\": \"所有命令在工作区内自动运行。具有沙箱执行的全自动模式。\"\n        },\n        \"bypassPermissions\": {\n          \"title\": \"绕过权限\",\n          \"description\": \"完全系统访问，无任何限制。所有命令自动运行，具有完整的磁盘和网络访问权限。请谨慎使用。\"\n        }\n      },\n      \"technicalDetails\": \"技术详情\",\n      \"technicalInfo\": {\n        \"default\": \"sandboxMode=workspace-write, approvalPolicy=untrusted。受信任的命令：cat、cd、grep、head、ls、pwd、tail、git status/log/diff/show、find（不带 -exec）等。\",\n        \"acceptEdits\": \"sandboxMode=workspace-write, approvalPolicy=never。所有命令在项目目录内自动执行。\",\n        \"bypassPermissions\": \"sandboxMode=danger-full-access, approvalPolicy=never。完全系统访问权限，仅在可信环境中使用。\",\n        \"overrideNote\": \"您可以使用聊天界面中的模式按钮按会话覆盖此设置。\"\n      }\n    },\n    \"actions\": {\n      \"add\": \"添加\"\n    }\n  },\n  \"mcpServers\": {\n    \"title\": \"MCP 服务器\",\n    \"description\": {\n      \"claude\": \"Model Context Protocol 服务器为 Claude 提供额外的工具和数据源\",\n      \"cursor\": \"Model Context Protocol 服务器为 Cursor 提供额外的工具和数据源\",\n      \"codex\": \"Model Context Protocol 服务器为 Codex 提供额外的工具和数据源\"\n    },\n    \"addButton\": \"添加 MCP 服务器\",\n    \"empty\": \"未配置 MCP 服务器\",\n    \"serverType\": \"类型\",\n    \"scope\": {\n      \"local\": \"本地\",\n      \"user\": \"用户\"\n    },\n    \"config\": {\n      \"command\": \"命令\",\n      \"url\": \"URL\",\n      \"args\": \"参数\",\n      \"environment\": \"环境变量\"\n    },\n    \"tools\": {\n      \"title\": \"工具\",\n      \"count\": \"（{{count}}）：\",\n      \"more\": \"还有 {{count}} 个\"\n    },\n    \"actions\": {\n      \"edit\": \"编辑服务器\",\n      \"delete\": \"删除服务器\"\n    },\n    \"help\": {\n      \"title\": \"关于 Codex MCP\",\n      \"description\": \"Codex 支持基于 stdio 的 MCP 服务器。您可以添加服务器，通过额外的工具和资源来扩展 Codex 的功能。\"\n    }\n  },\n  \"pluginSettings\": {\n    \"title\": \"插件\",\n    \"description\": \"通过自定义插件扩展界面。从 git 安装或直接将文件夹放入 ~/.claude-code-ui/plugins/\",\n    \"installPlaceholder\": \"https://github.com/user/my-plugin\",\n    \"installButton\": \"安装\",\n    \"installing\": \"安装中…\",\n    \"securityWarning\": \"仅安装您已审查过源代码或信任作者的插件。\",\n    \"scanningPlugins\": \"正在扫描插件…\",\n    \"noPluginsInstalled\": \"未安装插件\",\n    \"pullLatest\": \"从 git 拉取最新内容\",\n    \"noGitRemote\": \"无 git 远程仓库 — 无法更新\",\n    \"uninstallPlugin\": \"卸载插件\",\n    \"confirmUninstall\": \"再次点击确认\",\n    \"confirmUninstallMessage\": \"移除 {{name}}？此操作无法撤销。\",\n    \"cancel\": \"取消\",\n    \"remove\": \"移除\",\n    \"updateFailed\": \"更新失败\",\n    \"installFailed\": \"安装失败\",\n    \"uninstallFailed\": \"卸载失败\",\n    \"toggleFailed\": \"切换失败\",\n    \"buildYourOwn\": \"构建您自己的插件\",\n    \"starter\": \"入门模板\",\n    \"docs\": \"文档\",\n    \"starterPlugin\": {\n      \"name\": \"项目统计\",\n      \"badge\": \"入门\",\n      \"description\": \"查看项目的文件数、代码行数、文件类型分布以及最近活动。\",\n      \"install\": \"安装\"\n    },\n    \"morePlugins\": \"更多\",\n    \"enable\": \"启用\",\n    \"disable\": \"禁用\",\n    \"installAriaLabel\": \"插件 git 仓库 URL\",\n    \"tab\": \"标签\",\n    \"runningStatus\": \"运行中\"\n  }\n}"
  },
  {
    "path": "src/i18n/locales/zh-CN/sidebar.json",
    "content": "{\n  \"projects\": {\n    \"title\": \"项目\",\n    \"newProject\": \"新建项目\",\n    \"deleteProject\": \"删除项目\",\n    \"renameProject\": \"重命名项目\",\n    \"noProjects\": \"未找到项目\",\n    \"loadingProjects\": \"加载项目中...\",\n    \"searchPlaceholder\": \"搜索项目...\",\n    \"projectNamePlaceholder\": \"项目名称\",\n    \"starred\": \"星标\",\n    \"all\": \"全部\",\n    \"untitledSession\": \"未命名会话\",\n    \"newSession\": \"新会话\",\n    \"codexSession\": \"Codex 会话\",\n    \"fetchingProjects\": \"正在获取您的 Claude 项目和会话\",\n    \"projects\": \"项目\",\n    \"noMatchingProjects\": \"未找到匹配的项目\",\n    \"tryDifferentSearch\": \"尝试调整您的搜索词\",\n    \"runClaudeCli\": \"在项目目录中运行 Claude CLI 以开始使用\"\n  },\n  \"app\": {\n    \"title\": \"Claude Code UI\",\n    \"subtitle\": \"AI 编程助手\"\n  },\n  \"sessions\": {\n    \"title\": \"会话\",\n    \"newSession\": \"新建会话\",\n    \"deleteSession\": \"删除会话\",\n    \"renameSession\": \"重命名会话\",\n    \"noSessions\": \"暂无会话\",\n    \"loadingSessions\": \"加载会话中...\",\n    \"unnamed\": \"未命名\",\n    \"loading\": \"加载中...\",\n    \"showMore\": \"显示更多会话\"\n  },\n  \"tooltips\": {\n    \"viewEnvironments\": \"查看环境\",\n    \"hideSidebar\": \"隐藏侧边栏\",\n    \"createProject\": \"创建新项目\",\n    \"refresh\": \"刷新项目和会话 (Ctrl+R)\",\n    \"renameProject\": \"重命名项目 (F2)\",\n    \"deleteProject\": \"删除空项目 (Delete)\",\n    \"addToFavorites\": \"添加到收藏\",\n    \"removeFromFavorites\": \"从收藏移除\",\n    \"editSessionName\": \"手动编辑会话名称\",\n    \"deleteSession\": \"永久删除此会话\",\n    \"save\": \"保存\",\n    \"cancel\": \"取消\",\n    \"clearSearch\": \"清除搜索\"\n  },\n  \"navigation\": {\n    \"chat\": \"聊天\",\n    \"files\": \"文件\",\n    \"git\": \"Git\",\n    \"terminal\": \"终端\",\n    \"tasks\": \"任务\"\n  },\n  \"actions\": {\n    \"refresh\": \"刷新\",\n    \"settings\": \"设置\",\n    \"collapseAll\": \"全部折叠\",\n    \"expandAll\": \"全部展开\",\n    \"cancel\": \"取消\",\n    \"save\": \"保存\",\n    \"delete\": \"删除\",\n    \"rename\": \"重命名\",\n    \"joinCommunity\": \"加入社区\"\n  },\n  \"status\": {\n    \"active\": \"活动\",\n    \"inactive\": \"非活动\",\n    \"thinking\": \"思考中...\",\n    \"error\": \"错误\",\n    \"aborted\": \"已中止\",\n    \"unknown\": \"未知\"\n  },\n  \"time\": {\n    \"justNow\": \"刚刚\",\n    \"oneMinuteAgo\": \"1 分钟前\",\n    \"minutesAgo\": \"{{count}} 分钟前\",\n    \"oneHourAgo\": \"1 小时前\",\n    \"hoursAgo\": \"{{count}} 小时前\",\n    \"oneDayAgo\": \"1 天前\",\n    \"daysAgo\": \"{{count}} 天前\"\n  },\n  \"messages\": {\n    \"deleteConfirm\": \"确定要删除吗？\",\n    \"renameSuccess\": \"重命名成功\",\n    \"deleteSuccess\": \"删除成功\",\n    \"errorOccurred\": \"发生错误\",\n    \"deleteSessionConfirm\": \"确定要删除此会话吗？此操作无法撤销。\",\n    \"deleteProjectConfirm\": \"确定要删除此空项目吗？此操作无法撤销。\",\n    \"enterProjectPath\": \"请输入项目路径\",\n    \"deleteSessionFailed\": \"删除会话失败，请重试。\",\n    \"deleteSessionError\": \"删除会话时出错，请重试。\",\n    \"renameSessionFailed\": \"重命名会话失败，请重试。\",\n    \"renameSessionError\": \"重命名会话时出错，请重试。\",\n    \"deleteProjectFailed\": \"删除项目失败，请重试。\",\n    \"deleteProjectError\": \"删除项目时出错，请重试。\",\n    \"createProjectFailed\": \"创建项目失败，请重试。\",\n    \"createProjectError\": \"创建项目时出错，请重试。\"\n  },\n  \"version\": {\n    \"updateAvailable\": \"有可用更新\"\n  },\n  \"search\": {\n    \"modeProjects\": \"项目\",\n    \"modeConversations\": \"对话\",\n    \"conversationsPlaceholder\": \"搜索对话内容...\",\n    \"searching\": \"搜索中...\",\n    \"noResults\": \"未找到结果\",\n    \"tryDifferentQuery\": \"尝试不同的搜索词\",\n    \"matches_one\": \"{{count}} 个匹配\",\n    \"matches_other\": \"{{count}} 个匹配\",\n    \"projectsScanned_one\": \"{{count}} 个项目已扫描\",\n    \"projectsScanned_other\": \"{{count}} 个项目已扫描\"\n  },\n  \"deleteConfirmation\": {\n    \"deleteProject\": \"删除项目\",\n    \"deleteSession\": \"删除会话\",\n    \"confirmDelete\": \"您确定要删除\",\n    \"sessionCount_one\": \"此项目包含 {{count}} 个对话。\",\n    \"sessionCount_other\": \"此项目包含 {{count}} 个对话。\",\n    \"allConversationsDeleted\": \"所有对话将被永久删除。\",\n    \"cannotUndo\": \"此操作无法撤销。\"\n  }\n}\n"
  },
  {
    "path": "src/index.css",
    "content": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n/* Global spinner animation - defined early to ensure it loads */\n@keyframes spin {\n  0% {\n    transform: rotate(0deg);\n  }\n  100% {\n    transform: rotate(360deg);\n  }\n}\n\n@-webkit-keyframes spin {\n  0% {\n    -webkit-transform: rotate(0deg);\n  }\n  100% {\n    -webkit-transform: rotate(360deg);\n  }\n}\n\n@layer base {\n  :root {\n    --background: 0 0% 100%;\n    --foreground: 222.2 84% 4.9%;\n    --card: 0 0% 100%;\n    --card-foreground: 222.2 84% 4.9%;\n    --popover: 0 0% 100%;\n    --popover-foreground: 222.2 84% 4.9%;\n    --primary: 221.2 83.2% 53.3%;\n    --primary-foreground: 210 40% 98%;\n    --secondary: 210 40% 96.1%;\n    --secondary-foreground: 222.2 47.4% 11.2%;\n    --muted: 210 40% 96.1%;\n    --muted-foreground: 215.4 16.3% 46.9%;\n    --accent: 210 40% 96.1%;\n    --accent-foreground: 222.2 47.4% 11.2%;\n    --destructive: 0 84.2% 60.2%;\n    --destructive-foreground: 210 40% 98%;\n    --border: 214.3 31.8% 91.4%;\n    --input: 214.3 31.8% 91.4%;\n    --ring: 221.2 83.2% 53.3%;\n    --radius: 0.5rem;\n\n    /* Nav design tokens */\n    --nav-glass-bg: 0 0% 100% / 0.7;\n    --nav-glass-blur: 20px;\n    --nav-glass-saturate: 1.8;\n    --nav-tab-glow: 221.2 83.2% 53.3% / 0.18;\n    --nav-tab-ring: 221.2 83.2% 53.3% / 0.10;\n    --nav-float-shadow: 0 0% 0% / 0.06;\n    --nav-float-ring: 214.3 31.8% 91.4% / 0.5;\n    --nav-divider-color: 214.3 31.8% 91.4% / 0.5;\n    --nav-input-bg: 210 40% 96.1% / 0.5;\n    --nav-input-focus-ring: 221.2 83.2% 53.3% / 0.22;\n\n    /* Safe area CSS variables */\n    --safe-area-inset-top: env(safe-area-inset-top);\n    --safe-area-inset-right: env(safe-area-inset-right);\n    --safe-area-inset-bottom: env(safe-area-inset-bottom);\n    --safe-area-inset-left: env(safe-area-inset-left);\n\n    /* Mobile navigation dimensions - Single source of truth */\n    /* Floating nav: ~52px bar + 8px bottom margin + 12px px-3 top spacing */\n    --mobile-nav-height: 52px;\n    --mobile-nav-padding: 20px;\n    --mobile-nav-total: calc(var(--mobile-nav-height) + var(--mobile-nav-padding) + env(safe-area-inset-bottom, 0px));\n\n    /* Header safe area dimensions */\n    --header-safe-area-top: env(safe-area-inset-top, 0px);\n    --header-base-padding: 8px;\n    --header-total-padding: calc(var(--header-safe-area-top) + var(--header-base-padding));\n  }\n  \n  /* Fallback for older iOS versions */\n  @supports (padding-top: constant(safe-area-inset-top)) {\n    :root {\n      --safe-area-inset-top: constant(safe-area-inset-top);\n      --safe-area-inset-right: constant(safe-area-inset-right);\n      --safe-area-inset-bottom: constant(safe-area-inset-bottom);\n      --safe-area-inset-left: constant(safe-area-inset-left);\n    }\n  }\n\n  .dark {\n    --background: 222.2 84% 4.9%;\n    --foreground: 210 40% 98%;\n    --card: 217.2 91.2% 8%;\n    --card-foreground: 210 40% 98%;\n    --popover: 217.2 91.2% 8%;\n    --popover-foreground: 210 40% 98%;\n    --primary: 217.2 91.2% 59.8%;\n    --primary-foreground: 222.2 47.4% 11.2%;\n    --secondary: 217.2 32.6% 17.5%;\n    --secondary-foreground: 210 40% 98%;\n    --muted: 217.2 32.6% 17.5%;\n    --muted-foreground: 215 20.2% 65.1%;\n    --accent: 217.2 32.6% 17.5%;\n    --accent-foreground: 210 40% 98%;\n    --destructive: 0 62.8% 30.6%;\n    --destructive-foreground: 210 40% 98%;\n    --border: 217.2 32.6% 17.5%;\n    --input: 220 13% 46%;\n    --ring: 217.2 91.2% 59.8%;\n\n    /* Nav design tokens — dark overrides */\n    --nav-glass-bg: 217.2 91.2% 8% / 0.55;\n    --nav-glass-blur: 24px;\n    --nav-glass-saturate: 1.6;\n    --nav-tab-glow: 217.2 91.2% 59.8% / 0.25;\n    --nav-tab-ring: 217.2 91.2% 59.8% / 0.15;\n    --nav-float-shadow: 0 0% 0% / 0.35;\n    --nav-float-ring: 217.2 32.6% 17.5% / 0.3;\n    --nav-divider-color: 217.2 32.6% 17.5% / 0.5;\n    --nav-input-bg: 217.2 32.6% 17.5% / 0.5;\n    --nav-input-focus-ring: 217.2 91.2% 59.8% / 0.25;\n  }\n}\n\n@layer base {\n  * {\n    @apply border-border;\n    box-sizing: border-box;\n    transition: none;\n  }\n  \n  body {\n    @apply bg-background text-foreground;\n    font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif;\n    margin: 0;\n    padding: 0;\n  }\n\n  html, body {\n    height: 100%;\n    margin: 0;\n    padding: 0;\n  }\n  \n  /* Root element with safe area padding for PWA */\n  #root {\n    min-height: 100vh;\n    margin: 0;\n    padding: 0;\n    box-sizing: border-box;\n  }\n  \n  /* Apply safe area padding in standalone mode */\n  @supports (padding-top: env(safe-area-inset-top)) {\n    @media (display-mode: standalone) {\n      #root {\n        padding-top: var(--safe-area-inset-top);\n        padding-left: var(--safe-area-inset-left);\n        padding-right: var(--safe-area-inset-right);\n      }\n    }\n  }\n\n  /* PWA mode detected by JavaScript - more reliable */\n  body.pwa-mode #root {\n    padding-left: var(--safe-area-inset-left);\n    padding-right: var(--safe-area-inset-right);\n    height: 100vh;\n    overflow: hidden;\n  }\n\n  /* Adjust fixed inset positioning in PWA mode */\n  body.pwa-mode .fixed.inset-0 {\n    top: var(--header-total-padding);\n    left: var(--safe-area-inset-left);\n    right: var(--safe-area-inset-right);\n    bottom: 0;\n  }\n  \n  /* Global transition defaults */\n  button, \n  a, \n  input, \n  textarea, \n  select,\n  [role=\"button\"],\n  .transition-all {\n    transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);\n  }\n  \n  /* Color transitions for theme switching - exclude interactive elements */\n  body, div, section, article, aside, header, footer, nav, main,\n  h1, h2, h3, h4, h5, h6, p, span, blockquote,\n  ul, ol, li, dl, dt, dd,\n  table, thead, tbody, tfoot, tr, td, th,\n  form, fieldset, legend, label {\n    transition: background-color 200ms ease-in-out, \n                border-color 200ms ease-in-out,\n                color 200ms ease-in-out;\n  }\n  \n  /* Transform transitions */\n  .transition-transform {\n    transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1);\n  }\n  \n  /* Opacity transitions */\n  .transition-opacity {\n    transition: opacity 200ms ease-in-out;\n  }\n  \n  /* Scale transitions for interactions */\n  .transition-scale {\n    transition: transform 100ms cubic-bezier(0.4, 0, 0.2, 1);\n  }\n  \n  /* Shadow transitions */\n  .transition-shadow {\n    transition: box-shadow 200ms ease-in-out;\n  }\n  \n  /* Respect reduced motion preference */\n  @media (prefers-reduced-motion: reduce) {\n    *,\n    ::before,\n    ::after {\n      animation-duration: 0.01ms !important;\n      animation-iteration-count: 1 !important;\n      transition-duration: 0.01ms !important;\n      scroll-behavior: auto !important;\n    }\n  }\n}\n\n@layer utilities {\n  /* Smooth hover transitions for interactive elements */\n  button:hover,\n  a:hover,\n  [role=\"button\"]:hover {\n    transition-duration: 100ms;\n  }\n  \n  /* Active state transitions */\n  button:active,\n  a:active,\n  [role=\"button\"]:active {\n    transition-duration: 50ms;\n  }\n  \n  /* Focus transitions */\n  button:focus-visible,\n  a:focus-visible,\n  input:focus-visible,\n  textarea:focus-visible,\n  select:focus-visible {\n    transition: outline-offset 150ms ease-out, box-shadow 150ms ease-out;\n  }\n  \n  /* Sidebar transitions */\n  .sidebar-transition {\n    transition: transform 300ms cubic-bezier(0.4, 0, 0.2, 1),\n                opacity 300ms ease-in-out;\n  }\n\n  /* Nav glass surface — uses theme tokens */\n  .nav-glass {\n    background: hsl(var(--nav-glass-bg));\n    backdrop-filter: blur(var(--nav-glass-blur)) saturate(var(--nav-glass-saturate));\n    -webkit-backdrop-filter: blur(var(--nav-glass-blur)) saturate(var(--nav-glass-saturate));\n  }\n\n  /* Nav tab active pill glow — uses theme tokens */\n  .nav-tab-active {\n    box-shadow: 0 1px 8px hsl(var(--nav-tab-glow)),\n                0 0 0 1px hsl(var(--nav-tab-ring));\n  }\n\n  /* Floating mobile nav bar — uses theme tokens */\n  .mobile-nav-float {\n    box-shadow: 0 -1px 20px hsl(var(--nav-float-shadow)),\n                0 0 0 1px hsl(var(--nav-float-ring));\n  }\n\n  /* Subtle sidebar divider — uses theme tokens */\n  .nav-divider {\n    height: 1px;\n    background: linear-gradient(90deg, transparent, hsl(var(--nav-divider-color)) 20%, hsl(var(--nav-divider-color)) 80%, transparent);\n  }\n\n  /* Nav search input surface — uses theme tokens */\n  .nav-search-input {\n    background: hsl(var(--nav-input-bg));\n    border: none;\n  }\n\n  .nav-search-input:focus-within {\n    background: hsl(var(--background));\n    box-shadow: 0 0 0 2px hsl(var(--nav-input-focus-ring));\n  }\n  \n  /* Modal and dropdown transitions */\n  .modal-transition {\n    transition: opacity 200ms ease-in-out,\n                transform 200ms cubic-bezier(0.4, 0, 0.2, 1);\n  }\n  \n  /* Chat message transitions */\n  .message-transition {\n    transition: opacity 300ms ease-in-out,\n                transform 300ms cubic-bezier(0.4, 0, 0.2, 1);\n  }\n  \n  /* Height transitions for expanding elements */\n  .height-transition {\n    transition: height 200ms ease-in-out,\n                max-height 200ms ease-in-out;\n  }\n  \n  .scrollbar-thin {\n    scrollbar-width: thin;\n    scrollbar-color: hsl(var(--muted-foreground)) transparent;\n  }\n  \n  .scrollbar-thin::-webkit-scrollbar {\n    width: 6px;\n    height: 6px;\n  }\n  \n  .scrollbar-thin::-webkit-scrollbar-track {\n    background: transparent;\n  }\n  \n  .scrollbar-thin::-webkit-scrollbar-thumb {\n    background-color: hsl(var(--muted-foreground));\n    border-radius: 3px;\n  }\n  \n  .scrollbar-thin::-webkit-scrollbar-thumb:hover {\n    background-color: hsl(var(--muted-foreground) / 0.8);\n  }\n  \n  /* Dark mode scrollbar styles */\n  .dark .scrollbar-thin {\n    scrollbar-color: rgba(156, 163, 175, 0.5) transparent;\n  }\n  \n  .dark .scrollbar-thin::-webkit-scrollbar-track {\n    background: rgba(31, 41, 55, 0.3);\n  }\n  \n  .dark .scrollbar-thin::-webkit-scrollbar-thumb {\n    background-color: rgba(156, 163, 175, 0.5);\n    border-radius: 3px;\n  }\n  \n  .dark .scrollbar-thin::-webkit-scrollbar-thumb:hover {\n    background-color: rgba(156, 163, 175, 0.7);\n  }\n  \n  /* Global scrollbar styles for main content areas */\n  .dark::-webkit-scrollbar {\n    width: 8px;\n    height: 8px;\n  }\n  \n  .dark::-webkit-scrollbar-track {\n    background: rgba(31, 41, 55, 0.5);\n  }\n  \n  .dark::-webkit-scrollbar-thumb {\n    background-color: rgba(107, 114, 128, 0.5);\n    border-radius: 4px;\n  }\n  \n  .dark::-webkit-scrollbar-thumb:hover {\n    background-color: rgba(107, 114, 128, 0.7);\n  }\n  \n  /* Firefox scrollbar styles */\n  .dark {\n    scrollbar-width: thin;\n    scrollbar-color: rgba(107, 114, 128, 0.5) rgba(31, 41, 55, 0.5);\n  }\n  \n  /* Ensure checkbox styling is preserved */\n  input[type=\"checkbox\"] {\n    @apply accent-blue-600;\n    opacity: 1;\n  }\n  \n  input[type=\"checkbox\"]:focus {\n    opacity: 1;\n    outline: 2px solid hsl(var(--ring));\n    outline-offset: 2px;\n  }\n  \n  /* Fix checkbox appearance in dark mode */\n  .dark input[type=\"checkbox\"] {\n    background-color: rgb(31 41 55); /* gray-800 */\n    border-color: rgb(75 85 99); /* gray-600 */\n    color: rgb(37 99 235); /* blue-600 */\n    color-scheme: dark;\n  }\n  \n  .dark input[type=\"checkbox\"]:checked {\n    background-color: rgb(37 99 235); /* blue-600 */\n    border-color: rgb(37 99 235); /* blue-600 */\n  }\n  \n  .dark input[type=\"checkbox\"]:focus {\n    --tw-ring-color: rgb(59 130 246); /* blue-500 */\n    --tw-ring-offset-color: rgb(31 41 55); /* gray-800 */\n  }\n  \n  /* Fix radio button appearance in dark mode */\n  .dark input[type=\"radio\"] {\n    background-color: rgb(31 41 55); /* gray-800 */\n    border-color: rgb(75 85 99); /* gray-600 */\n    color: rgb(37 99 235); /* blue-600 */\n    color-scheme: dark;\n  }\n  \n  .dark input[type=\"radio\"]:checked {\n    background-color: rgb(37 99 235); /* blue-600 */\n    border-color: rgb(37 99 235); /* blue-600 */\n  }\n  \n  .dark input[type=\"radio\"]:focus {\n    --tw-ring-color: rgb(59 130 246); /* blue-500 */\n    --tw-ring-offset-color: rgb(31 41 55); /* gray-800 */\n  }\n  \n  /* Ensure textarea text is always visible in dark mode */\n  textarea {\n    color-scheme: light dark;\n  }\n  \n  .dark textarea {\n    color: rgb(243 244 246) !important; /* gray-100 */\n    -webkit-text-fill-color: rgb(243 244 246) !important;\n    caret-color: rgb(243 244 246) !important;\n  }\n  \n  /* Fix for focus state in dark mode */\n  .dark textarea:focus {\n    color: rgb(243 244 246) !important;\n    -webkit-text-fill-color: rgb(243 244 246) !important;\n  }\n  \n  /* Fix for iOS/Safari dark mode textarea issues */\n  @supports (-webkit-touch-callout: none) {\n    .dark textarea {\n      background-color: transparent !important;\n      color: rgb(243 244 246) !important;\n      -webkit-text-fill-color: rgb(243 244 246) !important;\n    }\n    \n    .dark textarea:focus {\n      background-color: transparent !important;\n      color: rgb(243 244 246) !important;\n      -webkit-text-fill-color: rgb(243 244 246) !important;\n    }\n  }\n  \n  /* Ensure parent container doesn't override textarea styles */\n  .dark .bg-gray-800 textarea {\n    color: rgb(243 244 246) !important;\n    -webkit-text-fill-color: rgb(243 244 246) !important;\n  }\n  \n  /* Fix focus-within container issues in dark mode */\n  .dark .focus-within\\:ring-2:focus-within {\n    background-color: rgb(31 41 55) !important; /* gray-800 */\n  }\n  \n  /* Ensure textarea remains transparent with visible text */\n  .dark textarea.bg-transparent {\n    background-color: transparent !important;\n    color: rgb(243 244 246) !important;\n    -webkit-text-fill-color: rgb(243 244 246) !important;\n  }\n  \n  /* Fix placeholder text color to be properly gray */\n  textarea::placeholder {\n    color: rgb(156 163 175) !important; /* gray-400 */\n    opacity: 1 !important;\n  }\n  \n  .dark textarea::placeholder {\n    color: rgb(75 85 99) !important; /* gray-600 - darker gray */\n    opacity: 1 !important;\n  }\n  \n  /* More specific selector for chat input textarea */\n  .dark .bg-gray-800 textarea::placeholder,\n  .dark textarea.bg-transparent::placeholder {\n    color: rgb(75 85 99) !important; /* gray-600 - darker gray */\n    opacity: 1 !important;\n    -webkit-text-fill-color: rgb(75 85 99) !important;\n  }\n  \n  /* Custom class for chat input placeholder */\n  .chat-input-placeholder::placeholder {\n    color: rgb(156 163 175) !important;\n    opacity: 1 !important;\n  }\n\n  .dark .chat-input-placeholder::placeholder {\n    color: rgb(75 85 99) !important;\n    opacity: 1 !important;\n    -webkit-text-fill-color: rgb(75 85 99) !important;\n  }\n\n  .chat-input-placeholder::-webkit-input-placeholder {\n    color: rgb(156 163 175) !important;\n    opacity: 1 !important;\n  }\n\n  .dark .chat-input-placeholder::-webkit-input-placeholder {\n    color: rgb(75 85 99) !important;\n    opacity: 1 !important;\n    -webkit-text-fill-color: rgb(75 85 99) !important;\n  }\n  \n  /* WebKit specific placeholder styles */\n  textarea::-webkit-input-placeholder {\n    color: rgb(156 163 175) !important;\n    opacity: 1 !important;\n  }\n  \n  .dark textarea::-webkit-input-placeholder {\n    color: rgb(75 85 99) !important; /* gray-600 - darker gray */\n    opacity: 1 !important;\n  }\n  \n  /* Mozilla specific placeholder styles */\n  textarea::-moz-placeholder {\n    color: rgb(156 163 175) !important;\n    opacity: 1 !important;\n  }\n  \n  .dark textarea::-moz-placeholder {\n    color: rgb(75 85 99) !important; /* gray-600 - darker gray */\n    opacity: 1 !important;\n  }\n  \n  /* IE/Edge specific placeholder styles */\n  textarea:-ms-input-placeholder {\n    color: rgb(156 163 175) !important;\n    opacity: 1 !important;\n  }\n  \n  .dark textarea:-ms-input-placeholder {\n    color: rgb(75 85 99) !important; /* gray-600 - darker gray */\n    opacity: 1 !important;\n  }\n}\n\n/* Mobile optimizations and components */\n@layer components {\n  /* Mobile touch optimization and safe areas */\n  @media (max-width: 768px) {\n    * {\n      touch-action: manipulation;\n      -webkit-tap-highlight-color: transparent;\n    }\n    \n    /* Allow vertical scrolling in scroll containers */\n    .overflow-y-auto, [data-scroll-container] {\n      touch-action: pan-y;\n      -webkit-overflow-scrolling: touch;\n    }\n    \n    /* Preserve checkbox visibility */\n    input[type=\"checkbox\"] {\n      -webkit-tap-highlight-color: rgba(0, 0, 0, 0.1);\n      opacity: 1 !important;\n    }\n    \n    button, \n    [role=\"button\"],\n    .clickable,\n    a,\n    .cursor-pointer {\n      -webkit-tap-highlight-color: transparent;\n      user-select: none;\n      -webkit-user-select: none;\n      touch-action: manipulation;\n    }\n    \n    /* Better mobile touch targets */\n    .mobile-touch-target {\n      @apply min-h-[44px] min-w-[44px];\n    }\n    \n    /* Chat message improvements */\n    .chat-message.user {\n      @apply justify-end;\n    }\n    \n    .chat-message.user > div {\n      @apply max-w-[85%];\n    }\n    \n    .chat-message.assistant > div,\n    .chat-message.error > div {\n      @apply w-full sm:max-w-[95%];\n    }\n    \n    /* Session name truncation on mobile */\n    .session-name-mobile {\n      @apply truncate;\n      max-width: calc(100vw - 120px); /* Account for sidebar padding and buttons */\n    }\n    \n    /* Enable text selection on mobile for terminal */\n    .xterm,\n    .xterm .xterm-viewport {\n      -webkit-user-select: text !important;\n      user-select: text !important;\n      -webkit-touch-callout: default !important;\n    }\n    \n    /* Fix mobile scrolling */\n    .overflow-y-auto {\n      touch-action: pan-y;\n      -webkit-overflow-scrolling: touch;\n    }\n    \n    .chat-message {\n      touch-action: pan-y;\n    }\n    \n    /* Fix hover states on mobile */\n    .group:active .group-hover\\:opacity-100,\n    .group .group-hover\\:opacity-100 {\n      opacity: 1 !important;\n    }\n    \n    @media (hover: none) and (pointer: coarse) {\n      .group-hover\\:opacity-100 {\n        opacity: 1 !important;\n      }\n      \n      .hover\\:bg-gray-50:hover,\n      .hover\\:bg-gray-100:hover,\n      .hover\\:bg-red-200:hover,\n      .dark\\:hover\\:bg-gray-700:hover,\n      .dark\\:hover\\:bg-red-900\\/50:hover {\n        background-color: inherit;\n      }\n    }\n  }\n  \n  /* Touch device optimizations for all screen sizes */\n  @media (hover: none) and (pointer: coarse) {\n    .touch\\:opacity-100 {\n      opacity: 1 !important;\n    }\n    \n    /* Completely disable hover states on touch devices */\n    * {\n      -webkit-tap-highlight-color: transparent !important;\n    }\n    \n    /* Preserve checkbox visibility on touch devices */\n    input[type=\"checkbox\"] {\n      -webkit-tap-highlight-color: rgba(0, 0, 0, 0.1) !important;\n      opacity: 1 !important;\n    }\n    \n    /* Only disable hover states for interactive elements, not containers */\n    button:hover,\n    [role=\"button\"]:hover,\n    .cursor-pointer:hover,\n    a:hover,\n    .hover\\:bg-gray-50:hover,\n    .hover\\:bg-gray-100:hover,\n    .hover\\:text-gray-900:hover,\n    .hover\\:opacity-100:hover {\n      background-color: inherit !important;\n      color: inherit !important;\n      opacity: inherit !important;\n      transform: inherit !important;\n    }\n    \n    /* Force buttons to be immediately clickable */\n    button, [role=\"button\"], .cursor-pointer {\n      cursor: pointer !important;\n      pointer-events: auto !important;\n    }\n    \n    /* Keep active states for immediate feedback */\n    .active\\:scale-\\[0\\.98\\]:active,\n    .active\\:scale-95:active {\n      transform: scale(0.98) !important;\n    }\n  }\n  \n  /* Safe area support for iOS devices */\n  .ios-bottom-safe {\n    padding-bottom: env(safe-area-inset-bottom);\n  }\n  \n  /* PWA specific header adjustments - uses CSS variables for consistency */\n  .pwa-header-safe {\n    padding-top: var(--header-base-padding);\n  }\n\n  /* When PWA mode is detected by JavaScript */\n  body.pwa-mode .pwa-header-safe {\n    /* Reset padding since #root already handles safe area */\n    padding-top: 0px !important;\n  }\n\n  /* For mobile PWA, add bottom padding for better spacing */\n  @media screen and (max-width: 768px) {\n    body.pwa-mode .pwa-header-safe {\n      padding-bottom: 8px;\n    }\n  }\n  \n  @media screen and (max-width: 768px) {\n    .chat-input-mobile {\n      padding-bottom: calc(60px + env(safe-area-inset-bottom));\n    }\n  }\n\n  /* Text wrapping improvements */\n  .chat-message {\n    word-wrap: break-word;\n    overflow-wrap: break-word;\n    hyphens: auto;\n  }\n\n  /* Force wrap long URLs and code */\n  .chat-message pre,\n  .chat-message code {\n    white-space: pre-wrap !important;\n    word-break: break-all;\n    overflow-wrap: break-word;\n  }\n\n  /* Prevent horizontal scroll in chat area */\n  .chat-message * {\n    max-width: 100%;\n    box-sizing: border-box;\n  }\n\n  /* Hide scrollbar utility for horizontal scroll */\n  .scrollbar-hide {\n    -ms-overflow-style: none;\n    scrollbar-width: none;\n  }\n\n  .scrollbar-hide::-webkit-scrollbar {\n    display: none;\n  }\n}\n\n/* Hide markdown backticks in prose content */\n.prose code::before,\n.prose code::after {\n  content: \"\" !important;\n  display: none !important;\n}\n\n/* Custom spinner animation for mobile compatibility */\n@layer utilities {\n  @keyframes spin {\n    from {\n      transform: rotate(0deg);\n    }\n    to {\n      transform: rotate(360deg);\n    }\n  }\n  \n  .animate-spin {\n    animation: spin 1s linear infinite;\n  }\n  \n  /* Force hardware acceleration for smoother animation on mobile */\n  .loading-spinner {\n    animation: spin 1s linear infinite;\n    will-change: transform;\n    transform: translateZ(0);\n    -webkit-transform: translateZ(0);\n    backface-visibility: hidden;\n    -webkit-backface-visibility: hidden;\n  }\n  \n  /* Improved textarea styling */\n  .chat-input-placeholder {\n    display: block !important;\n    scrollbar-width: thin;\n    scrollbar-color: rgba(156, 163, 175, 0.3) transparent;\n  }\n\n  /* Ensure container fits textarea tightly */\n  .chat-input-placeholder:not(:focus) {\n    height: auto;\n  }\n  \n  .chat-input-placeholder::-webkit-scrollbar {\n    width: 6px;\n  }\n  \n  .chat-input-placeholder::-webkit-scrollbar-track {\n    background: transparent;\n    margin: 8px 0;\n  }\n  \n  .chat-input-placeholder::-webkit-scrollbar-thumb {\n    background-color: rgba(156, 163, 175, 0.3);\n    border-radius: 3px;\n    transition: background-color 0.2s;\n  }\n  \n  .chat-input-placeholder::-webkit-scrollbar-thumb:hover {\n    background-color: rgba(156, 163, 175, 0.5);\n  }\n  \n  .dark .chat-input-placeholder {\n    scrollbar-color: rgba(107, 114, 128, 0.3) transparent;\n  }\n  \n  .dark .chat-input-placeholder::-webkit-scrollbar-thumb {\n    background-color: rgba(107, 114, 128, 0.3);\n  }\n  \n  .dark .chat-input-placeholder::-webkit-scrollbar-thumb:hover {\n    background-color: rgba(107, 114, 128, 0.5);\n  }\n  \n  /* Enhanced box shadow when textarea expands */\n  .chat-input-expanded {\n    box-shadow: 0 -5px 15px -3px rgba(0, 0, 0, 0.1), 0 -4px 6px -2px rgba(0, 0, 0, 0.05);\n  }\n  \n  .dark .chat-input-expanded {\n    box-shadow: 0 -5px 15px -3px rgba(0, 0, 0, 0.3), 0 -4px 6px -2px rgba(0, 0, 0, 0.2);\n  }\n  \n  /* Fix focus ring offset color in dark mode */\n  .dark [class*=\"ring-offset\"] {\n    --tw-ring-offset-color: rgb(31 41 55); /* gray-800 */\n  }\n  \n  /* Ensure buttons don't show white backgrounds in dark mode */\n  .dark button:focus {\n    --tw-ring-offset-color: rgb(31 41 55); /* gray-800 */\n  }\n  \n  /* Fix mobile select dropdown styling */\n  @supports (-webkit-touch-callout: none) {\n    select {\n      font-size: 16px !important;\n      -webkit-appearance: none;\n    }\n  }\n  \n  /* Improve select appearance in dark mode */\n  .dark select {\n    background-image: url(\"data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%239CA3AF' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e\");\n    background-repeat: no-repeat;\n    background-position: right 0.5rem center;\n    background-size: 1.5em 1.5em;\n    padding-right: 2.5rem;\n    -webkit-appearance: none;\n    -moz-appearance: none;\n    appearance: none;\n  }\n  \n  select {\n    background-image: url(\"data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e\");\n    background-repeat: no-repeat;\n    background-position: right 0.5rem center;\n    background-size: 1.5em 1.5em;\n    padding-right: 2.5rem;\n    -webkit-appearance: none;\n    -moz-appearance: none;\n    appearance: none;\n  }\n  \n  /* Fix select option text in mobile */\n  select option {\n    font-size: 16px !important;\n    padding: 8px !important;\n    background-color: var(--background) !important;\n    color: var(--foreground) !important;\n  }\n  \n  .dark select option {\n    background-color: rgb(31 41 55) !important;\n    color: rgb(243 244 246) !important;\n  }\n\n  /* Tool details chevron animation */\n  details[open] .details-chevron,\n  details[open] summary svg[class*=\"group-open\"] {\n    transform: rotate(180deg);\n  }\n\n  /* Smooth chevron transition */\n  .details-chevron,\n  summary svg[class*=\"transition-transform\"] {\n    transition: transform 200ms cubic-bezier(0.4, 0, 0.2, 1);\n  }\n\n  /* Settings content fade-in transition */\n  @keyframes settings-fade-in {\n    from { opacity: 0; transform: translateY(4px); }\n    to { opacity: 1; transform: translateY(0); }\n  }\n\n  .settings-content-enter {\n    animation: settings-fade-in 150ms ease-out;\n  }\n\n  /* Search result highlight flash */\n  .search-highlight-flash {\n    animation: search-flash 4s ease-out;\n  }\n\n  @keyframes search-flash {\n    0%, 50% {\n      outline: 3px solid hsl(var(--primary));\n      outline-offset: 4px;\n      border-radius: 8px;\n      background-color: hsl(var(--primary) / 0.06);\n    }\n    100% {\n      outline: 3px solid transparent;\n      outline-offset: 4px;\n      background-color: transparent;\n    }\n  }\n}\n"
  },
  {
    "path": "src/lib/utils.js",
    "content": "import { clsx } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs) {\n  return twMerge(clsx(inputs))\n}\n\nexport function safeJsonParse(value) {\n  if (!value || typeof value !== 'string') return null;\n  try {\n    return JSON.parse(value);\n  } catch {\n    return null;\n  }\n}\n"
  },
  {
    "path": "src/main.jsx",
    "content": "import React from 'react'\nimport ReactDOM from 'react-dom/client'\nimport App from './App.tsx'\nimport './index.css'\nimport 'katex/dist/katex.min.css'\n\n// Initialize i18n\nimport './i18n/config.js'\n\n// Register service worker for PWA + Web Push support\nif ('serviceWorker' in navigator) {\n  navigator.serviceWorker.register('/sw.js').catch(err => {\n    console.warn('Service worker registration failed:', err);\n  });\n}\n\nReactDOM.createRoot(document.getElementById('root')).render(\n  <React.StrictMode>\n    <App />\n  </React.StrictMode>,\n)\n"
  },
  {
    "path": "src/shared/view/ui/Badge.tsx",
    "content": "import * as React from 'react';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from '../../../lib/utils';\n\nconst badgeVariants = cva(\n  'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',\n  {\n    variants: {\n      variant: {\n        default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',\n        secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',\n        destructive:\n          'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',\n        outline: 'text-foreground',\n      },\n    },\n    defaultVariants: {\n      variant: 'default',\n    },\n  }\n);\n\ntype BadgeProps = React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof badgeVariants>;\n\nfunction Badge({ className, variant, ...props }: BadgeProps) {\n  return <div className={cn(badgeVariants({ variant }), className)} {...props} />;\n}\n\nexport { Badge, badgeVariants };\n"
  },
  {
    "path": "src/shared/view/ui/Button.tsx",
    "content": "import * as React from 'react';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from '../../../lib/utils';\n\n// Keep visual variants centralized so all button usages stay consistent.\nconst buttonVariants = cva(\n  'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium touch-manipulation transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',\n  {\n    variants: {\n      variant: {\n        default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90 active:bg-primary/80',\n        destructive:\n          'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90 active:bg-destructive/80',\n        outline:\n          'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground active:bg-accent/80',\n        secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80 active:bg-secondary/70',\n        ghost: 'hover:bg-accent hover:text-accent-foreground active:bg-accent/80',\n        link: 'text-primary underline-offset-4 hover:underline',\n      },\n      size: {\n        default: 'h-10 px-4 py-2',\n        sm: 'h-9 rounded-md px-3 text-sm',\n        lg: 'h-11 rounded-md px-8',\n        icon: 'h-10 w-10',\n      },\n    },\n    defaultVariants: {\n      variant: 'default',\n      size: 'default',\n    },\n  }\n);\n\ntype ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &\n  VariantProps<typeof buttonVariants>;\n\nconst Button = React.forwardRef<HTMLButtonElement, ButtonProps>(\n  ({ className, variant, size, ...props }, ref) => {\n    return (\n      <button className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />\n    );\n  }\n);\n\nButton.displayName = 'Button';\n\nexport { Button, buttonVariants };\n"
  },
  {
    "path": "src/shared/view/ui/DarkModeToggle.tsx",
    "content": "import { Moon, Sun } from 'lucide-react';\nimport { useTheme } from '../../../contexts/ThemeContext';\nimport { cn } from '../../../lib/utils';\n\ntype DarkModeToggleProps = {\n  checked?: boolean;\n  onToggle?: (nextValue: boolean) => void;\n  ariaLabel?: string;\n};\n\nfunction DarkModeToggle({\n  checked,\n  onToggle,\n  ariaLabel = 'Toggle dark mode',\n}: DarkModeToggleProps) {\n  const { isDarkMode, toggleDarkMode } = useTheme();\n  const isControlled = typeof checked === 'boolean' && typeof onToggle === 'function';\n  const isEnabled = isControlled ? checked : isDarkMode;\n\n  const handleToggle = () => {\n    if (isControlled && onToggle) {\n      onToggle(!isEnabled);\n      return;\n    }\n\n    toggleDarkMode();\n  };\n\n  return (\n    <button\n      onClick={handleToggle}\n      className={cn(\n        'relative inline-flex h-7 w-12 flex-shrink-0 touch-manipulation cursor-pointer items-center rounded-full border-2 transition-colors duration-200',\n        'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',\n        isEnabled ? 'border-primary bg-primary' : 'border-border bg-muted',\n      )}\n      role=\"switch\"\n      aria-checked={isEnabled}\n      aria-label={ariaLabel}\n    >\n      <span className=\"sr-only\">{ariaLabel}</span>\n      <span\n        className={cn(\n          'flex h-5 w-5 transform items-center justify-center rounded-full shadow-sm transition-transform duration-200',\n          isEnabled ? 'translate-x-[22px] bg-white' : 'translate-x-[2px] bg-foreground/60 dark:bg-foreground/80',\n        )}\n      >\n        {isEnabled ? (\n          <Moon className=\"h-3 w-3 text-primary\" />\n        ) : (\n          <Sun className=\"h-3 w-3 text-white dark:text-background\" />\n        )}\n      </span>\n    </button>\n  );\n}\n\nexport default DarkModeToggle;\n"
  },
  {
    "path": "src/shared/view/ui/Input.tsx",
    "content": "import * as React from 'react';\nimport { cn } from '../../../lib/utils';\n\ntype InputProps = React.InputHTMLAttributes<HTMLInputElement>;\n\nconst Input = React.forwardRef<HTMLInputElement, InputProps>(\n  ({ className, type, ...props }, ref) => {\n    return (\n      <input\n        type={type}\n        className={cn(\n          'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',\n          className\n        )}\n        ref={ref}\n        {...props}\n      />\n    );\n  }\n);\n\nInput.displayName = 'Input';\n\nexport { Input };\n"
  },
  {
    "path": "src/shared/view/ui/LanguageSelector.tsx",
    "content": "\n\nimport { useTranslation } from 'react-i18next';\nimport { Languages } from 'lucide-react';\nimport { languages } from '../../../i18n/languages';\n\ntype LanguageSelectorProps = {\n  compact?: boolean;\n};\n\n/**\n * Language Selector Component\n *\n * A dropdown component for selecting the application language.\n * Automatically updates the i18n language and persists to localStorage.\n *\n * Props:\n * @param {boolean} compact - If true, uses compact style (default: false)\n */\nexport default function LanguageSelector({ compact = false }: LanguageSelectorProps) {\n  const { i18n, t } = useTranslation('settings');\n\n  const handleLanguageChange = (event: React.ChangeEvent<HTMLSelectElement>) => {\n    const newLanguage = event.target.value;\n    i18n.changeLanguage(newLanguage);\n  };\n\n  // Compact style for QuickSettingsPanel\n  if (compact) {\n    return (\n      <div className=\"flex items-center justify-between rounded-lg border border-transparent bg-muted/50 p-3 transition-colors hover:border-border hover:bg-accent\">\n        <span className=\"flex items-center gap-2 text-sm text-foreground\">\n          <Languages className=\"h-4 w-4 text-muted-foreground\" />\n          {t('account.language')}\n        </span>\n        <select\n          value={i18n.language}\n          onChange={handleLanguageChange}\n          className=\"w-[100px] rounded-lg border border-input bg-card p-2 text-sm text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary\"\n        >\n          {languages.map((lang) => (\n            <option key={lang.value} value={lang.value}>\n              {lang.nativeName}\n            </option>\n          ))}\n        </select>\n      </div>\n    );\n  }\n\n  // Full style for Settings page\n  return (\n    <div className=\"flex items-center justify-between px-4 py-3.5\">\n      <div>\n        <div className=\"text-sm font-medium text-foreground\">\n          {t('account.languageLabel')}\n        </div>\n        <div className=\"mt-0.5 text-xs text-muted-foreground\">\n          {t('account.languageDescription')}\n        </div>\n      </div>\n      <select\n        value={i18n.language}\n        onChange={handleLanguageChange}\n        className=\"w-36 rounded-lg border border-input bg-card p-2 text-sm text-foreground focus:border-primary focus:ring-1 focus:ring-primary\"\n      >\n        {languages.map((lang) => (\n          <option key={lang.value} value={lang.value}>\n            {lang.nativeName}\n          </option>\n        ))}\n      </select>\n    </div>\n  );\n}\n"
  },
  {
    "path": "src/shared/view/ui/PillBar.tsx",
    "content": "import type { ReactNode } from 'react';\nimport { cn } from '../../../lib/utils';\n\n/* ── Container ─────────────────────────────────────────────────── */\ntype PillBarProps = {\n  children: ReactNode;\n  className?: string;\n};\n\nexport function PillBar({ children, className }: PillBarProps) {\n  return (\n    <div className={cn('inline-flex items-center gap-[2px] rounded-lg bg-muted/60 p-[3px]', className)}>\n      {children}\n    </div>\n  );\n}\n\n/* ── Individual pill button ────────────────────────────────────── */\ntype PillProps = {\n  isActive: boolean;\n  onClick: () => void;\n  children: ReactNode;\n  className?: string;\n};\n\nexport function Pill({ isActive, onClick, children, className }: PillProps) {\n  return (\n    <button\n      onClick={onClick}\n      className={cn(\n        'flex touch-manipulation items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium transition-all duration-150',\n        isActive\n          ? 'bg-background text-foreground shadow-sm'\n          : 'text-muted-foreground active:bg-background/50',\n        className,\n      )}\n    >\n      {children}\n    </button>\n  );\n}\n"
  },
  {
    "path": "src/shared/view/ui/ScrollArea.tsx",
    "content": "import * as React from 'react';\nimport { cn } from '../../../lib/utils';\n\ntype ScrollAreaProps = React.HTMLAttributes<HTMLDivElement>;\n\nconst ScrollArea = React.forwardRef<HTMLDivElement, ScrollAreaProps>(\n  ({ className, children, ...props }, ref) => (\n    <div className={cn(className, 'relative overflow-hidden')} {...props}>\n      {/* Inner container keeps border radius while allowing momentum scrolling on touch devices. */}\n      <div\n        ref={ref}\n        className=\"h-full w-full overflow-auto rounded-[inherit]\"\n        style={{\n          WebkitOverflowScrolling: 'touch',\n          touchAction: 'pan-y',\n        }}\n      >\n        {children}\n      </div>\n    </div>\n  )\n);\n\nScrollArea.displayName = 'ScrollArea';\n\nexport { ScrollArea };\n"
  },
  {
    "path": "src/shared/view/ui/Tooltip.tsx",
    "content": "import { type ReactNode, useEffect, useRef, useState } from 'react';\nimport { cn } from '../../../lib/utils';\n\ntype TooltipPosition = 'top' | 'bottom' | 'left' | 'right';\n\ntype TooltipProps = {\n  children: ReactNode;\n  content?: ReactNode;\n  position?: TooltipPosition;\n  className?: string;\n  delay?: number;\n};\n\nfunction getPositionClasses(position: TooltipPosition): string {\n  switch (position) {\n    case 'top':\n      return 'bottom-full left-1/2 transform -translate-x-1/2 mb-2';\n    case 'bottom':\n      return 'top-full left-1/2 transform -translate-x-1/2 mt-2';\n    case 'left':\n      return 'right-full top-1/2 transform -translate-y-1/2 mr-2';\n    case 'right':\n      return 'left-full top-1/2 transform -translate-y-1/2 ml-2';\n    default:\n      return 'bottom-full left-1/2 transform -translate-x-1/2 mb-2';\n  }\n}\n\nfunction getArrowClasses(position: TooltipPosition): string {\n  switch (position) {\n    case 'top':\n      return 'top-full left-1/2 transform -translate-x-1/2 border-t-gray-900 dark:border-t-gray-100';\n    case 'bottom':\n      return 'bottom-full left-1/2 transform -translate-x-1/2 border-b-gray-900 dark:border-b-gray-100';\n    case 'left':\n      return 'left-full top-1/2 transform -translate-y-1/2 border-l-gray-900 dark:border-l-gray-100';\n    case 'right':\n      return 'right-full top-1/2 transform -translate-y-1/2 border-r-gray-900 dark:border-r-gray-100';\n    default:\n      return 'top-full left-1/2 transform -translate-x-1/2 border-t-gray-900 dark:border-t-gray-100';\n  }\n}\n\nfunction Tooltip({\n  children,\n  content,\n  position = 'top',\n  className = '',\n  delay = 500,\n}: TooltipProps) {\n  const [isVisible, setIsVisible] = useState(false);\n  // Store the timer id without forcing re-renders while hovering.\n  const timeoutRef = useRef<number | null>(null);\n\n  const clearTooltipTimer = () => {\n    if (timeoutRef.current !== null) {\n      window.clearTimeout(timeoutRef.current);\n      timeoutRef.current = null;\n    }\n  };\n\n  const handleMouseEnter = () => {\n    clearTooltipTimer();\n    timeoutRef.current = window.setTimeout(() => {\n      setIsVisible(true);\n    }, delay);\n  };\n\n  const handleMouseLeave = () => {\n    clearTooltipTimer();\n    setIsVisible(false);\n  };\n\n  useEffect(() => {\n    // Avoid delayed updates after unmount.\n    return () => {\n      clearTooltipTimer();\n    };\n  }, []);\n\n  if (!content) {\n    return <>{children}</>;\n  }\n\n  return (\n    <div className=\"relative inline-block\" onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>\n      {children}\n      {isVisible && (\n        <div\n          className={cn(\n            'absolute z-50 px-2 py-1 text-xs font-medium text-white bg-gray-900 dark:bg-gray-100 dark:text-gray-900 rounded shadow-lg whitespace-nowrap pointer-events-none',\n            'animate-in fade-in-0 zoom-in-95 duration-200',\n            getPositionClasses(position),\n            className\n          )}\n        >\n          {content}\n          {/* Arrow */}\n          <div className={cn('absolute w-0 h-0 border-4 border-transparent', getArrowClasses(position))} />\n        </div>\n      )}\n    </div>\n  );\n}\n\nexport default Tooltip;\n"
  },
  {
    "path": "src/shared/view/ui/index.ts",
    "content": "export { Badge, badgeVariants } from './Badge';\nexport { Button, buttonVariants } from './Button';\nexport { default as DarkModeToggle } from './DarkModeToggle';\nexport { Input } from './Input';\nexport { ScrollArea } from './ScrollArea';\nexport { default as Tooltip } from './Tooltip';\nexport { PillBar, Pill } from './PillBar';\n"
  },
  {
    "path": "src/stores/useSessionStore.ts",
    "content": "/**\n * Session-keyed message store.\n *\n * Holds per-session state in a Map keyed by sessionId.\n * Session switch = change activeSessionId pointer. No clearing. Old data stays.\n * WebSocket handler = store.appendRealtime(msg.sessionId, msg). One line.\n * No localStorage for messages. Backend JSONL is the source of truth.\n */\n\nimport { useCallback, useMemo, useRef, useState } from 'react';\nimport type { SessionProvider } from '../types/app';\nimport { authenticatedFetch } from '../utils/api';\n\n// ─── NormalizedMessage (mirrors server/adapters/types.js) ────────────────────\n\nexport type MessageKind =\n  | 'text'\n  | 'tool_use'\n  | 'tool_result'\n  | 'thinking'\n  | 'stream_delta'\n  | 'stream_end'\n  | 'error'\n  | 'complete'\n  | 'status'\n  | 'permission_request'\n  | 'permission_cancelled'\n  | 'session_created'\n  | 'interactive_prompt'\n  | 'task_notification';\n\nexport interface NormalizedMessage {\n  id: string;\n  sessionId: string;\n  timestamp: string;\n  provider: SessionProvider;\n  kind: MessageKind;\n\n  // kind-specific fields (flat for simplicity)\n  role?: 'user' | 'assistant';\n  content?: string;\n  images?: string[];\n  toolName?: string;\n  toolInput?: unknown;\n  toolId?: string;\n  toolResult?: { content: string; isError: boolean; toolUseResult?: unknown } | null;\n  isError?: boolean;\n  text?: string;\n  tokens?: number;\n  canInterrupt?: boolean;\n  tokenBudget?: unknown;\n  requestId?: string;\n  input?: unknown;\n  context?: unknown;\n  newSessionId?: string;\n  status?: string;\n  summary?: string;\n  exitCode?: number;\n  actualSessionId?: string;\n  parentToolUseId?: string;\n  subagentTools?: unknown[];\n  isFinal?: boolean;\n  // Cursor-specific ordering\n  sequence?: number;\n  rowid?: number;\n}\n\n// ─── Per-session slot ────────────────────────────────────────────────────────\n\nexport type SessionStatus = 'idle' | 'loading' | 'streaming' | 'error';\n\nexport interface SessionSlot {\n  serverMessages: NormalizedMessage[];\n  realtimeMessages: NormalizedMessage[];\n  merged: NormalizedMessage[];\n  /** @internal Cache-invalidation refs for computeMerged */\n  _lastServerRef: NormalizedMessage[];\n  _lastRealtimeRef: NormalizedMessage[];\n  status: SessionStatus;\n  fetchedAt: number;\n  total: number;\n  hasMore: boolean;\n  offset: number;\n  tokenUsage: unknown;\n}\n\nconst EMPTY: NormalizedMessage[] = [];\n\nfunction createEmptySlot(): SessionSlot {\n  return {\n    serverMessages: EMPTY,\n    realtimeMessages: EMPTY,\n    merged: EMPTY,\n    _lastServerRef: EMPTY,\n    _lastRealtimeRef: EMPTY,\n    status: 'idle',\n    fetchedAt: 0,\n    total: 0,\n    hasMore: false,\n    offset: 0,\n    tokenUsage: null,\n  };\n}\n\n/**\n * Compute merged messages: server + realtime, deduped by id.\n * Server messages take priority (they're the persisted source of truth).\n * Realtime messages that aren't yet in server stay (in-flight streaming).\n */\nfunction computeMerged(server: NormalizedMessage[], realtime: NormalizedMessage[]): NormalizedMessage[] {\n  if (realtime.length === 0) return server;\n  if (server.length === 0) return realtime;\n  const serverIds = new Set(server.map(m => m.id));\n  const extra = realtime.filter(m => !serverIds.has(m.id));\n  if (extra.length === 0) return server;\n  return [...server, ...extra];\n}\n\n/**\n * Recompute slot.merged only when the input arrays have actually changed\n * (by reference). Returns true if merged was recomputed.\n */\nfunction recomputeMergedIfNeeded(slot: SessionSlot): boolean {\n  if (slot.serverMessages === slot._lastServerRef && slot.realtimeMessages === slot._lastRealtimeRef) {\n    return false;\n  }\n  slot._lastServerRef = slot.serverMessages;\n  slot._lastRealtimeRef = slot.realtimeMessages;\n  slot.merged = computeMerged(slot.serverMessages, slot.realtimeMessages);\n  return true;\n}\n\n// ─── Stale threshold ─────────────────────────────────────────────────────────\n\nconst STALE_THRESHOLD_MS = 30_000;\n\nconst MAX_REALTIME_MESSAGES = 500;\n\n// ─── Hook ────────────────────────────────────────────────────────────────────\n\nexport function useSessionStore() {\n  const storeRef = useRef(new Map<string, SessionSlot>());\n  const activeSessionIdRef = useRef<string | null>(null);\n  // Bump to force re-render — only when the active session's data changes\n  const [, setTick] = useState(0);\n  const notify = useCallback((sessionId: string) => {\n    if (sessionId === activeSessionIdRef.current) {\n      setTick(n => n + 1);\n    }\n  }, []);\n\n  const setActiveSession = useCallback((sessionId: string | null) => {\n    activeSessionIdRef.current = sessionId;\n  }, []);\n\n  const getSlot = useCallback((sessionId: string): SessionSlot => {\n    const store = storeRef.current;\n    if (!store.has(sessionId)) {\n      store.set(sessionId, createEmptySlot());\n    }\n    return store.get(sessionId)!;\n  }, []);\n\n  const has = useCallback((sessionId: string) => storeRef.current.has(sessionId), []);\n\n  /**\n   * Fetch messages from the unified endpoint and populate serverMessages.\n   */\n  const fetchFromServer = useCallback(async (\n    sessionId: string,\n    opts: {\n      provider?: SessionProvider;\n      projectName?: string;\n      projectPath?: string;\n      limit?: number | null;\n      offset?: number;\n    } = {},\n  ) => {\n    const slot = getSlot(sessionId);\n    slot.status = 'loading';\n    notify(sessionId);\n\n    try {\n      const params = new URLSearchParams();\n      if (opts.provider) params.append('provider', opts.provider);\n      if (opts.projectName) params.append('projectName', opts.projectName);\n      if (opts.projectPath) params.append('projectPath', opts.projectPath);\n      if (opts.limit !== null && opts.limit !== undefined) {\n        params.append('limit', String(opts.limit));\n        params.append('offset', String(opts.offset ?? 0));\n      }\n\n      const qs = params.toString();\n      const url = `/api/sessions/${encodeURIComponent(sessionId)}/messages${qs ? `?${qs}` : ''}`;\n      const response = await authenticatedFetch(url);\n\n      if (!response.ok) {\n        throw new Error(`HTTP ${response.status}`);\n      }\n\n      const data = await response.json();\n      const messages: NormalizedMessage[] = data.messages || [];\n\n      slot.serverMessages = messages;\n      slot.total = data.total ?? messages.length;\n      slot.hasMore = Boolean(data.hasMore);\n      slot.offset = (opts.offset ?? 0) + messages.length;\n      slot.fetchedAt = Date.now();\n      slot.status = 'idle';\n      recomputeMergedIfNeeded(slot);\n      if (data.tokenUsage) {\n        slot.tokenUsage = data.tokenUsage;\n      }\n\n      notify(sessionId);\n      return slot;\n    } catch (error) {\n      console.error(`[SessionStore] fetch failed for ${sessionId}:`, error);\n      slot.status = 'error';\n      notify(sessionId);\n      return slot;\n    }\n  }, [getSlot, notify]);\n\n  /**\n   * Load older (paginated) messages and prepend to serverMessages.\n   */\n  const fetchMore = useCallback(async (\n    sessionId: string,\n    opts: {\n      provider?: SessionProvider;\n      projectName?: string;\n      projectPath?: string;\n      limit?: number;\n    } = {},\n  ) => {\n    const slot = getSlot(sessionId);\n    if (!slot.hasMore) return slot;\n\n    const params = new URLSearchParams();\n    if (opts.provider) params.append('provider', opts.provider);\n    if (opts.projectName) params.append('projectName', opts.projectName);\n    if (opts.projectPath) params.append('projectPath', opts.projectPath);\n    const limit = opts.limit ?? 20;\n    params.append('limit', String(limit));\n    params.append('offset', String(slot.offset));\n\n    const qs = params.toString();\n    const url = `/api/sessions/${encodeURIComponent(sessionId)}/messages${qs ? `?${qs}` : ''}`;\n\n    try {\n      const response = await authenticatedFetch(url);\n      if (!response.ok) throw new Error(`HTTP ${response.status}`);\n      const data = await response.json();\n      const olderMessages: NormalizedMessage[] = data.messages || [];\n\n      // Prepend older messages (they're earlier in the conversation)\n      slot.serverMessages = [...olderMessages, ...slot.serverMessages];\n      slot.hasMore = Boolean(data.hasMore);\n      slot.offset = slot.offset + olderMessages.length;\n      recomputeMergedIfNeeded(slot);\n      notify(sessionId);\n      return slot;\n    } catch (error) {\n      console.error(`[SessionStore] fetchMore failed for ${sessionId}:`, error);\n      return slot;\n    }\n  }, [getSlot, notify]);\n\n  /**\n   * Append a realtime (WebSocket) message to the correct session slot.\n   * This works regardless of which session is actively viewed.\n   */\n  const appendRealtime = useCallback((sessionId: string, msg: NormalizedMessage) => {\n    const slot = getSlot(sessionId);\n    let updated = [...slot.realtimeMessages, msg];\n    if (updated.length > MAX_REALTIME_MESSAGES) {\n      updated = updated.slice(-MAX_REALTIME_MESSAGES);\n    }\n    slot.realtimeMessages = updated;\n    recomputeMergedIfNeeded(slot);\n    notify(sessionId);\n  }, [getSlot, notify]);\n\n  /**\n   * Append multiple realtime messages at once (batch).\n   */\n  const appendRealtimeBatch = useCallback((sessionId: string, msgs: NormalizedMessage[]) => {\n    if (msgs.length === 0) return;\n    const slot = getSlot(sessionId);\n    let updated = [...slot.realtimeMessages, ...msgs];\n    if (updated.length > MAX_REALTIME_MESSAGES) {\n      updated = updated.slice(-MAX_REALTIME_MESSAGES);\n    }\n    slot.realtimeMessages = updated;\n    recomputeMergedIfNeeded(slot);\n    notify(sessionId);\n  }, [getSlot, notify]);\n\n  /**\n   * Re-fetch serverMessages from the unified endpoint (e.g., on projects_updated).\n   */\n  const refreshFromServer = useCallback(async (\n    sessionId: string,\n    opts: {\n      provider?: SessionProvider;\n      projectName?: string;\n      projectPath?: string;\n    } = {},\n  ) => {\n    const slot = getSlot(sessionId);\n    try {\n      const params = new URLSearchParams();\n      if (opts.provider) params.append('provider', opts.provider);\n      if (opts.projectName) params.append('projectName', opts.projectName);\n      if (opts.projectPath) params.append('projectPath', opts.projectPath);\n\n      const qs = params.toString();\n      const url = `/api/sessions/${encodeURIComponent(sessionId)}/messages${qs ? `?${qs}` : ''}`;\n      const response = await authenticatedFetch(url);\n\n      if (!response.ok) throw new Error(`HTTP ${response.status}`);\n      const data = await response.json();\n\n      slot.serverMessages = data.messages || [];\n      slot.total = data.total ?? slot.serverMessages.length;\n      slot.hasMore = Boolean(data.hasMore);\n      slot.fetchedAt = Date.now();\n      // drop realtime messages that the server has caught up with to prevent unbounded growth.\n      slot.realtimeMessages = [];\n      recomputeMergedIfNeeded(slot);\n      notify(sessionId);\n    } catch (error) {\n      console.error(`[SessionStore] refresh failed for ${sessionId}:`, error);\n    }\n  }, [getSlot, notify]);\n\n  /**\n   * Update session status.\n   */\n  const setStatus = useCallback((sessionId: string, status: SessionStatus) => {\n    const slot = getSlot(sessionId);\n    slot.status = status;\n    notify(sessionId);\n  }, [getSlot, notify]);\n\n  /**\n   * Check if a session's data is stale (>30s old).\n   */\n  const isStale = useCallback((sessionId: string) => {\n    const slot = storeRef.current.get(sessionId);\n    if (!slot) return true;\n    return Date.now() - slot.fetchedAt > STALE_THRESHOLD_MS;\n  }, []);\n\n  /**\n   * Update or create a streaming message (accumulated text so far).\n   * Uses a well-known ID so subsequent calls replace the same message.\n   */\n  const updateStreaming = useCallback((sessionId: string, accumulatedText: string, msgProvider: SessionProvider) => {\n    const slot = getSlot(sessionId);\n    const streamId = `__streaming_${sessionId}`;\n    const msg: NormalizedMessage = {\n      id: streamId,\n      sessionId,\n      timestamp: new Date().toISOString(),\n      provider: msgProvider,\n      kind: 'stream_delta',\n      content: accumulatedText,\n    };\n    const idx = slot.realtimeMessages.findIndex(m => m.id === streamId);\n    if (idx >= 0) {\n      slot.realtimeMessages = [...slot.realtimeMessages];\n      slot.realtimeMessages[idx] = msg;\n    } else {\n      slot.realtimeMessages = [...slot.realtimeMessages, msg];\n    }\n    recomputeMergedIfNeeded(slot);\n    notify(sessionId);\n  }, [getSlot, notify]);\n\n  /**\n   * Finalize streaming: convert the streaming message to a regular text message.\n   * The well-known streaming ID is replaced with a unique text message ID.\n   */\n  const finalizeStreaming = useCallback((sessionId: string) => {\n    const slot = storeRef.current.get(sessionId);\n    if (!slot) return;\n    const streamId = `__streaming_${sessionId}`;\n    const idx = slot.realtimeMessages.findIndex(m => m.id === streamId);\n    if (idx >= 0) {\n      const stream = slot.realtimeMessages[idx];\n      slot.realtimeMessages = [...slot.realtimeMessages];\n      slot.realtimeMessages[idx] = {\n        ...stream,\n        id: `text_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,\n        kind: 'text',\n        role: 'assistant',\n      };\n      recomputeMergedIfNeeded(slot);\n      notify(sessionId);\n    }\n  }, [notify]);\n\n  /**\n   * Clear realtime messages for a session (e.g., after stream completes and server fetch catches up).\n   */\n  const clearRealtime = useCallback((sessionId: string) => {\n    const slot = storeRef.current.get(sessionId);\n    if (slot) {\n      slot.realtimeMessages = [];\n      recomputeMergedIfNeeded(slot);\n      notify(sessionId);\n    }\n  }, [notify]);\n\n  /**\n   * Get merged messages for a session (for rendering).\n   */\n  const getMessages = useCallback((sessionId: string): NormalizedMessage[] => {\n    return storeRef.current.get(sessionId)?.merged ?? [];\n  }, []);\n\n  /**\n   * Get session slot (for status, pagination info, etc.).\n   */\n  const getSessionSlot = useCallback((sessionId: string): SessionSlot | undefined => {\n    return storeRef.current.get(sessionId);\n  }, []);\n\n  return useMemo(() => ({\n    getSlot,\n    has,\n    fetchFromServer,\n    fetchMore,\n    appendRealtime,\n    appendRealtimeBatch,\n    refreshFromServer,\n    setActiveSession,\n    setStatus,\n    isStale,\n    updateStreaming,\n    finalizeStreaming,\n    clearRealtime,\n    getMessages,\n    getSessionSlot,\n  }), [\n    getSlot, has, fetchFromServer, fetchMore,\n    appendRealtime, appendRealtimeBatch, refreshFromServer,\n    setActiveSession, setStatus, isStale, updateStreaming, finalizeStreaming,\n    clearRealtime, getMessages, getSessionSlot,\n  ]);\n}\n\nexport type SessionStore = ReturnType<typeof useSessionStore>;\n"
  },
  {
    "path": "src/types/app.ts",
    "content": "export type SessionProvider = 'claude' | 'cursor' | 'codex' | 'gemini';\n\nexport type AppTab = 'chat' | 'files' | 'shell' | 'git' | 'tasks' | 'preview' | `plugin:${string}`;\n\nexport interface ProjectSession {\n  id: string;\n  title?: string;\n  summary?: string;\n  name?: string;\n  createdAt?: string;\n  created_at?: string;\n  updated_at?: string;\n  lastActivity?: string;\n  messageCount?: number;\n  __provider?: SessionProvider;\n  __projectName?: string;\n  [key: string]: unknown;\n}\n\nexport interface ProjectSessionMeta {\n  total?: number;\n  hasMore?: boolean;\n  [key: string]: unknown;\n}\n\nexport interface ProjectTaskmasterInfo {\n  hasTaskmaster?: boolean;\n  status?: string;\n  metadata?: Record<string, unknown>;\n  [key: string]: unknown;\n}\n\nexport interface Project {\n  name: string;\n  displayName: string;\n  fullPath: string;\n  path?: string;\n  sessions?: ProjectSession[];\n  cursorSessions?: ProjectSession[];\n  codexSessions?: ProjectSession[];\n  geminiSessions?: ProjectSession[];\n  sessionMeta?: ProjectSessionMeta;\n  taskmaster?: ProjectTaskmasterInfo;\n  [key: string]: unknown;\n}\n\nexport interface LoadingProgress {\n  type?: 'loading_progress';\n  phase?: string;\n  current: number;\n  total: number;\n  currentProject?: string;\n  [key: string]: unknown;\n}\n\nexport interface ProjectsUpdatedMessage {\n  type: 'projects_updated';\n  projects: Project[];\n  changedFile?: string;\n  [key: string]: unknown;\n}\n\nexport interface LoadingProgressMessage extends LoadingProgress {\n  type: 'loading_progress';\n}\n\nexport type AppSocketMessage =\n  | LoadingProgressMessage\n  | ProjectsUpdatedMessage\n  | { type?: string;[key: string]: unknown };\n"
  },
  {
    "path": "src/types/global.d.ts",
    "content": "export {};\n\ndeclare global {\n  interface Window {\n    __ROUTER_BASENAME__?: string;\n    refreshProjects?: () => void | Promise<void>;\n    openSettings?: (tab?: string) => void;\n  }\n\n  interface EventSourceEventMap {\n    result: MessageEvent;\n    progress: MessageEvent;\n    done: MessageEvent;\n  }\n}\n"
  },
  {
    "path": "src/types/react-syntax-highlighter.d.ts",
    "content": "declare module 'react-syntax-highlighter';\ndeclare module 'react-syntax-highlighter/dist/esm/styles/prism';\n"
  },
  {
    "path": "src/types/sharedTypes.ts",
    "content": "export type ReleaseInfo = {\n  title: string;\n  body: string;\n  htmlUrl: string;\n  publishedAt: string;\n};"
  },
  {
    "path": "src/utils/api.js",
    "content": "import { IS_PLATFORM } from \"../constants/config\";\n\n// Utility function for authenticated API calls\nexport const authenticatedFetch = (url, options = {}) => {\n  const token = localStorage.getItem('auth-token');\n\n  const defaultHeaders = {};\n\n  // Only set Content-Type for non-FormData requests\n  if (!(options.body instanceof FormData)) {\n    defaultHeaders['Content-Type'] = 'application/json';\n  }\n\n  if (!IS_PLATFORM && token) {\n    defaultHeaders['Authorization'] = `Bearer ${token}`;\n  }\n\n  return fetch(url, {\n    ...options,\n    headers: {\n      ...defaultHeaders,\n      ...options.headers,\n    },\n  }).then((response) => {\n    const refreshedToken = response.headers.get('X-Refreshed-Token');\n    if (refreshedToken) {\n      localStorage.setItem('auth-token', refreshedToken);\n    }\n    return response;\n  });\n};\n\n// API endpoints\nexport const api = {\n  // Auth endpoints (no token required)\n  auth: {\n    status: () => fetch('/api/auth/status'),\n    login: (username, password) => fetch('/api/auth/login', {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/json' },\n      body: JSON.stringify({ username, password }),\n    }),\n    register: (username, password) => fetch('/api/auth/register', {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/json' },\n      body: JSON.stringify({ username, password }),\n    }),\n    user: () => authenticatedFetch('/api/auth/user'),\n    logout: () => authenticatedFetch('/api/auth/logout', { method: 'POST' }),\n  },\n\n  // Protected endpoints\n  // config endpoint removed - no longer needed (frontend uses window.location)\n  projects: () => authenticatedFetch('/api/projects'),\n  sessions: (projectName, limit = 5, offset = 0) =>\n    authenticatedFetch(`/api/projects/${projectName}/sessions?limit=${limit}&offset=${offset}`),\n  // Unified endpoint — all providers through one URL\n  unifiedSessionMessages: (sessionId, provider = 'claude', { projectName = '', projectPath = '', limit = null, offset = 0 } = {}) => {\n    const params = new URLSearchParams();\n    params.append('provider', provider);\n    if (projectName) params.append('projectName', projectName);\n    if (projectPath) params.append('projectPath', projectPath);\n    if (limit !== null) {\n      params.append('limit', String(limit));\n      params.append('offset', String(offset));\n    }\n    const queryString = params.toString();\n    return authenticatedFetch(`/api/sessions/${encodeURIComponent(sessionId)}/messages${queryString ? `?${queryString}` : ''}`);\n  },\n  renameProject: (projectName, displayName) =>\n    authenticatedFetch(`/api/projects/${projectName}/rename`, {\n      method: 'PUT',\n      body: JSON.stringify({ displayName }),\n    }),\n  deleteSession: (projectName, sessionId) =>\n    authenticatedFetch(`/api/projects/${projectName}/sessions/${sessionId}`, {\n      method: 'DELETE',\n    }),\n  renameSession: (sessionId, summary, provider) =>\n    authenticatedFetch(`/api/sessions/${sessionId}/rename`, {\n      method: 'PUT',\n      body: JSON.stringify({ summary, provider }),\n    }),\n  deleteCodexSession: (sessionId) =>\n    authenticatedFetch(`/api/codex/sessions/${sessionId}`, {\n      method: 'DELETE',\n    }),\n  deleteGeminiSession: (sessionId) =>\n    authenticatedFetch(`/api/gemini/sessions/${sessionId}`, {\n      method: 'DELETE',\n    }),\n  deleteProject: (projectName, force = false) =>\n    authenticatedFetch(`/api/projects/${projectName}${force ? '?force=true' : ''}`, {\n      method: 'DELETE',\n    }),\n  searchConversationsUrl: (query, limit = 50) => {\n    const token = localStorage.getItem('auth-token');\n    const params = new URLSearchParams({ q: query, limit: String(limit) });\n    if (token) params.set('token', token);\n    return `/api/search/conversations?${params.toString()}`;\n  },\n  createProject: (path) =>\n    authenticatedFetch('/api/projects/create', {\n      method: 'POST',\n      body: JSON.stringify({ path }),\n    }),\n  createWorkspace: (workspaceData) =>\n    authenticatedFetch('/api/projects/create-workspace', {\n      method: 'POST',\n      body: JSON.stringify(workspaceData),\n    }),\n  readFile: (projectName, filePath) =>\n    authenticatedFetch(`/api/projects/${projectName}/file?filePath=${encodeURIComponent(filePath)}`),\n  saveFile: (projectName, filePath, content) =>\n    authenticatedFetch(`/api/projects/${projectName}/file`, {\n      method: 'PUT',\n      body: JSON.stringify({ filePath, content }),\n    }),\n  getFiles: (projectName, options = {}) =>\n    authenticatedFetch(`/api/projects/${projectName}/files`, options),\n\n  // File operations\n  createFile: (projectName, { path, type, name }) =>\n    authenticatedFetch(`/api/projects/${projectName}/files/create`, {\n      method: 'POST',\n      body: JSON.stringify({ path, type, name }),\n    }),\n\n  renameFile: (projectName, { oldPath, newName }) =>\n    authenticatedFetch(`/api/projects/${projectName}/files/rename`, {\n      method: 'PUT',\n      body: JSON.stringify({ oldPath, newName }),\n    }),\n\n  deleteFile: (projectName, { path, type }) =>\n    authenticatedFetch(`/api/projects/${projectName}/files`, {\n      method: 'DELETE',\n      body: JSON.stringify({ path, type }),\n    }),\n\n  uploadFiles: (projectName, formData) =>\n    authenticatedFetch(`/api/projects/${projectName}/files/upload`, {\n      method: 'POST',\n      body: formData,\n      headers: {}, // Let browser set Content-Type for FormData\n    }),\n\n  transcribe: (formData) =>\n    authenticatedFetch('/api/transcribe', {\n      method: 'POST',\n      body: formData,\n      headers: {}, // Let browser set Content-Type for FormData\n    }),\n\n  // TaskMaster endpoints\n  taskmaster: {\n    // Initialize TaskMaster in a project\n    init: (projectName) =>\n      authenticatedFetch(`/api/taskmaster/init/${projectName}`, {\n        method: 'POST',\n      }),\n\n    // Add a new task\n    addTask: (projectName, { prompt, title, description, priority, dependencies }) =>\n      authenticatedFetch(`/api/taskmaster/add-task/${projectName}`, {\n        method: 'POST',\n        body: JSON.stringify({ prompt, title, description, priority, dependencies }),\n      }),\n\n    // Parse PRD to generate tasks\n    parsePRD: (projectName, { fileName, numTasks, append }) =>\n      authenticatedFetch(`/api/taskmaster/parse-prd/${projectName}`, {\n        method: 'POST',\n        body: JSON.stringify({ fileName, numTasks, append }),\n      }),\n\n    // Get available PRD templates\n    getTemplates: () =>\n      authenticatedFetch('/api/taskmaster/prd-templates'),\n\n    // Apply a PRD template\n    applyTemplate: (projectName, { templateId, fileName, customizations }) =>\n      authenticatedFetch(`/api/taskmaster/apply-template/${projectName}`, {\n        method: 'POST',\n        body: JSON.stringify({ templateId, fileName, customizations }),\n      }),\n\n    // Update a task\n    updateTask: (projectName, taskId, updates) =>\n      authenticatedFetch(`/api/taskmaster/update-task/${projectName}/${taskId}`, {\n        method: 'PUT',\n        body: JSON.stringify(updates),\n      }),\n  },\n\n  // Browse filesystem for project suggestions\n  browseFilesystem: (dirPath = null) => {\n    const params = new URLSearchParams();\n    if (dirPath) params.append('path', dirPath);\n\n    return authenticatedFetch(`/api/browse-filesystem?${params}`);\n  },\n\n  createFolder: (folderPath) =>\n    authenticatedFetch('/api/create-folder', {\n      method: 'POST',\n      body: JSON.stringify({ path: folderPath }),\n    }),\n\n  // User endpoints\n  user: {\n    gitConfig: () => authenticatedFetch('/api/user/git-config'),\n    updateGitConfig: (gitName, gitEmail) =>\n      authenticatedFetch('/api/user/git-config', {\n        method: 'POST',\n        body: JSON.stringify({ gitName, gitEmail }),\n      }),\n    onboardingStatus: () => authenticatedFetch('/api/user/onboarding-status'),\n    completeOnboarding: () =>\n      authenticatedFetch('/api/user/complete-onboarding', {\n        method: 'POST',\n      }),\n  },\n\n  // Generic GET method for any endpoint\n  get: (endpoint) => authenticatedFetch(`/api${endpoint}`),\n\n  // Generic POST method for any endpoint\n  post: (endpoint, body) => authenticatedFetch(`/api${endpoint}`, {\n    method: 'POST',\n    ...(body instanceof FormData ? { body } : { body: JSON.stringify(body) }),\n  }),\n\n  // Generic PUT method for any endpoint\n  put: (endpoint, body) => authenticatedFetch(`/api${endpoint}`, {\n    method: 'PUT',\n    body: JSON.stringify(body),\n  }),\n\n  // Generic DELETE method for any endpoint\n  delete: (endpoint, options = {}) => authenticatedFetch(`/api${endpoint}`, {\n    method: 'DELETE',\n    ...options,\n  }),\n};"
  },
  {
    "path": "src/utils/clipboard.ts",
    "content": "function fallbackCopyToClipboard(text: string): boolean {\n  if (!text || typeof document === 'undefined') {\n    return false;\n  }\n\n  const textarea = document.createElement('textarea');\n  textarea.value = text;\n  textarea.setAttribute('readonly', '');\n  textarea.style.position = 'fixed';\n  textarea.style.opacity = '0';\n  textarea.style.pointerEvents = 'none';\n\n  document.body.appendChild(textarea);\n  textarea.focus();\n  textarea.select();\n\n  let copied = false;\n  try {\n    copied = document.execCommand('copy');\n  } catch {\n    copied = false;\n  } finally {\n    document.body.removeChild(textarea);\n  }\n\n  return copied;\n}\n\nexport async function copyTextToClipboard(text: string): Promise<boolean> {\n  if (!text) {\n    return false;\n  }\n\n  let copied = false;\n\n  try {\n    if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {\n      await navigator.clipboard.writeText(text);\n      copied = true;\n    }\n  } catch {\n    copied = false;\n  }\n\n  if (!copied) {\n    copied = fallbackCopyToClipboard(text);\n  }\n\n  return copied;\n}"
  },
  {
    "path": "src/utils/dateUtils.ts",
    "content": "import { TFunction } from 'i18next';\n\nexport const formatTimeAgo = (dateString: string, currentTime: Date, t: TFunction) => {\n  const date = new Date(dateString);\n  const now = currentTime;\n\n  // Check if date is valid\n  if (isNaN(date.getTime())) {\n    return t ? t('status.unknown') : 'Unknown';\n  }\n\n  const diffInMs = now.getTime() - date.getTime();\n  const diffInSeconds = Math.floor(diffInMs / 1000);\n  const diffInMinutes = Math.floor(diffInMs / (1000 * 60));\n  const diffInHours = Math.floor(diffInMs / (1000 * 60 * 60));\n  const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24));\n\n  if (diffInSeconds < 60) return t ? t('time.justNow') : 'Just now';\n  if (diffInMinutes === 1) return t ? t('time.oneMinuteAgo') : '1 min ago';\n  if (diffInMinutes < 60) return t ? t('time.minutesAgo', { count: diffInMinutes }) : `${diffInMinutes} mins ago`;\n  if (diffInHours === 1) return t ? t('time.oneHourAgo') : '1 hour ago';\n  if (diffInHours < 24) return t ? t('time.hoursAgo', { count: diffInHours }) : `${diffInHours} hours ago`;\n  if (diffInDays === 1) return t ? t('time.oneDayAgo') : '1 day ago';\n  if (diffInDays < 7) return t ? t('time.daysAgo', { count: diffInDays }) : `${diffInDays} days ago`;\n  return date.toLocaleDateString();\n};"
  },
  {
    "path": "src/vite-env.d.ts",
    "content": "/// <reference types=\"vite/client\" />\n"
  },
  {
    "path": "tailwind.config.js",
    "content": "/** @type {import('tailwindcss').Config} */\nexport default {\n  darkMode: [\"class\"],\n  content: [\n    \"./index.html\",\n    \"./src/**/*.{js,ts,jsx,tsx}\",\n  ],\n  theme: {\n    container: {\n      center: true,\n      padding: \"2rem\",\n      screens: {\n        \"2xl\": \"1400px\",\n      },\n    },\n    extend: {\n      colors: {\n        border: \"hsl(var(--border))\",\n        input: \"hsl(var(--input))\",\n        ring: \"hsl(var(--ring))\",\n        background: \"hsl(var(--background))\",\n        foreground: \"hsl(var(--foreground))\",\n        primary: {\n          DEFAULT: \"hsl(var(--primary))\",\n          foreground: \"hsl(var(--primary-foreground))\",\n        },\n        secondary: {\n          DEFAULT: \"hsl(var(--secondary))\",\n          foreground: \"hsl(var(--secondary-foreground))\",\n        },\n        destructive: {\n          DEFAULT: \"hsl(var(--destructive))\",\n          foreground: \"hsl(var(--destructive-foreground))\",\n        },\n        muted: {\n          DEFAULT: \"hsl(var(--muted))\",\n          foreground: \"hsl(var(--muted-foreground))\",\n        },\n        accent: {\n          DEFAULT: \"hsl(var(--accent))\",\n          foreground: \"hsl(var(--accent-foreground))\",\n        },\n        popover: {\n          DEFAULT: \"hsl(var(--popover))\",\n          foreground: \"hsl(var(--popover-foreground))\",\n        },\n        card: {\n          DEFAULT: \"hsl(var(--card))\",\n          foreground: \"hsl(var(--card-foreground))\",\n        },\n      },\n      borderRadius: {\n        lg: \"var(--radius)\",\n        md: \"calc(var(--radius) - 2px)\",\n        sm: \"calc(var(--radius) - 4px)\",\n      },\n      spacing: {\n        'safe-area-inset-bottom': 'env(safe-area-inset-bottom)',\n        'mobile-nav': 'var(--mobile-nav-total)',\n      },\n    },\n  },\n  plugins: [require('@tailwindcss/typography')],\n}"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2020\",\n    \"useDefineForClassFields\": true,\n    \"lib\": [\"ES2020\", \"DOM\", \"DOM.Iterable\"],\n    \"module\": \"ESNext\",\n    \"skipLibCheck\": true,\n    \"moduleResolution\": \"Bundler\",\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"noEmit\": true,\n    \"jsx\": \"react-jsx\",\n    \"strict\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"allowJs\": true,\n    // \"checkJs\": true, \n    \"types\": [\"vite/client\"]\n  },\n  \"include\": [\"src\", \"shared\", \"vite.config.js\"]\n}\n"
  },
  {
    "path": "vite.config.js",
    "content": "import { defineConfig, loadEnv } from 'vite'\nimport react from '@vitejs/plugin-react'\nimport { getConnectableHost, normalizeLoopbackHost } from './shared/networkHosts.js'\n\nexport default defineConfig(({ mode }) => {\n  // Load env file based on `mode` in the current working directory.\n  const env = loadEnv(mode, process.cwd(), '')\n\n  const configuredHost = env.HOST || '0.0.0.0'\n  // if the host is not a loopback address, it should be used directly. \n  // This allows the vite server to EXPOSE all interfaces when the host \n  // is set to '0.0.0.0' or '::', while still using 'localhost' for browser \n  // URLs and proxy targets.\n  const host = normalizeLoopbackHost(configuredHost)\n  \n  const proxyHost = getConnectableHost(configuredHost)\n  // TODO: Remove support for legacy PORT variables in all locations in a future major release, leaving only SERVER_PORT.\n  const serverPort = env.SERVER_PORT || env.PORT || 3001\n\n  return {\n    plugins: [react()],\n    server: {\n      host,\n      port: parseInt(env.VITE_PORT) || 5173,\n      proxy: {\n        '/api': `http://${proxyHost}:${serverPort}`,\n        '/ws': {\n          target: `ws://${proxyHost}:${serverPort}`,\n          ws: true\n        },\n        '/shell': {\n          target: `ws://${proxyHost}:${serverPort}`,\n          ws: true\n        }\n      }\n    },\n    build: {\n      outDir: 'dist',\n      chunkSizeWarningLimit: 1000,\n      rollupOptions: {\n        output: {\n          manualChunks: {\n            'vendor-react': ['react', 'react-dom', 'react-router-dom'],\n            'vendor-codemirror': [\n              '@uiw/react-codemirror',\n              '@codemirror/lang-css',\n              '@codemirror/lang-html',\n              '@codemirror/lang-javascript',\n              '@codemirror/lang-json',\n              '@codemirror/lang-markdown',\n              '@codemirror/lang-python',\n              '@codemirror/theme-one-dark'\n            ],\n            'vendor-xterm': ['@xterm/xterm', '@xterm/addon-fit', '@xterm/addon-clipboard', '@xterm/addon-webgl']\n          }\n        }\n      }\n    }\n  }\n})\n"
  }
]