diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 7b324603..9827612d 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -23,6 +23,8 @@ jobs: - uses: actions/checkout@v6 - name: Install dependencies uses: ./.github/actions/setup + - name: Build (terminal package) + run: bun run --cwd packages/terminal build - name: Build (docker-git package) run: bun run --cwd packages/app build - name: Build (session sync package) @@ -58,6 +60,8 @@ jobs: - uses: actions/checkout@v6 - name: Install dependencies uses: ./.github/actions/setup + - name: Typecheck (terminal) + run: bun run --cwd packages/terminal typecheck - name: Typecheck (app) run: bun run --cwd packages/app check - name: Typecheck (session sync) @@ -80,6 +84,8 @@ jobs: - uses: actions/checkout@v6 - name: Install dependencies uses: ./.github/actions/setup + - name: Lint (terminal) + run: bun run --cwd packages/terminal lint - name: Lint (app) run: bun run --cwd packages/app lint - name: Lint (session sync) @@ -103,6 +109,8 @@ jobs: - uses: actions/checkout@v6 - name: Install dependencies uses: ./.github/actions/setup + - name: Test (terminal) + run: bun run --cwd packages/terminal test - name: Test (app) run: bun run --cwd packages/app test - name: Test (session sync) @@ -125,6 +133,8 @@ jobs: - uses: actions/checkout@v6 - name: Install dependencies uses: ./.github/actions/setup + - name: Lint Effect-TS (terminal) + run: bun run --cwd packages/terminal lint:effect - name: Lint Effect-TS (app) run: bun run --cwd packages/app lint:effect - name: Lint Effect-TS (session sync) diff --git a/bun.lock b/bun.lock index 2e6e5962..7e364fcb 100644 --- a/bun.lock +++ b/bun.lock @@ -20,6 +20,7 @@ "@effect/schema": "^0.75.5", "@fedify/fedify": "^2.2.3", "@fedify/vocab": "^2.2.3", + "@prover-coder-ai/docker-git-terminal": "workspace:*", "effect": "^3.21.2", "node-pty": "^1.1.0", "ws": "^8.21.0", @@ -40,7 +41,7 @@ }, "packages/app": { "name": "@prover-coder-ai/docker-git", - "version": "1.1.40", + "version": "1.1.41", "bin": { "docker-git": "dist/src/docker-git/main.js", }, @@ -65,8 +66,6 @@ "react-dom": "^19.2.6", "react-reconciler": "^0.33.0", "ts-morph": "^28.0.0", - "xterm": "^5.3.0", - "xterm-addon-fit": "^0.8.0", }, "devDependencies": { "@biomejs/biome": "^2.4.16", @@ -78,6 +77,7 @@ "@eslint/compat": "2.1.0", "@eslint/eslintrc": "3.3.5", "@eslint/js": "10.0.1", + "@prover-coder-ai/docker-git-terminal": "workspace:*", "@prover-coder-ai/eslint-plugin-suggest-members": "^0.0.26", "@ton-ai-core/vibecode-linter": "^1.0.11", "@types/node": "^25.9.1", @@ -110,7 +110,7 @@ }, "packages/docker-git-session-sync": { "name": "@prover-coder-ai/docker-git-session-sync", - "version": "1.0.43", + "version": "1.0.44", "bin": { "docker-git-session-sync": "dist/docker-git-session-sync.js", }, @@ -176,6 +176,55 @@ "vitest": "^4.1.7", }, }, + "packages/terminal": { + "name": "@prover-coder-ai/docker-git-terminal", + "version": "0.1.0", + "dependencies": { + "@effect/platform": "^0.96.1", + "@effect/platform-node": "^0.106.0", + "@effect/schema": "^0.75.5", + "effect": "^3.21.2", + "react": "^19.2.6", + "xterm": "^5.3.0", + "xterm-addon-fit": "^0.8.0", + }, + "devDependencies": { + "@biomejs/biome": "^2.4.16", + "@effect/eslint-plugin": "^0.3.2", + "@effect/language-service": "latest", + "@effect/vitest": "^0.29.0", + "@eslint-community/eslint-plugin-eslint-comments": "^4.7.2", + "@eslint/compat": "2.1.0", + "@eslint/eslintrc": "3.3.5", + "@eslint/js": "10.0.1", + "@prover-coder-ai/eslint-plugin-suggest-members": "^0.0.26", + "@ton-ai-core/vibecode-linter": "^1.0.11", + "@types/node": "^25.9.1", + "@types/react": "^19.2.15", + "@types/react-dom": "^19.2.3", + "@typescript-eslint/eslint-plugin": "^8.60.0", + "@typescript-eslint/parser": "^8.60.0", + "@vitest/coverage-v8": "^4.1.7", + "@vitest/eslint-plugin": "^1.6.18", + "eslint": "^10.4.1", + "eslint-import-resolver-typescript": "^4.4.4", + "eslint-plugin-codegen": "0.34.1", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-simple-import-sort": "^13.0.0", + "eslint-plugin-sonarjs": "^4.0.3", + "eslint-plugin-sort-destructure-keys": "^3.0.0", + "eslint-plugin-unicorn": "^64.0.0", + "fast-check": "^4.8.0", + "globals": "^17.6.0", + "jscpd": "^4.2.4", + "react-dom": "^19.2.6", + "typescript": "^6.0.3", + "typescript-eslint": "^8.60.0", + "vite": "^8.0.14", + "vite-tsconfig-paths": "^6.1.1", + "vitest": "^4.1.7", + }, + }, }, "trustedDependencies": [ "node-pty", @@ -546,6 +595,8 @@ "@prover-coder-ai/docker-git-session-sync": ["@prover-coder-ai/docker-git-session-sync@workspace:packages/docker-git-session-sync"], + "@prover-coder-ai/docker-git-terminal": ["@prover-coder-ai/docker-git-terminal@workspace:packages/terminal"], + "@prover-coder-ai/eslint-plugin-suggest-members": ["@prover-coder-ai/eslint-plugin-suggest-members@0.0.26", "", { "dependencies": { "@effect/platform": "^0.96.0", "@effect/platform-node": "^0.106.0", "@effect/schema": "^0.75.5", "@typescript-eslint/utils": "8.57.2", "effect": "^3.21.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <7.0.0" } }, "sha512-RWl1jYZTMK1p0L6GA7VXvTrtiNkbQyjkgk3mvz0Vv7ImTrctDOLFfNIRoJmhU+e5irj1u5uK2p9QoZtRzi4ILQ=="], "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.2", "", { "os": "android", "cpu": "arm64" }, "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ=="], @@ -1084,6 +1135,8 @@ "globby": ["globby@11.1.0", "", { "dependencies": { "array-union": "2.1.0", "dir-glob": "3.0.1", "fast-glob": "3.3.3", "ignore": "5.3.2", "merge2": "1.4.1", "slash": "3.0.0" } }, "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g=="], + "globrex": ["globrex@0.1.2", "", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="], + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], @@ -1680,6 +1733,8 @@ "ts-pattern": ["ts-pattern@5.9.0", "", {}, "sha512-6s5V71mX8qBUmlgbrfL33xDUwO0fq48rxAu2LBE11WBeGdpCPOsXksQbZJHvHwhrd3QjUusd3mAOM5Gg0mFBLg=="], + "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="], + "tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "0.0.29", "json5": "1.0.2", "minimist": "1.2.8", "strip-bom": "3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -1730,6 +1785,8 @@ "vite": ["vite@8.0.14", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.2", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw=="], + "vite-tsconfig-paths": ["vite-tsconfig-paths@6.1.1", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" } }, "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg=="], + "vitest": ["vitest@4.1.7", "", { "dependencies": { "@vitest/expect": "4.1.7", "@vitest/mocker": "4.1.7", "@vitest/pretty-format": "4.1.7", "@vitest/runner": "4.1.7", "@vitest/snapshot": "4.1.7", "@vitest/spy": "4.1.7", "@vitest/utils": "4.1.7", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.7", "@vitest/browser-preview": "4.1.7", "@vitest/browser-webdriverio": "4.1.7", "@vitest/coverage-istanbul": "4.1.7", "@vitest/coverage-v8": "4.1.7", "@vitest/ui": "4.1.7", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA=="], "void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="], diff --git a/package.json b/package.json index f2993609..36f45516 100644 --- a/package.json +++ b/package.json @@ -8,17 +8,18 @@ "packages/api", "packages/app", "packages/docker-git-session-sync", - "packages/lib" + "packages/lib", + "packages/terminal" ], "scripts": { "setup:pre-commit-hook": "bun scripts/setup-pre-commit-hook.js", - "build": "bun run --filter @prover-coder-ai/docker-git-session-sync build && bun run --filter @prover-coder-ai/docker-git build", + "build": "bun run --filter @prover-coder-ai/docker-git-session-sync build && bun run --filter @prover-coder-ai/docker-git-terminal build && bun run --filter @prover-coder-ai/docker-git build", "api:build": "bun run --filter @effect-template/api build", "api:start": "bun run --filter @effect-template/api start", "api:dev": "bun run --filter @effect-template/api dev", "api:test": "bun run --filter @effect-template/api test", "api:typecheck": "bun run --filter @effect-template/api typecheck", - "check": "bun run --filter @prover-coder-ai/docker-git-session-sync check && bun run --filter @prover-coder-ai/docker-git check && bun run --filter @effect-template/lib typecheck", + "check": "bun run --filter @prover-coder-ai/docker-git-session-sync check && bun run --filter @prover-coder-ai/docker-git-terminal check && bun run --filter @prover-coder-ai/docker-git check && bun run --filter @effect-template/lib typecheck", "check:dist-deps-prune": "bun node_modules/@prover-coder-ai/dist-deps-prune/dist/main.js scan --package ./packages/app/package.json --prune-dev true --silent", "changeset": "changeset", "changeset-publish": "bun -e \"if (!process.env.NPM_TOKEN) { console.log('Skipping publish: NPM_TOKEN is not set'); process.exit(0); }\" && changeset publish", @@ -42,11 +43,11 @@ "web:build": "bun run --cwd packages/app build:web", "web:preview": "bun run --cwd packages/app preview:web", "web:serve": "bun run --cwd packages/app serve:web", - "lint": "bun run --filter @prover-coder-ai/docker-git lint && bun run --filter @effect-template/lib lint", + "lint": "bun run --filter @prover-coder-ai/docker-git-terminal lint && bun run --filter @prover-coder-ai/docker-git lint && bun run --filter @effect-template/lib lint", "lint:tests": "bun run --filter @prover-coder-ai/docker-git lint:tests", - "lint:effect": "bun run --filter @prover-coder-ai/docker-git lint:effect && bun run --filter @effect-template/lib lint:effect", - "test": "bun run --filter @prover-coder-ai/docker-git-session-sync test && bun run --filter @prover-coder-ai/docker-git test && bun run --filter @effect-template/lib test", - "typecheck": "bun run --filter @prover-coder-ai/docker-git-session-sync typecheck && bun run --filter @prover-coder-ai/docker-git typecheck && bun run --filter @effect-template/lib typecheck", + "lint:effect": "bun run --filter @prover-coder-ai/docker-git-terminal lint:effect && bun run --filter @prover-coder-ai/docker-git lint:effect && bun run --filter @effect-template/lib lint:effect", + "test": "bun run --filter @prover-coder-ai/docker-git-session-sync test && bun run --filter @prover-coder-ai/docker-git-terminal test && bun run --filter @prover-coder-ai/docker-git test && bun run --filter @effect-template/lib test", + "typecheck": "bun run --filter @prover-coder-ai/docker-git-session-sync typecheck && bun run --filter @prover-coder-ai/docker-git-terminal typecheck && bun run --filter @prover-coder-ai/docker-git typecheck && bun run --filter @effect-template/lib typecheck", "start": "bun run --cwd packages/app build:docker-git && bun ./packages/app/dist/src/docker-git/main.js" }, "devDependencies": { diff --git a/packages/api/Dockerfile b/packages/api/Dockerfile index 74af1b7d..df11fd31 100644 --- a/packages/api/Dockerfile +++ b/packages/api/Dockerfile @@ -55,11 +55,12 @@ RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \ FROM controller-base AS workspace-deps COPY package.json bun.lock bunfig.toml tsconfig.base.json tsconfig.json ./ -RUN mkdir -p packages/api packages/app packages/docker-git-session-sync packages/lib +RUN mkdir -p packages/api packages/app packages/docker-git-session-sync packages/lib packages/terminal COPY packages/api/package.json ./packages/api/package.json COPY packages/app/package.json ./packages/app/package.json COPY packages/docker-git-session-sync/package.json ./packages/docker-git-session-sync/package.json COPY packages/lib/package.json ./packages/lib/package.json +COPY packages/terminal/package.json ./packages/terminal/package.json RUN set -eu; \ for attempt in 1 2 3 4 5; do \ @@ -68,6 +69,7 @@ RUN set -eu; \ --silent \ --filter @effect-template/api \ --filter @effect-template/lib \ + --filter @prover-coder-ai/docker-git-terminal \ --filter @prover-coder-ai/docker-git-session-sync; then \ exit 0; \ fi; \ @@ -84,8 +86,10 @@ COPY patches ./patches COPY scripts ./scripts COPY packages/docker-git-session-sync ./packages/docker-git-session-sync COPY packages/lib ./packages/lib +COPY packages/terminal ./packages/terminal RUN bun run --cwd packages/docker-git-session-sync build +RUN bun run --cwd packages/terminal build RUN bun run --cwd packages/lib build FROM controller-base AS skiller-build diff --git a/packages/api/package.json b/packages/api/package.json index 547f9d09..5c054bc5 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -7,19 +7,20 @@ "type": "module", "packageManager": "bun@1.3.11", "scripts": { - "prebuild": "bun run --cwd ../lib build", + "prebuild": "bun run --cwd ../terminal build && bun run --cwd ../lib build", "build": "tsc -p tsconfig.json", "dev": "tsc -p tsconfig.json --watch", "prestart": "bun run build", "start": "bun dist/src/main.js", - "pretypecheck": "bun run --cwd ../lib build", + "pretypecheck": "bun run --cwd ../terminal build && bun run --cwd ../lib build", "typecheck": "tsc --noEmit -p tsconfig.json", "lint": "eslint .", - "pretest": "bun run --cwd ../lib build", + "pretest": "bun run --cwd ../terminal build && bun run --cwd ../lib build", "test": "vitest run" }, "dependencies": { "@effect-template/lib": "workspace:*", + "@prover-coder-ai/docker-git-terminal": "workspace:*", "@effect/platform": "^0.96.1", "@effect/platform-node": "^0.106.0", "@effect/schema": "^0.75.5", diff --git a/packages/api/src/services/auth-terminal-sessions.ts b/packages/api/src/services/auth-terminal-sessions.ts index 4580f9b7..1d4113d5 100644 --- a/packages/api/src/services/auth-terminal-sessions.ts +++ b/packages/api/src/services/auth-terminal-sessions.ts @@ -1,5 +1,12 @@ import * as ParseResult from "@effect/schema/ParseResult" import * as Schema from "@effect/schema/Schema" +import { + appendTerminalOutput, + emptyTerminalOutputBuffer, + renderTerminalOutputBuffer, + type TerminalOutputBuffer +} from "@prover-coder-ai/docker-git-terminal/core" +import type { TerminalServerMessage } from "@prover-coder-ai/docker-git-terminal/contracts" import { Either, Effect, Match } from "effect" import { randomUUID } from "node:crypto" import { fileURLToPath } from "node:url" @@ -10,12 +17,6 @@ import { WebSocket, WebSocketServer, type RawData } from "ws" import type { AuthTerminalFlow, AuthTerminalSessionRequest, TerminalSession, TerminalSessionStatus } from "../api/contracts.js" import { ApiConflictError, ApiNotFoundError, describeUnknown } from "../api/errors.js" import { spawnPtyBridge, type PtyBridge } from "./pty-bridge.js" -import { - appendTerminalOutput, - emptyTerminalOutputBuffer, - renderTerminalOutputBuffer, - type TerminalOutputBuffer -} from "./terminal-output-buffer.js" import { attachWebSocketHeartbeat } from "./websocket-heartbeat.js" type TerminalClientMessage = @@ -23,12 +24,6 @@ type TerminalClientMessage = | { readonly type: "resize"; readonly cols: number; readonly rows: number } | { readonly type: "close" } -type TerminalServerMessage = - | { readonly type: "ready"; readonly session: TerminalSession } - | { readonly type: "output"; readonly data: string } - | { readonly type: "exit"; readonly exitCode: number | null; readonly signal: number | null } - | { readonly type: "error"; readonly message: string } - type AuthTerminalRecord = { attachTimeout: ReturnType | null args: ReadonlyArray diff --git a/packages/api/src/services/terminal-image-fetch-core.ts b/packages/api/src/services/terminal-image-fetch-core.ts index b68d17ec..9e2dd5f6 100644 --- a/packages/api/src/services/terminal-image-fetch-core.ts +++ b/packages/api/src/services/terminal-image-fetch-core.ts @@ -1,155 +1,6 @@ -import { fileURLToPath } from "node:url" - -export type TerminalImageFetchPlan = - | { - readonly _tag: "InvalidTerminalImageFetch" - readonly message: string - } - | { - readonly _tag: "ValidTerminalImageFetch" - readonly containerPath: string - readonly mediaType: string - } - -export const terminalImageFetchMaxBytes = 10 * 1024 * 1024 - -const supportedExtensionMediaTypes = new Map([ - ["gif", "image/gif"], - ["jpeg", "image/jpeg"], - ["jpg", "image/jpeg"], - ["png", "image/png"], - ["webp", "image/webp"] -]) - -const controlCharRange = `${String.fromCodePoint(0)}-${String.fromCodePoint(0x1F)}` -const deleteChar = String.fromCodePoint(0x7F) -const invalidCharacterPattern = new RegExp(String.raw`[\s${controlCharRange}${deleteChar}]`, "u") -const traversalPattern = /(?:^|\/)(?:\.|\.\.)(?=\/|$)/u -const urlSchemePattern = /^[A-Za-z][A-Za-z0-9+.-]*:/u -const fileUrlPattern = /^file:\/\//iu -const encodedPathSeparatorPattern = /%(?:2f|5c)/iu -const fileUrlBackslashPattern = /\\/u -const fileUrlTraversalPattern = /(?:^|[\\/])(?:\.|%2e)(?:(?:\.|%2e))?(?=[\\/]|$)/iu - -type TerminalImagePathNormalization = - | { - readonly _tag: "InvalidTerminalImagePath" - readonly message: string - } - | { - readonly _tag: "ValidTerminalImagePath" - readonly path: string - } - -const lowercaseExtension = (path: string): string | null => { - const lastDot = path.lastIndexOf(".") - if (lastDot < 0 || lastDot === path.length - 1) { - return null - } - return path.slice(lastDot + 1).toLowerCase() -} - -const rawFileUrlPathname = (path: string): string => { - const withoutScheme = path.slice("file://".length) - const pathStart = withoutScheme.indexOf("/") - if (pathStart < 0) { - return "" - } - const pathAndSuffix = withoutScheme.slice(pathStart) - const queryStart = pathAndSuffix.indexOf("?") - const hashStart = pathAndSuffix.indexOf("#") - if (queryStart < 0 && hashStart < 0) { - return pathAndSuffix - } - if (queryStart < 0) { - return pathAndSuffix.slice(0, hashStart) - } - if (hashStart < 0) { - return pathAndSuffix.slice(0, queryStart) - } - return pathAndSuffix.slice(0, Math.min(queryStart, hashStart)) -} - -const normalizeTerminalImagePath = (path: string): TerminalImagePathNormalization => { - if (!urlSchemePattern.test(path)) { - return { _tag: "ValidTerminalImagePath", path } - } - if (!fileUrlPattern.test(path)) { - return { _tag: "InvalidTerminalImagePath", message: "Only file:// image URLs are supported." } - } - - const rawPathname = rawFileUrlPathname(path) - if (fileUrlTraversalPattern.test(rawPathname)) { - return { _tag: "InvalidTerminalImagePath", message: "Image path must not contain '.' or '..' segments." } - } - if (encodedPathSeparatorPattern.test(rawPathname) || fileUrlBackslashPattern.test(rawPathname)) { - return { - _tag: "InvalidTerminalImagePath", - message: "Image file URL must not contain encoded or backslash path separators." - } - } - - try { - const url = new URL(path) - if (url.protocol !== "file:" || (url.hostname !== "" && url.hostname !== "localhost")) { - return { _tag: "InvalidTerminalImagePath", message: "Image file URL must point to a local path." } - } - if (url.search.length > 0 || url.hash.length > 0) { - return { _tag: "InvalidTerminalImagePath", message: "Image file URL must not include query or fragment." } - } - return { _tag: "ValidTerminalImagePath", path: fileURLToPath(url, { windows: false }) } - } catch { - return { _tag: "InvalidTerminalImagePath", message: "Image file URL is invalid." } - } -} - -export type TerminalImageFetchOptions = { - readonly baseDir?: string -} - -const isAbsolutePosixPath = (value: string): boolean => value.startsWith("/") - -const joinBaseDirAndRelativePath = (baseDir: string, relativePath: string): string => { - const trimmedBase = baseDir.replace(/\/+$/u, "") - return `${trimmedBase}/${relativePath}` -} - -export const planTerminalImageFetch = ( - path: string, - options: TerminalImageFetchOptions = {} -): TerminalImageFetchPlan => { - if (typeof path !== "string" || path.length === 0) { - return { _tag: "InvalidTerminalImageFetch", message: "Image path is required." } - } - const normalized = normalizeTerminalImagePath(path) - if (normalized._tag === "InvalidTerminalImagePath") { - return { _tag: "InvalidTerminalImageFetch", message: normalized.message } - } - const normalizedPath = normalized.path - let containerPath = normalizedPath - if (!isAbsolutePosixPath(containerPath)) { - const baseDir = options.baseDir - if (baseDir === undefined || !isAbsolutePosixPath(baseDir)) { - return { _tag: "InvalidTerminalImageFetch", message: "Image path must be absolute." } - } - if (invalidCharacterPattern.test(baseDir) || traversalPattern.test(baseDir)) { - return { _tag: "InvalidTerminalImageFetch", message: "Image base directory is invalid." } - } - containerPath = joinBaseDirAndRelativePath(baseDir, containerPath) - } - if (invalidCharacterPattern.test(containerPath)) { - return { _tag: "InvalidTerminalImageFetch", message: "Image path contains invalid characters." } - } - if (traversalPattern.test(containerPath)) { - return { _tag: "InvalidTerminalImageFetch", message: "Image path must not contain '.' or '..' segments." } - } - const extension = lowercaseExtension(containerPath) - if (extension === null) { - return { _tag: "InvalidTerminalImageFetch", message: "Image path must include a file extension." } - } - const mediaType = supportedExtensionMediaTypes.get(extension) - if (mediaType === undefined) { - return { _tag: "InvalidTerminalImageFetch", message: `Unsupported image extension: .${extension}` } - } - return { _tag: "ValidTerminalImageFetch", containerPath, mediaType } -} +export { + planTerminalImageFetch, + terminalImageFetchMaxBytes, + type TerminalImageFetchOptions, + type TerminalImageFetchPlan +} from "@prover-coder-ai/docker-git-terminal/server" diff --git a/packages/api/src/services/terminal-image-paste-core.ts b/packages/api/src/services/terminal-image-paste-core.ts index a24914ce..e1487ec1 100644 --- a/packages/api/src/services/terminal-image-paste-core.ts +++ b/packages/api/src/services/terminal-image-paste-core.ts @@ -1,127 +1,9 @@ -export type TerminalImagePastePayload = { - readonly data: string - readonly mediaType: string - readonly name: string - readonly size: number -} - -export type TerminalImagePastePlan = - | { - readonly _tag: "InvalidTerminalImagePaste" - readonly message: string - } - | { - readonly _tag: "ValidTerminalImagePaste" - readonly containerPath: string - readonly decodedBytes: number - readonly normalizedBase64: string - } - -export const terminalImagePasteDirectory = "/home/dev/.docker-git/pasted-images" -export const terminalImagePasteMaxBytes = 10 * 1024 * 1024 - -const base64Pattern = /^(?:[+/0-9A-Za-z]{4})*(?:[+/0-9A-Za-z]{2}==|[+/0-9A-Za-z]{3}=)?$/u -const terminalImagePasteMaxBase64Length = Math.ceil(terminalImagePasteMaxBytes / 3) * 4 -const safeFileNameMaxLength = 72 - -const imageMediaTypeExtensions = new Map([ - ["image/gif", "gif"], - ["image/jpeg", "jpg"], - ["image/png", "png"], - ["image/webp", "webp"] -]) - -export const isSupportedTerminalImageMediaType = (mediaType: string): boolean => - imageMediaTypeExtensions.has(mediaType.toLowerCase()) - -const extensionForMediaType = (mediaType: string): string | null => - imageMediaTypeExtensions.get(mediaType.toLowerCase()) ?? null - -const normalizeBase64 = (data: string): string => data.replace(/\s+/gu, "") - -const decodedBase64Bytes = (data: string): number | null => { - if (data.length === 0 || data.length % 4 !== 0 || !base64Pattern.test(data)) { - return null - } - const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0 - return data.length / 4 * 3 - padding -} - -const lastPathSegment = (name: string): string => { - const segments = name.split(/[\\/]/u) - return segments.at(-1) ?? "" -} - -export const sanitizeTerminalImageBaseName = (name: string): string => { - const withoutExtension = lastPathSegment(name).replace(/\.[^.]*$/u, "") - const sanitized = withoutExtension - .replace(/[^0-9A-Za-z._-]+/gu, "-") - .replace(/^[.-]+/u, "") - .replace(/[.-]+$/u, "") - .slice(0, safeFileNameMaxLength) - return sanitized.length > 0 ? sanitized : "clipboard-image" -} - -const terminalImageFileName = ( - id: string, - name: string, - mediaType: string -): string | null => { - const extension = extensionForMediaType(mediaType) - if (extension === null) { - return null - } - return `${id}-${sanitizeTerminalImageBaseName(name)}.${extension}` -} - -export const createTerminalImagePastePlan = ( - payload: TerminalImagePastePayload, - id: string -): TerminalImagePastePlan => { - const mediaType = payload.mediaType.toLowerCase() - const fileName = terminalImageFileName(id, payload.name, mediaType) - if (fileName === null) { - return { - _tag: "InvalidTerminalImagePaste", - message: `Unsupported image type: ${payload.mediaType || "unknown"}.` - } - } - if (!Number.isFinite(payload.size) || payload.size <= 0) { - return { - _tag: "InvalidTerminalImagePaste", - message: "Image payload is empty." - } - } - if (payload.size > terminalImagePasteMaxBytes) { - return { - _tag: "InvalidTerminalImagePaste", - message: `Image is too large. Max size is ${terminalImagePasteMaxBytes} bytes.` - } - } - const normalizedBase64 = normalizeBase64(payload.data) - if (normalizedBase64.length > terminalImagePasteMaxBase64Length) { - return { - _tag: "InvalidTerminalImagePaste", - message: `Image is too large. Max size is ${terminalImagePasteMaxBytes} bytes.` - } - } - const decodedBytes = decodedBase64Bytes(normalizedBase64) - if (decodedBytes === null) { - return { - _tag: "InvalidTerminalImagePaste", - message: "Image payload is not valid base64." - } - } - if (decodedBytes !== payload.size) { - return { - _tag: "InvalidTerminalImagePaste", - message: "Image payload size does not match its base64 data." - } - } - return { - _tag: "ValidTerminalImagePaste", - containerPath: `${terminalImagePasteDirectory}/${fileName}`, - decodedBytes, - normalizedBase64 - } -} +export { + createTerminalImagePastePlan, + isSupportedTerminalImageMediaType, + sanitizeTerminalImageBaseName, + terminalImagePasteDirectory, + terminalImagePasteMaxBytes, + type TerminalImagePastePayload, + type TerminalImagePastePlan +} from "@prover-coder-ai/docker-git-terminal/core" diff --git a/packages/api/src/services/terminal-output-buffer.ts b/packages/api/src/services/terminal-output-buffer.ts index ac5ff94b..b1ff662d 100644 --- a/packages/api/src/services/terminal-output-buffer.ts +++ b/packages/api/src/services/terminal-output-buffer.ts @@ -1,68 +1,7 @@ -export type TerminalOutputBuffer = { - readonly charLength: number - readonly chunks: ReadonlyArray -} - -export const terminalOutputReplayMaxChars = 2 * 1024 * 1024 - -export const emptyTerminalOutputBuffer: TerminalOutputBuffer = { - charLength: 0, - chunks: [] -} - -const boundedMaxChars = (maxChars: number): number => - Number.isFinite(maxChars) ? Math.max(0, Math.floor(maxChars)) : 0 - -const trimTerminalOutputChunks = ( - chunks: ReadonlyArray, - overflow: number -): ReadonlyArray => { - const kept: Array = [] - let remainingOverflow = overflow - for (const chunk of chunks) { - if (remainingOverflow <= 0) { - kept.push(chunk) - continue - } - if (chunk.length <= remainingOverflow) { - remainingOverflow -= chunk.length - continue - } - kept.push(chunk.slice(remainingOverflow)) - remainingOverflow = 0 - } - return kept -} - -export const appendTerminalOutput = ( - buffer: TerminalOutputBuffer, - data: string, - maxChars = terminalOutputReplayMaxChars -): TerminalOutputBuffer => { - const boundedMax = boundedMaxChars(maxChars) - if (boundedMax === 0) { - return emptyTerminalOutputBuffer - } - if (data.length === 0) { - return buffer - } - if (data.length >= boundedMax) { - return { - charLength: boundedMax, - chunks: [data.slice(data.length - boundedMax)] - } - } - const charLength = buffer.charLength + data.length - const chunks = [...buffer.chunks, data] - if (charLength <= boundedMax) { - return { charLength, chunks } - } - const overflow = charLength - boundedMax - return { - charLength: boundedMax, - chunks: trimTerminalOutputChunks(chunks, overflow) - } -} - -export const renderTerminalOutputBuffer = (buffer: TerminalOutputBuffer): string => - buffer.chunks.join("") +export { + appendTerminalOutput, + emptyTerminalOutputBuffer, + renderTerminalOutputBuffer, + terminalOutputReplayMaxChars, + type TerminalOutputBuffer +} from "@prover-coder-ai/docker-git-terminal/core" diff --git a/packages/api/src/services/terminal-sessions.ts b/packages/api/src/services/terminal-sessions.ts index 8e2d162d..800df89b 100644 --- a/packages/api/src/services/terminal-sessions.ts +++ b/packages/api/src/services/terminal-sessions.ts @@ -18,6 +18,24 @@ import type * as PlatformPath from "@effect/platform/Path" import { NodeContext } from "@effect/platform-node" import * as ParseResult from "@effect/schema/ParseResult" import * as Schema from "@effect/schema/Schema" +import { + TerminalClientMessageSchema, + type TerminalClientMessage, + type TerminalServerMessage +} from "@prover-coder-ai/docker-git-terminal/contracts" +import { + appendTerminalOutput, + createTerminalImagePastePlan, + emptyTerminalOutputBuffer, + renderTerminalOutputBuffer, + terminalImagePasteDirectory, + type TerminalImagePastePayload, + type TerminalOutputBuffer +} from "@prover-coder-ai/docker-git-terminal/core" +import { + planTerminalImageFetch, + terminalImageFetchMaxBytes +} from "@prover-coder-ai/docker-git-terminal/server" import { Effect, Either } from "effect" import { Buffer } from "node:buffer" import { spawn } from "node:child_process" @@ -32,37 +50,10 @@ import { WebSocket, WebSocketServer, type RawData } from "ws" import type { TerminalSession, TerminalSessionStatus } from "../api/contracts.js" import { ApiBadRequestError, ApiConflictError, ApiInternalError, ApiNotFoundError, describeUnknown } from "../api/errors.js" import { emitProjectEvent, latestProjectCursor } from "./events.js" -import { - planTerminalImageFetch, - terminalImageFetchMaxBytes -} from "./terminal-image-fetch-core.js" -import { - createTerminalImagePastePlan, - terminalImagePasteDirectory, - type TerminalImagePastePayload -} from "./terminal-image-paste-core.js" -import { - appendTerminalOutput, - emptyTerminalOutputBuffer, - renderTerminalOutputBuffer, - type TerminalOutputBuffer -} from "./terminal-output-buffer.js" import { spawnPtyBridge, type PtyBridge } from "./pty-bridge.js" import { getProject, getProjectItemById, getProjectItemByKey, upProject } from "./projects.js" import { attachWebSocketHeartbeat } from "./websocket-heartbeat.js" -type TerminalClientMessage = - | { readonly type: "input"; readonly data: string } - | { readonly type: "resize"; readonly cols: number; readonly rows: number } - | ({ readonly type: "image" } & TerminalImagePastePayload) - | { readonly type: "close" } - -type TerminalServerMessage = - | { readonly type: "ready"; readonly session: TerminalSession } - | { readonly type: "output"; readonly data: string } - | { readonly type: "exit"; readonly exitCode: number | null; readonly signal: number | null } - | { readonly type: "error"; readonly message: string } - type TerminalRecord = { session: TerminalSession pty: PtyBridge | null @@ -120,30 +111,6 @@ const tmuxMissingMessage = "tmux is not available in this project container. Apply docker-git config or rebuild the project image so tmux is installed, then reopen this SSH terminal session." const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu -const TerminalClientMessageSchema = Schema.parseJson( - Schema.Union( - Schema.Struct({ - type: Schema.Literal("input"), - data: Schema.String - }), - Schema.Struct({ - type: Schema.Literal("resize"), - cols: Schema.Number, - rows: Schema.Number - }), - Schema.Struct({ - type: Schema.Literal("image"), - data: Schema.String, - mediaType: Schema.String, - name: Schema.String, - size: Schema.Number - }), - Schema.Struct({ - type: Schema.Literal("close") - }) - ) -) - const DurableTerminalSessionSchema = Schema.Struct({ id: Schema.String, projectId: Schema.String, diff --git a/packages/api/tests/terminal-image-fetch-core.test.ts b/packages/api/tests/terminal-image-fetch-core.test.ts index eebe5764..6aa8a4ac 100644 --- a/packages/api/tests/terminal-image-fetch-core.test.ts +++ b/packages/api/tests/terminal-image-fetch-core.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest" -import { planTerminalImageFetch } from "../src/services/terminal-image-fetch-core.js" +import { planTerminalImageFetch } from "@prover-coder-ai/docker-git-terminal/server" describe("terminal image fetch core", () => { it("continues to accept an absolute path with a supported image extension", () => { diff --git a/packages/api/tests/terminal-image-paste-core.test.ts b/packages/api/tests/terminal-image-paste-core.test.ts index d21acd6e..4b533492 100644 --- a/packages/api/tests/terminal-image-paste-core.test.ts +++ b/packages/api/tests/terminal-image-paste-core.test.ts @@ -5,7 +5,7 @@ import { sanitizeTerminalImageBaseName, terminalImagePasteDirectory, terminalImagePasteMaxBytes -} from "../src/services/terminal-image-paste-core.js" +} from "@prover-coder-ai/docker-git-terminal/core" describe("terminal image paste core", () => { it("creates a safe project-container path for supported images", () => { diff --git a/packages/api/tests/terminal-output-buffer.test.ts b/packages/api/tests/terminal-output-buffer.test.ts index 476d561e..6a71d5fe 100644 --- a/packages/api/tests/terminal-output-buffer.test.ts +++ b/packages/api/tests/terminal-output-buffer.test.ts @@ -4,7 +4,7 @@ import { appendTerminalOutput, emptyTerminalOutputBuffer, renderTerminalOutputBuffer -} from "../src/services/terminal-output-buffer.js" +} from "@prover-coder-ai/docker-git-terminal/core" describe("terminal output replay buffer", () => { it("replays appended terminal output in order", () => { diff --git a/packages/app/package.json b/packages/app/package.json index 9cd545d8..a7fb6d52 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -13,7 +13,7 @@ "doc": "doc" }, "scripts": { - "prebuild": "bun run --cwd ../docker-git-session-sync build && bun run --cwd ../lib build", + "prebuild": "bun run --cwd ../docker-git-session-sync build && bun run --cwd ../terminal build && bun run --cwd ../lib build", "build": "bun run build:app && bun run build:docker-git", "build:app": "vite build --ssr src/app/main.ts", "build:web": "vite build --config vite.web.config.ts", @@ -22,13 +22,13 @@ "dev": "vite build --watch --ssr src/app/main.ts", "dev:web": "vite --config vite.web.config.ts", "serve:web": "bun scripts/serve-dist-web.mjs", - "prelint": "bun run --cwd ../docker-git-session-sync build && bun run --cwd ../lib build", + "prelint": "bun run --cwd ../docker-git-session-sync build && bun run --cwd ../terminal build && bun run --cwd ../lib build", "lint": "NODE_OPTIONS=--max-old-space-size=4096 PATH=../../scripts:$PATH vibecode-linter src/", "lint:tests": "NODE_OPTIONS=--max-old-space-size=4096 PATH=../../scripts:$PATH vibecode-linter tests/", "lint:effect": "NODE_OPTIONS=--max-old-space-size=4096 PATH=../../scripts:$PATH eslint --config eslint.effect-ts-check.config.mjs .", - "prebuild:docker-git": "bun install --cwd ../.. && bun run --cwd ../docker-git-session-sync build && bun run --cwd ../lib build", + "prebuild:docker-git": "bun install --cwd ../.. && bun run --cwd ../docker-git-session-sync build && bun run --cwd ../terminal build && bun run --cwd ../lib build", "build:docker-git": "vite build --config vite.docker-git.config.ts", - "prebuild:docker-git:reuse-install": "bun run --cwd ../docker-git-session-sync build && bun run --cwd ../lib build", + "prebuild:docker-git:reuse-install": "bun run --cwd ../docker-git-session-sync build && bun run --cwd ../terminal build && bun run --cwd ../lib build", "build:docker-git:reuse-install": "vite build --config vite.docker-git.config.ts", "check": "bun run typecheck", "clone": "bun run build:docker-git && bun dist/src/docker-git/main.js clone", @@ -37,9 +37,9 @@ "list": "bun run build:docker-git && bun dist/src/docker-git/main.js ps", "preview:web": "vite preview --config vite.web.config.ts", "start": "bun run build:docker-git && bun dist/src/docker-git/main.js", - "pretest": "bun run --cwd ../docker-git-session-sync build && bun run --cwd ../lib build", + "pretest": "bun run --cwd ../docker-git-session-sync build && bun run --cwd ../terminal build && bun run --cwd ../lib build", "test": "bun run lint:tests && vitest run", - "pretypecheck": "bun run --cwd ../docker-git-session-sync build && bun run --cwd ../lib build", + "pretypecheck": "bun run --cwd ../docker-git-session-sync build && bun run --cwd ../terminal build && bun run --cwd ../lib build", "typecheck": "tsc --noEmit" }, "repository": { @@ -83,9 +83,7 @@ "react": "^19.2.6", "react-dom": "^19.2.6", "react-reconciler": "^0.33.0", - "ts-morph": "^28.0.0", - "xterm": "^5.3.0", - "xterm-addon-fit": "^0.8.0" + "ts-morph": "^28.0.0" }, "devDependencies": { "@biomejs/biome": "^2.4.16", @@ -97,6 +95,7 @@ "@eslint/compat": "2.1.0", "@eslint/eslintrc": "3.3.5", "@eslint/js": "10.0.1", + "@prover-coder-ai/docker-git-terminal": "workspace:*", "@prover-coder-ai/eslint-plugin-suggest-members": "^0.0.26", "@ton-ai-core/vibecode-linter": "^1.0.11", "@types/node": "^25.9.1", diff --git a/packages/app/src/docker-git/api-terminal-codec.ts b/packages/app/src/docker-git/api-terminal-codec.ts index c14aaab7..4e27c18e 100644 --- a/packages/app/src/docker-git/api-terminal-codec.ts +++ b/packages/app/src/docker-git/api-terminal-codec.ts @@ -1,4 +1,4 @@ -import type { TerminalSession } from "../shared/terminal-session-schema.js" +import type { TerminalSession } from "@prover-coder-ai/docker-git-terminal/contracts" import { asObject, asString, type JsonValue } from "./api-json.js" export type ApiTerminalSession = TerminalSession diff --git a/packages/app/src/docker-git/terminal-session-client.ts b/packages/app/src/docker-git/terminal-session-client.ts index f1e89e8b..bf69eefc 100644 --- a/packages/app/src/docker-git/terminal-session-client.ts +++ b/packages/app/src/docker-git/terminal-session-client.ts @@ -1,7 +1,7 @@ import * as ParseResult from "@effect/schema/ParseResult" +import { type TerminalServerMessage, TerminalServerMessageSchema } from "@prover-coder-ai/docker-git-terminal/contracts" import { Effect, Either } from "effect" -import { type TerminalServerMessage, TerminalServerMessageSchema } from "../shared/terminal-session-schema.js" import type { ApiTerminalSession } from "./api-client.js" import { resolveApiBaseUrl } from "./controller.js" import { writeToTerminal } from "./menu-shared.js" diff --git a/packages/app/src/lib/core/templates-prompt.ts b/packages/app/src/lib/core/templates-prompt.ts index d372566d..62f5f852 100644 --- a/packages/app/src/lib/core/templates-prompt.ts +++ b/packages/app/src/lib/core/templates-prompt.ts @@ -21,8 +21,65 @@ const dockerGitTerminalSanitizeShell = String.raw`docker_git_terminal_write_esca fi return 1 } +docker_git_terminal_process_args() { + ps -o args= -p "$1" 2>/dev/null || true +} +docker_git_terminal_parent_pid() { + ps -o ppid= -p "$1" 2>/dev/null | tr -d '[:space:]' +} +docker_git_terminal_command_basename() { + local command_line="$1" + printf "%s\n" "$command_line" | awk '{ name = $1; sub(/^.*\//, "", name); print name; exit }' +} +docker_git_terminal_is_agent_command() { + local command_name + command_name="$(docker_git_terminal_command_basename "$1")" + case "$command_name" in + .docker-git-claude-real|claude|codex|opencode|gemini|grok) + return 0 + ;; + *) + return 1 + ;; + esac +} +docker_git_terminal_has_agent_ancestor() { + local pid="$1" + local depth=0 + local command_line="" + local parent_pid="" + if [ -z "$pid" ]; then + pid="$$" + fi + while [ -n "$pid" ] && [ "$pid" != "0" ] && [ "$depth" -lt 32 ]; do + command_line="$(docker_git_terminal_process_args "$pid")" + if docker_git_terminal_is_agent_command "$command_line"; then + return 0 + fi + parent_pid="$(docker_git_terminal_parent_pid "$pid")" + if [ -z "$parent_pid" ] || [ "$parent_pid" = "$pid" ]; then + return 1 + fi + pid="$parent_pid" + depth=$((depth + 1)) + done + return 1 +} +docker_git_terminal_should_sanitize() { + if [ -n "$(printenv DOCKER_GIT_TERMINAL_FORCE_SANITIZE 2>/dev/null)" ]; then + return 0 + fi + if [ -n "$(printenv DOCKER_GIT_TERMINAL_DISABLE_SANITIZE 2>/dev/null)" ]; then + return 1 + fi + if docker_git_terminal_has_agent_ancestor "$$"; then + return 1 + fi + return 0 +} docker_git_terminal_sanitize() { # Recover interactive TTY settings after abrupt exits from fullscreen/raw-mode tools. + docker_git_terminal_should_sanitize || return 0 if [ -c /dev/tty ]; then { stty sane < /dev/tty > /dev/tty; } 2>/dev/null || { stty sane < /dev/tty; } 2>/dev/null || true elif [ -t 0 ]; then @@ -108,7 +165,7 @@ else PROMPT_COMMAND="docker_git_prompt_apply" fi docker_git_terminal_sanitize -trap 'docker_git_terminal_sanitize' EXIT INT TERM` +trap 'docker_git_terminal_sanitize' EXIT` export const renderPromptScript = (): string => dockerGitPromptScript diff --git a/packages/app/src/lib/core/templates-zsh.ts b/packages/app/src/lib/core/templates-zsh.ts index 635a787d..92b8c875 100644 --- a/packages/app/src/lib/core/templates-zsh.ts +++ b/packages/app/src/lib/core/templates-zsh.ts @@ -100,16 +100,6 @@ docker_git_terminal_sanitize add-zsh-hook precmd docker_git_prompt_apply add-zsh-hook zshexit docker_git_terminal_on_exit -TRAPINT() { - docker_git_terminal_sanitize - return 130 -} - -TRAPTERM() { - docker_git_terminal_sanitize - return 143 -} - HISTFILE="\${HISTFILE:-$HOME/.zsh_history}" HISTSIZE="\${HISTSIZE:-10000}" SAVEHIST="\${SAVEHIST:-20000}" diff --git a/packages/app/src/shared/terminal-session-schema.ts b/packages/app/src/shared/terminal-session-schema.ts index 8f29ab0a..a1c4716b 100644 --- a/packages/app/src/shared/terminal-session-schema.ts +++ b/packages/app/src/shared/terminal-session-schema.ts @@ -1,44 +1,11 @@ -import * as Schema from "@effect/schema/Schema" - -export const TerminalSessionSchema = Schema.Struct({ - id: Schema.String, - projectId: Schema.String, - sshCommand: Schema.String, - status: Schema.Union( - Schema.Literal("ready"), - Schema.Literal("attached"), - Schema.Literal("exited"), - Schema.Literal("failed") - ), - createdAt: Schema.String, - attachedClients: Schema.optional(Schema.Number), - startedAt: Schema.optional(Schema.String), - closedAt: Schema.optional(Schema.String), - exitCode: Schema.optional(Schema.Number), - signal: Schema.optional(Schema.Number) -}) - -const TerminalServerMessagePayloadSchema = Schema.Union( - Schema.Struct({ - type: Schema.Literal("ready"), - session: TerminalSessionSchema - }), - Schema.Struct({ - type: Schema.Literal("output"), - data: Schema.String - }), - Schema.Struct({ - type: Schema.Literal("exit"), - exitCode: Schema.NullOr(Schema.Number), - signal: Schema.NullOr(Schema.Number) - }), - Schema.Struct({ - type: Schema.Literal("error"), - message: Schema.String - }) -) - -export const TerminalServerMessageSchema = Schema.parseJson(TerminalServerMessagePayloadSchema) - -export type TerminalSession = Schema.Schema.Type -export type TerminalServerMessage = Schema.Schema.Type +export { + type TerminalClientMessage, + TerminalClientMessagePayloadSchema, + TerminalClientMessageSchema, + type TerminalServerMessage, + TerminalServerMessageSchema, + type TerminalSession, + TerminalSessionSchema, + type TerminalSessionStatus, + TerminalSessionStatusSchema +} from "@prover-coder-ai/docker-git-terminal/contracts" diff --git a/packages/app/src/web/api-terminal-schema.ts b/packages/app/src/web/api-terminal-schema.ts index 1dbd085e..bc100bef 100644 --- a/packages/app/src/web/api-terminal-schema.ts +++ b/packages/app/src/web/api-terminal-schema.ts @@ -1,6 +1,6 @@ import * as Schema from "@effect/schema/Schema" +import { TerminalSessionSchema } from "@prover-coder-ai/docker-git-terminal/contracts" -import { TerminalSessionSchema } from "../shared/terminal-session-schema.js" import { ProjectDetailsSchema } from "./api-project-schema.js" export const TerminalSessionResponseSchema = Schema.Struct({ @@ -29,4 +29,4 @@ export const AuthTerminalSessionResponseSchema = Schema.Struct({ session: TerminalSessionSchema }) -export { TerminalServerMessageSchema } from "../shared/terminal-session-schema.js" +export { TerminalServerMessageSchema } from "@prover-coder-ai/docker-git-terminal/contracts" diff --git a/packages/app/src/web/api-types.ts b/packages/app/src/web/api-types.ts index 98b7677d..0d564832 100644 --- a/packages/app/src/web/api-types.ts +++ b/packages/app/src/web/api-types.ts @@ -96,4 +96,4 @@ export type ProjectAuthFlow = | "ProjectGrokConnect" | "ProjectGrokDisconnect" -export { type TerminalServerMessage, type TerminalSession } from "../shared/terminal-session-schema.js" +export { type TerminalServerMessage, type TerminalSession } from "@prover-coder-ai/docker-git-terminal/contracts" diff --git a/packages/app/src/web/panel-terminal-header.tsx b/packages/app/src/web/panel-terminal-header.tsx index 97892076..cce54cfd 100644 --- a/packages/app/src/web/panel-terminal-header.tsx +++ b/packages/app/src/web/panel-terminal-header.tsx @@ -1,189 +1 @@ -import type { JSX } from "react" - -import { - closeButtonStyle, - compactCloseButtonStyle, - compactHeaderActionsStyle, - compactHeaderStyle, - compactHeaderTitleStyle, - compactStatusStyle, - headerActionsStyle, - headerStatusStyle, - headerStyle, - headerSubtitleStyle, - headerTitleStyle -} from "./panel-terminal-styles.js" -import type { TerminalPanelProps } from "./panel-terminal-types.js" -import type { TerminalStatus } from "./terminal-panel-runtime.js" - -type TerminalHeaderProps = - & Pick< - TerminalPanelProps, - | "onApplyProject" - | "onDetach" - | "onKill" - | "onOpenBrowser" - | "onOpenSkiller" - | "onOpenTaskManager" - | "onOpenTerminal" - | "session" - > - & { - readonly compactHeaderMode: boolean - readonly inlineImagePreviewsEnabled: boolean - readonly onToggleInlineImagePreviews: () => void - readonly status: TerminalStatus - } - -type TerminalActionDescriptor = { - readonly compactLabel: string - readonly label: string - readonly onClick: (() => void) | undefined -} - -const TerminalHeaderTitle = ( - { - compactHeaderMode, - session, - status - }: Pick & { - readonly compactHeaderMode: boolean - readonly status: TerminalStatus - } -): JSX.Element => - compactHeaderMode - ? ( -
-
- {session.browserProjectName ?? session.header} -
-
{status}
-
- ) - : ( -
-
{session.header}
-
{status}
-
{session.subtitle}
-
- ) - -const TerminalActionButton = ( - { - children, - compactTypingMode, - onClick, - pressed, - title - }: { - readonly children: string - readonly compactTypingMode: boolean - readonly onClick: () => void - readonly pressed?: boolean - readonly title?: string - } -): JSX.Element => ( - -) - -const optionalProjectActions = ( - props: TerminalHeaderProps -): ReadonlyArray => { - if (props.session.browserProjectId === undefined) { - return [] - } - return [ - { compactLabel: "Browser", label: "Open browser", onClick: props.onOpenBrowser }, - { compactLabel: "Skiller", label: "Skiller", onClick: props.onOpenSkiller }, - { compactLabel: "Apply", label: "Apply", onClick: props.onApplyProject }, - { compactLabel: "Tasks", label: "Task manager", onClick: props.onOpenTaskManager }, - { compactLabel: "New", label: "New terminal", onClick: props.onOpenTerminal } - ] -} - -const TerminalProjectActionButtons = ( - { - actions, - compactHeaderMode - }: { - readonly actions: ReadonlyArray - readonly compactHeaderMode: boolean - } -): JSX.Element => ( - <> - {actions.map((action) => - action.onClick === undefined - ? null - : ( - - {compactHeaderMode ? action.compactLabel : action.label} - - ) - )} - -) - -const TerminalImageToggleButton = ( - { - compactHeaderMode, - inlineImagePreviewsEnabled, - onToggleInlineImagePreviews - }: Pick -): JSX.Element => { - const label = inlineImagePreviewsEnabled ? "Images on" : "Images off" - const compactLabel = inlineImagePreviewsEnabled ? "Img on" : "Img off" - const title = inlineImagePreviewsEnabled - ? "Automatic image previews enabled" - : "Automatic image previews disabled" - - return ( - - {compactHeaderMode ? compactLabel : label} - - ) -} - -const TerminalHeaderActions = (props: TerminalHeaderProps): JSX.Element => ( -
- - - - Detach - - - Kill - -
-) - -export const TerminalHeader = (props: TerminalHeaderProps): JSX.Element => ( -
- - -
-) +export * from "@prover-coder-ai/docker-git-terminal/web/panel-terminal-header" diff --git a/packages/app/src/web/panel-terminal-mobile-controls.tsx b/packages/app/src/web/panel-terminal-mobile-controls.tsx index 8f38bab8..5bb7832a 100644 --- a/packages/app/src/web/panel-terminal-mobile-controls.tsx +++ b/packages/app/src/web/panel-terminal-mobile-controls.tsx @@ -1,177 +1 @@ -import type { JSX } from "react" - -import { - mobileArrowRowStyle, - mobileControlButtonStyle, - mobileControlsCollapsedStyle, - mobileControlsRowStyle, - mobileControlsStyle -} from "./panel-terminal-styles.js" -import { - isModifierOnlyTerminalKey, - type MobileTerminalKey, - mobileTerminalKeyInput, - terminalControlCharacterForKey -} from "./terminal-mobile-controls.js" -import type { TerminalInputController } from "./terminal-panel-runtime.js" - -type MobileTerminalControlsProps = { - readonly collapsed: boolean - readonly compactTypingMode: boolean - readonly ctrlArmed: boolean - readonly onKeyPress: (key: MobileTerminalKey) => void - readonly onToggleCollapsed: () => void - readonly onToggleCtrl: () => void -} - -type MobileTerminalArrowKey = Extract - -const mobileTerminalArrowKeys: ReadonlyArray = ["left", "up", "down", "right"] - -const mobileTerminalArrowLabels: Readonly> = { - down: "↓", - left: "←", - right: "→", - up: "↑" -} - -export const retainTerminalFocus = (controller: TerminalInputController | null): void => { - controller?.focus() -} - -export const sendTerminalMobileInput = ( - controller: TerminalInputController | null, - key: MobileTerminalKey -): void => { - controller?.sendInput(mobileTerminalKeyInput(key)) - retainTerminalFocus(controller) -} - -export const shouldKeepMobileCtrlArmed = (event: KeyboardEvent): boolean => - event.metaKey || event.altKey || event.ctrlKey || event.isComposing || isModifierOnlyTerminalKey(event.key) - -export const sendMobileCtrlEventInput = ( - controller: TerminalInputController | null, - event: KeyboardEvent -): void => { - const controlCharacter = terminalControlCharacterForKey(event.key) - if (controlCharacter === null) { - return - } - event.preventDefault() - event.stopPropagation() - controller?.sendInput(controlCharacter) - retainTerminalFocus(controller) -} - -const MobileTerminalControlButton = ( - { - active = false, - label, - onClick - }: { - readonly active?: boolean - readonly label: string - readonly onClick: () => void - } -): JSX.Element => ( - -) - -const MobileCommandControlsRow = ( - { - ctrlArmed, - onKeyPress, - onToggleCollapsed, - onToggleCtrl - }: Pick -): JSX.Element => ( -
- { - onKeyPress("escape") - }} - /> - { - onKeyPress("tab") - }} - /> - - { - onKeyPress("ctrl-c") - }} - /> - -
-) - -const MobileArrowControlsRow = ( - { onKeyPress }: Pick -): JSX.Element => ( -
- {mobileTerminalArrowKeys.map((key) => ( - { - onKeyPress(key) - }} - /> - ))} -
-) - -const CollapsedMobileTerminalControls = ( - { compactTypingMode, onToggleCollapsed }: Pick< - MobileTerminalControlsProps, - "compactTypingMode" | "onToggleCollapsed" - > -): JSX.Element => ( -
- -
-) - -const ExpandedMobileTerminalControls = (props: Omit): JSX.Element => ( -
- - -
-) - -export const MobileTerminalControls = (props: MobileTerminalControlsProps): JSX.Element => - props.collapsed - ? ( - - ) - : ( - - ) +export * from "@prover-coder-ai/docker-git-terminal/web/panel-terminal-mobile-controls" diff --git a/packages/app/src/web/panel-terminal-styles.ts b/packages/app/src/web/panel-terminal-styles.ts index 4cef64d7..a64fc93b 100644 --- a/packages/app/src/web/panel-terminal-styles.ts +++ b/packages/app/src/web/panel-terminal-styles.ts @@ -1,218 +1 @@ -import type { CSSProperties } from "react" - -import type { TerminalStatus } from "./terminal-panel-runtime.js" - -const panelStyle: CSSProperties = { - border: "1px solid #3a4652", - borderRadius: "8px", - display: "flex", - flex: 1, - flexDirection: "column", - minHeight: 0, - overflow: "hidden" -} - -export const terminalPanelStyle = (mobileMode: boolean, keyboardOpen: boolean): CSSProperties => ({ - ...panelStyle, - marginTop: mobileMode || keyboardOpen ? 0 : "8px" -}) - -export const headerStyle: CSSProperties = { - alignItems: "stretch", - background: "#101419", - borderBottom: "1px solid #3a4652", - display: "flex", - flexDirection: "column", - gap: "8px", - justifyContent: "flex-start", - padding: "10px 12px" -} - -export const compactHeaderStyle: CSSProperties = { - ...headerStyle, - alignItems: "center", - flexDirection: "row", - flexWrap: "wrap", - gap: "6px", - overflow: "visible", - padding: "5px 6px" -} - -const bodyStyle: CSSProperties = { - background: "#080a0d", - flex: 1, - minHeight: 0, - padding: "8px" -} - -const bodyStyleMobile: CSSProperties = { - ...bodyStyle, - padding: "2px" -} - -const bodyStyleKeyboardOpen: CSSProperties = { - ...bodyStyle, - padding: 0 -} - -const terminalBodyStyle = (compactTypingMode: boolean, mobileMode: boolean): CSSProperties => { - if (compactTypingMode) { - return bodyStyleKeyboardOpen - } - return mobileMode ? bodyStyleMobile : bodyStyle -} - -export const terminalBodyFrameStyle = (compactTypingMode: boolean, mobileMode: boolean): CSSProperties => ({ - ...terminalBodyStyle(compactTypingMode, mobileMode), - boxSizing: "border-box", - overflow: "hidden", - position: "relative" -}) - -export const terminalHostStyle: CSSProperties = { - height: "100%", - minHeight: 0, - overflow: "hidden" -} - -export const terminalBodyContentStyle: CSSProperties = { - bottom: 0, - height: "100%", - left: 0, - minHeight: 0, - overflow: "auto", - position: "absolute", - right: 0, - top: 0, - zIndex: 1 -} - -export const closeButtonStyle: CSSProperties = { - background: "#171d24", - border: "1px solid #3a4652", - borderRadius: "8px", - color: "#d6e5f7", - cursor: "pointer", - font: "inherit", - padding: "6px 10px" -} - -export const compactCloseButtonStyle: CSSProperties = { - ...closeButtonStyle, - fontSize: "11px", - padding: "4px 6px" -} - -export const headerActionsStyle: CSSProperties = { - alignItems: "center", - display: "flex", - flexShrink: 0, - flexWrap: "wrap", - gap: "8px", - justifyContent: "flex-start", - width: "100%" -} - -export const compactHeaderActionsStyle: CSSProperties = { - ...headerActionsStyle, - flexWrap: "wrap", - gap: "4px", - justifyContent: "flex-end", - marginLeft: "auto", - width: "auto" -} - -export const mobileControlsCollapsedStyle: CSSProperties = { - alignItems: "center", - background: "#0d1218", - borderTop: "1px solid #3a4652", - display: "flex", - flexShrink: 0, - justifyContent: "flex-end", - padding: "8px" -} - -export const mobileControlsStyle: CSSProperties = { - background: "#0d1218", - borderTop: "1px solid #3a4652", - display: "flex", - flexDirection: "column", - flexShrink: 0, - gap: "8px", - padding: "8px" -} - -export const mobileControlsRowStyle: CSSProperties = { - display: "grid", - gap: "8px", - gridTemplateColumns: "repeat(5, minmax(0, 1fr))" -} - -export const mobileArrowRowStyle: CSSProperties = { - display: "grid", - gap: "8px", - gridTemplateColumns: "repeat(4, minmax(0, 1fr))" -} - -export const mobileControlButtonStyle = (active = false): CSSProperties => ({ - background: active ? "#1d3550" : "#121a23", - border: `1px solid ${active ? "#78f0a3" : "#3a4652"}`, - borderRadius: "8px", - color: active ? "#e8fff0" : "#d6e5f7", - cursor: "pointer", - font: "inherit", - fontWeight: 600, - minHeight: "40px", - padding: "8px 10px" -}) - -const statusColor = (status: TerminalStatus): string => { - if (status === "attached") { - return "#56f39a" - } - if (status === "error") { - return "#ff8f8f" - } - if (status === "exited") { - return "#ffd166" - } - return "#8fd3ff" -} - -export const compactHeaderTitleStyle: CSSProperties = { - color: "#f6fbff", - flex: 1, - fontWeight: 700, - lineHeight: 1.2, - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap" -} - -export const compactStatusStyle = (status: TerminalStatus): CSSProperties => ({ - color: statusColor(status), - flexShrink: 0, - fontSize: "11px", - whiteSpace: "nowrap" -}) - -export const headerTitleStyle: CSSProperties = { - color: "#f6fbff", - fontWeight: 700, - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap" -} - -export const headerStatusStyle = (status: TerminalStatus): CSSProperties => ({ - color: statusColor(status), - whiteSpace: "nowrap" -}) - -export const headerSubtitleStyle: CSSProperties = { - color: "#8fa6c4", - fontSize: "12px", - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap" -} +export * from "@prover-coder-ai/docker-git-terminal/web/panel-terminal-styles" diff --git a/packages/app/src/web/panel-terminal-types.ts b/packages/app/src/web/panel-terminal-types.ts index 6915d120..a4824ec2 100644 --- a/packages/app/src/web/panel-terminal-types.ts +++ b/packages/app/src/web/panel-terminal-types.ts @@ -1,21 +1 @@ -import type { JSX } from "react" - -import type { TerminalExitInfo } from "./terminal-panel-runtime.js" -import type { ActiveTerminalSession } from "./terminal.js" - -export type TerminalPanelProps = { - readonly keyboardOpen: boolean - readonly mobileMode: boolean - readonly onAttachFailure: () => void - readonly onApplyProject?: (() => void) | undefined - readonly onDetach: () => void - readonly onExit?: ((info: TerminalExitInfo) => void) | undefined - readonly onKill: () => void - readonly onMessage: (message: string) => void - readonly onOpenBrowser?: (() => void) | undefined - readonly onOpenSkiller?: (() => void) | undefined - readonly onOpenTaskManager?: (() => void) | undefined - readonly onOpenTerminal?: (() => void) | undefined - readonly session: ActiveTerminalSession - readonly bodyContent?: JSX.Element | undefined -} +export * from "@prover-coder-ai/docker-git-terminal/web/panel-terminal-types" diff --git a/packages/app/src/web/panel-terminal.tsx b/packages/app/src/web/panel-terminal.tsx index e673b885..014699c6 100644 --- a/packages/app/src/web/panel-terminal.tsx +++ b/packages/app/src/web/panel-terminal.tsx @@ -1,310 +1,4 @@ -import "xterm/css/xterm.css" +import "./terminal.js" -import { type JSX, useCallback, useEffect, useRef, useState } from "react" - -import { TerminalHeader } from "./panel-terminal-header.js" -import { - MobileTerminalControls, - retainTerminalFocus, - sendMobileCtrlEventInput, - sendTerminalMobileInput, - shouldKeepMobileCtrlArmed -} from "./panel-terminal-mobile-controls.js" -import { - terminalBodyContentStyle, - terminalBodyFrameStyle, - terminalHostStyle, - terminalPanelStyle -} from "./panel-terminal-styles.js" -import type { TerminalPanelProps } from "./panel-terminal-types.js" -import type { MobileTerminalKey } from "./terminal-mobile-controls.js" -import { resolveTerminalCompactHeaderMode, resolveTerminalTypingMode } from "./terminal-mobile-layout.js" -import { - type TerminalConnectionState, - type TerminalExitInfo, - type TerminalInputController, - type TerminalStatus, - useTerminalSessionLifecycle -} from "./terminal-panel-runtime.js" -import { type ActiveTerminalSession, isPendingActiveTerminalSession } from "./terminal.js" - -type RefState = { current: T } - -type TerminalNotificationHandlers = { - readonly notifyAttachFailure: () => void - readonly notifyExit: (info: TerminalExitInfo) => void - readonly notifyMessage: (message: string) => void -} - -type InlineImagePreviewState = { - readonly inlineImagePreviewsEnabled: boolean - readonly inlineImagePreviewsEnabledRef: RefState - readonly toggleInlineImagePreviews: () => void -} - -type MobileTerminalControlState = { - readonly handleMobileKeyPress: (key: MobileTerminalKey) => void - readonly mobileControlsCollapsed: boolean - readonly mobileCtrlArmed: boolean - readonly toggleMobileControls: () => void - readonly toggleMobileCtrl: () => void -} - -type TerminalPanelLayoutProps = - & Pick< - TerminalPanelProps, - | "bodyContent" - | "keyboardOpen" - | "mobileMode" - | "onApplyProject" - | "onOpenBrowser" - | "onOpenSkiller" - | "onOpenTaskManager" - | "onOpenTerminal" - | "session" - > - & InlineImagePreviewState - & MobileTerminalControlState - & { - readonly compactHeaderMode: boolean - readonly compactTypingMode: boolean - readonly handleDetach: () => void - readonly handleKill: () => void - readonly hostRef: RefState - readonly status: TerminalStatus - } - -const resolveInitialTerminalStatus = (session: ActiveTerminalSession): TerminalStatus => - isPendingActiveTerminalSession(session) && session.pendingConnection.phase === "error" ? "error" : "connecting" - -const useTerminalNotificationHandlers = ( - { onAttachFailure, onExit, onMessage }: Pick -): TerminalNotificationHandlers => { - const onAttachFailureRef = useRef(onAttachFailure) - const onExitRef = useRef(onExit) - const onMessageRef = useRef(onMessage) - useEffect(() => { - onAttachFailureRef.current = onAttachFailure - }, [onAttachFailure]) - useEffect(() => { - onExitRef.current = onExit - }, [onExit]) - useEffect(() => { - onMessageRef.current = onMessage - }, [onMessage]) - return { - notifyAttachFailure: useCallback(() => { - onAttachFailureRef.current() - }, []), - notifyExit: useCallback((info: TerminalExitInfo) => { - onExitRef.current?.(info) - }, []), - notifyMessage: useCallback((message: string) => { - onMessageRef.current(message) - }, []) - } -} - -const useInlineImagePreviewState = ( - runtimeRef: RefState, - terminalSessionId: string -): InlineImagePreviewState => { - const inlineImagePreviewsEnabledRef = useRef(true) - const [inlineImagePreviewsEnabled, setInlineImagePreviewsEnabled] = useState(true) - useEffect(() => { - inlineImagePreviewsEnabledRef.current = true - setInlineImagePreviewsEnabled(true) - }, [terminalSessionId]) - const toggleInlineImagePreviews = useCallback(() => { - setInlineImagePreviewsEnabled((current) => { - const next = !current - inlineImagePreviewsEnabledRef.current = next - return next - }) - retainTerminalFocus(runtimeRef.current) - }, [runtimeRef]) - - return { inlineImagePreviewsEnabled, inlineImagePreviewsEnabledRef, toggleInlineImagePreviews } -} - -const useMobileCtrlKeyboard = ( - { - hostRef, - mobileCtrlArmed, - mobileMode, - runtimeRef, - setMobileCtrlArmed - }: { - readonly hostRef: RefState - readonly mobileCtrlArmed: boolean - readonly mobileMode: boolean - readonly runtimeRef: RefState - readonly setMobileCtrlArmed: (armed: boolean) => void - } -): void => { - useEffect(() => { - if (!mobileMode || !mobileCtrlArmed || hostRef.current === null) { - return - } - const handleKeyDown = (event: KeyboardEvent): void => { - if (event.key === "Escape") { - setMobileCtrlArmed(false) - return - } - if (shouldKeepMobileCtrlArmed(event)) { - return - } - setMobileCtrlArmed(false) - sendMobileCtrlEventInput(runtimeRef.current, event) - } - const host = hostRef.current - host.addEventListener("keydown", handleKeyDown, true) - return () => { - host.removeEventListener("keydown", handleKeyDown, true) - } - }, [hostRef, mobileCtrlArmed, mobileMode, runtimeRef, setMobileCtrlArmed]) -} - -const useMobileTerminalControlState = ( - mobileMode: boolean, - hostRef: RefState, - runtimeRef: RefState -): MobileTerminalControlState => { - const [mobileControlsCollapsed, setMobileControlsCollapsed] = useState(false) - const [mobileCtrlArmed, setMobileCtrlArmed] = useState(false) - useEffect(() => { - if (!mobileMode) { - setMobileControlsCollapsed(false) - setMobileCtrlArmed(false) - } - }, [mobileMode]) - useMobileCtrlKeyboard({ hostRef, mobileCtrlArmed, mobileMode, runtimeRef, setMobileCtrlArmed }) - const handleMobileKeyPress = useCallback((key: MobileTerminalKey) => { - if (key === "ctrl-c") { - setMobileCtrlArmed(false) - } - sendTerminalMobileInput(runtimeRef.current, key) - }, [runtimeRef]) - const toggleMobileControls = useCallback(() => { - setMobileControlsCollapsed((current) => !current) - setMobileCtrlArmed(false) - retainTerminalFocus(runtimeRef.current) - }, [runtimeRef]) - const toggleMobileCtrl = useCallback(() => { - setMobileCtrlArmed((current) => !current) - retainTerminalFocus(runtimeRef.current) - }, [runtimeRef]) - - return { handleMobileKeyPress, mobileControlsCollapsed, mobileCtrlArmed, toggleMobileControls, toggleMobileCtrl } -} - -const useTerminalCloseActions = ( - connectionRef: RefState, - onDetach: () => void, - onKill: () => void -): { readonly handleDetach: () => void; readonly handleKill: () => void } => ({ - handleDetach: useCallback(() => { - connectionRef.current.closing = true - onDetach() - }, [connectionRef, onDetach]), - handleKill: useCallback(() => { - connectionRef.current.closing = true - onKill() - }, [connectionRef, onKill]) -}) - -const TerminalPanelBody = ( - { - bodyContent, - compactTypingMode, - hostRef, - mobileMode - }: Pick -): JSX.Element => ( -
-
- {bodyContent === undefined ? null :
{bodyContent}
} -
-) - -const TerminalPanelMobileControls = (props: TerminalPanelLayoutProps): JSX.Element | null => - props.mobileMode && props.bodyContent === undefined - ? ( - - ) - : null - -const TerminalPanelLayout = (props: TerminalPanelLayoutProps): JSX.Element => ( -
- - - -
-) - -export const TerminalPanel = (props: TerminalPanelProps): JSX.Element => { - const connectionRef = useRef({ closing: false, opened: false }) - const hostRef = useRef(null) - const runtimeRef = useRef(null) - const [status, setStatus] = useState(() => resolveInitialTerminalStatus(props.session)) - const compactHeaderMode = resolveTerminalCompactHeaderMode(props.mobileMode) - const compactTypingMode = resolveTerminalTypingMode(props.mobileMode, props.keyboardOpen) - const terminalSessionId = props.session.session.id - const notifications = useTerminalNotificationHandlers(props) - const inlineImageState = useInlineImagePreviewState(runtimeRef, terminalSessionId) - const mobileControlState = useMobileTerminalControlState(props.mobileMode, hostRef, runtimeRef) - const closeActions = useTerminalCloseActions(connectionRef, props.onDetach, props.onKill) - - useEffect(() => { - setStatus(resolveInitialTerminalStatus(props.session)) - }, [props.session]) - useTerminalSessionLifecycle({ - connectionRef, - hostRef, - inlineImagePreviewsEnabledRef: inlineImageState.inlineImagePreviewsEnabledRef, - notifyExit: notifications.notifyExit, - notifyMessage: notifications.notifyMessage, - onAttachFailure: notifications.notifyAttachFailure, - runtimeRef, - session: props.session, - setStatus - }) - - return ( - - ) -} +export { TerminalPanel } from "@prover-coder-ai/docker-git-terminal/web/panel-terminal" +export type { TerminalPanelProps } from "@prover-coder-ai/docker-git-terminal/web/panel-terminal-types" diff --git a/packages/app/src/web/terminal-copy-interaction.ts b/packages/app/src/web/terminal-copy-interaction.ts index ce69d0af..51caf312 100644 --- a/packages/app/src/web/terminal-copy-interaction.ts +++ b/packages/app/src/web/terminal-copy-interaction.ts @@ -1,241 +1 @@ -import { - createTerminalSelectionDragController, - forceTerminalSelectionModifier, - suppressTerminalMouseReport, - type TerminalCopyMouseEvent, - type TerminalCopyMouseEventType, - type TerminalMouseButtonEvent, - type TerminalSelectionDragTarget -} from "./terminal-copy-selection-drag.js" - -export { forceTerminalSelectionModifier } from "./terminal-copy-selection-drag.js" - -export type TerminalMouseTrackingMode = "any" | "drag" | "none" | "vt200" | "x10" - -type TerminalSelectionTarget = { - readonly getSelection: () => string - readonly hasSelection: () => boolean -} - -export type TerminalCopyInteractionTerminal = TerminalSelectionTarget & { - readonly modes: { - readonly mouseTrackingMode: TerminalMouseTrackingMode - } -} - -type TerminalCopyClipboardData = { - readonly setData: (format: string, data: string) => void -} - -type TerminalCopyClipboardEvent = { - readonly clipboardData: TerminalCopyClipboardData | null - readonly preventDefault: () => void - readonly stopPropagation: () => void -} - -type TerminalCopyListenerRegistration = { - (type: "copy", listener: (event: TerminalCopyClipboardEvent) => void, options: true): void - (type: TerminalCopyMouseEventType, listener: (event: TerminalCopyMouseEvent) => void, options: true): void -} - -type TerminalCopyInteractionHost = { - readonly ownerDocument?: TerminalSelectionDragTarget | null - readonly addEventListener: TerminalCopyListenerRegistration - readonly removeEventListener: TerminalCopyListenerRegistration -} - -type TerminalCopyInteractionArgs = { - readonly host: TerminalCopyInteractionHost - readonly terminal: TerminalCopyInteractionTerminal -} - -const primaryMouseButton = 0 -const secondaryMouseButton = 2 -const terminalSelectionContextSnapshotTtlMs = 10_000 - -const isPrimaryMouseButton = (event: TerminalMouseButtonEvent): boolean => event.button === primaryMouseButton - -const isSecondaryMouseButton = (event: TerminalMouseButtonEvent): boolean => event.button === secondaryMouseButton - -const hasActiveMouseTracking = (terminal: TerminalCopyInteractionTerminal): boolean => - terminal.modes.mouseTrackingMode !== "none" - -export const shouldForceBrowserTerminalSelection = ( - event: TerminalMouseButtonEvent, - terminal: TerminalCopyInteractionTerminal -): boolean => isPrimaryMouseButton(event) && hasActiveMouseTracking(terminal) - -/** - * Decides whether a secondary-button event must preserve the terminal selection context. - * - * @param event - Mouse button event captured before xterm/tmux handlers can clear the selection. - * @param terminal - Terminal selection and mouse-tracking facade. - * @returns True iff the event is a secondary click, mouse tracking is active, and a selection exists. - * @pure true - * @effect isSecondaryMouseButton(event), hasActiveMouseTracking(terminal), terminal.hasSelection(). - * @invariant result <=> secondary(event) and tracking(terminal) and selected(terminal). - * @precondition `event` and `terminal` are non-null; mouse tracking may be `none`, which disables forcing. - * @postcondition True means the caller may snapshot selection text before suppressing terminal mouse reporting. - * @complexity O(1) - * @throws Never - */ -// CHANGE: document the guarded right-click selection preservation predicate -// WHY: selection protection is valid only while terminal mouse tracking can consume right-click events -// QUOTE(ТЗ): "right-click with selection should remain copyable in the terminal" -// REF: issue-340 -// SOURCE: n/a -// FORMAT THEOREM: forall e,t: force(e,t) <-> secondary(e) and tracking(t) and hasSelection(t) -// PURITY: CORE -// EFFECT: reads terminal.hasSelection through the injected terminal facade -// INVARIANT: mouseTrackingMode = none always yields false -// COMPLEXITY: O(1) -export const shouldForceTerminalSelectionContext = ( - event: TerminalMouseButtonEvent, - terminal: TerminalCopyInteractionTerminal -): boolean => isSecondaryMouseButton(event) && hasActiveMouseTracking(terminal) && terminal.hasSelection() - -export const writeTerminalSelectionToClipboardData = ( - terminal: TerminalSelectionTarget, - clipboardData: TerminalCopyClipboardData | null -): boolean => { - if (clipboardData === null || !terminal.hasSelection()) { - return false - } - const selection = terminal.getSelection() - if (selection.length === 0) { - return false - } - clipboardData.setData("text/plain", selection) - return true -} - -class TerminalSelectionContextSnapshot { - private selection = "" - private timer: ReturnType | null = null - - constructor(private readonly terminal: TerminalSelectionTarget) {} - - readonly clear = (): void => { - this.selection = "" - if (this.timer !== null) { - clearTimeout(this.timer) - this.timer = null - } - } - - readonly has = (): boolean => this.selection.length > 0 - - readonly refresh = (): boolean => { - const selection = this.terminal.getSelection() - if (selection.length === 0) { - this.clear() - return false - } - this.selection = selection - if (this.timer !== null) { - clearTimeout(this.timer) - } - this.timer = setTimeout(this.clear, terminalSelectionContextSnapshotTtlMs) - return true - } - - readonly writeToClipboardData = (clipboardData: TerminalCopyClipboardData | null): boolean => { - if (clipboardData === null || this.selection.length === 0) { - return false - } - clipboardData.setData("text/plain", this.selection) - return true - } -} - -class TerminalCopyInteractionController { - private readonly selectionContext: TerminalSelectionContextSnapshot - private readonly selectionDrag: ReturnType - - constructor(private readonly args: TerminalCopyInteractionArgs) { - this.selectionContext = new TerminalSelectionContextSnapshot(args.terminal) - this.selectionDrag = createTerminalSelectionDragController(args.host) - } - - readonly attach = (): { readonly dispose: () => void } => { - this.args.host.addEventListener("mousedown", this.onMouseDown, true) - this.args.host.addEventListener("mouseup", this.onMouseUp, true) - this.args.host.addEventListener("contextmenu", this.onContextMenu, true) - this.args.host.addEventListener("copy", this.onCopy, true) - return { dispose: this.dispose } - } - - private readonly shouldProtectSelectionContext = (event: TerminalCopyMouseEvent): boolean => - isSecondaryMouseButton(event) && - hasActiveMouseTracking(this.args.terminal) && - (this.selectionContext.has() || this.args.terminal.hasSelection()) - - private readonly onSelectionContextMouseEvent = (event: TerminalCopyMouseEvent): boolean => { - if (!this.shouldProtectSelectionContext(event)) { - return false - } - forceTerminalSelectionModifier(event) - if (this.args.terminal.hasSelection()) { - this.selectionContext.refresh() - } - return true - } - - private readonly onMouseDown = (event: TerminalCopyMouseEvent): void => { - if (isPrimaryMouseButton(event)) { - this.selectionContext.clear() - } - const forceBrowserSelection = shouldForceBrowserTerminalSelection(event, this.args.terminal) - const forceSelectionContext = shouldForceTerminalSelectionContext(event, this.args.terminal) - if (!forceBrowserSelection && !forceSelectionContext) { - if (isSecondaryMouseButton(event)) { - this.selectionContext.clear() - } - return - } - forceTerminalSelectionModifier(event) - if (forceSelectionContext) { - this.selectionContext.refresh() - suppressTerminalMouseReport(event) - return - } - if (forceBrowserSelection) { - this.selectionDrag.start() - } - } - - private readonly onMouseUp = (event: TerminalCopyMouseEvent): void => { - if (!this.onSelectionContextMouseEvent(event)) { - return - } - suppressTerminalMouseReport(event) - } - - private readonly onContextMenu = (event: TerminalCopyMouseEvent): void => { - this.onSelectionContextMouseEvent(event) - } - - private readonly onCopy = (event: TerminalCopyClipboardEvent): void => { - const wroteSelection = writeTerminalSelectionToClipboardData(this.args.terminal, event.clipboardData) - const wroteSnapshot = wroteSelection ? false : this.selectionContext.writeToClipboardData(event.clipboardData) - if (!wroteSelection && !wroteSnapshot) { - return - } - this.selectionContext.clear() - event.preventDefault() - event.stopPropagation() - } - - private readonly dispose = (): void => { - this.selectionDrag.dispose() - this.selectionContext.clear() - this.args.host.removeEventListener("mousedown", this.onMouseDown, true) - this.args.host.removeEventListener("mouseup", this.onMouseUp, true) - this.args.host.removeEventListener("contextmenu", this.onContextMenu, true) - this.args.host.removeEventListener("copy", this.onCopy, true) - } -} - -export const attachTerminalCopyInteraction = ( - args: TerminalCopyInteractionArgs -): { readonly dispose: () => void } => new TerminalCopyInteractionController(args).attach() +export * from "@prover-coder-ai/docker-git-terminal/web/terminal-copy-interaction" diff --git a/packages/app/src/web/terminal-copy-selection-drag.ts b/packages/app/src/web/terminal-copy-selection-drag.ts index d6e4c85c..e965e39e 100644 --- a/packages/app/src/web/terminal-copy-selection-drag.ts +++ b/packages/app/src/web/terminal-copy-selection-drag.ts @@ -1,202 +1 @@ -export type TerminalMouseButtonEvent = { - readonly button: number -} - -export type TerminalSelectionModifierEvent = { - readonly altKey: boolean - readonly shiftKey: boolean -} - -export type TerminalCopyMouseEvent = TerminalMouseButtonEvent & TerminalSelectionModifierEvent & { - readonly buttons?: number | undefined - readonly clientX?: number | undefined - readonly clientY?: number | undefined - readonly ctrlKey?: boolean | undefined - readonly detail?: number | undefined - readonly metaKey?: boolean | undefined - readonly preventDefault?: (() => void) | undefined - readonly screenX?: number | undefined - readonly screenY?: number | undefined - readonly stopImmediatePropagation?: (() => void) | undefined - readonly stopPropagation?: (() => void) | undefined -} - -export type TerminalSelectionDragEventType = "mousemove" | "mouseup" -export type TerminalCopyMouseEventType = "contextmenu" | "mousedown" | TerminalSelectionDragEventType - -type TerminalSelectionDragListenerRegistration = ( - type: TerminalSelectionDragEventType, - listener: (event: TerminalCopyMouseEvent) => void, - options: true -) => void - -export type TerminalSelectionDragTarget = { - readonly addEventListener: TerminalSelectionDragListenerRegistration - readonly dispatchEvent?: ((event: Event) => boolean) | undefined - readonly removeEventListener: TerminalSelectionDragListenerRegistration -} - -export type TerminalSelectionDragHost = TerminalSelectionDragTarget & { - readonly ownerDocument?: TerminalSelectionDragTarget | null -} - -type TerminalSelectionDragController = { - readonly dispose: () => void - readonly start: () => void -} - -const macPlatformNames = new Set(["Mac68K", "MacIntel", "Macintosh", "MacPPC"]) - -const currentNavigatorPlatform = (): string => { - if (typeof navigator === "undefined") { - return "" - } - return navigator.platform -} - -const terminalSelectionModifier = (platform: string): keyof TerminalSelectionModifierEvent => - macPlatformNames.has(platform) ? "altKey" : "shiftKey" - -export const forceTerminalSelectionModifier = ( - event: TerminalSelectionModifierEvent, - platform: string = currentNavigatorPlatform() -): boolean => - Reflect.defineProperty(event, terminalSelectionModifier(platform), { - configurable: true, - value: true - }) - -const optionalNumber = (value: number | undefined): number => value ?? 0 - -const optionalBoolean = (value: boolean | undefined): boolean => value ?? false - -const forcedTerminalMouseUpInit = (event: TerminalCopyMouseEvent): MouseEventInit => { - const selectionModifier = terminalSelectionModifier(currentNavigatorPlatform()) - return { - altKey: selectionModifier === "altKey" ? true : event.altKey, - bubbles: true, - button: event.button, - buttons: 0, - cancelable: true, - clientX: optionalNumber(event.clientX), - clientY: optionalNumber(event.clientY), - ctrlKey: optionalBoolean(event.ctrlKey), - detail: optionalNumber(event.detail), - metaKey: optionalBoolean(event.metaKey), - screenX: optionalNumber(event.screenX), - screenY: optionalNumber(event.screenY), - shiftKey: selectionModifier === "shiftKey" ? true : event.shiftKey - } -} - -const defineMouseEventProperty = ( - event: Event, - property: string, - value: boolean | number -): void => { - Reflect.defineProperty(event, property, { - configurable: true, - value - }) -} - -const copyMouseEventInitProperties = ( - event: Event, - init: MouseEventInit -): void => { - defineMouseEventProperty(event, "altKey", optionalBoolean(init.altKey)) - defineMouseEventProperty(event, "button", optionalNumber(init.button)) - defineMouseEventProperty(event, "buttons", optionalNumber(init.buttons)) - defineMouseEventProperty(event, "clientX", optionalNumber(init.clientX)) - defineMouseEventProperty(event, "clientY", optionalNumber(init.clientY)) - defineMouseEventProperty(event, "ctrlKey", optionalBoolean(init.ctrlKey)) - defineMouseEventProperty(event, "detail", optionalNumber(init.detail)) - defineMouseEventProperty(event, "metaKey", optionalBoolean(init.metaKey)) - defineMouseEventProperty(event, "screenX", optionalNumber(init.screenX)) - defineMouseEventProperty(event, "screenY", optionalNumber(init.screenY)) - defineMouseEventProperty(event, "shiftKey", optionalBoolean(init.shiftKey)) -} - -const createForcedTerminalMouseUpEvent = ( - sourceEvent: TerminalCopyMouseEvent -): Event => { - const init = forcedTerminalMouseUpInit(sourceEvent) - const event = typeof MouseEvent === "function" - ? new MouseEvent("mouseup", init) - : new Event("mouseup", { bubbles: true, cancelable: true }) - copyMouseEventInitProperties(event, init) - return event -} - -const suppressOriginalTerminalMouseUp = (event: TerminalCopyMouseEvent): void => { - event.preventDefault?.() - event.stopPropagation?.() - event.stopImmediatePropagation?.() -} - -export const suppressTerminalMouseReport = (event: TerminalCopyMouseEvent): void => { - event.stopPropagation?.() - event.stopImmediatePropagation?.() -} - -const replayForcedTerminalMouseUp = ( - target: TerminalSelectionDragTarget, - event: TerminalCopyMouseEvent -): void => { - target.dispatchEvent?.(createForcedTerminalMouseUpEvent(event)) -} - -const resolveTerminalSelectionDragTarget = ( - host: TerminalSelectionDragHost -): TerminalSelectionDragTarget => host.ownerDocument ?? host - -class TerminalSelectionDragControllerImpl implements TerminalSelectionDragController { - private forcedSelectionDrag = false - private selectionDragTarget: TerminalSelectionDragTarget | null = null - - constructor(private readonly host: TerminalSelectionDragHost) {} - - readonly dispose = (): void => { - if (this.selectionDragTarget === null) { - this.forcedSelectionDrag = false - return - } - this.selectionDragTarget.removeEventListener("mousemove", this.onMouseMove, true) - this.selectionDragTarget.removeEventListener("mouseup", this.onMouseUp, true) - this.selectionDragTarget = null - this.forcedSelectionDrag = false - } - - readonly start = (): void => { - this.dispose() - this.forcedSelectionDrag = true - this.selectionDragTarget = resolveTerminalSelectionDragTarget(this.host) - this.selectionDragTarget.addEventListener("mousemove", this.onMouseMove, true) - this.selectionDragTarget.addEventListener("mouseup", this.onMouseUp, true) - } - - private readonly onMouseMove = (event: TerminalCopyMouseEvent): void => { - if (this.forcedSelectionDrag) { - forceTerminalSelectionModifier(event) - } - } - - private readonly onMouseUp = (event: TerminalCopyMouseEvent): void => { - if (!this.forcedSelectionDrag) { - return - } - const target = this.selectionDragTarget - forceTerminalSelectionModifier(event) - if (target?.dispatchEvent === undefined) { - this.dispose() - return - } - suppressOriginalTerminalMouseUp(event) - this.dispose() - replayForcedTerminalMouseUp(target, event) - } -} - -export const createTerminalSelectionDragController = ( - host: TerminalSelectionDragHost -): TerminalSelectionDragController => new TerminalSelectionDragControllerImpl(host) +export * from "@prover-coder-ai/docker-git-terminal/web/terminal-copy-selection-drag" diff --git a/packages/app/src/web/terminal-image-paste.ts b/packages/app/src/web/terminal-image-paste.ts index 8d7e12c3..f502aaad 100644 --- a/packages/app/src/web/terminal-image-paste.ts +++ b/packages/app/src/web/terminal-image-paste.ts @@ -1,317 +1 @@ -import type { Terminal } from "xterm" - -import type { TerminalPasteGuard, TerminalSocketRef } from "./terminal-panel-runtime-types.js" - -type TerminalImagePasteArgs = { - readonly host: HTMLDivElement - readonly notifyMessage: (message: string) => void - readonly pasteGuard: TerminalPasteGuard - readonly socketRef: TerminalSocketRef - readonly terminal: Terminal -} - -type TerminalImagePasteClientMessage = { - readonly data: string - readonly mediaType: string - readonly name: string - readonly size: number - readonly type: "image" -} - -type TerminalPasteTrapState = { - active: boolean - restoreTimer: ReturnType | null -} - -const terminalImagePasteMaxBytes = 10 * 1024 * 1024 -const dataUrlBase64Marker = ";base64," -const nativeImagePasteControlInput = "\u0016" -const nativeImagePasteSuppressWindowMs = 800 -const pasteTrapRestoreDelayMs = 800 -const supportedImageMediaTypes = new Set(["image/gif", "image/jpeg", "image/png", "image/webp"]) - -export const extractTerminalImageBase64 = (dataUrl: string): string | null => { - const markerIndex = dataUrl.indexOf(dataUrlBase64Marker) - return markerIndex === -1 ? null : dataUrl.slice(markerIndex + dataUrlBase64Marker.length) -} - -const fileLabel = (file: File): string => { - const trimmed = file.name.trim() - return trimmed.length > 0 ? trimmed : "clipboard-image" -} - -const isSupportedImageFile = (file: File): boolean => supportedImageMediaTypes.has(file.type.toLowerCase()) - -const imageFilesFromItems = (items: DataTransferItemList): ReadonlyArray => - [...items].flatMap((item) => { - if (item.kind !== "file" || !item.type.toLowerCase().startsWith("image/")) { - return [] - } - const file = item.getAsFile() - return file === null ? [] : [file] - }) - -export const terminalImageFilesFromTransfer = ( - dataTransfer: DataTransfer | null -): ReadonlyArray => { - if (dataTransfer === null) { - return [] - } - const files = [...dataTransfer.files].filter((file) => file.type.toLowerCase().startsWith("image/")) - return files.length > 0 ? files : imageFilesFromItems(dataTransfer.items) -} - -const hasImageFileTransfer = (dataTransfer: DataTransfer | null): boolean => { - if (dataTransfer === null) { - return false - } - return [...dataTransfer.items].some((item) => item.kind === "file" && item.type.toLowerCase().startsWith("image/")) || - [...dataTransfer.files].some((file) => file.type.toLowerCase().startsWith("image/")) -} - -const socketCanSend = (socket: WebSocket | null): socket is WebSocket => - socket !== null && socket.readyState === WebSocket.OPEN - -const sendTerminalInput = ( - args: TerminalImagePasteArgs, - data: string -): void => { - const socket = args.socketRef.current - if (!socketCanSend(socket)) { - args.notifyMessage("Terminal is not connected; clipboard was not pasted.") - return - } - socket.send(JSON.stringify({ data, type: "input" })) -} - -const createImagePasteMessage = ( - file: File, - base64: string -): TerminalImagePasteClientMessage => ({ - data: base64, - mediaType: file.type, - name: fileLabel(file), - size: file.size, - type: "image" -}) - -const sendImagePasteMessage = ( - args: TerminalImagePasteArgs, - file: File, - base64: string -): void => { - const socket = args.socketRef.current - if (!socketCanSend(socket)) { - args.notifyMessage("Terminal is not connected; image was not pasted.") - return - } - socket.send(JSON.stringify(createImagePasteMessage(file, base64))) -} - -const readAndSendImageFile = ( - args: TerminalImagePasteArgs, - file: File -): void => { - if (!isSupportedImageFile(file)) { - args.notifyMessage(`Unsupported image type: ${file.type || "unknown"}.`) - return - } - if (file.size <= 0) { - args.notifyMessage("Image clipboard item is empty.") - return - } - if (file.size > terminalImagePasteMaxBytes) { - args.notifyMessage(`Image is too large. Max size is ${terminalImagePasteMaxBytes} bytes.`) - return - } - const reader = new FileReader() - reader.addEventListener("load", () => { - if (typeof reader.result !== "string") { - args.notifyMessage("Could not read pasted image.") - return - } - const base64 = extractTerminalImageBase64(reader.result) - if (base64 === null) { - args.notifyMessage("Could not encode pasted image.") - return - } - sendImagePasteMessage(args, file, base64) - }) - reader.addEventListener("error", () => { - args.notifyMessage("Could not read pasted image.") - }) - reader.readAsDataURL(file) -} - -const handleImageFiles = ( - args: TerminalImagePasteArgs, - files: ReadonlyArray -): void => { - if (files.length === 0) { - return - } - args.notifyMessage(files.length === 1 ? "Uploading pasted image..." : `Uploading ${files.length} pasted images...`) - for (const file of files) { - readAndSendImageFile(args, file) - } - args.terminal.focus() -} - -const textFromTransfer = (dataTransfer: DataTransfer | null): string => dataTransfer?.getData("text/plain") ?? "" - -const handleTerminalClipboardTransfer = ( - args: TerminalImagePasteArgs, - dataTransfer: DataTransfer | null, - includeText: boolean -): boolean => { - const files = terminalImageFilesFromTransfer(dataTransfer) - if (files.length > 0) { - handleImageFiles(args, files) - return true - } - if (!includeText) { - return false - } - const text = textFromTransfer(dataTransfer) - if (text.length === 0) { - return false - } - sendTerminalInput(args, text) - args.terminal.focus() - return true -} - -const handleTerminalImageDragOver = (event: DragEvent): void => { - if (!hasImageFileTransfer(event.dataTransfer)) { - return - } - event.preventDefault() - if (event.dataTransfer !== null) { - event.dataTransfer.dropEffect = "copy" - } -} - -type TerminalPasteShortcutEvent = Pick - -export const createTerminalPasteGuard = ( - currentTimeMillis: () => number = () => Date.now() -): TerminalPasteGuard => { - let expiresAtMs = 0 - let pending = false - return { - shouldSuppressTerminalInput: (data) => { - if (!pending || data !== nativeImagePasteControlInput || currentTimeMillis() > expiresAtMs) { - return false - } - pending = false - return true - }, - suppressNextNativeImagePaste: () => { - pending = true - expiresAtMs = currentTimeMillis() + nativeImagePasteSuppressWindowMs - } - } -} - -export const isTerminalPasteShortcut = (event: TerminalPasteShortcutEvent): boolean => - (event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "v" - -const eventTargetInsideHost = ( - host: HTMLDivElement, - event: Event -): boolean => event.target instanceof Node && host.contains(event.target) - -const createTerminalPasteTrap = (host: HTMLDivElement): HTMLTextAreaElement => { - const trap = document.createElement("textarea") - trap.setAttribute("aria-hidden", "true") - trap.tabIndex = -1 - trap.style.position = "fixed" - trap.style.left = "-10000px" - trap.style.top = "0" - trap.style.width = "1px" - trap.style.height = "1px" - trap.style.opacity = "0" - trap.style.pointerEvents = "none" - host.append(trap) - return trap -} - -const clearPasteTrapTimer = (state: TerminalPasteTrapState): void => { - if (state.restoreTimer !== null) { - clearTimeout(state.restoreTimer) - state.restoreTimer = null - } -} - -const deactivatePasteTrap = ( - args: TerminalImagePasteArgs, - state: TerminalPasteTrapState -): void => { - clearPasteTrapTimer(state) - state.active = false - args.terminal.focus() -} - -const activatePasteTrap = ( - args: TerminalImagePasteArgs, - trap: HTMLTextAreaElement, - state: TerminalPasteTrapState -): void => { - clearPasteTrapTimer(state) - state.active = true - trap.value = "" - trap.focus() - trap.select() - state.restoreTimer = setTimeout(() => { - deactivatePasteTrap(args, state) - }, pasteTrapRestoreDelayMs) -} - -export const attachTerminalImagePaste = ( - args: TerminalImagePasteArgs -): { readonly dispose: () => void } => { - const pasteTrap = createTerminalPasteTrap(args.host) - const trapState: TerminalPasteTrapState = { active: false, restoreTimer: null } - const onPaste = (event: ClipboardEvent): void => { - const handled = handleTerminalClipboardTransfer(args, event.clipboardData, trapState.active) - if (!handled) { - return - } - event.preventDefault() - event.stopPropagation() - deactivatePasteTrap(args, trapState) - } - const onKeyDown = (event: KeyboardEvent): void => { - if (!isTerminalPasteShortcut(event) || !eventTargetInsideHost(args.host, event)) { - return - } - args.pasteGuard.suppressNextNativeImagePaste() - event.stopImmediatePropagation() - event.stopPropagation() - activatePasteTrap(args, pasteTrap, trapState) - } - const onDrop = (event: DragEvent): void => { - const files = terminalImageFilesFromTransfer(event.dataTransfer) - if (files.length === 0) { - return - } - event.preventDefault() - handleImageFiles(args, files) - } - - args.host.addEventListener("paste", onPaste, true) - globalThis.addEventListener("keydown", onKeyDown, true) - args.host.addEventListener("dragover", handleTerminalImageDragOver, true) - args.host.addEventListener("drop", onDrop, true) - - return { - dispose: () => { - clearPasteTrapTimer(trapState) - args.host.removeEventListener("paste", onPaste, true) - globalThis.removeEventListener("keydown", onKeyDown, true) - args.host.removeEventListener("dragover", handleTerminalImageDragOver, true) - args.host.removeEventListener("drop", onDrop, true) - pasteTrap.remove() - } - } -} +export * from "@prover-coder-ai/docker-git-terminal/web/terminal-image-paste" diff --git a/packages/app/src/web/terminal-image-paths.ts b/packages/app/src/web/terminal-image-paths.ts index 2bb4591d..042f950e 100644 --- a/packages/app/src/web/terminal-image-paths.ts +++ b/packages/app/src/web/terminal-image-paths.ts @@ -1,81 +1 @@ -const supportedExtensions: ReadonlyArray = ["png", "jpg", "jpeg", "gif", "webp"] - -const extensionAlternation = supportedExtensions.join("|") - -const absoluteImagePathSource = String.raw`/[^\s"'(<>\[\]{}|\\]+\.(?:${extensionAlternation})` -const imagePathPattern = new RegExp( - String.raw`(?:^|[\s"'(<>\[\]{}|])((?:file://)?${absoluteImagePathSource})(?=$|[\s"')<>\[\]{}|.,;:?!])`, - "giu" -) - -const treePointerImagePathSource = String.raw`[^\s"'(<>\[\]{}|\\/][^\s"'(<>\[\]{}|\\]*\.(?:${extensionAlternation})` -const treePointerImagePathPattern = new RegExp( - String.raw`(?:^|\s)[└├]\s+(${treePointerImagePathSource})(?=$|[\s"')<>\[\]{}|.,;:?!])`, - "giu" -) - -const escapeChar = String.fromCodePoint(0x1B) -const bellChar = String.fromCodePoint(0x07) - -const buildAnsiPattern = (source: string): RegExp => new RegExp(source, "gu") - -const ansiCsiPattern = buildAnsiPattern(String.raw`${escapeChar}\[[0-?]*[ -/]*[@-~]`) -const ansiOscPattern = buildAnsiPattern(String.raw`${escapeChar}\][\s\S]*?(?:${bellChar}|${escapeChar}\\)`) -const ansiOtherEscapePattern = buildAnsiPattern(`${escapeChar}.`) - -export type TerminalImagePathMatch = { - readonly endIndex: number - readonly path: string - readonly startIndex: number -} - -export const stripTerminalAnsi = (text: string): string => - text.replace(ansiOscPattern, "").replace(ansiCsiPattern, "").replace(ansiOtherEscapePattern, "") - -const collectPatternMatches = ( - plainText: string, - pattern: RegExp, - matches: Array, - seenStartIndices: Set -): void => { - for (const match of plainText.matchAll(pattern)) { - const candidate = match[1] - if (candidate === undefined || candidate.length === 0) { - continue - } - const fullMatch = match[0] - const fullStartIndex = match.index - const startIndex = fullStartIndex + fullMatch.lastIndexOf(candidate) - if (seenStartIndices.has(startIndex)) { - continue - } - seenStartIndices.add(startIndex) - matches.push({ - endIndex: startIndex + candidate.length, - path: candidate, - startIndex - }) - } -} - -export const detectTerminalImagePathMatches = (text: string): ReadonlyArray => { - const plainText = stripTerminalAnsi(text) - const matches: Array = [] - const seenStartIndices = new Set() - collectPatternMatches(plainText, imagePathPattern, matches, seenStartIndices) - collectPatternMatches(plainText, treePointerImagePathPattern, matches, seenStartIndices) - return matches -} - -export const detectTerminalImagePaths = (text: string): ReadonlyArray => { - const matches = new Set() - for (const match of detectTerminalImagePathMatches(text)) { - matches.add(match.path) - } - return [...matches] -} - -export const isSupportedTerminalImagePath = (path: string): boolean => { - const lower = path.toLowerCase() - return supportedExtensions.some((extension) => lower.endsWith(`.${extension}`)) -} +export * from "@prover-coder-ai/docker-git-terminal/web/terminal-image-paths" diff --git a/packages/app/src/web/terminal-image-url.ts b/packages/app/src/web/terminal-image-url.ts index 3a8ed8d4..7a3c7d1f 100644 --- a/packages/app/src/web/terminal-image-url.ts +++ b/packages/app/src/web/terminal-image-url.ts @@ -1,13 +1 @@ -import { resolveTerminalApiOriginUrl } from "./terminal.js" - -const websocketSuffixPattern = /\/ws$/u - -export const resolveTerminalImageBasePath = (websocketPath: string): string => - websocketPath.replace(websocketSuffixPattern, "/image") - -export const resolveTerminalImageFetchUrl = (websocketPath: string, imagePath: string): string => { - const apiUrl = resolveTerminalApiOriginUrl() - apiUrl.pathname = `${apiUrl.pathname.replace(/\/$/u, "")}${resolveTerminalImageBasePath(websocketPath)}` - apiUrl.searchParams.set("path", imagePath) - return apiUrl.toString() -} +export * from "@prover-coder-ai/docker-git-terminal/web/terminal-image-url" diff --git a/packages/app/src/web/terminal-inline-images-core.ts b/packages/app/src/web/terminal-inline-images-core.ts index 003e26f0..e3a87045 100644 --- a/packages/app/src/web/terminal-inline-images-core.ts +++ b/packages/app/src/web/terminal-inline-images-core.ts @@ -1,90 +1 @@ -import { detectTerminalImagePaths } from "./terminal-image-paths.js" - -export type TerminalInlineImageOutputSegment = { - readonly endedWithLineBreak: boolean - readonly imagePaths: ReadonlyArray - readonly text: string -} - -export type TerminalInlineImagePreviewsEnabledRef = { readonly current: boolean } - -export type TerminalOutputSegmentWriter = { - readonly writePreviewLineBreak: (segment: TerminalInlineImageOutputSegment, onComplete: () => void) => void - readonly writePreviews: (paths: ReadonlyArray, onComplete: () => void) => void - readonly writeText: (text: string, onComplete: () => void) => void -} - -export type TerminalOutputSegmentWriteArgs = { - readonly inlineImagePreviewsEnabledRef: TerminalInlineImagePreviewsEnabledRef - readonly segment: TerminalInlineImageOutputSegment - readonly writer: TerminalOutputSegmentWriter -} - -const lineBreakPattern = /\r\n|\r|\n/gu - -const endsWithLineBreak = (text: string): boolean => /\r\n$|\r$|\n$/u.test(text) - -export const splitTerminalInlineImageOutput = ( - data: string -): ReadonlyArray => { - if (data.length === 0) { - return [] - } - const segments: Array = [] - let startIndex = 0 - for (const match of data.matchAll(lineBreakPattern)) { - const endIndex = match.index + match[0].length - const text = data.slice(startIndex, endIndex) - segments.push({ - endedWithLineBreak: true, - imagePaths: detectTerminalImagePaths(text), - text - }) - startIndex = endIndex - } - if (startIndex < data.length) { - const text = data.slice(startIndex) - segments.push({ - endedWithLineBreak: endsWithLineBreak(text), - imagePaths: detectTerminalImagePaths(text), - text - }) - } - return segments -} - -/** - * Coordinates terminal output writes for one parsed segment. - * - * This function only sequences the supplied writer callbacks. It does not fetch - * image data, allocate decorations, or mutate terminal state directly; those - * effects belong to the writer implementation. - * - * @pure false - invokes effectful writer callbacks. - * @effect writer callbacks: writeText, writePreviewLineBreak, writePreviews. - * @precondition segment is the next queued terminal output segment and - * onComplete belongs to the caller's active output queue drain. - * @postcondition writeText is requested exactly once; when previews are enabled - * and imagePaths is non-empty, the preview line break and preview writes are - * requested in order before onComplete. - * @invariant segment.text is emitted before any preview callback, and preview - * callbacks never run when imagePaths is empty or previews are disabled. - * @complexity O(1) plus writer callback complexity; image paths are forwarded - * without iteration. - * @throws Through writer callbacks or onComplete only; this function has no - * explicit throw path. - */ -export const writeTerminalOutputSegment = ( - { inlineImagePreviewsEnabledRef, segment, writer }: TerminalOutputSegmentWriteArgs, - onComplete: () => void -): void => { - writer.writeText(segment.text, () => { - if (segment.imagePaths.length === 0 || !inlineImagePreviewsEnabledRef.current) { - onComplete() - return - } - writer.writePreviewLineBreak(segment, () => { - writer.writePreviews(segment.imagePaths, onComplete) - }) - }) -} +export * from "@prover-coder-ai/docker-git-terminal/web/terminal-inline-images-core" diff --git a/packages/app/src/web/terminal-inline-images.ts b/packages/app/src/web/terminal-inline-images.ts index 8581de9d..02643004 100644 --- a/packages/app/src/web/terminal-inline-images.ts +++ b/packages/app/src/web/terminal-inline-images.ts @@ -1,274 +1 @@ -import type { IDisposable, ILink, Terminal } from "xterm" - -import { detectTerminalImagePathMatches } from "./terminal-image-paths.js" -import { resolveTerminalImageFetchUrl } from "./terminal-image-url.js" -import type { TerminalLifecycleState } from "./terminal-panel-runtime-types.js" -import type { ActiveTerminalSession } from "./terminal.js" - -export const terminalInlineImagePreviewLimit = 20 -export const terminalInlineImagePreviewRows = 4 - -export const terminalInlineImageSpacer = "\r\n".repeat(terminalInlineImagePreviewRows) - -const terminalInlineImagePreviewColumns = 16 -const terminalInlineImagePreviewHeightPx = 56 -const terminalInlineImagePreviewWidthPx = 96 - -export type TerminalInlineImageEntry = - | { - readonly _tag: "AvailableTerminalInlineImage" - readonly displayUrl: string - readonly fetchUrl: string - readonly path: string - } - | { - readonly _tag: "UnavailableTerminalInlineImage" - readonly fetchUrl: string - readonly path: string - } - -type TerminalInlineImageObjectUrlCache = Map - -const availableTerminalInlineImageEntry = ( - path: string, - fetchUrl: string, - displayUrl: string -): TerminalInlineImageEntry => ({ - _tag: "AvailableTerminalInlineImage", - displayUrl, - fetchUrl, - path -}) - -export const unavailableTerminalInlineImageEntry = ( - path: string, - fetchUrl: string -): TerminalInlineImageEntry => ({ - _tag: "UnavailableTerminalInlineImage", - fetchUrl, - path -}) - -export const cachedTerminalInlineImageEntry = ( - cache: TerminalInlineImageObjectUrlCache, - path: string, - fetchUrl: string -): TerminalInlineImageEntry | null => { - const displayUrl = cache.get(path) - return displayUrl === undefined ? null : availableTerminalInlineImageEntry(path, fetchUrl, displayUrl) -} - -const revokeTerminalInlineImageObjectUrl = (displayUrl: string): void => { - URL.revokeObjectURL(displayUrl) -} - -const trimTerminalInlineImageObjectUrlCache = ( - cache: TerminalInlineImageObjectUrlCache -): void => { - while (cache.size > terminalInlineImagePreviewLimit) { - const first = cache.entries().next() - if (first.done) { - return - } - const [path, displayUrl] = first.value - cache.delete(path) - revokeTerminalInlineImageObjectUrl(displayUrl) - } -} - -export const cacheTerminalInlineImageBlob = ( - cache: TerminalInlineImageObjectUrlCache, - path: string, - fetchUrl: string, - blob: Blob -): TerminalInlineImageEntry => { - const cached = cachedTerminalInlineImageEntry(cache, path, fetchUrl) - if (cached !== null) { - return cached - } - const displayUrl = URL.createObjectURL(blob) - cache.set(path, displayUrl) - trimTerminalInlineImageObjectUrlCache(cache) - return availableTerminalInlineImageEntry(path, fetchUrl, displayUrl) -} - -export const revokeTerminalInlineImageObjectUrlCache = ( - cache: TerminalInlineImageObjectUrlCache -): void => { - for (const displayUrl of cache.values()) { - revokeTerminalInlineImageObjectUrl(displayUrl) - } - cache.clear() -} - -const terminalInlineImageLinkUrl = (entry: TerminalInlineImageEntry): string => - entry._tag === "AvailableTerminalInlineImage" ? entry.displayUrl : entry.fetchUrl - -const terminalInlineImageTitle = (entry: TerminalInlineImageEntry): string => - entry._tag === "AvailableTerminalInlineImage" ? entry.path : `${entry.path} unavailable` - -const createTerminalInlineImageLink = (entry: TerminalInlineImageEntry): HTMLAnchorElement => { - const link = document.createElement("a") - link.href = terminalInlineImageLinkUrl(entry) - link.rel = "noreferrer" - link.target = "_blank" - link.title = terminalInlineImageTitle(entry) - link.style.alignItems = "center" - link.style.background = "#0d1218" - link.style.border = "1px solid #3a4652" - link.style.borderRadius = "6px" - link.style.boxSizing = "border-box" - link.style.cursor = "pointer" - link.style.display = "inline-flex" - link.style.height = `min(${terminalInlineImagePreviewHeightPx}px, calc(100% - 8px))` - link.style.justifyContent = "center" - link.style.margin = "4px 0" - link.style.padding = "4px" - link.style.pointerEvents = "auto" - link.style.width = `min(${terminalInlineImagePreviewWidthPx}px, 100%)` - return link -} - -const appendAvailableTerminalInlineImage = ( - link: HTMLAnchorElement, - entry: Extract -): void => { - const image = document.createElement("img") - image.alt = entry.path - image.src = entry.displayUrl - image.style.borderRadius = "4px" - image.style.display = "block" - image.style.height = "100%" - image.style.objectFit = "contain" - image.style.width = "100%" - link.append(image) -} - -const appendUnavailableTerminalInlineImage = (link: HTMLAnchorElement): void => { - const label = document.createElement("span") - label.textContent = "unavailable" - label.style.color = "#9aa8b6" - label.style.fontFamily = "'IBM Plex Mono', ui-monospace, monospace" - label.style.fontSize = "11px" - label.style.lineHeight = "1" - label.style.overflow = "hidden" - label.style.textOverflow = "ellipsis" - label.style.whiteSpace = "nowrap" - link.append(label) -} - -const appendTerminalInlineImageContent = ( - link: HTMLAnchorElement, - entry: TerminalInlineImageEntry -): void => { - if (entry._tag === "AvailableTerminalInlineImage") { - appendAvailableTerminalInlineImage(link, entry) - return - } - appendUnavailableTerminalInlineImage(link) -} - -const openImage = (fetchUrl: string): void => { - const imageWindow = window.open(fetchUrl, "_blank", "noopener,noreferrer") - if (imageWindow === null) { - return - } - imageWindow.opener = null -} - -const appendDecorationDisposable = ( - lifecycle: TerminalLifecycleState, - disposable: IDisposable -): void => { - lifecycle.inlineImageDisposables.push(disposable) - if (lifecycle.inlineImageDisposables.length <= terminalInlineImagePreviewLimit) { - return - } - lifecycle.inlineImageDisposables.shift()?.dispose() -} - -const renderInlineImageElement = ( - element: HTMLElement, - entry: TerminalInlineImageEntry -): void => { - if (element.dataset["path"] === entry.path && element.dataset["tag"] === entry._tag) { - return - } - - const link = createTerminalInlineImageLink(entry) - appendTerminalInlineImageContent(link, entry) - - element.dataset["path"] = entry.path - element.dataset["tag"] = entry._tag - element.style.pointerEvents = "none" - element.replaceChildren(link) -} - -export const appendTerminalInlineImagePreview = ( - terminal: Terminal, - lifecycle: TerminalLifecycleState, - entry: TerminalInlineImageEntry -): boolean => { - const marker = terminal.registerMarker(0) - const decoration = terminal.registerDecoration({ - height: terminalInlineImagePreviewRows, - layer: "top", - marker, - width: Math.min(terminalInlineImagePreviewColumns, Math.max(1, terminal.cols)) - }) - if (decoration === undefined) { - marker.dispose() - return false - } - - decoration.onRender((element) => { - renderInlineImageElement(element, entry) - }) - appendDecorationDisposable(lifecycle, decoration) - return true -} - -const imageLink = ( - session: ActiveTerminalSession, - bufferLineNumber: number, - match: ReturnType[number] -): ILink => { - const fetchUrl = resolveTerminalImageFetchUrl(session.websocketPath, match.path) - return { - activate: () => { - openImage(fetchUrl) - }, - decorations: { - pointerCursor: true, - underline: true - }, - range: { - end: { - x: match.endIndex, - y: bufferLineNumber - }, - start: { - x: match.startIndex + 1, - y: bufferLineNumber - } - }, - text: match.path - } -} - -export const attachTerminalImageLinks = ( - terminal: Terminal, - session: ActiveTerminalSession -): IDisposable => - terminal.registerLinkProvider({ - provideLinks: (bufferLineNumber, callback) => { - const line = terminal.buffer.active.getLine(bufferLineNumber - 1) - if (line === undefined) { - callback([]) - return - } - const text = line.translateToString(true) - const matches = detectTerminalImagePathMatches(text) - callback(matches.map((match) => imageLink(session, bufferLineNumber, match))) - } - }) +export * from "@prover-coder-ai/docker-git-terminal/web/terminal-inline-images" diff --git a/packages/app/src/web/terminal-mobile-controls.ts b/packages/app/src/web/terminal-mobile-controls.ts index b713e417..72d41e99 100644 --- a/packages/app/src/web/terminal-mobile-controls.ts +++ b/packages/app/src/web/terminal-mobile-controls.ts @@ -1,55 +1 @@ -export type MobileTerminalKey = "escape" | "left" | "right" | "tab" | "up" | "down" | "ctrl-c" - -const mobileTerminalKeyInputs: Record = { - escape: "\u001B", - left: "\u001B[D", - right: "\u001B[C", - tab: "\t", - up: "\u001B[A", - down: "\u001B[B", - "ctrl-c": "\u0003" -} - -const modifierOnlyKeys = new Set([ - "Alt", - "CapsLock", - "Control", - "Fn", - "Meta", - "NumLock", - "ScrollLock", - "Shift" -]) - -const terminalControlSymbolInputs: Readonly> = { - "@": "\u0000", - "[": "\u001B", - "\\": "\u001C", - "]": "\u001D", - "^": "\u001E", - _: "\u001F" -} - -export const mobileTerminalKeyInput = (key: MobileTerminalKey): string => mobileTerminalKeyInputs[key] - -export const isModifierOnlyTerminalKey = (key: string): boolean => modifierOnlyKeys.has(key) - -const controlCharacterFromRange = ( - key: string, - first: string, - last: string, - offset: number -): string | null => { - if (key.length !== 1 || key < first || key > last) { - return null - } - return String.fromCodePoint((key.codePointAt(0) ?? 0) - offset) -} - -export const terminalControlCharacterForKey = (key: string): string | null => { - const lower = controlCharacterFromRange(key, "a", "z", 96) - if (lower !== null) { - return lower - } - return controlCharacterFromRange(key, "A", "Z", 64) ?? terminalControlSymbolInputs[key] ?? null -} +export * from "@prover-coder-ai/docker-git-terminal/web/terminal-mobile-controls" diff --git a/packages/app/src/web/terminal-mobile-layout.ts b/packages/app/src/web/terminal-mobile-layout.ts index 9c75da8c..dac73ff1 100644 --- a/packages/app/src/web/terminal-mobile-layout.ts +++ b/packages/app/src/web/terminal-mobile-layout.ts @@ -1,7 +1 @@ -export const shouldShowTerminalTabs = (mobileMode: boolean, sessionCount: number): boolean => - !mobileMode || sessionCount > 1 - -export const resolveTerminalCompactHeaderMode = (mobileMode: boolean): boolean => mobileMode - -export const resolveTerminalTypingMode = (mobileMode: boolean, keyboardOpen: boolean): boolean => - mobileMode && keyboardOpen +export * from "@prover-coder-ai/docker-git-terminal/web/terminal-mobile-layout" diff --git a/packages/app/src/web/terminal-panel-cleanup-runtime.ts b/packages/app/src/web/terminal-panel-cleanup-runtime.ts index 345c6ebc..c2eb79d4 100644 --- a/packages/app/src/web/terminal-panel-cleanup-runtime.ts +++ b/packages/app/src/web/terminal-panel-cleanup-runtime.ts @@ -1,44 +1 @@ -import { revokeTerminalInlineImageObjectUrlCache } from "./terminal-inline-images.js" -import { runOptionalTerminalOperation } from "./terminal-panel-optional-operation.js" -import type { TerminalCleanupArgs } from "./terminal-panel-runtime-types.js" - -const closeSocket = (socket: WebSocket | null): void => { - if (socket === null || socket.readyState === WebSocket.CLOSED) { - return - } - runOptionalTerminalOperation(() => { - socket.close() - }) -} - -const clearReconnectTimer = (args: TerminalCleanupArgs): void => { - if (args.lifecycle.reconnectTimer !== null) { - clearTimeout(args.lifecycle.reconnectTimer) - args.lifecycle.reconnectTimer = null - } -} - -export const cleanupTerminalResources = ( - args: TerminalCleanupArgs -): void => { - args.lifecycle.disposed = true - clearReconnectTimer(args) - for (const disposable of args.lifecycle.inlineImageDisposables) { - runOptionalTerminalOperation(() => { - disposable.dispose() - }) - } - args.lifecycle.inlineImageDisposables = [] - revokeTerminalInlineImageObjectUrlCache(args.lifecycle.inlineImageObjectUrls) - args.lifecycle.outputQueue = [] - args.lifecycle.outputWriting = false - args.removeImageLinks() - args.removeImagePaste() - args.removeInput() - args.resizeObserver?.disconnect() - args.removeResize() - closeSocket(args.socketRef.current) - args.socketRef.current = null - args.runtimeRef.current = null - args.terminal.dispose() -} +export * from "@prover-coder-ai/docker-git-terminal/web/terminal-panel-cleanup-runtime" diff --git a/packages/app/src/web/terminal-panel-inline-images-runtime.ts b/packages/app/src/web/terminal-panel-inline-images-runtime.ts index d0deb6c2..39b45290 100644 --- a/packages/app/src/web/terminal-panel-inline-images-runtime.ts +++ b/packages/app/src/web/terminal-panel-inline-images-runtime.ts @@ -1,299 +1 @@ -import { FetchHttpClient, HttpClient, HttpClientResponse } from "@effect/platform" -import { Duration, Effect } from "effect" -import * as Stream from "effect/Stream" - -import { resolveTerminalImageFetchUrl } from "./terminal-image-url.js" -import { - splitTerminalInlineImageOutput, - type TerminalInlineImageOutputSegment, - writeTerminalOutputSegment -} from "./terminal-inline-images-core.js" -import { - appendTerminalInlineImagePreview, - cachedTerminalInlineImageEntry, - cacheTerminalInlineImageBlob, - terminalInlineImageSpacer, - unavailableTerminalInlineImageEntry -} from "./terminal-inline-images.js" -import type { TerminalInlineImageEntry } from "./terminal-inline-images.js" -import type { TerminalMessageHandlers } from "./terminal-panel-runtime-types.js" - -type TerminalInlineImageFetchError = { - readonly _tag: "TerminalInlineImageFetchError" - readonly message: string -} - -type TerminalInlineImageBufferState = { - readonly chunks: ReadonlyArray - readonly size: number -} - -const terminalInlineImageFetchTimeout = Duration.seconds(10) -const terminalInlineImageMaxBytes = 10 * 1024 * 1024 - -const emptyTerminalInlineImageBufferState: TerminalInlineImageBufferState = { - chunks: [], - size: 0 -} - -const terminalImageEntry = ( - handlers: TerminalMessageHandlers, - path: string -): TerminalInlineImageEntry | null => { - const fetchUrl = resolveTerminalImageFetchUrl(handlers.session.websocketPath, path) - return cachedTerminalInlineImageEntry(handlers.lifecycle.inlineImageObjectUrls, path, fetchUrl) -} - -const terminalInlineImageFetchError = (message: string): TerminalInlineImageFetchError => ({ - _tag: "TerminalInlineImageFetchError", - message -}) - -const terminalInlineImageFetchHeaders: Readonly> = { - accept: "image/*", - "cache-control": "no-cache, no-store, max-age=0", - pragma: "no-cache" -} - -const readContentLength = (headers: Readonly>): number | null => { - const value = headers["content-length"] - if (value === undefined) { - return null - } - const parsed = Number.parseInt(value, 10) - return Number.isFinite(parsed) && parsed >= 0 ? parsed : null -} - -const validateTerminalInlineImageSize = (size: number): Effect.Effect => - size > terminalInlineImageMaxBytes - ? Effect.fail(terminalInlineImageFetchError("Terminal image is too large.")) - : Effect.void - -const appendTerminalInlineImageChunk = ( - state: TerminalInlineImageBufferState, - chunk: Uint8Array -): Effect.Effect => { - const nextSize = state.size + chunk.byteLength - return validateTerminalInlineImageSize(nextSize).pipe( - Effect.as({ - chunks: [...state.chunks, chunk], - size: nextSize - }) - ) -} - -const copyChunkToArrayBuffer = (chunk: Uint8Array): ArrayBuffer => { - const copy = new Uint8Array(chunk.byteLength) - copy.set(chunk) - return copy.buffer -} - -const imageBlobFromChunks = ( - chunks: ReadonlyArray, - mediaType: string | undefined -): Blob => - new Blob( - chunks.map((chunk) => copyChunkToArrayBuffer(chunk)), - mediaType === undefined ? {} : { type: mediaType } - ) - -const readTerminalInlineImageBlob = ( - response: HttpClientResponse.HttpClientResponse -): Effect.Effect => { - const contentLength = readContentLength(response.headers) - if (contentLength !== null && contentLength > terminalInlineImageMaxBytes) { - return Effect.fail(terminalInlineImageFetchError("Terminal image is too large.")) - } - return HttpClientResponse.stream(Effect.succeed(response)).pipe( - Stream.runFoldEffect(emptyTerminalInlineImageBufferState, appendTerminalInlineImageChunk), - Effect.map((state) => imageBlobFromChunks(state.chunks, response.headers["content-type"])), - Effect.mapError(() => terminalInlineImageFetchError("Could not read terminal image response.")) - ) -} - -const fetchTerminalInlineImageBlob = ( - fetchUrl: string -): Effect.Effect => - Effect.gen(function*(_) { - const client = yield* _(HttpClient.HttpClient) - const response = yield* _( - client.get(fetchUrl, { headers: terminalInlineImageFetchHeaders }).pipe( - Effect.mapError(() => terminalInlineImageFetchError("Could not fetch terminal image.")) - ) - ) - if (response.status >= 400) { - return yield* _(Effect.fail(terminalInlineImageFetchError(`Terminal image returned HTTP ${response.status}.`))) - } - return yield* _(readTerminalInlineImageBlob(response)) - }).pipe( - Effect.timeoutFail({ - duration: terminalInlineImageFetchTimeout, - onTimeout: () => terminalInlineImageFetchError("Terminal image fetch timed out.") - }), - Effect.provide(FetchHttpClient.layer) - ) - -const loadTerminalImageEntry = ( - handlers: TerminalMessageHandlers, - path: string, - onComplete: (entry: TerminalInlineImageEntry) => void -): void => { - const fetchUrl = resolveTerminalImageFetchUrl(handlers.session.websocketPath, path) - const cached = cachedTerminalInlineImageEntry(handlers.lifecycle.inlineImageObjectUrls, path, fetchUrl) - if (cached !== null) { - onComplete(cached) - return - } - Effect.runFork( - fetchTerminalInlineImageBlob(fetchUrl).pipe( - Effect.match({ - onFailure: () => unavailableTerminalInlineImageEntry(path, fetchUrl), - onSuccess: (blob) => - handlers.lifecycle.disposed - ? null - : cacheTerminalInlineImageBlob(handlers.lifecycle.inlineImageObjectUrls, path, fetchUrl, blob) - }), - Effect.flatMap((entry) => - Effect.sync(() => { - if (entry === null || handlers.lifecycle.disposed) { - return - } - onComplete(entry) - }) - ) - ) - ) -} - -const writePreviewSpacer = ( - handlers: TerminalMessageHandlers, - onComplete: () => void -): void => { - handlers.terminal.write(terminalInlineImageSpacer, onComplete) -} - -const writeInlineImagePreview = ( - handlers: TerminalMessageHandlers, - path: string, - onComplete: () => void -): void => { - const cached = terminalImageEntry(handlers, path) - if (cached !== null) { - writeInlineImagePreviewEntry(handlers, cached, onComplete) - return - } - loadTerminalImageEntry(handlers, path, (entry) => { - writeInlineImagePreviewEntry(handlers, entry, onComplete) - }) -} - -const writeInlineImagePreviewEntry = ( - handlers: TerminalMessageHandlers, - entry: TerminalInlineImageEntry, - onComplete: () => void -): void => { - const appended = appendTerminalInlineImagePreview( - handlers.terminal, - handlers.lifecycle, - entry - ) - if (!appended) { - onComplete() - return - } - writePreviewSpacer(handlers, onComplete) -} - -const writeInlineImagePreviews = ( - handlers: TerminalMessageHandlers, - paths: ReadonlyArray, - onComplete: () => void -): void => { - let index = 0 - const writeNext = (): void => { - const path = paths[index] - if (path === undefined) { - onComplete() - return - } - index += 1 - writeInlineImagePreview(handlers, path, writeNext) - } - writeNext() -} - -const writeLineBreakBeforePreview = ( - handlers: TerminalMessageHandlers, - segment: TerminalInlineImageOutputSegment, - onComplete: () => void -): void => { - if (segment.endedWithLineBreak) { - onComplete() - return - } - handlers.terminal.write("\r\n", onComplete) -} - -const flushTerminalOutputQueue = (handlers: TerminalMessageHandlers): void => { - if (handlers.lifecycle.outputWriting || handlers.lifecycle.disposed) { - return - } - const segment = handlers.lifecycle.outputQueue.shift() - if (segment === undefined) { - return - } - - handlers.lifecycle.outputWriting = true - writeTerminalOutputSegment({ - inlineImagePreviewsEnabledRef: handlers.inlineImagePreviewsEnabledRef, - segment, - writer: { - writePreviewLineBreak: (outputSegment, onComplete) => { - writeLineBreakBeforePreview(handlers, outputSegment, onComplete) - }, - writePreviews: (paths, onComplete) => { - writeInlineImagePreviews(handlers, paths, onComplete) - }, - writeText: (text, onComplete) => { - handlers.terminal.write(text, onComplete) - } - } - }, () => { - handlers.lifecycle.outputWriting = false - flushTerminalOutputQueue(handlers) - }) -} - -/** - * Enqueues terminal output segments and starts the sequential terminal flush loop. - * - * @param handlers - Runtime terminal handlers with mutable lifecycle queues and flags. - * @param data - Raw terminal output chunk to split into text and inline-image preview segments. - * @returns Nothing; lifecycle state is updated through `handlers`. - * @pure false - * @effect TerminalMessageHandlers.lifecycle outputQueue/outputWriting/disposed and terminal writes. - * @invariant `outputWriting` acts as a semaphore: at most one flush writes to the terminal at a time. - * @precondition `handlers.lifecycle.outputQueue` is an array, `outputWriting` is boolean, and handlers are live. - * @postcondition All split segments are appended before `flushTerminalOutputQueue(handlers)` is invoked. - * @complexity O(n) where n is the number of output segments parsed from `data`. - * @throws Never - */ -// CHANGE: document terminal output queueing as the shell boundary for inline image writes -// WHY: queue order and the outputWriting semaphore protect terminal write ordering across async previews -// QUOTE(ТЗ): "Limit inline-preview loading by timeout and size without freezing terminal output" -// REF: issue-339 -// SOURCE: n/a -// FORMAT THEOREM: enqueue(q, segments) -> flush observes q followed by segments in input order -// PURITY: SHELL -// EFFECT: TerminalMessageHandlers -> mutates lifecycle.outputQueue/outputWriting and writes to terminal -// INVARIANT: disposed handlers never start a new flush, and flush is called only after queue append -// COMPLEXITY: O(n) where n is the number of output segments parsed from `data` -export const enqueueTerminalOutput = ( - handlers: TerminalMessageHandlers, - data: string -): void => { - for (const segment of splitTerminalInlineImageOutput(data)) { - handlers.lifecycle.outputQueue.push(segment) - } - flushTerminalOutputQueue(handlers) -} +export * from "@prover-coder-ai/docker-git-terminal/web/terminal-panel-inline-images-runtime" diff --git a/packages/app/src/web/terminal-panel-input.ts b/packages/app/src/web/terminal-panel-input.ts index 4fade175..66bc2130 100644 --- a/packages/app/src/web/terminal-panel-input.ts +++ b/packages/app/src/web/terminal-panel-input.ts @@ -1,60 +1 @@ -import type { TerminalPasteGuard } from "./terminal-panel-runtime-types.js" - -export type TerminalClientMessage = - | { readonly data: string; readonly type: "input" } - | { readonly cols: number; readonly rows: number; readonly type: "resize" } - -type TerminalClientSocket = { - readonly readyState: number - readonly send: (data: string) => void -} - -export type TerminalClientSocketRef = { readonly current: TerminalClientSocket | null } - -type TerminalInputTarget = { - readonly onData: (handler: (data: string) => void) => { readonly dispose: () => void } - readonly scrollToBottom: () => void -} - -const csiPrefix = "\u001B[" -const x10MouseReportPrefix = `${csiPrefix}M` -const x10MouseReportLength = 6 -const sgrMouseReportBodyPattern = /^<\d+;\d+;\d+[Mm]$/u -const urxvtMouseReportBodyPattern = /^\d+;\d+;\d+M$/u - -export const isTerminalMouseReportInput = (data: string): boolean => { - if (data.startsWith(x10MouseReportPrefix)) { - return data.length === x10MouseReportLength - } - if (!data.startsWith(csiPrefix)) { - return false - } - const body = data.slice(csiPrefix.length) - return sgrMouseReportBodyPattern.test(body) || urxvtMouseReportBodyPattern.test(body) -} - -export const sendTerminalClientMessage = ( - socketRef: TerminalClientSocketRef, - message: TerminalClientMessage -): void => { - const socket = socketRef.current - if (socket === null || socket.readyState !== WebSocket.OPEN) { - return - } - socket.send(JSON.stringify(message)) -} - -export const attachTerminalInput = ( - terminal: TerminalInputTarget, - socketRef: TerminalClientSocketRef, - pasteGuard: TerminalPasteGuard -) => - terminal.onData((data) => { - if (pasteGuard.shouldSuppressTerminalInput(data)) { - return - } - if (!isTerminalMouseReportInput(data)) { - terminal.scrollToBottom() - } - sendTerminalClientMessage(socketRef, { data, type: "input" }) - }) +export * from "@prover-coder-ai/docker-git-terminal/web/terminal-panel-input" diff --git a/packages/app/src/web/terminal-panel-optional-operation.ts b/packages/app/src/web/terminal-panel-optional-operation.ts index 9cd77b86..a9d14078 100644 --- a/packages/app/src/web/terminal-panel-optional-operation.ts +++ b/packages/app/src/web/terminal-panel-optional-operation.ts @@ -1,21 +1 @@ -import { Effect, type Either } from "effect" - -type OptionalTerminalOperationError = { - readonly _tag: "OptionalTerminalOperationError" - readonly message: string -} - -export type OptionalTerminalOperationResult = Either.Either - -export const runOptionalTerminalOperation = (operation: () => void): OptionalTerminalOperationResult => - Effect.runSync( - Effect.either( - Effect.try({ - try: operation, - catch: (error) => ({ - _tag: "OptionalTerminalOperationError", - message: String(error) - }) - }) - ) - ) +export * from "@prover-coder-ai/docker-git-terminal/web/terminal-panel-optional-operation" diff --git a/packages/app/src/web/terminal-panel-runtime-core.ts b/packages/app/src/web/terminal-panel-runtime-core.ts index f82267b9..aaa03d60 100644 --- a/packages/app/src/web/terminal-panel-runtime-core.ts +++ b/packages/app/src/web/terminal-panel-runtime-core.ts @@ -1,289 +1 @@ -import { Either } from "effect" -import { Terminal } from "xterm" -import { FitAddon } from "xterm-addon-fit" - -import { enqueueTerminalOutput } from "./terminal-panel-inline-images-runtime.js" -import { sendTerminalClientMessage } from "./terminal-panel-input.js" -import { runOptionalTerminalOperation } from "./terminal-panel-optional-operation.js" -import type { - TerminalExitInfo, - TerminalInputController, - TerminalLifecycleState, - TerminalMessageHandlers, - TerminalRuntime, - TerminalSocketConnectArgs, - TerminalSocketListenerArgs, - TerminalSocketRef -} from "./terminal-panel-runtime-types.js" -import { installTerminalQuerySuppression, type TerminalQuerySuppressionOptions } from "./terminal-query-suppression.js" -import { resolveTerminalReconnectDelay, terminalReconnectGraceMs } from "./terminal-reconnect.js" -import { parseTerminalServerMessage, resolveTerminalWebSocketUrl } from "./terminal.js" - -export { cleanupTerminalResources } from "./terminal-panel-cleanup-runtime.js" -export { attachTerminalInput, isTerminalMouseReportInput } from "./terminal-panel-input.js" - -type TerminalRuntimeOptions = { - readonly querySuppression?: TerminalQuerySuppressionOptions -} - -export const createLifecycleState = (): TerminalLifecycleState => ({ - attachedOnce: false, - disposed: false, - inlineImageDisposables: [], - inlineImageObjectUrls: new Map(), - outputQueue: [], - outputWriting: false, - readyNotified: false, - reconnectAttempt: 0, - reconnectStartedAtMs: null, - reconnectTimer: null, - terminalEnded: false -}) - -const clearReconnectTimer = (lifecycle: TerminalLifecycleState): void => { - if (lifecycle.reconnectTimer !== null) { - clearTimeout(lifecycle.reconnectTimer) - lifecycle.reconnectTimer = null - } -} - -export const createTerminalRuntime = ( - host: HTMLDivElement, - options: TerminalRuntimeOptions = {} -): TerminalRuntime => { - const terminal = new Terminal({ - allowProposedApi: true, - convertEol: false, - cursorBlink: true, - fontFamily: "'IBM Plex Mono', 'SFMono-Regular', monospace", - fontSize: 14, - macOptionClickForcesSelection: true, - scrollback: 50_000, - scrollOnUserInput: false, - theme: { background: "#080a0d", foreground: "#f4f7fb" } - }) - installTerminalQuerySuppression(terminal, options.querySuppression) - const fitAddon = new FitAddon() - terminal.loadAddon(fitAddon) - terminal.open(host) - fitAddon.fit() - terminal.focus() - return { fitAddon, terminal } -} - -export const createTerminalInputController = ( - terminal: Terminal, - socketRef: TerminalSocketRef -): TerminalInputController => ({ - focus: () => { - terminal.focus() - }, - sendInput: (data: string) => { - if (data.length === 0) { - return - } - sendTerminalClientMessage(socketRef, { data, type: "input" }) - } -}) - -const createTerminalSocket = ( - session: TerminalSocketConnectArgs["session"], - terminal: Terminal -): WebSocket => new WebSocket(resolveTerminalWebSocketUrl(session.websocketPath, terminal.cols, terminal.rows)) - -export const sendTerminalResize = ( - fitAddon: FitAddon, - socketRef: TerminalSocketRef, - terminal: Terminal -): void => { - const fitResult = runOptionalTerminalOperation(() => { - fitAddon.fit() - }) - if (Either.isLeft(fitResult)) { - return - } - sendTerminalClientMessage(socketRef, { - cols: terminal.cols, - rows: terminal.rows, - type: "resize" - }) -} - -export const observeTerminalResize = ( - host: HTMLDivElement, - onResize: () => void -): ResizeObserver | null => { - if (typeof ResizeObserver !== "function") { - return null - } - const resizeObserver = new ResizeObserver(onResize) - resizeObserver.observe(host) - return resizeObserver -} - -const notifyTerminalReady = ( - handlers: TerminalMessageHandlers -): void => { - handlers.lifecycle.attachedOnce = true - handlers.connectionRef.current.opened = true - handlers.lifecycle.reconnectAttempt = 0 - handlers.lifecycle.reconnectStartedAtMs = null - clearReconnectTimer(handlers.lifecycle) - handlers.setStatus("attached") - if (handlers.lifecycle.readyNotified) { - handlers.notifyMessage("Terminal reconnected.") - return - } - handlers.lifecycle.readyNotified = true - handlers.notifyMessage(handlers.session.readyMessage) - handlers.session.onReady?.() -} - -const endTerminalSession = ( - handlers: TerminalMessageHandlers, - status: "error" | "exited", - line: string, - message: string, - exitInfo?: TerminalExitInfo -): void => { - handlers.lifecycle.terminalEnded = true - clearReconnectTimer(handlers.lifecycle) - handlers.terminal.writeln(line) - handlers.setStatus(status) - handlers.notifyMessage(message) - if (status === "exited") { - if (exitInfo !== undefined) { - handlers.notifyExit(exitInfo) - } - handlers.session.onExit?.() - } -} - -const handleTerminalServerMessage = ( - handlers: TerminalMessageHandlers, - payload: string -): void => { - const message = parseTerminalServerMessage(payload) - if (message === null) { - endTerminalSession(handlers, "error", "\r\n[terminal protocol error]", "Terminal protocol error.") - return - } - if (message.type === "ready") { - notifyTerminalReady(handlers) - return - } - if (message.type === "output") { - enqueueTerminalOutput(handlers, message.data) - return - } - if (message.type === "error") { - endTerminalSession(handlers, "error", `\r\n[error] ${message.message}`, message.message) - return - } - endTerminalSession(handlers, "exited", "\r\n[session ended]", handlers.session.exitMessage, { - exitCode: message.exitCode, - signal: message.signal - }) -} - -const attachTerminalSocketListeners = ( - { lifecycle, onClose, onError, onMessage, onOpen, socket }: TerminalSocketListenerArgs -): void => { - socket.addEventListener("open", onOpen) - socket.addEventListener("message", (event) => { - onMessage(typeof event.data === "string" ? event.data : "") - }) - socket.addEventListener("close", () => { - onClose(socket) - }) - socket.addEventListener("error", () => { - if (!lifecycle.disposed) { - onError(socket) - } - }) -} - -const failBeforeAttach = ( - args: TerminalSocketConnectArgs, - terminalLine: string, - uiMessage: string -): void => { - args.lifecycle.terminalEnded = true - clearReconnectTimer(args.lifecycle) - args.terminal.writeln(`\r\n${terminalLine}`) - args.setStatus("error") - args.notifyMessage(uiMessage) - args.handlers.connectionRef.current.closing = true - if (!args.lifecycle.attachedOnce) { - args.onAttachFailure() - } -} - -const scheduleReconnect = (args: TerminalSocketConnectArgs): void => { - if (args.lifecycle.disposed || args.lifecycle.terminalEnded) { - return - } - const startedAt = args.lifecycle.reconnectStartedAtMs ?? Date.now() - args.lifecycle.reconnectStartedAtMs = startedAt - if (Date.now() - startedAt >= terminalReconnectGraceMs) { - failBeforeAttach(args, "[terminal reconnect failed]", "Terminal reconnect failed.") - return - } - if (args.lifecycle.reconnectAttempt === 0) { - args.terminal.writeln("\r\n[terminal connection lost; reconnecting]") - args.notifyMessage("Terminal connection lost. Reconnecting...") - } - args.setStatus("reconnecting") - const delayMs = resolveTerminalReconnectDelay(args.lifecycle.reconnectAttempt) - args.lifecycle.reconnectAttempt += 1 - clearReconnectTimer(args.lifecycle) - args.lifecycle.reconnectTimer = setTimeout(args.reconnect, delayMs) -} - -const handleSocketClose = ( - args: TerminalSocketConnectArgs, - closedSocket: WebSocket -): void => { - if (args.socketRef.current !== closedSocket) { - return - } - args.socketRef.current = null - if (args.lifecycle.disposed || args.lifecycle.terminalEnded) { - return - } - if (!args.lifecycle.attachedOnce) { - failBeforeAttach(args, "[websocket closed before attach]", "Terminal websocket closed before attach.") - return - } - scheduleReconnect(args) -} -const handleSocketError = ( - args: TerminalSocketConnectArgs, - failedSocket: WebSocket -): void => { - if (args.socketRef.current !== failedSocket || args.lifecycle.attachedOnce) { - return - } - failBeforeAttach(args, "[websocket error]", "Terminal websocket error.") -} -export const connectTerminalSocket = (args: TerminalSocketConnectArgs): void => { - if (args.lifecycle.disposed || args.lifecycle.terminalEnded) { - return - } - const socket = createTerminalSocket(args.session, args.terminal) - args.socketRef.current = socket - attachTerminalSocketListeners({ - lifecycle: args.lifecycle, - onClose: (closedSocket) => { - handleSocketClose(args, closedSocket) - }, - onError: (failedSocket) => { - handleSocketError(args, failedSocket) - }, - onMessage: (payload) => { - handleTerminalServerMessage(args.handlers, payload) - }, - onOpen: args.sendResize, - socket - }) -} +export * from "@prover-coder-ai/docker-git-terminal/web/terminal-panel-runtime-core" diff --git a/packages/app/src/web/terminal-panel-runtime-types.ts b/packages/app/src/web/terminal-panel-runtime-types.ts index b36ffd58..2dfa3b88 100644 --- a/packages/app/src/web/terminal-panel-runtime-types.ts +++ b/packages/app/src/web/terminal-panel-runtime-types.ts @@ -1,105 +1 @@ -import type { IDisposable, Terminal } from "xterm" -import type { FitAddon } from "xterm-addon-fit" - -import type { - TerminalInlineImageOutputSegment, - TerminalInlineImagePreviewsEnabledRef -} from "./terminal-inline-images-core.js" -import type { ActiveTerminalSession } from "./terminal.js" - -export type TerminalStatus = "attached" | "connecting" | "error" | "exited" | "reconnecting" - -export type TerminalExitInfo = { - readonly exitCode: number | null - readonly signal: number | null -} - -export type TerminalConnectionState = { closing: boolean; opened: boolean } - -export type TerminalRuntime = { readonly fitAddon: FitAddon; readonly terminal: Terminal } - -export type TerminalInputController = { - readonly focus: () => void - readonly sendInput: (data: string) => void -} - -export type TerminalLifecycleState = { - attachedOnce: boolean - disposed: boolean - inlineImageDisposables: Array - inlineImageObjectUrls: Map - outputQueue: Array - outputWriting: boolean - readyNotified: boolean - reconnectAttempt: number - reconnectStartedAtMs: number | null - reconnectTimer: ReturnType | null - terminalEnded: boolean -} - -export type TerminalSocketRef = { current: WebSocket | null } - -export type TerminalPasteGuard = { - readonly shouldSuppressTerminalInput: (data: string) => boolean - readonly suppressNextNativeImagePaste: () => void -} - -export type TerminalMessageHandlers = { - readonly connectionRef: { current: TerminalConnectionState } - readonly inlineImagePreviewsEnabledRef: TerminalInlineImagePreviewsEnabledRef - readonly lifecycle: TerminalLifecycleState - readonly notifyExit: (info: TerminalExitInfo) => void - readonly notifyMessage: (message: string) => void - readonly session: ActiveTerminalSession - readonly setStatus: (status: TerminalStatus) => void - readonly terminal: Terminal -} - -export type TerminalCleanupArgs = { - readonly connectionRef: { current: TerminalConnectionState } - readonly lifecycle: TerminalLifecycleState - readonly notifyMessage: (message: string) => void - readonly removeImageLinks: () => void - readonly removeImagePaste: () => void - readonly removeInput: () => void - readonly removeResize: () => void - readonly resizeObserver: ResizeObserver | null - readonly runtimeRef: { current: TerminalInputController | null } - readonly session: ActiveTerminalSession - readonly socketRef: TerminalSocketRef - readonly terminal: Terminal -} - -export type TerminalLifecycleArgs = { - readonly connectionRef: { current: TerminalConnectionState } - readonly hostRef: { readonly current: HTMLDivElement | null } - readonly inlineImagePreviewsEnabledRef: TerminalInlineImagePreviewsEnabledRef - readonly notifyExit: (info: TerminalExitInfo) => void - readonly notifyMessage: (message: string) => void - readonly onAttachFailure: () => void - readonly runtimeRef: { current: TerminalInputController | null } - readonly session: ActiveTerminalSession - readonly setStatus: (status: TerminalStatus) => void -} - -export type TerminalSocketListenerArgs = { - readonly lifecycle: TerminalLifecycleState - readonly onClose: (socket: WebSocket) => void - readonly onError: (socket: WebSocket) => void - readonly onMessage: (payload: string) => void - readonly onOpen: () => void - readonly socket: WebSocket -} - -export type TerminalSocketConnectArgs = { - readonly handlers: TerminalMessageHandlers - readonly lifecycle: TerminalLifecycleState - readonly notifyMessage: (message: string) => void - readonly onAttachFailure: () => void - readonly reconnect: () => void - readonly sendResize: () => void - readonly session: ActiveTerminalSession - readonly setStatus: (status: TerminalStatus) => void - readonly socketRef: TerminalSocketRef - readonly terminal: Terminal -} +export * from "@prover-coder-ai/docker-git-terminal/web/terminal-panel-runtime-types" diff --git a/packages/app/src/web/terminal-panel-runtime.ts b/packages/app/src/web/terminal-panel-runtime.ts index 7c24282f..90616373 100644 --- a/packages/app/src/web/terminal-panel-runtime.ts +++ b/packages/app/src/web/terminal-panel-runtime.ts @@ -1,280 +1 @@ -import { useEffect } from "react" - -import { attachTerminalCopyInteraction } from "./terminal-copy-interaction.js" -import { attachTerminalImagePaste, createTerminalPasteGuard } from "./terminal-image-paste.js" -import { attachTerminalImageLinks } from "./terminal-inline-images.js" -import { - attachTerminalInput, - cleanupTerminalResources, - connectTerminalSocket, - createLifecycleState, - createTerminalInputController, - createTerminalRuntime, - observeTerminalResize, - sendTerminalResize -} from "./terminal-panel-runtime-core.js" -import type { - TerminalLifecycleArgs, - TerminalLifecycleState, - TerminalMessageHandlers, - TerminalPasteGuard, - TerminalSocketConnectArgs, - TerminalSocketRef -} from "./terminal-panel-runtime-types.js" -import { attachTerminalWheelScroll } from "./terminal-wheel-scroll.js" -import { isPendingActiveTerminalSession } from "./terminal.js" - -type TerminalDisposable = { readonly dispose: () => void } - -type TerminalCleanupFactoryArgs = { - readonly cleanupArgs: Omit< - Parameters[0], - "removeImageLinks" | "removeImagePaste" | "removeInput" | "removeResize" - > - readonly copyInteractionDisposable: TerminalDisposable - readonly imageLinkDisposable: TerminalDisposable - readonly imagePasteDisposable: TerminalDisposable - readonly inputDisposable: TerminalDisposable - readonly wheelScrollDisposable: TerminalDisposable - readonly sendResize: () => void -} - -const createTerminalCleanup = ( - { - cleanupArgs, - copyInteractionDisposable, - imageLinkDisposable, - imagePasteDisposable, - inputDisposable, - sendResize, - wheelScrollDisposable - }: TerminalCleanupFactoryArgs -): () => void => -(): void => { - cleanupTerminalResources({ - ...cleanupArgs, - removeImageLinks: () => { - imageLinkDisposable.dispose() - }, - removeImagePaste: () => { - imagePasteDisposable.dispose() - }, - removeInput: () => { - copyInteractionDisposable.dispose() - inputDisposable.dispose() - wheelScrollDisposable.dispose() - }, - removeResize: () => { - globalThis.removeEventListener("resize", sendResize) - globalThis.visualViewport?.removeEventListener("resize", sendResize) - globalThis.visualViewport?.removeEventListener("scroll", sendResize) - } - }) -} - -const createConnectSocket = ( - args: Omit -): () => void => { - const connectSocket = () => { - connectTerminalSocket({ ...args, reconnect: connectSocket }) - } - return connectSocket -} - -const attachGlobalResizeListeners = (sendResize: () => void): void => { - globalThis.addEventListener("resize", sendResize) - globalThis.visualViewport?.addEventListener("resize", sendResize) - globalThis.visualViewport?.addEventListener("scroll", sendResize) -} - -const createTerminalMessageHandlers = ( - args: TerminalLifecycleArgs, - lifecycle: TerminalLifecycleState, - terminal: TerminalMessageHandlers["terminal"] -): TerminalMessageHandlers => ({ - connectionRef: args.connectionRef, - inlineImagePreviewsEnabledRef: args.inlineImagePreviewsEnabledRef, - lifecycle, - notifyExit: args.notifyExit, - notifyMessage: args.notifyMessage, - session: args.session, - setStatus: args.setStatus, - terminal -}) - -type MountedTerminalDisposables = { - readonly copyInteractionDisposable: TerminalDisposable - readonly imageLinkDisposable: TerminalDisposable - readonly imagePasteDisposable: TerminalDisposable - readonly inputDisposable: TerminalDisposable - readonly wheelScrollDisposable: TerminalDisposable -} - -type MountedTerminalCleanupArgs = { - readonly args: TerminalLifecycleArgs - readonly disposables: MountedTerminalDisposables - readonly lifecycle: TerminalLifecycleState - readonly resizeObserver: ResizeObserver | null - readonly sendResize: () => void - readonly socketRef: TerminalSocketRef - readonly terminal: TerminalMessageHandlers["terminal"] -} - -const createMountedTerminalDisposables = ( - args: TerminalLifecycleArgs, - host: HTMLDivElement, - pasteGuard: TerminalPasteGuard, - socketRef: TerminalSocketRef, - terminal: TerminalMessageHandlers["terminal"] -): MountedTerminalDisposables => ({ - copyInteractionDisposable: attachTerminalCopyInteraction({ host, terminal }), - imageLinkDisposable: attachTerminalImageLinks(terminal, args.session), - imagePasteDisposable: attachTerminalImagePaste({ - host, - notifyMessage: args.notifyMessage, - pasteGuard, - socketRef, - terminal - }), - inputDisposable: attachTerminalInput(terminal, socketRef, pasteGuard), - wheelScrollDisposable: attachTerminalWheelScroll({ host, terminal }) -}) - -const createMountedTerminalConnector = ( - args: TerminalLifecycleArgs, - lifecycle: TerminalLifecycleState, - socketRef: TerminalSocketRef, - terminal: TerminalMessageHandlers["terminal"], - sendResize: () => void -): () => void => - createConnectSocket({ - handlers: createTerminalMessageHandlers(args, lifecycle, terminal), - lifecycle, - notifyMessage: args.notifyMessage, - onAttachFailure: args.onAttachFailure, - sendResize, - session: args.session, - setStatus: args.setStatus, - socketRef, - terminal - }) - -const createMountedTerminalCleanup = ( - { args, disposables, lifecycle, resizeObserver, sendResize, socketRef, terminal }: MountedTerminalCleanupArgs -): () => void => - createTerminalCleanup({ - cleanupArgs: { - connectionRef: args.connectionRef, - lifecycle, - notifyMessage: args.notifyMessage, - resizeObserver, - runtimeRef: args.runtimeRef, - session: args.session, - socketRef, - terminal - }, - copyInteractionDisposable: disposables.copyInteractionDisposable, - imageLinkDisposable: disposables.imageLinkDisposable, - imagePasteDisposable: disposables.imagePasteDisposable, - inputDisposable: disposables.inputDisposable, - sendResize, - wheelScrollDisposable: disposables.wheelScrollDisposable - }) - -const resolveMountHost = ( - { hostRef, session }: Pick -): HTMLDivElement | null => { - if (isPendingActiveTerminalSession(session)) { - return null - } - return hostRef.current -} - -const shouldAllowTerminalMouseTracking = (session: TerminalLifecycleArgs["session"]): boolean => - session.browserProjectId !== undefined - -const shouldSuppressTerminalAlternateScreen = (session: TerminalLifecycleArgs["session"]): boolean => - session.browserProjectId !== undefined - -const mountTerminalSession = (args: TerminalLifecycleArgs): (() => void) | undefined => { - const host = resolveMountHost(args) - if (host === null) { - return undefined - } - - args.connectionRef.current = { closing: false, opened: false } - const lifecycle = createLifecycleState() - const socketRef: TerminalSocketRef = { current: null } - const { fitAddon, terminal } = createTerminalRuntime(host, { - querySuppression: { - allowMouseTracking: shouldAllowTerminalMouseTracking(args.session), - suppressAlternateScreen: shouldSuppressTerminalAlternateScreen(args.session) - } - }) - const terminalInputController = createTerminalInputController(terminal, socketRef) - const pasteGuard = createTerminalPasteGuard() - const sendResize = (): void => { - sendTerminalResize(fitAddon, socketRef, terminal) - } - const resizeObserver = observeTerminalResize(host, sendResize) - const disposables = createMountedTerminalDisposables(args, host, pasteGuard, socketRef, terminal) - const connectSocket = createMountedTerminalConnector(args, lifecycle, socketRef, terminal, sendResize) - - args.runtimeRef.current = terminalInputController - attachGlobalResizeListeners(sendResize) - connectSocket() - - return createMountedTerminalCleanup({ - args, - disposables, - lifecycle, - resizeObserver, - sendResize, - socketRef, - terminal - }) -} - -export const useTerminalSessionLifecycle = ( - { - connectionRef, - hostRef, - inlineImagePreviewsEnabledRef, - notifyExit, - notifyMessage, - onAttachFailure, - runtimeRef, - session, - setStatus - }: TerminalLifecycleArgs -): void => { - useEffect(() => { - return mountTerminalSession({ - connectionRef, - hostRef, - inlineImagePreviewsEnabledRef, - notifyExit, - notifyMessage, - onAttachFailure, - runtimeRef, - session, - setStatus - }) - }, [ - connectionRef, - hostRef, - notifyMessage, - notifyExit, - onAttachFailure, - runtimeRef, - session, - setStatus - ]) -} - -export { - type TerminalConnectionState, - type TerminalExitInfo, - type TerminalInputController, - type TerminalStatus -} from "./terminal-panel-runtime-types.js" +export * from "@prover-coder-ai/docker-git-terminal/web/terminal-panel-runtime" diff --git a/packages/app/src/web/terminal-query-suppression.ts b/packages/app/src/web/terminal-query-suppression.ts index a6417b7b..3d50631f 100644 --- a/packages/app/src/web/terminal-query-suppression.ts +++ b/packages/app/src/web/terminal-query-suppression.ts @@ -1,173 +1 @@ -export type TerminalQuerySuppression = { readonly dispose: () => void } - -export type TerminalQuerySuppressionOptions = { - readonly allowMouseTracking?: boolean - readonly suppressAlternateScreen?: boolean -} - -type Disposable = { readonly dispose: () => void } - -type FunctionIdentifier = { - readonly final: string - readonly intermediates?: string - readonly prefix?: string -} - -type CsiParam = number | ReadonlyArray - -type CsiParams = ReadonlyArray - -export type TerminalQuerySuppressionTarget = { - readonly parser: { - readonly registerCsiHandler: ( - id: FunctionIdentifier, - callback: (params: CsiParams) => boolean - ) => Disposable - readonly registerDcsHandler: ( - id: FunctionIdentifier, - callback: (data: string, params: CsiParams) => boolean - ) => Disposable - readonly registerOscHandler: ( - ident: number, - callback: (data: string) => boolean - ) => Disposable - } -} - -// DEC private modes whose `h`/`l` setter can cause xterm.js to emit event bytes -// back through `onData` on later DOM events. -const MOUSE_TRACKING_PRIVATE_MODES: ReadonlySet = new Set([ - 1000, - 1002, - 1003, - 1006, - 1015, - 1016 -]) -const FOCUS_REPORTING_PRIVATE_MODE = 1004 -const ALTERNATE_SCREEN_PRIVATE_MODES: ReadonlySet = new Set([47, 1047, 1049]) - -// Suppressing SET leaves xterm.js in the default state (no event emission); -// suppressing RESET is harmless and kept for symmetry. -// Modes intentionally left to fall through to xterm's built-in handlers: -// 25 — cursor visibility -// 2004 — bracketed paste -// 2026 — synchronized output (Ink uses BSU/ESU around every frame) -// 1007 — alternate scroll (only changes wheel semantics, no leak) -// 47/1047/1049 — alternate screen, unless project terminals opt out to keep xterm scrollback visible - -const isColorQuery = (data: string): boolean => { - for (const segment of data.split(";")) { - if (segment === "?") { - return true - } - } - return false -} - -const extractParam = (param: CsiParam): number | null => { - if (typeof param === "number") { - return param - } - const head = param[0] - return typeof head === "number" ? head : null -} - -const shouldSuppressPrivateMode = ( - mode: number, - options: TerminalQuerySuppressionOptions -): boolean => - mode === FOCUS_REPORTING_PRIVATE_MODE || - (options.allowMouseTracking !== true && MOUSE_TRACKING_PRIVATE_MODES.has(mode)) || - (options.suppressAlternateScreen === true && ALTERNATE_SCREEN_PRIVATE_MODES.has(mode)) - -const containsSuppressedPrivateMode = ( - params: CsiParams, - options: TerminalQuerySuppressionOptions -): boolean => { - for (const param of params) { - const value = extractParam(param) - if (value !== null && shouldSuppressPrivateMode(value, options)) { - return true - } - } - return false -} - -const registerOscColorQuerySuppressor = ( - terminal: TerminalQuerySuppressionTarget, - identifier: number -): Disposable => terminal.parser.registerOscHandler(identifier, (data) => isColorQuery(data)) - -const registerCsiSuppressor = ( - terminal: TerminalQuerySuppressionTarget, - identifier: FunctionIdentifier -): Disposable => terminal.parser.registerCsiHandler(identifier, () => true) - -const registerDcsSuppressor = ( - terminal: TerminalQuerySuppressionTarget, - identifier: FunctionIdentifier -): Disposable => terminal.parser.registerDcsHandler(identifier, () => true) - -const registerSelectivePrivateModeSuppressor = ( - terminal: TerminalQuerySuppressionTarget, - final: "h" | "l", - options: TerminalQuerySuppressionOptions -): Disposable => - terminal.parser.registerCsiHandler( - { final, prefix: "?" }, - (params) => containsSuppressedPrivateMode(params, options) - ) - -export const installTerminalQuerySuppression = ( - terminal: TerminalQuerySuppressionTarget, - options: TerminalQuerySuppressionOptions = {} -): TerminalQuerySuppression => { - const disposables: ReadonlyArray = [ - // OSC 4/10/11/12 — color queries (`?` payload). Set-color payloads fall through. - registerOscColorQuerySuppressor(terminal, 4), - registerOscColorQuerySuppressor(terminal, 10), - registerOscColorQuerySuppressor(terminal, 11), - registerOscColorQuerySuppressor(terminal, 12), - // CSI c / > c / = c — primary/secondary/tertiary device attributes (DA1/DA2/DA3). - registerCsiSuppressor(terminal, { final: "c" }), - registerCsiSuppressor(terminal, { final: "c", prefix: ">" }), - registerCsiSuppressor(terminal, { final: "c", prefix: "=" }), - // CSI n / ? n — device status report and cursor position report. - registerCsiSuppressor(terminal, { final: "n" }), - registerCsiSuppressor(terminal, { final: "n", prefix: "?" }), - // CSI $ p / CSI ? $ p — DECRQM (ANSI and DEC private forms). xterm.js always - // replies via `requestMode`, including for the `?2026 $p` synchronized-output - // probe that Ink emits during startup. - registerCsiSuppressor(terminal, { final: "p", intermediates: "$" }), - registerCsiSuppressor(terminal, { final: "p", intermediates: "$", prefix: "?" }), - // DCS $ q ... ST — DECRQSS. xterm.js always replies via `requestStatusString`. - registerDcsSuppressor(terminal, { final: "q", intermediates: "$" }), - // DCS + q ... ST — XTGETTCAP. No reply in 5.3.0; suppressed for defense-in-depth. - registerDcsSuppressor(terminal, { final: "q", intermediates: "+" }), - // CSI > q — XTVERSION. Not in 5.3.0 but auto-replies in xterm.js master. - registerCsiSuppressor(terminal, { final: "q", prefix: ">" }), - // CSI Pm t — window manipulation. Gated by `windowOptions` (off by default); - // suppressed so an accidental future enable does not leak size reports. - registerCsiSuppressor(terminal, { final: "t" }), - // CSI ? h / CSI ? l — block xterm from enabling focus reporting and, - // unless explicitly allowed for tmux project terminals, mouse tracking modes. - // Other DEC private modes fall through to xterm's built-in setters. - registerSelectivePrivateModeSuppressor(terminal, "h", options), - registerSelectivePrivateModeSuppressor(terminal, "l", options) - ] - return { - dispose: () => { - for (const disposable of disposables) { - disposable.dispose() - } - } - } -} - -export const isTerminalColorQuery = isColorQuery - -export const isSuppressedDecPrivateMode = ( - mode: number, - options: TerminalQuerySuppressionOptions = {} -): boolean => shouldSuppressPrivateMode(mode, options) +export * from "@prover-coder-ai/docker-git-terminal/web/terminal-query-suppression" diff --git a/packages/app/src/web/terminal-reconnect.ts b/packages/app/src/web/terminal-reconnect.ts index 6e4ca7db..08563a1f 100644 --- a/packages/app/src/web/terminal-reconnect.ts +++ b/packages/app/src/web/terminal-reconnect.ts @@ -1,7 +1 @@ -export const terminalReconnectGraceMs = 60_000 - -const reconnectBaseDelayMs = 500 -const reconnectMaxDelayMs = 3000 - -export const resolveTerminalReconnectDelay = (attempt: number): number => - Math.min(reconnectBaseDelayMs * (2 ** Math.max(0, attempt)), reconnectMaxDelayMs) +export * from "@prover-coder-ai/docker-git-terminal/web/terminal-reconnect" diff --git a/packages/app/src/web/terminal-state.ts b/packages/app/src/web/terminal-state.ts index ac837ed7..60fb906f 100644 --- a/packages/app/src/web/terminal-state.ts +++ b/packages/app/src/web/terminal-state.ts @@ -1,158 +1 @@ -import type { ActiveTerminalSession } from "./terminal.js" - -export type TerminalWorkspaceState = { - readonly activeTerminalSessionId: string | null - readonly terminalSessions: ReadonlyArray -} - -type RemoveTerminalSessionOptions = { - readonly activateNeighbor?: boolean -} - -export const emptyTerminalWorkspaceState: TerminalWorkspaceState = { - activeTerminalSessionId: null, - terminalSessions: [] -} - -export const terminalSessionId = (session: ActiveTerminalSession): string => session.session.id - -const isProjectTerminalSession = (session: ActiveTerminalSession, projectId: string): boolean => - session.browserProjectId === projectId - -export const terminalSessionsForProject = ( - sessions: ReadonlyArray, - projectId: string -): ReadonlyArray => sessions.filter((session) => isProjectTerminalSession(session, projectId)) - -const latestProjectTerminalSession = ( - sessions: ReadonlyArray, - projectId: string -): ActiveTerminalSession | null => { - let latest: ActiveTerminalSession | null = null - for (const session of sessions) { - if (isProjectTerminalSession(session, projectId)) { - latest = session - } - } - return latest -} - -export const reusableProjectTerminalSessionId = ( - sessions: ReadonlyArray, - activeTerminalSessionId: string | null, - projectId: string -): string | null => { - const active = sessions.find((session) => - terminalSessionId(session) === activeTerminalSessionId && isProjectTerminalSession(session, projectId) - ) - const reusable = active ?? latestProjectTerminalSession(sessions, projectId) - return reusable === null ? null : terminalSessionId(reusable) -} - -const hasSessionId = (sessions: ReadonlyArray, sessionId: string | null): boolean => - sessionId !== null && sessions.some((session) => terminalSessionId(session) === sessionId) - -const normalizeTerminalWorkspaceState = (state: TerminalWorkspaceState): TerminalWorkspaceState => - state.activeTerminalSessionId === null || hasSessionId(state.terminalSessions, state.activeTerminalSessionId) - ? state - : { - ...state, - activeTerminalSessionId: null - } - -export const activeTerminalSession = (state: TerminalWorkspaceState): ActiveTerminalSession | null => { - const normalized = normalizeTerminalWorkspaceState(state) - return normalized.terminalSessions.find((session) => - terminalSessionId(session) === normalized.activeTerminalSessionId - ) ?? null -} - -export const activeTerminalSessionForProject = ( - state: TerminalWorkspaceState, - projectId: string -): ActiveTerminalSession | null => { - const active = activeTerminalSession(state) - return active !== null && isProjectTerminalSession(active, projectId) ? active : null -} - -export const deactivateTerminalWorkspaceState = (state: TerminalWorkspaceState): TerminalWorkspaceState => ({ - activeTerminalSessionId: null, - terminalSessions: state.terminalSessions -}) - -export const visibleTerminalWorkspaceState = (state: TerminalWorkspaceState): TerminalWorkspaceState => { - const active = activeTerminalSession(state) - if (active === null) { - return emptyTerminalWorkspaceState - } - - const activeSessionId = terminalSessionId(active) - if (active.browserProjectId === undefined) { - return { - activeTerminalSessionId: activeSessionId, - terminalSessions: [active] - } - } - - return { - activeTerminalSessionId: activeSessionId, - terminalSessions: terminalSessionsForProject(state.terminalSessions, active.browserProjectId) - } -} - -export const addTerminalSessionState = ( - state: TerminalWorkspaceState, - session: ActiveTerminalSession -): TerminalWorkspaceState => { - const sessionId = terminalSessionId(session) - const existingIndex = state.terminalSessions.findIndex((candidate) => terminalSessionId(candidate) === sessionId) - const terminalSessions = existingIndex === -1 - ? [...state.terminalSessions, session] - : state.terminalSessions.map((candidate, index) => index === existingIndex ? session : candidate) - return { - activeTerminalSessionId: sessionId, - terminalSessions - } -} - -export const selectTerminalSessionState = ( - state: TerminalWorkspaceState, - sessionId: string -): TerminalWorkspaceState => - hasSessionId(state.terminalSessions, sessionId) - ? { ...state, activeTerminalSessionId: sessionId } - : normalizeTerminalWorkspaceState(state) - -export const removeTerminalSessionState = ( - state: TerminalWorkspaceState, - sessionId: string, - options: RemoveTerminalSessionOptions = {} -): TerminalWorkspaceState => { - const removedIndex = state.terminalSessions.findIndex((session) => terminalSessionId(session) === sessionId) - if (removedIndex === -1) { - return normalizeTerminalWorkspaceState(state) - } - - const terminalSessions = state.terminalSessions.filter((session) => terminalSessionId(session) !== sessionId) - if (state.activeTerminalSessionId !== sessionId) { - return normalizeTerminalWorkspaceState({ - ...state, - terminalSessions - }) - } - - if (options.activateNeighbor === false) { - return { - activeTerminalSessionId: null, - terminalSessions - } - } - - const nextActiveSession = terminalSessions[removedIndex] ?? terminalSessions[removedIndex - 1] - return { - activeTerminalSessionId: nextActiveSession === undefined ? null : terminalSessionId(nextActiveSession), - terminalSessions - } -} - -export const hasTerminalSessions = (state: TerminalWorkspaceState): boolean => state.terminalSessions.length > 0 +export * from "@prover-coder-ai/docker-git-terminal/web/terminal-state" diff --git a/packages/app/src/web/terminal-wheel-scroll.ts b/packages/app/src/web/terminal-wheel-scroll.ts index d038615a..77ba7e45 100644 --- a/packages/app/src/web/terminal-wheel-scroll.ts +++ b/packages/app/src/web/terminal-wheel-scroll.ts @@ -1,152 +1 @@ -export type TerminalWheelMouseTrackingMode = "any" | "drag" | "none" | "vt200" | "x10" - -export type TerminalWheelScrollBufferType = "alternate" | "normal" - -export type TerminalWheelScrollBuffer = { - readonly active: { - readonly baseY: number - readonly type: TerminalWheelScrollBufferType - readonly viewportY: number - } -} - -export type TerminalWheelScrollTerminal = { - readonly buffer?: TerminalWheelScrollBuffer | undefined - readonly element?: TerminalWheelScrollTarget | null | undefined - readonly modes: { - readonly mouseTrackingMode: TerminalWheelMouseTrackingMode - } - readonly rows: number - readonly scrollLines: (amount: number) => void -} - -type TerminalWheelScrollEvent = { - readonly deltaMode: number - readonly deltaY: number - readonly stopImmediatePropagation?: () => void - readonly preventDefault: () => void - readonly stopPropagation: () => void -} - -type TerminalWheelScrollTarget = { - readonly addEventListener: ( - type: "wheel", - listener: (event: TerminalWheelScrollEvent) => void, - options: AddEventListenerOptions - ) => void - readonly removeEventListener: ( - type: "wheel", - listener: (event: TerminalWheelScrollEvent) => void, - options: boolean - ) => void -} - -type TerminalWheelScrollDelta = { - readonly deltaMode: number - readonly deltaY: number - readonly previousPixelDeltaY: number - readonly rows: number -} - -export type ResolvedTerminalWheelScrollDelta = { - readonly lines: number - readonly nextPixelDeltaY: number -} - -type TerminalWheelScrollArgs = { - readonly host: TerminalWheelScrollTarget - readonly terminal: TerminalWheelScrollTerminal -} - -const wheelPixelDeltaMode = 0 -const wheelLineDeltaMode = 1 -const wheelPageDeltaMode = 2 -const pixelsPerTerminalLine = 15 - -const hasActiveMouseTracking = (terminal: TerminalWheelScrollTerminal): boolean => - terminal.modes.mouseTrackingMode !== "none" - -const hasActiveAlternateBuffer = (terminal: TerminalWheelScrollTerminal): boolean => - terminal.buffer?.active.type === "alternate" - -const hasScrollableTerminalHistory = (terminal: TerminalWheelScrollTerminal): boolean => { - const activeBuffer = terminal.buffer?.active - return activeBuffer !== undefined && activeBuffer.type === "normal" && activeBuffer.baseY > 0 -} - -export const shouldHandleTerminalWheelScroll = (terminal: TerminalWheelScrollTerminal): boolean => - hasActiveMouseTracking(terminal) || - hasActiveAlternateBuffer(terminal) || - hasScrollableTerminalHistory(terminal) - -const resolveTerminalWheelScrollTarget = ( - { host, terminal }: TerminalWheelScrollArgs -): TerminalWheelScrollTarget => terminal.element ?? host - -const validTerminalRows = (rows: number): number => { - if (!Number.isFinite(rows) || rows < 1) { - return 1 - } - return Math.trunc(rows) -} - -const finiteDelta = (delta: number): number => { - if (!Number.isFinite(delta)) { - return 0 - } - return delta -} - -export const resolveTerminalWheelScrollDelta = ( - delta: TerminalWheelScrollDelta -): ResolvedTerminalWheelScrollDelta => { - const deltaY = finiteDelta(delta.deltaY) - if (delta.deltaMode === wheelLineDeltaMode) { - return { lines: Math.trunc(deltaY), nextPixelDeltaY: 0 } - } - if (delta.deltaMode === wheelPageDeltaMode) { - return { lines: Math.trunc(deltaY * validTerminalRows(delta.rows)), nextPixelDeltaY: 0 } - } - if (delta.deltaMode !== wheelPixelDeltaMode) { - return { lines: Math.trunc(deltaY), nextPixelDeltaY: 0 } - } - const nextPixelDeltaY = finiteDelta(delta.previousPixelDeltaY) + deltaY - const lines = Math.trunc(nextPixelDeltaY / pixelsPerTerminalLine) - return { - lines, - nextPixelDeltaY: nextPixelDeltaY - lines * pixelsPerTerminalLine - } -} - -export const attachTerminalWheelScroll = ( - args: TerminalWheelScrollArgs -): { readonly dispose: () => void } => { - let previousPixelDeltaY = 0 - const target = resolveTerminalWheelScrollTarget(args) - const onWheel = (event: TerminalWheelScrollEvent): void => { - if (!shouldHandleTerminalWheelScroll(args.terminal)) { - return - } - const scrollDelta = resolveTerminalWheelScrollDelta({ - deltaMode: event.deltaMode, - deltaY: event.deltaY, - previousPixelDeltaY, - rows: args.terminal.rows - }) - previousPixelDeltaY = scrollDelta.nextPixelDeltaY - event.preventDefault() - event.stopPropagation() - event.stopImmediatePropagation?.() - if (scrollDelta.lines !== 0) { - args.terminal.scrollLines(scrollDelta.lines) - } - } - - target.addEventListener("wheel", onWheel, { capture: true, passive: false }) - - return { - dispose: () => { - target.removeEventListener("wheel", onWheel, true) - } - } -} +export * from "@prover-coder-ai/docker-git-terminal/web/terminal-wheel-scroll" diff --git a/packages/app/src/web/terminal.ts b/packages/app/src/web/terminal.ts index 3fcab31f..953e74dd 100644 --- a/packages/app/src/web/terminal.ts +++ b/packages/app/src/web/terminal.ts @@ -1,240 +1,18 @@ -import * as ParseResult from "@effect/schema/ParseResult" -import { Either } from "effect" +import { + setTerminalApiBaseUrlResolver, + trimTerminalTrailingSlash +} from "@prover-coder-ai/docker-git-terminal/web/terminal" -import { TerminalServerMessageSchema } from "../shared/terminal-session-schema.js" -import type { TerminalServerMessage as ParsedTerminalServerMessage } from "../shared/terminal-session-schema.js" -import { resolveApiBaseUrl, trimTrailingSlash } from "./api-http.js" -import type { TerminalSession } from "./api-schema.js" +import { resolveApiBaseUrl } from "./api-http.js" -type PendingTerminalConnection = { - readonly message: string - readonly phase: "connecting" | "error" -} - -export type PendingActiveTerminalSession = ActiveTerminalSession & { - readonly pendingConnection: PendingTerminalConnection -} - -export type ActiveTerminalSession = { - readonly browserProjectId?: string | undefined - readonly browserProjectKey?: string | undefined - readonly browserProjectName?: string | undefined - readonly closePath: string - readonly exitMessage: string - readonly header: string - readonly onExit?: () => void - readonly onReady?: () => void - readonly pendingConnection?: PendingTerminalConnection | undefined - readonly pendingDeleteMessage: string - readonly readyMessage: string - readonly sessionPath?: string | undefined - readonly session: TerminalSession - readonly subtitle: string - readonly websocketPath: string -} - -type ProjectActiveTerminalSessionArgs = { - readonly onExit?: () => void - readonly onReady?: () => void - readonly projectDisplayName: string - readonly projectId: string - readonly projectKey: string - readonly session: TerminalSession -} - -type PendingProjectActiveTerminalSessionArgs = { - readonly createdAt?: string - readonly onExit?: () => void - readonly pendingSessionId: string - readonly projectDisplayName: string - readonly projectId: string - readonly projectKey: string - readonly phase?: PendingTerminalConnection["phase"] - readonly message?: string -} - -type ProjectTerminalSessionBaseArgs = { - readonly projectDisplayName: string - readonly projectId: string - readonly projectKey: string - readonly sessionId: string -} - -type ProjectTerminalSessionBase = Pick< - ActiveTerminalSession, - | "browserProjectId" - | "browserProjectKey" - | "browserProjectName" - | "closePath" - | "header" - | "readyMessage" - | "websocketPath" -> - -export const terminalSessionRoutePath = (sessionId: string): string => `/ssh/session/${encodeURIComponent(sessionId)}` - -const encodeProjectKeyPath = (projectKey: string): string => - projectKey.split("/").map((segment) => encodeURIComponent(segment)).join("/") - -const terminalUuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu - -export const terminalRouteToken = (sessionId: string): string => - terminalUuidPattern.test(sessionId) ? sessionId.slice(0, 8) : sessionId - -export const projectSshRoutePath = (projectKey: string, terminalId?: string): string => { - const path = `/ssh/${encodeProjectKeyPath(projectKey)}` - return terminalId === undefined ? path : `${path}?t=${encodeURIComponent(terminalRouteToken(terminalId))}` -} - -type TerminalLabelSession = { - readonly createdAt: string - readonly id: string -} - -const compareTerminalLabelSession = (left: TerminalLabelSession, right: TerminalLabelSession): number => { - const byCreatedAt = left.createdAt.localeCompare(right.createdAt) - return byCreatedAt === 0 ? left.id.localeCompare(right.id) : byCreatedAt -} - -export const terminalTitle = (index: number): string => `Terminal ${index + 1}` - -const terminalTitleEntry = ( - session: TerminalLabelSession, - index: number -): readonly [string, string] => [session.id, terminalTitle(index)] - -export const terminalTitleById = ( - sessions: ReadonlyArray -): ReadonlyMap => - new Map( - sessions - .toSorted(compareTerminalLabelSession) - .map((session, index) => terminalTitleEntry(session, index)) - ) - -export const isPendingActiveTerminalSession = ( - session: ActiveTerminalSession -): session is PendingActiveTerminalSession => session.pendingConnection !== undefined - -const buildProjectTerminalSessionBase = ( - { projectDisplayName, projectId, projectKey, sessionId }: ProjectTerminalSessionBaseArgs -): ProjectTerminalSessionBase => { - const encodedProjectKey = encodeURIComponent(projectKey) - const encodedSessionId = encodeURIComponent(sessionId) - const terminalSessionPath = `/projects/by-key/${encodedProjectKey}/terminal-sessions/${encodedSessionId}` - return { - browserProjectId: projectId, - browserProjectKey: projectKey, - browserProjectName: projectDisplayName, - closePath: terminalSessionPath, - header: `SSH terminal: ${projectDisplayName}`, - readyMessage: `SSH connected: ${projectDisplayName}.`, - websocketPath: `${terminalSessionPath}/ws` - } -} - -export const buildProjectActiveTerminalSession = ( - { onExit, onReady, projectDisplayName, projectId, projectKey, session }: ProjectActiveTerminalSessionArgs -): ActiveTerminalSession => { - const base = buildProjectTerminalSessionBase({ - projectDisplayName, - projectId, - projectKey, - sessionId: session.id - }) - return { - ...base, - exitMessage: "SSH session ended.", - ...(onExit === undefined ? {} : { onExit }), - ...(onReady === undefined ? {} : { onReady }), - pendingDeleteMessage: `Terminal session was closed before attach: ${projectDisplayName}.`, - session, - sessionPath: projectSshRoutePath(projectKey, session.id), - subtitle: session.sshCommand - } -} - -const resolvePendingProjectMessage = ( - message: string | undefined, - phase: PendingTerminalConnection["phase"] -): string => { - const trimmedMessage = message?.trim() ?? "" - if (trimmedMessage.length > 0) { - return trimmedMessage - } - return phase === "error" - ? "SSH session startup failed." - : "Starting project and waiting for SSH..." -} - -export const buildPendingProjectActiveTerminalSession = ( - { - createdAt, - message, - onExit, - pendingSessionId, - phase = "connecting", - projectDisplayName, - projectId, - projectKey - }: PendingProjectActiveTerminalSessionArgs -): ActiveTerminalSession => { - const base = buildProjectTerminalSessionBase({ - projectDisplayName, - projectId, - projectKey, - sessionId: pendingSessionId - }) - const resolvedMessage = resolvePendingProjectMessage(message, phase) - return { - ...base, - exitMessage: "Pending SSH session closed.", - ...(onExit === undefined ? {} : { onExit }), - pendingConnection: { - message: resolvedMessage, - phase - }, - pendingDeleteMessage: `Pending SSH terminal was closed before attach: ${projectDisplayName}.`, - readyMessage: `SSH connected: ${projectDisplayName}.`, - session: { - createdAt: createdAt ?? new Date().toISOString(), - id: pendingSessionId, - projectId, - sshCommand: "Preparing SSH session...", - status: phase === "error" ? "failed" : "ready" - }, - sessionPath: projectSshRoutePath(projectKey, pendingSessionId), - subtitle: resolvedMessage - } -} - -export const resolveTerminalApiBaseUrl = (): string => { - const configured = import.meta.env.VITE_DOCKER_GIT_TERMINAL_API_BASE_URL +const resolveConfiguredTerminalApiBaseUrl = (): string | null => { + const configured = import.meta.env["VITE_DOCKER_GIT_TERMINAL_API_BASE_URL"] if (configured !== undefined && configured.trim().length > 0) { - return trimTrailingSlash(configured.trim()) + return trimTerminalTrailingSlash(configured.trim()) } - - return resolveApiBaseUrl() -} - -export const resolveTerminalApiOriginUrl = (): URL => { - const configured = resolveTerminalApiBaseUrl() - if (configured.startsWith("http://") || configured.startsWith("https://")) { - return new URL(configured) - } - return new URL(configured, globalThis.location.origin) -} - -export const resolveTerminalWebSocketUrl = (websocketPath: string, cols: number, rows: number): string => { - const apiUrl = resolveTerminalApiOriginUrl() - apiUrl.protocol = apiUrl.protocol === "https:" ? "wss:" : "ws:" - apiUrl.pathname = `${apiUrl.pathname.replace(/\/$/u, "")}${websocketPath}` - apiUrl.searchParams.set("cols", String(cols)) - apiUrl.searchParams.set("rows", String(rows)) - return apiUrl.toString() + return null } -export const parseTerminalServerMessage = (value: string): ParsedTerminalServerMessage | null => - Either.getOrNull(ParseResult.decodeUnknownEither(TerminalServerMessageSchema)(value)) +setTerminalApiBaseUrlResolver(() => resolveConfiguredTerminalApiBaseUrl() ?? resolveApiBaseUrl()) -export { type TerminalServerMessage } from "../shared/terminal-session-schema.js" +export * from "@prover-coder-ai/docker-git-terminal/web/terminal" diff --git a/packages/app/tests/docker-git/core-templates.test.ts b/packages/app/tests/docker-git/core-templates.test.ts index c8f7fbc0..904b036f 100644 --- a/packages/app/tests/docker-git/core-templates.test.ts +++ b/packages/app/tests/docker-git/core-templates.test.ts @@ -72,7 +72,9 @@ describe("app planFiles", () => { expect(entrypoint.contents).toContain("docker_git_stop_playwright_browser()") expect(entrypoint.contents).toContain("docker-git-browser-connection") expect(entrypoint.contents).toContain("stop --project \"$project_container\"") - expect(entrypoint.contents).toContain('command = "browser-connection"') - expect(entrypoint.contents).toContain('args = ["--project", "$DOCKER_GIT_BROWSER_PROJECT", "--network", "$DOCKER_GIT_BROWSER_NETWORK"]') + expect(entrypoint.contents).toContain("command = \"browser-connection\"") + expect(entrypoint.contents).toContain( + "args = [\"--project\", \"$DOCKER_GIT_BROWSER_PROJECT\", \"--network\", \"$DOCKER_GIT_BROWSER_NETWORK\"]" + ) }) }) diff --git a/packages/lib/src/core/templates-prompt.ts b/packages/lib/src/core/templates-prompt.ts index 61358844..91d38647 100644 --- a/packages/lib/src/core/templates-prompt.ts +++ b/packages/lib/src/core/templates-prompt.ts @@ -20,8 +20,65 @@ const dockerGitTerminalSanitizeShell = String.raw`docker_git_terminal_write_esca fi return 1 } +docker_git_terminal_process_args() { + ps -o args= -p "$1" 2>/dev/null || true +} +docker_git_terminal_parent_pid() { + ps -o ppid= -p "$1" 2>/dev/null | tr -d '[:space:]' +} +docker_git_terminal_command_basename() { + local command_line="$1" + printf "%s\n" "$command_line" | awk '{ name = $1; sub(/^.*\//, "", name); print name; exit }' +} +docker_git_terminal_is_agent_command() { + local command_name + command_name="$(docker_git_terminal_command_basename "$1")" + case "$command_name" in + .docker-git-claude-real|claude|codex|opencode|gemini|grok) + return 0 + ;; + *) + return 1 + ;; + esac +} +docker_git_terminal_has_agent_ancestor() { + local pid="$1" + local depth=0 + local command_line="" + local parent_pid="" + if [ -z "$pid" ]; then + pid="$$" + fi + while [ -n "$pid" ] && [ "$pid" != "0" ] && [ "$depth" -lt 32 ]; do + command_line="$(docker_git_terminal_process_args "$pid")" + if docker_git_terminal_is_agent_command "$command_line"; then + return 0 + fi + parent_pid="$(docker_git_terminal_parent_pid "$pid")" + if [ -z "$parent_pid" ] || [ "$parent_pid" = "$pid" ]; then + return 1 + fi + pid="$parent_pid" + depth=$((depth + 1)) + done + return 1 +} +docker_git_terminal_should_sanitize() { + if [ -n "$(printenv DOCKER_GIT_TERMINAL_FORCE_SANITIZE 2>/dev/null)" ]; then + return 0 + fi + if [ -n "$(printenv DOCKER_GIT_TERMINAL_DISABLE_SANITIZE 2>/dev/null)" ]; then + return 1 + fi + if docker_git_terminal_has_agent_ancestor "$$"; then + return 1 + fi + return 0 +} docker_git_terminal_sanitize() { # Recover interactive TTY settings after abrupt exits from fullscreen/raw-mode tools. + docker_git_terminal_should_sanitize || return 0 if [ -c /dev/tty ]; then { stty sane < /dev/tty > /dev/tty; } 2>/dev/null || { stty sane < /dev/tty; } 2>/dev/null || true elif [ -t 0 ]; then @@ -107,7 +164,7 @@ else PROMPT_COMMAND="docker_git_prompt_apply" fi docker_git_terminal_sanitize -trap 'docker_git_terminal_sanitize' EXIT INT TERM` +trap 'docker_git_terminal_sanitize' EXIT` export const renderPromptScript = (): string => dockerGitPromptScript diff --git a/packages/lib/src/core/templates-zsh.ts b/packages/lib/src/core/templates-zsh.ts index 635a787d..92b8c875 100644 --- a/packages/lib/src/core/templates-zsh.ts +++ b/packages/lib/src/core/templates-zsh.ts @@ -100,16 +100,6 @@ docker_git_terminal_sanitize add-zsh-hook precmd docker_git_prompt_apply add-zsh-hook zshexit docker_git_terminal_on_exit -TRAPINT() { - docker_git_terminal_sanitize - return 130 -} - -TRAPTERM() { - docker_git_terminal_sanitize - return 143 -} - HISTFILE="\${HISTFILE:-$HOME/.zsh_history}" HISTSIZE="\${HISTSIZE:-10000}" SAVEHIST="\${SAVEHIST:-20000}" diff --git a/packages/lib/tests/core/templates.test.ts b/packages/lib/tests/core/templates.test.ts index eaf41d45..aad832c2 100644 --- a/packages/lib/tests/core/templates.test.ts +++ b/packages/lib/tests/core/templates.test.ts @@ -269,7 +269,7 @@ describe("renderPromptScript", () => { fc.assert( fc.property( - fc.constantFrom("PROMPT_COMMAND=", "PS1=", "trap 'docker_git_terminal_sanitize' EXIT INT TERM"), + fc.constantFrom("PROMPT_COMMAND=", "PS1=", "trap 'docker_git_terminal_sanitize' EXIT"), (interactiveMutation) => { const script = renderPromptScript() const guardIndex = script.indexOf(nonInteractiveGuard) @@ -280,6 +280,142 @@ describe("renderPromptScript", () => { ) ) }) + + it("does not run terminal recovery traps for active interrupt or terminate signals", () => { + const script = renderPromptScript() + + expect(script).toContain("trap 'docker_git_terminal_sanitize' EXIT") + expect(script).not.toContain("trap 'docker_git_terminal_sanitize' EXIT INT TERM") + expect(script).not.toContain("trap 'docker_git_terminal_sanitize' INT") + expect(script).not.toContain("trap 'docker_git_terminal_sanitize' TERM") + }) + + it("gates terminal recovery before stty sane can touch the TTY", () => { + const script = renderPromptScript() + const guardIndex = script.indexOf("docker_git_terminal_should_sanitize || return 0") + const sttyIndex = script.indexOf("{ stty sane < /dev/tty > /dev/tty; }") + + expect(script).toContain("docker_git_terminal_has_agent_ancestor") + expect(script).toContain("docker_git_terminal_command_basename") + expect(script).toContain("printenv DOCKER_GIT_TERMINAL_FORCE_SANITIZE") + expect(script).toContain("printenv DOCKER_GIT_TERMINAL_DISABLE_SANITIZE") + expect(guardIndex).toBeGreaterThanOrEqual(0) + expect(sttyIndex).toBeGreaterThan(guardIndex) + }) + + it.effect("matches only agent command basenames for terminal recovery suppression", () => + pipe( + Command.make( + "bash", + "-lc", + String.raw`set -euo pipefail +source <(printf '%s' "$DOCKER_GIT_PROMPT_SCRIPT") +for command_line in \ + "claude --dangerously-skip-permissions" \ + "/usr/bin/.docker-git-claude-real" \ + "/usr/local/bin/codex resume" \ + "/opt/bin/opencode" \ + "/usr/bin/gemini --model test" \ + "/usr/bin/grok"; do + docker_git_terminal_is_agent_command "$command_line" || { printf 'missing:%s' "$command_line"; exit 1; } +done +for command_line in "codex-helper" "/tmp/grok-cache" "myclaude" "node /usr/bin/playwright-mcp"; do + if docker_git_terminal_is_agent_command "$command_line"; then + printf 'false-positive:%s' "$command_line" + exit 1 + fi +done +printf ok` + ), + Command.env({ DOCKER_GIT_PROMPT_SCRIPT: renderPromptScript() }), + Command.stdout("pipe"), + Command.stderr("pipe"), + Command.string, + Effect.tap((output) => Effect.sync(() => expect(output).toBe("ok"))), + Effect.asVoid, + Effect.provide(NodeContext.layer) + ) + ) + + it.effect("skips terminal recovery when the shell is under an agent process", () => + pipe( + Command.make( + "bash", + "-lc", + String.raw`set -euo pipefail +tmp="$(mktemp -d)" +cleanup() { rm -rf "$tmp"; } +trap cleanup EXIT +cat > "$tmp/ps" <<'EOS' +#!/usr/bin/env bash +if [ "$1" = "-o" ] && [ "$2" = "args=" ]; then + printf '%s\n' "/usr/bin/.docker-git-claude-real" + exit 0 +fi +if [ "$1" = "-o" ] && [ "$2" = "ppid=" ]; then + printf '%s\n' "0" + exit 0 +fi +exit 1 +EOS +chmod +x "$tmp/ps" +PATH="$tmp:$PATH" +source <(printf '%s' "$DOCKER_GIT_PROMPT_SCRIPT") +if docker_git_terminal_should_sanitize; then + printf bad +else + printf ok +fi` + ), + Command.env({ DOCKER_GIT_PROMPT_SCRIPT: renderPromptScript() }), + Command.stdout("pipe"), + Command.stderr("pipe"), + Command.string, + Effect.tap((output) => Effect.sync(() => expect(output).toBe("ok"))), + Effect.asVoid, + Effect.provide(NodeContext.layer) + ) + ) + + it.effect("allows terminal recovery when no agent is in the shell ancestry", () => + pipe( + Command.make( + "bash", + "-lc", + String.raw`set -euo pipefail +tmp="$(mktemp -d)" +cleanup() { rm -rf "$tmp"; } +trap cleanup EXIT +cat > "$tmp/ps" <<'EOS' +#!/usr/bin/env bash +if [ "$1" = "-o" ] && [ "$2" = "args=" ]; then + printf '%s\n' "-zsh" + exit 0 +fi +if [ "$1" = "-o" ] && [ "$2" = "ppid=" ]; then + printf '%s\n' "0" + exit 0 +fi +exit 1 +EOS +chmod +x "$tmp/ps" +PATH="$tmp:$PATH" +source <(printf '%s' "$DOCKER_GIT_PROMPT_SCRIPT") +if docker_git_terminal_should_sanitize; then + printf ok +else + printf bad +fi` + ), + Command.env({ DOCKER_GIT_PROMPT_SCRIPT: renderPromptScript() }), + Command.stdout("pipe"), + Command.stderr("pipe"), + Command.string, + Effect.tap((output) => Effect.sync(() => expect(output).toBe("ok"))), + Effect.asVoid, + Effect.provide(NodeContext.layer) + ) + ) }) describe("renderEntrypoint clone cache", () => { @@ -630,12 +766,14 @@ describe("renderEntrypoint auth bridge", () => { "{ stty sane < /dev/tty > /dev/tty; } 2>/dev/null", '*) return 0 2>/dev/null || exit 0 ;;', "docker_git_terminal_sanitize", - "trap 'docker_git_terminal_sanitize' EXIT INT TERM", + "trap 'docker_git_terminal_sanitize' EXIT", "add-zsh-hook zshexit docker_git_terminal_on_exit", - "TRAPINT() {", 'if [[ "${DOCKER_GIT_ZSH_AUTOSUGGEST:-0}" == "1" ]]', "DOCKER_GIT_ZSH_AUTOSUGGEST=0" ]) + expect(entrypoint).not.toContain("trap 'docker_git_terminal_sanitize' EXIT INT TERM") + expect(entrypoint).not.toContain("TRAPINT() {") + expect(entrypoint).not.toContain("TRAPTERM() {") }) it("refreshes clone cache mirrors without fetching GitHub pull request refs", () => { diff --git a/packages/terminal/.jscpd.json b/packages/terminal/.jscpd.json new file mode 100644 index 00000000..dbb0615e --- /dev/null +++ b/packages/terminal/.jscpd.json @@ -0,0 +1,16 @@ +{ + "threshold": 0, + "minTokens": 30, + "minLines": 5, + "ignore": [ + "**/node_modules/**", + "**/build/**", + "**/dist/**", + "**/*.min.js", + "**/reports/**" + ], + "skipComments": true, + "ignorePattern": [ + "private readonly \\w+: \\w+;\\s*private readonly \\w+: \\w+;\\s*private \\w+: \\w+ \\| null = null;\\s*private \\w+: \\w+ \\| null = null;" + ] +} diff --git a/packages/terminal/biome.json b/packages/terminal/biome.json new file mode 100644 index 00000000..4e2c41d1 --- /dev/null +++ b/packages/terminal/biome.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json", + "vcs": { + "enabled": false, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "ignoreUnknown": false + }, + "formatter": { + "enabled": false, + "indentStyle": "tab" + }, + "assist": { + "enabled": false + }, + "linter": { + "enabled": false, + "rules": { + "recommended": false, + "suspicious": { + "noExplicitAny": "off" + } + } + }, + "javascript": { + "formatter": { + "enabled": false, + "quoteStyle": "double", + "semicolons": "asNeeded" + } + } +} diff --git a/packages/terminal/eslint.config.mts b/packages/terminal/eslint.config.mts new file mode 100644 index 00000000..de6632f8 --- /dev/null +++ b/packages/terminal/eslint.config.mts @@ -0,0 +1,305 @@ +// eslint.config.mjs +// @ts-check +import eslint from '@eslint/js'; +import { defineConfig } from 'eslint/config'; +import tseslint from 'typescript-eslint'; +import vitest from "@vitest/eslint-plugin"; +import suggestMembers from "@prover-coder-ai/eslint-plugin-suggest-members"; +import sonarjs from "eslint-plugin-sonarjs"; +import unicorn from "eslint-plugin-unicorn"; +import * as effectEslint from "@effect/eslint-plugin"; +import { fixupPluginRules } from "@eslint/compat"; +import codegen from "eslint-plugin-codegen"; +import importPlugin from "eslint-plugin-import"; +import simpleImportSort from "eslint-plugin-simple-import-sort"; +import sortDestructureKeys from "eslint-plugin-sort-destructure-keys"; +import globals from "globals"; +import eslintCommentsConfigs from "@eslint-community/eslint-plugin-eslint-comments/configs"; + +const codegenPlugin = fixupPluginRules( + codegen as unknown as Parameters[0], +); + +const noFetchExample = [ + "Пример:", + " import { FetchHttpClient, HttpClient } from \"@effect/platform\"", + " import { Effect } from \"effect\"", + " const program = Effect.gen(function* () {", + " const client = yield* HttpClient.HttpClient", + " return yield* client.get(`${api}/robots`)", + " }).pipe(", + " Effect.scoped,", + " Effect.provide(FetchHttpClient.layer)", + " )", +].join("\n"); + +export default defineConfig( + eslint.configs.recommended, + tseslint.configs.strictTypeChecked, + effectEslint.configs.dprint, + suggestMembers.configs.recommended, + eslintCommentsConfigs.recommended, + { + name: "analyzers", + languageOptions: { + parser: tseslint.parser, + globals: { ...globals.node, ...globals.browser }, + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + plugins: { + sonarjs, + unicorn, + import: fixupPluginRules(importPlugin), + "sort-destructure-keys": sortDestructureKeys, + "simple-import-sort": simpleImportSort, + codegen: codegenPlugin, + }, + files: ["**/*.{ts,tsx}", '**/*.{test,spec}.{ts,tsx}', '**/tests/**', '**/__tests__/**'], + settings: { + "import/parsers": { + "@typescript-eslint/parser": [".ts", ".tsx"], + }, + "import/resolver": { + typescript: { + alwaysTryTypes: true, + }, + }, + }, + rules: { + ...sonarjs.configs.recommended.rules, + ...unicorn.configs.recommended.rules, + "no-restricted-imports": ["error", { + paths: [ + { + name: "ts-pattern", + message: "Use Effect.Match instead of ts-pattern.", + }, + { + name: "zod", + message: "Use @effect/schema for schemas and validation.", + }, + ], + }], + "codegen/codegen": "error", + "import/first": "error", + "import/newline-after-import": "error", + "import/no-duplicates": "error", + "import/no-unresolved": "off", + "import/order": "off", + "simple-import-sort/imports": "off", + "sort-destructure-keys/sort-destructure-keys": "error", + "no-fallthrough": "off", + "no-irregular-whitespace": "off", + "object-shorthand": "error", + "prefer-destructuring": "off", + "sort-imports": "off", + "no-unused-vars": "off", + "prefer-rest-params": "off", + "prefer-spread": "off", + "unicorn/prefer-top-level-await": "off", + "unicorn/prevent-abbreviations": "off", + "unicorn/no-null": "off", + complexity: ["error", 8], + "max-lines-per-function": [ + "error", + { max: 50, skipBlankLines: true, skipComments: true }, + ], + "max-params": ["error", 5], + "max-depth": ["error", 4], + "max-lines": [ + "error", + { max: 300, skipBlankLines: true, skipComments: true }, + ], + + "@typescript-eslint/restrict-template-expressions": ["error", { + allowNumber: true, + allowBoolean: true, + allowNullish: false, + allowAny: false, + allowRegExp: false + }], + "@eslint-community/eslint-comments/no-use": "error", + "@eslint-community/eslint-comments/no-unlimited-disable": "error", + "@eslint-community/eslint-comments/disable-enable-pair": "error", + "@eslint-community/eslint-comments/no-unused-disable": "error", + "no-restricted-syntax": [ + "error", + { + selector: "TSUnknownKeyword", + message: "Запрещено 'unknown'.", + }, + // CHANGE: запрет прямого fetch в коде + // WHY: enforce Effect-TS httpClient as единственный источник сетевых эффектов + // QUOTE(ТЗ): "Вместо fetch должно быть всегда написано httpClient от библиотеки Effect-TS" + // REF: user-msg-1 + // SOURCE: n/a + // FORMAT THEOREM: ∀call ∈ Calls: callee(call)=fetch → lint_error(call) + // PURITY: SHELL + // EFFECT: Effect + // INVARIANT: direct fetch calls are forbidden + // COMPLEXITY: O(1) + { + selector: "CallExpression[callee.name='fetch']", + message: `Запрещён fetch — используй HttpClient (Effect-TS).\n${noFetchExample}`, + }, + { + selector: + "CallExpression[callee.object.name='window'][callee.property.name='fetch']", + message: `Запрещён window.fetch — используй HttpClient (Effect-TS).\n${noFetchExample}`, + }, + { + selector: + "CallExpression[callee.object.name='globalThis'][callee.property.name='fetch']", + message: `Запрещён globalThis.fetch — используй HttpClient (Effect-TS).\n${noFetchExample}`, + }, + { + selector: + "CallExpression[callee.object.name='self'][callee.property.name='fetch']", + message: `Запрещён self.fetch — используй HttpClient (Effect-TS).\n${noFetchExample}`, + }, + { + selector: + "CallExpression[callee.object.name='global'][callee.property.name='fetch']", + message: `Запрещён global.fetch — используй HttpClient (Effect-TS).\n${noFetchExample}`, + }, + { + selector: "TryStatement", + message: "Используй Effect.try / catchAll вместо try/catch в core/app/domain.", + }, + { + selector: "SwitchStatement", + message: [ + "Switch statements are forbidden in functional programming paradigm.", + "How to fix: Use Effect.Match instead.", + "Example:", + " import { Match } from 'effect';", + " type Item = { type: 'this' } | { type: 'that' };", + " const result = Match.value(item).pipe(", + " Match.when({ type: 'this' }, (it) => processThis(it)),", + " Match.when({ type: 'that' }, (it) => processThat(it)),", + " Match.exhaustive,", + " );", + ].join("\n"), + }, + { + selector: 'CallExpression[callee.name="require"]', + message: "Avoid using require(). Use ES6 imports instead.", + }, + { + selector: "ThrowStatement > Literal:not([value=/^\\w+Error:/])", + message: + 'Do not throw string literals or non-Error objects. Throw new Error("...") instead.', + }, + { + selector: + "FunctionDeclaration[async=true], FunctionExpression[async=true], ArrowFunctionExpression[async=true]", + message: + "Запрещён async/await — используй Effect.gen / Effect.tryPromise.", + }, + { + selector: "NewExpression[callee.name='Promise']", + message: + "Запрещён new Promise — используй Effect.async / Effect.tryPromise.", + }, + { + selector: "CallExpression[callee.object.name='Promise']", + message: + "Запрещены Promise.* — используй комбинаторы Effect (all, forEach, etc.).", + }, + { + selector: "CallExpression[callee.property.name='push'] > SpreadElement.arguments", + message: "Do not use spread arguments in Array.push", + }, + ], + "no-throw-literal": "error", + "@typescript-eslint/no-restricted-types": [ + "error", + { + types: { + unknown: { + message: + "Не используем 'unknown'. Уточни тип или наведи порядок в источнике данных.", + }, + Promise: { + message: "Запрещён Promise — используй Effect.Effect.", + suggest: ["Effect.Effect"], + }, + "Promise<*>": { + message: + "Запрещён Promise — используй Effect.Effect.", + suggest: ["Effect.Effect"], + }, + }, + }, + ], + "@typescript-eslint/use-unknown-in-catch-callback-variable": "off", + // "no-throw-literal": "off", + "@typescript-eslint/only-throw-error": [ + "error", + { allowThrowingUnknown: false, allowThrowingAny: false }, + ], + "@typescript-eslint/array-type": ["warn", { + default: "generic", + readonly: "generic" + }], + "@typescript-eslint/member-delimiter-style": 0, + "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/ban-types": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-empty-interface": "off", + "@typescript-eslint/consistent-type-imports": "warn", + "@typescript-eslint/no-unused-vars": ["error", { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_" + }], + "@typescript-eslint/ban-ts-comment": "off", + "@typescript-eslint/camelcase": "off", + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/explicit-module-boundary-types": "off", + "@typescript-eslint/interface-name-prefix": "off", + "@typescript-eslint/no-array-constructor": "off", + "@typescript-eslint/no-use-before-define": "off", + "@typescript-eslint/no-namespace": "off", + "@effect/dprint": ["error", { + config: { + indentWidth: 2, + lineWidth: 120, + semiColons: "asi", + quoteStyle: "alwaysDouble", + trailingCommas: "never", + operatorPosition: "maintain", + "arrowFunction.useParentheses": "force" + } + }] + } + }, + { + files: ['**/*.{test,spec}.{ts,tsx}', 'tests/**', '**/__tests__/**'], + ...vitest.configs.all, + languageOptions: { + globals: { + ...vitest.environments.env.globals, + }, + }, + rules: { + // Allow eslint-disable/enable comments in test files for fine-grained control + '@eslint-community/eslint-comments/no-use': 'off', + // Disable line count limit for E2E tests that contain multiple test cases + 'max-lines-per-function': 'off', + // `it.effect` is not recognized by sonar rule; disable to avoid false positives + 'sonarjs/no-empty-test-file': 'off', + }, + }, + + // 3) Для JS-файлов отключим типо-зависимые проверки + { + files: ['**/*.{js,cjs,mjs}'], + extends: [tseslint.configs.disableTypeChecked], + }, + + // 4) Глобальные игноры + { ignores: ['dist/**', 'build/**', 'coverage/**', '**/dist/**'] }, +); diff --git a/packages/terminal/eslint.effect-ts-check.config.mjs b/packages/terminal/eslint.effect-ts-check.config.mjs new file mode 100644 index 00000000..b36df69e --- /dev/null +++ b/packages/terminal/eslint.effect-ts-check.config.mjs @@ -0,0 +1,220 @@ +// CHANGE: add Effect-TS compliance lint profile +// WHY: detect current deviations from strict Effect-TS guidance +// QUOTE(TZ): n/a +// REF: AGENTS.md Effect-TS compliance checks +// SOURCE: n/a +// PURITY: SHELL +// EFFECT: eslint config +// INVARIANT: config only flags explicit policy deviations +// COMPLEXITY: O(1)/O(1) +import eslintComments from "@eslint-community/eslint-plugin-eslint-comments" +import globals from "globals" +import tseslint from "typescript-eslint" + +const restrictedImports = [ + { + name: "node:fs", + message: "Use @effect/platform FileSystem instead of node:fs." + }, + { + name: "fs", + message: "Use @effect/platform FileSystem instead of fs." + }, + { + name: "node:fs/promises", + message: "Use @effect/platform FileSystem instead of node:fs/promises." + }, + { + name: "node:path/posix", + message: "Use @effect/platform Path instead of node:path/posix." + }, + { + name: "node:path", + message: "Use @effect/platform Path instead of node:path." + }, + { + name: "path", + message: "Use @effect/platform Path instead of path." + }, + { + name: "node:child_process", + message: "Use @effect/platform Command instead of node:child_process." + }, + { + name: "child_process", + message: "Use @effect/platform Command instead of child_process." + }, + { + name: "node:process", + message: "Use @effect/platform Runtime instead of node:process." + }, + { + name: "process", + message: "Use @effect/platform Runtime instead of process." + } +] + +const restrictedSyntaxBase = [ + { + selector: "SwitchStatement", + message: "Switch is forbidden. Use Match.exhaustive." + }, + { + selector: "TryStatement", + message: "Avoid try/catch in product code. Use Effect.try / Effect.catch*." + }, + { + selector: "AwaitExpression", + message: "Avoid await. Use Effect.gen / Effect.flatMap." + }, + { + selector: "FunctionDeclaration[async=true], FunctionExpression[async=true], ArrowFunctionExpression[async=true]", + message: "Avoid async/await. Use Effect.gen / Effect.tryPromise." + }, + { + selector: "NewExpression[callee.name='Promise']", + message: "Avoid new Promise. Use Effect.async / Effect.tryPromise." + }, + { + selector: "CallExpression[callee.object.name='Promise']", + message: "Avoid Promise.*. Use Effect combinators." + }, + { + selector: "CallExpression[callee.name='require']", + message: "Avoid require(). Use ES module imports." + }, + { + selector: "TSAsExpression", + message: "Casting is only allowed in src/core/axioms.ts." + }, + { + selector: "TSTypeAssertion", + message: "Casting is only allowed in src/core/axioms.ts." + }, + { + selector: "CallExpression[callee.name='makeFilesystemService']", + message: "Do not instantiate FilesystemService directly. Provide Layer and access via Tag." + }, + { + selector: "CallExpression[callee.property.name='catchAll']", + message: "Avoid catchAll that discards typed errors; map or propagate explicitly." + } +] + +const restrictedSyntaxCore = [ + ...restrictedSyntaxBase, + { + selector: "TSUnknownKeyword", + message: "unknown is allowed only at shell boundaries with decoding." + }, + { + selector: "CallExpression[callee.property.name='runSyncExit']", + message: "Effect.runSyncExit is shell-only. Move to a runner." + }, + { + selector: "CallExpression[callee.property.name='runSync']", + message: "Effect.runSync is shell-only. Move to a runner." + }, + { + selector: "CallExpression[callee.property.name='runPromise']", + message: "Effect.runPromise is shell-only. Move to a runner." + } +] + +const restrictedSyntaxCoreNoAs = [ + ...restrictedSyntaxCore.filter((rule) => + rule.selector !== "TSAsExpression" && rule.selector !== "TSTypeAssertion" + ) +] + +const restrictedSyntaxBaseNoServiceFactory = [ + ...restrictedSyntaxBase.filter((rule) => + rule.selector !== "CallExpression[callee.name='makeFilesystemService']" + ) +] + +export default tseslint.config( + { + name: "effect-ts-compliance-check", + files: ["src/**/*.ts", "scripts/**/*.ts"], + languageOptions: { + parser: tseslint.parser, + globals: { ...globals.node } + }, + plugins: { + "@typescript-eslint": tseslint.plugin, + "eslint-comments": eslintComments + }, + rules: { + "no-console": "error", + "no-restricted-imports": ["error", { + paths: restrictedImports, + patterns: [ + { + group: ["node:*"], + message: "Do not import from node:* directly. Use @effect/platform-node or @effect/platform services." + } + ] + }], + "no-restricted-syntax": ["error", ...restrictedSyntaxBase], + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/ban-ts-comment": ["error", { + "ts-ignore": true, + "ts-nocheck": true, + "ts-check": false, + "ts-expect-error": true + }], + "@typescript-eslint/no-restricted-types": ["error", { + types: { + Promise: { + message: "Avoid Promise in types. Use Effect.Effect." + }, + "Promise<*>": { + message: "Avoid Promise. Use Effect.Effect." + } + } + }], + "eslint-comments/no-use": "error", + "eslint-comments/no-unlimited-disable": "error", + "eslint-comments/disable-enable-pair": "error", + "eslint-comments/no-unused-disable": "error" + } + }, + { + name: "effect-ts-compliance-core", + files: ["src/core/**/*.ts"], + rules: { + "no-restricted-syntax": ["error", ...restrictedSyntaxCore], + "no-restricted-imports": ["error", { + paths: restrictedImports, + patterns: [ + { + group: [ + "../shell/**", + "../../shell/**", + "../../../shell/**", + "./shell/**", + "src/shell/**", + "shell/**" + ], + message: "CORE must not import from SHELL." + } + ] + }] + } + }, + { + name: "effect-ts-compliance-axioms", + files: ["src/core/axioms.ts"], + rules: { + "no-restricted-syntax": ["error", ...restrictedSyntaxCoreNoAs] + } + }, + { + name: "effect-ts-compliance-filesystem-service", + files: ["src/shell/services/filesystem.ts"], + rules: { + "no-restricted-syntax": ["error", ...restrictedSyntaxBaseNoServiceFactory] + } + } +) diff --git a/packages/terminal/linter.config.json b/packages/terminal/linter.config.json new file mode 100644 index 00000000..31dc1a21 --- /dev/null +++ b/packages/terminal/linter.config.json @@ -0,0 +1,33 @@ +{ + "priorityLevels": [ + { + "level": 1, + "name": "Critical Compiler Errors", + "rules": [ + "ts(2835)", + "ts(2307)", + "@prover-coder-ai/suggest-members/suggest-members", + "@prover-coder-ai/suggest-members/suggest-imports", + "@prover-coder-ai/suggest-members/suggest-module-paths", + "@prover-coder-ai/suggest-members/suggest-exports", + "@prover-coder-ai/suggest-members/suggest-missing-names", + "@typescript-eslint/no-explicit-any" + ] + }, + { + "level": 2, + "name": "Critical Compiler Errors", + "rules": ["all"] + }, + { + "level": 3, + "name": "Critical Compiler Errors (Code must follow Clean Code and best practices)", + "rules": ["max-lines-per-function", "max-lines"] + }, + { + "level": 4, + "name": "Critical Compiler Errors (Code must follow Clean Code and best practices)", + "rules": ["complexity", "max-params", "max-depth"] + } + ] +} diff --git a/packages/terminal/package.json b/packages/terminal/package.json new file mode 100644 index 00000000..2af885ef --- /dev/null +++ b/packages/terminal/package.json @@ -0,0 +1,104 @@ +{ + "name": "@prover-coder-ai/docker-git-terminal", + "version": "0.1.0", + "private": true, + "description": "Shared docker-git terminal contracts, core logic, and runtime adapters", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "type": "module", + "packageManager": "bun@1.3.11", + "scripts": { + "build": "tsc -p tsconfig.build.json", + "check": "bun run typecheck", + "dev": "tsc -p tsconfig.build.json --watch", + "lint": "NODE_OPTIONS=--max-old-space-size=4096 PATH=../../scripts:$PATH vibecode-linter src/", + "lint:tests": "NODE_OPTIONS=--max-old-space-size=4096 PATH=../../scripts:$PATH vibecode-linter tests/", + "lint:effect": "NODE_OPTIONS=--max-old-space-size=4096 PATH=../../scripts:$PATH eslint --config eslint.effect-ts-check.config.mjs .", + "test": "bun run lint:tests && vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@effect/platform": "^0.96.1", + "@effect/platform-node": "^0.106.0", + "@effect/schema": "^0.75.5", + "effect": "^3.21.2", + "react": "^19.2.6", + "xterm": "^5.3.0", + "xterm-addon-fit": "^0.8.0" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.16", + "@effect/eslint-plugin": "^0.3.2", + "@effect/language-service": "latest", + "@effect/vitest": "^0.29.0", + "@eslint-community/eslint-plugin-eslint-comments": "^4.7.2", + "@eslint/compat": "2.1.0", + "@eslint/eslintrc": "3.3.5", + "@eslint/js": "10.0.1", + "@prover-coder-ai/eslint-plugin-suggest-members": "^0.0.26", + "@ton-ai-core/vibecode-linter": "^1.0.11", + "@types/node": "^25.9.1", + "@types/react": "^19.2.15", + "@types/react-dom": "^19.2.3", + "@typescript-eslint/eslint-plugin": "^8.60.0", + "@typescript-eslint/parser": "^8.60.0", + "@vitest/coverage-v8": "^4.1.7", + "@vitest/eslint-plugin": "^1.6.18", + "eslint": "^10.4.1", + "eslint-import-resolver-typescript": "^4.4.4", + "eslint-plugin-codegen": "0.34.1", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-simple-import-sort": "^13.0.0", + "eslint-plugin-sonarjs": "^4.0.3", + "eslint-plugin-sort-destructure-keys": "^3.0.0", + "eslint-plugin-unicorn": "^64.0.0", + "fast-check": "^4.8.0", + "globals": "^17.6.0", + "jscpd": "^4.2.4", + "react-dom": "^19.2.6", + "typescript": "^6.0.3", + "typescript-eslint": "^8.60.0", + "vite": "^8.0.14", + "vite-tsconfig-paths": "^6.1.1", + "vitest": "^4.1.7" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./contracts": { + "types": "./dist/contracts/index.d.ts", + "import": "./dist/contracts/index.js" + }, + "./core": { + "types": "./dist/core/index.d.ts", + "import": "./dist/core/index.js" + }, + "./server": { + "types": "./dist/server/index.d.ts", + "import": "./dist/server/index.js" + }, + "./web": { + "types": "./dist/web/index.d.ts", + "import": "./dist/web/index.js" + }, + "./web/*": { + "types": "./dist/web/*.d.ts", + "import": "./dist/web/*.js" + }, + "./cli": { + "types": "./dist/cli/index.d.ts", + "import": "./dist/cli/index.js" + }, + "./shell": { + "types": "./dist/shell/index.d.ts", + "import": "./dist/shell/index.js" + } + }, + "repository": { + "type": "git", + "url": "git+https://github.com/ProverCoderAI/docker-git.git" + }, + "license": "MIT" +} diff --git a/packages/terminal/src/app/main.ts b/packages/terminal/src/app/main.ts new file mode 100644 index 00000000..0921311b --- /dev/null +++ b/packages/terminal/src/app/main.ts @@ -0,0 +1,18 @@ +import { NodeContext, NodeRuntime } from "@effect/platform-node" +import { Effect, pipe } from "effect" + +import { program } from "./program.js" + +// CHANGE: add a thin Node runtime entrypoint for the terminal package. +// WHY: follow the effect-template shape and keep runtime provision outside core/contracts. +// QUOTE(ТЗ): "Используй так же модуль" +// REF: issue-361-terminal-package +// SOURCE: https://github.com/ProverCoderAI/effect-template/tree/main/packages/app +// FORMAT THEOREM: main = provide(program, NodeContext) +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: NodeContext is provided only at the package runtime boundary. +// COMPLEXITY: O(1)/O(1) +const main = pipe(program, Effect.provide(NodeContext.layer)) + +NodeRuntime.runMain(main) diff --git a/packages/terminal/src/app/program.ts b/packages/terminal/src/app/program.ts new file mode 100644 index 00000000..4ae2d2e9 --- /dev/null +++ b/packages/terminal/src/app/program.ts @@ -0,0 +1,13 @@ +import { Effect } from "effect" + +// CHANGE: define a reusable terminal package program entrypoint. +// WHY: mirror effect-template's thin app/program composition while keeping library exports as the primary surface. +// QUOTE(ТЗ): "Используй так же модуль: https://github.com/ProverCoderAI/effect-template/tree/main/packages/app" +// REF: issue-361-terminal-package +// SOURCE: https://github.com/ProverCoderAI/effect-template/tree/main/packages/app +// FORMAT THEOREM: run(program) -> no_op +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: terminal package entrypoint has no side effects until a concrete adapter composes it. +// COMPLEXITY: O(1)/O(1) +export const program = Effect.void diff --git a/packages/terminal/src/cli/index.ts b/packages/terminal/src/cli/index.ts new file mode 100644 index 00000000..03fdefb5 --- /dev/null +++ b/packages/terminal/src/cli/index.ts @@ -0,0 +1,35 @@ +import { Context, Effect } from "effect" +import { makeTerminalRuntimeBoundaryLayer } from "../runtime-boundary.js" + +export type TerminalCliRuntimeService = { + readonly run: (args: ReadonlyArray) => Effect.Effect +} + +/** + * CLI runtime boundary for terminal commands. + * + * @pure false - service methods may run host CLI effects in concrete layers. + * @effect TerminalCliRuntime + * @invariant CLI effects are injected through this Context.Tag, never imported by core/contracts. + * @precondition args is an immutable argv slice supplied by the host CLI shell. + * @postcondition Noop layer preserves observable no-op behavior. + * @complexity O(1) for Noop; concrete layers define their own cost. + */ +// CHANGE: replace marker boundary with a real Effect service boundary. +// WHY: satisfy FCIS by making CLI effects injectable through Context.Tag/Layer. +// QUOTE(ТЗ): "Делаем то что говорит rabbit" +// REF: coderabbit-runtime-boundary +// SOURCE: n/a +// FORMAT THEOREM: ∀core: core ∉ Requires +// PURITY: SHELL +// EFFECT: Layer +// INVARIANT: CLI runtime is available only by providing TerminalCliRuntime. +// COMPLEXITY: O(1)/O(1) +export class TerminalCliRuntime extends Context.Tag("TerminalCliRuntime")< + TerminalCliRuntime, + TerminalCliRuntimeService +>() { + static readonly Noop = makeTerminalRuntimeBoundaryLayer(this, { + run: () => Effect.void + }) +} diff --git a/packages/terminal/src/contracts/index.ts b/packages/terminal/src/contracts/index.ts new file mode 100644 index 00000000..8433c379 --- /dev/null +++ b/packages/terminal/src/contracts/index.ts @@ -0,0 +1 @@ +export * from "./session.js" diff --git a/packages/terminal/src/contracts/session.ts b/packages/terminal/src/contracts/session.ts new file mode 100644 index 00000000..fc8a16ac --- /dev/null +++ b/packages/terminal/src/contracts/session.ts @@ -0,0 +1,174 @@ +import * as Schema from "@effect/schema/Schema" + +/** + * Terminal lifecycle status literals shared by server and clients. + * + * @pure true + * @invariant status ∈ {"ready","attached","exited","failed"} + * @complexity O(1) + */ +// CHANGE: expose shared terminal session status schema. +// WHY: API, browser, and CLI must decode one status vocabulary. +// QUOTE(ТЗ): "терминал это наше отображение терминала из докера с общим шерингом" +// REF: issue-361-terminal-package +// SOURCE: n/a +// FORMAT THEOREM: decode(status) succeeds ⇔ status ∈ TerminalSessionStatus +// PURITY: CORE +// INVARIANT: status schema is a closed literal union. +// COMPLEXITY: O(1)/O(1) +export const TerminalSessionStatusSchema = Schema.Union( + Schema.Literal("ready"), + Schema.Literal("attached"), + Schema.Literal("exited"), + Schema.Literal("failed") +) + +/** + * Terminal session state exchanged over HTTP/WebSocket boundaries. + * + * @pure true + * @invariant id, projectId, sshCommand, createdAt are required strings. + * @invariant optional lifecycle fields may be absent but keep their declared type when present. + * @complexity O(n) where n = encoded field count. + */ +// CHANGE: define the shared terminal session schema. +// WHY: API and UI should not maintain duplicate session contracts. +// QUOTE(ТЗ): "терминал это наше отображение терминала из докера с общим шерингом" +// REF: issue-361-terminal-package +// SOURCE: n/a +// FORMAT THEOREM: decode(session) succeeds → session.status ∈ TerminalSessionStatus +// PURITY: CORE +// INVARIANT: decoded sessions have a known lifecycle status. +// COMPLEXITY: O(n)/O(n) +export const TerminalSessionSchema = Schema.Struct({ + id: Schema.String, + projectId: Schema.String, + sshCommand: Schema.String, + status: TerminalSessionStatusSchema, + createdAt: Schema.String, + attachedClients: Schema.optional(Schema.Number), + startedAt: Schema.optional(Schema.String), + closedAt: Schema.optional(Schema.String), + exitCode: Schema.optional(Schema.Number), + signal: Schema.optional(Schema.Number) +}) + +/** + * Client-to-server terminal message payload schema. + * + * @pure true + * @invariant message.type determines the exact required payload fields. + * @complexity O(n) where n = encoded field count. + */ +// CHANGE: define shared terminal client message payloads. +// WHY: browser/CLI input, resize, image, and close messages need one decoder. +// QUOTE(ТЗ): "терминал это наше отображение терминала из докера с общим шерингом" +// REF: issue-361-terminal-package +// SOURCE: n/a +// FORMAT THEOREM: decode(clientMessage) succeeds → type ∈ {"input","resize","image","close"} +// PURITY: CORE +// INVARIANT: client message schema is a discriminated union by type. +// COMPLEXITY: O(n)/O(n) +export const TerminalClientMessagePayloadSchema = Schema.Union( + Schema.Struct({ + type: Schema.Literal("input"), + data: Schema.String + }), + Schema.Struct({ + type: Schema.Literal("resize"), + cols: Schema.Number, + rows: Schema.Number + }), + Schema.Struct({ + type: Schema.Literal("image"), + data: Schema.String, + mediaType: Schema.String, + name: Schema.String, + size: Schema.Number + }), + Schema.Struct({ + type: Schema.Literal("close") + }) +) + +/** + * Server-to-client terminal message payload schema. + * + * @pure true + * @invariant message.type determines the exact required payload fields. + * @complexity O(n) where n = encoded field count. + */ +const TerminalServerMessagePayloadSchema = Schema.Union( + Schema.Struct({ + type: Schema.Literal("ready"), + session: TerminalSessionSchema + }), + Schema.Struct({ + type: Schema.Literal("output"), + data: Schema.String + }), + Schema.Struct({ + type: Schema.Literal("exit"), + exitCode: Schema.NullOr(Schema.Number), + signal: Schema.NullOr(Schema.Number) + }), + Schema.Struct({ + type: Schema.Literal("error"), + message: Schema.String + }) +) + +/** + * JSON decoder for client terminal messages. + * + * @pure true + * @invariant successful decode returns TerminalClientMessage. + * @complexity O(n) where n = JSON input length. + */ +// CHANGE: expose JSON codec for terminal client messages. +// WHY: WebSocket adapters should share one parser. +// QUOTE(ТЗ): "терминал это наше отображение терминала из докера с общим шерингом" +// REF: issue-361-terminal-package +// SOURCE: n/a +// FORMAT THEOREM: parseJson(clientPayloadJson) = TerminalClientMessage | ParseError +// PURITY: CORE +// INVARIANT: codec parses only the shared client payload schema. +// COMPLEXITY: O(n)/O(n) +export const TerminalClientMessageSchema = Schema.parseJson( + TerminalClientMessagePayloadSchema +) + +/** + * JSON decoder for server terminal messages. + * + * @pure true + * @invariant successful decode returns TerminalServerMessage. + * @complexity O(n) where n = JSON input length. + */ +// CHANGE: expose JSON codec for terminal server messages. +// WHY: clients should share one parser for ready/output/exit/error events. +// QUOTE(ТЗ): "терминал это наше отображение терминала из докера с общим шерингом" +// REF: issue-361-terminal-package +// SOURCE: n/a +// FORMAT THEOREM: parseJson(serverPayloadJson) = TerminalServerMessage | ParseError +// PURITY: CORE +// INVARIANT: codec parses only the shared server payload schema. +// COMPLEXITY: O(n)/O(n) +export const TerminalServerMessageSchema = Schema.parseJson( + TerminalServerMessagePayloadSchema +) + +/** Shared terminal session status type derived from TerminalSessionStatusSchema. */ +export type TerminalSessionStatus = Schema.Schema.Type< + typeof TerminalSessionStatusSchema +> +/** Shared terminal session type derived from TerminalSessionSchema. */ +export type TerminalSession = Schema.Schema.Type +/** Shared client message type derived from TerminalClientMessagePayloadSchema. */ +export type TerminalClientMessage = Schema.Schema.Type< + typeof TerminalClientMessagePayloadSchema +> +/** Shared server message type derived from TerminalServerMessagePayloadSchema. */ +export type TerminalServerMessage = Schema.Schema.Type< + typeof TerminalServerMessagePayloadSchema +> diff --git a/packages/terminal/src/core/image-paste.ts b/packages/terminal/src/core/image-paste.ts new file mode 100644 index 00000000..811c70a1 --- /dev/null +++ b/packages/terminal/src/core/image-paste.ts @@ -0,0 +1,276 @@ +export type TerminalImagePastePayload = { + readonly data: string + readonly mediaType: string + readonly name: string + readonly size: number +} + +export type TerminalImagePastePlan = + | { + readonly _tag: "InvalidTerminalImagePaste" + readonly message: string + } + | { + readonly _tag: "ValidTerminalImagePaste" + readonly containerPath: string + readonly decodedBytes: number + readonly normalizedBase64: string + } + +type InvalidTerminalImagePastePlan = Extract< + TerminalImagePastePlan, + { readonly _tag: "InvalidTerminalImagePaste" } +> + +/** + * Container directory used for image paste files. + * + * @pure true + * @invariant all valid paste plans place files under this absolute directory. + * @complexity O(1) + */ +// CHANGE: define the shared paste directory in terminal core. +// WHY: API/runtime adapters need one deterministic container path root. +// QUOTE(ТЗ): "терминал это наше отображение терминала из докера с общим шерингом" +// REF: issue-361-terminal-package +// SOURCE: n/a +// FORMAT THEOREM: ∀validPlan: startsWith(validPlan.containerPath, terminalImagePasteDirectory + "/") +// PURITY: CORE +// INVARIANT: paste directory is absolute and deterministic. +// COMPLEXITY: O(1)/O(1) +export const terminalImagePasteDirectory = "/home/dev/.docker-git/pasted-images" + +/** + * Maximum accepted pasted image payload size in bytes. + * + * @pure true + * @invariant terminalImagePasteMaxBytes > 0 + * @complexity O(1) + */ +export const terminalImagePasteMaxBytes = 10 * 1024 * 1024 + +const base64Pattern = /^(?:[+/0-9A-Za-z]{4})*(?:[+/0-9A-Za-z]{2}==|[+/0-9A-Za-z]{3}=)?$/u +const terminalImagePasteMaxBase64Length = Math.ceil(terminalImagePasteMaxBytes / 3) * 4 +const terminalImagePasteTooLargeMessage = `Image is too large. Max size is ${terminalImagePasteMaxBytes} bytes.` +const safeFileNameMaxLength = 72 + +const imageMediaTypeExtensions = new Map([ + ["image/gif", "gif"], + ["image/jpeg", "jpg"], + ["image/png", "png"], + ["image/webp", "webp"] +]) + +type TerminalImagePasteDataValidation = + | { + readonly _tag: "InvalidTerminalImagePaste" + readonly message: string + } + | { + readonly _tag: "ValidTerminalImagePasteData" + readonly decodedBytes: number + } + +const invalidTerminalImagePaste = ( + message: string +): InvalidTerminalImagePastePlan => ({ + _tag: "InvalidTerminalImagePaste", + message +}) + +/** + * Checks whether a pasted image media type can be persisted by terminal core. + * + * @pure true + * @param mediaType - Browser supplied image media type. + * @returns true when the media type maps to a supported file extension. + * @invariant result = true iff lower(mediaType) is in imageMediaTypeExtensions. + * @precondition mediaType may be any string. + * @postcondition input is not mutated. + * @complexity O(1) + */ +// CHANGE: expose media-type support as pure terminal core logic. +// WHY: callers can reject unsupported clipboard payloads before shell effects. +// QUOTE(ТЗ): "терминал это наше отображение терминала из докера с общим шерингом" +// REF: issue-361-terminal-package +// SOURCE: n/a +// FORMAT THEOREM: supported(m) ⇔ lower(m) ∈ keys(imageMediaTypeExtensions) +// PURITY: CORE +// INVARIANT: support check is deterministic and case-insensitive. +// COMPLEXITY: O(1)/O(1) +export const isSupportedTerminalImageMediaType = (mediaType: string): boolean => + imageMediaTypeExtensions.has(mediaType.toLowerCase()) + +const extensionForMediaType = (mediaType: string): string | null => + imageMediaTypeExtensions.get(mediaType.toLowerCase()) ?? null + +const normalizeBase64 = (data: string): string => data.replaceAll(/\s+/gu, "") + +const base64PaddingLength = (data: string): number => { + if (data.endsWith("==")) { + return 2 + } + if (data.endsWith("=")) { + return 1 + } + return 0 +} + +const decodedBase64Bytes = (data: string): number | null => { + if (data.length === 0 || data.length % 4 !== 0 || !base64Pattern.test(data)) { + return null + } + const padding = base64PaddingLength(data) + return (data.length / 4) * 3 - padding +} + +const lastPathSegment = (name: string): string => { + const segments = name.split(/[\\/]/u) + return segments.at(-1) ?? "" +} + +const withoutLastExtension = (name: string): string => { + const lastDot = name.lastIndexOf(".") + return lastDot === -1 ? name : name.slice(0, lastDot) +} + +const isFileNameBoundaryChar = (char: string | undefined): boolean => char === "." || char === "-" + +const leftFileNameBoundaryIndex = (name: string, index: number): number => + isFileNameBoundaryChar(name[index]) + ? leftFileNameBoundaryIndex(name, index + 1) + : index + +const rightFileNameBoundaryIndex = (name: string, end: number): number => + end > 0 && isFileNameBoundaryChar(name[end - 1]) + ? rightFileNameBoundaryIndex(name, end - 1) + : end + +const trimFileNameBoundaryChars = (name: string): string => { + const start = leftFileNameBoundaryIndex(name, 0) + const end = rightFileNameBoundaryIndex(name, name.length) + return name.slice(start, Math.max(start, end)) +} + +const normalizeTerminalImagePathSegmentChars = (value: string): string => + value.replaceAll(/[^0-9A-Za-z._-]+/gu, "-").replaceAll(/\.{2,}/gu, ".") + +/** + * Sanitizes a user-provided file name into a single safe image base name. + * + * @pure true + * @param name - Browser or clipboard supplied image filename. + * @returns Safe non-empty file basename without extension. + * @invariant result.length > 0 + * @invariant result excludes "/" and "\\". + * @invariant result excludes "..". + * @precondition name may be any string. + * @postcondition result can be used as one POSIX path segment. + * @complexity O(n) where n = name.length + */ +// CHANGE: centralize terminal image filename sanitization. +// WHY: pasted images cross a browser-to-container boundary. +// QUOTE(ТЗ): "терминал это наше отображение терминала из докера с общим шерингом" +// REF: issue-361-terminal-package +// SOURCE: n/a +// FORMAT THEOREM: ∀name: segment(sanitizeTerminalImageBaseName(name)) +// PURITY: CORE +// INVARIANT: sanitized base name is one non-empty non-traversal path segment. +// COMPLEXITY: O(n)/O(n) +export const sanitizeTerminalImageBaseName = (name: string): string => { + const withoutExtension = withoutLastExtension(lastPathSegment(name)) + const sanitized = trimFileNameBoundaryChars( + normalizeTerminalImagePathSegmentChars(withoutExtension) + ).slice(0, safeFileNameMaxLength) + return sanitized.length > 0 ? sanitized : "clipboard-image" +} + +const sanitizeTerminalImageIdSegment = (id: string): string => { + const sanitized = trimFileNameBoundaryChars( + normalizeTerminalImagePathSegmentChars(lastPathSegment(id)) + ).slice(0, safeFileNameMaxLength) + return sanitized.length > 0 ? sanitized : "paste" +} + +const terminalImageFileName = ( + id: string, + name: string, + mediaType: string +): string | null => { + const extension = extensionForMediaType(mediaType) + if (extension === null) { + return null + } + return `${sanitizeTerminalImageIdSegment(id)}-${sanitizeTerminalImageBaseName(name)}.${extension}` +} + +const validateTerminalImagePasteData = ( + payload: TerminalImagePastePayload, + normalizedBase64: string +): TerminalImagePasteDataValidation => { + if (normalizedBase64.length > terminalImagePasteMaxBase64Length) { + return invalidTerminalImagePaste(terminalImagePasteTooLargeMessage) + } + const decodedBytes = decodedBase64Bytes(normalizedBase64) + if (decodedBytes === null) { + return invalidTerminalImagePaste("Image payload is not valid base64.") + } + if (decodedBytes !== payload.size) { + return invalidTerminalImagePaste("Image payload size does not match its base64 data.") + } + return { + _tag: "ValidTerminalImagePasteData", + decodedBytes + } +} + +/** + * Builds a pure paste plan for writing an image payload inside the container. + * + * @pure true + * @param payload - Clipboard image payload encoded as base64. + * @param id - Caller supplied paste id; sanitized before it becomes a filename segment. + * @returns A valid write plan or typed validation failure. + * @invariant valid.decodedBytes = payload.size + * @invariant valid.containerPath starts with terminalImagePasteDirectory + "/" + * @invariant valid.containerPath contains no caller-controlled path separators after the directory prefix + * @precondition payload.data is expected to be base64 text, possibly with whitespace. + * @postcondition no filesystem or process effects are performed. + * @complexity O(n) where n = payload.data.length + payload.name.length + id.length + */ +// CHANGE: plan terminal image paste writes as pure data. +// WHY: API runtime should perform effects only after core validates size, type, and path. +// QUOTE(ТЗ): "терминал это наше отображение терминала из докера с общим шерингом" +// REF: issue-361-terminal-package +// SOURCE: n/a +// FORMAT THEOREM: valid(plan(payload,id)) → bytes(base64(payload.data)) = payload.size +// PURITY: CORE +// INVARIANT: valid plans never escape terminalImagePasteDirectory. +// COMPLEXITY: O(n)/O(n) +export const createTerminalImagePastePlan = ( + payload: TerminalImagePastePayload, + id: string +): TerminalImagePastePlan => { + const mediaType = payload.mediaType.toLowerCase() + const fileName = terminalImageFileName(id, payload.name, mediaType) + if (fileName === null) { + return invalidTerminalImagePaste(`Unsupported image type: ${payload.mediaType || "unknown"}.`) + } + if (!Number.isFinite(payload.size) || payload.size <= 0) { + return invalidTerminalImagePaste("Image payload is empty.") + } + if (payload.size > terminalImagePasteMaxBytes) { + return invalidTerminalImagePaste(terminalImagePasteTooLargeMessage) + } + const normalizedBase64 = normalizeBase64(payload.data) + const validation = validateTerminalImagePasteData(payload, normalizedBase64) + if (validation._tag === "InvalidTerminalImagePaste") { + return validation + } + return { + _tag: "ValidTerminalImagePaste", + containerPath: `${terminalImagePasteDirectory}/${fileName}`, + decodedBytes: validation.decodedBytes, + normalizedBase64 + } +} diff --git a/packages/terminal/src/core/index.ts b/packages/terminal/src/core/index.ts new file mode 100644 index 00000000..cf242a9d --- /dev/null +++ b/packages/terminal/src/core/index.ts @@ -0,0 +1,2 @@ +export * from "./image-paste.js" +export * from "./output-buffer.js" diff --git a/packages/terminal/src/core/output-buffer.ts b/packages/terminal/src/core/output-buffer.ts new file mode 100644 index 00000000..87bc9c34 --- /dev/null +++ b/packages/terminal/src/core/output-buffer.ts @@ -0,0 +1,143 @@ +export type TerminalOutputBuffer = { + readonly charLength: number + readonly chunks: ReadonlyArray +} + +/** + * Maximum replay size retained for terminal reconnects. + * + * @pure true + * @invariant terminalOutputReplayMaxChars > 0 + * @complexity O(1) + */ +// CHANGE: expose the terminal replay budget as a shared core constant. +// WHY: API and clients need one bounded replay invariant. +// QUOTE(ТЗ): "терминал это наше отображение терминала из докера с общим шерингом" +// REF: issue-361-terminal-package +// SOURCE: n/a +// FORMAT THEOREM: terminalOutputReplayMaxChars = 2 MiB +// PURITY: CORE +// INVARIANT: replay budget is positive and deterministic. +// COMPLEXITY: O(1)/O(1) +export const terminalOutputReplayMaxChars = 2 * 1024 * 1024 + +/** + * Empty terminal output replay buffer. + * + * @pure true + * @invariant charLength = 0 ∧ chunks = [] + * @complexity O(1) + */ +export const emptyTerminalOutputBuffer: TerminalOutputBuffer = { + charLength: 0, + chunks: [] +} + +const boundedMaxChars = (maxChars: number): number => Number.isFinite(maxChars) ? Math.max(0, Math.floor(maxChars)) : 0 + +type TrimTerminalOutputState = { + readonly startIndex: number + readonly startOffset: number +} + +const trimTerminalOutputState = ( + chunks: ReadonlyArray, + overflow: number, + index: number +): TrimTerminalOutputState => { + const chunk = chunks[index] + if (chunk === undefined || overflow <= 0) { + return { startIndex: index, startOffset: 0 } + } + if (chunk.length <= overflow) { + return trimTerminalOutputState(chunks, overflow - chunk.length, index + 1) + } + return { startIndex: index, startOffset: overflow } +} + +const trimTerminalOutputChunks = ( + chunks: ReadonlyArray, + overflow: number +): ReadonlyArray => { + const trimState = trimTerminalOutputState(chunks, overflow, 0) + return chunks.slice(trimState.startIndex).map((chunk, index) => + index === 0 ? chunk.slice(trimState.startOffset) : chunk + ) +} + +/** + * Appends terminal output while keeping only the newest bounded suffix. + * + * @pure true + * @param buffer - Existing immutable replay buffer. + * @param data - New terminal output chunk. + * @param maxChars - Maximum rendered replay length after append. + * @returns New immutable replay buffer. + * @invariant 0 <= result.charLength <= floor(maxChars) + * @invariant render(result) is a suffix of render(buffer) + data + * @precondition buffer.charLength = render(buffer).length + * @postcondition result.charLength = render(result).length + * @complexity O(n + m) where n = chunks count and m = copied suffix length + */ +// CHANGE: keep terminal replay buffering as pure immutable core logic. +// WHY: reconnect replay must be deterministic and bounded. +// QUOTE(ТЗ): "терминал это наше отображение терминала из докера с общим шерингом" +// REF: issue-361-terminal-package +// SOURCE: n/a +// FORMAT THEOREM: ∀b,d,k: len(render(append(b,d,k))) <= max(0, floor(k)) +// PURITY: CORE +// INVARIANT: output replay is always the newest bounded suffix. +// COMPLEXITY: O(n + m)/O(n + m) +export const appendTerminalOutput = ( + buffer: TerminalOutputBuffer, + data: string, + maxChars = terminalOutputReplayMaxChars +): TerminalOutputBuffer => { + const boundedMax = boundedMaxChars(maxChars) + if (boundedMax === 0) { + return emptyTerminalOutputBuffer + } + if (data.length === 0) { + return buffer + } + if (data.length >= boundedMax) { + return { + charLength: boundedMax, + chunks: [data.slice(data.length - boundedMax)] + } + } + const charLength = buffer.charLength + data.length + const chunks = [...buffer.chunks, data] + if (charLength <= boundedMax) { + return { charLength, chunks } + } + const overflow = charLength - boundedMax + return { + charLength: boundedMax, + chunks: trimTerminalOutputChunks(chunks, overflow) + } +} + +/** + * Renders a terminal replay buffer into one output string. + * + * @pure true + * @param buffer - Immutable replay buffer. + * @returns Concatenated terminal output chunks. + * @invariant result.length = buffer.charLength for buffers produced by appendTerminalOutput + * @precondition buffer.chunks contains terminal output chunks in chronological order. + * @postcondition input buffer is not mutated. + * @complexity O(n) where n = total rendered character count. + */ +// CHANGE: expose a pure renderer for terminal replay buffers. +// WHY: adapters should not inspect chunk internals. +// QUOTE(ТЗ): "терминал это наше отображение терминала из докера с общим шерингом" +// REF: issue-361-terminal-package +// SOURCE: n/a +// FORMAT THEOREM: render({chunks}) = concat(chunks) +// PURITY: CORE +// INVARIANT: render preserves chunk order. +// COMPLEXITY: O(n)/O(n) +export const renderTerminalOutputBuffer = ( + buffer: TerminalOutputBuffer +): string => buffer.chunks.join("") diff --git a/packages/terminal/src/index.ts b/packages/terminal/src/index.ts new file mode 100644 index 00000000..a192d7db --- /dev/null +++ b/packages/terminal/src/index.ts @@ -0,0 +1,2 @@ +export * from "./contracts/index.js" +export * from "./core/index.js" diff --git a/packages/terminal/src/runtime-boundary.ts b/packages/terminal/src/runtime-boundary.ts new file mode 100644 index 00000000..682f0fbb --- /dev/null +++ b/packages/terminal/src/runtime-boundary.ts @@ -0,0 +1,36 @@ +import type { Context } from "effect" +import { Effect, Layer } from "effect" + +/** + * Builds a no-op runtime boundary layer through Effect dependency injection. + * + * @pure false - constructs an Effect Layer for shell/runtime boundaries. + * @param tag - Runtime service tag that will receive the service implementation. + * @param service - Immutable no-op service implementation. + * @returns Layer that provides the supplied service without external effects. + * @invariant The returned layer provides exactly the supplied Context.Tag. + * @precondition service methods are total no-op Effect values. + * @postcondition Building the layer performs no host IO. + * @complexity O(1) + */ +// CHANGE: share the terminal runtime Layer constructor. +// WHY: runtime boundary modules should use the same Effect.gen Layer pattern without duplicated shell code. +// QUOTE(ТЗ): "Делаем то что говорит rabbit" +// REF: coderabbit-runtime-boundary +// SOURCE: n/a +// FORMAT THEOREM: ∀tag,service: provides(make(tag,service), tag) = service +// PURITY: SHELL +// EFFECT: Layer +// INVARIANT: construction is injectable through Context.Tag and performs no host effects. +// COMPLEXITY: O(1)/O(1) +export const makeTerminalRuntimeBoundaryLayer = ( + tag: Context.Tag, + service: Service +): Layer.Layer => + Layer.effect( + tag, + Effect.gen(function*(_) { + yield* _(Effect.void) + return service + }) + ) diff --git a/packages/terminal/src/server/image-fetch.ts b/packages/terminal/src/server/image-fetch.ts new file mode 100644 index 00000000..36eb85ac --- /dev/null +++ b/packages/terminal/src/server/image-fetch.ts @@ -0,0 +1,314 @@ +export type TerminalImageFetchPlan = + | { + readonly _tag: "InvalidTerminalImageFetch" + readonly message: string + } + | { + readonly _tag: "ValidTerminalImageFetch" + readonly containerPath: string + readonly mediaType: string + } + +type InvalidTerminalImageFetchPlan = Extract< + TerminalImageFetchPlan, + { readonly _tag: "InvalidTerminalImageFetch" } +> + +/** + * Maximum accepted fetched image size in bytes. + * + * @pure true + * @invariant terminalImageFetchMaxBytes > 0 + * @complexity O(1) + */ +// CHANGE: expose a shared fetch size budget for terminal image adapters. +// WHY: server runtimes need one deterministic image fetch bound. +// QUOTE(ТЗ): "терминал это наше отображение терминала из докера с общим шерингом" +// REF: issue-361-terminal-package +// SOURCE: n/a +// FORMAT THEOREM: terminalImageFetchMaxBytes = 10 MiB +// PURITY: CORE +// INVARIANT: fetch budget is positive and deterministic. +// COMPLEXITY: O(1)/O(1) +export const terminalImageFetchMaxBytes = 10 * 1024 * 1024 + +const supportedExtensionMediaTypes = new Map([ + ["gif", "image/gif"], + ["jpeg", "image/jpeg"], + ["jpg", "image/jpeg"], + ["png", "image/png"], + ["webp", "image/webp"] +]) + +const controlCharRange = `${String.fromCodePoint(0)}-${String.fromCodePoint(0x1F)}` +const deleteChar = String.fromCodePoint(0x7F) +const invalidCharacterPattern = new RegExp( + String.raw`[\s${controlCharRange}${deleteChar}]`, + "u" +) +const traversalPattern = /(?:^|\/)(?:\.|\.\.)(?=\/|$)/u +const urlSchemePattern = /^[A-Za-z][A-Za-z0-9+.-]*:/u +const fileUrlPattern = /^file:\/\//iu +const encodedPathSeparatorPattern = /%(?:2f|5c)/iu +const encodedSpacePattern = /%20/giu +const fileUrlBackslashPattern = /\\/u +const fileUrlTraversalPattern = /(?:^|[\\/])(?:\.|%2e)(?:(?:\.|%2e))?(?=[\\/]|$)/iu +const invalidPercentEncodingPattern = /%(?![0-9A-Fa-f]{2})/u + +type TerminalImagePathNormalization = + | { + readonly _tag: "InvalidTerminalImagePath" + readonly message: string + } + | { + readonly _tag: "ValidTerminalImagePath" + readonly path: string + } + +type InvalidTerminalImagePathNormalization = Extract< + TerminalImagePathNormalization, + { readonly _tag: "InvalidTerminalImagePath" } +> + +type TerminalImageContainerPathResolution = + | InvalidTerminalImageFetchPlan + | { + readonly _tag: "ValidTerminalImageContainerPath" + readonly containerPath: string + } + +const invalidTerminalImageFetch = ( + message: string +): InvalidTerminalImageFetchPlan => ({ + _tag: "InvalidTerminalImageFetch", + message +}) + +const invalidTerminalImagePath = ( + message: string +): InvalidTerminalImagePathNormalization => ({ + _tag: "InvalidTerminalImagePath", + message +}) + +const validTerminalImagePath = ( + path: string +): TerminalImagePathNormalization => ({ + _tag: "ValidTerminalImagePath", + path +}) + +const validTerminalImageContainerPath = ( + containerPath: string +): TerminalImageContainerPathResolution => ({ + _tag: "ValidTerminalImageContainerPath", + containerPath +}) + +const lowercaseExtension = (path: string): string | null => { + const lastDot = path.lastIndexOf(".") + if (lastDot === -1 || lastDot === path.length - 1) { + return null + } + return path.slice(lastDot + 1).toLowerCase() +} + +const rawFileUrlPathname = (path: string): string => { + const withoutScheme = path.slice("file://".length) + const pathStart = withoutScheme.indexOf("/") + if (pathStart === -1) { + return "" + } + const pathAndSuffix = withoutScheme.slice(pathStart) + const queryStart = pathAndSuffix.indexOf("?") + const hashStart = pathAndSuffix.indexOf("#") + if (queryStart === -1 && hashStart === -1) { + return pathAndSuffix + } + if (queryStart === -1) { + return pathAndSuffix.slice(0, hashStart) + } + if (hashStart === -1) { + return pathAndSuffix.slice(0, queryStart) + } + return pathAndSuffix.slice(0, Math.min(queryStart, hashStart)) +} + +const normalizedFileUrlPathname = (pathname: string): string => pathname.replaceAll(encodedSpacePattern, " ") + +const validateRawFileUrlPathname = ( + path: string, + rawPathname: string +): InvalidTerminalImagePathNormalization | null => { + if (invalidPercentEncodingPattern.test(rawPathname) || !URL.canParse(path)) { + return invalidTerminalImagePath("Image file URL is invalid.") + } + if (fileUrlTraversalPattern.test(rawPathname)) { + return invalidTerminalImagePath("Image path must not contain '.' or '..' segments.") + } + if ( + encodedPathSeparatorPattern.test(rawPathname) || + fileUrlBackslashPattern.test(rawPathname) + ) { + return invalidTerminalImagePath( + "Image file URL must not contain encoded or backslash path separators." + ) + } + return null +} + +const validateFileUrl = ( + url: URL +): InvalidTerminalImagePathNormalization | null => { + if ( + url.protocol !== "file:" || + (url.hostname !== "" && url.hostname !== "localhost") + ) { + return invalidTerminalImagePath("Image file URL must point to a local path.") + } + if (url.search.length > 0 || url.hash.length > 0) { + return invalidTerminalImagePath("Image file URL must not include query or fragment.") + } + return null +} + +const normalizeTerminalImagePath = ( + path: string +): TerminalImagePathNormalization => { + if (!urlSchemePattern.test(path)) { + return validTerminalImagePath(path) + } + if (!fileUrlPattern.test(path)) { + return invalidTerminalImagePath("Only file:// image URLs are supported.") + } + + const rawPathname = rawFileUrlPathname(path) + const rawPathnameValidation = validateRawFileUrlPathname(path, rawPathname) + if (rawPathnameValidation !== null) { + return rawPathnameValidation + } + + const url = new URL(path) + const urlValidation = validateFileUrl(url) + if (urlValidation !== null) { + return urlValidation + } + return validTerminalImagePath(normalizedFileUrlPathname(url.pathname)) +} + +export type TerminalImageFetchOptions = { + readonly baseDir?: string +} + +const isAbsolutePosixPath = (value: string): boolean => value.startsWith("/") + +const trimRightSlashEnd = (value: string, end: number): number => + end > 0 && value[end - 1] === "/" ? trimRightSlashEnd(value, end - 1) : end + +const trimRightSlash = (value: string): string => value.slice(0, trimRightSlashEnd(value, value.length)) + +const joinBaseDirAndRelativePath = ( + baseDir: string, + relativePath: string +): string => { + const trimmedBase = trimRightSlash(baseDir) + return `${trimmedBase}/${relativePath}` +} + +const isInvalidTerminalImagePathString = (value: string): boolean => + invalidCharacterPattern.test(value) || traversalPattern.test(value) + +const resolveRelativeTerminalImagePath = ( + relativePath: string, + options: TerminalImageFetchOptions +): TerminalImageContainerPathResolution => { + const baseDir = options.baseDir + if (baseDir === undefined || !isAbsolutePosixPath(baseDir)) { + return invalidTerminalImageFetch("Image path must be absolute.") + } + if (isInvalidTerminalImagePathString(baseDir)) { + return invalidTerminalImageFetch("Image base directory is invalid.") + } + return validTerminalImageContainerPath(joinBaseDirAndRelativePath(baseDir, relativePath)) +} + +const resolveTerminalImageContainerPath = ( + path: string, + options: TerminalImageFetchOptions +): TerminalImageContainerPathResolution => { + if (isAbsolutePosixPath(path)) { + return validTerminalImageContainerPath(path) + } + return resolveRelativeTerminalImagePath(path, options) +} + +const validateTerminalImageContainerPath = ( + containerPath: string +): InvalidTerminalImageFetchPlan | null => { + if (invalidCharacterPattern.test(containerPath)) { + return invalidTerminalImageFetch("Image path contains invalid characters.") + } + if (traversalPattern.test(containerPath)) { + return invalidTerminalImageFetch("Image path must not contain '.' or '..' segments.") + } + return null +} + +const terminalImageFetchPlanForPath = ( + containerPath: string +): TerminalImageFetchPlan => { + const extension = lowercaseExtension(containerPath) + if (extension === null) { + return invalidTerminalImageFetch("Image path must include a file extension.") + } + const mediaType = supportedExtensionMediaTypes.get(extension) + if (mediaType === undefined) { + return invalidTerminalImageFetch(`Unsupported image extension: .${extension}`) + } + return { _tag: "ValidTerminalImageFetch", containerPath, mediaType } +} + +/** + * Builds a pure fetch plan for an image path inside the terminal container. + * + * @pure true + * @param path - Absolute path, relative path with baseDir, or local file URL. + * @param options - Optional base directory for relative image paths. + * @returns A valid fetch plan with media type or typed validation failure. + * @invariant valid.containerPath is absolute. + * @invariant valid.containerPath contains no "." or ".." path segments. + * @invariant valid.mediaType is determined only by the lowercase file extension. + * @precondition relative paths require an absolute options.baseDir. + * @postcondition no filesystem or network effects are performed. + * @complexity O(n) where n = path.length + baseDir.length + */ +// CHANGE: plan terminal image fetches as pure validation data. +// WHY: runtime adapters should perform image IO only after path and media type validation. +// QUOTE(ТЗ): "терминал это наше отображение терминала из докера с общим шерингом" +// REF: issue-361-terminal-package +// SOURCE: n/a +// FORMAT THEOREM: valid(plan(path)) → absolute(containerPath) ∧ mediaType = ext(containerPath) +// PURITY: CORE +// INVARIANT: valid fetch plans never contain traversal segments. +// COMPLEXITY: O(n)/O(n) +export const planTerminalImageFetch = ( + path: string, + options: TerminalImageFetchOptions = {} +): TerminalImageFetchPlan => { + if (path.length === 0) { + return invalidTerminalImageFetch("Image path is required.") + } + const normalized = normalizeTerminalImagePath(path) + if (normalized._tag === "InvalidTerminalImagePath") { + return invalidTerminalImageFetch(normalized.message) + } + const resolution = resolveTerminalImageContainerPath(normalized.path, options) + if (resolution._tag === "InvalidTerminalImageFetch") { + return resolution + } + const validation = validateTerminalImageContainerPath(resolution.containerPath) + if (validation !== null) { + return validation + } + return terminalImageFetchPlanForPath(resolution.containerPath) +} diff --git a/packages/terminal/src/server/index.ts b/packages/terminal/src/server/index.ts new file mode 100644 index 00000000..7f67ec71 --- /dev/null +++ b/packages/terminal/src/server/index.ts @@ -0,0 +1,83 @@ +import type { Effect } from "effect" + +import type { TerminalSession } from "../contracts/index.js" +import type { TerminalShellCommand } from "../shell/index.js" + +export type { TerminalShellCommand } from "../shell/index.js" +export * from "./image-fetch.js" + +/** + * Typed server-side integration boundary for project-backed terminal sessions. + * + * @pure false - methods are effectful adapter hooks. + * @effect TerminalProjectRuntime + * @invariant project/session effects stay behind this injected interface. + * @precondition project keys and ids come from validated API routes. + * @postcondition methods return typed Effect values instead of throwing. + * @complexity Adapter-defined. + */ +// CHANGE: define a typed server project runtime contract for terminal orchestration. +// WHY: terminal server helpers must not depend on concrete API services. +// QUOTE(ТЗ): "api тоже нужен не один огромный controller, а модульная структура" +// REF: issue-361-terminal-package +// SOURCE: n/a +// FORMAT THEOREM: TerminalProjectRuntime = resolveProject × prepareShell × recordActivity × emitEvent +// PURITY: SHELL +// EFFECT: Effect<*, RuntimeError, RuntimeContext> +// INVARIANT: project effects are accessed through this boundary only. +// COMPLEXITY: Adapter-defined/O(1) +export type TerminalProjectRuntime = { + readonly emitEvent: ( + projectId: string, + event: TerminalSessionEvent + ) => Effect.Effect + readonly prepareShell: ( + project: Project + ) => Effect.Effect + readonly recordActivity: ( + projectId: string + ) => Effect.Effect + readonly resolveProject: ( + projectKey: string + ) => Effect.Effect +} + +/** + * Server-side terminal session event emitted by runtime adapters. + * + * @pure true + * @invariant _tag determines all required event fields. + * @complexity O(1) + */ +// CHANGE: define terminal session event union in the shared server module. +// WHY: API runtime and future terminal server modules need one event vocabulary. +// QUOTE(ТЗ): "терминал это наше отображение терминала из докера с общим шерингом" +// REF: issue-361-terminal-package +// SOURCE: n/a +// FORMAT THEOREM: event ∈ Created | Ready | Output | Exited | Failed +// PURITY: CORE +// INVARIANT: event union is closed by _tag. +// COMPLEXITY: O(1)/O(1) +export type TerminalSessionEvent = + | { + readonly _tag: "Created" + readonly requestId?: string + readonly sessionId: string + } + | { readonly _tag: "Ready"; readonly session: TerminalSession } + | { + readonly _tag: "Output" + readonly data: string + readonly sessionId: string + } + | { + readonly _tag: "Exited" + readonly exitCode: number | null + readonly sessionId: string + readonly signal: number | null + } + | { + readonly _tag: "Failed" + readonly message: string + readonly sessionId: string + } diff --git a/packages/terminal/src/shell/index.ts b/packages/terminal/src/shell/index.ts new file mode 100644 index 00000000..63a9a07e --- /dev/null +++ b/packages/terminal/src/shell/index.ts @@ -0,0 +1,57 @@ +import { Context, Effect } from "effect" +import { makeTerminalRuntimeBoundaryLayer } from "../runtime-boundary.js" + +/** + * Host shell command prepared for terminal runtime execution. + * + * @pure true + * @invariant command is required; cwd and env are optional immutable data. + * @complexity O(1) + */ +// CHANGE: define shell command data at the shell boundary. +// WHY: server contracts can reference command data without owning shell execution. +// QUOTE(ТЗ): "shared domain/usecases/shell для docker-git orchestration" +// REF: issue-361-terminal-package +// SOURCE: n/a +// FORMAT THEOREM: TerminalShellCommand = command × cwd? × env? +// PURITY: SHELL +// INVARIANT: command data is immutable at the package boundary. +// COMPLEXITY: O(1)/O(1) +export type TerminalShellCommand = { + readonly command: string + readonly cwd?: string + readonly env?: Readonly> +} + +export type TerminalShellRuntimeService = { + readonly execute: (command: TerminalShellCommand) => Effect.Effect +} + +/** + * Shell runtime boundary for terminal host commands. + * + * @pure false - concrete layers may spawn processes or access host state. + * @effect TerminalShellRuntime + * @invariant shell effects are injected through this Context.Tag, never imported by core/contracts. + * @precondition command.command is a shell command prepared by an adapter/usecase layer. + * @postcondition Noop layer preserves observable no-op behavior. + * @complexity O(1) for Noop; concrete layers define their own cost. + */ +// CHANGE: replace marker boundary with a real Effect shell service boundary. +// WHY: enforce FCIS dependency direction through Context.Tag/Layer. +// QUOTE(ТЗ): "Делаем то что говорит rabbit" +// REF: coderabbit-runtime-boundary +// SOURCE: n/a +// FORMAT THEOREM: ∀core: core ∉ Requires +// PURITY: SHELL +// EFFECT: Layer +// INVARIANT: shell execution is possible only through TerminalShellRuntime. +// COMPLEXITY: O(1)/O(1) +export class TerminalShellRuntime extends Context.Tag("TerminalShellRuntime")< + TerminalShellRuntime, + TerminalShellRuntimeService +>() { + static readonly Noop = makeTerminalRuntimeBoundaryLayer(this, { + execute: () => Effect.void + }) +} diff --git a/packages/terminal/src/web/index.ts b/packages/terminal/src/web/index.ts new file mode 100644 index 00000000..1097c693 --- /dev/null +++ b/packages/terminal/src/web/index.ts @@ -0,0 +1,62 @@ +import { Context, Effect } from "effect" +import { makeTerminalRuntimeBoundaryLayer } from "../runtime-boundary.js" + +export type TerminalWebRuntimeService = { + readonly attach: (sessionId: string) => Effect.Effect +} + +/** + * Browser runtime boundary for terminal UI adapters. + * + * @pure false - concrete layers may attach browser terminal UI state. + * @effect TerminalWebRuntime + * @invariant web effects are injected through this Context.Tag, never imported by core/contracts. + * @precondition sessionId identifies an existing terminal session in the host adapter. + * @postcondition Noop layer preserves observable no-op behavior. + * @complexity O(1) for Noop; concrete layers define their own cost. + */ +// CHANGE: replace marker boundary with a real Effect web service boundary. +// WHY: enforce FCIS dependency direction through Context.Tag/Layer. +// QUOTE(ТЗ): "Делаем то что говорит rabbit" +// REF: coderabbit-runtime-boundary +// SOURCE: n/a +// FORMAT THEOREM: ∀core: core ∉ Requires +// PURITY: SHELL +// EFFECT: Layer +// INVARIANT: browser terminal effects are available only through TerminalWebRuntime. +// COMPLEXITY: O(1)/O(1) +export class TerminalWebRuntime extends Context.Tag("TerminalWebRuntime")< + TerminalWebRuntime, + TerminalWebRuntimeService +>() { + static readonly Noop = makeTerminalRuntimeBoundaryLayer(this, { + attach: () => Effect.void + }) +} + +export * from "./panel-terminal-header.js" +export * from "./panel-terminal-mobile-controls.js" +export * from "./panel-terminal-styles.js" +export * from "./panel-terminal-types.js" +export * from "./panel-terminal.js" +export * from "./terminal-copy-interaction.js" +export * from "./terminal-copy-selection-drag.js" +export * from "./terminal-image-paste.js" +export * from "./terminal-image-paths.js" +export * from "./terminal-image-url.js" +export * from "./terminal-inline-images-core.js" +export * from "./terminal-inline-images.js" +export * from "./terminal-mobile-controls.js" +export * from "./terminal-mobile-layout.js" +export * from "./terminal-panel-cleanup-runtime.js" +export * from "./terminal-panel-inline-images-runtime.js" +export * from "./terminal-panel-input.js" +export * from "./terminal-panel-optional-operation.js" +export * from "./terminal-panel-runtime-core.js" +export * from "./terminal-panel-runtime-types.js" +export * from "./terminal-panel-runtime.js" +export * from "./terminal-query-suppression.js" +export * from "./terminal-reconnect.js" +export * from "./terminal-state.js" +export * from "./terminal-wheel-scroll.js" +export * from "./terminal.js" diff --git a/packages/terminal/src/web/panel-terminal-header.tsx b/packages/terminal/src/web/panel-terminal-header.tsx new file mode 100644 index 00000000..97892076 --- /dev/null +++ b/packages/terminal/src/web/panel-terminal-header.tsx @@ -0,0 +1,189 @@ +import type { JSX } from "react" + +import { + closeButtonStyle, + compactCloseButtonStyle, + compactHeaderActionsStyle, + compactHeaderStyle, + compactHeaderTitleStyle, + compactStatusStyle, + headerActionsStyle, + headerStatusStyle, + headerStyle, + headerSubtitleStyle, + headerTitleStyle +} from "./panel-terminal-styles.js" +import type { TerminalPanelProps } from "./panel-terminal-types.js" +import type { TerminalStatus } from "./terminal-panel-runtime.js" + +type TerminalHeaderProps = + & Pick< + TerminalPanelProps, + | "onApplyProject" + | "onDetach" + | "onKill" + | "onOpenBrowser" + | "onOpenSkiller" + | "onOpenTaskManager" + | "onOpenTerminal" + | "session" + > + & { + readonly compactHeaderMode: boolean + readonly inlineImagePreviewsEnabled: boolean + readonly onToggleInlineImagePreviews: () => void + readonly status: TerminalStatus + } + +type TerminalActionDescriptor = { + readonly compactLabel: string + readonly label: string + readonly onClick: (() => void) | undefined +} + +const TerminalHeaderTitle = ( + { + compactHeaderMode, + session, + status + }: Pick & { + readonly compactHeaderMode: boolean + readonly status: TerminalStatus + } +): JSX.Element => + compactHeaderMode + ? ( +
+
+ {session.browserProjectName ?? session.header} +
+
{status}
+
+ ) + : ( +
+
{session.header}
+
{status}
+
{session.subtitle}
+
+ ) + +const TerminalActionButton = ( + { + children, + compactTypingMode, + onClick, + pressed, + title + }: { + readonly children: string + readonly compactTypingMode: boolean + readonly onClick: () => void + readonly pressed?: boolean + readonly title?: string + } +): JSX.Element => ( + +) + +const optionalProjectActions = ( + props: TerminalHeaderProps +): ReadonlyArray => { + if (props.session.browserProjectId === undefined) { + return [] + } + return [ + { compactLabel: "Browser", label: "Open browser", onClick: props.onOpenBrowser }, + { compactLabel: "Skiller", label: "Skiller", onClick: props.onOpenSkiller }, + { compactLabel: "Apply", label: "Apply", onClick: props.onApplyProject }, + { compactLabel: "Tasks", label: "Task manager", onClick: props.onOpenTaskManager }, + { compactLabel: "New", label: "New terminal", onClick: props.onOpenTerminal } + ] +} + +const TerminalProjectActionButtons = ( + { + actions, + compactHeaderMode + }: { + readonly actions: ReadonlyArray + readonly compactHeaderMode: boolean + } +): JSX.Element => ( + <> + {actions.map((action) => + action.onClick === undefined + ? null + : ( + + {compactHeaderMode ? action.compactLabel : action.label} + + ) + )} + +) + +const TerminalImageToggleButton = ( + { + compactHeaderMode, + inlineImagePreviewsEnabled, + onToggleInlineImagePreviews + }: Pick +): JSX.Element => { + const label = inlineImagePreviewsEnabled ? "Images on" : "Images off" + const compactLabel = inlineImagePreviewsEnabled ? "Img on" : "Img off" + const title = inlineImagePreviewsEnabled + ? "Automatic image previews enabled" + : "Automatic image previews disabled" + + return ( + + {compactHeaderMode ? compactLabel : label} + + ) +} + +const TerminalHeaderActions = (props: TerminalHeaderProps): JSX.Element => ( +
+ + + + Detach + + + Kill + +
+) + +export const TerminalHeader = (props: TerminalHeaderProps): JSX.Element => ( +
+ + +
+) diff --git a/packages/terminal/src/web/panel-terminal-mobile-controls.tsx b/packages/terminal/src/web/panel-terminal-mobile-controls.tsx new file mode 100644 index 00000000..8f38bab8 --- /dev/null +++ b/packages/terminal/src/web/panel-terminal-mobile-controls.tsx @@ -0,0 +1,177 @@ +import type { JSX } from "react" + +import { + mobileArrowRowStyle, + mobileControlButtonStyle, + mobileControlsCollapsedStyle, + mobileControlsRowStyle, + mobileControlsStyle +} from "./panel-terminal-styles.js" +import { + isModifierOnlyTerminalKey, + type MobileTerminalKey, + mobileTerminalKeyInput, + terminalControlCharacterForKey +} from "./terminal-mobile-controls.js" +import type { TerminalInputController } from "./terminal-panel-runtime.js" + +type MobileTerminalControlsProps = { + readonly collapsed: boolean + readonly compactTypingMode: boolean + readonly ctrlArmed: boolean + readonly onKeyPress: (key: MobileTerminalKey) => void + readonly onToggleCollapsed: () => void + readonly onToggleCtrl: () => void +} + +type MobileTerminalArrowKey = Extract + +const mobileTerminalArrowKeys: ReadonlyArray = ["left", "up", "down", "right"] + +const mobileTerminalArrowLabels: Readonly> = { + down: "↓", + left: "←", + right: "→", + up: "↑" +} + +export const retainTerminalFocus = (controller: TerminalInputController | null): void => { + controller?.focus() +} + +export const sendTerminalMobileInput = ( + controller: TerminalInputController | null, + key: MobileTerminalKey +): void => { + controller?.sendInput(mobileTerminalKeyInput(key)) + retainTerminalFocus(controller) +} + +export const shouldKeepMobileCtrlArmed = (event: KeyboardEvent): boolean => + event.metaKey || event.altKey || event.ctrlKey || event.isComposing || isModifierOnlyTerminalKey(event.key) + +export const sendMobileCtrlEventInput = ( + controller: TerminalInputController | null, + event: KeyboardEvent +): void => { + const controlCharacter = terminalControlCharacterForKey(event.key) + if (controlCharacter === null) { + return + } + event.preventDefault() + event.stopPropagation() + controller?.sendInput(controlCharacter) + retainTerminalFocus(controller) +} + +const MobileTerminalControlButton = ( + { + active = false, + label, + onClick + }: { + readonly active?: boolean + readonly label: string + readonly onClick: () => void + } +): JSX.Element => ( + +) + +const MobileCommandControlsRow = ( + { + ctrlArmed, + onKeyPress, + onToggleCollapsed, + onToggleCtrl + }: Pick +): JSX.Element => ( +
+ { + onKeyPress("escape") + }} + /> + { + onKeyPress("tab") + }} + /> + + { + onKeyPress("ctrl-c") + }} + /> + +
+) + +const MobileArrowControlsRow = ( + { onKeyPress }: Pick +): JSX.Element => ( +
+ {mobileTerminalArrowKeys.map((key) => ( + { + onKeyPress(key) + }} + /> + ))} +
+) + +const CollapsedMobileTerminalControls = ( + { compactTypingMode, onToggleCollapsed }: Pick< + MobileTerminalControlsProps, + "compactTypingMode" | "onToggleCollapsed" + > +): JSX.Element => ( +
+ +
+) + +const ExpandedMobileTerminalControls = (props: Omit): JSX.Element => ( +
+ + +
+) + +export const MobileTerminalControls = (props: MobileTerminalControlsProps): JSX.Element => + props.collapsed + ? ( + + ) + : ( + + ) diff --git a/packages/terminal/src/web/panel-terminal-styles.ts b/packages/terminal/src/web/panel-terminal-styles.ts new file mode 100644 index 00000000..4cef64d7 --- /dev/null +++ b/packages/terminal/src/web/panel-terminal-styles.ts @@ -0,0 +1,218 @@ +import type { CSSProperties } from "react" + +import type { TerminalStatus } from "./terminal-panel-runtime.js" + +const panelStyle: CSSProperties = { + border: "1px solid #3a4652", + borderRadius: "8px", + display: "flex", + flex: 1, + flexDirection: "column", + minHeight: 0, + overflow: "hidden" +} + +export const terminalPanelStyle = (mobileMode: boolean, keyboardOpen: boolean): CSSProperties => ({ + ...panelStyle, + marginTop: mobileMode || keyboardOpen ? 0 : "8px" +}) + +export const headerStyle: CSSProperties = { + alignItems: "stretch", + background: "#101419", + borderBottom: "1px solid #3a4652", + display: "flex", + flexDirection: "column", + gap: "8px", + justifyContent: "flex-start", + padding: "10px 12px" +} + +export const compactHeaderStyle: CSSProperties = { + ...headerStyle, + alignItems: "center", + flexDirection: "row", + flexWrap: "wrap", + gap: "6px", + overflow: "visible", + padding: "5px 6px" +} + +const bodyStyle: CSSProperties = { + background: "#080a0d", + flex: 1, + minHeight: 0, + padding: "8px" +} + +const bodyStyleMobile: CSSProperties = { + ...bodyStyle, + padding: "2px" +} + +const bodyStyleKeyboardOpen: CSSProperties = { + ...bodyStyle, + padding: 0 +} + +const terminalBodyStyle = (compactTypingMode: boolean, mobileMode: boolean): CSSProperties => { + if (compactTypingMode) { + return bodyStyleKeyboardOpen + } + return mobileMode ? bodyStyleMobile : bodyStyle +} + +export const terminalBodyFrameStyle = (compactTypingMode: boolean, mobileMode: boolean): CSSProperties => ({ + ...terminalBodyStyle(compactTypingMode, mobileMode), + boxSizing: "border-box", + overflow: "hidden", + position: "relative" +}) + +export const terminalHostStyle: CSSProperties = { + height: "100%", + minHeight: 0, + overflow: "hidden" +} + +export const terminalBodyContentStyle: CSSProperties = { + bottom: 0, + height: "100%", + left: 0, + minHeight: 0, + overflow: "auto", + position: "absolute", + right: 0, + top: 0, + zIndex: 1 +} + +export const closeButtonStyle: CSSProperties = { + background: "#171d24", + border: "1px solid #3a4652", + borderRadius: "8px", + color: "#d6e5f7", + cursor: "pointer", + font: "inherit", + padding: "6px 10px" +} + +export const compactCloseButtonStyle: CSSProperties = { + ...closeButtonStyle, + fontSize: "11px", + padding: "4px 6px" +} + +export const headerActionsStyle: CSSProperties = { + alignItems: "center", + display: "flex", + flexShrink: 0, + flexWrap: "wrap", + gap: "8px", + justifyContent: "flex-start", + width: "100%" +} + +export const compactHeaderActionsStyle: CSSProperties = { + ...headerActionsStyle, + flexWrap: "wrap", + gap: "4px", + justifyContent: "flex-end", + marginLeft: "auto", + width: "auto" +} + +export const mobileControlsCollapsedStyle: CSSProperties = { + alignItems: "center", + background: "#0d1218", + borderTop: "1px solid #3a4652", + display: "flex", + flexShrink: 0, + justifyContent: "flex-end", + padding: "8px" +} + +export const mobileControlsStyle: CSSProperties = { + background: "#0d1218", + borderTop: "1px solid #3a4652", + display: "flex", + flexDirection: "column", + flexShrink: 0, + gap: "8px", + padding: "8px" +} + +export const mobileControlsRowStyle: CSSProperties = { + display: "grid", + gap: "8px", + gridTemplateColumns: "repeat(5, minmax(0, 1fr))" +} + +export const mobileArrowRowStyle: CSSProperties = { + display: "grid", + gap: "8px", + gridTemplateColumns: "repeat(4, minmax(0, 1fr))" +} + +export const mobileControlButtonStyle = (active = false): CSSProperties => ({ + background: active ? "#1d3550" : "#121a23", + border: `1px solid ${active ? "#78f0a3" : "#3a4652"}`, + borderRadius: "8px", + color: active ? "#e8fff0" : "#d6e5f7", + cursor: "pointer", + font: "inherit", + fontWeight: 600, + minHeight: "40px", + padding: "8px 10px" +}) + +const statusColor = (status: TerminalStatus): string => { + if (status === "attached") { + return "#56f39a" + } + if (status === "error") { + return "#ff8f8f" + } + if (status === "exited") { + return "#ffd166" + } + return "#8fd3ff" +} + +export const compactHeaderTitleStyle: CSSProperties = { + color: "#f6fbff", + flex: 1, + fontWeight: 700, + lineHeight: 1.2, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap" +} + +export const compactStatusStyle = (status: TerminalStatus): CSSProperties => ({ + color: statusColor(status), + flexShrink: 0, + fontSize: "11px", + whiteSpace: "nowrap" +}) + +export const headerTitleStyle: CSSProperties = { + color: "#f6fbff", + fontWeight: 700, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap" +} + +export const headerStatusStyle = (status: TerminalStatus): CSSProperties => ({ + color: statusColor(status), + whiteSpace: "nowrap" +}) + +export const headerSubtitleStyle: CSSProperties = { + color: "#8fa6c4", + fontSize: "12px", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap" +} diff --git a/packages/terminal/src/web/panel-terminal-types.ts b/packages/terminal/src/web/panel-terminal-types.ts new file mode 100644 index 00000000..6915d120 --- /dev/null +++ b/packages/terminal/src/web/panel-terminal-types.ts @@ -0,0 +1,21 @@ +import type { JSX } from "react" + +import type { TerminalExitInfo } from "./terminal-panel-runtime.js" +import type { ActiveTerminalSession } from "./terminal.js" + +export type TerminalPanelProps = { + readonly keyboardOpen: boolean + readonly mobileMode: boolean + readonly onAttachFailure: () => void + readonly onApplyProject?: (() => void) | undefined + readonly onDetach: () => void + readonly onExit?: ((info: TerminalExitInfo) => void) | undefined + readonly onKill: () => void + readonly onMessage: (message: string) => void + readonly onOpenBrowser?: (() => void) | undefined + readonly onOpenSkiller?: (() => void) | undefined + readonly onOpenTaskManager?: (() => void) | undefined + readonly onOpenTerminal?: (() => void) | undefined + readonly session: ActiveTerminalSession + readonly bodyContent?: JSX.Element | undefined +} diff --git a/packages/terminal/src/web/panel-terminal.tsx b/packages/terminal/src/web/panel-terminal.tsx new file mode 100644 index 00000000..e673b885 --- /dev/null +++ b/packages/terminal/src/web/panel-terminal.tsx @@ -0,0 +1,310 @@ +import "xterm/css/xterm.css" + +import { type JSX, useCallback, useEffect, useRef, useState } from "react" + +import { TerminalHeader } from "./panel-terminal-header.js" +import { + MobileTerminalControls, + retainTerminalFocus, + sendMobileCtrlEventInput, + sendTerminalMobileInput, + shouldKeepMobileCtrlArmed +} from "./panel-terminal-mobile-controls.js" +import { + terminalBodyContentStyle, + terminalBodyFrameStyle, + terminalHostStyle, + terminalPanelStyle +} from "./panel-terminal-styles.js" +import type { TerminalPanelProps } from "./panel-terminal-types.js" +import type { MobileTerminalKey } from "./terminal-mobile-controls.js" +import { resolveTerminalCompactHeaderMode, resolveTerminalTypingMode } from "./terminal-mobile-layout.js" +import { + type TerminalConnectionState, + type TerminalExitInfo, + type TerminalInputController, + type TerminalStatus, + useTerminalSessionLifecycle +} from "./terminal-panel-runtime.js" +import { type ActiveTerminalSession, isPendingActiveTerminalSession } from "./terminal.js" + +type RefState = { current: T } + +type TerminalNotificationHandlers = { + readonly notifyAttachFailure: () => void + readonly notifyExit: (info: TerminalExitInfo) => void + readonly notifyMessage: (message: string) => void +} + +type InlineImagePreviewState = { + readonly inlineImagePreviewsEnabled: boolean + readonly inlineImagePreviewsEnabledRef: RefState + readonly toggleInlineImagePreviews: () => void +} + +type MobileTerminalControlState = { + readonly handleMobileKeyPress: (key: MobileTerminalKey) => void + readonly mobileControlsCollapsed: boolean + readonly mobileCtrlArmed: boolean + readonly toggleMobileControls: () => void + readonly toggleMobileCtrl: () => void +} + +type TerminalPanelLayoutProps = + & Pick< + TerminalPanelProps, + | "bodyContent" + | "keyboardOpen" + | "mobileMode" + | "onApplyProject" + | "onOpenBrowser" + | "onOpenSkiller" + | "onOpenTaskManager" + | "onOpenTerminal" + | "session" + > + & InlineImagePreviewState + & MobileTerminalControlState + & { + readonly compactHeaderMode: boolean + readonly compactTypingMode: boolean + readonly handleDetach: () => void + readonly handleKill: () => void + readonly hostRef: RefState + readonly status: TerminalStatus + } + +const resolveInitialTerminalStatus = (session: ActiveTerminalSession): TerminalStatus => + isPendingActiveTerminalSession(session) && session.pendingConnection.phase === "error" ? "error" : "connecting" + +const useTerminalNotificationHandlers = ( + { onAttachFailure, onExit, onMessage }: Pick +): TerminalNotificationHandlers => { + const onAttachFailureRef = useRef(onAttachFailure) + const onExitRef = useRef(onExit) + const onMessageRef = useRef(onMessage) + useEffect(() => { + onAttachFailureRef.current = onAttachFailure + }, [onAttachFailure]) + useEffect(() => { + onExitRef.current = onExit + }, [onExit]) + useEffect(() => { + onMessageRef.current = onMessage + }, [onMessage]) + return { + notifyAttachFailure: useCallback(() => { + onAttachFailureRef.current() + }, []), + notifyExit: useCallback((info: TerminalExitInfo) => { + onExitRef.current?.(info) + }, []), + notifyMessage: useCallback((message: string) => { + onMessageRef.current(message) + }, []) + } +} + +const useInlineImagePreviewState = ( + runtimeRef: RefState, + terminalSessionId: string +): InlineImagePreviewState => { + const inlineImagePreviewsEnabledRef = useRef(true) + const [inlineImagePreviewsEnabled, setInlineImagePreviewsEnabled] = useState(true) + useEffect(() => { + inlineImagePreviewsEnabledRef.current = true + setInlineImagePreviewsEnabled(true) + }, [terminalSessionId]) + const toggleInlineImagePreviews = useCallback(() => { + setInlineImagePreviewsEnabled((current) => { + const next = !current + inlineImagePreviewsEnabledRef.current = next + return next + }) + retainTerminalFocus(runtimeRef.current) + }, [runtimeRef]) + + return { inlineImagePreviewsEnabled, inlineImagePreviewsEnabledRef, toggleInlineImagePreviews } +} + +const useMobileCtrlKeyboard = ( + { + hostRef, + mobileCtrlArmed, + mobileMode, + runtimeRef, + setMobileCtrlArmed + }: { + readonly hostRef: RefState + readonly mobileCtrlArmed: boolean + readonly mobileMode: boolean + readonly runtimeRef: RefState + readonly setMobileCtrlArmed: (armed: boolean) => void + } +): void => { + useEffect(() => { + if (!mobileMode || !mobileCtrlArmed || hostRef.current === null) { + return + } + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key === "Escape") { + setMobileCtrlArmed(false) + return + } + if (shouldKeepMobileCtrlArmed(event)) { + return + } + setMobileCtrlArmed(false) + sendMobileCtrlEventInput(runtimeRef.current, event) + } + const host = hostRef.current + host.addEventListener("keydown", handleKeyDown, true) + return () => { + host.removeEventListener("keydown", handleKeyDown, true) + } + }, [hostRef, mobileCtrlArmed, mobileMode, runtimeRef, setMobileCtrlArmed]) +} + +const useMobileTerminalControlState = ( + mobileMode: boolean, + hostRef: RefState, + runtimeRef: RefState +): MobileTerminalControlState => { + const [mobileControlsCollapsed, setMobileControlsCollapsed] = useState(false) + const [mobileCtrlArmed, setMobileCtrlArmed] = useState(false) + useEffect(() => { + if (!mobileMode) { + setMobileControlsCollapsed(false) + setMobileCtrlArmed(false) + } + }, [mobileMode]) + useMobileCtrlKeyboard({ hostRef, mobileCtrlArmed, mobileMode, runtimeRef, setMobileCtrlArmed }) + const handleMobileKeyPress = useCallback((key: MobileTerminalKey) => { + if (key === "ctrl-c") { + setMobileCtrlArmed(false) + } + sendTerminalMobileInput(runtimeRef.current, key) + }, [runtimeRef]) + const toggleMobileControls = useCallback(() => { + setMobileControlsCollapsed((current) => !current) + setMobileCtrlArmed(false) + retainTerminalFocus(runtimeRef.current) + }, [runtimeRef]) + const toggleMobileCtrl = useCallback(() => { + setMobileCtrlArmed((current) => !current) + retainTerminalFocus(runtimeRef.current) + }, [runtimeRef]) + + return { handleMobileKeyPress, mobileControlsCollapsed, mobileCtrlArmed, toggleMobileControls, toggleMobileCtrl } +} + +const useTerminalCloseActions = ( + connectionRef: RefState, + onDetach: () => void, + onKill: () => void +): { readonly handleDetach: () => void; readonly handleKill: () => void } => ({ + handleDetach: useCallback(() => { + connectionRef.current.closing = true + onDetach() + }, [connectionRef, onDetach]), + handleKill: useCallback(() => { + connectionRef.current.closing = true + onKill() + }, [connectionRef, onKill]) +}) + +const TerminalPanelBody = ( + { + bodyContent, + compactTypingMode, + hostRef, + mobileMode + }: Pick +): JSX.Element => ( +
+
+ {bodyContent === undefined ? null :
{bodyContent}
} +
+) + +const TerminalPanelMobileControls = (props: TerminalPanelLayoutProps): JSX.Element | null => + props.mobileMode && props.bodyContent === undefined + ? ( + + ) + : null + +const TerminalPanelLayout = (props: TerminalPanelLayoutProps): JSX.Element => ( +
+ + + +
+) + +export const TerminalPanel = (props: TerminalPanelProps): JSX.Element => { + const connectionRef = useRef({ closing: false, opened: false }) + const hostRef = useRef(null) + const runtimeRef = useRef(null) + const [status, setStatus] = useState(() => resolveInitialTerminalStatus(props.session)) + const compactHeaderMode = resolveTerminalCompactHeaderMode(props.mobileMode) + const compactTypingMode = resolveTerminalTypingMode(props.mobileMode, props.keyboardOpen) + const terminalSessionId = props.session.session.id + const notifications = useTerminalNotificationHandlers(props) + const inlineImageState = useInlineImagePreviewState(runtimeRef, terminalSessionId) + const mobileControlState = useMobileTerminalControlState(props.mobileMode, hostRef, runtimeRef) + const closeActions = useTerminalCloseActions(connectionRef, props.onDetach, props.onKill) + + useEffect(() => { + setStatus(resolveInitialTerminalStatus(props.session)) + }, [props.session]) + useTerminalSessionLifecycle({ + connectionRef, + hostRef, + inlineImagePreviewsEnabledRef: inlineImageState.inlineImagePreviewsEnabledRef, + notifyExit: notifications.notifyExit, + notifyMessage: notifications.notifyMessage, + onAttachFailure: notifications.notifyAttachFailure, + runtimeRef, + session: props.session, + setStatus + }) + + return ( + + ) +} diff --git a/packages/terminal/src/web/terminal-copy-interaction.ts b/packages/terminal/src/web/terminal-copy-interaction.ts new file mode 100644 index 00000000..ce69d0af --- /dev/null +++ b/packages/terminal/src/web/terminal-copy-interaction.ts @@ -0,0 +1,241 @@ +import { + createTerminalSelectionDragController, + forceTerminalSelectionModifier, + suppressTerminalMouseReport, + type TerminalCopyMouseEvent, + type TerminalCopyMouseEventType, + type TerminalMouseButtonEvent, + type TerminalSelectionDragTarget +} from "./terminal-copy-selection-drag.js" + +export { forceTerminalSelectionModifier } from "./terminal-copy-selection-drag.js" + +export type TerminalMouseTrackingMode = "any" | "drag" | "none" | "vt200" | "x10" + +type TerminalSelectionTarget = { + readonly getSelection: () => string + readonly hasSelection: () => boolean +} + +export type TerminalCopyInteractionTerminal = TerminalSelectionTarget & { + readonly modes: { + readonly mouseTrackingMode: TerminalMouseTrackingMode + } +} + +type TerminalCopyClipboardData = { + readonly setData: (format: string, data: string) => void +} + +type TerminalCopyClipboardEvent = { + readonly clipboardData: TerminalCopyClipboardData | null + readonly preventDefault: () => void + readonly stopPropagation: () => void +} + +type TerminalCopyListenerRegistration = { + (type: "copy", listener: (event: TerminalCopyClipboardEvent) => void, options: true): void + (type: TerminalCopyMouseEventType, listener: (event: TerminalCopyMouseEvent) => void, options: true): void +} + +type TerminalCopyInteractionHost = { + readonly ownerDocument?: TerminalSelectionDragTarget | null + readonly addEventListener: TerminalCopyListenerRegistration + readonly removeEventListener: TerminalCopyListenerRegistration +} + +type TerminalCopyInteractionArgs = { + readonly host: TerminalCopyInteractionHost + readonly terminal: TerminalCopyInteractionTerminal +} + +const primaryMouseButton = 0 +const secondaryMouseButton = 2 +const terminalSelectionContextSnapshotTtlMs = 10_000 + +const isPrimaryMouseButton = (event: TerminalMouseButtonEvent): boolean => event.button === primaryMouseButton + +const isSecondaryMouseButton = (event: TerminalMouseButtonEvent): boolean => event.button === secondaryMouseButton + +const hasActiveMouseTracking = (terminal: TerminalCopyInteractionTerminal): boolean => + terminal.modes.mouseTrackingMode !== "none" + +export const shouldForceBrowserTerminalSelection = ( + event: TerminalMouseButtonEvent, + terminal: TerminalCopyInteractionTerminal +): boolean => isPrimaryMouseButton(event) && hasActiveMouseTracking(terminal) + +/** + * Decides whether a secondary-button event must preserve the terminal selection context. + * + * @param event - Mouse button event captured before xterm/tmux handlers can clear the selection. + * @param terminal - Terminal selection and mouse-tracking facade. + * @returns True iff the event is a secondary click, mouse tracking is active, and a selection exists. + * @pure true + * @effect isSecondaryMouseButton(event), hasActiveMouseTracking(terminal), terminal.hasSelection(). + * @invariant result <=> secondary(event) and tracking(terminal) and selected(terminal). + * @precondition `event` and `terminal` are non-null; mouse tracking may be `none`, which disables forcing. + * @postcondition True means the caller may snapshot selection text before suppressing terminal mouse reporting. + * @complexity O(1) + * @throws Never + */ +// CHANGE: document the guarded right-click selection preservation predicate +// WHY: selection protection is valid only while terminal mouse tracking can consume right-click events +// QUOTE(ТЗ): "right-click with selection should remain copyable in the terminal" +// REF: issue-340 +// SOURCE: n/a +// FORMAT THEOREM: forall e,t: force(e,t) <-> secondary(e) and tracking(t) and hasSelection(t) +// PURITY: CORE +// EFFECT: reads terminal.hasSelection through the injected terminal facade +// INVARIANT: mouseTrackingMode = none always yields false +// COMPLEXITY: O(1) +export const shouldForceTerminalSelectionContext = ( + event: TerminalMouseButtonEvent, + terminal: TerminalCopyInteractionTerminal +): boolean => isSecondaryMouseButton(event) && hasActiveMouseTracking(terminal) && terminal.hasSelection() + +export const writeTerminalSelectionToClipboardData = ( + terminal: TerminalSelectionTarget, + clipboardData: TerminalCopyClipboardData | null +): boolean => { + if (clipboardData === null || !terminal.hasSelection()) { + return false + } + const selection = terminal.getSelection() + if (selection.length === 0) { + return false + } + clipboardData.setData("text/plain", selection) + return true +} + +class TerminalSelectionContextSnapshot { + private selection = "" + private timer: ReturnType | null = null + + constructor(private readonly terminal: TerminalSelectionTarget) {} + + readonly clear = (): void => { + this.selection = "" + if (this.timer !== null) { + clearTimeout(this.timer) + this.timer = null + } + } + + readonly has = (): boolean => this.selection.length > 0 + + readonly refresh = (): boolean => { + const selection = this.terminal.getSelection() + if (selection.length === 0) { + this.clear() + return false + } + this.selection = selection + if (this.timer !== null) { + clearTimeout(this.timer) + } + this.timer = setTimeout(this.clear, terminalSelectionContextSnapshotTtlMs) + return true + } + + readonly writeToClipboardData = (clipboardData: TerminalCopyClipboardData | null): boolean => { + if (clipboardData === null || this.selection.length === 0) { + return false + } + clipboardData.setData("text/plain", this.selection) + return true + } +} + +class TerminalCopyInteractionController { + private readonly selectionContext: TerminalSelectionContextSnapshot + private readonly selectionDrag: ReturnType + + constructor(private readonly args: TerminalCopyInteractionArgs) { + this.selectionContext = new TerminalSelectionContextSnapshot(args.terminal) + this.selectionDrag = createTerminalSelectionDragController(args.host) + } + + readonly attach = (): { readonly dispose: () => void } => { + this.args.host.addEventListener("mousedown", this.onMouseDown, true) + this.args.host.addEventListener("mouseup", this.onMouseUp, true) + this.args.host.addEventListener("contextmenu", this.onContextMenu, true) + this.args.host.addEventListener("copy", this.onCopy, true) + return { dispose: this.dispose } + } + + private readonly shouldProtectSelectionContext = (event: TerminalCopyMouseEvent): boolean => + isSecondaryMouseButton(event) && + hasActiveMouseTracking(this.args.terminal) && + (this.selectionContext.has() || this.args.terminal.hasSelection()) + + private readonly onSelectionContextMouseEvent = (event: TerminalCopyMouseEvent): boolean => { + if (!this.shouldProtectSelectionContext(event)) { + return false + } + forceTerminalSelectionModifier(event) + if (this.args.terminal.hasSelection()) { + this.selectionContext.refresh() + } + return true + } + + private readonly onMouseDown = (event: TerminalCopyMouseEvent): void => { + if (isPrimaryMouseButton(event)) { + this.selectionContext.clear() + } + const forceBrowserSelection = shouldForceBrowserTerminalSelection(event, this.args.terminal) + const forceSelectionContext = shouldForceTerminalSelectionContext(event, this.args.terminal) + if (!forceBrowserSelection && !forceSelectionContext) { + if (isSecondaryMouseButton(event)) { + this.selectionContext.clear() + } + return + } + forceTerminalSelectionModifier(event) + if (forceSelectionContext) { + this.selectionContext.refresh() + suppressTerminalMouseReport(event) + return + } + if (forceBrowserSelection) { + this.selectionDrag.start() + } + } + + private readonly onMouseUp = (event: TerminalCopyMouseEvent): void => { + if (!this.onSelectionContextMouseEvent(event)) { + return + } + suppressTerminalMouseReport(event) + } + + private readonly onContextMenu = (event: TerminalCopyMouseEvent): void => { + this.onSelectionContextMouseEvent(event) + } + + private readonly onCopy = (event: TerminalCopyClipboardEvent): void => { + const wroteSelection = writeTerminalSelectionToClipboardData(this.args.terminal, event.clipboardData) + const wroteSnapshot = wroteSelection ? false : this.selectionContext.writeToClipboardData(event.clipboardData) + if (!wroteSelection && !wroteSnapshot) { + return + } + this.selectionContext.clear() + event.preventDefault() + event.stopPropagation() + } + + private readonly dispose = (): void => { + this.selectionDrag.dispose() + this.selectionContext.clear() + this.args.host.removeEventListener("mousedown", this.onMouseDown, true) + this.args.host.removeEventListener("mouseup", this.onMouseUp, true) + this.args.host.removeEventListener("contextmenu", this.onContextMenu, true) + this.args.host.removeEventListener("copy", this.onCopy, true) + } +} + +export const attachTerminalCopyInteraction = ( + args: TerminalCopyInteractionArgs +): { readonly dispose: () => void } => new TerminalCopyInteractionController(args).attach() diff --git a/packages/terminal/src/web/terminal-copy-selection-drag.ts b/packages/terminal/src/web/terminal-copy-selection-drag.ts new file mode 100644 index 00000000..d6e4c85c --- /dev/null +++ b/packages/terminal/src/web/terminal-copy-selection-drag.ts @@ -0,0 +1,202 @@ +export type TerminalMouseButtonEvent = { + readonly button: number +} + +export type TerminalSelectionModifierEvent = { + readonly altKey: boolean + readonly shiftKey: boolean +} + +export type TerminalCopyMouseEvent = TerminalMouseButtonEvent & TerminalSelectionModifierEvent & { + readonly buttons?: number | undefined + readonly clientX?: number | undefined + readonly clientY?: number | undefined + readonly ctrlKey?: boolean | undefined + readonly detail?: number | undefined + readonly metaKey?: boolean | undefined + readonly preventDefault?: (() => void) | undefined + readonly screenX?: number | undefined + readonly screenY?: number | undefined + readonly stopImmediatePropagation?: (() => void) | undefined + readonly stopPropagation?: (() => void) | undefined +} + +export type TerminalSelectionDragEventType = "mousemove" | "mouseup" +export type TerminalCopyMouseEventType = "contextmenu" | "mousedown" | TerminalSelectionDragEventType + +type TerminalSelectionDragListenerRegistration = ( + type: TerminalSelectionDragEventType, + listener: (event: TerminalCopyMouseEvent) => void, + options: true +) => void + +export type TerminalSelectionDragTarget = { + readonly addEventListener: TerminalSelectionDragListenerRegistration + readonly dispatchEvent?: ((event: Event) => boolean) | undefined + readonly removeEventListener: TerminalSelectionDragListenerRegistration +} + +export type TerminalSelectionDragHost = TerminalSelectionDragTarget & { + readonly ownerDocument?: TerminalSelectionDragTarget | null +} + +type TerminalSelectionDragController = { + readonly dispose: () => void + readonly start: () => void +} + +const macPlatformNames = new Set(["Mac68K", "MacIntel", "Macintosh", "MacPPC"]) + +const currentNavigatorPlatform = (): string => { + if (typeof navigator === "undefined") { + return "" + } + return navigator.platform +} + +const terminalSelectionModifier = (platform: string): keyof TerminalSelectionModifierEvent => + macPlatformNames.has(platform) ? "altKey" : "shiftKey" + +export const forceTerminalSelectionModifier = ( + event: TerminalSelectionModifierEvent, + platform: string = currentNavigatorPlatform() +): boolean => + Reflect.defineProperty(event, terminalSelectionModifier(platform), { + configurable: true, + value: true + }) + +const optionalNumber = (value: number | undefined): number => value ?? 0 + +const optionalBoolean = (value: boolean | undefined): boolean => value ?? false + +const forcedTerminalMouseUpInit = (event: TerminalCopyMouseEvent): MouseEventInit => { + const selectionModifier = terminalSelectionModifier(currentNavigatorPlatform()) + return { + altKey: selectionModifier === "altKey" ? true : event.altKey, + bubbles: true, + button: event.button, + buttons: 0, + cancelable: true, + clientX: optionalNumber(event.clientX), + clientY: optionalNumber(event.clientY), + ctrlKey: optionalBoolean(event.ctrlKey), + detail: optionalNumber(event.detail), + metaKey: optionalBoolean(event.metaKey), + screenX: optionalNumber(event.screenX), + screenY: optionalNumber(event.screenY), + shiftKey: selectionModifier === "shiftKey" ? true : event.shiftKey + } +} + +const defineMouseEventProperty = ( + event: Event, + property: string, + value: boolean | number +): void => { + Reflect.defineProperty(event, property, { + configurable: true, + value + }) +} + +const copyMouseEventInitProperties = ( + event: Event, + init: MouseEventInit +): void => { + defineMouseEventProperty(event, "altKey", optionalBoolean(init.altKey)) + defineMouseEventProperty(event, "button", optionalNumber(init.button)) + defineMouseEventProperty(event, "buttons", optionalNumber(init.buttons)) + defineMouseEventProperty(event, "clientX", optionalNumber(init.clientX)) + defineMouseEventProperty(event, "clientY", optionalNumber(init.clientY)) + defineMouseEventProperty(event, "ctrlKey", optionalBoolean(init.ctrlKey)) + defineMouseEventProperty(event, "detail", optionalNumber(init.detail)) + defineMouseEventProperty(event, "metaKey", optionalBoolean(init.metaKey)) + defineMouseEventProperty(event, "screenX", optionalNumber(init.screenX)) + defineMouseEventProperty(event, "screenY", optionalNumber(init.screenY)) + defineMouseEventProperty(event, "shiftKey", optionalBoolean(init.shiftKey)) +} + +const createForcedTerminalMouseUpEvent = ( + sourceEvent: TerminalCopyMouseEvent +): Event => { + const init = forcedTerminalMouseUpInit(sourceEvent) + const event = typeof MouseEvent === "function" + ? new MouseEvent("mouseup", init) + : new Event("mouseup", { bubbles: true, cancelable: true }) + copyMouseEventInitProperties(event, init) + return event +} + +const suppressOriginalTerminalMouseUp = (event: TerminalCopyMouseEvent): void => { + event.preventDefault?.() + event.stopPropagation?.() + event.stopImmediatePropagation?.() +} + +export const suppressTerminalMouseReport = (event: TerminalCopyMouseEvent): void => { + event.stopPropagation?.() + event.stopImmediatePropagation?.() +} + +const replayForcedTerminalMouseUp = ( + target: TerminalSelectionDragTarget, + event: TerminalCopyMouseEvent +): void => { + target.dispatchEvent?.(createForcedTerminalMouseUpEvent(event)) +} + +const resolveTerminalSelectionDragTarget = ( + host: TerminalSelectionDragHost +): TerminalSelectionDragTarget => host.ownerDocument ?? host + +class TerminalSelectionDragControllerImpl implements TerminalSelectionDragController { + private forcedSelectionDrag = false + private selectionDragTarget: TerminalSelectionDragTarget | null = null + + constructor(private readonly host: TerminalSelectionDragHost) {} + + readonly dispose = (): void => { + if (this.selectionDragTarget === null) { + this.forcedSelectionDrag = false + return + } + this.selectionDragTarget.removeEventListener("mousemove", this.onMouseMove, true) + this.selectionDragTarget.removeEventListener("mouseup", this.onMouseUp, true) + this.selectionDragTarget = null + this.forcedSelectionDrag = false + } + + readonly start = (): void => { + this.dispose() + this.forcedSelectionDrag = true + this.selectionDragTarget = resolveTerminalSelectionDragTarget(this.host) + this.selectionDragTarget.addEventListener("mousemove", this.onMouseMove, true) + this.selectionDragTarget.addEventListener("mouseup", this.onMouseUp, true) + } + + private readonly onMouseMove = (event: TerminalCopyMouseEvent): void => { + if (this.forcedSelectionDrag) { + forceTerminalSelectionModifier(event) + } + } + + private readonly onMouseUp = (event: TerminalCopyMouseEvent): void => { + if (!this.forcedSelectionDrag) { + return + } + const target = this.selectionDragTarget + forceTerminalSelectionModifier(event) + if (target?.dispatchEvent === undefined) { + this.dispose() + return + } + suppressOriginalTerminalMouseUp(event) + this.dispose() + replayForcedTerminalMouseUp(target, event) + } +} + +export const createTerminalSelectionDragController = ( + host: TerminalSelectionDragHost +): TerminalSelectionDragController => new TerminalSelectionDragControllerImpl(host) diff --git a/packages/terminal/src/web/terminal-image-paste.ts b/packages/terminal/src/web/terminal-image-paste.ts new file mode 100644 index 00000000..8d7e12c3 --- /dev/null +++ b/packages/terminal/src/web/terminal-image-paste.ts @@ -0,0 +1,317 @@ +import type { Terminal } from "xterm" + +import type { TerminalPasteGuard, TerminalSocketRef } from "./terminal-panel-runtime-types.js" + +type TerminalImagePasteArgs = { + readonly host: HTMLDivElement + readonly notifyMessage: (message: string) => void + readonly pasteGuard: TerminalPasteGuard + readonly socketRef: TerminalSocketRef + readonly terminal: Terminal +} + +type TerminalImagePasteClientMessage = { + readonly data: string + readonly mediaType: string + readonly name: string + readonly size: number + readonly type: "image" +} + +type TerminalPasteTrapState = { + active: boolean + restoreTimer: ReturnType | null +} + +const terminalImagePasteMaxBytes = 10 * 1024 * 1024 +const dataUrlBase64Marker = ";base64," +const nativeImagePasteControlInput = "\u0016" +const nativeImagePasteSuppressWindowMs = 800 +const pasteTrapRestoreDelayMs = 800 +const supportedImageMediaTypes = new Set(["image/gif", "image/jpeg", "image/png", "image/webp"]) + +export const extractTerminalImageBase64 = (dataUrl: string): string | null => { + const markerIndex = dataUrl.indexOf(dataUrlBase64Marker) + return markerIndex === -1 ? null : dataUrl.slice(markerIndex + dataUrlBase64Marker.length) +} + +const fileLabel = (file: File): string => { + const trimmed = file.name.trim() + return trimmed.length > 0 ? trimmed : "clipboard-image" +} + +const isSupportedImageFile = (file: File): boolean => supportedImageMediaTypes.has(file.type.toLowerCase()) + +const imageFilesFromItems = (items: DataTransferItemList): ReadonlyArray => + [...items].flatMap((item) => { + if (item.kind !== "file" || !item.type.toLowerCase().startsWith("image/")) { + return [] + } + const file = item.getAsFile() + return file === null ? [] : [file] + }) + +export const terminalImageFilesFromTransfer = ( + dataTransfer: DataTransfer | null +): ReadonlyArray => { + if (dataTransfer === null) { + return [] + } + const files = [...dataTransfer.files].filter((file) => file.type.toLowerCase().startsWith("image/")) + return files.length > 0 ? files : imageFilesFromItems(dataTransfer.items) +} + +const hasImageFileTransfer = (dataTransfer: DataTransfer | null): boolean => { + if (dataTransfer === null) { + return false + } + return [...dataTransfer.items].some((item) => item.kind === "file" && item.type.toLowerCase().startsWith("image/")) || + [...dataTransfer.files].some((file) => file.type.toLowerCase().startsWith("image/")) +} + +const socketCanSend = (socket: WebSocket | null): socket is WebSocket => + socket !== null && socket.readyState === WebSocket.OPEN + +const sendTerminalInput = ( + args: TerminalImagePasteArgs, + data: string +): void => { + const socket = args.socketRef.current + if (!socketCanSend(socket)) { + args.notifyMessage("Terminal is not connected; clipboard was not pasted.") + return + } + socket.send(JSON.stringify({ data, type: "input" })) +} + +const createImagePasteMessage = ( + file: File, + base64: string +): TerminalImagePasteClientMessage => ({ + data: base64, + mediaType: file.type, + name: fileLabel(file), + size: file.size, + type: "image" +}) + +const sendImagePasteMessage = ( + args: TerminalImagePasteArgs, + file: File, + base64: string +): void => { + const socket = args.socketRef.current + if (!socketCanSend(socket)) { + args.notifyMessage("Terminal is not connected; image was not pasted.") + return + } + socket.send(JSON.stringify(createImagePasteMessage(file, base64))) +} + +const readAndSendImageFile = ( + args: TerminalImagePasteArgs, + file: File +): void => { + if (!isSupportedImageFile(file)) { + args.notifyMessage(`Unsupported image type: ${file.type || "unknown"}.`) + return + } + if (file.size <= 0) { + args.notifyMessage("Image clipboard item is empty.") + return + } + if (file.size > terminalImagePasteMaxBytes) { + args.notifyMessage(`Image is too large. Max size is ${terminalImagePasteMaxBytes} bytes.`) + return + } + const reader = new FileReader() + reader.addEventListener("load", () => { + if (typeof reader.result !== "string") { + args.notifyMessage("Could not read pasted image.") + return + } + const base64 = extractTerminalImageBase64(reader.result) + if (base64 === null) { + args.notifyMessage("Could not encode pasted image.") + return + } + sendImagePasteMessage(args, file, base64) + }) + reader.addEventListener("error", () => { + args.notifyMessage("Could not read pasted image.") + }) + reader.readAsDataURL(file) +} + +const handleImageFiles = ( + args: TerminalImagePasteArgs, + files: ReadonlyArray +): void => { + if (files.length === 0) { + return + } + args.notifyMessage(files.length === 1 ? "Uploading pasted image..." : `Uploading ${files.length} pasted images...`) + for (const file of files) { + readAndSendImageFile(args, file) + } + args.terminal.focus() +} + +const textFromTransfer = (dataTransfer: DataTransfer | null): string => dataTransfer?.getData("text/plain") ?? "" + +const handleTerminalClipboardTransfer = ( + args: TerminalImagePasteArgs, + dataTransfer: DataTransfer | null, + includeText: boolean +): boolean => { + const files = terminalImageFilesFromTransfer(dataTransfer) + if (files.length > 0) { + handleImageFiles(args, files) + return true + } + if (!includeText) { + return false + } + const text = textFromTransfer(dataTransfer) + if (text.length === 0) { + return false + } + sendTerminalInput(args, text) + args.terminal.focus() + return true +} + +const handleTerminalImageDragOver = (event: DragEvent): void => { + if (!hasImageFileTransfer(event.dataTransfer)) { + return + } + event.preventDefault() + if (event.dataTransfer !== null) { + event.dataTransfer.dropEffect = "copy" + } +} + +type TerminalPasteShortcutEvent = Pick + +export const createTerminalPasteGuard = ( + currentTimeMillis: () => number = () => Date.now() +): TerminalPasteGuard => { + let expiresAtMs = 0 + let pending = false + return { + shouldSuppressTerminalInput: (data) => { + if (!pending || data !== nativeImagePasteControlInput || currentTimeMillis() > expiresAtMs) { + return false + } + pending = false + return true + }, + suppressNextNativeImagePaste: () => { + pending = true + expiresAtMs = currentTimeMillis() + nativeImagePasteSuppressWindowMs + } + } +} + +export const isTerminalPasteShortcut = (event: TerminalPasteShortcutEvent): boolean => + (event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "v" + +const eventTargetInsideHost = ( + host: HTMLDivElement, + event: Event +): boolean => event.target instanceof Node && host.contains(event.target) + +const createTerminalPasteTrap = (host: HTMLDivElement): HTMLTextAreaElement => { + const trap = document.createElement("textarea") + trap.setAttribute("aria-hidden", "true") + trap.tabIndex = -1 + trap.style.position = "fixed" + trap.style.left = "-10000px" + trap.style.top = "0" + trap.style.width = "1px" + trap.style.height = "1px" + trap.style.opacity = "0" + trap.style.pointerEvents = "none" + host.append(trap) + return trap +} + +const clearPasteTrapTimer = (state: TerminalPasteTrapState): void => { + if (state.restoreTimer !== null) { + clearTimeout(state.restoreTimer) + state.restoreTimer = null + } +} + +const deactivatePasteTrap = ( + args: TerminalImagePasteArgs, + state: TerminalPasteTrapState +): void => { + clearPasteTrapTimer(state) + state.active = false + args.terminal.focus() +} + +const activatePasteTrap = ( + args: TerminalImagePasteArgs, + trap: HTMLTextAreaElement, + state: TerminalPasteTrapState +): void => { + clearPasteTrapTimer(state) + state.active = true + trap.value = "" + trap.focus() + trap.select() + state.restoreTimer = setTimeout(() => { + deactivatePasteTrap(args, state) + }, pasteTrapRestoreDelayMs) +} + +export const attachTerminalImagePaste = ( + args: TerminalImagePasteArgs +): { readonly dispose: () => void } => { + const pasteTrap = createTerminalPasteTrap(args.host) + const trapState: TerminalPasteTrapState = { active: false, restoreTimer: null } + const onPaste = (event: ClipboardEvent): void => { + const handled = handleTerminalClipboardTransfer(args, event.clipboardData, trapState.active) + if (!handled) { + return + } + event.preventDefault() + event.stopPropagation() + deactivatePasteTrap(args, trapState) + } + const onKeyDown = (event: KeyboardEvent): void => { + if (!isTerminalPasteShortcut(event) || !eventTargetInsideHost(args.host, event)) { + return + } + args.pasteGuard.suppressNextNativeImagePaste() + event.stopImmediatePropagation() + event.stopPropagation() + activatePasteTrap(args, pasteTrap, trapState) + } + const onDrop = (event: DragEvent): void => { + const files = terminalImageFilesFromTransfer(event.dataTransfer) + if (files.length === 0) { + return + } + event.preventDefault() + handleImageFiles(args, files) + } + + args.host.addEventListener("paste", onPaste, true) + globalThis.addEventListener("keydown", onKeyDown, true) + args.host.addEventListener("dragover", handleTerminalImageDragOver, true) + args.host.addEventListener("drop", onDrop, true) + + return { + dispose: () => { + clearPasteTrapTimer(trapState) + args.host.removeEventListener("paste", onPaste, true) + globalThis.removeEventListener("keydown", onKeyDown, true) + args.host.removeEventListener("dragover", handleTerminalImageDragOver, true) + args.host.removeEventListener("drop", onDrop, true) + pasteTrap.remove() + } + } +} diff --git a/packages/terminal/src/web/terminal-image-paths.ts b/packages/terminal/src/web/terminal-image-paths.ts new file mode 100644 index 00000000..2bb4591d --- /dev/null +++ b/packages/terminal/src/web/terminal-image-paths.ts @@ -0,0 +1,81 @@ +const supportedExtensions: ReadonlyArray = ["png", "jpg", "jpeg", "gif", "webp"] + +const extensionAlternation = supportedExtensions.join("|") + +const absoluteImagePathSource = String.raw`/[^\s"'(<>\[\]{}|\\]+\.(?:${extensionAlternation})` +const imagePathPattern = new RegExp( + String.raw`(?:^|[\s"'(<>\[\]{}|])((?:file://)?${absoluteImagePathSource})(?=$|[\s"')<>\[\]{}|.,;:?!])`, + "giu" +) + +const treePointerImagePathSource = String.raw`[^\s"'(<>\[\]{}|\\/][^\s"'(<>\[\]{}|\\]*\.(?:${extensionAlternation})` +const treePointerImagePathPattern = new RegExp( + String.raw`(?:^|\s)[└├]\s+(${treePointerImagePathSource})(?=$|[\s"')<>\[\]{}|.,;:?!])`, + "giu" +) + +const escapeChar = String.fromCodePoint(0x1B) +const bellChar = String.fromCodePoint(0x07) + +const buildAnsiPattern = (source: string): RegExp => new RegExp(source, "gu") + +const ansiCsiPattern = buildAnsiPattern(String.raw`${escapeChar}\[[0-?]*[ -/]*[@-~]`) +const ansiOscPattern = buildAnsiPattern(String.raw`${escapeChar}\][\s\S]*?(?:${bellChar}|${escapeChar}\\)`) +const ansiOtherEscapePattern = buildAnsiPattern(`${escapeChar}.`) + +export type TerminalImagePathMatch = { + readonly endIndex: number + readonly path: string + readonly startIndex: number +} + +export const stripTerminalAnsi = (text: string): string => + text.replace(ansiOscPattern, "").replace(ansiCsiPattern, "").replace(ansiOtherEscapePattern, "") + +const collectPatternMatches = ( + plainText: string, + pattern: RegExp, + matches: Array, + seenStartIndices: Set +): void => { + for (const match of plainText.matchAll(pattern)) { + const candidate = match[1] + if (candidate === undefined || candidate.length === 0) { + continue + } + const fullMatch = match[0] + const fullStartIndex = match.index + const startIndex = fullStartIndex + fullMatch.lastIndexOf(candidate) + if (seenStartIndices.has(startIndex)) { + continue + } + seenStartIndices.add(startIndex) + matches.push({ + endIndex: startIndex + candidate.length, + path: candidate, + startIndex + }) + } +} + +export const detectTerminalImagePathMatches = (text: string): ReadonlyArray => { + const plainText = stripTerminalAnsi(text) + const matches: Array = [] + const seenStartIndices = new Set() + collectPatternMatches(plainText, imagePathPattern, matches, seenStartIndices) + collectPatternMatches(plainText, treePointerImagePathPattern, matches, seenStartIndices) + return matches +} + +export const detectTerminalImagePaths = (text: string): ReadonlyArray => { + const matches = new Set() + for (const match of detectTerminalImagePathMatches(text)) { + matches.add(match.path) + } + return [...matches] +} + +export const isSupportedTerminalImagePath = (path: string): boolean => { + const lower = path.toLowerCase() + return supportedExtensions.some((extension) => lower.endsWith(`.${extension}`)) +} diff --git a/packages/terminal/src/web/terminal-image-url.ts b/packages/terminal/src/web/terminal-image-url.ts new file mode 100644 index 00000000..3a8ed8d4 --- /dev/null +++ b/packages/terminal/src/web/terminal-image-url.ts @@ -0,0 +1,13 @@ +import { resolveTerminalApiOriginUrl } from "./terminal.js" + +const websocketSuffixPattern = /\/ws$/u + +export const resolveTerminalImageBasePath = (websocketPath: string): string => + websocketPath.replace(websocketSuffixPattern, "/image") + +export const resolveTerminalImageFetchUrl = (websocketPath: string, imagePath: string): string => { + const apiUrl = resolveTerminalApiOriginUrl() + apiUrl.pathname = `${apiUrl.pathname.replace(/\/$/u, "")}${resolveTerminalImageBasePath(websocketPath)}` + apiUrl.searchParams.set("path", imagePath) + return apiUrl.toString() +} diff --git a/packages/terminal/src/web/terminal-inline-images-core.ts b/packages/terminal/src/web/terminal-inline-images-core.ts new file mode 100644 index 00000000..003e26f0 --- /dev/null +++ b/packages/terminal/src/web/terminal-inline-images-core.ts @@ -0,0 +1,90 @@ +import { detectTerminalImagePaths } from "./terminal-image-paths.js" + +export type TerminalInlineImageOutputSegment = { + readonly endedWithLineBreak: boolean + readonly imagePaths: ReadonlyArray + readonly text: string +} + +export type TerminalInlineImagePreviewsEnabledRef = { readonly current: boolean } + +export type TerminalOutputSegmentWriter = { + readonly writePreviewLineBreak: (segment: TerminalInlineImageOutputSegment, onComplete: () => void) => void + readonly writePreviews: (paths: ReadonlyArray, onComplete: () => void) => void + readonly writeText: (text: string, onComplete: () => void) => void +} + +export type TerminalOutputSegmentWriteArgs = { + readonly inlineImagePreviewsEnabledRef: TerminalInlineImagePreviewsEnabledRef + readonly segment: TerminalInlineImageOutputSegment + readonly writer: TerminalOutputSegmentWriter +} + +const lineBreakPattern = /\r\n|\r|\n/gu + +const endsWithLineBreak = (text: string): boolean => /\r\n$|\r$|\n$/u.test(text) + +export const splitTerminalInlineImageOutput = ( + data: string +): ReadonlyArray => { + if (data.length === 0) { + return [] + } + const segments: Array = [] + let startIndex = 0 + for (const match of data.matchAll(lineBreakPattern)) { + const endIndex = match.index + match[0].length + const text = data.slice(startIndex, endIndex) + segments.push({ + endedWithLineBreak: true, + imagePaths: detectTerminalImagePaths(text), + text + }) + startIndex = endIndex + } + if (startIndex < data.length) { + const text = data.slice(startIndex) + segments.push({ + endedWithLineBreak: endsWithLineBreak(text), + imagePaths: detectTerminalImagePaths(text), + text + }) + } + return segments +} + +/** + * Coordinates terminal output writes for one parsed segment. + * + * This function only sequences the supplied writer callbacks. It does not fetch + * image data, allocate decorations, or mutate terminal state directly; those + * effects belong to the writer implementation. + * + * @pure false - invokes effectful writer callbacks. + * @effect writer callbacks: writeText, writePreviewLineBreak, writePreviews. + * @precondition segment is the next queued terminal output segment and + * onComplete belongs to the caller's active output queue drain. + * @postcondition writeText is requested exactly once; when previews are enabled + * and imagePaths is non-empty, the preview line break and preview writes are + * requested in order before onComplete. + * @invariant segment.text is emitted before any preview callback, and preview + * callbacks never run when imagePaths is empty or previews are disabled. + * @complexity O(1) plus writer callback complexity; image paths are forwarded + * without iteration. + * @throws Through writer callbacks or onComplete only; this function has no + * explicit throw path. + */ +export const writeTerminalOutputSegment = ( + { inlineImagePreviewsEnabledRef, segment, writer }: TerminalOutputSegmentWriteArgs, + onComplete: () => void +): void => { + writer.writeText(segment.text, () => { + if (segment.imagePaths.length === 0 || !inlineImagePreviewsEnabledRef.current) { + onComplete() + return + } + writer.writePreviewLineBreak(segment, () => { + writer.writePreviews(segment.imagePaths, onComplete) + }) + }) +} diff --git a/packages/terminal/src/web/terminal-inline-images.ts b/packages/terminal/src/web/terminal-inline-images.ts new file mode 100644 index 00000000..8581de9d --- /dev/null +++ b/packages/terminal/src/web/terminal-inline-images.ts @@ -0,0 +1,274 @@ +import type { IDisposable, ILink, Terminal } from "xterm" + +import { detectTerminalImagePathMatches } from "./terminal-image-paths.js" +import { resolveTerminalImageFetchUrl } from "./terminal-image-url.js" +import type { TerminalLifecycleState } from "./terminal-panel-runtime-types.js" +import type { ActiveTerminalSession } from "./terminal.js" + +export const terminalInlineImagePreviewLimit = 20 +export const terminalInlineImagePreviewRows = 4 + +export const terminalInlineImageSpacer = "\r\n".repeat(terminalInlineImagePreviewRows) + +const terminalInlineImagePreviewColumns = 16 +const terminalInlineImagePreviewHeightPx = 56 +const terminalInlineImagePreviewWidthPx = 96 + +export type TerminalInlineImageEntry = + | { + readonly _tag: "AvailableTerminalInlineImage" + readonly displayUrl: string + readonly fetchUrl: string + readonly path: string + } + | { + readonly _tag: "UnavailableTerminalInlineImage" + readonly fetchUrl: string + readonly path: string + } + +type TerminalInlineImageObjectUrlCache = Map + +const availableTerminalInlineImageEntry = ( + path: string, + fetchUrl: string, + displayUrl: string +): TerminalInlineImageEntry => ({ + _tag: "AvailableTerminalInlineImage", + displayUrl, + fetchUrl, + path +}) + +export const unavailableTerminalInlineImageEntry = ( + path: string, + fetchUrl: string +): TerminalInlineImageEntry => ({ + _tag: "UnavailableTerminalInlineImage", + fetchUrl, + path +}) + +export const cachedTerminalInlineImageEntry = ( + cache: TerminalInlineImageObjectUrlCache, + path: string, + fetchUrl: string +): TerminalInlineImageEntry | null => { + const displayUrl = cache.get(path) + return displayUrl === undefined ? null : availableTerminalInlineImageEntry(path, fetchUrl, displayUrl) +} + +const revokeTerminalInlineImageObjectUrl = (displayUrl: string): void => { + URL.revokeObjectURL(displayUrl) +} + +const trimTerminalInlineImageObjectUrlCache = ( + cache: TerminalInlineImageObjectUrlCache +): void => { + while (cache.size > terminalInlineImagePreviewLimit) { + const first = cache.entries().next() + if (first.done) { + return + } + const [path, displayUrl] = first.value + cache.delete(path) + revokeTerminalInlineImageObjectUrl(displayUrl) + } +} + +export const cacheTerminalInlineImageBlob = ( + cache: TerminalInlineImageObjectUrlCache, + path: string, + fetchUrl: string, + blob: Blob +): TerminalInlineImageEntry => { + const cached = cachedTerminalInlineImageEntry(cache, path, fetchUrl) + if (cached !== null) { + return cached + } + const displayUrl = URL.createObjectURL(blob) + cache.set(path, displayUrl) + trimTerminalInlineImageObjectUrlCache(cache) + return availableTerminalInlineImageEntry(path, fetchUrl, displayUrl) +} + +export const revokeTerminalInlineImageObjectUrlCache = ( + cache: TerminalInlineImageObjectUrlCache +): void => { + for (const displayUrl of cache.values()) { + revokeTerminalInlineImageObjectUrl(displayUrl) + } + cache.clear() +} + +const terminalInlineImageLinkUrl = (entry: TerminalInlineImageEntry): string => + entry._tag === "AvailableTerminalInlineImage" ? entry.displayUrl : entry.fetchUrl + +const terminalInlineImageTitle = (entry: TerminalInlineImageEntry): string => + entry._tag === "AvailableTerminalInlineImage" ? entry.path : `${entry.path} unavailable` + +const createTerminalInlineImageLink = (entry: TerminalInlineImageEntry): HTMLAnchorElement => { + const link = document.createElement("a") + link.href = terminalInlineImageLinkUrl(entry) + link.rel = "noreferrer" + link.target = "_blank" + link.title = terminalInlineImageTitle(entry) + link.style.alignItems = "center" + link.style.background = "#0d1218" + link.style.border = "1px solid #3a4652" + link.style.borderRadius = "6px" + link.style.boxSizing = "border-box" + link.style.cursor = "pointer" + link.style.display = "inline-flex" + link.style.height = `min(${terminalInlineImagePreviewHeightPx}px, calc(100% - 8px))` + link.style.justifyContent = "center" + link.style.margin = "4px 0" + link.style.padding = "4px" + link.style.pointerEvents = "auto" + link.style.width = `min(${terminalInlineImagePreviewWidthPx}px, 100%)` + return link +} + +const appendAvailableTerminalInlineImage = ( + link: HTMLAnchorElement, + entry: Extract +): void => { + const image = document.createElement("img") + image.alt = entry.path + image.src = entry.displayUrl + image.style.borderRadius = "4px" + image.style.display = "block" + image.style.height = "100%" + image.style.objectFit = "contain" + image.style.width = "100%" + link.append(image) +} + +const appendUnavailableTerminalInlineImage = (link: HTMLAnchorElement): void => { + const label = document.createElement("span") + label.textContent = "unavailable" + label.style.color = "#9aa8b6" + label.style.fontFamily = "'IBM Plex Mono', ui-monospace, monospace" + label.style.fontSize = "11px" + label.style.lineHeight = "1" + label.style.overflow = "hidden" + label.style.textOverflow = "ellipsis" + label.style.whiteSpace = "nowrap" + link.append(label) +} + +const appendTerminalInlineImageContent = ( + link: HTMLAnchorElement, + entry: TerminalInlineImageEntry +): void => { + if (entry._tag === "AvailableTerminalInlineImage") { + appendAvailableTerminalInlineImage(link, entry) + return + } + appendUnavailableTerminalInlineImage(link) +} + +const openImage = (fetchUrl: string): void => { + const imageWindow = window.open(fetchUrl, "_blank", "noopener,noreferrer") + if (imageWindow === null) { + return + } + imageWindow.opener = null +} + +const appendDecorationDisposable = ( + lifecycle: TerminalLifecycleState, + disposable: IDisposable +): void => { + lifecycle.inlineImageDisposables.push(disposable) + if (lifecycle.inlineImageDisposables.length <= terminalInlineImagePreviewLimit) { + return + } + lifecycle.inlineImageDisposables.shift()?.dispose() +} + +const renderInlineImageElement = ( + element: HTMLElement, + entry: TerminalInlineImageEntry +): void => { + if (element.dataset["path"] === entry.path && element.dataset["tag"] === entry._tag) { + return + } + + const link = createTerminalInlineImageLink(entry) + appendTerminalInlineImageContent(link, entry) + + element.dataset["path"] = entry.path + element.dataset["tag"] = entry._tag + element.style.pointerEvents = "none" + element.replaceChildren(link) +} + +export const appendTerminalInlineImagePreview = ( + terminal: Terminal, + lifecycle: TerminalLifecycleState, + entry: TerminalInlineImageEntry +): boolean => { + const marker = terminal.registerMarker(0) + const decoration = terminal.registerDecoration({ + height: terminalInlineImagePreviewRows, + layer: "top", + marker, + width: Math.min(terminalInlineImagePreviewColumns, Math.max(1, terminal.cols)) + }) + if (decoration === undefined) { + marker.dispose() + return false + } + + decoration.onRender((element) => { + renderInlineImageElement(element, entry) + }) + appendDecorationDisposable(lifecycle, decoration) + return true +} + +const imageLink = ( + session: ActiveTerminalSession, + bufferLineNumber: number, + match: ReturnType[number] +): ILink => { + const fetchUrl = resolveTerminalImageFetchUrl(session.websocketPath, match.path) + return { + activate: () => { + openImage(fetchUrl) + }, + decorations: { + pointerCursor: true, + underline: true + }, + range: { + end: { + x: match.endIndex, + y: bufferLineNumber + }, + start: { + x: match.startIndex + 1, + y: bufferLineNumber + } + }, + text: match.path + } +} + +export const attachTerminalImageLinks = ( + terminal: Terminal, + session: ActiveTerminalSession +): IDisposable => + terminal.registerLinkProvider({ + provideLinks: (bufferLineNumber, callback) => { + const line = terminal.buffer.active.getLine(bufferLineNumber - 1) + if (line === undefined) { + callback([]) + return + } + const text = line.translateToString(true) + const matches = detectTerminalImagePathMatches(text) + callback(matches.map((match) => imageLink(session, bufferLineNumber, match))) + } + }) diff --git a/packages/terminal/src/web/terminal-mobile-controls.ts b/packages/terminal/src/web/terminal-mobile-controls.ts new file mode 100644 index 00000000..b713e417 --- /dev/null +++ b/packages/terminal/src/web/terminal-mobile-controls.ts @@ -0,0 +1,55 @@ +export type MobileTerminalKey = "escape" | "left" | "right" | "tab" | "up" | "down" | "ctrl-c" + +const mobileTerminalKeyInputs: Record = { + escape: "\u001B", + left: "\u001B[D", + right: "\u001B[C", + tab: "\t", + up: "\u001B[A", + down: "\u001B[B", + "ctrl-c": "\u0003" +} + +const modifierOnlyKeys = new Set([ + "Alt", + "CapsLock", + "Control", + "Fn", + "Meta", + "NumLock", + "ScrollLock", + "Shift" +]) + +const terminalControlSymbolInputs: Readonly> = { + "@": "\u0000", + "[": "\u001B", + "\\": "\u001C", + "]": "\u001D", + "^": "\u001E", + _: "\u001F" +} + +export const mobileTerminalKeyInput = (key: MobileTerminalKey): string => mobileTerminalKeyInputs[key] + +export const isModifierOnlyTerminalKey = (key: string): boolean => modifierOnlyKeys.has(key) + +const controlCharacterFromRange = ( + key: string, + first: string, + last: string, + offset: number +): string | null => { + if (key.length !== 1 || key < first || key > last) { + return null + } + return String.fromCodePoint((key.codePointAt(0) ?? 0) - offset) +} + +export const terminalControlCharacterForKey = (key: string): string | null => { + const lower = controlCharacterFromRange(key, "a", "z", 96) + if (lower !== null) { + return lower + } + return controlCharacterFromRange(key, "A", "Z", 64) ?? terminalControlSymbolInputs[key] ?? null +} diff --git a/packages/terminal/src/web/terminal-mobile-layout.ts b/packages/terminal/src/web/terminal-mobile-layout.ts new file mode 100644 index 00000000..9c75da8c --- /dev/null +++ b/packages/terminal/src/web/terminal-mobile-layout.ts @@ -0,0 +1,7 @@ +export const shouldShowTerminalTabs = (mobileMode: boolean, sessionCount: number): boolean => + !mobileMode || sessionCount > 1 + +export const resolveTerminalCompactHeaderMode = (mobileMode: boolean): boolean => mobileMode + +export const resolveTerminalTypingMode = (mobileMode: boolean, keyboardOpen: boolean): boolean => + mobileMode && keyboardOpen diff --git a/packages/terminal/src/web/terminal-panel-cleanup-runtime.ts b/packages/terminal/src/web/terminal-panel-cleanup-runtime.ts new file mode 100644 index 00000000..345c6ebc --- /dev/null +++ b/packages/terminal/src/web/terminal-panel-cleanup-runtime.ts @@ -0,0 +1,44 @@ +import { revokeTerminalInlineImageObjectUrlCache } from "./terminal-inline-images.js" +import { runOptionalTerminalOperation } from "./terminal-panel-optional-operation.js" +import type { TerminalCleanupArgs } from "./terminal-panel-runtime-types.js" + +const closeSocket = (socket: WebSocket | null): void => { + if (socket === null || socket.readyState === WebSocket.CLOSED) { + return + } + runOptionalTerminalOperation(() => { + socket.close() + }) +} + +const clearReconnectTimer = (args: TerminalCleanupArgs): void => { + if (args.lifecycle.reconnectTimer !== null) { + clearTimeout(args.lifecycle.reconnectTimer) + args.lifecycle.reconnectTimer = null + } +} + +export const cleanupTerminalResources = ( + args: TerminalCleanupArgs +): void => { + args.lifecycle.disposed = true + clearReconnectTimer(args) + for (const disposable of args.lifecycle.inlineImageDisposables) { + runOptionalTerminalOperation(() => { + disposable.dispose() + }) + } + args.lifecycle.inlineImageDisposables = [] + revokeTerminalInlineImageObjectUrlCache(args.lifecycle.inlineImageObjectUrls) + args.lifecycle.outputQueue = [] + args.lifecycle.outputWriting = false + args.removeImageLinks() + args.removeImagePaste() + args.removeInput() + args.resizeObserver?.disconnect() + args.removeResize() + closeSocket(args.socketRef.current) + args.socketRef.current = null + args.runtimeRef.current = null + args.terminal.dispose() +} diff --git a/packages/terminal/src/web/terminal-panel-inline-images-runtime.ts b/packages/terminal/src/web/terminal-panel-inline-images-runtime.ts new file mode 100644 index 00000000..d0deb6c2 --- /dev/null +++ b/packages/terminal/src/web/terminal-panel-inline-images-runtime.ts @@ -0,0 +1,299 @@ +import { FetchHttpClient, HttpClient, HttpClientResponse } from "@effect/platform" +import { Duration, Effect } from "effect" +import * as Stream from "effect/Stream" + +import { resolveTerminalImageFetchUrl } from "./terminal-image-url.js" +import { + splitTerminalInlineImageOutput, + type TerminalInlineImageOutputSegment, + writeTerminalOutputSegment +} from "./terminal-inline-images-core.js" +import { + appendTerminalInlineImagePreview, + cachedTerminalInlineImageEntry, + cacheTerminalInlineImageBlob, + terminalInlineImageSpacer, + unavailableTerminalInlineImageEntry +} from "./terminal-inline-images.js" +import type { TerminalInlineImageEntry } from "./terminal-inline-images.js" +import type { TerminalMessageHandlers } from "./terminal-panel-runtime-types.js" + +type TerminalInlineImageFetchError = { + readonly _tag: "TerminalInlineImageFetchError" + readonly message: string +} + +type TerminalInlineImageBufferState = { + readonly chunks: ReadonlyArray + readonly size: number +} + +const terminalInlineImageFetchTimeout = Duration.seconds(10) +const terminalInlineImageMaxBytes = 10 * 1024 * 1024 + +const emptyTerminalInlineImageBufferState: TerminalInlineImageBufferState = { + chunks: [], + size: 0 +} + +const terminalImageEntry = ( + handlers: TerminalMessageHandlers, + path: string +): TerminalInlineImageEntry | null => { + const fetchUrl = resolveTerminalImageFetchUrl(handlers.session.websocketPath, path) + return cachedTerminalInlineImageEntry(handlers.lifecycle.inlineImageObjectUrls, path, fetchUrl) +} + +const terminalInlineImageFetchError = (message: string): TerminalInlineImageFetchError => ({ + _tag: "TerminalInlineImageFetchError", + message +}) + +const terminalInlineImageFetchHeaders: Readonly> = { + accept: "image/*", + "cache-control": "no-cache, no-store, max-age=0", + pragma: "no-cache" +} + +const readContentLength = (headers: Readonly>): number | null => { + const value = headers["content-length"] + if (value === undefined) { + return null + } + const parsed = Number.parseInt(value, 10) + return Number.isFinite(parsed) && parsed >= 0 ? parsed : null +} + +const validateTerminalInlineImageSize = (size: number): Effect.Effect => + size > terminalInlineImageMaxBytes + ? Effect.fail(terminalInlineImageFetchError("Terminal image is too large.")) + : Effect.void + +const appendTerminalInlineImageChunk = ( + state: TerminalInlineImageBufferState, + chunk: Uint8Array +): Effect.Effect => { + const nextSize = state.size + chunk.byteLength + return validateTerminalInlineImageSize(nextSize).pipe( + Effect.as({ + chunks: [...state.chunks, chunk], + size: nextSize + }) + ) +} + +const copyChunkToArrayBuffer = (chunk: Uint8Array): ArrayBuffer => { + const copy = new Uint8Array(chunk.byteLength) + copy.set(chunk) + return copy.buffer +} + +const imageBlobFromChunks = ( + chunks: ReadonlyArray, + mediaType: string | undefined +): Blob => + new Blob( + chunks.map((chunk) => copyChunkToArrayBuffer(chunk)), + mediaType === undefined ? {} : { type: mediaType } + ) + +const readTerminalInlineImageBlob = ( + response: HttpClientResponse.HttpClientResponse +): Effect.Effect => { + const contentLength = readContentLength(response.headers) + if (contentLength !== null && contentLength > terminalInlineImageMaxBytes) { + return Effect.fail(terminalInlineImageFetchError("Terminal image is too large.")) + } + return HttpClientResponse.stream(Effect.succeed(response)).pipe( + Stream.runFoldEffect(emptyTerminalInlineImageBufferState, appendTerminalInlineImageChunk), + Effect.map((state) => imageBlobFromChunks(state.chunks, response.headers["content-type"])), + Effect.mapError(() => terminalInlineImageFetchError("Could not read terminal image response.")) + ) +} + +const fetchTerminalInlineImageBlob = ( + fetchUrl: string +): Effect.Effect => + Effect.gen(function*(_) { + const client = yield* _(HttpClient.HttpClient) + const response = yield* _( + client.get(fetchUrl, { headers: terminalInlineImageFetchHeaders }).pipe( + Effect.mapError(() => terminalInlineImageFetchError("Could not fetch terminal image.")) + ) + ) + if (response.status >= 400) { + return yield* _(Effect.fail(terminalInlineImageFetchError(`Terminal image returned HTTP ${response.status}.`))) + } + return yield* _(readTerminalInlineImageBlob(response)) + }).pipe( + Effect.timeoutFail({ + duration: terminalInlineImageFetchTimeout, + onTimeout: () => terminalInlineImageFetchError("Terminal image fetch timed out.") + }), + Effect.provide(FetchHttpClient.layer) + ) + +const loadTerminalImageEntry = ( + handlers: TerminalMessageHandlers, + path: string, + onComplete: (entry: TerminalInlineImageEntry) => void +): void => { + const fetchUrl = resolveTerminalImageFetchUrl(handlers.session.websocketPath, path) + const cached = cachedTerminalInlineImageEntry(handlers.lifecycle.inlineImageObjectUrls, path, fetchUrl) + if (cached !== null) { + onComplete(cached) + return + } + Effect.runFork( + fetchTerminalInlineImageBlob(fetchUrl).pipe( + Effect.match({ + onFailure: () => unavailableTerminalInlineImageEntry(path, fetchUrl), + onSuccess: (blob) => + handlers.lifecycle.disposed + ? null + : cacheTerminalInlineImageBlob(handlers.lifecycle.inlineImageObjectUrls, path, fetchUrl, blob) + }), + Effect.flatMap((entry) => + Effect.sync(() => { + if (entry === null || handlers.lifecycle.disposed) { + return + } + onComplete(entry) + }) + ) + ) + ) +} + +const writePreviewSpacer = ( + handlers: TerminalMessageHandlers, + onComplete: () => void +): void => { + handlers.terminal.write(terminalInlineImageSpacer, onComplete) +} + +const writeInlineImagePreview = ( + handlers: TerminalMessageHandlers, + path: string, + onComplete: () => void +): void => { + const cached = terminalImageEntry(handlers, path) + if (cached !== null) { + writeInlineImagePreviewEntry(handlers, cached, onComplete) + return + } + loadTerminalImageEntry(handlers, path, (entry) => { + writeInlineImagePreviewEntry(handlers, entry, onComplete) + }) +} + +const writeInlineImagePreviewEntry = ( + handlers: TerminalMessageHandlers, + entry: TerminalInlineImageEntry, + onComplete: () => void +): void => { + const appended = appendTerminalInlineImagePreview( + handlers.terminal, + handlers.lifecycle, + entry + ) + if (!appended) { + onComplete() + return + } + writePreviewSpacer(handlers, onComplete) +} + +const writeInlineImagePreviews = ( + handlers: TerminalMessageHandlers, + paths: ReadonlyArray, + onComplete: () => void +): void => { + let index = 0 + const writeNext = (): void => { + const path = paths[index] + if (path === undefined) { + onComplete() + return + } + index += 1 + writeInlineImagePreview(handlers, path, writeNext) + } + writeNext() +} + +const writeLineBreakBeforePreview = ( + handlers: TerminalMessageHandlers, + segment: TerminalInlineImageOutputSegment, + onComplete: () => void +): void => { + if (segment.endedWithLineBreak) { + onComplete() + return + } + handlers.terminal.write("\r\n", onComplete) +} + +const flushTerminalOutputQueue = (handlers: TerminalMessageHandlers): void => { + if (handlers.lifecycle.outputWriting || handlers.lifecycle.disposed) { + return + } + const segment = handlers.lifecycle.outputQueue.shift() + if (segment === undefined) { + return + } + + handlers.lifecycle.outputWriting = true + writeTerminalOutputSegment({ + inlineImagePreviewsEnabledRef: handlers.inlineImagePreviewsEnabledRef, + segment, + writer: { + writePreviewLineBreak: (outputSegment, onComplete) => { + writeLineBreakBeforePreview(handlers, outputSegment, onComplete) + }, + writePreviews: (paths, onComplete) => { + writeInlineImagePreviews(handlers, paths, onComplete) + }, + writeText: (text, onComplete) => { + handlers.terminal.write(text, onComplete) + } + } + }, () => { + handlers.lifecycle.outputWriting = false + flushTerminalOutputQueue(handlers) + }) +} + +/** + * Enqueues terminal output segments and starts the sequential terminal flush loop. + * + * @param handlers - Runtime terminal handlers with mutable lifecycle queues and flags. + * @param data - Raw terminal output chunk to split into text and inline-image preview segments. + * @returns Nothing; lifecycle state is updated through `handlers`. + * @pure false + * @effect TerminalMessageHandlers.lifecycle outputQueue/outputWriting/disposed and terminal writes. + * @invariant `outputWriting` acts as a semaphore: at most one flush writes to the terminal at a time. + * @precondition `handlers.lifecycle.outputQueue` is an array, `outputWriting` is boolean, and handlers are live. + * @postcondition All split segments are appended before `flushTerminalOutputQueue(handlers)` is invoked. + * @complexity O(n) where n is the number of output segments parsed from `data`. + * @throws Never + */ +// CHANGE: document terminal output queueing as the shell boundary for inline image writes +// WHY: queue order and the outputWriting semaphore protect terminal write ordering across async previews +// QUOTE(ТЗ): "Limit inline-preview loading by timeout and size without freezing terminal output" +// REF: issue-339 +// SOURCE: n/a +// FORMAT THEOREM: enqueue(q, segments) -> flush observes q followed by segments in input order +// PURITY: SHELL +// EFFECT: TerminalMessageHandlers -> mutates lifecycle.outputQueue/outputWriting and writes to terminal +// INVARIANT: disposed handlers never start a new flush, and flush is called only after queue append +// COMPLEXITY: O(n) where n is the number of output segments parsed from `data` +export const enqueueTerminalOutput = ( + handlers: TerminalMessageHandlers, + data: string +): void => { + for (const segment of splitTerminalInlineImageOutput(data)) { + handlers.lifecycle.outputQueue.push(segment) + } + flushTerminalOutputQueue(handlers) +} diff --git a/packages/terminal/src/web/terminal-panel-input.ts b/packages/terminal/src/web/terminal-panel-input.ts new file mode 100644 index 00000000..4fade175 --- /dev/null +++ b/packages/terminal/src/web/terminal-panel-input.ts @@ -0,0 +1,60 @@ +import type { TerminalPasteGuard } from "./terminal-panel-runtime-types.js" + +export type TerminalClientMessage = + | { readonly data: string; readonly type: "input" } + | { readonly cols: number; readonly rows: number; readonly type: "resize" } + +type TerminalClientSocket = { + readonly readyState: number + readonly send: (data: string) => void +} + +export type TerminalClientSocketRef = { readonly current: TerminalClientSocket | null } + +type TerminalInputTarget = { + readonly onData: (handler: (data: string) => void) => { readonly dispose: () => void } + readonly scrollToBottom: () => void +} + +const csiPrefix = "\u001B[" +const x10MouseReportPrefix = `${csiPrefix}M` +const x10MouseReportLength = 6 +const sgrMouseReportBodyPattern = /^<\d+;\d+;\d+[Mm]$/u +const urxvtMouseReportBodyPattern = /^\d+;\d+;\d+M$/u + +export const isTerminalMouseReportInput = (data: string): boolean => { + if (data.startsWith(x10MouseReportPrefix)) { + return data.length === x10MouseReportLength + } + if (!data.startsWith(csiPrefix)) { + return false + } + const body = data.slice(csiPrefix.length) + return sgrMouseReportBodyPattern.test(body) || urxvtMouseReportBodyPattern.test(body) +} + +export const sendTerminalClientMessage = ( + socketRef: TerminalClientSocketRef, + message: TerminalClientMessage +): void => { + const socket = socketRef.current + if (socket === null || socket.readyState !== WebSocket.OPEN) { + return + } + socket.send(JSON.stringify(message)) +} + +export const attachTerminalInput = ( + terminal: TerminalInputTarget, + socketRef: TerminalClientSocketRef, + pasteGuard: TerminalPasteGuard +) => + terminal.onData((data) => { + if (pasteGuard.shouldSuppressTerminalInput(data)) { + return + } + if (!isTerminalMouseReportInput(data)) { + terminal.scrollToBottom() + } + sendTerminalClientMessage(socketRef, { data, type: "input" }) + }) diff --git a/packages/terminal/src/web/terminal-panel-optional-operation.ts b/packages/terminal/src/web/terminal-panel-optional-operation.ts new file mode 100644 index 00000000..9cd77b86 --- /dev/null +++ b/packages/terminal/src/web/terminal-panel-optional-operation.ts @@ -0,0 +1,21 @@ +import { Effect, type Either } from "effect" + +type OptionalTerminalOperationError = { + readonly _tag: "OptionalTerminalOperationError" + readonly message: string +} + +export type OptionalTerminalOperationResult = Either.Either + +export const runOptionalTerminalOperation = (operation: () => void): OptionalTerminalOperationResult => + Effect.runSync( + Effect.either( + Effect.try({ + try: operation, + catch: (error) => ({ + _tag: "OptionalTerminalOperationError", + message: String(error) + }) + }) + ) + ) diff --git a/packages/terminal/src/web/terminal-panel-runtime-core.ts b/packages/terminal/src/web/terminal-panel-runtime-core.ts new file mode 100644 index 00000000..f82267b9 --- /dev/null +++ b/packages/terminal/src/web/terminal-panel-runtime-core.ts @@ -0,0 +1,289 @@ +import { Either } from "effect" +import { Terminal } from "xterm" +import { FitAddon } from "xterm-addon-fit" + +import { enqueueTerminalOutput } from "./terminal-panel-inline-images-runtime.js" +import { sendTerminalClientMessage } from "./terminal-panel-input.js" +import { runOptionalTerminalOperation } from "./terminal-panel-optional-operation.js" +import type { + TerminalExitInfo, + TerminalInputController, + TerminalLifecycleState, + TerminalMessageHandlers, + TerminalRuntime, + TerminalSocketConnectArgs, + TerminalSocketListenerArgs, + TerminalSocketRef +} from "./terminal-panel-runtime-types.js" +import { installTerminalQuerySuppression, type TerminalQuerySuppressionOptions } from "./terminal-query-suppression.js" +import { resolveTerminalReconnectDelay, terminalReconnectGraceMs } from "./terminal-reconnect.js" +import { parseTerminalServerMessage, resolveTerminalWebSocketUrl } from "./terminal.js" + +export { cleanupTerminalResources } from "./terminal-panel-cleanup-runtime.js" +export { attachTerminalInput, isTerminalMouseReportInput } from "./terminal-panel-input.js" + +type TerminalRuntimeOptions = { + readonly querySuppression?: TerminalQuerySuppressionOptions +} + +export const createLifecycleState = (): TerminalLifecycleState => ({ + attachedOnce: false, + disposed: false, + inlineImageDisposables: [], + inlineImageObjectUrls: new Map(), + outputQueue: [], + outputWriting: false, + readyNotified: false, + reconnectAttempt: 0, + reconnectStartedAtMs: null, + reconnectTimer: null, + terminalEnded: false +}) + +const clearReconnectTimer = (lifecycle: TerminalLifecycleState): void => { + if (lifecycle.reconnectTimer !== null) { + clearTimeout(lifecycle.reconnectTimer) + lifecycle.reconnectTimer = null + } +} + +export const createTerminalRuntime = ( + host: HTMLDivElement, + options: TerminalRuntimeOptions = {} +): TerminalRuntime => { + const terminal = new Terminal({ + allowProposedApi: true, + convertEol: false, + cursorBlink: true, + fontFamily: "'IBM Plex Mono', 'SFMono-Regular', monospace", + fontSize: 14, + macOptionClickForcesSelection: true, + scrollback: 50_000, + scrollOnUserInput: false, + theme: { background: "#080a0d", foreground: "#f4f7fb" } + }) + installTerminalQuerySuppression(terminal, options.querySuppression) + const fitAddon = new FitAddon() + terminal.loadAddon(fitAddon) + terminal.open(host) + fitAddon.fit() + terminal.focus() + return { fitAddon, terminal } +} + +export const createTerminalInputController = ( + terminal: Terminal, + socketRef: TerminalSocketRef +): TerminalInputController => ({ + focus: () => { + terminal.focus() + }, + sendInput: (data: string) => { + if (data.length === 0) { + return + } + sendTerminalClientMessage(socketRef, { data, type: "input" }) + } +}) + +const createTerminalSocket = ( + session: TerminalSocketConnectArgs["session"], + terminal: Terminal +): WebSocket => new WebSocket(resolveTerminalWebSocketUrl(session.websocketPath, terminal.cols, terminal.rows)) + +export const sendTerminalResize = ( + fitAddon: FitAddon, + socketRef: TerminalSocketRef, + terminal: Terminal +): void => { + const fitResult = runOptionalTerminalOperation(() => { + fitAddon.fit() + }) + if (Either.isLeft(fitResult)) { + return + } + sendTerminalClientMessage(socketRef, { + cols: terminal.cols, + rows: terminal.rows, + type: "resize" + }) +} + +export const observeTerminalResize = ( + host: HTMLDivElement, + onResize: () => void +): ResizeObserver | null => { + if (typeof ResizeObserver !== "function") { + return null + } + const resizeObserver = new ResizeObserver(onResize) + resizeObserver.observe(host) + return resizeObserver +} + +const notifyTerminalReady = ( + handlers: TerminalMessageHandlers +): void => { + handlers.lifecycle.attachedOnce = true + handlers.connectionRef.current.opened = true + handlers.lifecycle.reconnectAttempt = 0 + handlers.lifecycle.reconnectStartedAtMs = null + clearReconnectTimer(handlers.lifecycle) + handlers.setStatus("attached") + if (handlers.lifecycle.readyNotified) { + handlers.notifyMessage("Terminal reconnected.") + return + } + handlers.lifecycle.readyNotified = true + handlers.notifyMessage(handlers.session.readyMessage) + handlers.session.onReady?.() +} + +const endTerminalSession = ( + handlers: TerminalMessageHandlers, + status: "error" | "exited", + line: string, + message: string, + exitInfo?: TerminalExitInfo +): void => { + handlers.lifecycle.terminalEnded = true + clearReconnectTimer(handlers.lifecycle) + handlers.terminal.writeln(line) + handlers.setStatus(status) + handlers.notifyMessage(message) + if (status === "exited") { + if (exitInfo !== undefined) { + handlers.notifyExit(exitInfo) + } + handlers.session.onExit?.() + } +} + +const handleTerminalServerMessage = ( + handlers: TerminalMessageHandlers, + payload: string +): void => { + const message = parseTerminalServerMessage(payload) + if (message === null) { + endTerminalSession(handlers, "error", "\r\n[terminal protocol error]", "Terminal protocol error.") + return + } + if (message.type === "ready") { + notifyTerminalReady(handlers) + return + } + if (message.type === "output") { + enqueueTerminalOutput(handlers, message.data) + return + } + if (message.type === "error") { + endTerminalSession(handlers, "error", `\r\n[error] ${message.message}`, message.message) + return + } + endTerminalSession(handlers, "exited", "\r\n[session ended]", handlers.session.exitMessage, { + exitCode: message.exitCode, + signal: message.signal + }) +} + +const attachTerminalSocketListeners = ( + { lifecycle, onClose, onError, onMessage, onOpen, socket }: TerminalSocketListenerArgs +): void => { + socket.addEventListener("open", onOpen) + socket.addEventListener("message", (event) => { + onMessage(typeof event.data === "string" ? event.data : "") + }) + socket.addEventListener("close", () => { + onClose(socket) + }) + socket.addEventListener("error", () => { + if (!lifecycle.disposed) { + onError(socket) + } + }) +} + +const failBeforeAttach = ( + args: TerminalSocketConnectArgs, + terminalLine: string, + uiMessage: string +): void => { + args.lifecycle.terminalEnded = true + clearReconnectTimer(args.lifecycle) + args.terminal.writeln(`\r\n${terminalLine}`) + args.setStatus("error") + args.notifyMessage(uiMessage) + args.handlers.connectionRef.current.closing = true + if (!args.lifecycle.attachedOnce) { + args.onAttachFailure() + } +} + +const scheduleReconnect = (args: TerminalSocketConnectArgs): void => { + if (args.lifecycle.disposed || args.lifecycle.terminalEnded) { + return + } + const startedAt = args.lifecycle.reconnectStartedAtMs ?? Date.now() + args.lifecycle.reconnectStartedAtMs = startedAt + if (Date.now() - startedAt >= terminalReconnectGraceMs) { + failBeforeAttach(args, "[terminal reconnect failed]", "Terminal reconnect failed.") + return + } + if (args.lifecycle.reconnectAttempt === 0) { + args.terminal.writeln("\r\n[terminal connection lost; reconnecting]") + args.notifyMessage("Terminal connection lost. Reconnecting...") + } + args.setStatus("reconnecting") + const delayMs = resolveTerminalReconnectDelay(args.lifecycle.reconnectAttempt) + args.lifecycle.reconnectAttempt += 1 + clearReconnectTimer(args.lifecycle) + args.lifecycle.reconnectTimer = setTimeout(args.reconnect, delayMs) +} + +const handleSocketClose = ( + args: TerminalSocketConnectArgs, + closedSocket: WebSocket +): void => { + if (args.socketRef.current !== closedSocket) { + return + } + args.socketRef.current = null + if (args.lifecycle.disposed || args.lifecycle.terminalEnded) { + return + } + if (!args.lifecycle.attachedOnce) { + failBeforeAttach(args, "[websocket closed before attach]", "Terminal websocket closed before attach.") + return + } + scheduleReconnect(args) +} +const handleSocketError = ( + args: TerminalSocketConnectArgs, + failedSocket: WebSocket +): void => { + if (args.socketRef.current !== failedSocket || args.lifecycle.attachedOnce) { + return + } + failBeforeAttach(args, "[websocket error]", "Terminal websocket error.") +} +export const connectTerminalSocket = (args: TerminalSocketConnectArgs): void => { + if (args.lifecycle.disposed || args.lifecycle.terminalEnded) { + return + } + const socket = createTerminalSocket(args.session, args.terminal) + args.socketRef.current = socket + attachTerminalSocketListeners({ + lifecycle: args.lifecycle, + onClose: (closedSocket) => { + handleSocketClose(args, closedSocket) + }, + onError: (failedSocket) => { + handleSocketError(args, failedSocket) + }, + onMessage: (payload) => { + handleTerminalServerMessage(args.handlers, payload) + }, + onOpen: args.sendResize, + socket + }) +} diff --git a/packages/terminal/src/web/terminal-panel-runtime-types.ts b/packages/terminal/src/web/terminal-panel-runtime-types.ts new file mode 100644 index 00000000..b36ffd58 --- /dev/null +++ b/packages/terminal/src/web/terminal-panel-runtime-types.ts @@ -0,0 +1,105 @@ +import type { IDisposable, Terminal } from "xterm" +import type { FitAddon } from "xterm-addon-fit" + +import type { + TerminalInlineImageOutputSegment, + TerminalInlineImagePreviewsEnabledRef +} from "./terminal-inline-images-core.js" +import type { ActiveTerminalSession } from "./terminal.js" + +export type TerminalStatus = "attached" | "connecting" | "error" | "exited" | "reconnecting" + +export type TerminalExitInfo = { + readonly exitCode: number | null + readonly signal: number | null +} + +export type TerminalConnectionState = { closing: boolean; opened: boolean } + +export type TerminalRuntime = { readonly fitAddon: FitAddon; readonly terminal: Terminal } + +export type TerminalInputController = { + readonly focus: () => void + readonly sendInput: (data: string) => void +} + +export type TerminalLifecycleState = { + attachedOnce: boolean + disposed: boolean + inlineImageDisposables: Array + inlineImageObjectUrls: Map + outputQueue: Array + outputWriting: boolean + readyNotified: boolean + reconnectAttempt: number + reconnectStartedAtMs: number | null + reconnectTimer: ReturnType | null + terminalEnded: boolean +} + +export type TerminalSocketRef = { current: WebSocket | null } + +export type TerminalPasteGuard = { + readonly shouldSuppressTerminalInput: (data: string) => boolean + readonly suppressNextNativeImagePaste: () => void +} + +export type TerminalMessageHandlers = { + readonly connectionRef: { current: TerminalConnectionState } + readonly inlineImagePreviewsEnabledRef: TerminalInlineImagePreviewsEnabledRef + readonly lifecycle: TerminalLifecycleState + readonly notifyExit: (info: TerminalExitInfo) => void + readonly notifyMessage: (message: string) => void + readonly session: ActiveTerminalSession + readonly setStatus: (status: TerminalStatus) => void + readonly terminal: Terminal +} + +export type TerminalCleanupArgs = { + readonly connectionRef: { current: TerminalConnectionState } + readonly lifecycle: TerminalLifecycleState + readonly notifyMessage: (message: string) => void + readonly removeImageLinks: () => void + readonly removeImagePaste: () => void + readonly removeInput: () => void + readonly removeResize: () => void + readonly resizeObserver: ResizeObserver | null + readonly runtimeRef: { current: TerminalInputController | null } + readonly session: ActiveTerminalSession + readonly socketRef: TerminalSocketRef + readonly terminal: Terminal +} + +export type TerminalLifecycleArgs = { + readonly connectionRef: { current: TerminalConnectionState } + readonly hostRef: { readonly current: HTMLDivElement | null } + readonly inlineImagePreviewsEnabledRef: TerminalInlineImagePreviewsEnabledRef + readonly notifyExit: (info: TerminalExitInfo) => void + readonly notifyMessage: (message: string) => void + readonly onAttachFailure: () => void + readonly runtimeRef: { current: TerminalInputController | null } + readonly session: ActiveTerminalSession + readonly setStatus: (status: TerminalStatus) => void +} + +export type TerminalSocketListenerArgs = { + readonly lifecycle: TerminalLifecycleState + readonly onClose: (socket: WebSocket) => void + readonly onError: (socket: WebSocket) => void + readonly onMessage: (payload: string) => void + readonly onOpen: () => void + readonly socket: WebSocket +} + +export type TerminalSocketConnectArgs = { + readonly handlers: TerminalMessageHandlers + readonly lifecycle: TerminalLifecycleState + readonly notifyMessage: (message: string) => void + readonly onAttachFailure: () => void + readonly reconnect: () => void + readonly sendResize: () => void + readonly session: ActiveTerminalSession + readonly setStatus: (status: TerminalStatus) => void + readonly socketRef: TerminalSocketRef + readonly terminal: Terminal +} diff --git a/packages/terminal/src/web/terminal-panel-runtime.ts b/packages/terminal/src/web/terminal-panel-runtime.ts new file mode 100644 index 00000000..ac0bc0d9 --- /dev/null +++ b/packages/terminal/src/web/terminal-panel-runtime.ts @@ -0,0 +1,276 @@ +import { useEffect } from "react" + +import { attachTerminalCopyInteraction } from "./terminal-copy-interaction.js" +import { attachTerminalImagePaste, createTerminalPasteGuard } from "./terminal-image-paste.js" +import { attachTerminalImageLinks } from "./terminal-inline-images.js" +import { + attachTerminalInput, + cleanupTerminalResources, + connectTerminalSocket, + createLifecycleState, + createTerminalInputController, + createTerminalRuntime, + observeTerminalResize, + sendTerminalResize +} from "./terminal-panel-runtime-core.js" +import type { + TerminalLifecycleArgs, + TerminalLifecycleState, + TerminalMessageHandlers, + TerminalPasteGuard, + TerminalSocketConnectArgs, + TerminalSocketRef +} from "./terminal-panel-runtime-types.js" +import { attachTerminalWheelScroll } from "./terminal-wheel-scroll.js" +import { isPendingActiveTerminalSession } from "./terminal.js" + +type TerminalDisposable = { readonly dispose: () => void } + +type TerminalCleanupFactoryArgs = { + readonly cleanupArgs: Omit< + Parameters[0], + "removeImageLinks" | "removeImagePaste" | "removeInput" | "removeResize" + > + readonly copyInteractionDisposable: TerminalDisposable + readonly imageLinkDisposable: TerminalDisposable + readonly imagePasteDisposable: TerminalDisposable + readonly inputDisposable: TerminalDisposable + readonly wheelScrollDisposable: TerminalDisposable + readonly sendResize: () => void +} + +const createTerminalCleanup = ( + { + cleanupArgs, + copyInteractionDisposable, + imageLinkDisposable, + imagePasteDisposable, + inputDisposable, + sendResize, + wheelScrollDisposable + }: TerminalCleanupFactoryArgs +): () => void => +(): void => { + cleanupTerminalResources({ + ...cleanupArgs, + removeImageLinks: () => { + imageLinkDisposable.dispose() + }, + removeImagePaste: () => { + imagePasteDisposable.dispose() + }, + removeInput: () => { + copyInteractionDisposable.dispose() + inputDisposable.dispose() + wheelScrollDisposable.dispose() + }, + removeResize: () => { + globalThis.removeEventListener("resize", sendResize) + globalThis.visualViewport?.removeEventListener("resize", sendResize) + globalThis.visualViewport?.removeEventListener("scroll", sendResize) + } + }) +} + +const createConnectSocket = ( + args: Omit +): () => void => { + const connectSocket = () => { + connectTerminalSocket({ ...args, reconnect: connectSocket }) + } + return connectSocket +} + +const attachGlobalResizeListeners = (sendResize: () => void): void => { + globalThis.addEventListener("resize", sendResize) + globalThis.visualViewport?.addEventListener("resize", sendResize) + globalThis.visualViewport?.addEventListener("scroll", sendResize) +} + +const createTerminalMessageHandlers = ( + args: TerminalLifecycleArgs, + lifecycle: TerminalLifecycleState, + terminal: TerminalMessageHandlers["terminal"] +): TerminalMessageHandlers => ({ + connectionRef: args.connectionRef, + inlineImagePreviewsEnabledRef: args.inlineImagePreviewsEnabledRef, + lifecycle, + notifyExit: args.notifyExit, + notifyMessage: args.notifyMessage, + session: args.session, + setStatus: args.setStatus, + terminal +}) + +type MountedTerminalDisposables = { + readonly copyInteractionDisposable: TerminalDisposable + readonly imageLinkDisposable: TerminalDisposable + readonly imagePasteDisposable: TerminalDisposable + readonly inputDisposable: TerminalDisposable + readonly wheelScrollDisposable: TerminalDisposable +} + +type MountedTerminalCleanupArgs = { + readonly args: TerminalLifecycleArgs + readonly disposables: MountedTerminalDisposables + readonly lifecycle: TerminalLifecycleState + readonly resizeObserver: ResizeObserver | null + readonly sendResize: () => void + readonly socketRef: TerminalSocketRef + readonly terminal: TerminalMessageHandlers["terminal"] +} + +const createMountedTerminalDisposables = ( + args: TerminalLifecycleArgs, + host: HTMLDivElement, + pasteGuard: TerminalPasteGuard, + socketRef: TerminalSocketRef, + terminal: TerminalMessageHandlers["terminal"] +): MountedTerminalDisposables => ({ + copyInteractionDisposable: attachTerminalCopyInteraction({ host, terminal }), + imageLinkDisposable: attachTerminalImageLinks(terminal, args.session), + imagePasteDisposable: attachTerminalImagePaste({ + host, + notifyMessage: args.notifyMessage, + pasteGuard, + socketRef, + terminal + }), + inputDisposable: attachTerminalInput(terminal, socketRef, pasteGuard), + wheelScrollDisposable: attachTerminalWheelScroll({ host, terminal }) +}) + +const createMountedTerminalConnector = ( + args: TerminalLifecycleArgs, + lifecycle: TerminalLifecycleState, + socketRef: TerminalSocketRef, + terminal: TerminalMessageHandlers["terminal"], + sendResize: () => void +): () => void => + createConnectSocket({ + handlers: createTerminalMessageHandlers(args, lifecycle, terminal), + lifecycle, + notifyMessage: args.notifyMessage, + onAttachFailure: args.onAttachFailure, + sendResize, + session: args.session, + setStatus: args.setStatus, + socketRef, + terminal + }) + +const createMountedTerminalCleanup = ( + { args, disposables, lifecycle, resizeObserver, sendResize, socketRef, terminal }: MountedTerminalCleanupArgs +): () => void => + createTerminalCleanup({ + cleanupArgs: { + connectionRef: args.connectionRef, + lifecycle, + notifyMessage: args.notifyMessage, + resizeObserver, + runtimeRef: args.runtimeRef, + session: args.session, + socketRef, + terminal + }, + copyInteractionDisposable: disposables.copyInteractionDisposable, + imageLinkDisposable: disposables.imageLinkDisposable, + imagePasteDisposable: disposables.imagePasteDisposable, + inputDisposable: disposables.inputDisposable, + sendResize, + wheelScrollDisposable: disposables.wheelScrollDisposable + }) + +const resolveMountHost = ( + { hostRef, session }: Pick +): HTMLDivElement | null => { + if (isPendingActiveTerminalSession(session)) { + return null + } + return hostRef.current +} + +const shouldAllowTerminalMouseTracking = (session: TerminalLifecycleArgs["session"]): boolean => + session.browserProjectId !== undefined + +const mountTerminalSession = (args: TerminalLifecycleArgs): (() => void) | undefined => { + const host = resolveMountHost(args) + if (host === null) { + return undefined + } + + args.connectionRef.current = { closing: false, opened: false } + const lifecycle = createLifecycleState() + const socketRef: TerminalSocketRef = { current: null } + const { fitAddon, terminal } = createTerminalRuntime(host, { + querySuppression: { + allowMouseTracking: shouldAllowTerminalMouseTracking(args.session) + } + }) + const terminalInputController = createTerminalInputController(terminal, socketRef) + const pasteGuard = createTerminalPasteGuard() + const sendResize = (): void => { + sendTerminalResize(fitAddon, socketRef, terminal) + } + const resizeObserver = observeTerminalResize(host, sendResize) + const disposables = createMountedTerminalDisposables(args, host, pasteGuard, socketRef, terminal) + const connectSocket = createMountedTerminalConnector(args, lifecycle, socketRef, terminal, sendResize) + + args.runtimeRef.current = terminalInputController + attachGlobalResizeListeners(sendResize) + connectSocket() + + return createMountedTerminalCleanup({ + args, + disposables, + lifecycle, + resizeObserver, + sendResize, + socketRef, + terminal + }) +} + +export const useTerminalSessionLifecycle = ( + { + connectionRef, + hostRef, + inlineImagePreviewsEnabledRef, + notifyExit, + notifyMessage, + onAttachFailure, + runtimeRef, + session, + setStatus + }: TerminalLifecycleArgs +): void => { + useEffect(() => { + return mountTerminalSession({ + connectionRef, + hostRef, + inlineImagePreviewsEnabledRef, + notifyExit, + notifyMessage, + onAttachFailure, + runtimeRef, + session, + setStatus + }) + }, [ + connectionRef, + hostRef, + notifyMessage, + notifyExit, + onAttachFailure, + runtimeRef, + session, + setStatus + ]) +} + +export { + type TerminalConnectionState, + type TerminalExitInfo, + type TerminalInputController, + type TerminalStatus +} from "./terminal-panel-runtime-types.js" diff --git a/packages/terminal/src/web/terminal-query-suppression.ts b/packages/terminal/src/web/terminal-query-suppression.ts new file mode 100644 index 00000000..a6417b7b --- /dev/null +++ b/packages/terminal/src/web/terminal-query-suppression.ts @@ -0,0 +1,173 @@ +export type TerminalQuerySuppression = { readonly dispose: () => void } + +export type TerminalQuerySuppressionOptions = { + readonly allowMouseTracking?: boolean + readonly suppressAlternateScreen?: boolean +} + +type Disposable = { readonly dispose: () => void } + +type FunctionIdentifier = { + readonly final: string + readonly intermediates?: string + readonly prefix?: string +} + +type CsiParam = number | ReadonlyArray + +type CsiParams = ReadonlyArray + +export type TerminalQuerySuppressionTarget = { + readonly parser: { + readonly registerCsiHandler: ( + id: FunctionIdentifier, + callback: (params: CsiParams) => boolean + ) => Disposable + readonly registerDcsHandler: ( + id: FunctionIdentifier, + callback: (data: string, params: CsiParams) => boolean + ) => Disposable + readonly registerOscHandler: ( + ident: number, + callback: (data: string) => boolean + ) => Disposable + } +} + +// DEC private modes whose `h`/`l` setter can cause xterm.js to emit event bytes +// back through `onData` on later DOM events. +const MOUSE_TRACKING_PRIVATE_MODES: ReadonlySet = new Set([ + 1000, + 1002, + 1003, + 1006, + 1015, + 1016 +]) +const FOCUS_REPORTING_PRIVATE_MODE = 1004 +const ALTERNATE_SCREEN_PRIVATE_MODES: ReadonlySet = new Set([47, 1047, 1049]) + +// Suppressing SET leaves xterm.js in the default state (no event emission); +// suppressing RESET is harmless and kept for symmetry. +// Modes intentionally left to fall through to xterm's built-in handlers: +// 25 — cursor visibility +// 2004 — bracketed paste +// 2026 — synchronized output (Ink uses BSU/ESU around every frame) +// 1007 — alternate scroll (only changes wheel semantics, no leak) +// 47/1047/1049 — alternate screen, unless project terminals opt out to keep xterm scrollback visible + +const isColorQuery = (data: string): boolean => { + for (const segment of data.split(";")) { + if (segment === "?") { + return true + } + } + return false +} + +const extractParam = (param: CsiParam): number | null => { + if (typeof param === "number") { + return param + } + const head = param[0] + return typeof head === "number" ? head : null +} + +const shouldSuppressPrivateMode = ( + mode: number, + options: TerminalQuerySuppressionOptions +): boolean => + mode === FOCUS_REPORTING_PRIVATE_MODE || + (options.allowMouseTracking !== true && MOUSE_TRACKING_PRIVATE_MODES.has(mode)) || + (options.suppressAlternateScreen === true && ALTERNATE_SCREEN_PRIVATE_MODES.has(mode)) + +const containsSuppressedPrivateMode = ( + params: CsiParams, + options: TerminalQuerySuppressionOptions +): boolean => { + for (const param of params) { + const value = extractParam(param) + if (value !== null && shouldSuppressPrivateMode(value, options)) { + return true + } + } + return false +} + +const registerOscColorQuerySuppressor = ( + terminal: TerminalQuerySuppressionTarget, + identifier: number +): Disposable => terminal.parser.registerOscHandler(identifier, (data) => isColorQuery(data)) + +const registerCsiSuppressor = ( + terminal: TerminalQuerySuppressionTarget, + identifier: FunctionIdentifier +): Disposable => terminal.parser.registerCsiHandler(identifier, () => true) + +const registerDcsSuppressor = ( + terminal: TerminalQuerySuppressionTarget, + identifier: FunctionIdentifier +): Disposable => terminal.parser.registerDcsHandler(identifier, () => true) + +const registerSelectivePrivateModeSuppressor = ( + terminal: TerminalQuerySuppressionTarget, + final: "h" | "l", + options: TerminalQuerySuppressionOptions +): Disposable => + terminal.parser.registerCsiHandler( + { final, prefix: "?" }, + (params) => containsSuppressedPrivateMode(params, options) + ) + +export const installTerminalQuerySuppression = ( + terminal: TerminalQuerySuppressionTarget, + options: TerminalQuerySuppressionOptions = {} +): TerminalQuerySuppression => { + const disposables: ReadonlyArray = [ + // OSC 4/10/11/12 — color queries (`?` payload). Set-color payloads fall through. + registerOscColorQuerySuppressor(terminal, 4), + registerOscColorQuerySuppressor(terminal, 10), + registerOscColorQuerySuppressor(terminal, 11), + registerOscColorQuerySuppressor(terminal, 12), + // CSI c / > c / = c — primary/secondary/tertiary device attributes (DA1/DA2/DA3). + registerCsiSuppressor(terminal, { final: "c" }), + registerCsiSuppressor(terminal, { final: "c", prefix: ">" }), + registerCsiSuppressor(terminal, { final: "c", prefix: "=" }), + // CSI n / ? n — device status report and cursor position report. + registerCsiSuppressor(terminal, { final: "n" }), + registerCsiSuppressor(terminal, { final: "n", prefix: "?" }), + // CSI $ p / CSI ? $ p — DECRQM (ANSI and DEC private forms). xterm.js always + // replies via `requestMode`, including for the `?2026 $p` synchronized-output + // probe that Ink emits during startup. + registerCsiSuppressor(terminal, { final: "p", intermediates: "$" }), + registerCsiSuppressor(terminal, { final: "p", intermediates: "$", prefix: "?" }), + // DCS $ q ... ST — DECRQSS. xterm.js always replies via `requestStatusString`. + registerDcsSuppressor(terminal, { final: "q", intermediates: "$" }), + // DCS + q ... ST — XTGETTCAP. No reply in 5.3.0; suppressed for defense-in-depth. + registerDcsSuppressor(terminal, { final: "q", intermediates: "+" }), + // CSI > q — XTVERSION. Not in 5.3.0 but auto-replies in xterm.js master. + registerCsiSuppressor(terminal, { final: "q", prefix: ">" }), + // CSI Pm t — window manipulation. Gated by `windowOptions` (off by default); + // suppressed so an accidental future enable does not leak size reports. + registerCsiSuppressor(terminal, { final: "t" }), + // CSI ? h / CSI ? l — block xterm from enabling focus reporting and, + // unless explicitly allowed for tmux project terminals, mouse tracking modes. + // Other DEC private modes fall through to xterm's built-in setters. + registerSelectivePrivateModeSuppressor(terminal, "h", options), + registerSelectivePrivateModeSuppressor(terminal, "l", options) + ] + return { + dispose: () => { + for (const disposable of disposables) { + disposable.dispose() + } + } + } +} + +export const isTerminalColorQuery = isColorQuery + +export const isSuppressedDecPrivateMode = ( + mode: number, + options: TerminalQuerySuppressionOptions = {} +): boolean => shouldSuppressPrivateMode(mode, options) diff --git a/packages/terminal/src/web/terminal-reconnect.ts b/packages/terminal/src/web/terminal-reconnect.ts new file mode 100644 index 00000000..6e4ca7db --- /dev/null +++ b/packages/terminal/src/web/terminal-reconnect.ts @@ -0,0 +1,7 @@ +export const terminalReconnectGraceMs = 60_000 + +const reconnectBaseDelayMs = 500 +const reconnectMaxDelayMs = 3000 + +export const resolveTerminalReconnectDelay = (attempt: number): number => + Math.min(reconnectBaseDelayMs * (2 ** Math.max(0, attempt)), reconnectMaxDelayMs) diff --git a/packages/terminal/src/web/terminal-state.ts b/packages/terminal/src/web/terminal-state.ts new file mode 100644 index 00000000..ac837ed7 --- /dev/null +++ b/packages/terminal/src/web/terminal-state.ts @@ -0,0 +1,158 @@ +import type { ActiveTerminalSession } from "./terminal.js" + +export type TerminalWorkspaceState = { + readonly activeTerminalSessionId: string | null + readonly terminalSessions: ReadonlyArray +} + +type RemoveTerminalSessionOptions = { + readonly activateNeighbor?: boolean +} + +export const emptyTerminalWorkspaceState: TerminalWorkspaceState = { + activeTerminalSessionId: null, + terminalSessions: [] +} + +export const terminalSessionId = (session: ActiveTerminalSession): string => session.session.id + +const isProjectTerminalSession = (session: ActiveTerminalSession, projectId: string): boolean => + session.browserProjectId === projectId + +export const terminalSessionsForProject = ( + sessions: ReadonlyArray, + projectId: string +): ReadonlyArray => sessions.filter((session) => isProjectTerminalSession(session, projectId)) + +const latestProjectTerminalSession = ( + sessions: ReadonlyArray, + projectId: string +): ActiveTerminalSession | null => { + let latest: ActiveTerminalSession | null = null + for (const session of sessions) { + if (isProjectTerminalSession(session, projectId)) { + latest = session + } + } + return latest +} + +export const reusableProjectTerminalSessionId = ( + sessions: ReadonlyArray, + activeTerminalSessionId: string | null, + projectId: string +): string | null => { + const active = sessions.find((session) => + terminalSessionId(session) === activeTerminalSessionId && isProjectTerminalSession(session, projectId) + ) + const reusable = active ?? latestProjectTerminalSession(sessions, projectId) + return reusable === null ? null : terminalSessionId(reusable) +} + +const hasSessionId = (sessions: ReadonlyArray, sessionId: string | null): boolean => + sessionId !== null && sessions.some((session) => terminalSessionId(session) === sessionId) + +const normalizeTerminalWorkspaceState = (state: TerminalWorkspaceState): TerminalWorkspaceState => + state.activeTerminalSessionId === null || hasSessionId(state.terminalSessions, state.activeTerminalSessionId) + ? state + : { + ...state, + activeTerminalSessionId: null + } + +export const activeTerminalSession = (state: TerminalWorkspaceState): ActiveTerminalSession | null => { + const normalized = normalizeTerminalWorkspaceState(state) + return normalized.terminalSessions.find((session) => + terminalSessionId(session) === normalized.activeTerminalSessionId + ) ?? null +} + +export const activeTerminalSessionForProject = ( + state: TerminalWorkspaceState, + projectId: string +): ActiveTerminalSession | null => { + const active = activeTerminalSession(state) + return active !== null && isProjectTerminalSession(active, projectId) ? active : null +} + +export const deactivateTerminalWorkspaceState = (state: TerminalWorkspaceState): TerminalWorkspaceState => ({ + activeTerminalSessionId: null, + terminalSessions: state.terminalSessions +}) + +export const visibleTerminalWorkspaceState = (state: TerminalWorkspaceState): TerminalWorkspaceState => { + const active = activeTerminalSession(state) + if (active === null) { + return emptyTerminalWorkspaceState + } + + const activeSessionId = terminalSessionId(active) + if (active.browserProjectId === undefined) { + return { + activeTerminalSessionId: activeSessionId, + terminalSessions: [active] + } + } + + return { + activeTerminalSessionId: activeSessionId, + terminalSessions: terminalSessionsForProject(state.terminalSessions, active.browserProjectId) + } +} + +export const addTerminalSessionState = ( + state: TerminalWorkspaceState, + session: ActiveTerminalSession +): TerminalWorkspaceState => { + const sessionId = terminalSessionId(session) + const existingIndex = state.terminalSessions.findIndex((candidate) => terminalSessionId(candidate) === sessionId) + const terminalSessions = existingIndex === -1 + ? [...state.terminalSessions, session] + : state.terminalSessions.map((candidate, index) => index === existingIndex ? session : candidate) + return { + activeTerminalSessionId: sessionId, + terminalSessions + } +} + +export const selectTerminalSessionState = ( + state: TerminalWorkspaceState, + sessionId: string +): TerminalWorkspaceState => + hasSessionId(state.terminalSessions, sessionId) + ? { ...state, activeTerminalSessionId: sessionId } + : normalizeTerminalWorkspaceState(state) + +export const removeTerminalSessionState = ( + state: TerminalWorkspaceState, + sessionId: string, + options: RemoveTerminalSessionOptions = {} +): TerminalWorkspaceState => { + const removedIndex = state.terminalSessions.findIndex((session) => terminalSessionId(session) === sessionId) + if (removedIndex === -1) { + return normalizeTerminalWorkspaceState(state) + } + + const terminalSessions = state.terminalSessions.filter((session) => terminalSessionId(session) !== sessionId) + if (state.activeTerminalSessionId !== sessionId) { + return normalizeTerminalWorkspaceState({ + ...state, + terminalSessions + }) + } + + if (options.activateNeighbor === false) { + return { + activeTerminalSessionId: null, + terminalSessions + } + } + + const nextActiveSession = terminalSessions[removedIndex] ?? terminalSessions[removedIndex - 1] + return { + activeTerminalSessionId: nextActiveSession === undefined ? null : terminalSessionId(nextActiveSession), + terminalSessions + } +} + +export const hasTerminalSessions = (state: TerminalWorkspaceState): boolean => state.terminalSessions.length > 0 diff --git a/packages/terminal/src/web/terminal-wheel-scroll.ts b/packages/terminal/src/web/terminal-wheel-scroll.ts new file mode 100644 index 00000000..d038615a --- /dev/null +++ b/packages/terminal/src/web/terminal-wheel-scroll.ts @@ -0,0 +1,152 @@ +export type TerminalWheelMouseTrackingMode = "any" | "drag" | "none" | "vt200" | "x10" + +export type TerminalWheelScrollBufferType = "alternate" | "normal" + +export type TerminalWheelScrollBuffer = { + readonly active: { + readonly baseY: number + readonly type: TerminalWheelScrollBufferType + readonly viewportY: number + } +} + +export type TerminalWheelScrollTerminal = { + readonly buffer?: TerminalWheelScrollBuffer | undefined + readonly element?: TerminalWheelScrollTarget | null | undefined + readonly modes: { + readonly mouseTrackingMode: TerminalWheelMouseTrackingMode + } + readonly rows: number + readonly scrollLines: (amount: number) => void +} + +type TerminalWheelScrollEvent = { + readonly deltaMode: number + readonly deltaY: number + readonly stopImmediatePropagation?: () => void + readonly preventDefault: () => void + readonly stopPropagation: () => void +} + +type TerminalWheelScrollTarget = { + readonly addEventListener: ( + type: "wheel", + listener: (event: TerminalWheelScrollEvent) => void, + options: AddEventListenerOptions + ) => void + readonly removeEventListener: ( + type: "wheel", + listener: (event: TerminalWheelScrollEvent) => void, + options: boolean + ) => void +} + +type TerminalWheelScrollDelta = { + readonly deltaMode: number + readonly deltaY: number + readonly previousPixelDeltaY: number + readonly rows: number +} + +export type ResolvedTerminalWheelScrollDelta = { + readonly lines: number + readonly nextPixelDeltaY: number +} + +type TerminalWheelScrollArgs = { + readonly host: TerminalWheelScrollTarget + readonly terminal: TerminalWheelScrollTerminal +} + +const wheelPixelDeltaMode = 0 +const wheelLineDeltaMode = 1 +const wheelPageDeltaMode = 2 +const pixelsPerTerminalLine = 15 + +const hasActiveMouseTracking = (terminal: TerminalWheelScrollTerminal): boolean => + terminal.modes.mouseTrackingMode !== "none" + +const hasActiveAlternateBuffer = (terminal: TerminalWheelScrollTerminal): boolean => + terminal.buffer?.active.type === "alternate" + +const hasScrollableTerminalHistory = (terminal: TerminalWheelScrollTerminal): boolean => { + const activeBuffer = terminal.buffer?.active + return activeBuffer !== undefined && activeBuffer.type === "normal" && activeBuffer.baseY > 0 +} + +export const shouldHandleTerminalWheelScroll = (terminal: TerminalWheelScrollTerminal): boolean => + hasActiveMouseTracking(terminal) || + hasActiveAlternateBuffer(terminal) || + hasScrollableTerminalHistory(terminal) + +const resolveTerminalWheelScrollTarget = ( + { host, terminal }: TerminalWheelScrollArgs +): TerminalWheelScrollTarget => terminal.element ?? host + +const validTerminalRows = (rows: number): number => { + if (!Number.isFinite(rows) || rows < 1) { + return 1 + } + return Math.trunc(rows) +} + +const finiteDelta = (delta: number): number => { + if (!Number.isFinite(delta)) { + return 0 + } + return delta +} + +export const resolveTerminalWheelScrollDelta = ( + delta: TerminalWheelScrollDelta +): ResolvedTerminalWheelScrollDelta => { + const deltaY = finiteDelta(delta.deltaY) + if (delta.deltaMode === wheelLineDeltaMode) { + return { lines: Math.trunc(deltaY), nextPixelDeltaY: 0 } + } + if (delta.deltaMode === wheelPageDeltaMode) { + return { lines: Math.trunc(deltaY * validTerminalRows(delta.rows)), nextPixelDeltaY: 0 } + } + if (delta.deltaMode !== wheelPixelDeltaMode) { + return { lines: Math.trunc(deltaY), nextPixelDeltaY: 0 } + } + const nextPixelDeltaY = finiteDelta(delta.previousPixelDeltaY) + deltaY + const lines = Math.trunc(nextPixelDeltaY / pixelsPerTerminalLine) + return { + lines, + nextPixelDeltaY: nextPixelDeltaY - lines * pixelsPerTerminalLine + } +} + +export const attachTerminalWheelScroll = ( + args: TerminalWheelScrollArgs +): { readonly dispose: () => void } => { + let previousPixelDeltaY = 0 + const target = resolveTerminalWheelScrollTarget(args) + const onWheel = (event: TerminalWheelScrollEvent): void => { + if (!shouldHandleTerminalWheelScroll(args.terminal)) { + return + } + const scrollDelta = resolveTerminalWheelScrollDelta({ + deltaMode: event.deltaMode, + deltaY: event.deltaY, + previousPixelDeltaY, + rows: args.terminal.rows + }) + previousPixelDeltaY = scrollDelta.nextPixelDeltaY + event.preventDefault() + event.stopPropagation() + event.stopImmediatePropagation?.() + if (scrollDelta.lines !== 0) { + args.terminal.scrollLines(scrollDelta.lines) + } + } + + target.addEventListener("wheel", onWheel, { capture: true, passive: false }) + + return { + dispose: () => { + target.removeEventListener("wheel", onWheel, true) + } + } +} diff --git a/packages/terminal/src/web/terminal.ts b/packages/terminal/src/web/terminal.ts new file mode 100644 index 00000000..ff229aa0 --- /dev/null +++ b/packages/terminal/src/web/terminal.ts @@ -0,0 +1,257 @@ +import * as ParseResult from "@effect/schema/ParseResult" +import { Either } from "effect" +import { + type TerminalServerMessage as ParsedTerminalServerMessage, + TerminalServerMessageSchema, + type TerminalSession +} from "../contracts/index.js" + +type PendingTerminalConnection = { + readonly message: string + readonly phase: "connecting" | "error" +} + +export type PendingActiveTerminalSession = ActiveTerminalSession & { + readonly pendingConnection: PendingTerminalConnection +} + +export type ActiveTerminalSession = { + readonly browserProjectId?: string | undefined + readonly browserProjectKey?: string | undefined + readonly browserProjectName?: string | undefined + readonly closePath: string + readonly exitMessage: string + readonly header: string + readonly onExit?: () => void + readonly onReady?: () => void + readonly pendingConnection?: PendingTerminalConnection | undefined + readonly pendingDeleteMessage: string + readonly readyMessage: string + readonly sessionPath?: string | undefined + readonly session: TerminalSession + readonly subtitle: string + readonly websocketPath: string +} + +export type TerminalApiBaseUrlResolver = () => string + +const defaultApiBaseUrl = "/api" + +let terminalApiBaseUrlResolver: TerminalApiBaseUrlResolver | null = null + +export const trimTerminalTrailingSlash = (value: string): string => { + let next = value + while (next.endsWith("/")) { + next = next.slice(0, -1) + } + return next +} + +export const setTerminalApiBaseUrlResolver = ( + resolver: TerminalApiBaseUrlResolver | null +): void => { + terminalApiBaseUrlResolver = resolver +} + +type ProjectActiveTerminalSessionArgs = { + readonly onExit?: () => void + readonly onReady?: () => void + readonly projectDisplayName: string + readonly projectId: string + readonly projectKey: string + readonly session: TerminalSession +} + +type PendingProjectActiveTerminalSessionArgs = { + readonly createdAt?: string + readonly onExit?: () => void + readonly pendingSessionId: string + readonly projectDisplayName: string + readonly projectId: string + readonly projectKey: string + readonly phase?: PendingTerminalConnection["phase"] + readonly message?: string +} + +type ProjectTerminalSessionBaseArgs = { + readonly projectDisplayName: string + readonly projectId: string + readonly projectKey: string + readonly sessionId: string +} + +type ProjectTerminalSessionBase = Pick< + ActiveTerminalSession, + | "browserProjectId" + | "browserProjectKey" + | "browserProjectName" + | "closePath" + | "header" + | "readyMessage" + | "websocketPath" +> + +export const terminalSessionRoutePath = (sessionId: string): string => `/ssh/session/${encodeURIComponent(sessionId)}` + +const encodeProjectKeyPath = (projectKey: string): string => + projectKey.split("/").map((segment) => encodeURIComponent(segment)).join("/") + +const terminalUuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu + +export const terminalRouteToken = (sessionId: string): string => + terminalUuidPattern.test(sessionId) ? sessionId.slice(0, 8) : sessionId + +export const projectSshRoutePath = (projectKey: string, terminalId?: string): string => { + const path = `/ssh/${encodeProjectKeyPath(projectKey)}` + return terminalId === undefined ? path : `${path}?t=${encodeURIComponent(terminalRouteToken(terminalId))}` +} + +type TerminalLabelSession = { + readonly createdAt: string + readonly id: string +} + +const compareTerminalLabelSession = (left: TerminalLabelSession, right: TerminalLabelSession): number => { + const byCreatedAt = left.createdAt.localeCompare(right.createdAt) + return byCreatedAt === 0 ? left.id.localeCompare(right.id) : byCreatedAt +} + +export const terminalTitle = (index: number): string => `Terminal ${index + 1}` + +const terminalTitleEntry = ( + session: TerminalLabelSession, + index: number +): readonly [string, string] => [session.id, terminalTitle(index)] + +export const terminalTitleById = ( + sessions: ReadonlyArray +): ReadonlyMap => + new Map( + sessions + .toSorted(compareTerminalLabelSession) + .map((session, index) => terminalTitleEntry(session, index)) + ) + +export const isPendingActiveTerminalSession = ( + session: ActiveTerminalSession +): session is PendingActiveTerminalSession => session.pendingConnection !== undefined + +const buildProjectTerminalSessionBase = ( + { projectDisplayName, projectId, projectKey, sessionId }: ProjectTerminalSessionBaseArgs +): ProjectTerminalSessionBase => { + const encodedProjectKey = encodeURIComponent(projectKey) + const encodedSessionId = encodeURIComponent(sessionId) + const terminalSessionPath = `/projects/by-key/${encodedProjectKey}/terminal-sessions/${encodedSessionId}` + return { + browserProjectId: projectId, + browserProjectKey: projectKey, + browserProjectName: projectDisplayName, + closePath: terminalSessionPath, + header: `SSH terminal: ${projectDisplayName}`, + readyMessage: `SSH connected: ${projectDisplayName}.`, + websocketPath: `${terminalSessionPath}/ws` + } +} + +export const buildProjectActiveTerminalSession = ( + { onExit, onReady, projectDisplayName, projectId, projectKey, session }: ProjectActiveTerminalSessionArgs +): ActiveTerminalSession => { + const base = buildProjectTerminalSessionBase({ + projectDisplayName, + projectId, + projectKey, + sessionId: session.id + }) + return { + ...base, + exitMessage: "SSH session ended.", + ...(onExit === undefined ? {} : { onExit }), + ...(onReady === undefined ? {} : { onReady }), + pendingDeleteMessage: `Terminal session was closed before attach: ${projectDisplayName}.`, + session, + sessionPath: projectSshRoutePath(projectKey, session.id), + subtitle: session.sshCommand + } +} + +const resolvePendingProjectMessage = ( + message: string | undefined, + phase: PendingTerminalConnection["phase"] +): string => { + const trimmedMessage = message?.trim() ?? "" + if (trimmedMessage.length > 0) { + return trimmedMessage + } + return phase === "error" + ? "SSH session startup failed." + : "Starting project and waiting for SSH..." +} + +export const buildPendingProjectActiveTerminalSession = ( + { + createdAt, + message, + onExit, + pendingSessionId, + phase = "connecting", + projectDisplayName, + projectId, + projectKey + }: PendingProjectActiveTerminalSessionArgs +): ActiveTerminalSession => { + const base = buildProjectTerminalSessionBase({ + projectDisplayName, + projectId, + projectKey, + sessionId: pendingSessionId + }) + const resolvedMessage = resolvePendingProjectMessage(message, phase) + return { + ...base, + exitMessage: "Pending SSH session closed.", + ...(onExit === undefined ? {} : { onExit }), + pendingConnection: { + message: resolvedMessage, + phase + }, + pendingDeleteMessage: `Pending SSH terminal was closed before attach: ${projectDisplayName}.`, + readyMessage: `SSH connected: ${projectDisplayName}.`, + session: { + createdAt: createdAt ?? new Date().toISOString(), + id: pendingSessionId, + projectId, + sshCommand: "Preparing SSH session...", + status: phase === "error" ? "failed" : "ready" + }, + sessionPath: projectSshRoutePath(projectKey, pendingSessionId), + subtitle: resolvedMessage + } +} + +export const resolveTerminalApiBaseUrl = (): string => { + return terminalApiBaseUrlResolver === null + ? defaultApiBaseUrl + : trimTerminalTrailingSlash(terminalApiBaseUrlResolver()) +} + +export const resolveTerminalApiOriginUrl = (): URL => { + const configured = resolveTerminalApiBaseUrl() + if (configured.startsWith("http://") || configured.startsWith("https://")) { + return new URL(configured) + } + return new URL(configured, globalThis.location.origin) +} + +export const resolveTerminalWebSocketUrl = (websocketPath: string, cols: number, rows: number): string => { + const apiUrl = resolveTerminalApiOriginUrl() + apiUrl.protocol = apiUrl.protocol === "https:" ? "wss:" : "ws:" + apiUrl.pathname = `${apiUrl.pathname.replace(/\/$/u, "")}${websocketPath}` + apiUrl.searchParams.set("cols", String(cols)) + apiUrl.searchParams.set("rows", String(rows)) + return apiUrl.toString() +} + +export const parseTerminalServerMessage = (value: string): ParsedTerminalServerMessage | null => + Either.getOrNull(ParseResult.decodeUnknownEither(TerminalServerMessageSchema)(value)) + +export { type TerminalServerMessage } from "../contracts/index.js" diff --git a/packages/terminal/tests/architecture/boundaries.test.ts b/packages/terminal/tests/architecture/boundaries.test.ts new file mode 100644 index 00000000..beb6e2e9 --- /dev/null +++ b/packages/terminal/tests/architecture/boundaries.test.ts @@ -0,0 +1,133 @@ +import { NodeContext } from "@effect/platform-node" +import type { PlatformError } from "@effect/platform/Error" +import * as FileSystem from "@effect/platform/FileSystem" +import * as Path from "@effect/platform/Path" +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import ts from "typescript" + +const sourceRootPath = new URL("../../src", import.meta.url).pathname + +type ArchitectureTestServices = FileSystem.FileSystem | Path.Path + +const flattenReadonly = ( + arrays: ReadonlyArray> +): ReadonlyArray => arrays.flat() + +const sourceFilesForEntry = ( + directory: string, + entry: string +): Effect.Effect, PlatformError, ArchitectureTestServices> => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const entryPath = path.join(directory, entry) + const info = yield* _(fs.stat(entryPath)) + if (info.type === "Directory") { + return yield* _(sourceFiles(entryPath)) + } + return info.type === "File" && entryPath.endsWith(".ts") ? [entryPath] : [] + }) + +const sourceFiles = ( + directory: string +): Effect.Effect, PlatformError, ArchitectureTestServices> => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const entries = yield* _(fs.readDirectory(directory)) + const files = yield* _( + Effect.all(entries.map((entry) => sourceFilesForEntry(directory, entry))) + ) + return flattenReadonly(files) + }) + +const stringLiteralText = (node: ts.Node | undefined): string | null => + node !== undefined && ts.isStringLiteralLike(node) ? node.text : null + +const importTypeSource = (node: ts.ImportTypeNode): string | null => { + const argument = node.argument + if (!ts.isLiteralTypeNode(argument)) { + return null + } + return stringLiteralText(argument.literal) +} + +const importSourceForNode = (node: ts.Node): string | null => { + if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) { + return stringLiteralText(node.moduleSpecifier) + } + if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) { + return stringLiteralText(node.arguments.at(0)) + } + if (ts.isImportTypeNode(node)) { + return importTypeSource(node) + } + return null +} + +const importsFromSourceFile = (sourceFile: ts.SourceFile): ReadonlyArray => { + const visit = (node: ts.Node): ReadonlyArray => { + const ownSource = importSourceForNode(node) + const childSources = node.getChildren(sourceFile).flatMap((child) => visit(child)) + return ownSource === null ? childSources : [ownSource, ...childSources] + } + return visit(sourceFile) +} + +const importsFrom = ( + sourcePath: string +): Effect.Effect, PlatformError, FileSystem.FileSystem> => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const sourceText = yield* _(fs.readFileString(sourcePath)) + const sourceFile = ts.createSourceFile( + sourcePath, + sourceText, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS + ) + return importsFromSourceFile(sourceFile) + }) + +const bannedCoreImport = (source: string): boolean => + source.includes("/shell") || + source.includes("/server") || + source.includes("/web") || + source.includes("/cli") || + source === "ws" || + source === "xterm" || + source.startsWith("node:") + +const boundaryViolationsForFile = ( + path: Path.Path, + file: string +): Effect.Effect, PlatformError, FileSystem.FileSystem> => + Effect.map(importsFrom(file), (sources) => + sources + .filter((source) => bannedCoreImport(source)) + .map((source) => `${path.relative(sourceRootPath, file)} -> ${source}`)) + +describe("terminal package boundaries", () => { + it.effect("keeps contracts and core free of runtime adapter imports", () => + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + const files = yield* _( + Effect.map( + Effect.all([ + sourceFiles(`${sourceRootPath}/contracts`), + sourceFiles(`${sourceRootPath}/core`) + ]), + ([contractFiles, coreFiles]) => [...contractFiles, ...coreFiles] + ) + ) + const violations = yield* _( + Effect.map( + Effect.all(files.map((file) => boundaryViolationsForFile(path, file))), + (fileViolations) => flattenReadonly(fileViolations) + ) + ) + + expect(violations).toEqual([]) + }).pipe(Effect.provide(NodeContext.layer))) +}) diff --git a/packages/terminal/tests/contracts/session.test.ts b/packages/terminal/tests/contracts/session.test.ts new file mode 100644 index 00000000..bcca68ad --- /dev/null +++ b/packages/terminal/tests/contracts/session.test.ts @@ -0,0 +1,60 @@ +import * as ParseResult from "@effect/schema/ParseResult" +import { describe, expect, it } from "@effect/vitest" +import { Effect, Either } from "effect" + +import { + TerminalClientMessageSchema, + type TerminalServerMessage, + TerminalServerMessageSchema, + TerminalSessionSchema +} from "../../src/contracts/index.js" + +const readyMessage: TerminalServerMessage = { + session: { + createdAt: "2026-04-08T10:00:00.000Z", + id: "session-1", + projectId: "project-1", + sshCommand: "ssh dev@127.0.0.1", + status: "attached" + }, + type: "ready" +} + +describe("terminal contracts", () => { + it.effect("decodes terminal session payloads", () => + Effect.sync(() => { + const result = ParseResult.decodeUnknownEither(TerminalSessionSchema)(readyMessage.session) + + expect(Either.isRight(result)).toBe(true) + })) + + it.effect("decodes JSON server messages", () => + Effect.sync(() => { + const result = ParseResult.decodeUnknownEither(TerminalServerMessageSchema)(JSON.stringify(readyMessage)) + + expect(Either.getOrNull(result)).toEqual(readyMessage) + })) + + it.effect("rejects malformed server messages", () => + Effect.sync(() => { + const result = ParseResult.decodeUnknownEither(TerminalServerMessageSchema)("{\"type\":\"output\",\"data\":1}") + + expect(Either.isLeft(result)).toBe(true) + })) + + it.effect("decodes client input, resize, image, and close messages", () => + Effect.sync(() => { + const messages = [ + { type: "input", data: "ls\n" }, + { type: "resize", cols: 120, rows: 40 }, + { type: "image", data: "aGVsbG8=", mediaType: "image/png", name: "hello.png", size: 5 }, + { type: "close" } + ].map((message) => JSON.stringify(message)) + + expect( + messages.every((message) => + Either.isRight(ParseResult.decodeUnknownEither(TerminalClientMessageSchema)(message)) + ) + ).toBe(true) + })) +}) diff --git a/packages/terminal/tests/core/image-paste.test.ts b/packages/terminal/tests/core/image-paste.test.ts new file mode 100644 index 00000000..fea59532 --- /dev/null +++ b/packages/terminal/tests/core/image-paste.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import * as fc from "fast-check" + +import { + createTerminalImagePastePlan, + sanitizeTerminalImageBaseName, + terminalImagePasteDirectory +} from "../../src/core/index.js" + +describe("terminal image paste planning", () => { + it.effect("builds a valid paste plan for supported image payloads", () => + Effect.sync(() => { + const plan = createTerminalImagePastePlan( + { data: "aGVsbG8=", mediaType: "image/png", name: "../hello world.png", size: 5 }, + "paste-1" + ) + + expect(plan).toEqual({ + _tag: "ValidTerminalImagePaste", + containerPath: `${terminalImagePasteDirectory}/paste-1-hello-world.png`, + decodedBytes: 5, + normalizedBase64: "aGVsbG8=" + }) + })) + + it.effect("sanitizes unsafe image names", () => + Effect.sync(() => { + expect(sanitizeTerminalImageBaseName("../..//.bad name!!.png")).toBe("bad-name") + expect(sanitizeTerminalImageBaseName("////.png")).toBe("clipboard-image") + })) + + it.effect("sanitizes arbitrary image names into non-traversal path segments", () => + Effect.sync(() => { + fc.assert( + fc.property(fc.string({ maxLength: 256 }), (name) => { + const sanitized = sanitizeTerminalImageBaseName(name) + + expect(sanitized.length).toBeGreaterThan(0) + expect(sanitized.length).toBeLessThanOrEqual(72) + expect(sanitized).not.toContain("/") + expect(sanitized).not.toContain("\\") + expect(sanitized).not.toContain("..") + }), + { numRuns: 100 } + ) + })) + + it.effect("keeps arbitrary paste ids and names inside the paste directory", () => + Effect.sync(() => { + fc.assert( + fc.property( + fc.string({ maxLength: 128 }), + fc.string({ maxLength: 128 }), + (id, name) => { + const plan = createTerminalImagePastePlan( + { data: "AA==", mediaType: "image/png", name, size: 1 }, + id + ) + + expect(plan._tag).toBe("ValidTerminalImagePaste") + if (plan._tag === "ValidTerminalImagePaste") { + const prefix = `${terminalImagePasteDirectory}/` + const relativePath = plan.containerPath.slice(prefix.length) + + expect(plan.containerPath.startsWith(prefix)).toBe(true) + expect(relativePath).not.toContain("/") + expect(relativePath).not.toContain("\\") + expect(relativePath).not.toContain("..") + } + } + ), + { numRuns: 100 } + ) + })) + + it.effect("rejects invalid base64 payloads", () => + Effect.sync(() => { + const plan = createTerminalImagePastePlan( + { data: "not base64", mediaType: "image/png", name: "bad.png", size: 10 }, + "paste-1" + ) + + expect(plan).toEqual({ + _tag: "InvalidTerminalImagePaste", + message: "Image payload is not valid base64." + }) + })) +}) diff --git a/packages/terminal/tests/core/output-buffer.test.ts b/packages/terminal/tests/core/output-buffer.test.ts new file mode 100644 index 00000000..9881d513 --- /dev/null +++ b/packages/terminal/tests/core/output-buffer.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import * as fc from "fast-check" + +import { + appendTerminalOutput, + emptyTerminalOutputBuffer, + renderTerminalOutputBuffer, + type TerminalOutputBuffer +} from "../../src/core/index.js" + +const appendChunks = ( + chunks: ReadonlyArray, + budget: number, + index = 0, + buffer = emptyTerminalOutputBuffer +): TerminalOutputBuffer => { + const chunk = chunks[index] + if (chunk === undefined) { + return buffer + } + return appendChunks(chunks, budget, index + 1, appendTerminalOutput(buffer, chunk, budget)) +} + +describe("terminal output replay buffer", () => { + it.effect("replays appended terminal output in order", () => + Effect.sync(() => { + const buffer = appendTerminalOutput( + appendTerminalOutput(emptyTerminalOutputBuffer, "first\n", 100), + "second\n", + 100 + ) + + expect(renderTerminalOutputBuffer(buffer)).toBe("first\nsecond\n") + })) + + it.effect("keeps only the newest output when the replay budget is exceeded", () => + Effect.sync(() => { + const buffer = appendTerminalOutput( + appendTerminalOutput(emptyTerminalOutputBuffer, "abcdef", 8), + "ghij", + 8 + ) + + expect(buffer.charLength).toBe(8) + expect(renderTerminalOutputBuffer(buffer)).toBe("cdefghij") + })) + + it.effect("trims an oversized chunk to the replay budget", () => + Effect.sync(() => { + const buffer = appendTerminalOutput(emptyTerminalOutputBuffer, "0123456789", 4) + + expect(buffer.charLength).toBe(4) + expect(renderTerminalOutputBuffer(buffer)).toBe("6789") + })) + + it.effect("preserves replay budget and newest suffix for arbitrary chunks", () => + Effect.sync(() => { + fc.assert( + fc.property( + fc.array(fc.string({ maxLength: 32 }), { maxLength: 24 }), + fc.integer({ min: 0, max: 256 }), + (chunks, budget) => { + const buffer = appendChunks(chunks, budget) + const rendered = renderTerminalOutputBuffer(buffer) + const allOutput = chunks.join("") + const expected = allOutput.slice(Math.max(0, allOutput.length - budget)) + + expect(buffer.charLength).toBeLessThanOrEqual(budget) + expect(buffer.charLength).toBe(rendered.length) + expect(rendered).toBe(expected) + } + ), + { numRuns: 100 } + ) + })) +}) diff --git a/packages/terminal/tests/server/image-fetch.test.ts b/packages/terminal/tests/server/image-fetch.test.ts new file mode 100644 index 00000000..73898fe9 --- /dev/null +++ b/packages/terminal/tests/server/image-fetch.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import * as fc from "fast-check" + +import { planTerminalImageFetch } from "../../src/server/index.js" + +const expectedMediaTypes = new Map([ + ["gif", "image/gif"], + ["jpeg", "image/jpeg"], + ["jpg", "image/jpeg"], + ["png", "image/png"], + ["webp", "image/webp"] +]) + +const safePathName = fc + .array(fc.constantFrom("a", "b", "c", "d", "e", "f", "0", "1", "2", "-", "_"), { + maxLength: 32, + minLength: 1 + }) + .map((chars) => chars.join("")) + +describe("terminal image fetch planning", () => { + it.effect("accepts absolute supported image paths", () => + Effect.sync(() => { + expect(planTerminalImageFetch("/home/dev/image.png")).toEqual({ + _tag: "ValidTerminalImageFetch", + containerPath: "/home/dev/image.png", + mediaType: "image/png" + }) + })) + + it.effect("joins relative paths to a valid base directory", () => + Effect.sync(() => { + expect(planTerminalImageFetch("screens/shot.webp", { baseDir: "/home/dev/project" })).toEqual({ + _tag: "ValidTerminalImageFetch", + containerPath: "/home/dev/project/screens/shot.webp", + mediaType: "image/webp" + }) + })) + + it.effect("rejects traversal paths", () => + Effect.sync(() => { + expect(planTerminalImageFetch("/home/dev/../secret.png")).toEqual({ + _tag: "InvalidTerminalImageFetch", + message: "Image path must not contain '.' or '..' segments." + }) + })) + + it.effect("maps supported extensions to deterministic media types", () => + Effect.sync(() => { + fc.assert( + fc.property( + fc.constantFrom("gif", "jpeg", "jpg", "png", "webp"), + safePathName, + (extension, name) => { + const plan = planTerminalImageFetch(`/home/dev/${name}.${extension}`) + + expect(plan).toEqual({ + _tag: "ValidTerminalImageFetch", + containerPath: `/home/dev/${name}.${extension}`, + mediaType: expectedMediaTypes.get(extension) + }) + } + ), + { numRuns: 100 } + ) + })) + + it.effect("rejects arbitrary paths containing traversal segments", () => + Effect.sync(() => { + fc.assert( + fc.property( + fc.constantFrom(".", ".."), + fc.constantFrom("png", "jpg", "webp"), + (segment, extension) => { + const plan = planTerminalImageFetch(`/home/dev/${segment}/image.${extension}`) + + expect(plan).toEqual({ + _tag: "InvalidTerminalImageFetch", + message: "Image path must not contain '.' or '..' segments." + }) + } + ), + { numRuns: 50 } + ) + })) +}) diff --git a/packages/app/tests/docker-git/fixtures/terminal-copy-interaction.ts b/packages/terminal/tests/web/fixtures/terminal-copy-interaction.ts similarity index 100% rename from packages/app/tests/docker-git/fixtures/terminal-copy-interaction.ts rename to packages/terminal/tests/web/fixtures/terminal-copy-interaction.ts diff --git a/packages/app/tests/docker-git/panel-terminal-skiller.test.ts b/packages/terminal/tests/web/panel-terminal-skiller.test.ts similarity index 100% rename from packages/app/tests/docker-git/panel-terminal-skiller.test.ts rename to packages/terminal/tests/web/panel-terminal-skiller.test.ts diff --git a/packages/app/tests/docker-git/terminal-copy-interaction.test.ts b/packages/terminal/tests/web/terminal-copy-interaction.test.ts similarity index 100% rename from packages/app/tests/docker-git/terminal-copy-interaction.test.ts rename to packages/terminal/tests/web/terminal-copy-interaction.test.ts diff --git a/packages/app/tests/docker-git/terminal-copy-right-click-interaction.test.ts b/packages/terminal/tests/web/terminal-copy-right-click-interaction.test.ts similarity index 100% rename from packages/app/tests/docker-git/terminal-copy-right-click-interaction.test.ts rename to packages/terminal/tests/web/terminal-copy-right-click-interaction.test.ts diff --git a/packages/app/tests/docker-git/terminal-image-paths.test.ts b/packages/terminal/tests/web/terminal-image-paths.test.ts similarity index 100% rename from packages/app/tests/docker-git/terminal-image-paths.test.ts rename to packages/terminal/tests/web/terminal-image-paths.test.ts diff --git a/packages/app/tests/docker-git/terminal-inline-images-core.test.ts b/packages/terminal/tests/web/terminal-inline-images-core.test.ts similarity index 100% rename from packages/app/tests/docker-git/terminal-inline-images-core.test.ts rename to packages/terminal/tests/web/terminal-inline-images-core.test.ts diff --git a/packages/app/tests/docker-git/terminal-mobile-controls.test.ts b/packages/terminal/tests/web/terminal-mobile-controls.test.ts similarity index 100% rename from packages/app/tests/docker-git/terminal-mobile-controls.test.ts rename to packages/terminal/tests/web/terminal-mobile-controls.test.ts diff --git a/packages/app/tests/docker-git/terminal-panel-runtime-core.test.ts b/packages/terminal/tests/web/terminal-panel-runtime-core.test.ts similarity index 72% rename from packages/app/tests/docker-git/terminal-panel-runtime-core.test.ts rename to packages/terminal/tests/web/terminal-panel-runtime-core.test.ts index 700d258b..0391b5e9 100644 --- a/packages/app/tests/docker-git/terminal-panel-runtime-core.test.ts +++ b/packages/terminal/tests/web/terminal-panel-runtime-core.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, vi } from "vitest" import { attachTerminalInput, isTerminalMouseReportInput } from "../../src/web/terminal-panel-input.js" type TerminalDataHandler = (data: string) => void +type TerminalPasteGuard = Parameters[2] const noop = (): void => undefined @@ -47,11 +48,19 @@ const createOpenSocketRef = () => { } } -const passThroughPasteGuard = { +const passThroughPasteGuard: TerminalPasteGuard = { shouldSuppressTerminalInput: () => false, suppressNextNativeImagePaste: noop } +const attachOpenTerminalInput = (pasteGuard: TerminalPasteGuard = passThroughPasteGuard) => { + const input = createTerminalInputHarness() + const { sent, socketRef } = createOpenSocketRef() + const disposable = attachTerminalInput(input.terminal, socketRef, pasteGuard) + + return { disposable, input, sent } +} + describe("terminal panel runtime core", () => { beforeEach(() => { vi.stubGlobal("WebSocket", { OPEN: 1 }) @@ -71,10 +80,7 @@ describe("terminal panel runtime core", () => { }) it("scrolls to bottom for regular terminal input before sending it to the socket", () => { - const input = createTerminalInputHarness() - const { sent, socketRef } = createOpenSocketRef() - - const disposable = attachTerminalInput(input.terminal, socketRef, passThroughPasteGuard) + const { disposable, input, sent } = attachOpenTerminalInput() input.emit("a") disposable.dispose() @@ -83,11 +89,20 @@ describe("terminal panel runtime core", () => { expect(sent).toEqual([JSON.stringify({ data: "a", type: "input" })]) }) - it("keeps the viewport stable for terminal mouse click reports", () => { - const input = createTerminalInputHarness() - const { sent, socketRef } = createOpenSocketRef() + it("forwards arrow escape sequences as regular terminal input", () => { + const { input, sent } = attachOpenTerminalInput() + input.emit("\u001B[C") + input.emit("\u001B[A") + + expect(input.state.scrolls).toBe(2) + expect(sent).toEqual([ + JSON.stringify({ data: "\u001B[C", type: "input" }), + JSON.stringify({ data: "\u001B[A", type: "input" }) + ]) + }) - attachTerminalInput(input.terminal, socketRef, passThroughPasteGuard) + it("keeps the viewport stable for terminal mouse click reports", () => { + const { input, sent } = attachOpenTerminalInput() input.emit("\u001B[<0;10;5M") expect(input.state.scrolls).toBe(0) @@ -95,14 +110,12 @@ describe("terminal panel runtime core", () => { }) it("does not scroll or send input suppressed by the paste guard", () => { - const input = createTerminalInputHarness() - const { sent, socketRef } = createOpenSocketRef() const pasteGuard = { shouldSuppressTerminalInput: () => true, suppressNextNativeImagePaste: noop } + const { input, sent } = attachOpenTerminalInput(pasteGuard) - attachTerminalInput(input.terminal, socketRef, pasteGuard) input.emit("\u0016") expect(input.state.scrolls).toBe(0) diff --git a/packages/app/tests/docker-git/terminal-query-suppression.test.ts b/packages/terminal/tests/web/terminal-query-suppression.test.ts similarity index 98% rename from packages/app/tests/docker-git/terminal-query-suppression.test.ts rename to packages/terminal/tests/web/terminal-query-suppression.test.ts index 60046c06..942f5b08 100644 --- a/packages/app/tests/docker-git/terminal-query-suppression.test.ts +++ b/packages/terminal/tests/web/terminal-query-suppression.test.ts @@ -249,10 +249,11 @@ describe("terminal query suppression", () => { const handlers = privateModeHandlers(mock) expectPrivateModesHandled(handlers, MOUSE_TRACKING_MODES, false) + expectPrivateModesHandled(handlers, ALTERNATE_SCREEN_MODES, false) expectPrivateModesHandled(handlers, [FOCUS_REPORTING_MODE], true) }) - it("blocks alternate screen modes when project terminals preserve xterm scrollback", () => { + it("blocks alternate screen modes only when scrollback preservation is explicitly requested", () => { const mock = createMockTerminal() installTerminalQuerySuppression(mock.terminal, { allowMouseTracking: true, diff --git a/packages/app/tests/docker-git/terminal-state.test.ts b/packages/terminal/tests/web/terminal-state.test.ts similarity index 100% rename from packages/app/tests/docker-git/terminal-state.test.ts rename to packages/terminal/tests/web/terminal-state.test.ts diff --git a/packages/app/tests/docker-git/terminal-wheel-scroll.test.ts b/packages/terminal/tests/web/terminal-wheel-scroll.test.ts similarity index 100% rename from packages/app/tests/docker-git/terminal-wheel-scroll.test.ts rename to packages/terminal/tests/web/terminal-wheel-scroll.test.ts diff --git a/packages/app/tests/docker-git/terminal.test.ts b/packages/terminal/tests/web/terminal.test.ts similarity index 93% rename from packages/app/tests/docker-git/terminal.test.ts rename to packages/terminal/tests/web/terminal.test.ts index 1f098387..8594bb10 100644 --- a/packages/app/tests/docker-git/terminal.test.ts +++ b/packages/terminal/tests/web/terminal.test.ts @@ -17,27 +17,24 @@ import { parseTerminalServerMessage, projectSshRoutePath, resolveTerminalWebSocketUrl, + setTerminalApiBaseUrlResolver, terminalRouteToken, terminalTitleById } from "../../src/web/terminal.js" import type { TerminalServerMessage } from "../../src/web/terminal.js" -const resolveApiBaseUrlMock = vi.hoisted(() => vi.fn<() => string>()) +const resolveApiBaseUrlMock = vi.fn<() => string>() -const readyMessagePayload: TerminalServerMessage = { +const readyTerminalMessagePayload = (): TerminalServerMessage => ({ + type: "ready", session: { - createdAt: "2026-04-08T10:00:00.000Z", - id: "session-1", - projectId: "project-1", - sshCommand: "ssh dev@127.0.0.1", - status: "attached" - }, - type: "ready" -} - -vi.mock("../../src/web/api-http.js", () => ({ - resolveApiBaseUrl: resolveApiBaseUrlMock -})) + id: "browser-session-1", + createdAt: "2026-05-10T11:00:00.000Z", + projectId: "browser-project-1", + status: "attached", + sshCommand: "ssh dev@192.0.2.1" + } +}) const stubSameOriginLocation = (host: string, httpProtocol: string): void => { resolveApiBaseUrlMock.mockReturnValue("/api") @@ -51,9 +48,11 @@ const stubSameOriginLocation = (host: string, httpProtocol: string): void => { describe("browser terminal helpers", () => { beforeEach(() => { resolveApiBaseUrlMock.mockReset() + setTerminalApiBaseUrlResolver(resolveApiBaseUrlMock) }) afterEach(() => { + setTerminalApiBaseUrlResolver(null) vi.unstubAllGlobals() }) @@ -80,6 +79,7 @@ describe("browser terminal helpers", () => { }) it("parses ready terminal messages", () => { + const readyMessagePayload = readyTerminalMessagePayload() const parsed = parseTerminalServerMessage(JSON.stringify(readyMessagePayload)) expect(parsed).toEqual(readyMessagePayload) diff --git a/packages/terminal/tsconfig.build.json b/packages/terminal/tsconfig.build.json new file mode 100644 index 00000000..1cf9c5e5 --- /dev/null +++ b/packages/terminal/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "types": ["node", "vite/client"] + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules", "tests"] +} diff --git a/packages/terminal/tsconfig.json b/packages/terminal/tsconfig.json new file mode 100644 index 00000000..a850d3af --- /dev/null +++ b/packages/terminal/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "dist", + "ignoreDeprecations": "6.0", + "jsx": "react-jsx", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "types": ["vitest", "vite/client"], + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": [ + "src/**/*", + "tests/**/*", + "vite.config.ts", + "vitest.config.ts" + ], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/terminal/vite.config.ts b/packages/terminal/vite.config.ts new file mode 100644 index 00000000..cfd0bb55 --- /dev/null +++ b/packages/terminal/vite.config.ts @@ -0,0 +1,32 @@ +import path from "node:path" +import { fileURLToPath } from "node:url" +import { defineConfig } from "vite" +import tsconfigPaths from "vite-tsconfig-paths" + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +export default defineConfig({ + plugins: [tsconfigPaths()], + publicDir: false, + resolve: { + alias: { + "@": path.resolve(__dirname, "src") + } + }, + build: { + target: "node20", + outDir: "dist", + sourcemap: true, + ssr: "src/app/main.ts", + rollupOptions: { + output: { + format: "es", + entryFileNames: "main.js" + } + } + }, + ssr: { + target: "node" + } +}) diff --git a/packages/terminal/vitest.config.ts b/packages/terminal/vitest.config.ts new file mode 100644 index 00000000..319bffbb --- /dev/null +++ b/packages/terminal/vitest.config.ts @@ -0,0 +1,85 @@ +// CHANGE: Migrate from Jest to Vitest with mathematical equivalence +// WHY: Faster execution, native ESM, Effect integration via @effect/vitest +// QUOTE(ТЗ): "Проект использует Effect + функциональную парадигму" +// REF: Migration from jest.config.mjs +// PURITY: SHELL (configuration only) +// INVARIANT: ∀ test: behavior_jest ≡ behavior_vitest +// EFFECT: Effect +// COMPLEXITY: O(n) test execution where n = |test_files| + +import path from "node:path" +import { fileURLToPath } from "node:url" +import tsconfigPaths from "vite-tsconfig-paths" +import { defineConfig } from "vitest/config" + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +export default defineConfig({ + plugins: [tsconfigPaths()], // Resolves @/* paths from tsconfig + test: { + // CHANGE: Native ESM support without experimental flags + // WHY: Vitest designed for ESM, no need for --experimental-vm-modules + // INVARIANT: Deterministic test execution without side effects + globals: false, // IMPORTANT: Use explicit imports for type safety + environment: "node", + + // CHANGE: Match Jest's test file patterns + // INVARIANT: Same test discovery as Jest + include: ["tests/**/*.{test,spec}.ts"], + exclude: ["node_modules", "dist", "dist-test"], + + // CHANGE: Coverage with 100% threshold for CORE (same as Jest) + // WHY: CORE must maintain mathematical guarantees via complete coverage + // INVARIANT: coverage_vitest ≥ coverage_jest ∧ ∀ f ∈ CORE: coverage(f) = 100% + coverage: { + provider: "v8", // Faster than babel (istanbul), native V8 coverage + reporter: ["text", "json", "html"], + include: ["src/**/*.ts"], + exclude: [ + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/__tests__/**", + "scripts/**/*.ts" + ], + // CHANGE: Maintain exact same thresholds as Jest + // WHY: Enforce 100% coverage for CORE, 10% minimum for SHELL + // INVARIANT: ∀ f ∈ src/core/**/*.ts: all_metrics(f) = 100% + // NOTE: Vitest v8 provider collects coverage for all matched files by default + thresholds: { + "src/core/**/*.ts": { + branches: 100, + functions: 100, + lines: 100, + statements: 100 + }, + global: { + branches: 10, + functions: 10, + lines: 10, + statements: 10 + } + } + }, + + // CHANGE: Faster test execution via thread pooling + // WHY: Vitest uses worker threads by default (faster than Jest's processes) + // COMPLEXITY: O(n/k) where n = tests, k = worker_count + // NOTE: Vitest runs tests in parallel by default, no additional config needed + + // CHANGE: Clear mocks between tests (Jest equivalence) + // WHY: Prevent test contamination, ensure test independence + // INVARIANT: ∀ test_i, test_j: independent(test_i, test_j) ⇒ no_shared_state + clearMocks: true, + mockReset: true, + restoreMocks: true + // CHANGE: Disable globals to enforce explicit imports + // WHY: Type safety, explicit dependencies, functional purity + // NOTE: Tests must import { describe, it, expect } from "vitest" + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "src") + } + } +}) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 773eb65d..01e0510b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,3 +3,4 @@ packages: - packages/app - packages/docker-git-session-sync - packages/lib + - packages/terminal diff --git a/scripts/e2e/_lib.sh b/scripts/e2e/_lib.sh index 4cfa6316..6bd3a7ba 100644 --- a/scripts/e2e/_lib.sh +++ b/scripts/e2e/_lib.sh @@ -157,15 +157,17 @@ dg_log_duration() { # The reuse fast path assumes Bun installed the current workspace layout: # root node_modules plus Vite/TypeScript bins for packages/app, packages/lib, -# and packages/docker-git-session-sync. If package names, locations, or the -# package manager change, this check should fail closed and print the missing -# path so CI falls back to a fresh install instead of silently using stale deps. +# packages/docker-git-session-sync, and packages/terminal. If package names, +# locations, or the package manager change, this check should fail closed and +# print the missing path so CI falls back to a fresh install instead of silently +# using stale deps. dg_workspace_install_ready() { local repo_root="$1" local required_bins=( "$repo_root/packages/app/node_modules/.bin/vite" "$repo_root/packages/lib/node_modules/.bin/tsc" "$repo_root/packages/docker-git-session-sync/node_modules/.bin/vite" + "$repo_root/packages/terminal/node_modules/.bin/tsc" ) local bin