diff --git a/.pi-map.index.md b/.pi-map.index.md index ecec63f..74d5b96 100644 --- a/.pi-map.index.md +++ b/.pi-map.index.md @@ -16,7 +16,7 @@ dir: . Trust boundary: index routes, map orients, source decides. ## role -Infrastructure and configuration package for "Headquarter," a self-hosted project management platform with Git repository features, containing deployment configs, development tooling, and UI refactoring documentation. +Package . ## parent - ## children @@ -32,15 +32,6 @@ Infrastructure and configuration package for "Headquarter," a self-hosted projec - .pi index: .pi/.pi-map.index.md map: .pi/.pi-map.md -- .pi-lens - index: .pi-lens/.pi-map.index.md - map: .pi-lens/.pi-map.md -- .pytest_cache - index: .pytest_cache/.pi-map.index.md - map: .pytest_cache/.pi-map.md -- .ruff_cache - index: .ruff_cache/.pi-map.index.md - map: .ruff_cache/.pi-map.md - .sisyphus index: .sisyphus/.pi-map.index.md map: .sisyphus/.pi-map.md @@ -62,12 +53,12 @@ Infrastructure and configuration package for "Headquarter," a self-hosted projec - scripts index: scripts/.pi-map.index.md map: scripts/.pi-map.md -- tests - index: tests/.pi-map.index.md - map: tests/.pi-map.md - tool-images index: tool-images/.pi-map.index.md map: tool-images/.pi-map.md +- uploads + index: uploads/.pi-map.index.md + map: uploads/.pi-map.md ## files - .env.example - .gitignore diff --git a/.pi-map.md b/.pi-map.md index b2ce571..9a04798 100644 --- a/.pi-map.md +++ b/.pi-map.md @@ -18,25 +18,25 @@ index: ./.pi-map.index.md Trust boundary: index routes, map orients, source decides. ## role -Infrastructure and configuration package for "Headquarter," a self-hosted project management platform with Git repository features, containing deployment configs, development tooling, and UI refactoring documentation. +Package . ## files -- .env.example | Provides a template of environment variables for configuring database, Redis, sessions, domains, Authentik OAuth, frontend, and Docker settings for the application. | dep: Postgres, Redis, Authentik, Vite, Docker Compose, Traefik -- .gitignore | Specifies intentionally untracked files and directories to exclude from version control across multiple technologies and environments. -- AGENTS.md | Defines rules, workflow conventions, and verification standards for AI agents operating on the project, using OpenSpec as the source of truth and Superpowers as the default workflow. | dep: OpenSpec, Superpowers, Git -- CHANGELOG.md | Documents notable changes and version history for a project management application with Git repository features. -- Makefile | Provides common development automation commands for a Dockerized full-stack application, including service lifecycle, testing, linting, migrations, and cleanup. | dep: docker compose, alembic, pytest, playwright, ruff, mypy, npm -- README.md | Provides documentation and onboarding instructions for "Headquarter," a self-hosted platform for managing projects, git repositories, and development tools. -- docker-compose.traefik.yml | Defines a Docker Compose deployment configuration for a multi-service application (PostgreSQL, Redis, web frontend, API) behind an existing Traefik reverse proxy with TLS termination. | dep: postgres:15-alpine, redis:7-alpine, traefik, docker.sock, authentik -- docker-compose.yml | Defines a multi-container Docker Compose configuration for a full-stack application with PostgreSQL, Redis, an API service, and a web frontend. | dep: postgres:15-alpine, redis:7-alpine, apps/api, apps/web -- progress.md | Tracks progress of a backend-frontend refactoring project across completed phases, pending tasks, and remaining work. -- swap-pane | Empty package initialization file that marks a directory as a Python package. -- ui-audit-spacing-typography.md | Audit report documenting spacing, typography, and visual rhythm issues in the web UI, including missing CSS classes, inconsistent units, incomplete design tokens, and recommendations for fixes. -- ui-rework-foundations-apply.md | Documenting the implementation of Pass 1 (Foundations) of a web UI spacing/typography/visual-rhythm rework, summarizing changed files, commits, validation results, and next steps. | dep: tokens.css, global.css, utilities.css, ConfigProfileListSidebar.tsx, ConfigProfileEditorPanel.tsx, instance-list.tsx, openspec artifacts -- ui-rework-pass2-apply.md | Implementation report documenting a UI component cleanup pass that refactored inline styles into CSS utility classes and design tokens. +- .env.example | Provides example environment variable configuration for a full-stack application with database, caching, authentication, and deployment settings +- .gitignore | Specifies files and directories for Git to ignore across a project using Beads/Dolt, Python, Node, and various IDE/OS tooling. | dep: git, dolt, beads, python, node, npm, yarn, pnpm, pytest, mypy, ruff, coverage +- AGENTS.md | Defines operational guidelines and workflows for AI agents collaborating on a software project governed by OpenSpec | dep: OpenSpec, superpowers (brainstorming, writing-plans, test-driven-development, systematic-debugging, verification-before-completion, using-git-worktrees, dispatching-parallel-agents), git +- CHANGELOG.md | Documents version history and notable changes for a project management and Git repository application +- Makefile | Provides standardized development commands for managing a Docker-based full-stack application with API, web frontend, database, and testing infrastructure | dep: docker compose, alembic, pytest, ruff, mypy, playwright, npm, postgres, redis +- README.md | Documentation for a self-hosted development platform that manages projects, git repositories, and development tools with OAuth2 authentication | dep: FastAPI, SQLAlchemy, Pydantic, Alembic, python-jose, React, TypeScript, Vite, React Router, Docker, PostgreSQL, Traefik, Authentik +- docker-compose.traefik.yml | Deploys a multi-service application (PostgreSQL, Redis, web frontend, API) behind an existing Traefik reverse proxy with TLS termination | dep: docker, docker-compose, traefik, postgres, redis, node/vite, python/fastapi +- docker-compose.yml | Defines a multi-service Docker Compose stack for a web application with PostgreSQL, Redis, API backend, and web frontend services | dep: Docker, Docker Compose, PostgreSQL, Redis, Vite, asyncpg, Python/FastAPI (implied), Node.js (implied) +- progress.md | Documents the progress and remaining tasks for a backend-frontend refactoring project involving modularization, code reorganization, and verification. +- swap-pane | Provides a command to swap the position of two tmux panes within a window or between windows | dep: tmux, client, window, layout, cmd-find, cmd-parse, options +- ui-audit-spacing-typography.md | A detailed audit report identifying critical CSS styling gaps, mobile layout failures, and design system inconsistencies in a web application's UI components and stylesheets. | dep: React/TSX components, CSS stylesheets (global.css, utilities.css, tokens.css, page-specific CSS), JSX/TSX files in apps/web/src/components and apps/web/src/pages +- ui-rework-foundations-apply.md | Documents the implementation of Pass 1 (Foundations) of a web UI spacing/typography/visual-rhythm rework, including design token expansion, primitive CSS class additions, component refactors, and OpenSpec documentation. | dep: CSS design tokens, React/TSX components, OpenSpec documentation system, Git/SSH, npm build toolchain +- ui-rework-pass2-apply.md | Documents the implementation and verification of Pass 2 of a web UI spacing/typography rework, refactoring inline styles into utility classes and design tokens across six components. | dep: CSS custom properties, BEM methodology, utility-first CSS, React/TSX components, OpenSpec documentation system ## arch -Dockerized microservices architecture with Docker Compose orchestrating PostgreSQL, Redis, API, and web frontend services; uses Traefik reverse proxy with TLS termination, environment-based configuration, and Makefile-driven development workflow with documented UI design token system. +Contains 13 files. ## tags -docker, ui, application, web, css, rework, redis, frontend +docker, web, git, application, ui, rework, python, redis ## symbols - ## workflows diff --git a/apps/.pi-map.index.md b/apps/.pi-map.index.md index fe888c5..b7487fe 100644 --- a/apps/.pi-map.index.md +++ b/apps/.pi-map.index.md @@ -2,7 +2,7 @@ dir: apps ## role -Contains the main application entry points and executable modules for the project. +Package apps ## parent index: ./.pi-map.index.md map: ./.pi-map.md diff --git a/apps/.pi-map.md b/apps/.pi-map.md index 009b28f..8cdce39 100644 --- a/apps/.pi-map.md +++ b/apps/.pi-map.md @@ -4,10 +4,10 @@ dir: apps index: apps/.pi-map.index.md ## role -Contains the main application entry points and executable modules for the project. +Package apps ## files ## arch -Modular application structure with separate deployable units, likely following microservices or multi-app monorepo pattern. +Contains 0 files. ## tags - ## symbols diff --git a/apps/web/.pi-map.index.md b/apps/web/.pi-map.index.md index 8b6f59a..fbe333b 100644 --- a/apps/web/.pi-map.index.md +++ b/apps/web/.pi-map.index.md @@ -2,7 +2,7 @@ dir: apps/web ## role -Frontend web application serving as the user-facing UI for the "headquarter" project, built as a React/Vite single-page application and containerized for production deployment. +Frontend web application for a terminal/code-editor interface named "Headquarter," built with React/TypeScript using Vite and served as a static single-page app. ## parent index: apps/.pi-map.index.md map: apps/.pi-map.md diff --git a/apps/web/.pi-map.md b/apps/web/.pi-map.md index 38e7af2..cfa20ec 100644 --- a/apps/web/.pi-map.md +++ b/apps/web/.pi-map.md @@ -4,21 +4,21 @@ dir: apps/web index: apps/web/.pi-map.index.md ## role -Frontend web application serving as the user-facing UI for the "headquarter" project, built as a React/Vite single-page application and containerized for production deployment. +Frontend web application for a terminal/code-editor interface named "Headquarter," built with React/TypeScript using Vite and served as a static single-page app. ## files -- .env.example | Provides example environment variable configuration for a Vite-based frontend application | dep: Vite +- .env.example | Template/example file defining environment variables for a Vite-based application's API and OAuth configuration | dep: Vite - .eslintrc.cjs | Configures ESLint for a TypeScript browser project with modern ES2022 module support | dep: eslint, @typescript-eslint/parser, @typescript-eslint/eslint-plugin -- Dockerfile | Multi-stage Docker build for a Node.js/Vite frontend application compiled to static assets and served by nginx with non-root user security | dep: node:20-alpine, nginx:alpine, npm, wget -- index.html | HTML entry point for a React/Vite single-page application that loads a TypeScript root component | dep: Google Fonts (Inter, IBM Plex Mono), /src/main.tsx -- nginx.conf | Configures an nginx web server to serve a single-page application with gzip compression, client-side routing support, cache control for static assets, and a health check endpoint. | dep: nginx -- package-lock.json | Auto-generated dependency lock file that records exact versions and resolved URLs of all npm packages for reproducible installs of a React-based web application named "headquarter-web" | dep: npm, react, react-dom, react-router-dom, axios, tailwindcss, prismjs, xterm, vite, vitest, typescript, eslint, @babel/core, @vitejs/plugin-react, @testing-library/react, jsdom -- package.json | Defines the configuration, dependencies, and scripts for a React-based web application called "headquarter-web" built with Vite and TypeScript. | dep: react, react-dom, react-router-dom, axios, tailwindcss, prismjs, react-simple-code-editor, xterm, xterm-addon-fit, xterm-addon-web-links, xterm-addon-webgl, @phosphor-icons/react, vite, typescript, vitest, eslint, @testing-library/react, jsdom -- tsconfig.json | Configures TypeScript compiler settings for a modern React/Vite web application with strict type checking and ES module output. | dep: TypeScript, Vite, React -- vite.config.ts | Configures Vite build tool with React plugin, dev server port, and Vitest test settings | dep: vite, @vitejs/plugin-react +- Dockerfile | Multi-stage Docker build for a Node.js/Vite frontend application compiled to static assets and served by nginx as a non-root user | dep: node:20-alpine, nginx:alpine, npm, wget +- index.html | Entry point HTML file for a React application named "Headquarter" that loads a TypeScript module and Google Fonts | dep: Google Fonts (Inter, IBM Plex Mono), main.tsx module +- nginx.conf | Configures Nginx as a web server for a single-page application with gzip compression, client-side routing support, cache control for static assets, and a health check endpoint. | dep: nginx +- package-lock.json | Records exact dependency versions for a React-based web application to ensure reproducible installs | dep: npm, React, React Router, Tailwind CSS, Vite, Vitest, TypeScript, ESLint, xterm, PrismJS, axios, phosphor-icons +- package.json | Defines a React-based web application project named "headquarter-web" with build tooling, testing, and UI dependencies for a terminal/code editor interface. | dep: react, react-dom, react-router-dom, vite, typescript, tailwindcss, axios, prismjs, xterm, @phosphor-icons/react, react-simple-code-editor, vitest, eslint +- tsconfig.json | Configures TypeScript compiler options for a modern React/Vite project with ES2020 target and strict type checking | dep: TypeScript, React, Vite +- vite.config.ts | Configures Vite build tool with React support, dev server port, and Vitest testing settings | dep: vite, @vitejs/plugin-react ## arch -Modern React SPA architecture using TypeScript with Vite build tooling, containerized via multi-stage Docker build producing static assets served by nginx, with strict type checking, ESLint code quality, and Vitest testing integration. +Containerized single-page React/TypeScript app using Vite for build/dev, Vitest for testing, ESLint for linting, and nginx for static asset delivery with client-side routing support. ## tags -react, vite, application, eslint, typescript, web, nginx, xterm +react, vite, eslint, application, typescript, nginx, configures, web ## symbols - ## workflows diff --git a/apps/web/src/.pi-map.index.md b/apps/web/src/.pi-map.index.md index 620fed8..174b4c3 100644 --- a/apps/web/src/.pi-map.index.md +++ b/apps/web/src/.pi-map.index.md @@ -2,7 +2,7 @@ dir: apps/web/src ## role -Frontend web application bootstrap and core infrastructure package providing React SPA initialization, routing, and shared type definitions. +Frontend web client that bootstraps and renders a React single-page application with routing, authentication, and session-driven UI. ## parent index: apps/web/.pi-map.index.md map: apps/web/.pi-map.md diff --git a/apps/web/src/.pi-map.md b/apps/web/src/.pi-map.md index f2592ae..4e50308 100644 --- a/apps/web/src/.pi-map.md +++ b/apps/web/src/.pi-map.md @@ -4,15 +4,15 @@ dir: apps/web/src index: apps/web/src/.pi-map.index.md ## role -Frontend web application bootstrap and core infrastructure package providing React SPA initialization, routing, and shared type definitions. +Frontend web client that bootstraps and renders a React single-page application with routing, authentication, and session-driven UI. ## files -- main.tsx | Entry point that bootstraps a React SPA with routing, authentication, and session context providers. | dep: react, react-dom/client, react-router-dom, ./router, ./state/auth, ./state/sessions, ./styles/tokens.css, ./styles/global.css, ./styles/utilities.css, ./styles/syntax-highlight.css, ./styles/pages/git-history.css, ./styles/pages/projects.css, ./styles/pages/sessions.css, ./styles/pages/ssh-keys.css, ./styles/pages/workspace-detail.css, ./styles/pages/workspaces.css -- router.tsx | Defines the React Router configuration for a web application with nested routes, protected routes, and redirects. | exp: AppRouter | dep: react-router-dom, ./components/app-shell, ./components/protected-route, ./pages/DashboardPage, ./pages/PlaceholderPage, ./pages/ProfilePage, ./pages/ProjectsPage, ./pages/GitRepositoriesPage, ./pages/GitHistoryPage, ./pages/ProjectSettingsPage, ./pages/SettingsPage, ./pages/TerminalPage, ./pages/ToolWorkshopPage, ./pages/SshKeysPage, ./pages/ConfigProfilesPage, ./pages/SessionsPage, ./pages/WorkspacesPage, ./pages/WorkspaceDetailPage -- types.ts | Defines core TypeScript type definitions for user sessions, projects, repositories, and workspaces in an application. | exp: SessionUser, SessionPayload, Project, WorkspaceSummary, RepositorySummary, ProjectWithRepos +- main.tsx | Entry point that bootstraps a React SPA with routing, authentication, and session state management | dep: react, react-dom/client, react-router-dom, ./router, ./state/auth, ./state/sessions, ./styles/tokens.css, ./styles/global.css, ./styles/utilities.css, ./styles/syntax-highlight.css, ./styles/pages/git-history.css, ./styles/pages/projects.css, ./styles/pages/sessions.css, ./styles/pages/ssh-keys.css, ./styles/pages/workspace-detail.css, ./styles/pages/workspaces.css, react-dom +- router.tsx | Defines the React Router configuration for a multi-page application with nested routes, protected authentication, and redirects. | exp: AppRouter | dep: react-router-dom, ./components/app-shell, ./components/protected-route, ./pages/DashboardPage, ./pages/PlaceholderPage, ./pages/ProfilePage, ./pages/ProjectsPage, ./pages/GitRepositoriesPage, ./pages/GitHistoryPage, ./pages/ProjectSettingsPage, ./pages/SettingsPage, ./pages/TerminalPage, ./pages/ToolWorkshopPage, ./pages/SshKeysPage, ./pages/ConfigProfilesPage, ./pages/SessionsPage, ./pages/WorkspacesPage, ./pages/WorkspaceDetailPage +- types.ts | Defines TypeScript type declarations for user sessions, projects, repositories, and workspaces in an application. | exp: SessionUser, SessionPayload, Project, WorkspaceSummary, RepositorySummary, ProjectWithRepos ## arch -Single-page application using React with context-based dependency injection for auth/session state, declarative nested routing with route guards, and centralized TypeScript domain modeling. +React SPA built with a declarative React Router configuration, protected/nested routes, and centralized TypeScript types for users, sessions, projects, repositories, and workspaces. ## tags -pages, styles, css, router, react, session, project, summary +pages, styles, css, router, react, session, dom, project ## symbols - AppRouter - SessionUser diff --git a/apps/web/src/components/.pi-map.index.md b/apps/web/src/components/.pi-map.index.md index 54bdf16..8e33c92 100644 --- a/apps/web/src/components/.pi-map.index.md +++ b/apps/web/src/components/.pi-map.index.md @@ -2,7 +2,7 @@ dir: apps/web/src/components ## role -Provides reusable, type-safe React UI components for the web application including layout shells, data display states, iconography, code editing, syntax highlighting, authentication guards, and toast notification rules. +Package components ## parent index: apps/web/src/.pi-map.index.md map: apps/web/src/.pi-map.md diff --git a/apps/web/src/components/.pi-map.md b/apps/web/src/components/.pi-map.md index 1ee5ee8..3b312e4 100644 --- a/apps/web/src/components/.pi-map.md +++ b/apps/web/src/components/.pi-map.md @@ -4,22 +4,22 @@ dir: apps/web/src/components index: apps/web/src/components/.pi-map.index.md ## role -Provides reusable, type-safe React UI components for the web application including layout shells, data display states, iconography, code editing, syntax highlighting, authentication guards, and toast notification rules. +Package components ## files -- app-shell.tsx | Provides the main application layout shell with navigation, session management, and responsive mobile/desktop views for a React application. | exp: AppShell | dep: react-router-dom, ../api/sessions, ../hooks/use-theme, ../utils/open-session, ../state/auth, ../state/sessions, ../hooks/use-mobile-viewport, ../state/events, ../state/toast, ../state/notifications, ./features/notification/event-toast-bridge, ./features/notification/notification-center, ./icon, ./features/mobile/mobile-nav, ./features/tool/start-tool-fab, ../utils/icons -- code-editor.tsx | A React component that renders a syntax-highlighted code editor with line numbers using a lightweight editor library. | exp: CodeEditor | dep: react, react-simple-code-editor, ../utils/language -- data-states.tsx | Defines reusable React components for displaying loading, error, and empty data states in a UI. | exp: LoadingState, ErrorState, EmptyState | dep: ./icon, icon -- icon.tsx | Provides a centralized, type-safe React component that maps semantic icon names to Phosphor icons with configurable size, weight, color, and accessibility properties. | exp: IconName, IconProps, Icon | dep: react, @phosphor-icons/react -- loading-overlay.tsx | Renders an accessible loading spinner overlay with optional text label that conditionally displays based on visibility prop | exp: func:LoadingOverlay({ visible, label }: LoadingOverlayProps) | dep: ./icon, icon -- protected-route.test.tsx | Tests a ProtectedRoute component that guards content based on authentication state | dep: @testing-library/react, react-router-dom, vitest, ./protected-route, ../state/auth -- protected-route.tsx | A React component that conditionally renders children or redirects to login based on authentication state | exp: ProtectedRoute | dep: react-router-dom, ../state/auth -- syntax-highlighter.tsx | A React component that renders syntax-highlighted code blocks with optional line numbers, language badge, and copy-to-clipboard functionality. | exp: SyntaxHighlighter | dep: react, ./icon, ../utils/language -- toast-rules.test.ts | Unit tests for toast notification rules that map instance events to UI categories and severity levels. | dep: vitest, ./toast-rules, ../types/events -- toast-rules.ts | Maps instance events to toast notifications with deduplication logic to prevent spam. | exp: func:mapEventToCategory(event: InstanceEventPayload) → string, call:event.event.startsWith, func:mapEventToSeverity(event: InstanceEventPayload) → "info" | "warning" | "error" | "success", func:handleEventToast(event: InstanceEventPayload) → void, call:shouldShowToast, call:toast.info, call:toast.success, call:toast.warning, call:toast.error, func:clearToastDedup() → void, call:lastToastTime.clear | dep: ../state/toast, ../types/events, toast, InstanceEventPayload +- app-shell.tsx | Main application layout shell with responsive navigation, session management, and global state providers for notifications and toasts. | exp: AppShell | dep: react-router-dom, ../api/sessions, ../hooks/use-theme, ../utils/open-session, ../state/auth, ../state/sessions, ../hooks/use-mobile-viewport, ../state/events, ../state/toast, ../state/notifications, ./features/notification/event-toast-bridge, ./features/notification/notification-center, ./icon, ./features/mobile/mobile-nav, ./features/tool/start-tool-fab, ../utils/icons +- code-editor.tsx | A React component that renders a syntax-highlighting code editor with line numbers using react-simple-code-editor. | exp: CodeEditor | dep: react, react-simple-code-editor, ../utils/language +- data-states.tsx | Provides reusable UI components for rendering loading, error, and empty data states. | exp: LoadingState, ErrorState, EmptyState | dep: ./icon, Icon +- icon.tsx | Provides a centralized, type-safe React icon component that maps semantic names to Phosphor icons with configurable sizes, weights, and colors. | exp: IconName, IconProps, Icon | dep: react, @phosphor-icons/react +- loading-overlay.tsx | Renders an accessible loading overlay with optional text label when visible | exp: func:LoadingOverlay({ visible, label }: LoadingOverlayProps) | dep: ./icon, icon +- protected-route.test.tsx | Tests a ProtectedRoute component that guards content based on authentication state, showing loading or redirecting to login | dep: @testing-library/react, react-router-dom, vitest, ./protected-route +- protected-route.tsx | Conditionally renders children or redirects to login based on authentication state | exp: ProtectedRoute | dep: react-router-dom, ../state/auth +- syntax-highlighter.tsx | A React component that renders syntax-highlighted code with optional line numbers and a copy-to-clipboard button | exp: SyntaxHighlighter | dep: react, ./icon, ../utils/language, React, icon, language utils +- toast-rules.test.ts | Unit tests for mapping instance events to toast notification categories and severities | dep: vitest, ./toast-rules, ../types/events +- toast-rules.ts | Maps instance events to toast notifications with deduplication logic | exp: func:mapEventToCategory(event: InstanceEventPayload) → string, call:event.event.startsWith, func:mapEventToSeverity(event: InstanceEventPayload) → "info" | "warning" | "error" | "success", func:handleEventToast(event: InstanceEventPayload) → void, call:shouldShowToast, call:toast.info, call:toast.success, call:toast.warning, call:toast.error, func:clearToastDedup() → void, call:lastToastTime.clear | dep: ../state/toast, ../types/events, toast, InstanceEventPayload ## arch -Follows a component-based architecture with presentational/ui components, separation of concerns (display logic vs. business rules in `.ts` files), test co-location, and centralized abstractions for common patterns like icons and data states. +Contains 10 files. ## tags -toast, state, react, icon, loading, event, code, editor +toast, state, icon, react, loading, code, event, editor ## symbols - LoadingOverlay - mapEventToCategory diff --git a/apps/web/src/components/features/.pi-map.index.md b/apps/web/src/components/features/.pi-map.index.md index 34628ed..4932fd6 100644 --- a/apps/web/src/components/features/.pi-map.index.md +++ b/apps/web/src/components/features/.pi-map.index.md @@ -2,7 +2,7 @@ dir: apps/web/src/components/features ## role -Reusable UI components organized by domain-specific feature areas for the web application frontend. +Feature-specific UI components organized by business domain/capability for the web ## parent index: apps/web/src/components/.pi-map.index.md map: apps/web/src/components/.pi-map.md diff --git a/apps/web/src/components/features/.pi-map.md b/apps/web/src/components/features/.pi-map.md index d0d6c76..1337d26 100644 --- a/apps/web/src/components/features/.pi-map.md +++ b/apps/web/src/components/features/.pi-map.md @@ -4,10 +4,10 @@ dir: apps/web/src/components/features index: apps/web/src/components/features/.pi-map.index.md ## role -Reusable UI components organized by domain-specific feature areas for the web application frontend. +Feature-specific UI components organized by business domain/capability for the web ## files ## arch -Feature-based component colocation pattern with domain-driven folder structure, separating shared/common components from page-specific feature implementations. +Contains 0 files. ## tags - ## symbols diff --git a/apps/web/src/components/features/terminal/.pi-map.index.md b/apps/web/src/components/features/terminal/.pi-map.index.md index 866007f..596f89f 100644 --- a/apps/web/src/components/features/terminal/.pi-map.index.md +++ b/apps/web/src/components/features/terminal/.pi-map.index.md @@ -2,7 +2,7 @@ dir: apps/web/src/components/features/terminal ## role -Provides responsive, cross-platform terminal UI components with session management, virtual special keys, and WebSocket-backed xterm.js integration for web-based terminal access. +Provides the UI components for a responsive, multi-session terminal interface in the web app, including desktop and mobile layouts, session tabs, and special-key input controls. ## parent index: apps/web/src/components/features/.pi-map.index.md map: apps/web/src/components/features/.pi-map.md diff --git a/apps/web/src/components/features/terminal/.pi-map.md b/apps/web/src/components/features/terminal/.pi-map.md index fb39962..801f922 100644 --- a/apps/web/src/components/features/terminal/.pi-map.md +++ b/apps/web/src/components/features/terminal/.pi-map.md @@ -4,19 +4,19 @@ dir: apps/web/src/components/features/terminal index: apps/web/src/components/features/terminal/.pi-map.index.md ## role -Provides responsive, cross-platform terminal UI components with session management, virtual special keys, and WebSocket-backed xterm.js integration for web-based terminal access. +Provides the UI components for a responsive, multi-session terminal interface in the web app, including desktop and mobile layouts, session tabs, and special-key input controls. ## files -- DesktopTerminalView.tsx | Renders a desktop terminal view with session tabs, fullscreen controls, and multiple terminal instances | exp: DesktopTerminalView | dep: react, ./terminal, ./terminal-session-tabs, ../../../api/terminal -- MobileTerminalView.tsx | Renders a mobile-optimized terminal interface with session tabs, toolbar controls, special keys input, and keyboard-aware layout adjustments. | exp: MobileTerminalView | dep: react, ./terminal, ./terminal-session-tabs, ../../icon, ./special-keys-strip, ./special-keys-panel, ../../../hooks/use-special-keys, ../../../api/terminal, terminal, terminal-session-tabs, icon, special-keys-strip, special-keys-panel, use-special-keys, api/terminal -- special-keys-panel.tsx | Renders an overlay panel of special keyboard keys (Home, End, F1-F12, Ctrl+C, etc.) that sends terminal sequences when clicked and supports modifier key combinations. | exp: SpecialKeysPanel | dep: react, ../../../hooks/use-special-keys, React, use-special-keys hook -- special-keys-strip.tsx | Renders a strip of virtual special keys (Esc, Tab, Ctrl, Alt, arrows, etc.) for sending terminal key sequences with optional modifier support. | exp: SpecialKeysStrip | dep: react, ../../../hooks/use-special-keys, use-special-keys hook -- terminal-session-tabs.test.tsx | Tests a React terminal session tabs component for rendering, selection, closing with confirmation, renaming, session limits, and connection status display. | dep: @testing-library/react, vitest, ./terminal-session-tabs -- terminal-session-tabs.tsx | Renders an interactive tab bar for managing multiple terminal sessions with selection, renaming, closing, and creation capabilities | exp: TerminalSessionInfo, TerminalSessionTabsProps, TerminalSessionTabs | dep: react, React, useState, useRef, useCallback -- terminal.tsx | A React terminal component that provides an interactive xterm.js-based terminal with WebSocket connectivity, mobile touch support, and session management. | exp: TerminalProps, TerminalRef, TerminalComponent | dep: react, xterm, xterm-addon-fit, xterm-addon-web-links, xterm/css/xterm.css, ../../../hooks/use-special-keys, React +- DesktopTerminalView.tsx | Renders the desktop layout for a multi-session terminal UI, managing tabs, fullscreen mode, font controls, and reset confirmation. | exp: DesktopTerminalView | dep: react, ./terminal, ./terminal-session-tabs, ../../../api/terminal, React, TerminalComponent, TerminalSessionTabs, TerminalSession +- MobileTerminalView.tsx | Renders a mobile-specific terminal interface with session tabs, toolbar controls, special key inputs, and multiple terminal instances. | exp: MobileTerminalView | dep: react, ./terminal, ./terminal-session-tabs, ../../icon, ./special-keys-strip, ./special-keys-panel, ../../../hooks/use-special-keys, ../../../api/terminal, React, TerminalComponent, TerminalSessionTabs, Icon, SpecialKeysStrip, SpecialKeysPanel +- special-keys-panel.tsx | Renders a modal overlay panel of special keyboard keys (Home, End, F1-F12, Ctrl+C, etc.) that sends escape sequences to a terminal when clicked. | exp: SpecialKeysPanel | dep: react, ../../../hooks/use-special-keys +- special-keys-strip.tsx | Renders a touch-accessible strip of special keyboard keys (Esc, Tab, arrows, modifiers) that sends terminal escape sequences with optional modifier support. | exp: SpecialKeysStrip | dep: react, ../../../hooks/use-special-keys, React, use-special-keys hook +- terminal-session-tabs.test.tsx | Unit tests for a TerminalSessionTabs React component covering tab rendering, selection, close confirmation, inline renaming, session limit enforcement, and connection status display. | dep: @testing-library/react, vitest, ./terminal-session-tabs +- terminal-session-tabs.tsx | Renders a tabbed interface for managing multiple terminal sessions with support for switching, renaming, closing with confirmation, and creating new sessions. | exp: TerminalSessionInfo, TerminalSessionTabsProps, TerminalSessionTabs | dep: react, React +- terminal.tsx | | exp: TerminalProps, TerminalRef, TerminalComponent | dep: react, xterm, xterm-addon-fit, xterm-addon-web-links, xterm/css/xterm.css, ../../../hooks/use-special-keys ## arch -Component-based architecture with platform-specific view layers (desktop/mobile), modular special keys subsystems (strip/overlay panel), tab-based session management with React state, and xterm.js integration via WebSocket abstraction with touch/mobile adaptations. +Modular React component architecture organized by feature, with separate presentational components for desktop/mobile viewports, shared tab/session management, and helper components for terminal input handling. ## tags -terminal, special, keys, session, tabs, react, view, strip +terminal, special, session, keys, react, tabs, view, strip ## symbols - DesktopTerminalView - MobileTerminalView diff --git a/apps/web/src/components/features/terminal/terminal.tsx b/apps/web/src/components/features/terminal/terminal.tsx index 0e9cec2..bf77dac 100644 --- a/apps/web/src/components/features/terminal/terminal.tsx +++ b/apps/web/src/components/features/terminal/terminal.tsx @@ -1,9 +1,9 @@ import React, { - useEffect, - useImperativeHandle, - useRef, - useState, - useCallback, + useEffect, + useImperativeHandle, + useRef, + useState, + useCallback, } from "react"; import { Terminal } from "xterm"; import { FitAddon } from "xterm-addon-fit"; @@ -11,36 +11,36 @@ import { WebLinksAddon } from "xterm-addon-web-links"; import "xterm/css/xterm.css"; import { - applyModifierToChar, - type ModifierKey, + applyModifierToChar, + type ModifierKey, } from "../../../hooks/use-special-keys"; export interface TerminalProps { - instanceId: string; - sessionId?: string; - onClose?: () => void; - isMobile?: boolean; - showControls?: boolean; - activeModifier?: ModifierKey | null; - onModifierChange?: (modifier: ModifierKey | null) => void; - onTerminalReady?: ( - sessionId: string | undefined, - sendData: (data: string) => void, - connectionStatus: - | "connecting" - | "connected" - | "disconnected" - | "error" - | "resetting", - focusInput: () => void, - changeFontSize: (delta: number) => void, - ) => void; + instanceId: string; + sessionId?: string; + onClose?: () => void; + isMobile?: boolean; + showControls?: boolean; + activeModifier?: ModifierKey | null; + onModifierChange?: (modifier: ModifierKey | null) => void; + onTerminalReady?: ( + sessionId: string | undefined, + sendData: (data: string) => void, + connectionStatus: + | "connecting" + | "connected" + | "disconnected" + | "error" + | "resetting", + focusInput: () => void, + changeFontSize: (delta: number) => void, + ) => void; } export interface TerminalRef { - fit: () => void; - focus: () => void; - reset: () => void; + fit: () => void; + focus: () => void; + reset: () => void; } const FONT_SIZE_KEY = "terminal-font-size"; @@ -50,846 +50,858 @@ const RECONNECT_ATTEMPTS = 3; const RECONNECT_DELAY_BASE = 1000; export const TerminalComponent = React.forwardRef( - ( - { - instanceId, - sessionId, - onClose, - isMobile = false, - showControls = true, - activeModifier, - onModifierChange, - onTerminalReady, - }, - ref, - ) => { - const terminalRef = useRef(null); - const hiddenInputRef = useRef(null); - const wsRef = useRef(null); - const termRef = useRef(null); - const fitAddonRef = useRef(null); - const reconnectAttemptsRef = useRef(0); - const onTerminalReadyRef = useRef(onTerminalReady); - onTerminalReadyRef.current = onTerminalReady; - const handleFontSizeChangeRef = useRef<(delta: number) => void>(() => {}); - const [status, setStatus] = useState< - "connecting" | "connected" | "disconnected" | "error" | "resetting" - >("connecting"); - const [error, setError] = useState(null); - const [showResetConfirm, setShowResetConfirm] = useState(false); - const activeModifierRef = useRef(activeModifier); - activeModifierRef.current = activeModifier; - const [fontSize, setFontSize] = useState(() => { - if (typeof window === "undefined") return isMobile ? 8 : 8; - const stored = localStorage.getItem(FONT_SIZE_KEY); - if (stored) { - const parsed = parseInt(stored, 10); - return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, parsed)); - } - return isMobile ? 8 : 8; - }); - const lastPingRef = useRef(0); - const heartbeatCheckRef = useRef(null); - const isUnmountingRef = useRef(false); - const permanentErrorRef = useRef(null); + ( + { + instanceId, + sessionId, + onClose, + isMobile = false, + showControls = true, + activeModifier, + onModifierChange, + onTerminalReady, + }, + ref, + ) => { + const terminalRef = useRef(null); + const hiddenInputRef = useRef(null); + const wsRef = useRef(null); + const termRef = useRef(null); + const fitAddonRef = useRef(null); + const reconnectAttemptsRef = useRef(0); + const onTerminalReadyRef = useRef(onTerminalReady); + onTerminalReadyRef.current = onTerminalReady; + const handleFontSizeChangeRef = useRef<(delta: number) => void>(() => {}); + const [status, setStatus] = useState< + "connecting" | "connected" | "disconnected" | "error" | "resetting" + >("connecting"); + const [error, setError] = useState(null); + const [showResetConfirm, setShowResetConfirm] = useState(false); + const activeModifierRef = useRef(activeModifier); + activeModifierRef.current = activeModifier; + const [fontSize, setFontSize] = useState(() => { + if (typeof window === "undefined") return isMobile ? 8 : 8; + const stored = localStorage.getItem(FONT_SIZE_KEY); + if (stored) { + const parsed = parseInt(stored, 10); + return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, parsed)); + } + return isMobile ? 8 : 8; + }); + const lastPingRef = useRef(0); + const heartbeatCheckRef = useRef(null); + const isUnmountingRef = useRef(false); + const permanentErrorRef = useRef(null); - const calculateFontSize = useCallback(() => { - return fontSize; - }, [fontSize]); + const calculateFontSize = useCallback(() => { + return fontSize; + }, [fontSize]); - const connectWebSocket = useCallback(() => { - const apiUrl = import.meta.env.VITE_API_BASE_URL || ""; - const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, ""); - const wsPath = sessionId - ? `/ws/tool-instances/${instanceId}/terminal/${sessionId}` - : `/ws/tool-instances/${instanceId}/terminal`; - const wsUrl = `${wsProtocol}//${wsHost}${wsPath}`; + const connectWebSocket = useCallback(() => { + const apiUrl = import.meta.env.VITE_API_BASE_URL || ""; + const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, ""); + const wsPath = sessionId + ? `/ws/tool-instances/${instanceId}/terminal/${sessionId}` + : `/ws/tool-instances/${instanceId}/terminal`; + const wsUrl = `${wsProtocol}//${wsHost}${wsPath}`; - // WebSocket connection established - const ws = new WebSocket(wsUrl); - ws.binaryType = "arraybuffer"; - wsRef.current = ws; + // WebSocket connection established + const ws = new WebSocket(wsUrl); + ws.binaryType = "arraybuffer"; + wsRef.current = ws; - ws.onopen = () => { - setStatus("connected"); - setError(null); - reconnectAttemptsRef.current = 0; - lastPingRef.current = Date.now(); + ws.onopen = () => { + setStatus("connected"); + setError(null); + reconnectAttemptsRef.current = 0; + lastPingRef.current = Date.now(); - // Send current terminal size immediately on connect - if (termRef.current) { - const { cols, rows } = termRef.current; - // Only send if we have valid dimensions - if (cols > 0 && rows > 0) { - ws.send(JSON.stringify({ type: "resize", cols, rows })); - } - } + // Send current terminal size immediately on connect + if (termRef.current) { + const { cols, rows } = termRef.current; + // Only send if we have valid dimensions + if (cols > 0 && rows > 0) { + ws.send(JSON.stringify({ type: "resize", cols, rows })); + } + } - // Start heartbeat check - if (heartbeatCheckRef.current) { - window.clearInterval(heartbeatCheckRef.current); - } - heartbeatCheckRef.current = window.setInterval(() => { - const elapsed = Date.now() - lastPingRef.current; - if (elapsed > 60000) { - // No ping for 60 seconds, connection may be dead - ws.close(4000, "Heartbeat timeout"); - } - }, 30000); - }; + // Start heartbeat check + if (heartbeatCheckRef.current) { + window.clearInterval(heartbeatCheckRef.current); + } + heartbeatCheckRef.current = window.setInterval(() => { + const elapsed = Date.now() - lastPingRef.current; + if (elapsed > 60000) { + // No ping for 60 seconds, connection may be dead + ws.close(4000, "Heartbeat timeout"); + } + }, 30000); + }; - // Flow control: accumulate processed bytes and send ack - let ackAccumulator = 0; - const ACK_THRESHOLD = 4096; - let ackTimeout: ReturnType | null = null; + // Flow control: accumulate processed bytes and send ack + let ackAccumulator = 0; + const ACK_THRESHOLD = 4096; + let ackTimeout: ReturnType | null = null; - const flushAck = () => { - if (ackAccumulator > 0 && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: "ack", chars: ackAccumulator })); - ackAccumulator = 0; - } - }; + const flushAck = () => { + if (ackAccumulator > 0 && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "ack", chars: ackAccumulator })); + ackAccumulator = 0; + } + }; - ws.onmessage = (event) => { - if (!termRef.current) return; + ws.onmessage = (event) => { + if (!termRef.current) return; - if (event.data instanceof ArrayBuffer) { - const data = new Uint8Array(event.data); - termRef.current.write(data); + if (event.data instanceof ArrayBuffer) { + const data = new Uint8Array(event.data); + termRef.current.write(data); - // Flow control: accumulate processed bytes - ackAccumulator += data.length; - if (ackAccumulator >= ACK_THRESHOLD) { - flushAck(); - } else if (!ackTimeout) { - ackTimeout = setTimeout(() => { - ackTimeout = null; - flushAck(); - }, 100); - } - } else if (typeof event.data === "string") { - try { - const msg = JSON.parse(event.data); - if (msg.type === "status") { - if (msg.status === "connected") { - setStatus("connected"); - setError(null); - // Refit after reset/reconnect but do NOT clear the - // terminal: the server keeps the session buffer, and - // clearing would erase it when the user switches back - // to an already-connected session. - if (termRef.current) { - requestAnimationFrame(() => { - if (fitAddonRef.current && termRef.current) { - fitAddonRef.current.fit(); - const { cols, rows } = termRef.current; - const currentWs = wsRef.current; - if (currentWs?.readyState === WebSocket.OPEN) { - currentWs.send( - JSON.stringify({ type: "resize", cols, rows }), - ); - } - } - }); - } - } else if (msg.status === "resetting") { - setStatus("resetting"); - } - } else if (msg.type === "ping") { - // Respond with pong and update last ping time - lastPingRef.current = Date.now(); - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: "pong" })); - } - } - } catch { - termRef.current?.write(event.data); - } - } - }; + // TEMP diagnostic: log when pi's bracketed-paste enable sequence reaches xterm + const text = new TextDecoder().decode(data); + if (text.includes("\x1b[?2004h")) { + console.log("[BPMDIAG] enable sequence (\\e[?2004h) reached xterm"); + } - ws.onclose = (event) => { - // Clean up heartbeat check - if (heartbeatCheckRef.current) { - window.clearInterval(heartbeatCheckRef.current); - heartbeatCheckRef.current = null; - } + // Flow control: accumulate processed bytes + ackAccumulator += data.length; + if (ackAccumulator >= ACK_THRESHOLD) { + flushAck(); + } else if (!ackTimeout) { + ackTimeout = setTimeout(() => { + ackTimeout = null; + flushAck(); + }, 100); + } + } else if (typeof event.data === "string") { + try { + const msg = JSON.parse(event.data); + if (msg.type === "status") { + if (msg.status === "connected") { + setStatus("connected"); + setError(null); + // Refit after reset/reconnect but do NOT clear the + // terminal: the server keeps the session buffer, and + // clearing would erase it when the user switches back + // to an already-connected session. + if (termRef.current) { + requestAnimationFrame(() => { + if (fitAddonRef.current && termRef.current) { + fitAddonRef.current.fit(); + const { cols, rows } = termRef.current; + const currentWs = wsRef.current; + if (currentWs?.readyState === WebSocket.OPEN) { + currentWs.send( + JSON.stringify({ type: "resize", cols, rows }), + ); + } + } + }); + } + } else if (msg.status === "resetting") { + setStatus("resetting"); + } + } else if (msg.type === "ping") { + // Respond with pong and update last ping time + lastPingRef.current = Date.now(); + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "pong" })); + } + } + } catch { + termRef.current?.write(event.data); + } + } + }; - // Permanent errors: do not retry - if (event.code === 4001 || event.code === 4003 || event.code === 4004) { - const reason = event.reason || `Instance error (code: ${event.code})`; - setStatus("error"); - setError(reason); - permanentErrorRef.current = reason; - return; - } + ws.onclose = (event) => { + // Clean up heartbeat check + if (heartbeatCheckRef.current) { + window.clearInterval(heartbeatCheckRef.current); + heartbeatCheckRef.current = null; + } - if (event.code === 1000) { - setStatus("disconnected"); - return; - } + // Permanent errors: do not retry + if (event.code === 4001 || event.code === 4003 || event.code === 4004) { + const reason = event.reason || `Instance error (code: ${event.code})`; + setStatus("error"); + setError(reason); + permanentErrorRef.current = reason; + return; + } - if (event.code === 4000) { - // Server closed old connection for concurrent connection - don't reconnect - // The new connection is already established - return; - } + if (event.code === 1000) { + setStatus("disconnected"); + return; + } - // Transient errors: attempt reconnection - setStatus("disconnected"); - setError(`Connection closed (code: ${event.code})`); + if (event.code === 4000) { + // Server closed old connection for concurrent connection - don't reconnect + // The new connection is already established + return; + } - if (reconnectAttemptsRef.current < RECONNECT_ATTEMPTS) { - reconnectAttemptsRef.current++; - const delay = - RECONNECT_DELAY_BASE * - Math.pow(2, reconnectAttemptsRef.current - 1); - setTimeout(() => { - if (isUnmountingRef.current) { - return; - } - if (document.visibilityState !== "hidden") { - connectWebSocket(); - } - }, delay); - } - }; + // Transient errors: attempt reconnection + setStatus("disconnected"); + setError(`Connection closed (code: ${event.code})`); - ws.onerror = () => { - setStatus("error"); - setError("WebSocket error"); - }; + if (reconnectAttemptsRef.current < RECONNECT_ATTEMPTS) { + reconnectAttemptsRef.current++; + const delay = + RECONNECT_DELAY_BASE * + Math.pow(2, reconnectAttemptsRef.current - 1); + setTimeout(() => { + if (isUnmountingRef.current) { + return; + } + if (document.visibilityState !== "hidden") { + connectWebSocket(); + } + }, delay); + } + }; - return ws; - }, [instanceId, sessionId]); + ws.onerror = () => { + setStatus("error"); + setError("WebSocket error"); + }; - useEffect(() => { - if (!terminalRef.current) return; + return ws; + }, [instanceId, sessionId]); - // Initialize terminal - const currentFontSize = calculateFontSize(); - const term = new Terminal({ - cursorBlink: true, - fontSize: currentFontSize, - fontFamily: 'Menlo, Monaco, "Courier New", monospace', - lineHeight: 1.2, - letterSpacing: 0, - allowTransparency: false, - // This terminal only ever hosts full-screen TUI tools (pi-agent, - // opencode), which repaint in place in the normal buffer and do not - // use the alternate screen or mouse tracking. With scrollback, every - // repaint accumulates as history → a viewport scrollbar appears and - // the mouse-wheel scrolls through stale frames instead of the app. - // scrollback:0 keeps only the live viewport: no bar, no stale-frame - // wheel jank. (Scrollbar is also hidden via CSS for belt-and-suspenders.) - scrollback: 0, - ignoreBracketedPasteMode: false, - fastScrollSensitivity: 0, - scrollSensitivity: 0, - smoothScrollDuration: 0, - theme: { - background: "#1e1e1e", - foreground: "#d4d4d4", - cursor: "#d4d4d4", - selectionBackground: "#264f78", - black: "#000000", - red: "#cd3131", - green: "#0dbc79", - yellow: "#e5e510", - blue: "#2472c8", - magenta: "#bc3fbc", - cyan: "#11a8cd", - white: "#e5e5e5", - brightBlack: "#666666", - brightRed: "#f14c4c", - brightGreen: "#23d18b", - brightYellow: "#f5f543", - brightBlue: "#3b8eea", - brightMagenta: "#d670d6", - brightCyan: "#29b8db", - brightWhite: "#e5e5e5", - }, - }); + useEffect(() => { + if (!terminalRef.current) return; - termRef.current = term; + // Initialize terminal + const currentFontSize = calculateFontSize(); + const term = new Terminal({ + cursorBlink: true, + fontSize: currentFontSize, + fontFamily: 'Menlo, Monaco, "Courier New", monospace', + lineHeight: 1.2, + letterSpacing: 0, + allowTransparency: false, + // This terminal only ever hosts full-screen TUI tools (pi-agent, + // opencode), which repaint in place in the normal buffer and do not + // use the alternate screen or mouse tracking. With scrollback, every + // repaint accumulates as history → a viewport scrollbar appears and + // the mouse-wheel scrolls through stale frames instead of the app. + // scrollback:0 keeps only the live viewport: no bar, no stale-frame + // wheel jank. (Scrollbar is also hidden via CSS for belt-and-suspenders.) + scrollback: 10000, + ignoreBracketedPasteMode: false, + fastScrollSensitivity: 0, + scrollSensitivity: 0, + smoothScrollDuration: 0, + theme: { + background: "#1e1e1e", + foreground: "#d4d4d4", + cursor: "#d4d4d4", + selectionBackground: "#264f78", + black: "#000000", + red: "#cd3131", + green: "#0dbc79", + yellow: "#e5e510", + blue: "#2472c8", + magenta: "#bc3fbc", + cyan: "#11a8cd", + white: "#e5e5e5", + brightBlack: "#666666", + brightRed: "#f14c4c", + brightGreen: "#23d18b", + brightYellow: "#f5f543", + brightBlue: "#3b8eea", + brightMagenta: "#d670d6", + brightCyan: "#29b8db", + brightWhite: "#e5e5e5", + }, + }); - const fitAddon = new FitAddon(); - fitAddonRef.current = fitAddon; - term.loadAddon(fitAddon); - term.loadAddon(new WebLinksAddon()); + termRef.current = term; - // NOTE: WebGL renderer disabled. - // The WebGL addon causes black-on-black rendering artifacts with - // tmux/vim reverse-video (inverse color) sequences on desktop. - // Mobile already uses the DOM renderer (WebGL fails there), which - // handles these color attributes correctly. The DOM renderer is - // fast enough for typical terminal workloads. - // See: xterm.js WebGL known issues with reverse video / minimumContrastRatio + const fitAddon = new FitAddon(); + fitAddonRef.current = fitAddon; + term.loadAddon(fitAddon); + term.loadAddon(new WebLinksAddon()); - const container = terminalRef.current; + // NOTE: WebGL renderer disabled. + // The WebGL addon causes black-on-black rendering artifacts with + // tmux/vim reverse-video (inverse color) sequences on desktop. + // Mobile already uses the DOM renderer (WebGL fails there), which + // handles these color attributes correctly. The DOM renderer is + // fast enough for typical terminal workloads. + // See: xterm.js WebGL known issues with reverse video / minimumContrastRatio - // Define fitTerminal before connectWebSocket so it's available in onmessage - let lastSentCols = 0; - let lastSentRows = 0; - const fitTerminal = () => { - if (!fitAddonRef.current || !termRef.current) return; - try { - fitAddonRef.current.fit(); - } catch { - // Ignore fit errors during initialization - return; - } - const { cols, rows } = termRef.current; - // Only send resize when dimensions actually changed - if ( - cols > 0 && - rows > 0 && - (cols !== lastSentCols || rows !== lastSentRows) - ) { - lastSentCols = cols; - lastSentRows = rows; - const currentWs = wsRef.current; - if (currentWs?.readyState === WebSocket.OPEN) { - currentWs.send(JSON.stringify({ type: "resize", cols, rows })); - } - } - }; + const container = terminalRef.current; - // Open xterm first (must happen before fit) - term.open(container); - term.focus(); - const ws = connectWebSocket(); + // Define fitTerminal before connectWebSocket so it's available in onmessage + let lastSentCols = 0; + let lastSentRows = 0; + const fitTerminal = () => { + if (!fitAddonRef.current || !termRef.current) return; + try { + fitAddonRef.current.fit(); + } catch { + // Ignore fit errors during initialization + return; + } + const { cols, rows } = termRef.current; + // Only send resize when dimensions actually changed + if ( + cols > 0 && + rows > 0 && + (cols !== lastSentCols || rows !== lastSentRows) + ) { + lastSentCols = cols; + lastSentRows = rows; + const currentWs = wsRef.current; + if (currentWs?.readyState === WebSocket.OPEN) { + currentWs.send(JSON.stringify({ type: "resize", cols, rows })); + } + } + }; - // Mobile touch scroll. - // In normal mode xterm.js has a scrollable viewport; in alternate - // screen (tmux/vim) there is no scrollback and the only way to - // scroll is to send mouse-wheel protocol sequences to the - // application. We detect the active buffer by reference equality - // (term.buffer.active === term.buffer.alternate) because the - // `type` string can be unreliable in some xterm.js versions. - let touchCleanup: (() => void) | undefined; - if (isMobile) { - let startY = 0; - let startX = 0; - let isScrolling = false; - let scrollPending = 0; - const WHEEL_DISTANCE = 12; + // Open xterm first (must happen before fit) + term.open(container); + term.focus(); + const ws = connectWebSocket(); - const flushScroll = (force = false) => { - if (scrollPending === 0) return; - if (!force && Math.abs(scrollPending) < WHEEL_DISTANCE) return; - const direction = Math.sign(scrollPending); - const steps = Math.max( - 1, - Math.floor(Math.abs(scrollPending) / WHEEL_DISTANCE), - ); - const ws = wsRef.current; - if (ws?.readyState === WebSocket.OPEN && termRef.current) { - const buf = termRef.current.buffer.active; - const col = buf.cursorX + 1; - const row = buf.cursorY + 1; - const btn = direction > 0 ? 64 : 65; - for (let i = 0; i < steps; i++) { - ws.send(`\x1b[<${btn};${col};${row}M`); - } - } - scrollPending = 0; - }; + // Mobile touch scroll. + // In normal mode xterm.js has a scrollable viewport; in alternate + // screen (tmux/vim) there is no scrollback and the only way to + // scroll is to send mouse-wheel protocol sequences to the + // application. We detect the active buffer by reference equality + // (term.buffer.active === term.buffer.alternate) because the + // `type` string can be unreliable in some xterm.js versions. + let touchCleanup: (() => void) | undefined; + if (isMobile) { + let startY = 0; + let startX = 0; + let isScrolling = false; + let scrollPending = 0; + const WHEEL_DISTANCE = 12; - const isAlternateScreen = () => { - const term = termRef.current; - if (!term) return false; - return term.buffer.active === term.buffer.alternate; - }; + const flushScroll = (force = false) => { + if (scrollPending === 0) return; + if (!force && Math.abs(scrollPending) < WHEEL_DISTANCE) return; + const direction = Math.sign(scrollPending); + const steps = Math.max( + 1, + Math.floor(Math.abs(scrollPending) / WHEEL_DISTANCE), + ); + const ws = wsRef.current; + if (ws?.readyState === WebSocket.OPEN && termRef.current) { + const buf = termRef.current.buffer.active; + const col = buf.cursorX + 1; + const row = buf.cursorY + 1; + const btn = direction > 0 ? 64 : 65; + for (let i = 0; i < steps; i++) { + ws.send(`\x1b[<${btn};${col};${row}M`); + } + } + scrollPending = 0; + }; - const onTouchStart = (e: TouchEvent) => { - if (e.touches.length === 1) { - startY = e.touches[0].clientY; - startX = e.touches[0].clientX; - isScrolling = false; - scrollPending = 0; - } - }; - const onTouchMove = (e: TouchEvent) => { - if (e.touches.length !== 1) return; - const touch = e.touches[0]; - const deltaY = startY - touch.clientY; - const deltaX = Math.abs(startX - touch.clientX); + const isAlternateScreen = () => { + const term = termRef.current; + if (!term) return false; + return term.buffer.active === term.buffer.alternate; + }; - // Decide early whether this is a vertical scroll gesture. - if (!isScrolling) { - if (Math.abs(deltaY) > deltaX && Math.abs(deltaY) > 2) { - isScrolling = true; - } - } - if (!isScrolling) return; + const onTouchStart = (e: TouchEvent) => { + if (e.touches.length === 1) { + startY = e.touches[0].clientY; + startX = e.touches[0].clientX; + isScrolling = false; + scrollPending = 0; + } + }; + const onTouchMove = (e: TouchEvent) => { + if (e.touches.length !== 1) return; + const touch = e.touches[0]; + const deltaY = startY - touch.clientY; + const deltaX = Math.abs(startX - touch.clientX); - // Take over the gesture so the page/toolbar never scrolls. - e.preventDefault(); + // Decide early whether this is a vertical scroll gesture. + if (!isScrolling) { + if (Math.abs(deltaY) > deltaX && Math.abs(deltaY) > 2) { + isScrolling = true; + } + } + if (!isScrolling) return; - if (isAlternateScreen()) { - // Alternate screen (tmux/vim): accumulate the swipe and - // send SGR 1006 mouse-wheel events in steps. - scrollPending += deltaY; - flushScroll(); - } else if (termRef.current) { - // Normal buffer: let xterm.js scroll its own viewport by - // the number of lines corresponding to the swipe distance. - const lineHeight = - termRef.current.options.fontSize != null - ? (termRef.current.options.fontSize as number) * 1.2 - : 10; - const lines = Math.round(deltaY / lineHeight); - if (lines !== 0) { - termRef.current.scrollLines(lines); - } - } - startY = touch.clientY; - }; - const onTouchEnd = () => { - if (isScrolling) { - flushScroll(true); - } - isScrolling = false; - }; + // Take over the gesture so the page/toolbar never scrolls. + e.preventDefault(); - container.addEventListener("touchstart", onTouchStart, { - passive: false, - capture: true, - }); - container.addEventListener("touchmove", onTouchMove, { - passive: false, - capture: true, - }); - container.addEventListener("touchend", onTouchEnd, { - capture: true, - }); - touchCleanup = () => { - container.removeEventListener("touchstart", onTouchStart, { - capture: true, - }); - container.removeEventListener("touchmove", onTouchMove, { - capture: true, - }); - container.removeEventListener("touchend", onTouchEnd, { - capture: true, - }); - }; - } + if (isAlternateScreen()) { + // Alternate screen (tmux/vim): accumulate the swipe and + // send SGR 1006 mouse-wheel events in steps. + scrollPending += deltaY; + flushScroll(); + } else if (termRef.current) { + // Normal buffer: let xterm.js scroll its own viewport by + // the number of lines corresponding to the swipe distance. + const lineHeight = + termRef.current.options.fontSize != null + ? (termRef.current.options.fontSize as number) * 1.2 + : 10; + const lines = Math.round(deltaY / lineHeight); + if (lines !== 0) { + termRef.current.scrollLines(lines); + } + } + startY = touch.clientY; + }; + const onTouchEnd = () => { + if (isScrolling) { + flushScroll(true); + } + isScrolling = false; + }; - // Initial fit after layout settles (terminal must be opened first) - let fitAttempts = 0; - const doInitialFit = () => { - if (!container.isConnected) return; - fitAttempts++; - // Ensure container has dimensions before fitting - if (container.clientWidth > 0 && container.clientHeight > 0) { - fitTerminal(); - } else if (fitAttempts < 50) { - // Container not ready yet, try again (max 50 attempts ~ 1s) - requestAnimationFrame(doInitialFit); - } - }; - requestAnimationFrame(doInitialFit); + container.addEventListener("touchstart", onTouchStart, { + passive: false, + capture: true, + }); + container.addEventListener("touchmove", onTouchMove, { + passive: false, + capture: true, + }); + container.addEventListener("touchend", onTouchEnd, { + capture: true, + }); + touchCleanup = () => { + container.removeEventListener("touchstart", onTouchStart, { + capture: true, + }); + container.removeEventListener("touchmove", onTouchMove, { + capture: true, + }); + container.removeEventListener("touchend", onTouchEnd, { + capture: true, + }); + }; + } - // Refit after font load (metrics may change) - document.fonts.ready.then(() => { - requestAnimationFrame(() => fitTerminal()); - }); + // Initial fit after layout settles (terminal must be opened first) + let fitAttempts = 0; + const doInitialFit = () => { + if (!container.isConnected) return; + fitAttempts++; + // Ensure container has dimensions before fitting + if (container.clientWidth > 0 && container.clientHeight > 0) { + fitTerminal(); + } else if (fitAttempts < 50) { + // Container not ready yet, try again (max 50 attempts ~ 1s) + requestAnimationFrame(doInitialFit); + } + }; + requestAnimationFrame(doInitialFit); - // Handle terminal input - term.onData((data) => { - const currentWs = wsRef.current; - if (currentWs?.readyState !== WebSocket.OPEN) return; + // Refit after font load (metrics may change) + document.fonts.ready.then(() => { + requestAnimationFrame(() => fitTerminal()); + }); - // Apply active modifier to single-character input - const modifier = activeModifierRef.current; - if (modifier && data.length === 1) { - const modified = applyModifierToChar(data, modifier); - if (modified) { - currentWs.send(modified); - onModifierChange?.(null); - return; - } - } + // Handle terminal input + term.onData((data) => { + // TEMP diagnostic: log paste-like outbound data and whether BPM-wrapped + if (data.length > 10 && (data.includes("\n") || data.includes("\r"))) { + const bpmWrapped = data.startsWith("\x1b[200~") && data.endsWith("\x1b[201~"); + console.log("[PASTEDIAG] bpmWrapped=" + bpmWrapped + " len=" + data.length); + } - currentWs.send(data); - }); + const currentWs = wsRef.current; + if (currentWs?.readyState !== WebSocket.OPEN) return; - // Handle container resize with ResizeObserver for accurate dimension tracking - let resizeTimeout: ReturnType; - let lastWidth = 0; - let lastHeight = 0; - const resizeObserver = new ResizeObserver((entries) => { - const entry = entries[0]; - if (!entry) return; + // Apply active modifier to single-character input + const modifier = activeModifierRef.current; + if (modifier && data.length === 1) { + const modified = applyModifierToChar(data, modifier); + if (modified) { + currentWs.send(modified); + onModifierChange?.(null); + return; + } + } - const { width, height } = entry.contentRect; - // Only trigger if dimensions actually changed - if (width === lastWidth && height === lastHeight) return; - lastWidth = width; - lastHeight = height; + currentWs.send(data); + }); - clearTimeout(resizeTimeout); - resizeTimeout = setTimeout(() => { - requestAnimationFrame(() => { - if (!container.isConnected) return; - fitTerminal(); - }); - }, 50); - }); - resizeObserver.observe(container); + // Handle container resize with ResizeObserver for accurate dimension tracking + let resizeTimeout: ReturnType; + let lastWidth = 0; + let lastHeight = 0; + const resizeObserver = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) return; - // Window resize fallback (for viewport changes that don't affect container dimensions) - let windowResizeTimeout: ReturnType; - const handleWindowResize = () => { - clearTimeout(windowResizeTimeout); - windowResizeTimeout = setTimeout(() => { - requestAnimationFrame(() => fitTerminal()); - }, 250); - }; - window.addEventListener("resize", handleWindowResize); + const { width, height } = entry.contentRect; + // Only trigger if dimensions actually changed + if (width === lastWidth && height === lastHeight) return; + lastWidth = width; + lastHeight = height; - // Refit after mobile header auto-hides (3s delay + 0.3s transition) - const headerHideTimeout = setTimeout(() => { - fitTerminal(); - }, 4000); + clearTimeout(resizeTimeout); + resizeTimeout = setTimeout(() => { + requestAnimationFrame(() => { + if (!container.isConnected) return; + fitTerminal(); + }); + }, 50); + }); + resizeObserver.observe(container); - // Notify parent about terminal readiness - if (onTerminalReadyRef.current) { - const sendData = (data: string) => { - const currentWs = wsRef.current; - if (currentWs?.readyState === WebSocket.OPEN) { - currentWs.send(data); - } - }; - const focusInput = () => { - termRef.current?.focus(); - }; - const changeFontSize = (delta: number) => { - handleFontSizeChangeRef.current(delta); - }; - onTerminalReadyRef.current( - sessionId, - sendData, - status, - focusInput, - changeFontSize, - ); - } + // Window resize fallback (for viewport changes that don't affect container dimensions) + let windowResizeTimeout: ReturnType; + const handleWindowResize = () => { + clearTimeout(windowResizeTimeout); + windowResizeTimeout = setTimeout(() => { + requestAnimationFrame(() => fitTerminal()); + }, 250); + }; + window.addEventListener("resize", handleWindowResize); - // Visibility API for reconnection - const handleVisibilityChange = () => { - if ( - document.visibilityState === "visible" && - ws && - ws.readyState !== WebSocket.OPEN - ) { - if (permanentErrorRef.current) { - return; - } - reconnectAttemptsRef.current = 0; - connectWebSocket(); - } - }; - document.addEventListener("visibilitychange", handleVisibilityChange); + // Refit after mobile header auto-hides (3s delay + 0.3s transition) + const headerHideTimeout = setTimeout(() => { + fitTerminal(); + }, 4000); - return () => { - isUnmountingRef.current = true; - clearTimeout(resizeTimeout); - clearTimeout(windowResizeTimeout); - clearTimeout(headerHideTimeout); - resizeObserver.disconnect(); - window.removeEventListener("resize", handleWindowResize); - document.removeEventListener( - "visibilitychange", - handleVisibilityChange, - ); - if (touchCleanup) touchCleanup(); - if (ws) { - ws.close(1000, "Component unmounting"); - } - if (heartbeatCheckRef.current) { - window.clearInterval(heartbeatCheckRef.current); - heartbeatCheckRef.current = null; - } - try { - term.dispose(); - } catch { - // Ignore disposal errors from partially torn-down terminal - } - }; - }, [instanceId, connectWebSocket]); + // Notify parent about terminal readiness + if (onTerminalReadyRef.current) { + const sendData = (data: string) => { + const currentWs = wsRef.current; + if (currentWs?.readyState === WebSocket.OPEN) { + currentWs.send(data); + } + }; + const focusInput = () => { + termRef.current?.focus(); + }; + const changeFontSize = (delta: number) => { + handleFontSizeChangeRef.current(delta); + }; + onTerminalReadyRef.current( + sessionId, + sendData, + status, + focusInput, + changeFontSize, + ); + } - useImperativeHandle(ref, () => ({ - fit: () => { - if (fitAddonRef.current && termRef.current) { - try { - fitAddonRef.current.fit(); - const { cols, rows } = termRef.current; - if ( - wsRef.current?.readyState === WebSocket.OPEN && - cols > 0 && - rows > 0 - ) { - wsRef.current.send( - JSON.stringify({ type: "resize", cols, rows }), - ); - } - } catch { - // Ignore fit errors - } - } - }, - focus: () => { - termRef.current?.focus(); - }, - reset: () => { - if (wsRef.current?.readyState === WebSocket.OPEN) { - wsRef.current.send(JSON.stringify({ type: "reset" })); - } - }, - })); + // Visibility API for reconnection + const handleVisibilityChange = () => { + if ( + document.visibilityState === "visible" && + ws && + ws.readyState !== WebSocket.OPEN + ) { + if (permanentErrorRef.current) { + return; + } + reconnectAttemptsRef.current = 0; + connectWebSocket(); + } + }; + document.addEventListener("visibilitychange", handleVisibilityChange); - // Update parent about status changes - useEffect(() => { - if (onTerminalReady && termRef.current) { - const sendData = (data: string) => { - if (wsRef.current?.readyState === WebSocket.OPEN) { - wsRef.current.send(data); - } - }; - const focusInput = () => { - termRef.current?.focus(); - }; - const changeFontSize = (delta: number) => { - handleFontSizeChangeRef.current(delta); - }; - onTerminalReady( - sessionId, - sendData, - status, - focusInput, - changeFontSize, - ); - } - }, [sessionId, status, onTerminalReady]); + return () => { + isUnmountingRef.current = true; + clearTimeout(resizeTimeout); + clearTimeout(windowResizeTimeout); + clearTimeout(headerHideTimeout); + resizeObserver.disconnect(); + window.removeEventListener("resize", handleWindowResize); + document.removeEventListener( + "visibilitychange", + handleVisibilityChange, + ); + if (touchCleanup) touchCleanup(); + if (ws) { + ws.close(1000, "Component unmounting"); + } + if (heartbeatCheckRef.current) { + window.clearInterval(heartbeatCheckRef.current); + heartbeatCheckRef.current = null; + } + try { + term.dispose(); + } catch { + // Ignore disposal errors from partially torn-down terminal + } + }; + }, [instanceId, connectWebSocket]); - const handleFontSizeChange = (delta: number) => { - const newSize = Math.max( - MIN_FONT_SIZE, - Math.min(MAX_FONT_SIZE, fontSize + delta), - ); - setFontSize(newSize); - localStorage.setItem(FONT_SIZE_KEY, newSize.toString()); - if (termRef.current && fitAddonRef.current) { - termRef.current.options.fontSize = newSize; - requestAnimationFrame(() => { - if (termRef.current && fitAddonRef.current) { - try { - fitAddonRef.current.fit(); - const { cols, rows } = termRef.current; - if (wsRef.current?.readyState === WebSocket.OPEN) { - wsRef.current.send( - JSON.stringify({ - type: "resize", - cols, - rows, - }), - ); - } - } catch { - // Ignore fit errors during re-initialization - } - } - }); - } - }; - handleFontSizeChangeRef.current = handleFontSizeChange; + useImperativeHandle(ref, () => ({ + fit: () => { + if (fitAddonRef.current && termRef.current) { + try { + fitAddonRef.current.fit(); + const { cols, rows } = termRef.current; + if ( + wsRef.current?.readyState === WebSocket.OPEN && + cols > 0 && + rows > 0 + ) { + wsRef.current.send( + JSON.stringify({ type: "resize", cols, rows }), + ); + } + } catch { + // Ignore fit errors + } + } + }, + focus: () => { + termRef.current?.focus(); + }, + reset: () => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: "reset" })); + } + }, + })); - const handleCopy = async () => { - if (!termRef.current) return; - const selection = termRef.current.getSelection(); - if (selection) { - try { - await navigator.clipboard.writeText(selection); - } catch { - // Fallback for older browsers - const textarea = document.createElement("textarea"); - textarea.value = selection; - document.body.appendChild(textarea); - textarea.select(); - document.execCommand("copy"); - document.body.removeChild(textarea); - } - } - }; + // Update parent about status changes + useEffect(() => { + if (onTerminalReady && termRef.current) { + const sendData = (data: string) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(data); + } + }; + const focusInput = () => { + termRef.current?.focus(); + }; + const changeFontSize = (delta: number) => { + handleFontSizeChangeRef.current(delta); + }; + onTerminalReady( + sessionId, + sendData, + status, + focusInput, + changeFontSize, + ); + } + }, [sessionId, status, onTerminalReady]); - const handlePaste = async () => { - try { - const text = await navigator.clipboard.readText(); - // Route through xterm.js instead of sending raw text directly. - // term.paste() wraps the content in bracketed-paste markers - // (\e[200~...\e[201~) when the app has enabled BPM, so multiline - // pastes arrive as a single input rather than one prompt per line. - // It emits via onData, which the existing handler forwards to the - // WebSocket, so the readyState check happens there. - termRef.current?.paste(text); - } catch { - // Clipboard API not available - } - }; + const handleFontSizeChange = (delta: number) => { + const newSize = Math.max( + MIN_FONT_SIZE, + Math.min(MAX_FONT_SIZE, fontSize + delta), + ); + setFontSize(newSize); + localStorage.setItem(FONT_SIZE_KEY, newSize.toString()); + if (termRef.current && fitAddonRef.current) { + termRef.current.options.fontSize = newSize; + requestAnimationFrame(() => { + if (termRef.current && fitAddonRef.current) { + try { + fitAddonRef.current.fit(); + const { cols, rows } = termRef.current; + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send( + JSON.stringify({ + type: "resize", + cols, + rows, + }), + ); + } + } catch { + // Ignore fit errors during re-initialization + } + } + }); + } + }; + handleFontSizeChangeRef.current = handleFontSizeChange; - // Focus terminal on mobile to keep keyboard open - const handleTerminalClick = () => { - if (isMobile && termRef.current) { - termRef.current.focus(); - } - }; + const handleCopy = async () => { + if (!termRef.current) return; + const selection = termRef.current.getSelection(); + if (selection) { + try { + await navigator.clipboard.writeText(selection); + } catch { + // Fallback for older browsers + const textarea = document.createElement("textarea"); + textarea.value = selection; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + document.body.removeChild(textarea); + } + } + }; - return ( -
- {showControls && ( -
-
-
- - - {status === "resetting" - ? "Resetting..." - : reconnectAttemptsRef.current > 0 && status !== "connected" - ? `Reconnecting (${reconnectAttemptsRef.current}/${RECONNECT_ATTEMPTS})...` - : status} - -
- {isMobile && ( - <> - - - - )} -
-
- - - - {onClose && ( - - )} -
-
- )} - {showResetConfirm && ( -
-
-

- Reset terminal? This will kill the current shell session and - start fresh. -

-
- - -
-
-
- )} - {error && ( -
- {error} - {status === "error" && ( - - )} -
- )} -
- {isMobile && ( - - )} -
- ); - }, + const handlePaste = async () => { + try { + const text = await navigator.clipboard.readText(); + // Route through xterm.js instead of sending raw text directly. + // term.paste() wraps the content in bracketed-paste markers + // (\e[200~...\e[201~) when the app has enabled BPM, so multiline + // pastes arrive as a single input rather than one prompt per line. + // It emits via onData, which the existing handler forwards to the + // WebSocket, so the readyState check happens there. + termRef.current?.paste(text); + } catch { + // Clipboard API not available + } + }; + + // Focus terminal on mobile to keep keyboard open + const handleTerminalClick = () => { + if (isMobile && termRef.current) { + termRef.current.focus(); + } + }; + + return ( +
+ {showControls && ( +
+
+
+ + + {status === "resetting" + ? "Resetting..." + : reconnectAttemptsRef.current > 0 && status !== "connected" + ? `Reconnecting (${reconnectAttemptsRef.current}/${RECONNECT_ATTEMPTS})...` + : status} + +
+ {isMobile && ( + <> + + + + )} +
+
+ + + + {onClose && ( + + )} +
+
+ )} + {showResetConfirm && ( +
+
+

+ Reset terminal? This will kill the current shell session and + start fresh. +

+
+ + +
+
+
+ )} + {error && ( +
+ {error} + {status === "error" && ( + + )} +
+ )} +
+ {isMobile && ( + + )} +
+ ); + }, ); TerminalComponent.displayName = "TerminalComponent"; diff --git a/apps/web/src/styles/utilities.css b/apps/web/src/styles/utilities.css index 82670fb..87f7faa 100644 --- a/apps/web/src/styles/utilities.css +++ b/apps/web/src/styles/utilities.css @@ -152,7 +152,9 @@ } .font-mono { - font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-family: + "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + monospace; } .font-medium { @@ -3817,7 +3819,9 @@ a:active, color: var(--muted); cursor: pointer; border-radius: var(--radius-md); - transition: background 0.12s ease, color 0.12s ease; + transition: + background 0.12s ease, + color 0.12s ease; } .delete-btn:hover { @@ -3844,7 +3848,9 @@ a:active, font-size: var(--font-size-sm); font-weight: 500; text-decoration: none; - transition: background 0.12s ease, border-color 0.12s ease; + transition: + background 0.12s ease, + border-color 0.12s ease; } .workspace-header-action-btn:hover { @@ -3919,7 +3925,9 @@ a:active, color: var(--muted); font-size: var(--font-size-xs); cursor: pointer; - transition: background 0.12s ease, color 0.12s ease; + transition: + background 0.12s ease, + color 0.12s ease; } .copy-button:hover { @@ -4165,7 +4173,10 @@ a:active, } .drag-item-active { - background: var(--brand-light, color-mix(in srgb, var(--brand) 10%, transparent)); + background: var( + --brand-light, + color-mix(in srgb, var(--brand) 10%, transparent) + ); border-color: var(--brand); } @@ -4181,7 +4192,9 @@ a:active, padding: var(--space-4); border-radius: var(--radius-md); overflow: auto; - font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-family: + "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + monospace; font-size: var(--font-size-sm); line-height: var(--line-height-normal); } @@ -4329,7 +4342,9 @@ a:active, color: var(--muted); cursor: pointer; font-weight: 600; - transition: border-color 0.15s, color 0.15s; + transition: + border-color 0.15s, + color 0.15s; } .sidebar-create-button:hover { @@ -4354,7 +4369,9 @@ a:active, display: flex; align-items: center; justify-content: center; - transition: transform 0.15s ease, box-shadow 0.15s ease; + transition: + transform 0.15s ease, + box-shadow 0.15s ease; } .mobile-fab:hover {