@aion0/forge 0.5.26 → 0.5.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.forge/worktrees/pipeline-4dd8dc2d/CLAUDE.md +86 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/README.md +136 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/RELEASE_NOTES.md +36 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/agents/route.ts +17 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/auth/[...nextauth]/route.ts +3 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/auth/verify/route.ts +46 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/claude/[id]/route.ts +31 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/claude/[id]/stream/route.ts +63 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/claude/route.ts +28 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/claude-sessions/[projectName]/entries/route.ts +23 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/claude-sessions/[projectName]/live/route.ts +72 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/claude-sessions/[projectName]/route.ts +37 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/claude-sessions/sync/route.ts +17 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/claude-templates/route.ts +145 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/code/route.ts +299 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/delivery/[id]/route.ts +62 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/delivery/route.ts +40 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/detect-cli/route.ts +46 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/docs/route.ts +176 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/docs/sessions/route.ts +54 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/favorites/route.ts +26 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/flows/route.ts +6 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/flows/run/route.ts +19 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/git/route.ts +149 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/help/route.ts +84 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/issue-scanner/route.ts +116 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/logs/route.ts +100 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/mobile-chat/route.ts +115 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/monitor/route.ts +74 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/notifications/route.ts +42 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/notify/test/route.ts +33 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/online/route.ts +40 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/pipelines/[id]/route.ts +41 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/pipelines/route.ts +90 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/plugins/route.ts +75 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/preview/[...path]/route.ts +64 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/preview/route.ts +156 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/project-pipelines/route.ts +91 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/project-sessions/route.ts +61 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/projects/route.ts +26 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/sessions/[id]/chat/route.ts +64 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/sessions/[id]/messages/route.ts +9 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/sessions/[id]/route.ts +17 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/sessions/route.ts +20 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/settings/route.ts +64 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/skills/local/route.ts +228 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/skills/route.ts +182 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/smith-templates/route.ts +81 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/status/route.ts +12 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/tabs/route.ts +25 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/tasks/[id]/route.ts +51 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/tasks/[id]/stream/route.ts +77 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/tasks/link/route.ts +37 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/tasks/route.ts +44 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/tasks/session/route.ts +14 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/telegram/route.ts +23 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/templates/route.ts +6 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/terminal-bell/route.ts +39 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/terminal-cwd/route.ts +19 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/terminal-state/route.ts +15 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/tunnel/route.ts +26 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/upgrade/route.ts +43 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/usage/route.ts +20 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/version/route.ts +78 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/watchers/route.ts +33 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/workspace/[id]/agents/route.ts +35 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/workspace/[id]/memory/route.ts +23 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/workspace/[id]/smith/route.ts +22 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/workspace/[id]/stream/route.ts +31 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/api/workspace/route.ts +79 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/global-error.tsx +21 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/globals.css +52 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/icon.ico +0 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/icon.png +0 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/icon.svg +106 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/layout.tsx +17 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/login/LoginForm.tsx +96 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/login/page.tsx +10 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/mobile/page.tsx +10 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/app/page.tsx +22 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/bin/forge-server.mjs +484 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/check-forge-status.sh +71 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/cli/mw.ts +579 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/BrowserPanel.tsx +175 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/ChatPanel.tsx +191 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/ClaudeTerminal.tsx +267 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/CodeViewer.tsx +787 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/ConversationEditor.tsx +411 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/ConversationGraphView.tsx +347 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/ConversationTerminalView.tsx +303 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/Dashboard.tsx +807 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/DashboardWrapper.tsx +9 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/DeliveryFlowEditor.tsx +491 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/DeliveryList.tsx +230 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/DeliveryWorkspace.tsx +589 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/DocTerminal.tsx +187 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/DocsViewer.tsx +574 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/HelpDialog.tsx +169 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/HelpTerminal.tsx +141 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/InlinePipelineView.tsx +111 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/LogViewer.tsx +194 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/MarkdownContent.tsx +73 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/MobileView.tsx +385 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/MonitorPanel.tsx +122 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/NewSessionModal.tsx +93 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/NewTaskModal.tsx +492 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/PipelineEditor.tsx +570 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/PipelineView.tsx +1018 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/PluginsPanel.tsx +472 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/ProjectDetail.tsx +1618 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/ProjectList.tsx +108 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/ProjectManager.tsx +401 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/SessionList.tsx +74 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/SessionView.tsx +726 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/SettingsModal.tsx +1647 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/SkillsPanel.tsx +969 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/StatusBar.tsx +99 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/TabBar.tsx +46 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/TaskBoard.tsx +113 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/TaskDetail.tsx +372 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/TerminalLauncher.tsx +398 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/TunnelToggle.tsx +206 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/UsagePanel.tsx +207 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/WebTerminal.tsx +1743 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/WorkspaceTree.tsx +221 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/components/WorkspaceView.tsx +4048 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/dev-test.sh +5 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/docs/Forge_Memory_Layer_Design.docx +0 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/docs/Forge_Strategy_Research_2026.docx +0 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/docs/LOCAL-DEPLOY.md +144 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/docs/roadmap-multi-agent-workflow.md +330 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/forge-logo.png +0 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/forge-logo.svg +106 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/hooks/useSidebarResize.ts +52 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/install.sh +29 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/instrumentation.ts +35 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/agents/claude-adapter.ts +104 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/agents/generic-adapter.ts +64 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/agents/index.ts +245 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/agents/types.ts +70 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/artifacts.ts +106 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/auth.ts +62 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/builtin-plugins/docker.yaml +70 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/builtin-plugins/http.yaml +66 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/builtin-plugins/jenkins.yaml +92 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/builtin-plugins/llm-vision.yaml +85 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/builtin-plugins/playwright.yaml +111 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/builtin-plugins/shell-command.yaml +60 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/builtin-plugins/slack.yaml +48 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/builtin-plugins/webhook.yaml +56 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/claude-process.ts +361 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/claude-sessions.ts +266 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/claude-templates.ts +227 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/cloudflared.ts +424 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/crypto.ts +67 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/delivery.ts +787 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/dirs.ts +99 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/flows.ts +86 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/forge-mcp-server.ts +732 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/forge-skills/forge-inbox.md +38 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/forge-skills/forge-send.md +47 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/forge-skills/forge-status.md +32 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/forge-skills/forge-workspace-sync.md +37 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/00-overview.md +40 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/01-settings.md +194 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/02-telegram.md +41 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/03-tunnel.md +31 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/04-tasks.md +52 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/05-pipelines.md +460 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/06-skills.md +43 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/07-projects.md +73 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/08-rules.md +53 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/09-issue-autofix.md +55 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/10-troubleshooting.md +89 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/11-workspace.md +810 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/help-docs/CLAUDE.md +62 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/init.ts +266 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/issue-scanner.ts +298 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/logger.ts +79 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/notifications.ts +75 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/notify.ts +108 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/password.ts +97 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/pipeline-scheduler.ts +373 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/pipeline.ts +1565 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/plugins/executor.ts +347 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/plugins/registry.ts +228 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/plugins/types.ts +103 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/project-sessions.ts +53 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/projects.ts +86 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/session-manager.ts +156 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/session-utils.ts +53 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/session-watcher.ts +345 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/settings.ts +195 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/skills.ts +458 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/task-manager.ts +951 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/telegram-bot.ts +1477 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/telegram-standalone.ts +83 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/terminal-server.ts +70 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/terminal-standalone.ts +438 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/usage-scanner.ts +249 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/__tests__/state-machine.test.ts +388 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/__tests__/workspace.test.ts +311 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/agent-bus.ts +416 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/agent-worker.ts +655 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/backends/api-backend.ts +262 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/backends/cli-backend.ts +491 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/index.ts +84 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/manager.ts +136 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/orchestrator.ts +3415 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/persistence.ts +309 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/presets.ts +649 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/requests.ts +287 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/session-monitor.ts +240 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/skill-installer.ts +275 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/smith-memory.ts +498 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/types.ts +241 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace/watch-manager.ts +560 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/lib/workspace-standalone.ts +978 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/middleware.ts +51 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/next.config.ts +26 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/package.json +74 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/pnpm-lock.yaml +3719 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/pnpm-workspace.yaml +1 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/postcss.config.mjs +7 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/publish.sh +133 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/README.md +66 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/results/.gitignore +2 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/run.ts +635 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/tasks/01-text-utils/task.md +26 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/tasks/01-text-utils/validator.sh +46 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/tasks/02-pagination/setup.sh +19 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/tasks/02-pagination/task.md +48 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/tasks/02-pagination/validator.sh +69 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/tasks/03-bug-fix/setup.sh +82 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/tasks/03-bug-fix/task.md +30 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/bench/tasks/03-bug-fix/validator.sh +29 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/scripts/verify-usage.ts +178 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/src/config/index.ts +129 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/src/core/db/database.ts +259 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/src/core/memory/strategy.ts +32 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/src/core/providers/chat.ts +65 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/src/core/providers/registry.ts +60 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/src/core/session/manager.ts +190 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/src/types/index.ts +129 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/start.sh +32 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/templates/smith-lead.json +45 -0
- package/.forge/worktrees/pipeline-4dd8dc2d/tsconfig.json +42 -0
- package/RELEASE_NOTES.md +11 -28
- package/app/api/terminal-bell/route.ts +6 -2
- package/components/WebTerminal.tsx +36 -2
- package/lib/terminal-standalone.ts +19 -2
- package/next-env.d.ts +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,1743 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useRef, useCallback, memo, useImperativeHandle, forwardRef } from 'react';
|
|
4
|
+
import { TerminalSessionPickerLazy, fetchProjectSessions } from './TerminalLauncher';
|
|
5
|
+
import { Terminal } from '@xterm/xterm';
|
|
6
|
+
import { FitAddon } from '@xterm/addon-fit';
|
|
7
|
+
import { WebglAddon } from '@xterm/addon-webgl';
|
|
8
|
+
import { Unicode11Addon } from '@xterm/addon-unicode11';
|
|
9
|
+
import { SearchAddon } from '@xterm/addon-search';
|
|
10
|
+
import '@xterm/xterm/css/xterm.css';
|
|
11
|
+
|
|
12
|
+
// ─── Imperative API for parent components ────────────────────
|
|
13
|
+
|
|
14
|
+
export interface WebTerminalHandle {
|
|
15
|
+
openSessionInTerminal: (sessionId: string, projectPath: string) => void;
|
|
16
|
+
openProjectTerminal: (projectPath: string, projectName: string, agentId?: string, resumeMode?: boolean, sessionId?: string, profileEnv?: Record<string, string>) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface WebTerminalProps {
|
|
20
|
+
onActiveSession?: (sessionName: string | null) => void;
|
|
21
|
+
onCodeOpenChange?: (open: boolean) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ─── Types ───────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
interface TmuxSession {
|
|
27
|
+
name: string;
|
|
28
|
+
created: string;
|
|
29
|
+
attached: boolean;
|
|
30
|
+
windows: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type SplitNode =
|
|
34
|
+
| { type: 'terminal'; id: number; sessionName?: string; projectPath?: string }
|
|
35
|
+
| { type: 'split'; id: number; direction: 'horizontal' | 'vertical'; ratio: number; first: SplitNode; second: SplitNode };
|
|
36
|
+
|
|
37
|
+
interface TabState {
|
|
38
|
+
id: number;
|
|
39
|
+
label: string;
|
|
40
|
+
tree: SplitNode;
|
|
41
|
+
ratios: Record<number, number>;
|
|
42
|
+
activeId: number;
|
|
43
|
+
projectPath?: string;
|
|
44
|
+
bellEnabled?: boolean;
|
|
45
|
+
agent?: string; // agent ID (e.g., 'claude', 'codex', 'aider')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ─── Layout persistence ──────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
function getWsUrl() {
|
|
51
|
+
if (typeof window === 'undefined') return `ws://localhost:${parseInt(process.env.TERMINAL_PORT || '8404')}`;
|
|
52
|
+
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
53
|
+
const wsHost = window.location.hostname;
|
|
54
|
+
// When accessed via tunnel or non-localhost, use the Next.js proxy path
|
|
55
|
+
if (wsHost !== 'localhost' && wsHost !== '127.0.0.1') {
|
|
56
|
+
return `${wsProtocol}//${window.location.host}/terminal-ws`;
|
|
57
|
+
}
|
|
58
|
+
// Terminal port = web port + 1
|
|
59
|
+
const webPort = parseInt(window.location.port) || 8403;
|
|
60
|
+
return `${wsProtocol}//${wsHost}:${webPort + 1}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Load shared terminal state via API (always available, doesn't depend on terminal WebSocket server) */
|
|
64
|
+
async function loadSharedState(): Promise<{ tabs: TabState[]; activeTabId: number; sessionLabels: Record<string, string> } | null> {
|
|
65
|
+
try {
|
|
66
|
+
const res = await fetch('/api/terminal-state');
|
|
67
|
+
if (!res.ok) return null;
|
|
68
|
+
const d = await res.json();
|
|
69
|
+
if (d && Array.isArray(d.tabs) && d.tabs.length > 0 && typeof d.activeTabId === 'number') {
|
|
70
|
+
// Always start with bell disabled — user can enable manually per tab
|
|
71
|
+
const tabs = d.tabs.map((t: any) => ({ ...t, bellEnabled: false }));
|
|
72
|
+
return { tabs, activeTabId: d.activeTabId, sessionLabels: d.sessionLabels || {} };
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Save shared terminal state to server (fire-and-forget) */
|
|
81
|
+
function saveSharedState(tabs: TabState[], activeTabId: number, sessionLabels: Record<string, string>) {
|
|
82
|
+
try {
|
|
83
|
+
const ws = new WebSocket(getWsUrl());
|
|
84
|
+
ws.onopen = () => {
|
|
85
|
+
ws.send(JSON.stringify({ type: 'save-state', data: { tabs, activeTabId, sessionLabels } }));
|
|
86
|
+
setTimeout(() => ws.close(), 200);
|
|
87
|
+
};
|
|
88
|
+
ws.onerror = () => ws.close();
|
|
89
|
+
} catch {}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ─── Split tree helpers ──────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
let nextId = 1;
|
|
95
|
+
|
|
96
|
+
function initNextId(tree: SplitNode) {
|
|
97
|
+
if (tree.type === 'terminal') {
|
|
98
|
+
nextId = Math.max(nextId, tree.id + 1);
|
|
99
|
+
} else {
|
|
100
|
+
nextId = Math.max(nextId, tree.id + 1);
|
|
101
|
+
initNextId(tree.first);
|
|
102
|
+
initNextId(tree.second);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function initNextIdFromTabs(tabs: TabState[]) {
|
|
107
|
+
for (const tab of tabs) {
|
|
108
|
+
nextId = Math.max(nextId, tab.id + 1);
|
|
109
|
+
initNextId(tab.tree);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function makeTerminal(sessionName?: string, projectPath?: string): SplitNode {
|
|
114
|
+
return { type: 'terminal', id: nextId++, sessionName, projectPath };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function makeSplit(direction: 'horizontal' | 'vertical', first: SplitNode, second: SplitNode): SplitNode {
|
|
118
|
+
return { type: 'split', id: nextId++, direction, ratio: 0.5, first, second };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function splitNodeById(tree: SplitNode, targetId: number, direction: 'horizontal' | 'vertical'): SplitNode {
|
|
122
|
+
if (tree.type === 'terminal') {
|
|
123
|
+
if (tree.id === targetId) return makeSplit(direction, tree, makeTerminal());
|
|
124
|
+
return tree;
|
|
125
|
+
}
|
|
126
|
+
return { ...tree, first: splitNodeById(tree.first, targetId, direction), second: splitNodeById(tree.second, targetId, direction) };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function removeNodeById(tree: SplitNode, targetId: number): SplitNode | null {
|
|
130
|
+
if (tree.type === 'terminal') return tree.id === targetId ? null : tree;
|
|
131
|
+
if (tree.first.type === 'terminal' && tree.first.id === targetId) return tree.second;
|
|
132
|
+
if (tree.second.type === 'terminal' && tree.second.id === targetId) return tree.first;
|
|
133
|
+
const f = removeNodeById(tree.first, targetId);
|
|
134
|
+
if (f !== tree.first) return f ? { ...tree, first: f } : tree.second;
|
|
135
|
+
const s = removeNodeById(tree.second, targetId);
|
|
136
|
+
if (s !== tree.second) return s ? { ...tree, second: s } : tree.first;
|
|
137
|
+
return tree;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function updateSessionName(tree: SplitNode, targetId: number, sessionName: string): SplitNode {
|
|
141
|
+
if (tree.type === 'terminal') {
|
|
142
|
+
return tree.id === targetId ? { ...tree, sessionName } : tree;
|
|
143
|
+
}
|
|
144
|
+
return { ...tree, first: updateSessionName(tree.first, targetId, sessionName), second: updateSessionName(tree.second, targetId, sessionName) };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function countTerminals(tree: SplitNode): number {
|
|
148
|
+
if (tree.type === 'terminal') return 1;
|
|
149
|
+
return countTerminals(tree.first) + countTerminals(tree.second);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function firstTerminalId(n: SplitNode): number {
|
|
153
|
+
return n.type === 'terminal' ? n.id : firstTerminalId(n.first);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function collectPaneIds(tree: SplitNode): number[] {
|
|
157
|
+
if (tree.type === 'terminal') return [tree.id];
|
|
158
|
+
return [...collectPaneIds(tree.first), ...collectPaneIds(tree.second)];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function collectSessionNames(tree: SplitNode): string[] {
|
|
162
|
+
if (tree.type === 'terminal') return tree.sessionName ? [tree.sessionName] : [];
|
|
163
|
+
return [...collectSessionNames(tree.first), ...collectSessionNames(tree.second)];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function collectAllSessionNames(tabs: TabState[]): string[] {
|
|
167
|
+
return tabs.flatMap(t => collectSessionNames(t.tree));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ─── Mouse Toggle Button ─────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
function MouseToggle() {
|
|
173
|
+
const [mouseOn, setMouseOn] = useState(true);
|
|
174
|
+
|
|
175
|
+
const toggle = () => {
|
|
176
|
+
const next = !mouseOn;
|
|
177
|
+
try {
|
|
178
|
+
const wsUrl = getWsUrl();
|
|
179
|
+
const ws = new WebSocket(wsUrl);
|
|
180
|
+
ws.onopen = () => {
|
|
181
|
+
ws.send(JSON.stringify({ type: 'tmux-mouse', mouse: next }));
|
|
182
|
+
setTimeout(() => ws.close(), 300);
|
|
183
|
+
};
|
|
184
|
+
ws.onerror = () => ws.close();
|
|
185
|
+
} catch {}
|
|
186
|
+
setMouseOn(next);
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
<div className="flex items-center gap-1 mr-2">
|
|
191
|
+
<span className="text-[8px] text-gray-600">
|
|
192
|
+
{mouseOn ? 'scroll: trackpad · copy: Shift+drag' : 'scroll: Ctrl+B [ · copy: drag'}
|
|
193
|
+
</span>
|
|
194
|
+
<button onClick={toggle} title={mouseOn ? 'Click to disable mouse (easier text select)' : 'Click to enable mouse (trackpad scroll)'}
|
|
195
|
+
className={`text-[9px] px-1.5 py-0.5 rounded border transition-colors ${mouseOn ? 'border-green-600/40 text-green-400 bg-green-500/10' : 'border-gray-600 text-gray-500'}`}>
|
|
196
|
+
🖱️ {mouseOn ? 'ON' : 'OFF'}
|
|
197
|
+
</button>
|
|
198
|
+
</div>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ─── Pending commands for new terminal panes ────────────────
|
|
203
|
+
|
|
204
|
+
const pendingCommands = new Map<number, string>();
|
|
205
|
+
|
|
206
|
+
// ─── Bell notification tracking ─────────────────────────────
|
|
207
|
+
|
|
208
|
+
const bellEnabledPanes = new Set<number>();
|
|
209
|
+
const bellPaneLabels = new Map<number, string>();
|
|
210
|
+
const bellLastFired = new Map<string, number>(); // tabLabel -> timestamp
|
|
211
|
+
const BELL_COOLDOWN = 120000; // 2min cooldown between bells
|
|
212
|
+
|
|
213
|
+
function fireBellNotification(paneId: number) {
|
|
214
|
+
const label = bellPaneLabels.get(paneId) || 'Terminal';
|
|
215
|
+
const now = Date.now();
|
|
216
|
+
const last = bellLastFired.get(label) || 0;
|
|
217
|
+
if (now - last < BELL_COOLDOWN) return;
|
|
218
|
+
bellLastFired.set(label, now);
|
|
219
|
+
|
|
220
|
+
// Browser notification
|
|
221
|
+
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
|
|
222
|
+
new Notification('Forge — Terminal Idle', { body: `"${label}" appears to have finished.`, icon: '/icon.png' });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Telegram + in-app via API
|
|
226
|
+
fetch('/api/terminal-bell', {
|
|
227
|
+
method: 'POST',
|
|
228
|
+
headers: { 'Content-Type': 'application/json' },
|
|
229
|
+
body: JSON.stringify({ tabLabel: label }),
|
|
230
|
+
}).catch(() => {});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ─── Global drag lock — suppress terminal fit() during split drag ──
|
|
234
|
+
|
|
235
|
+
let globalDragging = false;
|
|
236
|
+
|
|
237
|
+
// ─── Main component ─────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function WebTerminal({ onActiveSession, onCodeOpenChange }, ref) {
|
|
240
|
+
const [tabs, setTabs] = useState<TabState[]>(() => {
|
|
241
|
+
const tree = makeTerminal();
|
|
242
|
+
return [{ id: nextId++, label: 'Terminal 1', tree, ratios: {}, activeId: firstTerminalId(tree) }];
|
|
243
|
+
});
|
|
244
|
+
const [activeTabId, setActiveTabId] = useState(() => tabs[0]?.id || 1);
|
|
245
|
+
const [hydrated, setHydrated] = useState(false);
|
|
246
|
+
const stateLoadedRef = useRef(false);
|
|
247
|
+
const [tmuxSessions, setTmuxSessions] = useState<TmuxSession[]>([]);
|
|
248
|
+
const [showSessionPicker, setShowSessionPicker] = useState(false);
|
|
249
|
+
const [editingTabId, setEditingTabId] = useState<number | null>(null);
|
|
250
|
+
const [editingLabel, setEditingLabel] = useState('');
|
|
251
|
+
const [closeConfirm, setCloseConfirm] = useState<{ tabId: number; sessions: string[] } | null>(null);
|
|
252
|
+
const sessionLabelsRef = useRef<Record<string, string>>({});
|
|
253
|
+
const dragTabRef = useRef<number | null>(null);
|
|
254
|
+
const [refreshKeys, setRefreshKeys] = useState<Record<number, number>>({});
|
|
255
|
+
const [tabCodeOpen, setTabCodeOpen] = useState<Record<number, boolean>>({});
|
|
256
|
+
const [showNewTabModal, setShowNewTabModal] = useState(false);
|
|
257
|
+
const [vibePickerInfo, setVibePickerInfo] = useState<{ projectPath: string; projectName: string; agentId: string; profileEnv?: Record<string, string>; supportsSession: boolean; currentSessionId: string | null } | null>(null);
|
|
258
|
+
const [projectRoots, setProjectRoots] = useState<string[]>([]);
|
|
259
|
+
const [allProjects, setAllProjects] = useState<{ name: string; path: string; root: string }[]>([]);
|
|
260
|
+
const [skipPermissions, setSkipPermissions] = useState(false);
|
|
261
|
+
const [expandedRoot, setExpandedRoot] = useState<string | null>(null);
|
|
262
|
+
const [availableAgents, setAvailableAgents] = useState<{ id: string; name: string; detected?: boolean }[]>([]);
|
|
263
|
+
const [selectedAgent, setSelectedAgent] = useState<string>('');
|
|
264
|
+
const [defaultAgentId, setDefaultAgentId] = useState('claude');
|
|
265
|
+
|
|
266
|
+
// Restore shared state from server after mount
|
|
267
|
+
useEffect(() => {
|
|
268
|
+
// Fetch settings for skipPermissions
|
|
269
|
+
fetch('/api/settings').then(r => r.json())
|
|
270
|
+
.then((s: any) => { if (s.skipPermissions) setSkipPermissions(true); })
|
|
271
|
+
.catch(() => {});
|
|
272
|
+
// Load state + projects together, then patch missing projectPath
|
|
273
|
+
Promise.all([
|
|
274
|
+
loadSharedState(),
|
|
275
|
+
fetch('/api/projects').then(r => r.json()).catch(() => []),
|
|
276
|
+
]).then(([saved, projects]) => {
|
|
277
|
+
const projList: { name: string; path: string; root: string }[] = Array.isArray(projects) ? projects : [];
|
|
278
|
+
setAllProjects(projList);
|
|
279
|
+
setProjectRoots([...new Set(projList.map(p => p.root))]);
|
|
280
|
+
|
|
281
|
+
if (saved && saved.tabs.length > 0) {
|
|
282
|
+
initNextIdFromTabs(saved.tabs);
|
|
283
|
+
// Patch missing projectPath by matching tab label to project name
|
|
284
|
+
for (const tab of saved.tabs) {
|
|
285
|
+
if (!tab.projectPath) {
|
|
286
|
+
const match = projList.find(p => p.name.toLowerCase() === tab.label.toLowerCase());
|
|
287
|
+
if (match) {
|
|
288
|
+
tab.projectPath = match.path;
|
|
289
|
+
// Also patch tree node
|
|
290
|
+
if (tab.tree.type === 'terminal') tab.tree.projectPath = match.path;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
setTabs(saved.tabs);
|
|
295
|
+
setActiveTabId(saved.activeTabId);
|
|
296
|
+
sessionLabelsRef.current = saved.sessionLabels || {};
|
|
297
|
+
stateLoadedRef.current = true;
|
|
298
|
+
}
|
|
299
|
+
setHydrated(true);
|
|
300
|
+
});
|
|
301
|
+
}, []);
|
|
302
|
+
|
|
303
|
+
// Persist to server on changes (debounced, only after hydration)
|
|
304
|
+
const saveTimerRef = useRef(0);
|
|
305
|
+
useEffect(() => {
|
|
306
|
+
if (!hydrated) return;
|
|
307
|
+
// Collect all active session names from current tabs
|
|
308
|
+
const activeSessionNames = new Set<string>();
|
|
309
|
+
for (const tab of tabs) {
|
|
310
|
+
for (const sn of collectSessionNames(tab.tree)) {
|
|
311
|
+
activeSessionNames.add(sn);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
// Only keep labels for active sessions (clean up stale entries)
|
|
315
|
+
const labels: Record<string, string> = {};
|
|
316
|
+
for (const sn of activeSessionNames) {
|
|
317
|
+
labels[sn] = sessionLabelsRef.current[sn] || '';
|
|
318
|
+
}
|
|
319
|
+
for (const tab of tabs) {
|
|
320
|
+
for (const sn of collectSessionNames(tab.tree)) {
|
|
321
|
+
labels[sn] = tab.label;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
sessionLabelsRef.current = labels;
|
|
325
|
+
// Debounced save to server
|
|
326
|
+
clearTimeout(saveTimerRef.current);
|
|
327
|
+
saveTimerRef.current = window.setTimeout(() => {
|
|
328
|
+
saveSharedState(tabs, activeTabId, labels);
|
|
329
|
+
}, 500);
|
|
330
|
+
}, [tabs, activeTabId, hydrated]);
|
|
331
|
+
|
|
332
|
+
const activeTab = tabs.find(t => t.id === activeTabId) || tabs[0];
|
|
333
|
+
|
|
334
|
+
// Notify parent when active terminal session or code state changes
|
|
335
|
+
useEffect(() => {
|
|
336
|
+
if (!activeTab) return;
|
|
337
|
+
if (onActiveSession) {
|
|
338
|
+
const sessions = collectSessionNames(activeTab.tree);
|
|
339
|
+
onActiveSession(sessions[0] || null);
|
|
340
|
+
}
|
|
341
|
+
if (onCodeOpenChange) {
|
|
342
|
+
onCodeOpenChange(tabCodeOpen[activeTab.id] ?? false);
|
|
343
|
+
}
|
|
344
|
+
}, [activeTabId, activeTab, onActiveSession, onCodeOpenChange, tabCodeOpen]);
|
|
345
|
+
|
|
346
|
+
// ─── Imperative handle for parent ─────────────────────
|
|
347
|
+
|
|
348
|
+
useImperativeHandle(ref, () => ({
|
|
349
|
+
async openSessionInTerminal(sessionId: string, projectPath: string) {
|
|
350
|
+
const tree = makeTerminal(undefined, projectPath);
|
|
351
|
+
const paneId = firstTerminalId(tree);
|
|
352
|
+
const sf = skipPermissions ? ' --dangerously-skip-permissions' : '';
|
|
353
|
+
let mcpFlag = '';
|
|
354
|
+
try { const { getMcpFlag } = await import('@/lib/session-utils'); mcpFlag = await getMcpFlag(projectPath); } catch {}
|
|
355
|
+
const cmd = `cd "${projectPath}" && claude --resume ${sessionId}${sf}${mcpFlag}\n`;
|
|
356
|
+
pendingCommands.set(paneId, cmd);
|
|
357
|
+
const projectName = projectPath.split('/').pop() || 'Terminal';
|
|
358
|
+
const newTab: TabState = {
|
|
359
|
+
id: nextId++,
|
|
360
|
+
label: projectName,
|
|
361
|
+
tree,
|
|
362
|
+
ratios: {},
|
|
363
|
+
activeId: paneId,
|
|
364
|
+
projectPath,
|
|
365
|
+
};
|
|
366
|
+
setTabs(prev => [...prev, newTab]);
|
|
367
|
+
setTimeout(() => setActiveTabId(newTab.id), 0);
|
|
368
|
+
},
|
|
369
|
+
async openProjectTerminal(projectPath: string, projectName: string, agentId?: string, resumeMode?: boolean, sessionId?: string, profileEnv?: Record<string, string>) {
|
|
370
|
+
const agent = agentId || 'claude';
|
|
371
|
+
|
|
372
|
+
// Resolve agent info via API — get correct cliCmd, cliType, supportsSession
|
|
373
|
+
let agentCmd = 'claude';
|
|
374
|
+
let supportsSession = true;
|
|
375
|
+
let agentSkipFlag = '';
|
|
376
|
+
try {
|
|
377
|
+
const resolveRes = await fetch(`/api/agents?resolve=${encodeURIComponent(agent)}`);
|
|
378
|
+
const info = await resolveRes.json();
|
|
379
|
+
agentCmd = info.cliCmd || 'claude';
|
|
380
|
+
supportsSession = info.supportsSession ?? true;
|
|
381
|
+
// Merge profile env if not already provided
|
|
382
|
+
if (!profileEnv && (info.env || info.model)) {
|
|
383
|
+
const pe: Record<string, string> = { ...(info.env || {}) };
|
|
384
|
+
if (info.model) pe.CLAUDE_MODEL = info.model;
|
|
385
|
+
profileEnv = pe;
|
|
386
|
+
}
|
|
387
|
+
// Get skip-permissions flag from agent config
|
|
388
|
+
const agentsRes = await fetch('/api/agents');
|
|
389
|
+
const agentsData = await agentsRes.json();
|
|
390
|
+
const agentConfig = (agentsData.agents || []).find((a: any) => a.id === agent);
|
|
391
|
+
agentSkipFlag = agentConfig?.skipPermissionsFlag || '';
|
|
392
|
+
} catch {}
|
|
393
|
+
|
|
394
|
+
// Resume flag: explicit sessionId > fixedSession > -c (only for session-capable agents)
|
|
395
|
+
let resumeFlag = '';
|
|
396
|
+
if (supportsSession) {
|
|
397
|
+
if (sessionId) resumeFlag = ` --resume ${sessionId}`;
|
|
398
|
+
else if (resumeMode) resumeFlag = ' -c';
|
|
399
|
+
// Override with fixedSession if no explicit sessionId
|
|
400
|
+
if (!sessionId && projectPath) {
|
|
401
|
+
try {
|
|
402
|
+
const { resolveFixedSession } = await import('@/lib/session-utils');
|
|
403
|
+
const fixedId = await resolveFixedSession(projectPath);
|
|
404
|
+
if (fixedId) resumeFlag = ` --resume ${fixedId}`;
|
|
405
|
+
} catch {}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Model flag from profile
|
|
410
|
+
const modelFlag = profileEnv?.CLAUDE_MODEL ? ` --model ${profileEnv.CLAUDE_MODEL}` : '';
|
|
411
|
+
|
|
412
|
+
// Build env exports from profile (exclude CLAUDE_MODEL — passed via --model)
|
|
413
|
+
const envExports = profileEnv
|
|
414
|
+
? Object.entries(profileEnv)
|
|
415
|
+
.filter(([k]) => k !== 'CLAUDE_MODEL')
|
|
416
|
+
.map(([k, v]) => `export ${k}="${v}"`)
|
|
417
|
+
.join(' && ')
|
|
418
|
+
: '';
|
|
419
|
+
const envPrefix = envExports ? envExports + ' && ' : '';
|
|
420
|
+
|
|
421
|
+
// Skip-permissions flag
|
|
422
|
+
let sf = '';
|
|
423
|
+
if (skipPermissions) {
|
|
424
|
+
sf = agentSkipFlag ? ` ${agentSkipFlag}` : (agentCmd === 'claude' ? ' --dangerously-skip-permissions' : '');
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// MCP config for claude-code agents
|
|
428
|
+
let mcpFlag = '';
|
|
429
|
+
if (agentCmd === 'claude' && projectPath) {
|
|
430
|
+
try { const { getMcpFlag } = await import('@/lib/session-utils'); mcpFlag = await getMcpFlag(projectPath); } catch {}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
let targetTabId: number | null = null;
|
|
434
|
+
|
|
435
|
+
setTabs(prev => {
|
|
436
|
+
// Reuse existing tab only if same project AND same agent
|
|
437
|
+
const existing = prev.find(t => t.projectPath === projectPath && (!t.agent || t.agent === agent));
|
|
438
|
+
if (existing) {
|
|
439
|
+
targetTabId = existing.id;
|
|
440
|
+
return prev;
|
|
441
|
+
}
|
|
442
|
+
const tree = makeTerminal(undefined, projectPath);
|
|
443
|
+
const paneId = firstTerminalId(tree);
|
|
444
|
+
pendingCommands.set(paneId, `${envPrefix}cd "${projectPath}" && ${agentCmd}${resumeFlag}${modelFlag}${sf}${mcpFlag}\n`);
|
|
445
|
+
const newTab: TabState = {
|
|
446
|
+
id: nextId++,
|
|
447
|
+
label: agent !== 'claude' ? `${projectName} (${agentCmd})` : projectName,
|
|
448
|
+
tree,
|
|
449
|
+
ratios: {},
|
|
450
|
+
activeId: paneId,
|
|
451
|
+
projectPath,
|
|
452
|
+
agent,
|
|
453
|
+
};
|
|
454
|
+
targetTabId = newTab.id;
|
|
455
|
+
return [...prev, newTab];
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
// Set active tab after React processes the state update
|
|
459
|
+
setTimeout(() => {
|
|
460
|
+
if (targetTabId !== null) setActiveTabId(targetTabId);
|
|
461
|
+
}, 0);
|
|
462
|
+
},
|
|
463
|
+
}), [skipPermissions]);
|
|
464
|
+
|
|
465
|
+
// ─── Tab operations ───────────────────────────────────
|
|
466
|
+
|
|
467
|
+
const addTab = useCallback((projectPath?: string) => {
|
|
468
|
+
const tree = makeTerminal(undefined, projectPath);
|
|
469
|
+
const tabNum = tabs.length + 1;
|
|
470
|
+
const label = projectPath ? projectPath.split('/').pop() || `Terminal ${tabNum}` : `Terminal ${tabNum}`;
|
|
471
|
+
const newTab: TabState = { id: nextId++, label, tree, ratios: {}, activeId: firstTerminalId(tree), projectPath };
|
|
472
|
+
setTabs(prev => [...prev, newTab]);
|
|
473
|
+
setActiveTabId(newTab.id);
|
|
474
|
+
}, [tabs.length]);
|
|
475
|
+
|
|
476
|
+
const removeTab = useCallback((tabId: number) => {
|
|
477
|
+
setTabs(prev => {
|
|
478
|
+
if (prev.length <= 1) return prev;
|
|
479
|
+
const filtered = prev.filter(t => t.id !== tabId);
|
|
480
|
+
// Also fix activeTabId if needed
|
|
481
|
+
setActiveTabId(curActive => {
|
|
482
|
+
if (curActive === tabId) {
|
|
483
|
+
const idx = prev.findIndex(t => t.id === tabId);
|
|
484
|
+
const next = prev[idx - 1] || prev[idx + 1];
|
|
485
|
+
return next?.id || prev[0]?.id || 0;
|
|
486
|
+
}
|
|
487
|
+
return curActive;
|
|
488
|
+
});
|
|
489
|
+
return filtered;
|
|
490
|
+
});
|
|
491
|
+
}, []);
|
|
492
|
+
|
|
493
|
+
const closeTab = useCallback((tabId: number) => {
|
|
494
|
+
setTabs(prev => {
|
|
495
|
+
const tab = prev.find(t => t.id === tabId);
|
|
496
|
+
if (!tab) return prev;
|
|
497
|
+
const sessions = collectSessionNames(tab.tree);
|
|
498
|
+
if (sessions.length > 0) {
|
|
499
|
+
setCloseConfirm({ tabId, sessions });
|
|
500
|
+
return prev; // don't remove yet, show dialog
|
|
501
|
+
}
|
|
502
|
+
// No sessions, just close directly
|
|
503
|
+
if (prev.length <= 1) return prev;
|
|
504
|
+
const filtered = prev.filter(t => t.id !== tabId);
|
|
505
|
+
setActiveTabId(curActive => {
|
|
506
|
+
if (curActive === tabId) {
|
|
507
|
+
const idx = prev.findIndex(t => t.id === tabId);
|
|
508
|
+
const next = prev[idx - 1] || prev[idx + 1];
|
|
509
|
+
return next?.id || prev[0]?.id || 0;
|
|
510
|
+
}
|
|
511
|
+
return curActive;
|
|
512
|
+
});
|
|
513
|
+
return filtered;
|
|
514
|
+
});
|
|
515
|
+
}, []);
|
|
516
|
+
|
|
517
|
+
const closeTabWithAction = useCallback((action: 'detach' | 'kill') => {
|
|
518
|
+
if (!closeConfirm) return;
|
|
519
|
+
const { tabId, sessions } = closeConfirm;
|
|
520
|
+
if (action === 'kill') {
|
|
521
|
+
for (const sn of sessions) {
|
|
522
|
+
const ws = new WebSocket(getWsUrl());
|
|
523
|
+
ws.onopen = () => {
|
|
524
|
+
ws.send(JSON.stringify({ type: 'kill', sessionName: sn }));
|
|
525
|
+
setTimeout(() => ws.close(), 500);
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
removeTab(tabId);
|
|
530
|
+
setCloseConfirm(null);
|
|
531
|
+
}, [closeConfirm, removeTab]);
|
|
532
|
+
|
|
533
|
+
const moveTab = useCallback((fromId: number, toId: number) => {
|
|
534
|
+
if (fromId === toId) return;
|
|
535
|
+
setTabs(prev => {
|
|
536
|
+
const fromIdx = prev.findIndex(t => t.id === fromId);
|
|
537
|
+
const toIdx = prev.findIndex(t => t.id === toId);
|
|
538
|
+
if (fromIdx < 0 || toIdx < 0) return prev;
|
|
539
|
+
const next = [...prev];
|
|
540
|
+
const [moved] = next.splice(fromIdx, 1);
|
|
541
|
+
next.splice(toIdx, 0, moved);
|
|
542
|
+
return next;
|
|
543
|
+
});
|
|
544
|
+
}, []);
|
|
545
|
+
|
|
546
|
+
const renameTab = useCallback((tabId: number, newLabel: string) => {
|
|
547
|
+
const label = newLabel.trim();
|
|
548
|
+
if (!label) return;
|
|
549
|
+
setTabs(prev => {
|
|
550
|
+
const tab = prev.find(t => t.id === tabId);
|
|
551
|
+
if (tab) {
|
|
552
|
+
const sessions = collectSessionNames(tab.tree);
|
|
553
|
+
for (const sn of sessions) {
|
|
554
|
+
sessionLabelsRef.current[sn] = label;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return prev.map(t => t.id === tabId ? { ...t, label } : t);
|
|
558
|
+
});
|
|
559
|
+
setEditingTabId(null);
|
|
560
|
+
}, []);
|
|
561
|
+
|
|
562
|
+
// ─── Update active tab's state ─────────────────────────
|
|
563
|
+
|
|
564
|
+
const updateActiveTab = useCallback((updater: (tab: TabState) => TabState) => {
|
|
565
|
+
setTabs(prev => prev.map(t => t.id === activeTabId ? updater(t) : t));
|
|
566
|
+
}, [activeTabId]);
|
|
567
|
+
|
|
568
|
+
const onSessionConnected = useCallback((paneId: number, sessionName: string) => {
|
|
569
|
+
stateLoadedRef.current = true; // Allow saving once a session is connected
|
|
570
|
+
setTabs(prev => prev.map(t => ({
|
|
571
|
+
...t,
|
|
572
|
+
tree: updateSessionName(t.tree, paneId, sessionName),
|
|
573
|
+
})));
|
|
574
|
+
}, []);
|
|
575
|
+
|
|
576
|
+
const refreshSessions = useCallback(() => {
|
|
577
|
+
// Use a short-lived WS to list sessions, with abort guard
|
|
578
|
+
let closed = false;
|
|
579
|
+
const ws = new WebSocket(getWsUrl());
|
|
580
|
+
const timeout = setTimeout(() => { closed = true; ws.close(); }, 3000);
|
|
581
|
+
ws.onopen = () => {
|
|
582
|
+
if (closed) return;
|
|
583
|
+
ws.send(JSON.stringify({ type: 'list' }));
|
|
584
|
+
};
|
|
585
|
+
ws.onmessage = (e) => {
|
|
586
|
+
clearTimeout(timeout);
|
|
587
|
+
try {
|
|
588
|
+
const msg = JSON.parse(e.data);
|
|
589
|
+
if (msg.type === 'sessions') setTmuxSessions(msg.sessions);
|
|
590
|
+
} catch {}
|
|
591
|
+
ws.close();
|
|
592
|
+
};
|
|
593
|
+
ws.onerror = () => { clearTimeout(timeout); ws.close(); };
|
|
594
|
+
}, []);
|
|
595
|
+
|
|
596
|
+
const onSplit = useCallback((dir: 'horizontal' | 'vertical') => {
|
|
597
|
+
if (!activeTab) return;
|
|
598
|
+
updateActiveTab(t => ({ ...t, tree: splitNodeById(t.tree, t.activeId, dir) }));
|
|
599
|
+
}, [activeTab, updateActiveTab]);
|
|
600
|
+
|
|
601
|
+
const onClosePane = useCallback(() => {
|
|
602
|
+
if (!activeTab) return;
|
|
603
|
+
updateActiveTab(t => {
|
|
604
|
+
if (countTerminals(t.tree) <= 1) return t;
|
|
605
|
+
const newTree = removeNodeById(t.tree, t.activeId) || t.tree;
|
|
606
|
+
return { ...t, tree: newTree, activeId: firstTerminalId(newTree) };
|
|
607
|
+
});
|
|
608
|
+
}, [activeTab, updateActiveTab]);
|
|
609
|
+
|
|
610
|
+
const closePaneById = useCallback((id: number) => {
|
|
611
|
+
updateActiveTab(t => {
|
|
612
|
+
if (countTerminals(t.tree) <= 1) return t;
|
|
613
|
+
const newTree = removeNodeById(t.tree, id) || t.tree;
|
|
614
|
+
const newActiveId = t.activeId === id ? firstTerminalId(newTree) : t.activeId;
|
|
615
|
+
return { ...t, tree: newTree, activeId: newActiveId };
|
|
616
|
+
});
|
|
617
|
+
}, [updateActiveTab]);
|
|
618
|
+
|
|
619
|
+
const setActiveId = useCallback((id: number) => {
|
|
620
|
+
updateActiveTab(t => ({ ...t, activeId: id }));
|
|
621
|
+
}, [updateActiveTab]);
|
|
622
|
+
|
|
623
|
+
const setRatios = useCallback((updater: React.SetStateAction<Record<number, number>>) => {
|
|
624
|
+
updateActiveTab(t => ({
|
|
625
|
+
...t,
|
|
626
|
+
ratios: typeof updater === 'function' ? updater(t.ratios) : updater,
|
|
627
|
+
}));
|
|
628
|
+
}, [updateActiveTab]);
|
|
629
|
+
|
|
630
|
+
const usedSessions = collectAllSessionNames(tabs);
|
|
631
|
+
|
|
632
|
+
// Toggle bell for a tab
|
|
633
|
+
const toggleBell = useCallback((tabId: number) => {
|
|
634
|
+
if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
|
|
635
|
+
Notification.requestPermission();
|
|
636
|
+
}
|
|
637
|
+
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, bellEnabled: !t.bellEnabled } : t));
|
|
638
|
+
}, []);
|
|
639
|
+
|
|
640
|
+
// Sync bell state to module-level sets for MemoTerminalPane to read
|
|
641
|
+
useEffect(() => {
|
|
642
|
+
bellEnabledPanes.clear();
|
|
643
|
+
bellPaneLabels.clear();
|
|
644
|
+
for (const tab of tabs) {
|
|
645
|
+
if (tab.bellEnabled) {
|
|
646
|
+
const paneIds = collectPaneIds(tab.tree);
|
|
647
|
+
for (const id of paneIds) {
|
|
648
|
+
bellEnabledPanes.add(id);
|
|
649
|
+
bellPaneLabels.set(id, tab.label);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}, [tabs]);
|
|
654
|
+
|
|
655
|
+
// Auto-refresh tmux sessions periodically to show detached count
|
|
656
|
+
useEffect(() => {
|
|
657
|
+
if (!hydrated) return;
|
|
658
|
+
refreshSessions();
|
|
659
|
+
const timer = setInterval(refreshSessions, 10000);
|
|
660
|
+
return () => clearInterval(timer);
|
|
661
|
+
}, [hydrated, refreshSessions]);
|
|
662
|
+
|
|
663
|
+
const detachedCount = tmuxSessions.filter(s => !usedSessions.includes(s.name)).length;
|
|
664
|
+
|
|
665
|
+
return (
|
|
666
|
+
<div className="h-full w-full flex-1 flex flex-col bg-[var(--term-bg)] overflow-hidden">
|
|
667
|
+
{/* Tab bar + toolbar */}
|
|
668
|
+
<div className="flex items-center bg-[var(--term-bar)] border-b border-[var(--term-border)] shrink-0">
|
|
669
|
+
{/* Tabs */}
|
|
670
|
+
<div className="flex items-center overflow-x-auto">
|
|
671
|
+
{tabs.map(tab => (
|
|
672
|
+
<div
|
|
673
|
+
key={tab.id}
|
|
674
|
+
draggable={editingTabId !== tab.id}
|
|
675
|
+
onDragStart={(e) => {
|
|
676
|
+
dragTabRef.current = tab.id;
|
|
677
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
678
|
+
// Make drag image semi-transparent
|
|
679
|
+
if (e.currentTarget instanceof HTMLElement) {
|
|
680
|
+
e.dataTransfer.setDragImage(e.currentTarget, 0, 0);
|
|
681
|
+
}
|
|
682
|
+
}}
|
|
683
|
+
onDragOver={(e) => {
|
|
684
|
+
e.preventDefault();
|
|
685
|
+
e.dataTransfer.dropEffect = 'move';
|
|
686
|
+
}}
|
|
687
|
+
onDrop={(e) => {
|
|
688
|
+
e.preventDefault();
|
|
689
|
+
if (dragTabRef.current !== null) {
|
|
690
|
+
moveTab(dragTabRef.current, tab.id);
|
|
691
|
+
dragTabRef.current = null;
|
|
692
|
+
}
|
|
693
|
+
}}
|
|
694
|
+
onDragEnd={() => { dragTabRef.current = null; }}
|
|
695
|
+
className={`flex items-center gap-1 px-3 py-1 text-[11px] cursor-pointer border-r border-[var(--term-border)] select-none ${
|
|
696
|
+
tab.id === activeTabId
|
|
697
|
+
? 'bg-[var(--term-bg)] text-white'
|
|
698
|
+
: 'text-gray-500 hover:text-gray-300 hover:bg-[var(--term-bg)]/50'
|
|
699
|
+
}`}
|
|
700
|
+
onClick={() => setActiveTabId(tab.id)}
|
|
701
|
+
>
|
|
702
|
+
{editingTabId === tab.id ? (
|
|
703
|
+
<input
|
|
704
|
+
autoFocus
|
|
705
|
+
value={editingLabel}
|
|
706
|
+
onChange={(e) => setEditingLabel(e.target.value)}
|
|
707
|
+
onBlur={() => renameTab(tab.id, editingLabel)}
|
|
708
|
+
onKeyDown={(e) => {
|
|
709
|
+
if (e.key === 'Enter') renameTab(tab.id, editingLabel);
|
|
710
|
+
if (e.key === 'Escape') setEditingTabId(null);
|
|
711
|
+
}}
|
|
712
|
+
onClick={(e) => e.stopPropagation()}
|
|
713
|
+
className="bg-transparent border border-[var(--term-border)] rounded px-1 text-[11px] text-white outline-none w-20"
|
|
714
|
+
/>
|
|
715
|
+
) : (
|
|
716
|
+
<span
|
|
717
|
+
className="truncate max-w-[100px]"
|
|
718
|
+
onDoubleClick={(e) => {
|
|
719
|
+
e.stopPropagation();
|
|
720
|
+
setEditingTabId(tab.id);
|
|
721
|
+
setEditingLabel(tab.label);
|
|
722
|
+
}}
|
|
723
|
+
>
|
|
724
|
+
{tab.label}
|
|
725
|
+
</span>
|
|
726
|
+
)}
|
|
727
|
+
{tab.agent && tab.agent !== 'claude' && (
|
|
728
|
+
<span className="text-[8px] text-[var(--accent)] ml-0.5">{tab.agent}</span>
|
|
729
|
+
)}
|
|
730
|
+
<button
|
|
731
|
+
onClick={(e) => { e.stopPropagation(); toggleBell(tab.id); }}
|
|
732
|
+
className={`text-[10px] ml-1 ${tab.bellEnabled ? 'text-yellow-400' : 'text-gray-600 hover:text-gray-400'}`}
|
|
733
|
+
title={tab.bellEnabled ? 'Disable notification' : 'Enable notification when idle'}
|
|
734
|
+
>{tab.bellEnabled ? '🔔' : '🔕'}</button>
|
|
735
|
+
{tabs.length > 1 && (
|
|
736
|
+
<button
|
|
737
|
+
onClick={(e) => { e.stopPropagation(); closeTab(tab.id); }}
|
|
738
|
+
className="text-[9px] text-gray-600 hover:text-red-400 ml-1"
|
|
739
|
+
>
|
|
740
|
+
x
|
|
741
|
+
</button>
|
|
742
|
+
)}
|
|
743
|
+
</div>
|
|
744
|
+
))}
|
|
745
|
+
<button
|
|
746
|
+
onClick={() => {
|
|
747
|
+
setShowNewTabModal(true);
|
|
748
|
+
setSelectedAgent('');
|
|
749
|
+
// Refresh projects + agents when opening modal
|
|
750
|
+
fetch('/api/projects').then(r => r.json())
|
|
751
|
+
.then((p: { name: string; path: string; root: string }[]) => {
|
|
752
|
+
if (!Array.isArray(p)) return;
|
|
753
|
+
setAllProjects(p);
|
|
754
|
+
setProjectRoots([...new Set(p.map(proj => proj.root))]);
|
|
755
|
+
})
|
|
756
|
+
.catch(() => {});
|
|
757
|
+
fetch('/api/agents').then(r => r.json())
|
|
758
|
+
.then(data => {
|
|
759
|
+
setAvailableAgents((data.agents || []).filter((a: any) => a.enabled));
|
|
760
|
+
setDefaultAgentId(data.defaultAgent || 'claude');
|
|
761
|
+
})
|
|
762
|
+
.catch(() => {});
|
|
763
|
+
}}
|
|
764
|
+
className="px-2 py-1 text-[11px] text-gray-500 hover:text-white hover:bg-[var(--term-border)]"
|
|
765
|
+
title="New tab"
|
|
766
|
+
>
|
|
767
|
+
+
|
|
768
|
+
</button>
|
|
769
|
+
</div>
|
|
770
|
+
|
|
771
|
+
{/* Toolbar */}
|
|
772
|
+
<div className="flex items-center gap-1 px-2 ml-auto">
|
|
773
|
+
<MouseToggle />
|
|
774
|
+
<button onClick={() => onSplit('vertical')} className="text-[10px] px-2 py-0.5 text-gray-400 hover:text-white hover:bg-[var(--term-border)] rounded">
|
|
775
|
+
Split Right
|
|
776
|
+
</button>
|
|
777
|
+
<button onClick={() => onSplit('horizontal')} className="text-[10px] px-2 py-0.5 text-gray-400 hover:text-white hover:bg-[var(--term-border)] rounded">
|
|
778
|
+
Split Down
|
|
779
|
+
</button>
|
|
780
|
+
<button
|
|
781
|
+
onClick={() => { refreshSessions(); setShowSessionPicker(v => !v); }}
|
|
782
|
+
className={`text-[10px] px-2 py-0.5 rounded relative ${showSessionPicker ? 'text-white bg-[#7c5bf0]/30' : 'text-gray-400 hover:text-white hover:bg-[var(--term-border)]'}`}
|
|
783
|
+
>
|
|
784
|
+
Sessions
|
|
785
|
+
{detachedCount > 0 && (
|
|
786
|
+
<span className="ml-1 inline-flex items-center justify-center min-w-[14px] h-[14px] rounded-full bg-yellow-500/80 text-[8px] text-black font-bold px-1">
|
|
787
|
+
{detachedCount}
|
|
788
|
+
</span>
|
|
789
|
+
)}
|
|
790
|
+
</button>
|
|
791
|
+
<button
|
|
792
|
+
onClick={() => {
|
|
793
|
+
if (!activeTab) return;
|
|
794
|
+
setRefreshKeys(prev => ({ ...prev, [activeTab.activeId]: (prev[activeTab.activeId] || 0) + 1 }));
|
|
795
|
+
}}
|
|
796
|
+
className="text-[11px] px-3 py-1 text-black bg-yellow-400 hover:bg-yellow-300 rounded font-bold"
|
|
797
|
+
title="Refresh terminal (fix garbled display)"
|
|
798
|
+
>
|
|
799
|
+
Refresh
|
|
800
|
+
</button>
|
|
801
|
+
{onCodeOpenChange && activeTab && (
|
|
802
|
+
<button
|
|
803
|
+
onClick={() => {
|
|
804
|
+
const current = tabCodeOpen[activeTab.id] ?? false;
|
|
805
|
+
const next = !current;
|
|
806
|
+
setTabCodeOpen(prev => ({ ...prev, [activeTab.id]: next }));
|
|
807
|
+
onCodeOpenChange(next);
|
|
808
|
+
}}
|
|
809
|
+
className={`text-[11px] px-3 py-1 rounded font-bold ${(tabCodeOpen[activeTab.id] ?? false) ? 'text-white bg-red-500 hover:bg-red-400' : 'text-red-400 border border-red-500 hover:bg-red-500 hover:text-white'}`}
|
|
810
|
+
title={(tabCodeOpen[activeTab.id] ?? false) ? 'Hide code panel' : 'Show code panel'}
|
|
811
|
+
>
|
|
812
|
+
Code
|
|
813
|
+
</button>
|
|
814
|
+
)}
|
|
815
|
+
</div>
|
|
816
|
+
</div>
|
|
817
|
+
|
|
818
|
+
{/* Session management panel */}
|
|
819
|
+
{showSessionPicker && (
|
|
820
|
+
<div className="bg-[var(--term-bar)] border-b border-[var(--term-border)] px-3 py-2 shrink-0 max-h-48 overflow-y-auto">
|
|
821
|
+
<div className="flex items-center justify-between mb-2">
|
|
822
|
+
<span className="text-[10px] text-gray-400 font-semibold uppercase">Tmux Sessions</span>
|
|
823
|
+
<button
|
|
824
|
+
onClick={refreshSessions}
|
|
825
|
+
className="text-[9px] text-gray-500 hover:text-white"
|
|
826
|
+
>
|
|
827
|
+
Refresh
|
|
828
|
+
</button>
|
|
829
|
+
</div>
|
|
830
|
+
{tmuxSessions.length === 0 ? (
|
|
831
|
+
<p className="text-[10px] text-gray-500">No persistent sessions. New terminals auto-create tmux sessions.</p>
|
|
832
|
+
) : (
|
|
833
|
+
<table className="w-full text-[10px]">
|
|
834
|
+
<thead>
|
|
835
|
+
<tr className="text-gray-500 text-left border-b border-[var(--term-border)]">
|
|
836
|
+
<th className="py-1 pr-3 font-medium">Session</th>
|
|
837
|
+
<th className="py-1 pr-3 font-medium">Created</th>
|
|
838
|
+
<th className="py-1 pr-3 font-medium">Status</th>
|
|
839
|
+
<th className="py-1 font-medium text-right">Actions</th>
|
|
840
|
+
</tr>
|
|
841
|
+
</thead>
|
|
842
|
+
<tbody>
|
|
843
|
+
{tmuxSessions.map(s => {
|
|
844
|
+
const inUse = usedSessions.includes(s.name);
|
|
845
|
+
const savedLabel = sessionLabelsRef.current[s.name];
|
|
846
|
+
return (
|
|
847
|
+
<tr key={s.name} className="border-b border-[var(--term-border)]/50 hover:bg-[var(--term-bg)]">
|
|
848
|
+
<td className="py-1.5 pr-3 text-gray-300">
|
|
849
|
+
{savedLabel ? (
|
|
850
|
+
<><span>{savedLabel}</span> <span className="font-mono text-gray-600 text-[9px]">{s.name.replace('mw-', '')}</span></>
|
|
851
|
+
) : (
|
|
852
|
+
<span className="font-mono">{s.name.replace('mw-', '')}</span>
|
|
853
|
+
)}
|
|
854
|
+
</td>
|
|
855
|
+
<td className="py-1.5 pr-3 text-gray-500">{new Date(s.created).toLocaleString()}</td>
|
|
856
|
+
<td className="py-1.5 pr-3">
|
|
857
|
+
{inUse ? (
|
|
858
|
+
<span className="text-green-400">● connected</span>
|
|
859
|
+
) : (
|
|
860
|
+
<span className="text-yellow-500">○ detached</span>
|
|
861
|
+
)}
|
|
862
|
+
</td>
|
|
863
|
+
<td className="py-1.5 text-right space-x-2">
|
|
864
|
+
{!inUse && (
|
|
865
|
+
<button
|
|
866
|
+
onClick={() => {
|
|
867
|
+
// Open in a new tab, restore saved label if available
|
|
868
|
+
const tree = makeTerminal(s.name);
|
|
869
|
+
const label = sessionLabelsRef.current[s.name] || s.name.replace('mw-', '');
|
|
870
|
+
const newTab: TabState = { id: nextId++, label, tree, ratios: {}, activeId: firstTerminalId(tree) };
|
|
871
|
+
setTabs(prev => [...prev, newTab]);
|
|
872
|
+
setActiveTabId(newTab.id);
|
|
873
|
+
setShowSessionPicker(false);
|
|
874
|
+
}}
|
|
875
|
+
className="text-[#7c5bf0] hover:text-white"
|
|
876
|
+
>
|
|
877
|
+
Attach
|
|
878
|
+
</button>
|
|
879
|
+
)}
|
|
880
|
+
<button
|
|
881
|
+
onClick={() => {
|
|
882
|
+
if (!confirm(`Kill session ${s.name}?`)) return;
|
|
883
|
+
const ws = new WebSocket(getWsUrl());
|
|
884
|
+
ws.onopen = () => {
|
|
885
|
+
ws.send(JSON.stringify({ type: 'kill', sessionName: s.name }));
|
|
886
|
+
setTimeout(() => { ws.close(); refreshSessions(); }, 500);
|
|
887
|
+
};
|
|
888
|
+
}}
|
|
889
|
+
className="text-red-400/60 hover:text-red-400"
|
|
890
|
+
>
|
|
891
|
+
Kill
|
|
892
|
+
</button>
|
|
893
|
+
</td>
|
|
894
|
+
</tr>
|
|
895
|
+
);
|
|
896
|
+
})}
|
|
897
|
+
</tbody>
|
|
898
|
+
</table>
|
|
899
|
+
)}
|
|
900
|
+
</div>
|
|
901
|
+
)}
|
|
902
|
+
|
|
903
|
+
{/* New tab modal */}
|
|
904
|
+
{showNewTabModal && (
|
|
905
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => { setShowNewTabModal(false); setExpandedRoot(null); }}>
|
|
906
|
+
<div className="bg-[var(--term-bg)] border border-[var(--term-border)] rounded-lg shadow-xl w-[350px] max-h-[70vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
|
907
|
+
<div className="px-4 py-3 border-b border-[var(--term-border)]">
|
|
908
|
+
<h3 className="text-sm font-semibold text-white">New Tab</h3>
|
|
909
|
+
</div>
|
|
910
|
+
<div className="flex-1 overflow-y-auto p-2">
|
|
911
|
+
{/* Plain terminal */}
|
|
912
|
+
<button
|
|
913
|
+
onClick={() => { addTab(); setShowNewTabModal(false); setExpandedRoot(null); }}
|
|
914
|
+
className="w-full text-left px-3 py-2 rounded hover:bg-[var(--term-border)] text-[12px] text-gray-300 flex items-center gap-2"
|
|
915
|
+
>
|
|
916
|
+
<span className="text-gray-500">▸</span> Terminal (no agent)
|
|
917
|
+
</button>
|
|
918
|
+
|
|
919
|
+
{/* Project roots */}
|
|
920
|
+
{projectRoots.length > 0 && (
|
|
921
|
+
<div className="mt-2 pt-2 border-t border-[var(--term-border)]">
|
|
922
|
+
<div className="px-3 py-1 text-[9px] text-gray-500 uppercase">Agent in Project</div>
|
|
923
|
+
{projectRoots.map(root => {
|
|
924
|
+
const rootName = root.split('/').pop() || root;
|
|
925
|
+
const isExpanded = expandedRoot === root;
|
|
926
|
+
const rootProjects = allProjects.filter(p => p.root === root);
|
|
927
|
+
return (
|
|
928
|
+
<div key={root}>
|
|
929
|
+
<button
|
|
930
|
+
onClick={() => setExpandedRoot(isExpanded ? null : root)}
|
|
931
|
+
className="w-full text-left px-3 py-2 rounded hover:bg-[var(--term-border)] text-[12px] text-gray-300 flex items-center gap-2"
|
|
932
|
+
>
|
|
933
|
+
<span className="text-gray-500 text-[10px] w-3">{isExpanded ? '▾' : '▸'}</span>
|
|
934
|
+
<span>{rootName}</span>
|
|
935
|
+
<span className="text-[9px] text-gray-600 ml-auto">{rootProjects.length}</span>
|
|
936
|
+
</button>
|
|
937
|
+
{isExpanded && (
|
|
938
|
+
<div className="ml-4">
|
|
939
|
+
{rootProjects.map(p => (
|
|
940
|
+
<div key={p.path} className="flex items-center gap-1 px-3 py-1.5 rounded hover:bg-[var(--term-border)]/50 text-[11px]" title={p.path}>
|
|
941
|
+
<span className="text-gray-600 text-[10px]">↳</span>
|
|
942
|
+
<span className="text-gray-300 truncate">{p.name}</span>
|
|
943
|
+
<AgentButtons
|
|
944
|
+
agents={availableAgents}
|
|
945
|
+
defaultAgentId={defaultAgentId}
|
|
946
|
+
onSelect={async (a) => {
|
|
947
|
+
setShowNewTabModal(false); setExpandedRoot(null);
|
|
948
|
+
try {
|
|
949
|
+
const resolveRes = await fetch(`/api/agents?resolve=${encodeURIComponent(a.id)}`);
|
|
950
|
+
const info = await resolveRes.json();
|
|
951
|
+
const profileEnv: Record<string, string> = { ...(info.env || {}) };
|
|
952
|
+
if (info.model) profileEnv.CLAUDE_MODEL = info.model;
|
|
953
|
+
let currentSessionId: string | null = null;
|
|
954
|
+
if (info.supportsSession) {
|
|
955
|
+
try {
|
|
956
|
+
const { resolveFixedSession } = await import('@/lib/session-utils');
|
|
957
|
+
currentSessionId = await resolveFixedSession(p.path) || null;
|
|
958
|
+
} catch {}
|
|
959
|
+
}
|
|
960
|
+
setVibePickerInfo({
|
|
961
|
+
projectPath: p.path, projectName: p.name, agentId: a.id,
|
|
962
|
+
profileEnv: Object.keys(profileEnv).length > 0 ? profileEnv : undefined,
|
|
963
|
+
supportsSession: info.supportsSession ?? true,
|
|
964
|
+
currentSessionId,
|
|
965
|
+
});
|
|
966
|
+
} catch {
|
|
967
|
+
// Fallback: open directly without picker
|
|
968
|
+
window.dispatchEvent(new CustomEvent('forge:open-terminal', {
|
|
969
|
+
detail: { projectPath: p.path, projectName: p.name, agentId: a.id },
|
|
970
|
+
}));
|
|
971
|
+
}
|
|
972
|
+
}}
|
|
973
|
+
/>
|
|
974
|
+
</div>
|
|
975
|
+
))}
|
|
976
|
+
{rootProjects.length === 0 && (
|
|
977
|
+
<div className="px-3 py-1.5 text-[10px] text-gray-600">No projects</div>
|
|
978
|
+
)}
|
|
979
|
+
</div>
|
|
980
|
+
)}
|
|
981
|
+
</div>
|
|
982
|
+
);
|
|
983
|
+
})}
|
|
984
|
+
</div>
|
|
985
|
+
)}
|
|
986
|
+
</div>
|
|
987
|
+
<div className="px-4 py-2 border-t border-[var(--term-border)]">
|
|
988
|
+
<button
|
|
989
|
+
onClick={() => { setShowNewTabModal(false); setExpandedRoot(null); }}
|
|
990
|
+
className="w-full text-center text-[11px] text-gray-500 hover:text-gray-300 py-1"
|
|
991
|
+
>
|
|
992
|
+
Cancel
|
|
993
|
+
</button>
|
|
994
|
+
</div>
|
|
995
|
+
</div>
|
|
996
|
+
</div>
|
|
997
|
+
)}
|
|
998
|
+
|
|
999
|
+
{/* Close confirmation dialog */}
|
|
1000
|
+
{closeConfirm && (
|
|
1001
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setCloseConfirm(null)}>
|
|
1002
|
+
<div className="bg-[var(--term-bg)] border border-[var(--term-border)] rounded-lg p-4 shadow-xl max-w-sm" onClick={(e) => e.stopPropagation()}>
|
|
1003
|
+
<h3 className="text-sm font-semibold text-white mb-2">Close Tab</h3>
|
|
1004
|
+
<p className="text-xs text-gray-400 mb-1">
|
|
1005
|
+
This tab has {closeConfirm.sessions.length} active session{closeConfirm.sessions.length > 1 ? 's' : ''}:
|
|
1006
|
+
</p>
|
|
1007
|
+
<div className="text-[10px] text-gray-500 font-mono mb-3 space-y-0.5">
|
|
1008
|
+
{closeConfirm.sessions.map(s => (
|
|
1009
|
+
<div key={s}>• {s.replace('mw-', '')}</div>
|
|
1010
|
+
))}
|
|
1011
|
+
</div>
|
|
1012
|
+
<div className="flex gap-2">
|
|
1013
|
+
<button
|
|
1014
|
+
onClick={() => closeTabWithAction('detach')}
|
|
1015
|
+
className="flex-1 px-3 py-1.5 text-[11px] rounded bg-[#2a2a4a] text-gray-300 hover:bg-[#3a3a5a] hover:text-white"
|
|
1016
|
+
>
|
|
1017
|
+
Hide Tab
|
|
1018
|
+
<span className="block text-[9px] text-gray-500 mt-0.5">Session keeps running</span>
|
|
1019
|
+
</button>
|
|
1020
|
+
<button
|
|
1021
|
+
onClick={() => closeTabWithAction('kill')}
|
|
1022
|
+
className="flex-1 px-3 py-1.5 text-[11px] rounded bg-red-500/20 text-red-400 hover:bg-red-500/30"
|
|
1023
|
+
>
|
|
1024
|
+
Kill Session
|
|
1025
|
+
<span className="block text-[9px] text-red-400/60 mt-0.5">Permanently close</span>
|
|
1026
|
+
</button>
|
|
1027
|
+
</div>
|
|
1028
|
+
<button
|
|
1029
|
+
onClick={() => setCloseConfirm(null)}
|
|
1030
|
+
className="w-full mt-2 px-3 py-1 text-[10px] text-gray-500 hover:text-gray-300"
|
|
1031
|
+
>
|
|
1032
|
+
Cancel
|
|
1033
|
+
</button>
|
|
1034
|
+
</div>
|
|
1035
|
+
</div>
|
|
1036
|
+
)}
|
|
1037
|
+
|
|
1038
|
+
{/* VibeCoding Terminal Session Picker */}
|
|
1039
|
+
{vibePickerInfo && (
|
|
1040
|
+
<TerminalSessionPickerLazy
|
|
1041
|
+
agentLabel={vibePickerInfo.projectName}
|
|
1042
|
+
currentSessionId={vibePickerInfo.currentSessionId}
|
|
1043
|
+
supportsSession={vibePickerInfo.supportsSession}
|
|
1044
|
+
fetchSessions={() => fetchProjectSessions(vibePickerInfo.projectName)}
|
|
1045
|
+
onSelect={(sel) => {
|
|
1046
|
+
const info = vibePickerInfo;
|
|
1047
|
+
setVibePickerInfo(null);
|
|
1048
|
+
const detail: any = { projectPath: info.projectPath, projectName: info.projectName, agentId: info.agentId, profileEnv: info.profileEnv };
|
|
1049
|
+
if (sel.mode !== 'new') { detail.resumeMode = true; detail.sessionId = sel.sessionId; }
|
|
1050
|
+
window.dispatchEvent(new CustomEvent('forge:open-terminal', { detail }));
|
|
1051
|
+
}}
|
|
1052
|
+
onCancel={() => setVibePickerInfo(null)}
|
|
1053
|
+
/>
|
|
1054
|
+
)}
|
|
1055
|
+
|
|
1056
|
+
{/* Terminal panes — render all tabs, hide inactive */}
|
|
1057
|
+
{tabs.map(tab => (
|
|
1058
|
+
<div key={tab.id} className={`flex-1 min-h-0 ${tab.id === activeTabId ? '' : 'hidden'}`}>
|
|
1059
|
+
<PaneRenderer
|
|
1060
|
+
node={tab.tree}
|
|
1061
|
+
activeId={tab.activeId}
|
|
1062
|
+
onFocus={tab.id === activeTabId ? setActiveId : () => {}}
|
|
1063
|
+
ratios={tab.ratios}
|
|
1064
|
+
setRatios={tab.id === activeTabId ? setRatios : () => {}}
|
|
1065
|
+
onSessionConnected={onSessionConnected}
|
|
1066
|
+
refreshKeys={refreshKeys}
|
|
1067
|
+
skipPermissions={skipPermissions}
|
|
1068
|
+
canClose={countTerminals(tab.tree) > 1}
|
|
1069
|
+
onClosePane={tab.id === activeTabId ? closePaneById : undefined}
|
|
1070
|
+
/>
|
|
1071
|
+
</div>
|
|
1072
|
+
))}
|
|
1073
|
+
</div>
|
|
1074
|
+
);
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
export default WebTerminal;
|
|
1078
|
+
|
|
1079
|
+
// ─── Pane renderer ───────────────────────────────────────────
|
|
1080
|
+
|
|
1081
|
+
// ─── Agent shortcut buttons (inline with project name) ──────
|
|
1082
|
+
|
|
1083
|
+
function AgentButtons({ agents, defaultAgentId, onSelect }: {
|
|
1084
|
+
agents: { id: string; name: string; detected?: boolean }[];
|
|
1085
|
+
defaultAgentId: string;
|
|
1086
|
+
onSelect: (agent: { id: string; name: string }) => void;
|
|
1087
|
+
}) {
|
|
1088
|
+
const [showMore, setShowMore] = useState(false);
|
|
1089
|
+
const MAX_INLINE = 3;
|
|
1090
|
+
|
|
1091
|
+
const getAbbr = (id: string) =>
|
|
1092
|
+
id === 'claude' ? 'C' : id === 'codex' ? 'X' : id === 'aider' ? 'A' : id.charAt(0).toUpperCase();
|
|
1093
|
+
|
|
1094
|
+
const btnClass = (id: string, detected?: boolean) => {
|
|
1095
|
+
if (detected === false) return 'w-5 h-5 flex items-center justify-center rounded text-[9px] font-bold bg-gray-800/50 text-gray-600 cursor-not-allowed';
|
|
1096
|
+
if (id === defaultAgentId) return 'w-5 h-5 flex items-center justify-center rounded text-[9px] font-bold bg-green-500/30 text-green-400 hover:bg-green-500 hover:text-white';
|
|
1097
|
+
return 'w-5 h-5 flex items-center justify-center rounded text-[9px] font-bold bg-green-900/30 text-green-300/70 hover:bg-green-700/50 hover:text-green-200';
|
|
1098
|
+
};
|
|
1099
|
+
|
|
1100
|
+
const inline = agents.slice(0, MAX_INLINE);
|
|
1101
|
+
const overflow = agents.slice(MAX_INLINE);
|
|
1102
|
+
|
|
1103
|
+
return (
|
|
1104
|
+
<div className="flex items-center gap-0.5 ml-auto shrink-0 relative">
|
|
1105
|
+
{inline.map(a => (
|
|
1106
|
+
<button
|
|
1107
|
+
key={a.id}
|
|
1108
|
+
title={a.detected === false ? `${a.name} (not installed)` : `Open with ${a.name}`}
|
|
1109
|
+
onClick={() => { if (a.detected !== false) onSelect(a); }}
|
|
1110
|
+
className={btnClass(a.id, a.detected)}
|
|
1111
|
+
>
|
|
1112
|
+
{getAbbr(a.id)}
|
|
1113
|
+
</button>
|
|
1114
|
+
))}
|
|
1115
|
+
{overflow.length > 0 && (
|
|
1116
|
+
<>
|
|
1117
|
+
<button
|
|
1118
|
+
title="More agents"
|
|
1119
|
+
onClick={(e) => { e.stopPropagation(); setShowMore(v => !v); }}
|
|
1120
|
+
className="w-5 h-5 flex items-center justify-center rounded text-[9px] bg-gray-700/50 text-gray-400 hover:bg-gray-600 hover:text-white"
|
|
1121
|
+
>…</button>
|
|
1122
|
+
{showMore && (
|
|
1123
|
+
<>
|
|
1124
|
+
<div className="fixed inset-0 z-40" onClick={() => setShowMore(false)} />
|
|
1125
|
+
<div className="absolute right-0 top-6 z-50 bg-[var(--term-bg)] border border-[var(--term-border)] rounded shadow-lg py-1 min-w-[120px]">
|
|
1126
|
+
{overflow.map(a => (
|
|
1127
|
+
<button
|
|
1128
|
+
key={a.id}
|
|
1129
|
+
onClick={() => { if (a.detected !== false) { setShowMore(false); onSelect(a); } }}
|
|
1130
|
+
className={`w-full text-left px-3 py-1 text-[10px] flex items-center gap-2 ${a.detected === false ? 'text-gray-600 cursor-not-allowed' : 'text-gray-300 hover:bg-[var(--term-border)]'}`}
|
|
1131
|
+
>
|
|
1132
|
+
<span className={btnClass(a.id, a.detected) + ' w-4 h-4 text-[8px]'}>{getAbbr(a.id)}</span>
|
|
1133
|
+
{a.name} {a.detected === false ? '(not installed)' : ''}
|
|
1134
|
+
</button>
|
|
1135
|
+
))}
|
|
1136
|
+
</div>
|
|
1137
|
+
</>
|
|
1138
|
+
)}
|
|
1139
|
+
</>
|
|
1140
|
+
)}
|
|
1141
|
+
</div>
|
|
1142
|
+
);
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
function PaneRenderer({
|
|
1146
|
+
node, activeId, onFocus, ratios, setRatios, onSessionConnected, refreshKeys, skipPermissions, canClose, onClosePane,
|
|
1147
|
+
}: {
|
|
1148
|
+
node: SplitNode;
|
|
1149
|
+
activeId: number;
|
|
1150
|
+
onFocus: (id: number) => void;
|
|
1151
|
+
ratios: Record<number, number>;
|
|
1152
|
+
setRatios: React.Dispatch<React.SetStateAction<Record<number, number>>>;
|
|
1153
|
+
onSessionConnected: (paneId: number, sessionName: string) => void;
|
|
1154
|
+
refreshKeys: Record<number, number>;
|
|
1155
|
+
skipPermissions?: boolean;
|
|
1156
|
+
canClose?: boolean;
|
|
1157
|
+
onClosePane?: (id: number) => void;
|
|
1158
|
+
}) {
|
|
1159
|
+
if (node.type === 'terminal') {
|
|
1160
|
+
return (
|
|
1161
|
+
<div className={`h-full w-full relative group/pane ${activeId === node.id ? 'ring-1 ring-[#7c5bf0]/50 ring-inset' : ''}`} onMouseDown={() => onFocus(node.id)}>
|
|
1162
|
+
<MemoTerminalPane key={`${node.id}-${refreshKeys[node.id] || 0}`} id={node.id} sessionName={node.sessionName} projectPath={node.projectPath} skipPermissions={skipPermissions} onSessionConnected={onSessionConnected} />
|
|
1163
|
+
{canClose && onClosePane && (
|
|
1164
|
+
<button
|
|
1165
|
+
onClick={(e) => { e.stopPropagation(); if (confirm('Close this pane?')) onClosePane(node.id); }}
|
|
1166
|
+
className="absolute top-1.5 right-1.5 z-10 w-6 h-6 flex items-center justify-center rounded bg-red-500/80 text-white hover:bg-red-500 opacity-0 group-hover/pane:opacity-100 transition-opacity text-xs font-bold shadow"
|
|
1167
|
+
title="Close this pane"
|
|
1168
|
+
>✕</button>
|
|
1169
|
+
)}
|
|
1170
|
+
</div>
|
|
1171
|
+
);
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
const ratio = ratios[node.id] ?? node.ratio;
|
|
1175
|
+
|
|
1176
|
+
return (
|
|
1177
|
+
<DraggableSplit splitId={node.id} direction={node.direction} ratio={ratio} setRatios={setRatios}>
|
|
1178
|
+
<PaneRenderer node={node.first} activeId={activeId} onFocus={onFocus} ratios={ratios} setRatios={setRatios} onSessionConnected={onSessionConnected} refreshKeys={refreshKeys} skipPermissions={skipPermissions} canClose={canClose} onClosePane={onClosePane} />
|
|
1179
|
+
<PaneRenderer node={node.second} activeId={activeId} onFocus={onFocus} ratios={ratios} setRatios={setRatios} onSessionConnected={onSessionConnected} refreshKeys={refreshKeys} skipPermissions={skipPermissions} canClose={canClose} onClosePane={onClosePane} />
|
|
1180
|
+
</DraggableSplit>
|
|
1181
|
+
);
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// ─── Draggable split — uses pointer capture for reliable drag ─
|
|
1185
|
+
|
|
1186
|
+
function DraggableSplit({
|
|
1187
|
+
splitId, direction, ratio, setRatios, children,
|
|
1188
|
+
}: {
|
|
1189
|
+
splitId: number;
|
|
1190
|
+
direction: 'horizontal' | 'vertical';
|
|
1191
|
+
ratio: number;
|
|
1192
|
+
setRatios: React.Dispatch<React.SetStateAction<Record<number, number>>>;
|
|
1193
|
+
children: [React.ReactNode, React.ReactNode];
|
|
1194
|
+
}) {
|
|
1195
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
1196
|
+
const firstRef = useRef<HTMLDivElement>(null);
|
|
1197
|
+
const secondRef = useRef<HTMLDivElement>(null);
|
|
1198
|
+
const dividerRef = useRef<HTMLDivElement>(null);
|
|
1199
|
+
const draggingRef = useRef(false);
|
|
1200
|
+
const ratioRef = useRef(ratio);
|
|
1201
|
+
const isVert = direction === 'vertical';
|
|
1202
|
+
|
|
1203
|
+
// Keep ref in sync — avoid re-registering listeners on every ratio change
|
|
1204
|
+
ratioRef.current = ratio;
|
|
1205
|
+
|
|
1206
|
+
// Apply ratio to DOM (only when not dragging — drag updates imperatively)
|
|
1207
|
+
useEffect(() => {
|
|
1208
|
+
if (draggingRef.current) return;
|
|
1209
|
+
if (!firstRef.current || !secondRef.current) return;
|
|
1210
|
+
const prop = isVert ? 'width' : 'height';
|
|
1211
|
+
firstRef.current.style[prop] = `calc(${ratio * 100}% - 4px)`;
|
|
1212
|
+
secondRef.current.style[prop] = `calc(${(1 - ratio) * 100}% - 4px)`;
|
|
1213
|
+
}, [ratio, isVert]);
|
|
1214
|
+
|
|
1215
|
+
// Pointer capture drag — registered once, uses refs
|
|
1216
|
+
useEffect(() => {
|
|
1217
|
+
const divider = dividerRef.current;
|
|
1218
|
+
const container = containerRef.current;
|
|
1219
|
+
const first = firstRef.current;
|
|
1220
|
+
const second = secondRef.current;
|
|
1221
|
+
if (!divider || !container || !first || !second) return;
|
|
1222
|
+
|
|
1223
|
+
const vertical = isVert;
|
|
1224
|
+
const prop = vertical ? 'width' : 'height';
|
|
1225
|
+
let lastRatio = ratioRef.current;
|
|
1226
|
+
|
|
1227
|
+
const onPointerDown = (e: PointerEvent) => {
|
|
1228
|
+
e.preventDefault();
|
|
1229
|
+
e.stopPropagation();
|
|
1230
|
+
divider.setPointerCapture(e.pointerId);
|
|
1231
|
+
draggingRef.current = true;
|
|
1232
|
+
globalDragging = true;
|
|
1233
|
+
lastRatio = ratioRef.current;
|
|
1234
|
+
document.body.style.cursor = vertical ? 'col-resize' : 'row-resize';
|
|
1235
|
+
document.body.style.userSelect = 'none';
|
|
1236
|
+
};
|
|
1237
|
+
|
|
1238
|
+
const onPointerMove = (e: PointerEvent) => {
|
|
1239
|
+
if (!draggingRef.current) return;
|
|
1240
|
+
const rect = container.getBoundingClientRect();
|
|
1241
|
+
let r = vertical
|
|
1242
|
+
? (e.clientX - rect.left) / rect.width
|
|
1243
|
+
: (e.clientY - rect.top) / rect.height;
|
|
1244
|
+
r = Math.max(0.1, Math.min(0.9, r));
|
|
1245
|
+
lastRatio = r;
|
|
1246
|
+
// Imperative DOM update — no React re-render during drag
|
|
1247
|
+
first.style[prop] = `calc(${r * 100}% - 4px)`;
|
|
1248
|
+
second.style[prop] = `calc(${(1 - r) * 100}% - 4px)`;
|
|
1249
|
+
};
|
|
1250
|
+
|
|
1251
|
+
const onPointerUp = () => {
|
|
1252
|
+
if (!draggingRef.current) return;
|
|
1253
|
+
draggingRef.current = false;
|
|
1254
|
+
globalDragging = false;
|
|
1255
|
+
document.body.style.cursor = '';
|
|
1256
|
+
document.body.style.userSelect = '';
|
|
1257
|
+
// Commit final ratio to React state (single re-render)
|
|
1258
|
+
setRatios(prev => ({ ...prev, [splitId]: lastRatio }));
|
|
1259
|
+
// Trigger a global resize so all terminals fit() once after drag ends
|
|
1260
|
+
window.dispatchEvent(new Event('terminal-drag-end'));
|
|
1261
|
+
};
|
|
1262
|
+
|
|
1263
|
+
divider.addEventListener('pointerdown', onPointerDown);
|
|
1264
|
+
divider.addEventListener('pointermove', onPointerMove);
|
|
1265
|
+
divider.addEventListener('pointerup', onPointerUp);
|
|
1266
|
+
divider.addEventListener('lostpointercapture', onPointerUp);
|
|
1267
|
+
|
|
1268
|
+
return () => {
|
|
1269
|
+
divider.removeEventListener('pointerdown', onPointerDown);
|
|
1270
|
+
divider.removeEventListener('pointermove', onPointerMove);
|
|
1271
|
+
divider.removeEventListener('pointerup', onPointerUp);
|
|
1272
|
+
divider.removeEventListener('lostpointercapture', onPointerUp);
|
|
1273
|
+
};
|
|
1274
|
+
// Only re-register if direction or splitId changes (not on every ratio change)
|
|
1275
|
+
}, [isVert, splitId, setRatios]);
|
|
1276
|
+
|
|
1277
|
+
return (
|
|
1278
|
+
<div ref={containerRef} className="h-full w-full" style={{ display: 'flex', flexDirection: isVert ? 'row' : 'column' }}>
|
|
1279
|
+
<div ref={firstRef} style={{ minWidth: 0, minHeight: 0, overflow: 'hidden', [isVert ? 'width' : 'height']: `calc(${ratio * 100}% - 4px)` }}>
|
|
1280
|
+
{children[0]}
|
|
1281
|
+
</div>
|
|
1282
|
+
<div
|
|
1283
|
+
ref={dividerRef}
|
|
1284
|
+
className={`shrink-0 ${isVert ? 'w-2 cursor-col-resize' : 'h-2 cursor-row-resize'} bg-[#2a2a4a] hover:bg-[#7c5bf0] active:bg-[#7c5bf0] transition-colors`}
|
|
1285
|
+
style={{ touchAction: 'none', zIndex: 10 }}
|
|
1286
|
+
/>
|
|
1287
|
+
<div ref={secondRef} style={{ minWidth: 0, minHeight: 0, overflow: 'hidden', [isVert ? 'width' : 'height']: `calc(${(1 - ratio) * 100}% - 4px)` }}>
|
|
1288
|
+
{children[1]}
|
|
1289
|
+
</div>
|
|
1290
|
+
</div>
|
|
1291
|
+
);
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
// ─── Terminal pane with tmux session support ──────────────────
|
|
1295
|
+
|
|
1296
|
+
const MemoTerminalPane = memo(function TerminalPane({
|
|
1297
|
+
id,
|
|
1298
|
+
sessionName,
|
|
1299
|
+
projectPath,
|
|
1300
|
+
skipPermissions,
|
|
1301
|
+
onSessionConnected,
|
|
1302
|
+
}: {
|
|
1303
|
+
id: number;
|
|
1304
|
+
sessionName?: string;
|
|
1305
|
+
projectPath?: string;
|
|
1306
|
+
skipPermissions?: boolean;
|
|
1307
|
+
onSessionConnected: (paneId: number, sessionName: string) => void;
|
|
1308
|
+
}) {
|
|
1309
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
1310
|
+
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
1311
|
+
const searchAddonRef = useRef<SearchAddon | null>(null);
|
|
1312
|
+
const [showSearch, setShowSearch] = useState(false);
|
|
1313
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
1314
|
+
const sessionNameRef = useRef(sessionName);
|
|
1315
|
+
sessionNameRef.current = sessionName;
|
|
1316
|
+
const projectPathRef = useRef(projectPath);
|
|
1317
|
+
const skipPermRef = useRef(skipPermissions);
|
|
1318
|
+
skipPermRef.current = skipPermissions;
|
|
1319
|
+
projectPathRef.current = projectPath;
|
|
1320
|
+
|
|
1321
|
+
useEffect(() => {
|
|
1322
|
+
if (!containerRef.current) return;
|
|
1323
|
+
|
|
1324
|
+
let disposed = false; // guard against post-cleanup writes (React Strict Mode)
|
|
1325
|
+
let bellArmed = false; // armed after user presses Enter
|
|
1326
|
+
let bellNewBytes = 0;
|
|
1327
|
+
let bellIdleTimer = 0;
|
|
1328
|
+
let bellArmedAt = 0; // timestamp when armed
|
|
1329
|
+
|
|
1330
|
+
// Read terminal theme from CSS variables
|
|
1331
|
+
const cs = getComputedStyle(document.documentElement);
|
|
1332
|
+
const tv = (name: string) => cs.getPropertyValue(name).trim();
|
|
1333
|
+
const termBg = tv('--term-bg') || '#1a1a2e';
|
|
1334
|
+
const termFg = tv('--term-fg') || '#e0e0e0';
|
|
1335
|
+
const termCursor = tv('--term-cursor') || '#7c5bf0';
|
|
1336
|
+
const isLight = document.documentElement.getAttribute('data-theme') === 'light';
|
|
1337
|
+
|
|
1338
|
+
const term = new Terminal({
|
|
1339
|
+
allowProposedApi: true,
|
|
1340
|
+
cursorBlink: true,
|
|
1341
|
+
fontSize: 13,
|
|
1342
|
+
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
|
1343
|
+
scrollback: 10000,
|
|
1344
|
+
logger: { trace: () => {}, debug: () => {}, info: () => {}, warn: () => {}, error: () => {} },
|
|
1345
|
+
theme: isLight ? {
|
|
1346
|
+
background: termBg,
|
|
1347
|
+
foreground: termFg,
|
|
1348
|
+
cursor: termCursor,
|
|
1349
|
+
selectionBackground: termCursor + '44',
|
|
1350
|
+
black: '#1a1a1a',
|
|
1351
|
+
red: '#d32f2f',
|
|
1352
|
+
green: '#388e3c',
|
|
1353
|
+
yellow: '#f57f17',
|
|
1354
|
+
blue: '#1976d2',
|
|
1355
|
+
magenta: '#7b1fa2',
|
|
1356
|
+
cyan: '#0097a7',
|
|
1357
|
+
white: '#424242',
|
|
1358
|
+
brightBlack: '#757575',
|
|
1359
|
+
brightRed: '#e53935',
|
|
1360
|
+
brightGreen: '#43a047',
|
|
1361
|
+
brightYellow: '#f9a825',
|
|
1362
|
+
brightBlue: '#1e88e5',
|
|
1363
|
+
brightMagenta: '#8e24aa',
|
|
1364
|
+
brightCyan: '#00acc1',
|
|
1365
|
+
brightWhite: '#1a1a1a',
|
|
1366
|
+
} : {
|
|
1367
|
+
background: termBg,
|
|
1368
|
+
foreground: termFg,
|
|
1369
|
+
cursor: termCursor,
|
|
1370
|
+
selectionBackground: termCursor + '66',
|
|
1371
|
+
black: '#1a1a2e',
|
|
1372
|
+
red: '#ff6b6b',
|
|
1373
|
+
green: '#69db7c',
|
|
1374
|
+
yellow: '#ffd43b',
|
|
1375
|
+
blue: '#7c5bf0',
|
|
1376
|
+
magenta: '#da77f2',
|
|
1377
|
+
cyan: '#66d9ef',
|
|
1378
|
+
white: '#e0e0e0',
|
|
1379
|
+
brightBlack: '#555',
|
|
1380
|
+
brightRed: '#ff8787',
|
|
1381
|
+
brightGreen: '#8ce99a',
|
|
1382
|
+
brightYellow: '#ffe066',
|
|
1383
|
+
brightBlue: '#9775fa',
|
|
1384
|
+
brightMagenta: '#e599f7',
|
|
1385
|
+
brightCyan: '#99e9f2',
|
|
1386
|
+
brightWhite: '#ffffff',
|
|
1387
|
+
},
|
|
1388
|
+
});
|
|
1389
|
+
|
|
1390
|
+
const fit = new FitAddon();
|
|
1391
|
+
term.loadAddon(fit);
|
|
1392
|
+
|
|
1393
|
+
// Wait for container to be visible and have stable dimensions before opening
|
|
1394
|
+
let initDone = false;
|
|
1395
|
+
const el = containerRef.current;
|
|
1396
|
+
|
|
1397
|
+
function initTerminal() {
|
|
1398
|
+
if (initDone || disposed || !el) return;
|
|
1399
|
+
// Don't init if inside a hidden tab or too small
|
|
1400
|
+
if (el.closest('.hidden') || el.offsetWidth < 50 || el.offsetHeight < 30) return;
|
|
1401
|
+
initDone = true;
|
|
1402
|
+
term.open(el);
|
|
1403
|
+
// WebGL: GPU-accelerated rendering with canvas fallback
|
|
1404
|
+
try {
|
|
1405
|
+
const webgl = new WebglAddon();
|
|
1406
|
+
webgl.onContextLoss(() => webgl.dispose());
|
|
1407
|
+
term.loadAddon(webgl);
|
|
1408
|
+
} catch {}
|
|
1409
|
+
// Unicode 11: correct width for CJK characters
|
|
1410
|
+
try {
|
|
1411
|
+
const unicode11 = new Unicode11Addon();
|
|
1412
|
+
term.loadAddon(unicode11);
|
|
1413
|
+
term.unicode.activeVersion = '11';
|
|
1414
|
+
} catch {}
|
|
1415
|
+
// Search: Ctrl/Cmd+F to find text in terminal buffer
|
|
1416
|
+
try {
|
|
1417
|
+
const search = new SearchAddon();
|
|
1418
|
+
term.loadAddon(search);
|
|
1419
|
+
searchAddonRef.current = search;
|
|
1420
|
+
} catch {}
|
|
1421
|
+
try { fit.fit(); } catch {}
|
|
1422
|
+
connect();
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// Try immediately, then observe for visibility changes
|
|
1426
|
+
requestAnimationFrame(() => {
|
|
1427
|
+
if (disposed) return;
|
|
1428
|
+
initTerminal();
|
|
1429
|
+
});
|
|
1430
|
+
|
|
1431
|
+
// If not visible yet (hidden tab), use IntersectionObserver to detect when it becomes visible
|
|
1432
|
+
const visObserver = new IntersectionObserver((entries) => {
|
|
1433
|
+
if (entries[0]?.isIntersecting) {
|
|
1434
|
+
initTerminal();
|
|
1435
|
+
}
|
|
1436
|
+
});
|
|
1437
|
+
visObserver.observe(el);
|
|
1438
|
+
|
|
1439
|
+
// ── WebSocket with auto-reconnect ──
|
|
1440
|
+
|
|
1441
|
+
const wsUrl = getWsUrl();
|
|
1442
|
+
let ws: WebSocket | null = null;
|
|
1443
|
+
let reconnectTimer = 0;
|
|
1444
|
+
let connectedSession: string | null = null;
|
|
1445
|
+
let createRetries = 0;
|
|
1446
|
+
const MAX_CREATE_RETRIES = 2;
|
|
1447
|
+
let reconnectAttempts = 0;
|
|
1448
|
+
let isNewlyCreated = false;
|
|
1449
|
+
|
|
1450
|
+
function connect() {
|
|
1451
|
+
if (disposed) return;
|
|
1452
|
+
const socket = new WebSocket(wsUrl);
|
|
1453
|
+
ws = socket;
|
|
1454
|
+
|
|
1455
|
+
socket.onopen = () => {
|
|
1456
|
+
if (disposed) { socket.close(); return; }
|
|
1457
|
+
if (socket.readyState !== WebSocket.OPEN) return;
|
|
1458
|
+
const cols = term.cols;
|
|
1459
|
+
const rows = term.rows;
|
|
1460
|
+
|
|
1461
|
+
if (connectedSession) {
|
|
1462
|
+
// Reconnect to the same session
|
|
1463
|
+
socket.send(JSON.stringify({ type: 'attach', sessionName: connectedSession, cols, rows }));
|
|
1464
|
+
} else {
|
|
1465
|
+
const sn = sessionNameRef.current;
|
|
1466
|
+
if (sn) {
|
|
1467
|
+
socket.send(JSON.stringify({ type: 'attach', sessionName: sn, cols, rows }));
|
|
1468
|
+
} else if (createRetries < MAX_CREATE_RETRIES) {
|
|
1469
|
+
createRetries++;
|
|
1470
|
+
isNewlyCreated = true;
|
|
1471
|
+
socket.send(JSON.stringify({ type: 'create', cols, rows }));
|
|
1472
|
+
} else {
|
|
1473
|
+
term.write('\r\n\x1b[91m[failed to create session — check server logs]\x1b[0m\r\n');
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
};
|
|
1477
|
+
|
|
1478
|
+
ws.onmessage = (event) => {
|
|
1479
|
+
if (disposed || !initDone) return;
|
|
1480
|
+
try {
|
|
1481
|
+
const msg = JSON.parse(event.data);
|
|
1482
|
+
if (msg.type === 'output') {
|
|
1483
|
+
try { term.write(msg.data); } catch {};
|
|
1484
|
+
// Bell: detect claude completion
|
|
1485
|
+
// Bell: idle detection after user submits prompt
|
|
1486
|
+
if (bellEnabledPanes.has(id) && bellArmed) {
|
|
1487
|
+
bellNewBytes += (msg.data as string).length;
|
|
1488
|
+
clearTimeout(bellIdleTimer);
|
|
1489
|
+
if (bellNewBytes > 2000) {
|
|
1490
|
+
// 10s idle = claude finished
|
|
1491
|
+
bellIdleTimer = window.setTimeout(() => {
|
|
1492
|
+
bellArmed = false;
|
|
1493
|
+
fireBellNotification(id);
|
|
1494
|
+
}, 10000);
|
|
1495
|
+
// Fallback: if 90s since armed with activity, force fire
|
|
1496
|
+
if (Date.now() - bellArmedAt > 90000) {
|
|
1497
|
+
bellArmed = false;
|
|
1498
|
+
clearTimeout(bellIdleTimer);
|
|
1499
|
+
fireBellNotification(id);
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
} else if (msg.type === 'connected') {
|
|
1504
|
+
connectedSession = msg.sessionName;
|
|
1505
|
+
createRetries = 0;
|
|
1506
|
+
reconnectAttempts = 0;
|
|
1507
|
+
onSessionConnected(id, msg.sessionName);
|
|
1508
|
+
// Auto-run claude for project tabs (only if no pendingCommand already set)
|
|
1509
|
+
if (isNewlyCreated && projectPathRef.current && !pendingCommands.has(id)) {
|
|
1510
|
+
isNewlyCreated = false;
|
|
1511
|
+
const pp = projectPathRef.current;
|
|
1512
|
+
import('@/lib/session-utils').then(({ resolveFixedSession, buildResumeFlag, getMcpFlag }) => {
|
|
1513
|
+
Promise.all([resolveFixedSession(pp), getMcpFlag(pp)]).then(([fixedId, mcpFlag]) => {
|
|
1514
|
+
const resumeFlag = buildResumeFlag(fixedId, true);
|
|
1515
|
+
const skipFlag = skipPermRef.current ? ' --dangerously-skip-permissions' : '';
|
|
1516
|
+
setTimeout(() => {
|
|
1517
|
+
if (!disposed && ws?.readyState === WebSocket.OPEN) {
|
|
1518
|
+
ws.send(JSON.stringify({ type: 'input', data: `cd "${pp}" && claude${resumeFlag}${skipFlag}${mcpFlag}\n` }));
|
|
1519
|
+
}
|
|
1520
|
+
}, 300);
|
|
1521
|
+
});
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1524
|
+
isNewlyCreated = false;
|
|
1525
|
+
// Force tmux to redraw by toggling size, then send reset
|
|
1526
|
+
setTimeout(() => {
|
|
1527
|
+
if (disposed || ws?.readyState !== WebSocket.OPEN) return;
|
|
1528
|
+
const c = term.cols, r = term.rows;
|
|
1529
|
+
ws!.send(JSON.stringify({ type: 'resize', cols: c - 1, rows: r }));
|
|
1530
|
+
setTimeout(() => {
|
|
1531
|
+
if (disposed || ws?.readyState !== WebSocket.OPEN) return;
|
|
1532
|
+
ws!.send(JSON.stringify({ type: 'resize', cols: c, rows: r }));
|
|
1533
|
+
}, 50);
|
|
1534
|
+
}, 100);
|
|
1535
|
+
const cmd = pendingCommands.get(id);
|
|
1536
|
+
if (cmd) {
|
|
1537
|
+
pendingCommands.delete(id);
|
|
1538
|
+
setTimeout(() => {
|
|
1539
|
+
if (!disposed && ws?.readyState === WebSocket.OPEN) {
|
|
1540
|
+
ws.send(JSON.stringify({ type: 'input', data: cmd }));
|
|
1541
|
+
}
|
|
1542
|
+
}, 500);
|
|
1543
|
+
}
|
|
1544
|
+
} else if (msg.type === 'error') {
|
|
1545
|
+
// Session no longer exists — auto-create a new one
|
|
1546
|
+
if (!connectedSession && msg.message?.includes('no longer exists') && createRetries < MAX_CREATE_RETRIES) {
|
|
1547
|
+
createRetries++;
|
|
1548
|
+
isNewlyCreated = true;
|
|
1549
|
+
term.write(`\r\n\x1b[93m[${msg.message} — creating new session...]\x1b[0m\r\n`);
|
|
1550
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
1551
|
+
socket.send(JSON.stringify({ type: 'create', cols: term.cols, rows: term.rows }));
|
|
1552
|
+
}
|
|
1553
|
+
} else {
|
|
1554
|
+
term.write(`\r\n\x1b[93m[${msg.message || 'error'}]\x1b[0m\r\n`);
|
|
1555
|
+
}
|
|
1556
|
+
} else if (msg.type === 'exit') {
|
|
1557
|
+
term.write('\r\n\x1b[90m[session ended]\x1b[0m\r\n');
|
|
1558
|
+
}
|
|
1559
|
+
} catch {}
|
|
1560
|
+
};
|
|
1561
|
+
|
|
1562
|
+
ws.onclose = () => {
|
|
1563
|
+
if (disposed) return;
|
|
1564
|
+
reconnectAttempts++;
|
|
1565
|
+
// Exponential backoff: 2s, 4s, 8s, ... max 30s
|
|
1566
|
+
const delay = Math.min(2000 * Math.pow(2, reconnectAttempts - 1), 30000);
|
|
1567
|
+
term.write(`\r\n\x1b[90m[disconnected — reconnecting in ${delay / 1000}s...]\x1b[0m\r\n`);
|
|
1568
|
+
reconnectTimer = window.setTimeout(connect, delay);
|
|
1569
|
+
};
|
|
1570
|
+
|
|
1571
|
+
ws.onerror = () => {
|
|
1572
|
+
// onclose will fire after this, triggering reconnect
|
|
1573
|
+
};
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
// NOTE: connect() is called inside initTerminal() — do NOT call it here.
|
|
1577
|
+
// Calling it both here and in initTerminal() causes duplicate WebSocket
|
|
1578
|
+
// connections to the same tmux session, resulting in doubled output.
|
|
1579
|
+
|
|
1580
|
+
term.attachCustomKeyEventHandler((event: KeyboardEvent) => {
|
|
1581
|
+
if ((event.ctrlKey || event.metaKey) && event.key === 'f' && event.type === 'keydown') {
|
|
1582
|
+
setShowSearch(true);
|
|
1583
|
+
setTimeout(() => searchInputRef.current?.focus(), 0);
|
|
1584
|
+
return false;
|
|
1585
|
+
}
|
|
1586
|
+
if (event.key === 'Escape' && event.type === 'keydown') {
|
|
1587
|
+
setShowSearch(false);
|
|
1588
|
+
searchAddonRef.current?.clearDecorations();
|
|
1589
|
+
}
|
|
1590
|
+
// Ctrl+Shift+C / Cmd+Shift+C → copy selection to clipboard
|
|
1591
|
+
if ((event.ctrlKey || event.metaKey) && event.shiftKey && event.key === 'C' && event.type === 'keydown') {
|
|
1592
|
+
const sel = term.getSelection();
|
|
1593
|
+
if (sel) {
|
|
1594
|
+
if (navigator.clipboard && window.isSecureContext) {
|
|
1595
|
+
navigator.clipboard.writeText(sel);
|
|
1596
|
+
} else {
|
|
1597
|
+
const ta = document.createElement('textarea');
|
|
1598
|
+
ta.value = sel;
|
|
1599
|
+
ta.style.position = 'fixed';
|
|
1600
|
+
ta.style.opacity = '0';
|
|
1601
|
+
document.body.appendChild(ta);
|
|
1602
|
+
ta.select();
|
|
1603
|
+
document.execCommand('copy');
|
|
1604
|
+
document.body.removeChild(ta);
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
return false;
|
|
1608
|
+
}
|
|
1609
|
+
// Ctrl+Shift+V / Cmd+Shift+V → paste from clipboard
|
|
1610
|
+
if ((event.ctrlKey || event.metaKey) && event.shiftKey && event.key === 'V' && event.type === 'keydown') {
|
|
1611
|
+
if (navigator.clipboard && window.isSecureContext) {
|
|
1612
|
+
navigator.clipboard.readText().then(text => { if (text) term.paste(text); });
|
|
1613
|
+
}
|
|
1614
|
+
return false;
|
|
1615
|
+
}
|
|
1616
|
+
return true;
|
|
1617
|
+
});
|
|
1618
|
+
|
|
1619
|
+
term.onData((data) => {
|
|
1620
|
+
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data }));
|
|
1621
|
+
// Arm bell on Enter (user submitted a new prompt)
|
|
1622
|
+
if (data === '\r' || data === '\n') {
|
|
1623
|
+
bellArmed = true;
|
|
1624
|
+
bellNewBytes = 0;
|
|
1625
|
+
bellArmedAt = Date.now();
|
|
1626
|
+
clearTimeout(bellIdleTimer);
|
|
1627
|
+
}
|
|
1628
|
+
});
|
|
1629
|
+
|
|
1630
|
+
// ── Resize handling ──
|
|
1631
|
+
|
|
1632
|
+
let resizeTimer = 0;
|
|
1633
|
+
let lastW = 0;
|
|
1634
|
+
let lastH = 0;
|
|
1635
|
+
|
|
1636
|
+
const doFit = () => {
|
|
1637
|
+
if (disposed) return;
|
|
1638
|
+
const el = containerRef.current;
|
|
1639
|
+
if (!el || el.offsetWidth === 0 || el.offsetHeight === 0) return;
|
|
1640
|
+
// Skip if container is inside a hidden tab (prevents wrong resize)
|
|
1641
|
+
if (el.closest('.hidden')) return;
|
|
1642
|
+
// Skip unreasonably small sizes — xterm crashes if rows/cols go below 2
|
|
1643
|
+
if (el.offsetWidth < 100 || el.offsetHeight < 50) return;
|
|
1644
|
+
const w = el.offsetWidth;
|
|
1645
|
+
const h = el.offsetHeight;
|
|
1646
|
+
if (w === lastW && h === lastH) return;
|
|
1647
|
+
lastW = w;
|
|
1648
|
+
lastH = h;
|
|
1649
|
+
try {
|
|
1650
|
+
fit.fit();
|
|
1651
|
+
// Skip if xterm computed unreasonable dimensions
|
|
1652
|
+
if (term.cols < 2 || term.rows < 2) return;
|
|
1653
|
+
if (ws?.readyState === WebSocket.OPEN) {
|
|
1654
|
+
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
|
1655
|
+
}
|
|
1656
|
+
} catch {}
|
|
1657
|
+
};
|
|
1658
|
+
|
|
1659
|
+
const handleResize = () => {
|
|
1660
|
+
if (globalDragging) return;
|
|
1661
|
+
clearTimeout(resizeTimer);
|
|
1662
|
+
resizeTimer = window.setTimeout(doFit, 150);
|
|
1663
|
+
};
|
|
1664
|
+
|
|
1665
|
+
const onDragEnd = () => {
|
|
1666
|
+
clearTimeout(resizeTimer);
|
|
1667
|
+
resizeTimer = window.setTimeout(doFit, 50);
|
|
1668
|
+
};
|
|
1669
|
+
window.addEventListener('terminal-drag-end', onDragEnd);
|
|
1670
|
+
|
|
1671
|
+
const resizeObserver = new ResizeObserver(() => {
|
|
1672
|
+
if (!initDone) { initTerminal(); return; }
|
|
1673
|
+
handleResize();
|
|
1674
|
+
});
|
|
1675
|
+
resizeObserver.observe(containerRef.current);
|
|
1676
|
+
|
|
1677
|
+
// ── Cleanup ──
|
|
1678
|
+
|
|
1679
|
+
const mountTime = Date.now();
|
|
1680
|
+
|
|
1681
|
+
return () => {
|
|
1682
|
+
disposed = true;
|
|
1683
|
+
visObserver.disconnect();
|
|
1684
|
+
clearTimeout(resizeTimer);
|
|
1685
|
+
clearTimeout(reconnectTimer);
|
|
1686
|
+
window.removeEventListener('terminal-drag-end', onDragEnd);
|
|
1687
|
+
resizeObserver.disconnect();
|
|
1688
|
+
// Strict Mode cleanup: if disposed within 2s of mount and we created a
|
|
1689
|
+
// new session (not attaching), kill the orphaned tmux session
|
|
1690
|
+
const isStrictModeCleanup = Date.now() - mountTime < 2000;
|
|
1691
|
+
const isNewSession = !sessionNameRef.current && connectedSession;
|
|
1692
|
+
if (ws) {
|
|
1693
|
+
if (isStrictModeCleanup && isNewSession && ws.readyState === WebSocket.OPEN) {
|
|
1694
|
+
ws.send(JSON.stringify({ type: 'kill', sessionName: connectedSession }));
|
|
1695
|
+
}
|
|
1696
|
+
ws.onclose = null;
|
|
1697
|
+
ws.close();
|
|
1698
|
+
}
|
|
1699
|
+
term.dispose();
|
|
1700
|
+
};
|
|
1701
|
+
}, [id, onSessionConnected]);
|
|
1702
|
+
|
|
1703
|
+
return (
|
|
1704
|
+
<div className="relative h-full w-full">
|
|
1705
|
+
<div ref={containerRef} className="h-full w-full" />
|
|
1706
|
+
{showSearch && (
|
|
1707
|
+
<div className="absolute top-1 right-2 flex items-center gap-1 px-2 py-1 rounded border border-[var(--term-border)] shadow-lg z-10"
|
|
1708
|
+
style={{ background: 'var(--term-bg, #1a1b26)' }}
|
|
1709
|
+
onClick={e => e.stopPropagation()}>
|
|
1710
|
+
<input
|
|
1711
|
+
ref={searchInputRef}
|
|
1712
|
+
type="text"
|
|
1713
|
+
value={searchQuery}
|
|
1714
|
+
onChange={e => {
|
|
1715
|
+
setSearchQuery(e.target.value);
|
|
1716
|
+
if (e.target.value) searchAddonRef.current?.findNext(e.target.value, { regex: false, caseSensitive: false, decorations: { matchOverviewRuler: '#888', activeMatchColorOverviewRuler: '#ff0' } });
|
|
1717
|
+
else searchAddonRef.current?.clearDecorations();
|
|
1718
|
+
}}
|
|
1719
|
+
onKeyDown={e => {
|
|
1720
|
+
if (e.key === 'Enter') {
|
|
1721
|
+
if (e.shiftKey) searchAddonRef.current?.findPrevious(searchQuery, { regex: false, caseSensitive: false, decorations: { matchOverviewRuler: '#888', activeMatchColorOverviewRuler: '#ff0' } });
|
|
1722
|
+
else searchAddonRef.current?.findNext(searchQuery, { regex: false, caseSensitive: false, decorations: { matchOverviewRuler: '#888', activeMatchColorOverviewRuler: '#ff0' } });
|
|
1723
|
+
}
|
|
1724
|
+
if (e.key === 'Escape') {
|
|
1725
|
+
setShowSearch(false);
|
|
1726
|
+
searchAddonRef.current?.clearDecorations();
|
|
1727
|
+
}
|
|
1728
|
+
}}
|
|
1729
|
+
placeholder="Search..."
|
|
1730
|
+
className="bg-transparent text-[11px] text-[var(--term-fg,#c0caf5)] outline-none w-32 placeholder-gray-600"
|
|
1731
|
+
autoFocus
|
|
1732
|
+
/>
|
|
1733
|
+
<button onClick={() => searchAddonRef.current?.findPrevious(searchQuery, { regex: false, caseSensitive: false, decorations: { matchOverviewRuler: '#888', activeMatchColorOverviewRuler: '#ff0' } })}
|
|
1734
|
+
className="text-[10px] text-gray-500 hover:text-gray-300 px-1" title="Previous (Shift+Enter)">▲</button>
|
|
1735
|
+
<button onClick={() => searchAddonRef.current?.findNext(searchQuery, { regex: false, caseSensitive: false, decorations: { matchOverviewRuler: '#888', activeMatchColorOverviewRuler: '#ff0' } })}
|
|
1736
|
+
className="text-[10px] text-gray-500 hover:text-gray-300 px-1" title="Next (Enter)">▼</button>
|
|
1737
|
+
<button onClick={() => { setShowSearch(false); searchAddonRef.current?.clearDecorations(); }}
|
|
1738
|
+
className="text-[10px] text-gray-500 hover:text-gray-300 px-1" title="Close (Esc)">✕</button>
|
|
1739
|
+
</div>
|
|
1740
|
+
)}
|
|
1741
|
+
</div>
|
|
1742
|
+
);
|
|
1743
|
+
});
|