diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml new file mode 100644 index 00000000..43e5fda9 --- /dev/null +++ b/.github/workflows/desktop-release.yml @@ -0,0 +1,305 @@ +name: Desktop Release + +on: + workflow_dispatch: + inputs: + tag: + description: "Release tag to create or update (defaults to v)" + required: false + type: string + release_name: + description: 'Release name (defaults to "CloudCLI Desktop ")' + required: false + type: string + prerelease: + description: "Mark the GitHub release as a prerelease" + required: true + default: false + type: boolean + +jobs: + resolve-release: + name: Resolve release metadata + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + tag: ${{ steps.release.outputs.tag }} + release_name: ${{ steps.release.outputs.release_name }} + server_bundle_tag: ${{ steps.release.outputs.server_bundle_tag }} + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false + + - name: Resolve release metadata + id: release + env: + TAG_INPUT: ${{ inputs.tag }} + RELEASE_NAME_INPUT: ${{ inputs.release_name }} + run: | + VERSION="$(node -p "require('./package.json').version")" + TAG="$TAG_INPUT" + if [ -z "$TAG" ]; then + TAG="v${VERSION}" + fi + TAG="$(printf '%s' "$TAG" | tr -d '\r\n' | sed 's/[^A-Za-z0-9._-]/-/g')" + if [ -z "$TAG" ]; then + echo "Resolved release tag is empty after normalization." >&2 + exit 1 + fi + + RELEASE_NAME="$RELEASE_NAME_INPUT" + if [ -z "$RELEASE_NAME" ]; then + RELEASE_NAME="CloudCLI Desktop ${TAG}" + fi + RELEASE_NAME_DELIMITER="release_name_${GITHUB_RUN_ID}_${GITHUB_RUN_ATTEMPT}" + + { + echo "tag=$TAG" + echo "release_name<<$RELEASE_NAME_DELIMITER" + printf '%s\n' "$RELEASE_NAME" + echo "$RELEASE_NAME_DELIMITER" + echo "server_bundle_tag=cloudcli-local-server-${TAG}" + } >> "$GITHUB_OUTPUT" + + build-macos: + name: Build signed macOS desktop app + needs: resolve-release + runs-on: macos-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Typecheck + run: npm run typecheck + + - name: Configure release server bundle source + env: + SERVER_BUNDLE_TAG: ${{ needs.resolve-release.outputs.server_bundle_tag }} + run: printf '{"releaseTag":"%s"}\n' "$SERVER_BUNDLE_TAG" > electron/server-bundle-config.json + + - name: Verify macOS signing secrets are configured + run: | + test -n "$CSC_LINK" + test -n "$CSC_KEY_PASSWORD" + test -n "$APPLE_ID" + test -n "$APPLE_APP_SPECIFIC_PASSWORD" + test -n "$APPLE_TEAM_ID" + env: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + - name: Build signed and notarized macOS artifacts + run: npm run desktop:dist:mac -- --publish never + env: + CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1" + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + + - name: Build macOS local server bundle + run: node scripts/release/build-server-bundle.js + + - name: Stage macOS release assets + run: | + mkdir -p desktop-release-assets server-release-assets + test -n "$(find release/desktop -maxdepth 1 -name '*.dmg' -print -quit)" + shasum -a 256 release/desktop/*.dmg > desktop-release-assets/SHASUMS256-macos.txt + cp release/desktop/*.dmg desktop-release-assets/ + + test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz' -print -quit)" + test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz.sha256' -print -quit)" + cp release/local-server/* server-release-assets/ + + - name: Upload macOS desktop assets + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: desktop-release-macos + path: desktop-release-assets/* + if-no-files-found: error + + - name: Upload macOS server assets + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: server-release-macos + path: server-release-assets/* + if-no-files-found: error + + build-windows: + name: Build Windows desktop app + needs: resolve-release + runs-on: windows-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + env: + GITHUB_TOKEN: ${{ github.token }} + + - name: Typecheck + run: npm run typecheck + + - name: Configure release server bundle source + shell: bash + env: + SERVER_BUNDLE_TAG: ${{ needs.resolve-release.outputs.server_bundle_tag }} + run: printf '{"releaseTag":"%s"}\n' "$SERVER_BUNDLE_TAG" > electron/server-bundle-config.json + + - name: Check Windows signing secrets + id: windows-signing + shell: bash + env: + WINDOWS_CSC_LINK: ${{ secrets.WINDOWS_CSC_LINK }} + WINDOWS_CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CSC_KEY_PASSWORD }} + run: | + if [ -n "$WINDOWS_CSC_LINK" ] && [ -n "$WINDOWS_CSC_KEY_PASSWORD" ]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + else + echo "enabled=false" >> "$GITHUB_OUTPUT" + fi + + - name: Build signed Windows artifacts + if: steps.windows-signing.outputs.enabled == 'true' + run: npm run desktop:dist:win -- --publish never + env: + CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1" + CSC_LINK: ${{ secrets.WINDOWS_CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CSC_KEY_PASSWORD }} + + - name: Build unsigned Windows artifacts + if: steps.windows-signing.outputs.enabled != 'true' + run: npm run desktop:dist:win -- --publish never + env: + CLOUDCLI_SEMANTICS_BUILD_REQUIRED: "1" + CSC_IDENTITY_AUTO_DISCOVERY: "false" + + - name: Build Windows local server bundle + run: node scripts/release/build-server-bundle.js + + - name: Stage Windows release assets + shell: bash + run: | + mkdir -p desktop-release-assets server-release-assets + test -n "$(find release/desktop -maxdepth 1 -name '*.exe' -print -quit)" + sha256sum release/desktop/*.exe > desktop-release-assets/SHASUMS256-windows.txt + cp release/desktop/*.exe desktop-release-assets/ + + test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz' -print -quit)" + test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz.sha256' -print -quit)" + cp release/local-server/* server-release-assets/ + + - name: Upload Windows desktop assets + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: desktop-release-windows + path: desktop-release-assets/* + if-no-files-found: error + + - name: Upload Windows server assets + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: server-release-windows + path: server-release-assets/* + if-no-files-found: error + + publish: + name: Publish desktop release + needs: + - resolve-release + - build-macos + - build-windows + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download desktop assets + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + pattern: desktop-release-* + path: release/desktop + merge-multiple: true + + - name: Download server assets + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6 + with: + pattern: server-release-* + path: release/local-server + merge-multiple: true + + - name: Verify release assets + run: | + test -n "$(find release/desktop -maxdepth 1 -name '*.dmg' -print -quit)" + test -n "$(find release/desktop -maxdepth 1 -name '*.exe' -print -quit)" + test -f release/desktop/SHASUMS256-macos.txt + test -f release/desktop/SHASUMS256-windows.txt + test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz' -print -quit)" + test -n "$(find release/local-server -maxdepth 1 -name 'cloudcli-local-server-*.tar.gz.sha256' -print -quit)" + find release -maxdepth 2 -type f -print | sort + + - name: Publish local server runtime assets + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 + with: + tag_name: ${{ needs.resolve-release.outputs.server_bundle_tag }} + target_commitish: ${{ github.sha }} + name: CloudCLI Local Server Runtime (${{ needs.resolve-release.outputs.tag }}) + body: | + This prerelease contains the Local mode runtime for CloudCLI Desktop. + + Download CloudCLI Desktop from the main ${{ needs.resolve-release.outputs.tag }} release. When you open Local CloudCLI, the desktop app automatically downloads the matching runtime from this prerelease. + + You do not need to download these runtime files manually. + prerelease: true + fail_on_unmatched_files: false + overwrite_files: true + files: | + release/local-server/* + + - name: Publish GitHub release assets + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 + with: + tag_name: ${{ needs.resolve-release.outputs.tag }} + target_commitish: ${{ github.sha }} + name: ${{ needs.resolve-release.outputs.release_name }} + body: | + Download the CloudCLI Desktop installer for your platform. + + The local server runtime used by local mode is installed automatically by the desktop app. You do not need to download any server bundle manually. + prerelease: ${{ inputs.prerelease }} + fail_on_unmatched_files: false + overwrite_files: true + files: | + release/desktop/* diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d33fd337..a1253ca9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,9 +4,9 @@ on: workflow_dispatch: inputs: increment: - description: 'Version bump: patch, minor, major, or explicit (e.g. 1.27.0)' + description: "Version bump: patch, minor, major, or explicit (e.g. 1.27.0)" required: true - default: 'patch' + default: "patch" type: string release_name: description: 'Custom release name (optional, defaults to "CloudCLI UI vX.Y.Z")' @@ -124,6 +124,9 @@ jobs: path: server/modules/computer-use/semantics/bin merge-multiple: true + - name: Restore semantic helper permissions + run: find server/modules/computer-use/semantics/bin -path '*/darwin-*/CloudCLISemantics' -type f -exec chmod 755 {} + + - name: Verify bundled semantic helpers run: | test -x server/modules/computer-use/semantics/bin/darwin-arm64/CloudCLISemantics diff --git a/CHANGELOG.md b/CHANGELOG.md index 10a218ef..f0f59e84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,59 @@ All notable changes to CloudCLI UI will be documented in this file. +## [1.35.0](https://github.com/siteboon/claudecodeui/compare/v1.34.0...v1.35.0) (2026-06-29) + +### New Features + +* add Electron desktop app ([97c9b67](https://github.com/siteboon/claudecodeui/commit/97c9b67bfc2d803560cd1559a4e79eea9731c7b5)) +* **chat:** derive activity indicator from per-session state and unify provider lifecycle events ([afc717e](https://github.com/siteboon/claudecodeui/commit/afc717e69e67f53173c30d2230722236f9180d39)) +* **chat:** unify session gateway with stable IDs and a single WS protocol ([f5eac2e](https://github.com/siteboon/claudecodeui/commit/f5eac2ec12c8575bf80202fafe807d9e04720105)) +* **i18n:** add French (fr) locale ([#878](https://github.com/siteboon/claudecodeui/issues/878)) ([f319d2c](https://github.com/siteboon/claudecodeui/commit/f319d2cf8d61452deaf6adf345494dd3e6898284)) +* play sound for pending tool requests ([#918](https://github.com/siteboon/claudecodeui/issues/918)) ([c947eaa](https://github.com/siteboon/claudecodeui/commit/c947eaaee5fbc959563efb917f4ec7c88847dd6b)) +* render changelog as markdown in version upgrade modal ([6a53c31](https://github.com/siteboon/claudecodeui/commit/6a53c31e907fffa79320997c27f99660c946b4a6)) +* **sidebar:** improve running session state tracking ([591b18e](https://github.com/siteboon/claudecodeui/commit/591b18e9e343fda23affe100a53911f76aaa8f57)) +* **skills:** add provider skill management ([#909](https://github.com/siteboon/claudecodeui/issues/909)) ([c5fe127](https://github.com/siteboon/claudecodeui/commit/c5fe127958d830eee19d008d8634c0e7d77fe1b9)) +* **version:** warn when the server was updated but not restarted ([#898](https://github.com/siteboon/claudecodeui/issues/898)) ([f6326c8](https://github.com/siteboon/claudecodeui/commit/f6326c8082dfbe8a65dcdb836d3e71c635594c26)) + +### Bug Fixes + +* changes provider logos to svg for fast load ([7bed675](https://github.com/siteboon/claudecodeui/commit/7bed675ad5fd1ecf7912d1a04afe9db5b1032823)) +* **chat:** prevent chat interface crash on malformed AskUserQuestion payload ([#920](https://github.com/siteboon/claudecodeui/issues/920)) ([ed4ae31](https://github.com/siteboon/claudecodeui/commit/ed4ae3114aafc1d4ecb0b621eaf9d3b26dbca5b1)) +* **chat:** prevent normalizeInlineCodeFences from breaking adjacent fenced code blocks ([#903](https://github.com/siteboon/claudecodeui/issues/903)) ([4712431](https://github.com/siteboon/claudecodeui/commit/4712431be81718dfb559ef43d7d7d5315bf4e01a)) +* **chat:** sort messages appropriately ([123ae31](https://github.com/siteboon/claudecodeui/commit/123ae310207fe5969c3b313f62b9dee27e5d7489)) +* **claude-sync:** skip subagent transcripts to prevent main session corruption ([#854](https://github.com/siteboon/claudecodeui/issues/854)) ([a12ca8e](https://github.com/siteboon/claudecodeui/commit/a12ca8eed373ef56cd37fbdd097845eaab34dee9)) +* correct notification session id ([881e72d](https://github.com/siteboon/claudecodeui/commit/881e72d4a00ec9c1a5e1ae4799bffa900f27c1f8)) +* create one unified function for frontend session processing ([677d330](https://github.com/siteboon/claudecodeui/commit/677d330981ef29a856f09e62b9f69bac0fa580d4)) +* **i18n:** add missing sidebar message keys to all locales ([#896](https://github.com/siteboon/claudecodeui/issues/896)) ([7ca3556](https://github.com/siteboon/claudecodeui/commit/7ca355651f0a805965bc27af3d75def626c5fb96)) +* keep running-session polling active ([39b0473](https://github.com/siteboon/claudecodeui/commit/39b0473e38201c29ff1e5388946452d2eed44527)) +* normalize project session payloads ([d0adddb](https://github.com/siteboon/claudecodeui/commit/d0adddbbdafecfd5713a8ac5b95c87a8f7fc54f8)) +* **opencode:** bind watcher sessions to app rows early ([5b9adbb](https://github.com/siteboon/claudecodeui/commit/5b9adbbdee8561439a27ad90744388225823427b)) +* **opencode:** pass workspace dir explicitly ([416a737](https://github.com/siteboon/claudecodeui/commit/416a737d76e654d2fc649206c2b921a7db150775)) +* recover pending permission requests ([56b2e14](https://github.com/siteboon/claudecodeui/commit/56b2e1405967c50301d0c773567349763edc8560)) +* remove provider specific token usage calculator ([2abb456](https://github.com/siteboon/claudecodeui/commit/2abb45636b5e1109733cfa58c8ab92fd4c812165)) +* resolve session provider on backend reads ([9fb2d91](https://github.com/siteboon/claudecodeui/commit/9fb2d91b26bef9579337d953a29718802c466fed)) +* **sessions:** canonicalize sidebar ids and timestamps ([3bbb42c](https://github.com/siteboon/claudecodeui/commit/3bbb42c23324c3cbb5587f2bcab09b1dc23086a8)) +* **shell:** prioritize user npm binaries ([#913](https://github.com/siteboon/claudecodeui/issues/913)) ([4a503b1](https://github.com/siteboon/claudecodeui/commit/4a503b1dc87ff58821670c8bfb1d8a8c1dab2bcf)) +* **shell:** use correct session id ([89f0524](https://github.com/siteboon/claudecodeui/commit/89f05247eddec4fe53bd1616c6a5563e3ae2427a)) +* **sidebar:** align session status controls across layouts ([1b336e9](https://github.com/siteboon/claudecodeui/commit/1b336e9aa9d2cccf0676d852815d9ba613ac04d2)) +* upgrade gemini logo ([9cb2afd](https://github.com/siteboon/claudecodeui/commit/9cb2afd67eb25a4f869b88abcf86f7748b2b6d71)) +* voice tts format settings ([#919](https://github.com/siteboon/claudecodeui/issues/919)) ([591e8e7](https://github.com/siteboon/claudecodeui/commit/591e8e7642589b0584f9b29b46b881aaab54624e)) + +### Documentation + +* update available plugin readmes ([f549bd9](https://github.com/siteboon/claudecodeui/commit/f549bd99e7106362a27cf4ccee6e9d434b8b5363)) +* update session activity guard comment ([e23e6af](https://github.com/siteboon/claudecodeui/commit/e23e6af06a44cc4b016df5778984602d49e52629)) + +### Maintenance + +* add github issues board plugin ([21b0f14](https://github.com/siteboon/claudecodeui/commit/21b0f14e7a86f257c65484742c43b9f85152b32c)) +* add more plugins list ([bc34085](https://github.com/siteboon/claudecodeui/commit/bc34085af9912da8d8592881a5845cff84a53f7d)) +* move tests to appropriate folder ([d7a38a5](https://github.com/siteboon/claudecodeui/commit/d7a38a567a5e9039935353a886310b3c32b25a79)) +* move tests to appropriate folder ([c6c153e](https://github.com/siteboon/claudecodeui/commit/c6c153e7f2a60572b08d687b59f010b4ad4f5d72)) +* remove a log ([00e526b](https://github.com/siteboon/claudecodeui/commit/00e526b6e90ee0baf09ebf48873bc10824ab80ba)) +* remove unused modelConstants from the project ([92de0ed](https://github.com/siteboon/claudecodeui/commit/92de0ed6137bf4571056deb3b930cc9fd22e2a08)) +* upgrade gemini models ([3d94821](https://github.com/siteboon/claudecodeui/commit/3d948217ef3084e764171ebc5dda55f663150b2c)) + ## [](https://github.com/siteboon/claudecodeui/compare/v1.33.3...vnull) (2026-06-09) ### New Features diff --git a/index.html b/index.html index c476f13e..37d2217d 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - + CloudCLI UI diff --git a/package-lock.json b/package-lock.json index af223460..516badd3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cloudcli-ai/cloudcli", - "version": "1.34.0", + "version": "1.35.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cloudcli-ai/cloudcli", - "version": "1.34.0", + "version": "1.35.0", "hasInstallScript": true, "license": "AGPL-3.0-or-later", "dependencies": { diff --git a/package.json b/package.json index 388d1323..2245ef02 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cloudcli-ai/cloudcli", - "version": "1.34.0", + "version": "1.35.0", "productName": "CloudCLI", "description": "A web-based UI for Claude Code CLI", "type": "module", diff --git a/src/components/shell/constants/constants.ts b/src/components/shell/constants/constants.ts index 49dffd50..6880b68c 100644 --- a/src/components/shell/constants/constants.ts +++ b/src/components/shell/constants/constants.ts @@ -1,6 +1,5 @@ import type { ITerminalOptions } from '@xterm/xterm'; -export const CODEX_DEVICE_AUTH_URL = 'https://auth.openai.com/codex/device'; export const SHELL_RESTART_DELAY_MS = 200; export const TERMINAL_INIT_DELAY_MS = 100; export const TERMINAL_RESIZE_DELAY_MS = 50; diff --git a/src/components/shell/hooks/useShellConnection.ts b/src/components/shell/hooks/useShellConnection.ts index 918ed76c..f88372c0 100644 --- a/src/components/shell/hooks/useShellConnection.ts +++ b/src/components/shell/hooks/useShellConnection.ts @@ -24,7 +24,6 @@ type UseShellConnectionOptions = { autoConnect: boolean; closeSocket: () => void; clearTerminalScreen: () => void; - setAuthUrl: (nextAuthUrl: string) => void; onOutputRef?: MutableRefObject<(() => void) | null>; }; @@ -49,7 +48,6 @@ export function useShellConnection({ autoConnect, closeSocket, clearTerminalScreen, - setAuthUrl, onOutputRef, }: UseShellConnectionOptions): UseShellConnectionResult { const [isConnected, setIsConnected] = useState(false); @@ -100,14 +98,8 @@ export function useShellConnection({ return; } - if (message.type === 'auth_url' || message.type === 'url_open') { - const nextAuthUrl = typeof message.url === 'string' ? message.url : ''; - if (nextAuthUrl) { - setAuthUrl(nextAuthUrl); - } - } }, - [handleProcessCompletion, onOutputRef, setAuthUrl, terminalRef], + [handleProcessCompletion, onOutputRef, terminalRef], ); const connectWebSocket = useCallback( @@ -133,7 +125,6 @@ export function useShellConnection({ setIsConnected(true); setIsConnecting(false); connectingRef.current = false; - setAuthUrl(''); window.setTimeout(() => { const currentTerminal = terminalRef.current; @@ -196,7 +187,6 @@ export function useShellConnection({ isPlainShellRef, selectedProjectRef, selectedSessionRef, - setAuthUrl, terminalRef, wsRef, ], @@ -225,8 +215,7 @@ export function useShellConnection({ setIsConnecting(false); connectingRef.current = false; forceRestartOnInitRef.current = false; - setAuthUrl(''); - }, [clearTerminalScreen, closeSocket, setAuthUrl]); + }, [clearTerminalScreen, closeSocket]); useEffect(() => { if ( diff --git a/src/components/shell/hooks/useShellRuntime.ts b/src/components/shell/hooks/useShellRuntime.ts index 2176d109..21054dca 100644 --- a/src/components/shell/hooks/useShellRuntime.ts +++ b/src/components/shell/hooks/useShellRuntime.ts @@ -1,8 +1,9 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import type { FitAddon } from '@xterm/addon-fit'; import type { Terminal } from '@xterm/xterm'; + import type { UseShellRuntimeOptions, UseShellRuntimeResult } from '../types/types'; -import { copyTextToClipboard } from '../../../utils/clipboard'; + import { useShellConnection } from './useShellConnection'; import { useShellTerminal } from './useShellTerminal'; @@ -22,15 +23,11 @@ export function useShellRuntime({ const fitAddonRef = useRef(null); const wsRef = useRef(null); - const [authUrl, setAuthUrl] = useState(''); - const [authUrlVersion, setAuthUrlVersion] = useState(0); - const selectedProjectRef = useRef(selectedProject); const selectedSessionRef = useRef(selectedSession); const initialCommandRef = useRef(initialCommand); const isPlainShellRef = useRef(isPlainShell); const onProcessCompleteRef = useRef(onProcessComplete); - const authUrlRef = useRef(''); const lastSessionIdRef = useRef(selectedSession?.id ?? null); // Keep mutable values in refs so websocket handlers always read current data. @@ -42,12 +39,6 @@ export function useShellRuntime({ onProcessCompleteRef.current = onProcessComplete; }, [selectedProject, selectedSession, initialCommand, isPlainShell, onProcessComplete]); - const setCurrentAuthUrl = useCallback((nextAuthUrl: string) => { - authUrlRef.current = nextAuthUrl; - setAuthUrl(nextAuthUrl); - setAuthUrlVersion((previous) => previous + 1); - }, []); - const closeSocket = useCallback(() => { const activeSocket = wsRef.current; if (!activeSocket) { @@ -64,32 +55,6 @@ export function useShellRuntime({ wsRef.current = null; }, []); - const openAuthUrlInBrowser = useCallback((url = authUrlRef.current) => { - if (!url) { - return false; - } - - const popup = window.open(url, '_blank'); - if (popup) { - try { - popup.opener = null; - } catch { - // Ignore cross-origin restrictions when trying to null opener. - } - return true; - } - - return false; - }, []); - - const copyAuthUrlToClipboard = useCallback(async (url = authUrlRef.current) => { - if (!url) { - return false; - } - - return copyTextToClipboard(url); - }, []); - const { isInitialized, clearTerminalScreen, disposeTerminal } = useShellTerminal({ terminalContainerRef, terminalRef, @@ -98,10 +63,6 @@ export function useShellRuntime({ selectedProject, minimal, isRestarting, - initialCommandRef, - isPlainShellRef, - authUrlRef, - copyAuthUrlToClipboard, closeSocket, }); @@ -118,7 +79,6 @@ export function useShellRuntime({ autoConnect, closeSocket, clearTerminalScreen, - setAuthUrl: setCurrentAuthUrl, onOutputRef, }); @@ -156,11 +116,7 @@ export function useShellRuntime({ isConnected, isInitialized, isConnecting, - authUrl, - authUrlVersion, connectToShell, disconnectFromShell, - openAuthUrlInBrowser, - copyAuthUrlToClipboard, }; } diff --git a/src/components/shell/hooks/useShellTerminal.ts b/src/components/shell/hooks/useShellTerminal.ts index 011e7ee0..076c5b76 100644 --- a/src/components/shell/hooks/useShellTerminal.ts +++ b/src/components/shell/hooks/useShellTerminal.ts @@ -4,15 +4,18 @@ import { FitAddon } from '@xterm/addon-fit'; import { WebLinksAddon } from '@xterm/addon-web-links'; import { WebglAddon } from '@xterm/addon-webgl'; import { Terminal } from '@xterm/xterm'; + import type { Project } from '../../../types/app'; +import { copyTextToClipboard } from '../../../utils/clipboard'; import { - CODEX_DEVICE_AUTH_URL, TERMINAL_INIT_DELAY_MS, TERMINAL_OPTIONS, TERMINAL_RESIZE_DELAY_MS, } from '../constants/constants'; -import { copyTextToClipboard } from '../../../utils/clipboard'; -import { isCodexLoginCommand } from '../utils/auth'; +import { + installMobileTerminalSelection, + type MobileTerminalSelectionManager, +} from '../utils/mobileTerminalSelection'; import { sendSocketMessage } from '../utils/socket'; import { ensureXtermFocusStyles } from '../utils/terminalStyles'; @@ -24,10 +27,6 @@ type UseShellTerminalOptions = { selectedProject: Project | null | undefined; minimal: boolean; isRestarting: boolean; - initialCommandRef: MutableRefObject; - isPlainShellRef: MutableRefObject; - authUrlRef: MutableRefObject; - copyAuthUrlToClipboard: (url?: string) => Promise; closeSocket: () => void; }; @@ -45,14 +44,11 @@ export function useShellTerminal({ selectedProject, minimal, isRestarting, - initialCommandRef, - isPlainShellRef, - authUrlRef, - copyAuthUrlToClipboard, closeSocket, }: UseShellTerminalOptions): UseShellTerminalResult { const [isInitialized, setIsInitialized] = useState(false); const resizeTimeoutRef = useRef(null); + const mobileSelectionRef = useRef(null); const selectedProjectKey = selectedProject?.fullPath || selectedProject?.path || ''; const hasSelectedProject = Boolean(selectedProject); @@ -70,6 +66,11 @@ export function useShellTerminal({ }, [terminalRef]); const disposeTerminal = useCallback(() => { + if (mobileSelectionRef.current) { + mobileSelectionRef.current.dispose(); + mobileSelectionRef.current = null; + } + if (terminalRef.current) { terminalRef.current.dispose(); terminalRef.current = null; @@ -80,7 +81,8 @@ export function useShellTerminal({ }, [fitAddonRef, terminalRef]); useEffect(() => { - if (!terminalContainerRef.current || !hasSelectedProject || isRestarting || terminalRef.current) { + const terminalContainer = terminalContainerRef.current; + if (!terminalContainer || !hasSelectedProject || isRestarting || terminalRef.current) { return; } @@ -102,7 +104,28 @@ export function useShellTerminal({ console.warn('[Shell] WebGL renderer unavailable, using Canvas fallback'); } - nextTerminal.open(terminalContainerRef.current); + nextTerminal.open(terminalContainer); + mobileSelectionRef.current = installMobileTerminalSelection( + nextTerminal, + terminalContainer, + { + onFontSizeChange: (fontSize) => { + nextTerminal.options.fontSize = fontSize; + + const currentFitAddon = fitAddonRef.current; + if (currentFitAddon) { + currentFitAddon.fit(); + sendSocketMessage(wsRef.current, { + type: 'resize', + cols: nextTerminal.cols, + rows: nextTerminal.rows, + }); + } else { + nextTerminal.refresh(0, nextTerminal.rows - 1); + } + }, + }, + ); const copyTerminalSelection = async () => { const selection = nextTerminal.getSelection(); @@ -133,29 +156,9 @@ export function useShellTerminal({ void copyTextToClipboard(selection); }; - terminalContainerRef.current.addEventListener('copy', handleTerminalCopy); + terminalContainer.addEventListener('copy', handleTerminalCopy); nextTerminal.attachCustomKeyEventHandler((event) => { - const activeAuthUrl = isCodexLoginCommand(initialCommandRef.current) - ? CODEX_DEVICE_AUTH_URL - : authUrlRef.current; - - if ( - event.type === 'keydown' && - minimal && - isPlainShellRef.current && - activeAuthUrl && - !event.ctrlKey && - !event.metaKey && - !event.altKey && - event.key?.toLowerCase() === 'c' - ) { - event.preventDefault(); - event.stopPropagation(); - void copyAuthUrlToClipboard(activeAuthUrl); - return false; - } - if ( event.type === 'keydown' && (event.ctrlKey || event.metaKey) && @@ -240,10 +243,10 @@ export function useShellTerminal({ }, TERMINAL_RESIZE_DELAY_MS); }); - resizeObserver.observe(terminalContainerRef.current); + resizeObserver.observe(terminalContainer); return () => { - terminalContainerRef.current?.removeEventListener('copy', handleTerminalCopy); + terminalContainer.removeEventListener('copy', handleTerminalCopy); resizeObserver.disconnect(); if (resizeTimeoutRef.current !== null) { window.clearTimeout(resizeTimeoutRef.current); @@ -254,16 +257,12 @@ export function useShellTerminal({ disposeTerminal(); }; }, [ - authUrlRef, closeSocket, - copyAuthUrlToClipboard, disposeTerminal, fitAddonRef, - initialCommandRef, - isPlainShellRef, isRestarting, - minimal, hasSelectedProject, + minimal, selectedProjectKey, terminalContainerRef, terminalRef, diff --git a/src/components/shell/types/types.ts b/src/components/shell/types/types.ts index 72a19785..a164e9cd 100644 --- a/src/components/shell/types/types.ts +++ b/src/components/shell/types/types.ts @@ -4,8 +4,6 @@ import type { Terminal } from '@xterm/xterm'; import type { Project, ProjectSession } from '../../../types/app'; -export type AuthCopyStatus = 'idle' | 'copied' | 'failed'; - export type ShellInitMessage = { type: 'init'; projectPath: string; @@ -54,7 +52,6 @@ export type ShellSharedRefs = { wsRef: MutableRefObject; terminalRef: MutableRefObject; fitAddonRef: MutableRefObject; - authUrlRef: MutableRefObject; selectedProjectRef: MutableRefObject; selectedSessionRef: MutableRefObject; initialCommandRef: MutableRefObject; @@ -69,10 +66,6 @@ export type UseShellRuntimeResult = { isConnected: boolean; isInitialized: boolean; isConnecting: boolean; - authUrl: string; - authUrlVersion: number; connectToShell: (options?: { forceRestart?: boolean }) => void; disconnectFromShell: (options?: { suppressAutoConnect?: boolean }) => void; - openAuthUrlInBrowser: (url?: string) => boolean; - copyAuthUrlToClipboard: (url?: string) => Promise; }; diff --git a/src/components/shell/utils/auth.ts b/src/components/shell/utils/auth.ts index d55041b1..fde9d4bc 100644 --- a/src/components/shell/utils/auth.ts +++ b/src/components/shell/utils/auth.ts @@ -1,17 +1,4 @@ import type { ProjectSession } from '../../../types/app'; -import { CODEX_DEVICE_AUTH_URL } from '../constants/constants'; - -export function isCodexLoginCommand(command: string | null | undefined): boolean { - return typeof command === 'string' && /\bcodex\s+login\b/i.test(command); -} - -export function resolveAuthUrlForDisplay(command: string | null | undefined, authUrl: string): string { - if (isCodexLoginCommand(command)) { - return CODEX_DEVICE_AUTH_URL; - } - - return authUrl; -} export function getSessionDisplayName(session: ProjectSession | null | undefined): string | null { if (!session) { @@ -21,4 +8,4 @@ export function getSessionDisplayName(session: ProjectSession | null | undefined return session.__provider === 'cursor' ? session.name || 'Untitled Session' : session.summary || 'New Session'; -} \ No newline at end of file +} diff --git a/src/components/shell/utils/mobileTerminalSelection.ts b/src/components/shell/utils/mobileTerminalSelection.ts new file mode 100644 index 00000000..ef20e066 --- /dev/null +++ b/src/components/shell/utils/mobileTerminalSelection.ts @@ -0,0 +1,1068 @@ +import type { IDisposable, Terminal } from '@xterm/xterm'; + +import { copyTextToClipboard } from '../../../utils/clipboard'; + +type TerminalCoords = { + col: number; + row: number; +}; + +type TouchCoords = { + clientX: number; + clientY: number; +}; + +type CellDimensions = { + width: number; + height: number; +}; + +type DragHandle = 'start' | 'end'; + +type TerminalWithRenderService = Terminal & { + _core?: { + _renderService?: { + dimensions?: { + css?: { + cell?: { + width?: number; + height?: number; + }; + }; + }; + }; + }; +}; + +export type MobileTerminalSelectionManager = { + dispose: () => void; + updateHandles: () => void; +}; + +const LONG_PRESS_MS = 600; +const MOVE_THRESHOLD_PX = 8; +const HANDLE_SIZE_PX = 22; +const FINGER_OFFSET_PX = 40; +const CONTEXT_MENU_GAP_PX = 12; +const CONTEXT_MENU_EDGE_PADDING_PX = 8; +const ZOOM_THROTTLE_MS = 50; +const DEFAULT_MIN_FONT_SIZE = 8; +const DEFAULT_MAX_FONT_SIZE = 48; +// xterm scrolls the viewport 1:1 with the finger and never coasts, so we add +// our own inertial (fling) scrolling: track finger velocity during the drag and +// keep scrolling with friction after release. +const SCROLL_INERTIA_FRICTION = 0.95; // velocity multiplier per ~16ms frame +const SCROLL_INERTIA_MIN_VELOCITY = 0.02; // px/ms below which coasting stops +const SCROLL_INERTIA_MAX_VELOCITY = 5; // px/ms cap to avoid runaway flings +const SCROLL_INERTIA_MAX_IDLE_MS = 90; // ignore a flick if the finger paused before lifting + +type ContextMenuItem = { + label: string; + action: () => void; +}; + +export type MobileTerminalSelectionOptions = { + minFontSize?: number; + maxFontSize?: number; + onFontSizeChange?: (fontSize: number) => void; +}; + +function isTouchSelectionEnvironment(): boolean { + if (typeof window === 'undefined' || typeof navigator === 'undefined') { + return false; + } + + return ( + navigator.maxTouchPoints > 0 || + 'ontouchstart' in window || + window.matchMedia?.('(pointer: coarse)').matches === true + ); +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function getDistance(start: TouchCoords, end: TouchCoords): number { + return Math.hypot(end.clientX - start.clientX, end.clientY - start.clientY); +} + +class ShellMobileSelectionCore implements MobileTerminalSelectionManager { + private readonly terminal: Terminal; + private readonly terminalContent: HTMLElement; + private readonly overlay: HTMLDivElement; + private readonly startHandle: HTMLDivElement; + private readonly endHandle: HTMLDivElement; + private readonly contextMenu: HTMLDivElement; + private readonly disposables: IDisposable[] = []; + private readonly originalPosition: string; + + private didSetPosition = false; + private isDestroyed = false; + private isSelecting = false; + private isHandleDragging = false; + private dragHandle: DragHandle | null = null; + private selectionStart: TerminalCoords | null = null; + private selectionEnd: TerminalCoords | null = null; + private touchStart: TouchCoords | null = null; + private pendingClearTouch: { point: TouchCoords; moved: boolean } | null = null; + private tapHoldTimeout: number | null = null; + private cellDimensions: CellDimensions = { width: 0, height: 0 }; + private isContextMenuVisible = false; + + private readonly minFontSize: number; + private readonly maxFontSize: number; + private readonly onFontSizeChange: (fontSize: number) => void; + private isPinching = false; + private pinchStartDistance = 0; + private initialFontSize = 0; + private lastZoomTime = 0; + + private viewportElement: HTMLElement | null = null; + private lastScrollTouchY: number | null = null; + private lastScrollTouchTime = 0; + private scrollVelocity = 0; + private inertiaFrame: number | null = null; + + constructor( + terminal: Terminal, + terminalContent: HTMLElement, + options: MobileTerminalSelectionOptions = {}, + ) { + this.terminal = terminal; + this.terminalContent = terminalContent; + this.originalPosition = terminalContent.style.position; + + const minFontSize = Number(options.minFontSize) || DEFAULT_MIN_FONT_SIZE; + const maxFontSize = Number(options.maxFontSize) || DEFAULT_MAX_FONT_SIZE; + this.minFontSize = Math.min(minFontSize, maxFontSize); + this.maxFontSize = Math.max(minFontSize, maxFontSize); + this.onFontSizeChange = + options.onFontSizeChange ?? + ((fontSize) => { + this.terminal.options.fontSize = fontSize; + this.terminal.refresh(0, this.terminal.rows - 1); + }); + + if (window.getComputedStyle(terminalContent).position === 'static') { + terminalContent.style.position = 'relative'; + this.didSetPosition = true; + } + + this.overlay = this.createSelectionOverlay(); + this.startHandle = this.createHandle('start'); + this.endHandle = this.createHandle('end'); + this.contextMenu = this.createContextMenu(); + this.overlay.append(this.startHandle, this.endHandle, this.contextMenu); + this.terminalContent.appendChild(this.overlay); + + this.attachEventListeners(); + this.updateCellDimensions(); + } + + private createSelectionOverlay(): HTMLDivElement { + const overlay = document.createElement('div'); + overlay.className = 'shell-mobile-selection-overlay'; + overlay.style.position = 'absolute'; + overlay.style.inset = '0'; + overlay.style.overflow = 'hidden'; + overlay.style.pointerEvents = 'none'; + overlay.style.zIndex = '30'; + return overlay; + } + + private createHandle(type: DragHandle): HTMLDivElement { + const handle = document.createElement('div'); + handle.className = `shell-mobile-selection-handle shell-mobile-selection-handle-${type}`; + handle.dataset.handleType = type; + handle.style.position = 'absolute'; + handle.style.width = `${HANDLE_SIZE_PX}px`; + handle.style.height = `${HANDLE_SIZE_PX}px`; + handle.style.borderRadius = '50%'; + handle.style.background = '#3b82f6'; + handle.style.border = '2px solid #fff'; + handle.style.boxShadow = '0 2px 8px rgba(0,0,0,0.3)'; + handle.style.display = 'none'; + handle.style.pointerEvents = 'auto'; + handle.style.touchAction = 'none'; + handle.style.zIndex = '31'; + return handle; + } + + private createContextMenu(): HTMLDivElement { + const menu = document.createElement('div'); + menu.className = 'shell-mobile-selection-menu'; + menu.style.position = 'absolute'; + menu.style.display = 'none'; + menu.style.alignItems = 'stretch'; + menu.style.padding = '4px'; + menu.style.gap = '2px'; + menu.style.background = '#1f2937'; + menu.style.border = '1px solid rgba(255,255,255,0.12)'; + menu.style.borderRadius = '10px'; + menu.style.boxShadow = '0 6px 20px rgba(0,0,0,0.4)'; + menu.style.pointerEvents = 'auto'; + menu.style.touchAction = 'none'; + menu.style.zIndex = '32'; + menu.style.whiteSpace = 'nowrap'; + menu.style.userSelect = 'none'; + + const items: ContextMenuItem[] = [ + { label: 'Copy', action: () => this.copySelection() }, + { label: 'Select All', action: () => this.selectAllText() }, + ]; + + for (const item of items) { + menu.appendChild(this.createContextMenuButton(item)); + } + + return menu; + } + + private createContextMenuButton(item: ContextMenuItem): HTMLButtonElement { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = item.label; + button.style.appearance = 'none'; + button.style.border = 'none'; + button.style.margin = '0'; + button.style.padding = '8px 14px'; + button.style.background = 'transparent'; + button.style.color = '#f9fafb'; + button.style.fontSize = '14px'; + button.style.fontFamily = 'inherit'; + button.style.lineHeight = '1'; + button.style.borderRadius = '6px'; + button.style.cursor = 'pointer'; + button.style.pointerEvents = 'auto'; + button.style.touchAction = 'none'; + + let actionExecuted = false; + const arm = (event: Event): void => { + event.preventDefault(); + event.stopPropagation(); + actionExecuted = false; + }; + const run = (event: Event): void => { + event.preventDefault(); + event.stopPropagation(); + if (actionExecuted) { + return; + } + actionExecuted = true; + item.action(); + }; + + button.addEventListener('touchstart', arm, { passive: false }); + button.addEventListener('touchend', run, { passive: false }); + button.addEventListener('mousedown', arm); + button.addEventListener('mouseup', run); + button.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + }); + + return button; + } + + private attachEventListeners(): void { + if (!this.terminal.element) { + return; + } + + this.terminal.element.addEventListener('touchstart', this.onTerminalTouchStart, { + passive: false, + }); + this.terminal.element.addEventListener('touchmove', this.onTerminalTouchMove, { + passive: false, + }); + this.terminal.element.addEventListener('touchend', this.onTerminalTouchEnd, { + passive: false, + }); + this.terminal.element.addEventListener('touchcancel', this.onTerminalTouchCancel, { + passive: false, + }); + + this.startHandle.addEventListener('touchstart', this.onHandleTouchStart, { passive: false }); + this.startHandle.addEventListener('touchmove', this.onHandleTouchMove, { passive: false }); + this.startHandle.addEventListener('touchend', this.onHandleTouchEnd, { passive: false }); + this.startHandle.addEventListener('touchcancel', this.onHandleTouchEnd, { passive: false }); + + this.endHandle.addEventListener('touchstart', this.onHandleTouchStart, { passive: false }); + this.endHandle.addEventListener('touchmove', this.onHandleTouchMove, { passive: false }); + this.endHandle.addEventListener('touchend', this.onHandleTouchEnd, { passive: false }); + this.endHandle.addEventListener('touchcancel', this.onHandleTouchEnd, { passive: false }); + + document.addEventListener('touchstart', this.onDocumentTouchStart, { passive: true }); + + this.disposables.push( + this.terminal.onSelectionChange(this.onSelectionChange), + this.terminal.onResize(this.onTerminalResize), + this.terminal.onScroll(this.onTerminalScroll), + ); + } + + private onTerminalTouchStart = (event: TouchEvent): void => { + this.cancelInertia(); + this.resetScrollTracking(); + + if (event.touches.length === 2) { + event.preventDefault(); + this.startPinchZoom(event); + return; + } + + if (event.touches.length !== 1) { + this.clearTapHoldTimeout(); + return; + } + + const touch = this.toTouchCoords(event.touches[0]); + this.touchStart = touch; + + if (this.isSelecting) { + this.pendingClearTouch = { point: touch, moved: false }; + return; + } + + this.clearTapHoldTimeout(); + this.tapHoldTimeout = window.setTimeout(() => { + this.tapHoldTimeout = null; + this.startSelection(touch); + }, LONG_PRESS_MS); + }; + + private onTerminalTouchMove = (event: TouchEvent): void => { + if (event.touches.length === 2 && this.isPinching) { + event.preventDefault(); + this.handlePinchZoom(event); + return; + } + + if (event.touches.length !== 1) { + this.clearTapHoldTimeout(); + return; + } + + if (this.isPinching) { + return; + } + + const touch = this.toTouchCoords(event.touches[0]); + const touchStart = this.touchStart; + + if (this.pendingClearTouch) { + this.pendingClearTouch.moved = + this.pendingClearTouch.moved || + getDistance(this.pendingClearTouch.point, touch) > MOVE_THRESHOLD_PX; + return; + } + + if (!touchStart) { + return; + } + + const moved = getDistance(touchStart, touch) > MOVE_THRESHOLD_PX; + if (moved) { + this.clearTapHoldTimeout(); + } + + if (this.isSelecting && !this.isHandleDragging) { + event.preventDefault(); + this.extendSelection(touch); + return; + } + + // Plain one-finger scrolling: xterm moves the viewport itself; we only + // record the finger velocity so we can add inertia when the touch ends. + this.recordScrollSample(touch); + }; + + private onTerminalTouchEnd = (event: TouchEvent): void => { + if (this.isPinching) { + this.endPinchZoom(); + return; + } + + this.clearTapHoldTimeout(); + this.touchStart = null; + + // A long-press selection (or a tap dismissing one) must not let the browser + // synthesize the mouse click that refocuses xterm's hidden textarea — that + // is what pops up the mobile keyboard. A plain tap leaves isSelecting false + // and falls through, so it still focuses the terminal and shows the keyboard. + if (this.isSelecting || this.isHandleDragging) { + event.preventDefault(); + this.blurTerminalInput(); + } + + if (!this.pendingClearTouch) { + this.maybeStartInertia(); + return; + } + + const shouldClear = this.isSelecting && !this.pendingClearTouch.moved && !this.isHandleDragging; + this.pendingClearTouch = null; + + if (shouldClear) { + this.clearSelection(); + } + }; + + private onTerminalTouchCancel = (): void => { + if (this.isPinching) { + this.endPinchZoom(); + } + + this.clearTapHoldTimeout(); + this.touchStart = null; + this.pendingClearTouch = null; + }; + + private onHandleTouchStart = (event: TouchEvent): void => { + event.preventDefault(); + event.stopPropagation(); + + if (event.touches.length !== 1) { + return; + } + + const target = event.currentTarget as HTMLElement; + this.dragHandle = target.dataset.handleType === 'start' ? 'start' : 'end'; + this.isHandleDragging = true; + this.pendingClearTouch = null; + }; + + private onHandleTouchMove = (event: TouchEvent): void => { + if (!this.isHandleDragging || !this.dragHandle || event.touches.length !== 1) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + const touch = this.toTouchCoords(event.touches[0]); + const adjustedTouch = { + clientX: touch.clientX, + clientY: touch.clientY - FINGER_OFFSET_PX, + }; + const coords = this.touchToTerminalCoords(adjustedTouch); + if (!coords) { + return; + } + + if (this.dragHandle === 'start') { + this.selectionStart = coords; + } else { + this.selectionEnd = coords; + } + + this.swapHandlesIfNeeded(); + this.updateSelection(); + }; + + private onHandleTouchEnd = (event: TouchEvent): void => { + if (!this.isHandleDragging) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + this.isHandleDragging = false; + this.dragHandle = null; + }; + + private onSelectionChange = (): void => { + if (!this.isSelecting) { + return; + } + + if (!this.terminal.hasSelection()) { + this.resetSelectionState(); + return; + } + + this.updateHandles(); + }; + + private onTerminalResize = (): void => { + this.updateCellDimensions(); + this.updateHandles(); + }; + + private onTerminalScroll = (): void => { + this.updateHandles(); + }; + + private onDocumentTouchStart = (event: TouchEvent): void => { + if (!this.isSelecting || !event.target) { + return; + } + + if (this.terminalContent.contains(event.target as Node)) { + return; + } + + this.clearSelection(); + }; + + private startSelection(touch: TouchCoords): void { + const coords = this.touchToTerminalCoords(touch); + if (!coords) { + return; + } + + const wordBounds = this.getWordBoundsAt(coords); + this.selectionStart = wordBounds?.start ?? coords; + this.selectionEnd = wordBounds?.end ?? coords; + this.isSelecting = true; + + // Dismiss the mobile keyboard if it was open: selecting text is not typing. + this.blurTerminalInput(); + + this.updateSelection(); + this.showHandles(); + this.showContextMenu(); + } + + private extendSelection(touch: TouchCoords): void { + const coords = this.touchToTerminalCoords(touch); + if (!coords) { + return; + } + + this.selectionEnd = coords; + this.updateSelection(); + } + + private updateSelection(): void { + if (!this.selectionStart || !this.selectionEnd) { + return; + } + + const { start, end } = this.getOrderedSelection(); + const length = this.calculateSelectionLength(start, end); + if (length <= 0) { + return; + } + + this.terminal.select(start.col, start.row, length); + this.updateHandles(); + } + + private calculateSelectionLength(start: TerminalCoords, end: TerminalCoords): number { + if (start.row === end.row) { + return end.col - start.col + 1; + } + + return (end.row - start.row) * this.terminal.cols - start.col + end.col + 1; + } + + private getOrderedSelection(): { start: TerminalCoords; end: TerminalCoords } { + const start = this.selectionStart; + const end = this.selectionEnd; + if (!start || !end) { + throw new Error('Cannot order empty terminal selection'); + } + + if (start.row < end.row || (start.row === end.row && start.col <= end.col)) { + return { start, end }; + } + + return { start: end, end: start }; + } + + private swapHandlesIfNeeded(): void { + if (!this.selectionStart || !this.selectionEnd || !this.dragHandle) { + return; + } + + const { start, end } = this.getOrderedSelection(); + if (start === this.selectionStart && end === this.selectionEnd) { + return; + } + + this.selectionStart = start; + this.selectionEnd = end; + this.dragHandle = this.dragHandle === 'start' ? 'end' : 'start'; + } + + private showHandles(): void { + this.startHandle.style.display = 'block'; + this.endHandle.style.display = 'block'; + this.updateHandles(); + } + + private hideHandles(): void { + this.startHandle.style.display = 'none'; + this.endHandle.style.display = 'none'; + } + + private showContextMenu(): void { + this.contextMenu.style.display = 'flex'; + this.isContextMenuVisible = true; + this.positionContextMenu(); + } + + private hideContextMenu(): void { + this.contextMenu.style.display = 'none'; + this.isContextMenuVisible = false; + } + + private positionContextMenu(): void { + if (!this.isContextMenuVisible) { + return; + } + + const containerRect = this.terminalContent.getBoundingClientRect(); + const menuWidth = this.contextMenu.offsetWidth || 0; + const menuHeight = this.contextMenu.offsetHeight || 0; + + const ordered = + this.selectionStart && this.selectionEnd ? this.getOrderedSelection() : null; + const startPosition = ordered ? this.terminalCoordsToPixels(ordered.start) : null; + const endPosition = ordered ? this.terminalCoordsToPixels(ordered.end) : null; + + let menuX: number; + let menuY: number; + + if (startPosition || endPosition) { + const topY = Math.min( + startPosition?.y ?? endPosition!.y, + endPosition?.y ?? startPosition!.y, + ); + const centerX = + startPosition && endPosition + ? (startPosition.x + endPosition.x) / 2 + : (startPosition ?? endPosition)!.x; + + menuX = centerX - menuWidth / 2; + menuY = topY - menuHeight - CONTEXT_MENU_GAP_PX; + + // Not enough room above the selection: drop below the handles instead. + if (menuY < CONTEXT_MENU_EDGE_PADDING_PX) { + const bottomY = Math.max( + startPosition?.y ?? endPosition!.y, + endPosition?.y ?? startPosition!.y, + ); + menuY = bottomY + this.cellDimensions.height + HANDLE_SIZE_PX + CONTEXT_MENU_GAP_PX; + } + } else { + // Whole-buffer selection (Select All): pin to the bottom center. + menuX = (containerRect.width - menuWidth) / 2; + menuY = containerRect.height - menuHeight - CONTEXT_MENU_GAP_PX; + } + + const maxX = containerRect.width - menuWidth - CONTEXT_MENU_EDGE_PADDING_PX; + const maxY = containerRect.height - menuHeight - CONTEXT_MENU_EDGE_PADDING_PX; + menuX = clamp(menuX, CONTEXT_MENU_EDGE_PADDING_PX, Math.max(CONTEXT_MENU_EDGE_PADDING_PX, maxX)); + menuY = clamp(menuY, CONTEXT_MENU_EDGE_PADDING_PX, Math.max(CONTEXT_MENU_EDGE_PADDING_PX, maxY)); + + this.contextMenu.style.left = `${menuX}px`; + this.contextMenu.style.top = `${menuY}px`; + } + + private copySelection(): void { + const selectionText = this.terminal.getSelection(); + if (selectionText) { + void copyTextToClipboard(selectionText); + } + this.clearSelection(); + } + + private selectAllText(): void { + this.terminal.selectAll(); + this.selectionStart = null; + this.selectionEnd = null; + this.isSelecting = true; + this.hideHandles(); + + if (this.terminal.hasSelection()) { + this.showContextMenu(); + } else { + this.clearSelection(); + } + } + + private startPinchZoom(event: TouchEvent): void { + if (event.touches.length !== 2) { + return; + } + + this.clearTapHoldTimeout(); + if (this.isSelecting) { + this.clearSelection(); + } + + this.isPinching = true; + this.initialFontSize = this.terminal.options.fontSize ?? DEFAULT_MIN_FONT_SIZE; + this.pinchStartDistance = this.getTouchDistance(event.touches[0], event.touches[1]); + this.lastZoomTime = 0; + } + + private handlePinchZoom(event: TouchEvent): void { + if (!this.isPinching || event.touches.length !== 2 || this.pinchStartDistance <= 0) { + return; + } + + const now = Date.now(); + if (now - this.lastZoomTime < ZOOM_THROTTLE_MS) { + return; + } + this.lastZoomTime = now; + + const currentDistance = this.getTouchDistance(event.touches[0], event.touches[1]); + const scale = currentDistance / this.pinchStartDistance; + const nextFontSize = clamp( + Math.round(this.initialFontSize * scale), + this.minFontSize, + this.maxFontSize, + ); + + if (nextFontSize !== this.terminal.options.fontSize) { + this.onFontSizeChange(nextFontSize); + } + } + + private endPinchZoom(): void { + this.isPinching = false; + this.pinchStartDistance = 0; + this.initialFontSize = 0; + } + + private getTouchDistance(first: Touch, second: Touch): number { + return Math.hypot(second.clientX - first.clientX, second.clientY - first.clientY); + } + + private getViewportElement(): HTMLElement | null { + if (this.viewportElement?.isConnected) { + return this.viewportElement; + } + + this.viewportElement = + this.terminal.element?.querySelector('.xterm-viewport') ?? null; + return this.viewportElement; + } + + private resetScrollTracking(): void { + this.lastScrollTouchY = null; + this.lastScrollTouchTime = 0; + this.scrollVelocity = 0; + } + + private recordScrollSample(touch: TouchCoords): void { + const now = performance.now(); + + if (this.lastScrollTouchY !== null) { + const dt = now - this.lastScrollTouchTime; + if (dt > 0) { + // Positive when the finger moves up, matching how xterm increases + // scrollTop, so the inertia continues in the same direction. + const velocity = (this.lastScrollTouchY - touch.clientY) / dt; + this.scrollVelocity = this.scrollVelocity * 0.4 + velocity * 0.6; + } + } + + this.lastScrollTouchY = touch.clientY; + this.lastScrollTouchTime = now; + } + + private maybeStartInertia(): void { + if (this.isSelecting || this.isHandleDragging || this.isPinching) { + return; + } + + const idle = performance.now() - this.lastScrollTouchTime; + if (idle > SCROLL_INERTIA_MAX_IDLE_MS) { + return; + } + + if (Math.abs(this.scrollVelocity) < SCROLL_INERTIA_MIN_VELOCITY) { + return; + } + + this.startInertia(this.scrollVelocity); + } + + private startInertia(initialVelocity: number): void { + const viewport = this.getViewportElement(); + if (!viewport) { + return; + } + + this.cancelInertia(); + + let velocity = clamp( + initialVelocity, + -SCROLL_INERTIA_MAX_VELOCITY, + SCROLL_INERTIA_MAX_VELOCITY, + ); + let lastFrame = performance.now(); + + const step = (now: number): void => { + const dt = Math.max(1, now - lastFrame); + lastFrame = now; + velocity *= Math.pow(SCROLL_INERTIA_FRICTION, dt / 16); + + if (Math.abs(velocity) < SCROLL_INERTIA_MIN_VELOCITY) { + this.inertiaFrame = null; + return; + } + + const before = viewport.scrollTop; + viewport.scrollTop = before + velocity * dt; + if (viewport.scrollTop === before) { + // Reached the top or bottom of the buffer. + this.inertiaFrame = null; + return; + } + + this.inertiaFrame = window.requestAnimationFrame(step); + }; + + this.inertiaFrame = window.requestAnimationFrame(step); + } + + private cancelInertia(): void { + if (this.inertiaFrame !== null) { + window.cancelAnimationFrame(this.inertiaFrame); + this.inertiaFrame = null; + } + } + + updateHandles(): void { + if (this.isContextMenuVisible) { + this.positionContextMenu(); + } + + if (!this.isSelecting || !this.selectionStart || !this.selectionEnd) { + this.hideHandles(); + return; + } + + const { start, end } = this.getOrderedSelection(); + const startPosition = this.terminalCoordsToPixels(start); + const endPosition = this.terminalCoordsToPixels(end); + + // Keep the full handle inside the overlay (which clips via overflow:hidden) + // so a selection that begins at column 0 doesn't leave the handle clipped + // off the left edge where it can't be tapped. + const maxHandleLeft = Math.max(0, this.terminalContent.clientWidth - HANDLE_SIZE_PX); + + if (startPosition) { + this.startHandle.style.display = 'block'; + this.startHandle.style.left = `${clamp(startPosition.x - HANDLE_SIZE_PX / 2, 0, maxHandleLeft)}px`; + this.startHandle.style.top = `${startPosition.y + this.cellDimensions.height + 4}px`; + } else { + this.startHandle.style.display = 'none'; + } + + if (endPosition) { + this.endHandle.style.display = 'block'; + this.endHandle.style.left = `${clamp(endPosition.x + this.cellDimensions.width - HANDLE_SIZE_PX / 2, 0, maxHandleLeft)}px`; + this.endHandle.style.top = `${endPosition.y + this.cellDimensions.height + 4}px`; + } else { + this.endHandle.style.display = 'none'; + } + } + + private clearSelection(): void { + this.terminal.clearSelection(); + this.resetSelectionState(); + } + + private resetSelectionState(): void { + this.isSelecting = false; + this.isHandleDragging = false; + this.dragHandle = null; + this.selectionStart = null; + this.selectionEnd = null; + this.pendingClearTouch = null; + this.touchStart = null; + this.hideHandles(); + this.hideContextMenu(); + this.clearTapHoldTimeout(); + } + + private touchToTerminalCoords(touch: TouchCoords): TerminalCoords | null { + const screenElement = this.getTerminalScreenElement(); + if (!screenElement) { + return null; + } + + const rect = screenElement.getBoundingClientRect(); + const x = touch.clientX - rect.left; + const y = touch.clientY - rect.top; + if (x < 0 || y < 0 || x > rect.width || y > rect.height) { + return null; + } + + this.updateCellDimensions(); + if (!this.cellDimensions.width || !this.cellDimensions.height) { + return null; + } + + const col = clamp(Math.floor(x / this.cellDimensions.width), 0, this.terminal.cols - 1); + const row = Math.floor(y / this.cellDimensions.height) + this.terminal.buffer.active.viewportY; + + return { + col, + row: Math.max(0, row), + }; + } + + private terminalCoordsToPixels(coords: TerminalCoords): { x: number; y: number } | null { + const screenElement = this.getTerminalScreenElement(); + if (!screenElement) { + return null; + } + + this.updateCellDimensions(); + + const visibleRow = coords.row - this.terminal.buffer.active.viewportY; + if (visibleRow < 0 || visibleRow >= this.terminal.rows) { + return null; + } + + const screenRect = screenElement.getBoundingClientRect(); + const containerRect = this.terminalContent.getBoundingClientRect(); + + return { + x: screenRect.left - containerRect.left + coords.col * this.cellDimensions.width, + y: screenRect.top - containerRect.top + visibleRow * this.cellDimensions.height, + }; + } + + private updateCellDimensions(): void { + const renderCell = (this.terminal as TerminalWithRenderService)._core?._renderService + ?.dimensions?.css?.cell; + if (renderCell?.width && renderCell.height) { + this.cellDimensions = { + width: renderCell.width, + height: renderCell.height, + }; + return; + } + + const screenElement = this.getTerminalScreenElement(); + const rect = screenElement?.getBoundingClientRect(); + if (!rect || !this.terminal.cols || !this.terminal.rows) { + this.cellDimensions = { width: 0, height: 0 }; + return; + } + + this.cellDimensions = { + width: rect.width / this.terminal.cols, + height: rect.height / this.terminal.rows, + }; + } + + private getWordBoundsAt(coords: TerminalCoords): { + start: TerminalCoords; + end: TerminalCoords; + } | null { + const line = this.terminal.buffer.active.getLine(coords.row); + if (!line) { + return null; + } + + const lineText = line.translateToString(false); + if (!lineText || coords.col >= lineText.length || /\s/.test(lineText[coords.col])) { + return null; + } + + let startCol = coords.col; + let endCol = coords.col; + + while (startCol > 0 && !/\s/.test(lineText[startCol - 1])) { + startCol--; + } + + while (endCol < lineText.length - 1 && !/\s/.test(lineText[endCol + 1])) { + endCol++; + } + + return { + start: { row: coords.row, col: startCol }, + end: { row: coords.row, col: endCol }, + }; + } + + private blurTerminalInput(): void { + const textarea = this.terminal.element?.querySelector( + '.xterm-helper-textarea', + ); + textarea?.blur(); + } + + private getTerminalScreenElement(): HTMLElement | null { + return ( + this.terminal.element?.querySelector('.xterm-screen') ?? + this.terminal.element ?? + null + ); + } + + private toTouchCoords(touch: Touch): TouchCoords { + return { + clientX: touch.clientX, + clientY: touch.clientY, + }; + } + + private clearTapHoldTimeout(): void { + if (this.tapHoldTimeout === null) { + return; + } + + window.clearTimeout(this.tapHoldTimeout); + this.tapHoldTimeout = null; + } + + dispose(): void { + if (this.isDestroyed) { + return; + } + + this.isDestroyed = true; + this.clearTapHoldTimeout(); + this.cancelInertia(); + + this.terminal.element?.removeEventListener('touchstart', this.onTerminalTouchStart); + this.terminal.element?.removeEventListener('touchmove', this.onTerminalTouchMove); + this.terminal.element?.removeEventListener('touchend', this.onTerminalTouchEnd); + this.terminal.element?.removeEventListener('touchcancel', this.onTerminalTouchCancel); + + this.startHandle.removeEventListener('touchstart', this.onHandleTouchStart); + this.startHandle.removeEventListener('touchmove', this.onHandleTouchMove); + this.startHandle.removeEventListener('touchend', this.onHandleTouchEnd); + this.startHandle.removeEventListener('touchcancel', this.onHandleTouchEnd); + + this.endHandle.removeEventListener('touchstart', this.onHandleTouchStart); + this.endHandle.removeEventListener('touchmove', this.onHandleTouchMove); + this.endHandle.removeEventListener('touchend', this.onHandleTouchEnd); + this.endHandle.removeEventListener('touchcancel', this.onHandleTouchEnd); + + document.removeEventListener('touchstart', this.onDocumentTouchStart); + this.disposables.forEach((disposable) => disposable.dispose()); + this.disposables.length = 0; + + this.overlay.remove(); + + if (this.didSetPosition) { + this.terminalContent.style.position = this.originalPosition; + } + } +} + +export function installMobileTerminalSelection( + terminal: Terminal, + terminalContent: HTMLElement, + options: MobileTerminalSelectionOptions = {}, +): MobileTerminalSelectionManager | null { + if (!isTouchSelectionEnvironment() || !terminal.element) { + return null; + } + + return new ShellMobileSelectionCore(terminal, terminalContent, options); +} diff --git a/src/components/shell/view/Shell.tsx b/src/components/shell/view/Shell.tsx index 575b0ac3..c133378d 100644 --- a/src/components/shell/view/Shell.tsx +++ b/src/components/shell/view/Shell.tsx @@ -59,12 +59,8 @@ export default function Shell({ isConnected, isInitialized, isConnecting, - authUrl, - authUrlVersion, connectToShell, disconnectFromShell, - openAuthUrlInBrowser, - copyAuthUrlToClipboard, } = useShellRuntime({ selectedProject, selectedSession, @@ -243,15 +239,7 @@ export default function Shell({ if (minimal) { return ( <> - + ; - authUrl: string; - authUrlVersion: number; - initialCommand: string | null | undefined; - isConnected: boolean; - openAuthUrlInBrowser: (url: string) => boolean; - copyAuthUrlToClipboard: (url: string) => Promise; }; export default function ShellMinimalView({ terminalContainerRef, - authUrl, - authUrlVersion, - initialCommand, - isConnected, - openAuthUrlInBrowser, - copyAuthUrlToClipboard, }: ShellMinimalViewProps) { - const [authUrlCopyStatus, setAuthUrlCopyStatus] = useState('idle'); - const [isAuthPanelHidden, setIsAuthPanelHidden] = useState(false); - - const displayAuthUrl = useMemo( - () => resolveAuthUrlForDisplay(initialCommand, authUrl), - [authUrl, initialCommand], - ); - - // Keep auth panel UI state local to minimal mode and reset it when connection/url changes. - useEffect(() => { - setAuthUrlCopyStatus('idle'); - setIsAuthPanelHidden(false); - }, [authUrlVersion, displayAuthUrl, isConnected]); - - const hasAuthUrl = Boolean(displayAuthUrl); - const showMobileAuthPanel = hasAuthUrl && !isAuthPanelHidden; - const showMobileAuthPanelToggle = hasAuthUrl && isAuthPanelHidden; - return (
- - {showMobileAuthPanel && ( -
-
-
-

Open or copy the login URL:

- -
- - event.currentTarget.select()} - className="w-full rounded border border-gray-600 bg-gray-800 px-2 py-1 text-xs text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500" - aria-label="Authentication URL" - /> - -
- - - -
-
-
- )} - - {showMobileAuthPanelToggle && ( -
- -
- )}
); } diff --git a/src/components/shell/view/subcomponents/TerminalShortcutsPanel.tsx b/src/components/shell/view/subcomponents/TerminalShortcutsPanel.tsx index 94d3d491..88c1ee9e 100644 --- a/src/components/shell/view/subcomponents/TerminalShortcutsPanel.tsx +++ b/src/components/shell/view/subcomponents/TerminalShortcutsPanel.tsx @@ -26,6 +26,7 @@ const MOBILE_KEYS: Shortcut[] = [ { type: 'arrow', id: 'arrow-down', sequence: '\x1b[B', icon: 'down' }, { type: 'arrow', id: 'arrow-left', sequence: '\x1b[D', icon: 'left' }, { type: 'arrow', id: 'arrow-right', sequence: '\x1b[C', icon: 'right' }, + { type: 'key', id: 'ctrl-c', label: 'Ctrl+C', sequence: '\x03' }, ]; const ARROW_ICONS = { diff --git a/src/index.css b/src/index.css index f99ce614..20ea8a55 100644 --- a/src/index.css +++ b/src/index.css @@ -139,6 +139,12 @@ height: 100%; margin: 0; padding: 0; + /* The app shell is a fixed inset-0 container (see AppContent), so the + document itself never needs to scroll. Clipping it removes the phantom + full-height page scrollbar and disables the browser pull-to-refresh + gesture that reloads the page when scrolling up on mobile. */ + overflow: hidden; + overscroll-behavior-y: contain; } /* Root element with safe area padding for PWA */